fix: bound resolver caches, validate MCP input sizes, add integration tests (#213)

Replace the 7 unbounded ReferenceResolver Map caches with a bounded LRU
(env-tunable via CODEGRAPH_RESOLVER_CACHE_SIZE) so memory stays flat on large
codebases, and add length caps on MCP tool string inputs (query/task/symbol +
projectPath/path/pattern) to prevent oversized-payload DoS. Includes LRU,
MCP-input-limit, and full-pipeline integration tests.

Closes #213
This commit is contained in:
SRIKANTH A
2026-05-22 13:18:26 -05:00
committed by GitHub
parent b13f2f1ba1
commit 7340892290
6 changed files with 623 additions and 9 deletions
+41 -7
View File
@@ -22,6 +22,24 @@ import { detectFrameworks } from './frameworks';
import { loadProjectAliases, type AliasMap } from './path-aliases';
import { logDebug } from '../errors';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
/**
* Cache size limits. Each per-resolver cache is bounded so memory
* stays flat on large codebases (20k+ files). Sizes were chosen to
* cover the working set for typical resolution batches without
* exceeding a few hundred MB worst-case. Override via the env var
* `CODEGRAPH_RESOLVER_CACHE_SIZE` (single integer applied to all
* caches) when tuning for very large or very small projects.
*/
const DEFAULT_CACHE_LIMIT = 5_000;
function resolveCacheLimit(): number {
const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE;
if (!raw) return DEFAULT_CACHE_LIMIT;
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
return DEFAULT_CACHE_LIMIT;
}
// Re-export types
export * from './types';
@@ -121,13 +139,16 @@ export class ReferenceResolver {
private queries: QueryBuilder;
private context: ResolutionContext;
private frameworks: FrameworkResolver[] = [];
private nodeCache: Map<string, Node[]> = new Map(); // per-file node cache (bounded)
private fileCache: Map<string, string | null> = new Map(); // per-file content cache (bounded)
private importMappingCache: Map<string, ImportMapping[]> = new Map();
private reExportCache: Map<string, ReExport[]> = new Map();
private nameCache: Map<string, Node[]> = new Map(); // name → nodes cache
private lowerNameCache: Map<string, Node[]> = new Map(); // lower(name) → nodes cache
private qualifiedNameCache: Map<string, Node[]> = new Map(); // qualified_name → nodes cache
// All per-resolver caches are LRU-bounded. Previously these were
// unbounded Maps that grew with every distinct lookup and OOM'd on
// codebases with 20k+ files (see issue: unbounded cache growth).
private nodeCache: LRUCache<string, Node[]>; // per-file node cache
private fileCache: LRUCache<string, string | null>; // per-file content cache
private importMappingCache: LRUCache<string, ImportMapping[]>;
private reExportCache: LRUCache<string, ReExport[]>;
private nameCache: LRUCache<string, Node[]>; // name → nodes cache
private lowerNameCache: LRUCache<string, Node[]>; // lower(name) → nodes cache
private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
private knownFiles: Set<string> | null = null;
private cachesWarmed = false;
@@ -139,6 +160,19 @@ export class ReferenceResolver {
constructor(projectRoot: string, queries: QueryBuilder) {
this.projectRoot = projectRoot;
this.queries = queries;
const limit = resolveCacheLimit();
// The content cache is heavier (full file text), so we give it a
// smaller budget than the metadata caches.
const contentLimit = Math.max(64, Math.floor(limit / 5));
this.nodeCache = new LRUCache(limit);
this.fileCache = new LRUCache(contentLimit);
this.importMappingCache = new LRUCache(limit);
this.reExportCache = new LRUCache(limit);
this.nameCache = new LRUCache(limit);
this.lowerNameCache = new LRUCache(limit);
this.qualifiedNameCache = new LRUCache(limit);
this.context = this.createContext();
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Simple LRU cache backed by JavaScript's insertion-ordered Map.
*
* Used by ReferenceResolver to bound the per-resolver caches that
* previously grew without limit and OOM'd on large codebases (20k+
* files). Each cache is sized independently — see `index.ts` for
* the chosen limits per cache type.
*
* Eviction is plain LRU: on `set`, if the cache is full, the
* least-recently-used entry (the first one in iteration order) is
* evicted. Touching via `get` moves the entry to the most-recently-used
* position so hot keys survive eviction passes.
*/
export class LRUCache<K, V> {
private readonly max: number;
private readonly store = new Map<K, V>();
constructor(max: number) {
if (!Number.isFinite(max) || max <= 0) {
throw new Error(`LRUCache max must be a positive finite number, got ${max}`);
}
this.max = Math.floor(max);
}
get size(): number {
return this.store.size;
}
get(key: K): V | undefined {
const value = this.store.get(key);
if (value === undefined) {
// Distinguish "missing" from "stored undefined" by checking has().
// We don't store undefined in practice, but be defensive.
return this.store.has(key) ? value : undefined;
}
// Refresh recency by re-inserting.
this.store.delete(key);
this.store.set(key, value);
return value;
}
has(key: K): boolean {
return this.store.has(key);
}
set(key: K, value: V): void {
if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.max) {
// Evict the oldest entry — first key in iteration order.
const oldest = this.store.keys().next().value;
if (oldest !== undefined) {
this.store.delete(oldest);
}
}
this.store.set(key, value);
}
clear(): void {
this.store.clear();
}
}