authorgravatar for 107891771+Michel-Liao@users.noreply.github.comMichel Liao <107891771+Michel-Liao@users.noreply.github.com> 2026-10-06 00:08:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:08:28-07:00
logfec71c5ab4fd860331fb15ce37c15edf6db1c236
treeb2f8bbbfd9e85174ee330fd639eb823778e27620
parentff2fda7d6d59a05b9077b8fc48a5e0b67054b8d5
signature Signed by PGP key B5690EEEBB952194

fix(server): return partial workspace index on timeout (#11500)


2 files changed, 89 insertions(+), 4 deletions(-)

apps/server/src/workspace/WorkspaceSearchIndex.test.ts+68
...@@ -140,6 +140,74 @@ it.effect("preserves a full-index warmup timeout as a structured error", () =>...@@ -140,6 +140,74 @@ it.effect("preserves a full-index warmup timeout as a structured error", () =>
140 }),140 }),
141);141);
142142
143it.effect("returns partial path results when the initial scan times out", () =>
144 Effect.scoped(
145 Effect.gen(function* () {
146 const mixedSearch = vi.fn(() => ({
147 ok: true as const,
148 value: {
149 items: [
150 {
151 type: "directory" as const,
152 item: { relativePath: "src", fileName: "src", fileCount: 1 },
153 },
154 { type: "file" as const, item: fileItem("src/index.ts") },
155 ],
156 scores: [],
157 totalMatched: 2,
158 totalFiles: 1,
159 },
160 }));
161 let scanning = true;
162 const finder = {
163 destroy: vi.fn(),
164 isScanning: vi.fn(() => scanning),
165 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })),
166 mixedSearch,
167 } as unknown as FileFinder;
168 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
169
170 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths");
171 const list = yield* searchIndex.list();
172 const search = yield* searchIndex.search("src", 10);
173 scanning = true;
174 mixedSearch.mockImplementationOnce(() => {
175 scanning = false;
176 return {
177 ok: true as const,
178 value: {
179 items: [
180 {
181 type: "directory" as const,
182 item: { relativePath: "src", fileName: "src", fileCount: 1 },
183 },
184 { type: "file" as const, item: fileItem("src/index.ts") },
185 ],
186 scores: [],
187 totalMatched: 2,
188 totalFiles: 1,
189 },
190 };
191 });
192 const searchCompletingDuringQuery = yield* searchIndex.search("src", 10);
193 scanning = false;
194 const completedSearch = yield* searchIndex.search("src", 10);
195
196 expect(list).toEqual({
197 entries: [
198 { kind: "directory", path: "src" },
199 { kind: "file", path: "src/index.ts" },
200 ],
201 truncated: true,
202 });
203 expect(search.truncated).toBe(true);
204 expect(searchCompletingDuringQuery.truncated).toBe(true);
205 expect(completedSearch.truncated).toBe(false);
206 expect(mixedSearch).toHaveBeenCalledTimes(4);
207 }),
208 ),
209);
210
143it.effect("preserves FileFinder destroy failures as structured defects", () =>211it.effect("preserves FileFinder destroy failures as structured defects", () =>
144 Effect.gen(function* () {212 Effect.gen(function* () {
145 const cause = new Error("native destroy failed");213 const cause = new Error("native destroy failed");
apps/server/src/workspace/WorkspaceSearchIndex.ts+21-4
...@@ -365,6 +365,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -365,6 +365,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
365 catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }),365 catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }),
366 }).pipe(Effect.orDie),366 }).pipe(Effect.orDie),
367 );367 );
368 let initialScanTimedOut = false;
368 yield* waitForIndexReady(369 yield* waitForIndexReady(
369 cwd,370 cwd,
370 finder,371 finder,
...@@ -374,8 +375,18 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -374,8 +375,18 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
374 reason,375 reason,
375 cause,376 cause,
376 }),377 }),
378 ).pipe(
379 Effect.catchTag("WorkspaceSearchIndexScanTimedOut", (error) =>
380 variant === "paths"
381 ? Effect.sync(() => {
382 initialScanTimedOut = true;
383 })
384 : Effect.fail(error),
385 ),
377 );386 );
378387
388 const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning();
389
379 const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(390 const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(
380 query: string,391 query: string,
381 pageSize: number,392 pageSize: number,
...@@ -436,6 +447,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -436,6 +447,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
436447
437 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(448 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
438 function* () {449 function* () {
450 const incompleteBeforeQuery = hasIncompleteInitialScan();
439 const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () =>451 const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () =>
440 finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }),452 finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }),
441 );453 );
...@@ -446,7 +458,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -446,7 +458,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
446 const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES);458 const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES);
447 return {459 return {
448 entries,460 entries,
449 truncated: mapped.truncated || entries.length < sortedEntries.length,461 truncated:
462 incompleteBeforeQuery || mapped.truncated || entries.length < sortedEntries.length,
450 };463 };
451 },464 },
452 );465 );
...@@ -455,22 +468,26 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (...@@ -455,22 +468,26 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
455 "WorkspaceSearchIndex.search",468 "WorkspaceSearchIndex.search",
456 )(function* (query, limit, kind, imageOnly) {469 )(function* (query, limit, kind, imageOnly) {
457 const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1);470 const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1);
471 const incompleteBeforeQuery = hasIncompleteInitialScan();
458 if (kind === "file" || imageOnly) {472 if (kind === "file" || imageOnly) {
459 const result = yield* runSearch(query, pageSize, "fileSearch", () =>473 const result = yield* runSearch(query, pageSize, "fileSearch", () =>
460 finder.fileSearch(query, { pageSize }),474 finder.fileSearch(query, { pageSize }),
461 );475 );
462 return mapFileSearchResult(result, limit, imageOnly);476 const mapped = mapFileSearchResult(result, limit, imageOnly);
477 return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated };
463 }478 }
464 if (kind === "directory") {479 if (kind === "directory") {
465 const result = yield* runSearch(query, pageSize, "directorySearch", () =>480 const result = yield* runSearch(query, pageSize, "directorySearch", () =>
466 finder.directorySearch(query, { pageSize }),481 finder.directorySearch(query, { pageSize }),
467 );482 );
468 return mapDirectorySearchResult(result, limit);483 const mapped = mapDirectorySearchResult(result, limit);
484 return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated };
469 }485 }
470 const result = yield* runSearch(query, pageSize, "mixedSearch", () =>486 const result = yield* runSearch(query, pageSize, "mixedSearch", () =>
471 finder.mixedSearch(query, { pageSize }),487 finder.mixedSearch(query, { pageSize }),
472 );488 );
473 return mapMixedSearchResult(result, limit);489 const mapped = mapMixedSearchResult(result, limit);
490 return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated };
474 });491 });
475492
476 const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn(493 const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn(