Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
"generate": "node scripts/generate-git-commit-info.js",
"generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts",
"build": "node scripts/build.js",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" node scripts/build.js",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] cross-env NODE_OPTIONS="--max-old-space-size=3072" hardcodes a heap limit that overwrites any NODE_OPTIONS set in the user's or CI's environment. This can crash Node.js on machines with <4GB RAM (V8 exits if the requested heap size exceeds available physical memory). Consider moving this to CI config (.github/workflows/*.yml) so local developers and low-memory environments aren't affected.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cross-env NODE_OPTIONS="--max-old-space-size=3072" replaces any existing NODE_OPTIONS the user or CI environment has set. If a user has NODE_OPTIONS="--experimental-vm-modules --openssl-legacy-provider" already, this clobbers it.

Safer approach:

"build": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS:-} --max-old-space-size=3072\" node scripts/build.js"

Or handle this in the build script itself where you can append to process.env.NODE_OPTIONS without overwriting.

"build-and-start": "npm run build && npm run start",
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present --parallel",
"test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",
"test": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test --workspaces --if-present --parallel",
"test:ci": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] In test:ci, the npm run test:scripts after && does not inherit the NODE_OPTIONS flag set by cross-env (it only applies to the immediate child process). If test:scripts also needs the memory limit, it should be repeated: cross-env NODE_OPTIONS="..." npm run test:scripts.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/services/fileReadCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,67 @@ describe('FileReadCache', () => {
expect(cache.check(fs.statSync(file)).state).toBe('stale');
});
});

describe('eviction', () => {
it('evicts the oldest entry when the cache exceeds MAX_ENTRIES', () => {
// Fill cache to capacity (MAX_ENTRIES = 4096).
const cache = new FileReadCache();
for (let i = 0; i < 4096; i++) {
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
full: true,
cacheable: true,
});
}
expect(cache.size()).toBe(4096);

// The 4097th write triggers eviction of the oldest (ino=0).
cache.recordWrite('/x/file-new.ts', makeStats({ ino: 4096 }));
expect(cache.size()).toBeLessThanOrEqual(4096);
expect(cache.check(makeStats({ ino: 0 })).state).toBe('unknown');
expect(cache.check(makeStats({ ino: 4096 })).state).toBe('fresh');
});

it('keeps size at MAX_ENTRIES after multiple overflows', () => {
const cache = new FileReadCache();
// Add MAX_ENTRIES + 100 distinct inodes.
for (let i = 0; i < 4196; i++) {
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
full: true,
cacheable: true,
});
}
expect(cache.size()).toBeLessThanOrEqual(4096);
});

it('should have bumped entries survive eviction', () => {
const cache = new FileReadCache();
// Fill to capacity.
for (let i = 0; i < 4096; i++) {
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
full: true,
cacheable: true,
});
}

// Frequently update ino=0 — after bump lands this moves it to the
// back of the eviction queue.
for (let i = 0; i < 10; i++) {
cache.recordRead('/x/file-0.ts', makeStats({ ino: 0 }), {
full: true,
cacheable: true,
});
}

// Add 50 new entries — they push the *least* recently bumped out.
for (let i = 4096; i < 4146; i++) {
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
full: true,
cacheable: true,
});
}

expect(cache.size()).toBeLessThanOrEqual(4096);
expect(cache.check(makeStats({ ino: 0 })).state).not.toBe('unknown');
});
});
});
12 changes: 12 additions & 0 deletions packages/core/src/services/fileReadCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export type FileReadCheckResult =

export class FileReadCache {
private readonly byInode = new Map<string, FileReadEntry>();
private static readonly MAX_ENTRIES = 4096;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike crawlCache.read() which now bumps on read, fileReadCache.check() does not bump entries on access — only upsert() bumps. This means entries that are frequently read but rarely written will still be evicted oldest-first by write time.

This is a deliberate difference from crawlCache, but worth noting: check() is the hot-path method (called for every file tool invocation). If reads should also extend an entry's lifetime, check() would need the same delete-and-reinsert treatment. If not, the current behavior is fine — just make sure the 4096 limit is large enough for the working set.


/** Build the canonical key for a file from its Stats. */
static inodeKey(stats: Stats): string {
Expand Down Expand Up @@ -261,11 +262,22 @@ export class FileReadCache {
const key = FileReadCache.inodeKey(stats);
const existing = this.byInode.get(key);
if (existing) {
// Bump: move existing entry to the end of the FIFO queue so that
// frequently-updated entries survive eviction.
this.byInode.delete(key);
existing.realPath = absPath;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pattern. The delete-then-re-insert on existing entries moves the key to the end of Map iteration order, giving LRU-like eviction using only a Map — no separate linked list needed. The fileReadCache tests cover this well (the should have bumped entries survive eviction test).

existing.mtimeMs = stats.mtimeMs;
existing.sizeBytes = stats.size;
this.byInode.set(key, existing);
return existing;
}
// Evict oldest entry when cache exceeds MAX_ENTRIES (FIFO)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The upsert() method's existing branch above (lines 264-269) mutates in-place without delete+re-insert. Since Map.keys().next() always returns the oldest insertion-order key, a file read first in the session will be the FIRST evicted at 4096 entries — even if it's the most frequently accessed. This can cause priorReadEnforcement to reject edits with confusing "file not read" errors.

Suggested change
// Evict oldest entry when cache exceeds MAX_ENTRIES (FIFO)
if (existing) {
this.byInode.delete(key);
existing.realPath = absPath;
existing.mtimeMs = stats.mtimeMs;
existing.sizeBytes = stats.size;
this.byInode.set(key, existing);
return existing;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

if (this.byInode.size >= FileReadCache.MAX_ENTRIES) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Cache eviction happens silently — no debug log. When priorReadEnforcement rejects an edit because an evicted file returns unknown from check(), there is no way to diagnose why a file the model read is now "not read in this session." This would be a 3AM debugging nightmare. Consider adding a rate-limited debug log for observability.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const oldestKey = this.byInode.keys().next().value;
if (oldestKey) {
this.byInode.delete(oldestKey);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The MAX_ENTRIES=4096 FIFO eviction logic added in this PR (lines 270-280) has zero test coverage. fileReadCache.test.ts was not modified — no test verifies that eviction fires, that the oldest entry is removed, or that recordRead/recordWrite/check behave correctly after eviction.

Impact: This is a blind OOM fix — 8 lines of new production code with no safety net. A future refactor of upsert() could silently break eviction and no test would catch it.

Suggested change
}
// Add to fileReadCache.test.ts a describe('eviction') block:
// - Fill cache to 4097 distinct inode entries via recordRead/recordWrite, verify oldest evicted
// - Verify cache.size() stays ≤ MAX_ENTRIES after overflow
// - Verify bumped entries (once bump is implemented) survive eviction

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}
const entry: FileReadEntry = {
inodeKey: key,
realPath: absPath,
Expand Down
89 changes: 88 additions & 1 deletion packages/core/src/utils/filesearch/crawlCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
*/

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { getCacheKey, read, write, clear } from './crawlCache.js';
import {
getCacheKey,
read,
write,
clear,
MAX_CACHE_ENTRIES,
} from './crawlCache.js';

describe('CrawlCache', () => {
describe('getCacheKey', () => {
Expand Down Expand Up @@ -125,5 +131,86 @@ describe('CrawlCache', () => {
await vi.advanceTimersByTimeAsync(2001);
expect(read(key)).toBeUndefined();
});

it('should enforce MAX_TOTAL_PATHS when updating an existing key with a large array', () => {
// Helper to create an array of given length
const makePaths = (n: number) =>
Array.from({ length: n }, (_, i) => `path/${i}`);

// Write key_A with a moderate number of paths
const keyA = 'project-a';
const keyB = 'project-b';
const keyC = 'project-c';

write(keyA, makePaths(1000), 60000);
write(keyB, makePaths(20000), 60000);
write(keyC, makePaths(20000), 60000);

// Now update key_A with 60000 paths — this alone exceeds MAX_TOTAL_PATHS (50000)
// Before the fix, !crawlCache.has(keyA) was false, so eviction was skipped.
// After the fix, keyB and keyC should be evicted to make room.
write(keyA, makePaths(60000), 60000);

// Verify keyA's data is stored
expect(read(keyA)).toBeDefined();
expect(read(keyA)!.length).toBe(60000);

// After eviction, keyB and keyC must be gone because keyA with 60000 paths
// exceeds the MAX_TOTAL_PATHS limit (50000), triggering eviction of others.
expect(read(keyB)).toBeUndefined();
expect(read(keyC)).toBeUndefined();
});

it('should bump existing key to end of FIFO queue on update', () => {
// When MAX_CACHE_ENTRIES is reached, the oldest (first inserted) entry is evicted.
// Updating an existing key should move it to the end, preventing its eviction.

// Fill cache to capacity with distinct keys
for (let i = 0; i < MAX_CACHE_ENTRIES; i++) {
write(`key-${i}`, [`path`], 60000);
}
// key-0 is the oldest entry
expect(read('key-0')).toBeDefined();

// Update key-0 — it should bump to the end of the queue
write('key-0', [`updated-path`], 60000);

// Now insert one more key, which should trigger eviction of the OLDEST entry.
// After bump, key-1 is the oldest, not key-0.
write(`key-new`, [`new-path`], 60000);

// key-0 should survive because it was bumped to the end
expect(read('key-0')).toBeDefined();
expect(read('key-0')![0]).toBe('updated-path');

// key-1 should be evicted (it became the oldest after key-0 was bumped)
expect(read('key-1')).toBeUndefined();
});

it('should evict other entries when a new key exceeds MAX_TOTAL_PATHS', () => {
// Exact scenario from reviewer comment #2: a new key whose array
// alone exceeds MAX_TOTAL_PATHS should trigger eviction of others.
const makePaths = (n: number) =>
Array.from({ length: n }, (_, i) => `path/${i}`);

const keyA = 'project-a';
const keyB = 'project-b';
const keyC = 'project-c';

// Pre-populate with moderate entries
write(keyA, makePaths(10000), 60000);
write(keyB, makePaths(10000), 60000);
write(keyC, makePaths(10000), 60000);

// New key with 60000 paths — exceeds MAX_TOTAL_PATHS (50000) alone
write('project-new', makePaths(60000), 60000);

// New key should be stored, others evicted to make room
expect(read('project-new')).toBeDefined();
expect(read('project-new')!.length).toBe(60000);
expect(read(keyA)).toBeUndefined();
expect(read(keyB)).toBeUndefined();
expect(read(keyC)).toBeUndefined();
});
});
});
66 changes: 65 additions & 1 deletion packages/core/src/utils/filesearch/crawlCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import crypto from 'node:crypto';
const crawlCache = new Map<string, string[]>();
const cacheTimers = new Map<string, NodeJS.Timeout>();

// Limits to prevent heap exhaustion when many projects are crawled concurrently
export const MAX_CACHE_ENTRIES = 256; // max distinct project roots cached

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: MAX_TOTAL_PATHS is a soft limit. A single write() call with 60,000 paths will succeed (as tested in should enforce MAX_TOTAL_PATHS when updating an existing key with a large array), evicting all other entries but leaving the cache with 60k paths — 20% over the 50k limit.

This is acceptable (you can't reject a write without breaking the caller), but the constant name and doc comment should clarify it's a target, not a hard cap. Something like "target ceiling" instead of "max total paths" to set expectations.

export const MAX_TOTAL_PATHS = 50_000; // max total paths across all entries

/**
* Generates a unique cache key based on the project directory and the content
* of ignore files. This ensures that the cache is invalidated if the project
Expand Down Expand Up @@ -36,19 +40,79 @@ export const getCacheKey = (

/**
* Reads cached data from the in-memory cache.
* Bumps the entry to the end of the FIFO queue on hit so that
* frequently-read crawl results survive eviction by auxiliary crawls.
* Returns undefined if the key is not found.
*/
export const read = (key: string): string[] | undefined => crawlCache.get(key);
export const read = (key: string): string[] | undefined => {
const result = crawlCache.get(key);
if (result !== undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read() now has a mutation side-effect: it deletes and re-inserts the entry to bump it in the Map's iteration order. This makes the crawl cache LRU-like, which is reasonable for eviction quality, but it's surprising — callers expect a read to be a pure lookup.

At minimum, rename to reflect the mutation, or add a doc comment noting the side-effect. Alternatively, only bump on write() and let read() stay pure — the write() bump alone gives decent LRU behavior since crawl results are re-written when refreshed.

crawlCache.delete(key);
crawlCache.set(key, result);
}
return result;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] read() is a bare Map.get() with no FIFO bump. write() bumps on update (delete+set at lines 96-98), but read() does not. In a long-running session, the working project's crawl result — read frequently — stays at the FIFO head and is evicted by the 256th auxiliary crawl (multi-root workspace, dependency scan).

Impact: Users see random slowdowns when a frequently-used crawl result is silently evicted, forcing a full re-crawl with no diagnostic signal.

Suggested change
export const read = (key: string): string[] | undefined => {
const result = crawlCache.get(key);
// Bump to end of FIFO queue so frequently-read entries survive eviction
if (result !== undefined) {
crawlCache.delete(key);
crawlCache.set(key, result);
}
return result;
};

— DeepSeek/deepseek-v4-pro via Qwen Code /review

/**
* Writes data to the in-memory cache and sets a timer to evict it after the TTL.
* Enforces MAX_CACHE_ENTRIES (LRU by insertion order) and MAX_TOTAL_PATHS to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] JSDoc on write() says LRU by insertion order but the implementation is pure FIFO (no access counting or reordering). The earlier size > 1 guard has been fixed, but the misleading JSDoc remains. Consider changing to FIFO (first-in-first-out) by insertion order to avoid confusing future maintainers — especially since LruCache.ts already exists in this codebase with true LRU semantics.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

* prevent heap exhaustion when many large projects are crawled.
*/
export const write = (key: string, results: string[], ttlMs: number): void => {
// Clear any existing timer for this key to prevent premature deletion
if (cacheTimers.has(key)) {
clearTimeout(cacheTimers.get(key)!);
}

// Evict oldest entries when cache exceeds entry limit (FIFO / insertion-order).
// Guard: updating an existing key doesn't increase entry count, so skip eviction.
while (crawlCache.size >= MAX_CACHE_ENTRIES && !crawlCache.has(key)) {
const oldestKey = crawlCache.keys().next().value;
if (oldestKey) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The crawlCache.delete(key) + clearTimeout + cacheTimers.delete cleanup trio is duplicated in both the entry-count eviction loop (here) and the total-paths eviction loop (below). Extracting a local deleteEntry(key) helper would eliminate the duplication and ensure future changes (e.g., adding another parallel data structure) only need one edit point.

Suggested change
if (oldestKey) {
const deleteEntry = (k: string) => {
crawlCache.delete(k);
const timer = cacheTimers.get(k);
if (timer) {
clearTimeout(timer);
cacheTimers.delete(k);
}
};
while (crawlCache.size >= MAX_CACHE_ENTRIES && !crawlCache.has(key)) {
const oldestKey = crawlCache.keys().next().value;
if (oldestKey) {
deleteEntry(oldestKey);
}
}

— glm-5.1 via Qwen Code /review

crawlCache.delete(oldestKey);
if (cacheTimers.has(oldestKey)) {
clearTimeout(cacheTimers.get(oldestKey)!);
cacheTimers.delete(oldestKey);
}
}
}

// Evict largest entries when total path count exceeds limit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The totalPaths computation here silently depends on entry-count eviction (above) having already removed entries. If the two eviction blocks are ever reordered or parallelized, the accounting breaks silently.

Consider adding a brief comment documenting the ordering dependency, or computing totalPaths once at the top and maintaining it across both loops.

— glm-5.1 via Qwen Code /review

// Calculate totalPaths excluding the key being updated to avoid counting old value.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] totalPaths is recomputed from scratch on every write() call by iterating all cached entries, even when no eviction is needed (the common case). This adds O(n) overhead proportional to cache fullness on every crawl cache write.

Consider maintaining a module-level let totalCachedPaths = 0 counter — increment by results.length on insert, decrement on eviction and in the TTL timer callback. This makes the limit check O(1) and eliminates the per-write scan entirely.

— glm-5.1 via Qwen Code /review

let totalPaths = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The MAX_TOTAL_PATHS eviction test only covers updating an existing key. The production code path (crawler.ts writes a new key on every crawl) is untested. The totalPaths calculation (lines 76-81) correctly excludes the key being written for both new and existing keys — but only the existing-key path has a test.

Impact: The actual execution path in production is untested. If the totalPaths logic or the while-loop guard is refactored incorrectly, existing tests would still pass.

Suggested change
let totalPaths = 0;
// Add to crawlCache.test.ts:
it('should evict entries when a NEW key exceeds MAX_TOTAL_PATHS', () => {
write('existing-A', makePaths(20000), 60000);
write('existing-B', makePaths(20000), 60000);
write('new-key', makePaths(20000), 60000);
expect(read('new-key')).toBeDefined();
// At least one of existing-A or existing-B must be evicted
const survivor = read('existing-A') ?? read('existing-B');
expect(survivor).toBeDefined();
expect(read('existing-A') && read('existing-B')).toBeFalsy();
});

— DeepSeek/deepseek-v4-pro via Qwen Code /review

for (const [k, entry] of crawlCache) {
if (k !== key) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The crawlCache.size > 1 guard in the total-paths eviction loop prevents eviction of the last remaining "other" entry. Once a single oversized entry (>50000 paths) enters the cache, subsequent writes also bypass total-path enforcement because the oversized entry cannot be evicted — and the guard won't allow removing the sole other entry. Paths accumulate past the limit.

Impact: MAX_TOTAL_PATHS degrades from a hard cap to a best-effort soft limit. The theoretical worst case is 256 × max_crawl_size, well above 50000.

Suggested change
if (k !== key) {
// Option A: document the soft-limit semantics
// Replace the guard comment with:
// "size > 1 prevents evicting the only other entry (single huge crawl is
// better than no cache). This means MAX_TOTAL_PATHS is a best-effort limit."
// Option B: enforce a hard cap by truncating oversized results
if (results.length > MAX_TOTAL_PATHS) {
results = results.slice(0, MAX_TOTAL_PATHS);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

totalPaths += entry.length;
}
}
while (totalPaths + results.length > MAX_TOTAL_PATHS && crawlCache.size > 0) {
// Find and remove the entry with the most paths (never evict the current key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The total-paths eviction strategy evicts the largest entry, not the oldest. This is a different heuristic from the entry-count eviction (FIFO/oldest-first).

Evicting the largest entry means a recently-crawled 40k-path monorepo could be evicted while a stale 100-path project survives — forcing an expensive re-crawl of the large project on the next access. Consider evicting oldest-first here too (consistent with the entry-count path), or document the rationale for largest-first.

Also, this inner loop is O(n) per eviction and runs in a while loop, making the worst case O(n²). With MAX_CACHE_ENTRIES=256 this is fast enough, but worth noting.

let largestKey: string | undefined;
let largestSize = 0;
for (const [k, v] of crawlCache) {
if (k === key) continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Empty-array entries (v.length === 0) cause the largest-size search to stall: v.length > largestSize with largestSize = 0 skips empty arrays, so largestKey remains undefined and the loop breaks without evicting anything. While crawlers always produce non-empty results, nothing prevents callers from writing empty arrays.

Consider initializing largestSize = -1 or changing > to >= so empty-array entries are eligible for eviction.

— glm-5.1 via Qwen Code /review

if (v.length > largestSize) {
largestSize = v.length;
largestKey = k;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The else { break; } branch is untested. When the cache holds only the current key and results.length > MAX_TOTAL_PATHS, the largest-key scan finds nothing and breaks — the oversized entry is stored unconditionally, violating the invariant. No test covers this control flow.

Consider adding a test for a single-key cache updated with >50k paths, and optionally refuse oversized single entries (if (results.length > MAX_TOTAL_PATHS && crawlCache.size <= 1) return).

— glm-5.1 via Qwen Code /review

}
}
if (largestKey) {
totalPaths -= crawlCache.get(largestKey)!.length;
crawlCache.delete(largestKey);
if (cacheTimers.has(largestKey)) {
clearTimeout(cacheTimers.get(largestKey)!);
cacheTimers.delete(largestKey);
}
} else {
break;
}
}

// Bump existing key to end of FIFO queue (mirror fileReadCache.upsert behavior)
if (crawlCache.has(key)) {
crawlCache.delete(key);
}

// Store the new data
crawlCache.set(key, results);

Expand Down
Loading