From 1d6f7c7d67daeaaf9828f310fb6ef2ff98c4b08c Mon Sep 17 00:00:00 2001 From: mikeohuo Date: Tue, 20 Oct 2026 21:39:39 -0400 Subject: [PATCH 1/8] fix(server): finished runs no longer force a full file-index rescan Run finalization called WorkspaceEntries.refresh, which forces fff's scanFiles() on the server's JS thread. That call waits on fff's picker lock and froze the server for 15-52 s on large repositories. fff's watcher already keeps the search indexes current. Fixes #22586 --- .../src/orchestration-v2/RunFinalizationQueue.test.ts | 2 -- .../src/orchestration-v2/RunFinalizationService.test.ts | 2 -- .../src/orchestration-v2/RunFinalizationService.ts | 9 +++------ 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index c2b9e0aabcd5b..3f21b20917850 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -8,7 +8,6 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as CheckpointRollback from "./CheckpointRollbackService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -48,7 +47,6 @@ it.effect.each([ Layer.provide( Layer.mergeAll( projections, - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void }), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ refreshLocalStatus: () => diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 38a18192531a5..4ac0fcabd7b90 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -10,7 +10,6 @@ import * as Layer from "effect/Layer"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as RunFinalization from "./RunFinalizationService.ts"; @@ -98,7 +97,6 @@ it.effect.each( const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 40882033ba527..b6f0e1852eb13 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -6,7 +6,6 @@ import * as Schema from "effect/Schema"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -92,7 +91,6 @@ export const layer = Layer.effect(RunFinalizationService, make); export const layerObserver = Layer.effect( RunFinalizationObserver, Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; @@ -100,10 +98,9 @@ export const layerObserver = Layer.effect( refreshAfterTurn: pullRequests.refreshAfterTurn, refresh: Effect.fn("RunFinalizationService.refresh")( function* ({ cwd, threadId, runId }) { - const [, local] = yield* Effect.all( - [workspaceEntries.refresh(cwd), vcsStatus.refreshLocalStatus(cwd)], - { concurrency: "unbounded" }, - ); + // The search indexes already watch file changes. A forced `scanFiles()` here waits on + // fff's picker lock on the event loop, freezing the server while the watcher holds it. + const local = yield* vcsStatus.refreshLocalStatus(cwd); if (local.refName === null || local.isDefaultRef) return; const thread = yield* projections.getThreadShell(threadId); if (!thread || thread.branch !== local.refName) return; From 6ef99ea8ef709a94abda70638b0029c7c9fee073 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 12:51:53 +0000 Subject: [PATCH 2/8] fix(server): recover missed file events without rescanning every turn --- .../workspace/WorkspaceSearchIndex.test.ts | 50 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 17 +++++++ 2 files changed, 67 insertions(+) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 2c41e4ed0b3d0..bab60b7567324 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -7,6 +7,7 @@ import { } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -28,6 +29,55 @@ function fileItem(relativePath: string): FileItem { }; } +it.effect("recovers missed watcher changes on reads without rescanning every read", () => + Effect.scoped( + Effect.gen(function* () { + let indexed = "old.ts"; + let onDisk = indexed; + const scanFiles = vi.fn(() => { + indexed = onDisk; + return { ok: true as const, value: undefined }; + }); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + scanFiles, + fileSearch: () => ({ + ok: true as const, + value: { items: [fileItem(indexed)], scores: [], totalMatched: 1, totalFiles: 1 }, + }), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + const index = yield* WorkspaceSearchIndex.make("/workspace/project"); + onDisk = "new.ts"; + expect((yield* index.search("", 10, "file")).entries).toEqual([ + { path: "old.ts", kind: "file" }, + ]); + expect(scanFiles).not.toHaveBeenCalled(); + yield* TestClock.adjust("5 minutes"); + const results = yield* Effect.all( + [index.search("", 10, "file"), index.search("", 10, "file")], + { concurrency: "unbounded" }, + ); + for (const result of results) { + expect(result.entries).toEqual([{ path: "new.ts", kind: "file" }]); + } + expect(scanFiles).toHaveBeenCalledTimes(1); + yield* index.search("", 10, "file"); + expect(scanFiles).toHaveBeenCalledTimes(1); + scanFiles.mockImplementationOnce(() => { + throw new Error("scan unavailable"); + }); + yield* TestClock.adjust("5 minutes"); + expect((yield* index.search("", 10, "file")).entries).toEqual([ + { path: "new.ts", kind: "file" }, + ]); + yield* index.search("", 10, "file"); + expect(scanFiles).toHaveBeenCalledTimes(2); + }), + ), +); + it.effect("filters image searches before applying the result limit", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8f2b64cf26a34..5955b1315cdc6 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -12,10 +12,12 @@ import type { SearchResult, } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import type { ProjectEntry, @@ -36,6 +38,7 @@ const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; +const WORKSPACE_INDEX_RECOVERY_INTERVAL_MS = 5 * 60_000; const CONTENT_SEARCH_TIME_BUDGET_MS = 250; const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; @@ -372,6 +375,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( ); const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + const refreshPermit = yield* Semaphore.make(1); + let refreshedAt = yield* Clock.currentTimeMillis; const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, @@ -379,6 +384,17 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", execute: () => Result, ): Effect.fn.Return { + // Watchers can lose events; repair hot indexes occasionally, rather than after every turn. + yield* Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if (now - refreshedAt < WORKSPACE_INDEX_RECOVERY_INTERVAL_MS) return; + refreshedAt = now; + yield* refresh().pipe( + Effect.catch((cause) => + Effect.logWarning("Periodic workspace index recovery failed", { cwd, cause }), + ), + ); + }).pipe(refreshPermit.withPermits(1)); const result = yield* Effect.try({ try: execute, catch: (cause) => @@ -429,6 +445,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( cause, }), ); + refreshedAt = yield* Clock.currentTimeMillis; }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( From 7f970597ec475836315ece16312e03eaeaea85e6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 12:56:07 +0000 Subject: [PATCH 3/8] fix(server): keep index recovery outside the content search budget --- .../workspace/WorkspaceSearchIndex.test.ts | 6 +++++ .../src/workspace/WorkspaceSearchIndex.ts | 26 +++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index bab60b7567324..0e6bc977e867a 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -157,14 +157,20 @@ it.effect("continues whole-word searches after a filtered grep page", () => value: grepResult("needleSuffix", [[0, 6]], nextCursor), }, ); + using now = vi.spyOn(performance, "now").mockReturnValue(0); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + scanFiles: () => { + now.mockReturnValue(1_000); + return { ok: true as const, value: undefined }; + }, grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + yield* TestClock.adjust("5 minutes"); const result = yield* searchIndex.searchContents({ query: "needle", limit: 1, diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 5955b1315cdc6..a66fcca63d8ba 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -384,17 +384,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", execute: () => Result, ): Effect.fn.Return { - // Watchers can lose events; repair hot indexes occasionally, rather than after every turn. - yield* Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - if (now - refreshedAt < WORKSPACE_INDEX_RECOVERY_INTERVAL_MS) return; - refreshedAt = now; - yield* refresh().pipe( - Effect.catch((cause) => - Effect.logWarning("Periodic workspace index recovery failed", { cwd, cause }), - ), - ); - }).pipe(refreshPermit.withPermits(1)); const result = yield* Effect.try({ try: execute, catch: (cause) => @@ -448,8 +437,21 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( refreshedAt = yield* Clock.currentTimeMillis; }); + // Watchers can lose events; repair hot indexes occasionally, rather than after every turn. + const recover = Effect.fn("WorkspaceSearchIndex.recover")(function* () { + const now = yield* Clock.currentTimeMillis; + if (now - refreshedAt < WORKSPACE_INDEX_RECOVERY_INTERVAL_MS) return; + refreshedAt = now; + yield* refresh().pipe( + Effect.catch((cause) => + Effect.logWarning("Periodic workspace index recovery failed", { cwd, cause }), + ), + ); + }, refreshPermit.withPermits(1)); + const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { + yield* recover(); const incompleteBeforeQuery = hasIncompleteInitialScan(); const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), @@ -470,6 +472,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", )(function* (query, limit, kind, imageOnly) { + yield* recover(); const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); const incompleteBeforeQuery = hasIncompleteInitialScan(); if (kind === "file" || imageOnly) { @@ -496,6 +499,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( "WorkspaceSearchIndex.searchContents", )(function* (input) { + yield* recover(); const { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. From 3540c6ea25a8c4a35f39f60714c25516f7966c11 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 05:37:37 +0000 Subject: [PATCH 4/8] fix(server): remove read-triggered workspace rescans --- .../workspace/WorkspaceSearchIndex.test.ts | 53 +++++++------------ .../src/workspace/WorkspaceSearchIndex.ts | 21 -------- 2 files changed, 18 insertions(+), 56 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 0e6bc977e867a..460562022241e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -29,51 +29,40 @@ function fileItem(relativePath: string): FileItem { }; } -it.effect("recovers missed watcher changes on reads without rescanning every read", () => +it.effect("reads never rescan after the former recovery interval", () => Effect.scoped( Effect.gen(function* () { - let indexed = "old.ts"; - let onDisk = indexed; - const scanFiles = vi.fn(() => { - indexed = onDisk; - return { ok: true as const, value: undefined }; - }); + const scanFiles = vi.fn(() => ({ ok: true as const, value: undefined })); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), scanFiles, + mixedSearch: () => ({ ok: true as const, value: { items: [], totalMatched: 0 } }), fileSearch: () => ({ ok: true as const, - value: { items: [fileItem(indexed)], scores: [], totalMatched: 1, totalFiles: 1 }, + value: { items: [fileItem("watched.ts")], scores: [], totalMatched: 1, totalFiles: 1 }, }), + grep: () => ({ ok: true as const, value: { items: [], nextCursor: null } }), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const index = yield* WorkspaceSearchIndex.make("/workspace/project"); - onDisk = "new.ts"; + yield* TestClock.adjust("10 minutes"); + expect(yield* index.list()).toEqual({ entries: [], truncated: false }); expect((yield* index.search("", 10, "file")).entries).toEqual([ - { path: "old.ts", kind: "file" }, + { path: "watched.ts", kind: "file" }, ]); + expect( + yield* index.searchContents({ + query: "needle", + limit: 1, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }), + ).toEqual({ matches: [], truncated: false }); expect(scanFiles).not.toHaveBeenCalled(); - yield* TestClock.adjust("5 minutes"); - const results = yield* Effect.all( - [index.search("", 10, "file"), index.search("", 10, "file")], - { concurrency: "unbounded" }, - ); - for (const result of results) { - expect(result.entries).toEqual([{ path: "new.ts", kind: "file" }]); - } - expect(scanFiles).toHaveBeenCalledTimes(1); - yield* index.search("", 10, "file"); + yield* index.refresh(); expect(scanFiles).toHaveBeenCalledTimes(1); - scanFiles.mockImplementationOnce(() => { - throw new Error("scan unavailable"); - }); - yield* TestClock.adjust("5 minutes"); - expect((yield* index.search("", 10, "file")).entries).toEqual([ - { path: "new.ts", kind: "file" }, - ]); - yield* index.search("", 10, "file"); - expect(scanFiles).toHaveBeenCalledTimes(2); }), ), ); @@ -157,20 +146,14 @@ it.effect("continues whole-word searches after a filtered grep page", () => value: grepResult("needleSuffix", [[0, 6]], nextCursor), }, ); - using now = vi.spyOn(performance, "now").mockReturnValue(0); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - scanFiles: () => { - now.mockReturnValue(1_000); - return { ok: true as const, value: undefined }; - }, grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); - yield* TestClock.adjust("5 minutes"); const result = yield* searchIndex.searchContents({ query: "needle", limit: 1, diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index a66fcca63d8ba..8f2b64cf26a34 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -12,12 +12,10 @@ import type { SearchResult, } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import type { ProjectEntry, @@ -38,7 +36,6 @@ const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const WORKSPACE_INDEX_RECOVERY_INTERVAL_MS = 5 * 60_000; const CONTENT_SEARCH_TIME_BUDGET_MS = 250; const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; @@ -375,8 +372,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( ); const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); - const refreshPermit = yield* Semaphore.make(1); - let refreshedAt = yield* Clock.currentTimeMillis; const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, @@ -434,24 +429,10 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( cause, }), ); - refreshedAt = yield* Clock.currentTimeMillis; }); - // Watchers can lose events; repair hot indexes occasionally, rather than after every turn. - const recover = Effect.fn("WorkspaceSearchIndex.recover")(function* () { - const now = yield* Clock.currentTimeMillis; - if (now - refreshedAt < WORKSPACE_INDEX_RECOVERY_INTERVAL_MS) return; - refreshedAt = now; - yield* refresh().pipe( - Effect.catch((cause) => - Effect.logWarning("Periodic workspace index recovery failed", { cwd, cause }), - ), - ); - }, refreshPermit.withPermits(1)); - const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - yield* recover(); const incompleteBeforeQuery = hasIncompleteInitialScan(); const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), @@ -472,7 +453,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", )(function* (query, limit, kind, imageOnly) { - yield* recover(); const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); const incompleteBeforeQuery = hasIncompleteInitialScan(); if (kind === "file" || imageOnly) { @@ -499,7 +479,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( "WorkspaceSearchIndex.searchContents", )(function* (input) { - yield* recover(); const { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. From ed3a1a57458f7feaa5438fa6dce97d46b004ad23 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:50:44 +0000 Subject: [PATCH 5/8] test(server): drop dead workspace mocks and a test that passes on main AI-assisted; verified with the tests and tools listed above. --- .../RunFinalizationService.test.ts | 2 - .../workspace/WorkspaceSearchIndex.test.ts | 39 ------------------- 2 files changed, 41 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 4aed1ae3672ed..b572344be05ca 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -148,7 +148,6 @@ it.effect("returns from the post-turn refresh without waiting for the PR lookup" const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), @@ -198,7 +197,6 @@ it.effect("replaces a still-running PR lookup when a newer turn finishes", () => const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( - Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 460562022241e..2c41e4ed0b3d0 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -7,7 +7,6 @@ import { } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -29,44 +28,6 @@ function fileItem(relativePath: string): FileItem { }; } -it.effect("reads never rescan after the former recovery interval", () => - Effect.scoped( - Effect.gen(function* () { - const scanFiles = vi.fn(() => ({ ok: true as const, value: undefined })); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - scanFiles, - mixedSearch: () => ({ ok: true as const, value: { items: [], totalMatched: 0 } }), - fileSearch: () => ({ - ok: true as const, - value: { items: [fileItem("watched.ts")], scores: [], totalMatched: 1, totalFiles: 1 }, - }), - grep: () => ({ ok: true as const, value: { items: [], nextCursor: null } }), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - const index = yield* WorkspaceSearchIndex.make("/workspace/project"); - yield* TestClock.adjust("10 minutes"); - expect(yield* index.list()).toEqual({ entries: [], truncated: false }); - expect((yield* index.search("", 10, "file")).entries).toEqual([ - { path: "watched.ts", kind: "file" }, - ]); - expect( - yield* index.searchContents({ - query: "needle", - limit: 1, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }), - ).toEqual({ matches: [], truncated: false }); - expect(scanFiles).not.toHaveBeenCalled(); - yield* index.refresh(); - expect(scanFiles).toHaveBeenCalledTimes(1); - }), - ), -); - it.effect("filters image searches before applying the result limit", () => Effect.scoped( Effect.gen(function* () { From 57b22a228e86c3b7000874af7838c7a7dce7c3e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:03:40 +0000 Subject: [PATCH 6/8] fix(server): rescan the file index off the event loop instead of skipping it Finalization keeps its index refresh, which recovers files the watcher dropped during large bursts. The refresh now calls a patched fff-node scanFilesAsync that waits on the picker lock on a native thread, so a contended lock no longer freezes the server. Destroying a finder while that scan is pending is deferred until the scan settles. AI-assisted; verified with the tests and tools listed above. --- .../RunFinalizationQueue.test.ts | 2 + .../RunFinalizationService.test.ts | 4 + .../RunFinalizationService.ts | 9 +- .../src/workspace/WorkspaceEntries.test.ts | 7 +- .../src/workspace/WorkspaceSearchIndex.ts | 8 +- patches/@ff-labs__fff-node@0.11.0.patch | 119 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 7 files changed, 132 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index 3f21b20917850..c2b9e0aabcd5b 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -8,6 +8,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as CheckpointRollback from "./CheckpointRollbackService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -47,6 +48,7 @@ it.effect.each([ Layer.provide( Layer.mergeAll( projections, + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void }), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ refreshLocalStatus: () => diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index b572344be05ca..b51ec8fb80de0 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -14,6 +14,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as RunFinalization from "./RunFinalizationService.ts"; @@ -102,6 +103,7 @@ it.effect.each( const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), @@ -148,6 +150,7 @@ it.effect("returns from the post-turn refresh without waiting for the PR lookup" const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), @@ -197,6 +200,7 @@ it.effect("replaces a still-running PR lookup when a newer turn finishes", () => const layer = RunFinalization.layerObserver.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: () => Effect.void }), Layer.mock(PullRequestService.PullRequestService)({ refreshAfterTurn: () => Effect.void, }), diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index 1ea1131607af7..3775e63f0e733 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -92,6 +93,7 @@ export const layer = Layer.effect(RunFinalizationService, make); export const layerObserver = Layer.effect( RunFinalizationObserver, Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; @@ -100,9 +102,10 @@ export const layerObserver = Layer.effect( refreshAfterTurn: pullRequests.refreshAfterTurn, refresh: Effect.fn("RunFinalizationService.refresh")( function* ({ cwd, threadId, runId }) { - // The search indexes already watch file changes. A forced `scanFiles()` here waits on - // fff's picker lock on the event loop, freezing the server while the watcher holds it. - const local = yield* vcsStatus.refreshLocalStatus(cwd); + const [, local] = yield* Effect.all( + [workspaceEntries.refresh(cwd), vcsStatus.refreshLocalStatus(cwd)], + { concurrency: "unbounded" }, + ); if (local.refName === null || local.isDefaultRef) return; const thread = yield* projections.getThreadShell(threadId); if (!thread || thread.branch !== local.refName) return; diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index b0633dac04cc7..3088ec070296a 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -756,10 +756,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { if (created.ok) { const finder = created.value; finders += 1; - const scanFiles = finder.scanFiles.bind(finder); finder.scanFiles = () => { + throw new Error("refresh must not rescan on the event loop"); + }; + const scanFilesAsync = finder.scanFilesAsync.bind(finder); + finder.scanFilesAsync = () => { rescans += 1; - return scanFiles(); + return scanFilesAsync(); }; const waitForIndexReady = finder.waitForIndexReady.bind(finder); finder.waitForIndexReady = (timeoutMs) => diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index ecb8c6ba87a73..5bffa3a2d9c0e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -401,12 +401,14 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( "WorkspaceSearchIndex.refresh", )(function* () { - const result = yield* Effect.try({ - try: () => finder.scanFiles(), + // The rescan recovers events the watcher dropped. It waits on fff's picker lock, so it must + // not run on the event loop: a contended lock froze the server for tens of seconds. + const result = yield* Effect.tryPromise({ + try: () => finder.scanFilesAsync(), catch: (cause) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason: "FileFinder.scanFiles threw unexpectedly.", + reason: "FileFinder.scanFilesAsync rejected unexpectedly.", cause, }), }); diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index 76e45e152a51e..c69eb3a7df6b7 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -1,16 +1,5 @@ -diff --git a/package.json b/package.json ---- a/package.json -+++ b/package.json -@@ -11,7 +11,7 @@ - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", -- "require": "./dist/index.cjs", -+ "require": "./dist/index.js", - "default": "./dist/index.js" - }, - "./package.json": "./package.json" diff --git a/dist/fff-api.d.ts b/dist/fff-api.d.ts +index 0f90eedca20655ccd30b8f28e433b2094341fbdf..c2ef86927d6e3ab0e8584330fa0ac3fdacc06cf5 100644 --- a/dist/fff-api.d.ts +++ b/dist/fff-api.d.ts @@ -199,7 +199,7 @@ export type Location = { @@ -40,7 +29,24 @@ diff --git a/dist/fff-api.d.ts b/dist/fff-api.d.ts scores: Score[]; /** Total number of items (files + dirs) that matched */ totalMatched: number; +diff --git a/dist/finder.d.ts b/dist/finder.d.ts +index 452102d720cc19b0560cfc9e2f1a320687047c92..312046cb4195000ef91f41ffd07cf127e79be232 100644 +--- a/dist/finder.d.ts ++++ b/dist/finder.d.ts +@@ -242,6 +242,11 @@ export declare class FileFinder implements FileFinderApi { + * background watcher might have missed. + */ + scanFiles(): Result; ++ /** ++ * Trigger a rescan on a native thread so a contended picker lock never blocks the JS thread. ++ * Patched in T3 Code. ++ */ ++ scanFilesAsync(): Promise>; + /** + * Check if a scan is currently in progress. + */ diff --git a/dist/index.js b/dist/index.js +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..2dae1cc0ba3dc8b99f8104dde9c555b4fcb41d66 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -263,3 +269,92 @@ diff --git a/dist/index.js b/dist/index.js } load({ library: LIBRARY_KEY, +@@ -1167,6 +1183,29 @@ function ffiMultiGrep(handle, patternsJoined, constraints, maxFileSize, maxMatch + function ffiScanFiles(handle) { + return callVoidResult("fff_scan_files", [DataType.External], [handle]); + } ++async function ffiScanFilesAsync(handle) { ++ loadLibrary(); ++ const rawPtr = await load({ ++ library: LIBRARY_KEY, ++ funcName: "fff_scan_files", ++ retType: DataType.External, ++ paramsType: [DataType.External], ++ paramsValue: [handle], ++ freeResultMemory: false, ++ runInNewThread: true ++ }); ++ const [structData] = restorePointer({ ++ retType: [FFF_RESULT_STRUCT], ++ paramsValue: wrapPointer([rawPtr]) ++ }); ++ if (structData.success === 0) { ++ const errorStr = readCString(structData.error); ++ freeResult(rawPtr); ++ return err(errorStr || "Unknown error"); ++ } ++ freeResult(rawPtr); ++ return { ok: true, value: void 0 }; ++} + function ffiIsScanning(handle) { + loadLibrary(); + return load({ +@@ -1429,6 +1468,7 @@ var FileFinder = class _FileFinder { + handle; + /** Native ids of this instance's active watch subscriptions. */ + watchers = /* @__PURE__ */ new Set(); ++ pendingScan = null; + constructor(handle) { + this.handle = handle; + } +@@ -1485,6 +1525,16 @@ var FileFinder = class _FileFinder { + destroy() { + if (this.handle !== null) { + const handle = this.handle; ++ if (this.pendingScan) { ++ this.handle = null; ++ const watchers = this.watchers; ++ this.watchers = /* @__PURE__ */ new Set(); ++ this.pendingScan.finally(() => { ++ ffiDestroy(handle); ++ ffiWatchCleanupAfterDestroy(handle, watchers); ++ }); ++ return; ++ } + ffiDestroy(handle); + this.handle = null; + ffiWatchCleanupAfterDestroy(handle, this.watchers); +@@ -1757,6 +1807,20 @@ var FileFinder = class _FileFinder { + if (!guard.ok) return guard; + return ffiScanFiles(guard.value); + } ++ /** ++ * Trigger a rescan without blocking the JS thread. Patched in T3 Code: ++ * `fff_scan_files` can wait on the picker lock, so it runs on a native thread. ++ */ ++ scanFilesAsync() { ++ const guard = this.ensureAlive(); ++ if (!guard.ok) return Promise.resolve(guard); ++ if (this.pendingScan) return this.pendingScan; ++ const scan = ffiScanFilesAsync(guard.value).catch((cause) => err(String(cause))).finally(() => { ++ if (this.pendingScan === scan) this.pendingScan = null; ++ }); ++ this.pendingScan = scan; ++ return scan; ++ } + /** + * Check if a scan is currently in progress. + */ +diff --git a/package.json b/package.json +index e000a78b3f79826200979ebb3fe297b599bcea67..84325461c2d963cb42cf72267079d36a419c1398 100644 +--- a/package.json ++++ b/package.json +@@ -11,7 +11,7 @@ + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", +- "require": "./dist/index.cjs", ++ "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6451f3f97cb29..9465a5eb4f2e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,7 +358,7 @@ patchedDependencies: '@alchemy.run/test@2.0.0-beta.85': 29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e '@clerk/expo-native-components@0.4.4': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 - '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c + '@ff-labs/fff-node@0.11.0': f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63 '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e @@ -832,7 +832,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c) + version: 0.11.0(patch_hash=f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -14717,7 +14717,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c)': + '@ff-labs/fff-node@0.11.0(patch_hash=f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63)': dependencies: ffi-rs: 1.3.2 optionalDependencies: From f3bc16adff9d64b22931834414aaa03d8c0972d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:18:18 +0000 Subject: [PATCH 7/8] fix(server): poll file-index readiness off the event loop too waitForIndexReady polled fff_get_scan_progress synchronously, which takes the same picker lock as the rescan, so a refresh could still stall the server while the watcher held it. The patched fff-node now reads scan progress on a native thread and defers destroy until every such call settles. AI-assisted; verified with the tests and tools listed above. --- patches/@ff-labs__fff-node@0.11.0.patch | 84 ++++++++++++++++++++++--- pnpm-lock.yaml | 6 +- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index c69eb3a7df6b7..5e44d30ac41b8 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -46,7 +46,7 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..312046cb4195000ef91f41ffd07cf127 * Check if a scan is currently in progress. */ diff --git a/dist/index.js b/dist/index.js -index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..2dae1cc0ba3dc8b99f8104dde9c555b4fcb41d66 100644 +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef1041da261c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -299,23 +299,73 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..2dae1cc0ba3dc8b99f8104dde9c555b4 function ffiIsScanning(handle) { loadLibrary(); return load({ -@@ -1429,6 +1468,7 @@ var FileFinder = class _FileFinder { +@@ -1212,6 +1251,48 @@ function ffiGetScanProgress(handle) { + }); + return { ok: true, value: result }; + } ++async function ffiGetScanProgressAsync(handle) { ++ loadLibrary(); ++ const rawPtr = await load({ ++ library: LIBRARY_KEY, ++ funcName: "fff_get_scan_progress", ++ retType: DataType.External, ++ paramsType: [DataType.External], ++ paramsValue: [handle], ++ freeResultMemory: false, ++ runInNewThread: true ++ }); ++ const [structData] = restorePointer({ ++ retType: [FFF_RESULT_STRUCT], ++ paramsValue: wrapPointer([rawPtr]) ++ }); ++ if (structData.success === 0) { ++ const errorStr = readCString(structData.error); ++ freeResult(rawPtr); ++ return err(errorStr || "Unknown error"); ++ } ++ const handlePtr = structData.handle; ++ freeResult(rawPtr); ++ if (isNullPointer(handlePtr)) return err("scan progress returned null"); ++ const [sp] = restorePointer({ ++ retType: [FFF_SCAN_PROGRESS_STRUCT], ++ paramsValue: wrapPointer([handlePtr]) ++ }); ++ const result = { ++ scannedFilesCount: Number(sp.scanned_files_count), ++ isScanning: sp.is_scanning !== 0, ++ isWatcherReady: sp.is_watcher_ready !== 0, ++ isWarmupComplete: sp.is_warmup_complete !== 0 ++ }; ++ load({ ++ library: LIBRARY_KEY, ++ funcName: "fff_free_scan_progress", ++ retType: DataType.Void, ++ paramsType: [DataType.External], ++ paramsValue: [handlePtr] ++ }); ++ return { ok: true, value: result }; ++} + function ffiWaitForScan(handle, timeoutMs) { + return callBoolResult( + "fff_wait_for_scan", +@@ -1429,6 +1510,8 @@ var FileFinder = class _FileFinder { handle; /** Native ids of this instance's active watch subscriptions. */ watchers = /* @__PURE__ */ new Set(); + pendingScan = null; ++ pendingNativeCalls = /* @__PURE__ */ new Set(); constructor(handle) { this.handle = handle; } -@@ -1485,6 +1525,16 @@ var FileFinder = class _FileFinder { +@@ -1485,6 +1568,16 @@ var FileFinder = class _FileFinder { destroy() { if (this.handle !== null) { const handle = this.handle; -+ if (this.pendingScan) { ++ if (this.pendingNativeCalls.size > 0) { + this.handle = null; + const watchers = this.watchers; + this.watchers = /* @__PURE__ */ new Set(); -+ this.pendingScan.finally(() => { ++ Promise.allSettled(this.pendingNativeCalls).then(() => { + ffiDestroy(handle); + ffiWatchCleanupAfterDestroy(handle, watchers); + }); @@ -324,7 +374,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..2dae1cc0ba3dc8b99f8104dde9c555b4 ffiDestroy(handle); this.handle = null; ffiWatchCleanupAfterDestroy(handle, this.watchers); -@@ -1757,6 +1807,20 @@ var FileFinder = class _FileFinder { +@@ -1757,6 +1850,28 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; return ffiScanFiles(guard.value); } @@ -336,15 +386,35 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..2dae1cc0ba3dc8b99f8104dde9c555b4 + const guard = this.ensureAlive(); + if (!guard.ok) return Promise.resolve(guard); + if (this.pendingScan) return this.pendingScan; -+ const scan = ffiScanFilesAsync(guard.value).catch((cause) => err(String(cause))).finally(() => { ++ const scan = this.runOnNativeThread(ffiScanFilesAsync(guard.value)).finally(() => { + if (this.pendingScan === scan) this.pendingScan = null; + }); + this.pendingScan = scan; + return scan; ++ } ++ /** Keeps the handle alive until a native-thread call settles. Patched in T3 Code. */ ++ runOnNativeThread(call) { ++ const tracked = call.catch((cause) => err(String(cause))).finally(() => { ++ this.pendingNativeCalls.delete(tracked); ++ }); ++ this.pendingNativeCalls.add(tracked); ++ return tracked; + } /** * Check if a scan is currently in progress. */ +@@ -1838,7 +1953,10 @@ var FileFinder = class _FileFinder { + if (!guard.ok) return guard; + const deadline = Date.now() + timeoutMs; + while (true) { +- const progress = this.getScanProgress(); ++ // Patched in T3 Code: the progress read takes the picker lock, so it runs on a native thread. ++ const alive = this.ensureAlive(); ++ if (!alive.ok) return alive; ++ const progress = await this.runOnNativeThread(ffiGetScanProgressAsync(alive.value)); + if (!progress.ok) return progress; + if (!progress.value.isScanning && progress.value.isWarmupComplete) { + return { ok: true, value: true }; diff --git a/package.json b/package.json index e000a78b3f79826200979ebb3fe297b599bcea67..84325461c2d963cb42cf72267079d36a419c1398 100644 --- a/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9465a5eb4f2e9..8b862792fb39c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,7 +358,7 @@ patchedDependencies: '@alchemy.run/test@2.0.0-beta.85': 29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e '@clerk/expo-native-components@0.4.4': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 - '@ff-labs/fff-node@0.11.0': f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63 + '@ff-labs/fff-node@0.11.0': bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6 '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e @@ -832,7 +832,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63) + version: 0.11.0(patch_hash=bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -14717,7 +14717,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=f2bf177e6a440d3d467349aeb0680d6723b40f5ae01f06dbe55644e2097a0d63)': + '@ff-labs/fff-node@0.11.0(patch_hash=bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6)': dependencies: ffi-rs: 1.3.2 optionalDependencies: From 0120d57266b2f741406f9276d5365835b5c4cc47 Mon Sep 17 00:00:00 2001 From: mikeohuo Date: Wed, 11 Nov 2026 18:25:40 -0500 Subject: [PATCH 8/8] fix(server): concurrent index refreshes share one native progress read Before: each index refresh that waited for a rescan polled fff's scan progress with its own native call. ffi-rs runs a `runInNewThread` call on Node's 4-thread libuv pool, so several refreshes that waited on fff's picker lock at the same time filled the pool. The event loop stayed responsive, but asynchronous file system calls in the server waited until the lock was released. After: the refreshes that wait on one finder share one in-flight progress read, so one finder holds at most two pool threads, one for the rescan call and one for the progress read. Description: - The fff-node patch adds `FileFinder.getScanProgressAsync()`. It returns the pending read when one exists, the same way `scanFilesAsync()` shares the rescan call. `waitForIndexReady` polls through it. - A new test in `WorkspaceEntries.test.ts` runs 8 concurrent refreshes on a real finder and expects at most one progress read in flight. With the sharing disabled it fails with "expected 8 to be 1". - Measured on Windows 11 with fff-node 0.11.0 and ffi-rs 1.3.2: a 272,000-file repository with a content index, a 250,000-file folder moved into it, and one finished run each second. Before, an `fs.promises.stat` waited 25.4 s and 24.1 s in two runs. After, no call waited 100 ms or longer. - Not covered: four finders that wait on their locks at the same time can still fill the pool. --- .../src/workspace/WorkspaceEntries.test.ts | 44 +++++++++++++++++++ patches/@ff-labs__fff-node@0.11.0.patch | 43 ++++++++++++++---- pnpm-lock.yaml | 6 +-- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 3088ec070296a..624be9dc1bd55 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -792,6 +792,50 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); }), ); + + it.effect("shares one native progress read between concurrent refreshes", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-pool-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + // Each read holds a libuv pool thread while it waits on fff's picker lock. + const readsInFlight = new Set>(); + let maxReadsInFlight = 0; + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (created.ok) { + const finder = created.value; + const getScanProgressAsync = finder.getScanProgressAsync.bind(finder); + finder.getScanProgressAsync = () => { + const read = getScanProgressAsync(); + if (!readsInFlight.has(read)) { + readsInFlight.add(read); + maxReadsInFlight = Math.max(maxReadsInFlight, readsInFlight.size); + void read.finally(() => readsInFlight.delete(read)); + } + return read; + }; + } + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + yield* workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + yield* Effect.all( + Array.from({ length: 8 }, () => workspaceEntries.refresh(cwd)), + { concurrency: "unbounded" }, + ); + + expect(maxReadsInFlight).toBe(1); + }), + ); }); describe("browse", () => { diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index 5e44d30ac41b8..d2063031855b7 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -30,7 +30,7 @@ index 0f90eedca20655ccd30b8f28e433b2094341fbdf..c2ef86927d6e3ab0e8584330fa0ac3fd /** Total number of items (files + dirs) that matched */ totalMatched: number; diff --git a/dist/finder.d.ts b/dist/finder.d.ts -index 452102d720cc19b0560cfc9e2f1a320687047c92..312046cb4195000ef91f41ffd07cf127e79be232 100644 +index 452102d720cc19b0560cfc9e2f1a320687047c92..092162277e7a906f65a559d874fbe19571e62133 100644 --- a/dist/finder.d.ts +++ b/dist/finder.d.ts @@ -242,6 +242,11 @@ export declare class FileFinder implements FileFinderApi { @@ -45,8 +45,20 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..312046cb4195000ef91f41ffd07cf127 /** * Check if a scan is currently in progress. */ +@@ -254,6 +259,11 @@ export declare class FileFinder implements FileFinderApi { + * Get the current scan progress. + */ + getScanProgress(): Result; ++ /** ++ * Read scan progress on a native thread. Concurrent callers share one call. ++ * Patched in T3 Code. ++ */ ++ getScanProgressAsync(): Promise>; + /** + * Wait for the initial file scan to complete. + * Non-blocking: polls `isScanning` and yields to the event loop between checks. diff --git a/dist/index.js b/dist/index.js -index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef1041da261c 100644 +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..9643e1614ff128bd0cef71368b531314dfdee678 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -348,16 +360,17 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef10 function ffiWaitForScan(handle, timeoutMs) { return callBoolResult( "fff_wait_for_scan", -@@ -1429,6 +1510,8 @@ var FileFinder = class _FileFinder { +@@ -1429,6 +1510,9 @@ var FileFinder = class _FileFinder { handle; /** Native ids of this instance's active watch subscriptions. */ watchers = /* @__PURE__ */ new Set(); + pendingScan = null; ++ pendingScanProgress = null; + pendingNativeCalls = /* @__PURE__ */ new Set(); constructor(handle) { this.handle = handle; } -@@ -1485,6 +1568,16 @@ var FileFinder = class _FileFinder { +@@ -1485,6 +1569,16 @@ var FileFinder = class _FileFinder { destroy() { if (this.handle !== null) { const handle = this.handle; @@ -374,7 +387,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef10 ffiDestroy(handle); this.handle = null; ffiWatchCleanupAfterDestroy(handle, this.watchers); -@@ -1757,6 +1850,28 @@ var FileFinder = class _FileFinder { +@@ -1757,6 +1851,42 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; return ffiScanFiles(guard.value); } @@ -392,6 +405,20 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef10 + this.pendingScan = scan; + return scan; + } ++ /** ++ * Read scan progress without blocking the JS thread. Patched in T3 Code: concurrent readers ++ * share one call, because each call holds a libuv pool thread while it waits on the picker lock. ++ */ ++ getScanProgressAsync() { ++ const guard = this.ensureAlive(); ++ if (!guard.ok) return Promise.resolve(guard); ++ if (this.pendingScanProgress) return this.pendingScanProgress; ++ const read = this.runOnNativeThread(ffiGetScanProgressAsync(guard.value)).finally(() => { ++ if (this.pendingScanProgress === read) this.pendingScanProgress = null; ++ }); ++ this.pendingScanProgress = read; ++ return read; ++ } + /** Keeps the handle alive until a native-thread call settles. Patched in T3 Code. */ + runOnNativeThread(call) { + const tracked = call.catch((cause) => err(String(cause))).finally(() => { @@ -403,15 +430,13 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..cf3dac957335027ba9cb33621fb8ef10 /** * Check if a scan is currently in progress. */ -@@ -1838,7 +1953,10 @@ var FileFinder = class _FileFinder { +@@ -1838,7 +1968,8 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; const deadline = Date.now() + timeoutMs; while (true) { - const progress = this.getScanProgress(); + // Patched in T3 Code: the progress read takes the picker lock, so it runs on a native thread. -+ const alive = this.ensureAlive(); -+ if (!alive.ok) return alive; -+ const progress = await this.runOnNativeThread(ffiGetScanProgressAsync(alive.value)); ++ const progress = await this.getScanProgressAsync(); if (!progress.ok) return progress; if (!progress.value.isScanning && progress.value.isWarmupComplete) { return { ok: true, value: true }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b862792fb39c..3b75d43d34955 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,7 +358,7 @@ patchedDependencies: '@alchemy.run/test@2.0.0-beta.85': 29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e '@clerk/expo-native-components@0.4.4': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 - '@ff-labs/fff-node@0.11.0': bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6 + '@ff-labs/fff-node@0.11.0': 34dd25f8217a0a0e9c7287bde40c6bb46e42b96375817815e77f1df0741b1692 '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e @@ -832,7 +832,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6) + version: 0.11.0(patch_hash=34dd25f8217a0a0e9c7287bde40c6bb46e42b96375817815e77f1df0741b1692) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -14717,7 +14717,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=bd3bc8f9df9a4701f9e8152c612a0e822cbe228539a31603ed28847e45b4d8d6)': + '@ff-labs/fff-node@0.11.0(patch_hash=34dd25f8217a0a0e9c7287bde40c6bb46e42b96375817815e77f1df0741b1692)': dependencies: ffi-rs: 1.3.2 optionalDependencies: