From e9adc6ccd1aa2b869726809f18cee17d7ccae719 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 21 Oct 2026 18:32:49 -0500 Subject: [PATCH 1/2] fix(server): keep live workspace indexes when a refresh outlasts its scan wait A rescan that runs past the 15 second wait keeps going on the live finder, which still answers from its previous index. Treat that the way index creation already does instead of failing the refresh, so WorkspaceEntries.refresh no longer invalidates a working index after a file write and forces a rebuild that can time out again. Co-Authored-By: Claude Opus 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 38 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 9 ++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 150ac2c69131a..57dc03096cbdc 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -569,6 +570,43 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); }), ); + + it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + let scansOutlastWait = false; + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (created.ok) { + const finder = created.value; + const waitForIndexReady = finder.waitForIndexReady.bind(finder); + finder.waitForIndexReady = (timeoutMs) => + scansOutlastWait + ? Promise.resolve({ ok: true, value: false }) + : waitForIndexReady(timeoutMs); + } + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searchNeedle = workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + yield* searchNeedle; + + scansOutlastWait = true; + yield* workspaceEntries.refresh(cwd); + const result = yield* searchNeedle; + + expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); + }), + ); }); describe("browse", () => { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 3ae5d524611a4..1cf77eb51fc62 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -120,10 +120,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly searchContents: ( input: Omit, ) => Effect.Effect; - readonly refresh: () => Effect.Effect< - void, - WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut - >; + readonly refresh: () => Effect.Effect; } >()("t3/workspace/WorkspaceSearchIndex") {} @@ -428,6 +425,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } + // A rescan that outlasts the wait keeps running, and the finder answers + // from its previous index until it finishes, so the index stays usable. yield* waitForIndexReady( cwd, finder, @@ -437,7 +436,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason, cause, }), - ); + ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( From af0aefcd2f4b8864951ff3af1a76df92cce5904c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 21:47:54 -0500 Subject: [PATCH 2/2] test(server): assert the refreshed live finder answers the second search The refresh regression test only checked that the second search matched. It now also checks that one finder was created and rescanned once, so the match comes from the live index after a timed-out refresh wait rather than from a rebuild. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/workspace/WorkspaceEntries.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 57dc03096cbdc..260bd7ee4bf5c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -576,11 +576,19 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); let scansOutlastWait = false; + let finders = 0; + let rescans = 0; const createFinder = FileFinder.create.bind(FileFinder); vi.spyOn(FileFinder, "create").mockImplementation((options) => { const created = createFinder(options); if (created.ok) { const finder = created.value; + finders += 1; + const scanFiles = finder.scanFiles.bind(finder); + finder.scanFiles = () => { + rescans += 1; + return scanFiles(); + }; const waitForIndexReady = finder.waitForIndexReady.bind(finder); finder.waitForIndexReady = (timeoutMs) => scansOutlastWait @@ -605,6 +613,8 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const result = yield* searchNeedle; expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); + // The second search ran on the rescanned live finder, not a rebuild. + expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); }), ); });