From 1b894a1f8c4ad2e7307dbf28d88f0373a9454c21 Mon Sep 17 00:00:00 2001 From: RonenMars Date: Sat, 12 Sep 2026 05:00:52 +0300 Subject: [PATCH] fix(scanner): never build a persistent scanner for a cold-start scan The three scan-construction sites passed `statCache ? { persistent: false } : undefined`, and the undefined branch falls through to the scanner's persistent default. That branch is taken exactly when the cache holds no stat rows, which is the state `tb-streamer cache clear` and the integrity monitor's reset-and-rescan both create. A cold start therefore read the scanner's own persistent index instead of parsing the tree, importing rows for files deleted long ago. On one machine that turned an emptied cache into 172 rows against 51 transcripts on disk, 122 of them naming files that no longer exist, and raised a high-severity integrity alert. The streamer never serves from the scanner's persistent index in any other state, so forcing non-persistent costs nothing. Closes #876 --- .../cold-start-non-persistent-scan.test.ts | 81 +++++++++++++++++++ src/scanner-manager.ts | 9 ++- src/server.ts | 7 +- 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 __tests__/cold-start-non-persistent-scan.test.ts diff --git a/__tests__/cold-start-non-persistent-scan.test.ts b/__tests__/cold-start-non-persistent-scan.test.ts new file mode 100644 index 00000000..d03fe368 --- /dev/null +++ b/__tests__/cold-start-non-persistent-scan.test.ts @@ -0,0 +1,81 @@ +import { ScannerManager } from "../src/scanner-manager"; + +/** + * Captures what each ConversationScanner was constructed with. The scanner is + * mocked rather than driven for real because the defect is entirely in the + * constructor argument: a persistent scanner reads its own index, and on a + * cold start that index is the only thing it can read. + */ +const hoisted = vi.hoisted(() => ({ ctorArgs: [] as unknown[] })); + +vi.mock("@threadbase-sh/scanner", () => ({ + ConversationScanner: class { + constructor(options?: unknown) { + hoisted.ctorArgs.push(options); + } + scan() { + return Promise.resolve([]); + } + getMetadataCache() { + return new Map(); + } + close() { + return Promise.resolve(); + } + }, +})); + +/** persistenceDisabled:false is the normal case — the flag only flips when cache.open() throws. */ +const makeManager = () => + new ScannerManager({ + scanProfiles: [], + codexRoots: [], + directoryDebounceMs: 0, + persistenceDisabled: false, + // No cache at all is the same condition an emptied cache.db produces: + // buildStatCache returns undefined, so nothing forced persistent:false. + cache: () => null, + cacheMonitor: () => null, + projectsRepo: () => null, + conversationsRepo: () => null, + cacheMetadataRepo: () => null, + trackCacheWrite: () => {}, + }); + +describe("cold-start scans never use the scanner's persistent index", () => { + beforeEach(() => { + hoisted.ctorArgs.length = 0; + }); + + it("builds a non-persistent scanner when there is no stat cache", async () => { + const manager = makeManager(); + await manager.get(); + + expect(hoisted.ctorArgs.length).toBeGreaterThan(0); + for (const args of hoisted.ctorArgs) { + expect(args).toEqual({ persistent: false }); + } + await manager.close(); + }); + + it("builds a non-persistent scanner for a full rescan with no stat cache", async () => { + const manager = makeManager(); + hoisted.ctorArgs.length = 0; + await manager.getFresh(); + + expect(hoisted.ctorArgs.length).toBeGreaterThan(0); + for (const args of hoisted.ctorArgs) { + expect(args).toEqual({ persistent: false }); + } + await manager.close(); + }); + + it("never constructs a scanner with undefined options, which defaults to persistent", async () => { + const manager = makeManager(); + await manager.get(); + await manager.getFresh(); + + expect(hoisted.ctorArgs).not.toContain(undefined); + await manager.close(); + }); +}); diff --git a/src/scanner-manager.ts b/src/scanner-manager.ts index 0f3bfb23..cb612105 100644 --- a/src/scanner-manager.ts +++ b/src/scanner-manager.ts @@ -559,8 +559,11 @@ export class ScannerManager { } this.takeStaleFiles(); const statCache = this.buildStatCache(this.scanner); - // Scanner 0.9.4 reads statCache only in non-persistent scans. - this.scanner = this.newScanner(statCache ? { persistent: false } : undefined); + // Scanner 0.9.4 reads statCache only in non-persistent scans, and the + // streamer never serves from the scanner's persistent index. Letting the + // no-statCache case fall through to the persistent default made an empty + // cache import rows for files deleted long ago (#876). + this.scanner = this.newScanner({ persistent: false }); this.allScanners.add(this.scanner); this.scannerReady = this.scanner.scan({ ...(this.deps.scanProfiles ? { profiles: this.deps.scanProfiles } : {}), @@ -609,7 +612,7 @@ export class ScannerManager { this.takeStaleFiles(); const previous = this.scanner; const statCache = this.buildStatCache(previous); - const shadow = this.newScanner(statCache ? { persistent: false } : undefined); + const shadow = this.newScanner({ persistent: false }); this.allScanners.add(shadow); this.scannerReady = shadow.scan({ ...(this.deps.scanProfiles ? { profiles: this.deps.scanProfiles } : {}), diff --git a/src/server.ts b/src/server.ts index 41019e5b..73a1f643 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1762,10 +1762,9 @@ export class StreamerServer { // that onConversationChanged invalidations during the scan cannot cause // getScanner() to restart indefinitely and leave the warm-up stuck. const warmupStatCache = this.scannerManager.buildStatCache(null); - // Scanner 0.9.4 reads statCache only in non-persistent scans. - const warmupScanner = this.scannerManager.newScanner( - warmupStatCache ? { persistent: false } : undefined, - ); + // Scanner 0.9.4 reads statCache only in non-persistent scans, and a + // cold start has none — see the note in ScannerManager.get (#876). + const warmupScanner = this.scannerManager.newScanner({ persistent: false }); this.scannerManager.track(warmupScanner); // Throttle the per-file onProgress firings to ~one frame per whole // percent (plus the final tick) so a large scan doesn't flood every