From 7b6965856b8f8940f7ee6da1e625ce13ffeeeb5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 4 Aug 2026 13:09:30 +0200 Subject: [PATCH] Prevent workspace file watching from stalling the daemon (#2858) * fix(server): pin workspace file watchers to native backends Backend auto-detection could select an IPC watcher whose subscription lock deadlocked the Node worker pool during watcher reconfiguration. Select the host-native backend explicitly so filesystem work, logging, and relay DNS cannot be starved by that path. * test(server): cover native watcher production boundary Route the existing real filesystem observation test through the same subscription function used by the daemon. This proves native watcher selection and reconfiguration with actual file events instead of only testing the platform selector. * test(server): synchronize native watcher readiness Replace fixed startup sleeps with a real tracked-file event barrier. Native backends can resolve their subscription before the initial stream is fully drained, so the integration test now begins assertions only after an event has been delivered and refreshed. * fix(server): measure native workspace watchers --- .../server/scripts/measure-git-observation.ts | 3 +- ...it-service.observation.integration.test.ts | 24 ++++++++++++---- .../workspace-git-service.primitive.test.ts | 15 ++++++++++ .../src/server/workspace-git-service.ts | 28 ++++++++++++++++++- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/server/scripts/measure-git-observation.ts b/packages/server/scripts/measure-git-observation.ts index c501a333b..ecd6d07f2 100644 --- a/packages/server/scripts/measure-git-observation.ts +++ b/packages/server/scripts/measure-git-observation.ts @@ -17,6 +17,7 @@ import { } from "../src/utils/run-git-command.js"; import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js"; import { + subscribeToWorkspaceFileChanges, WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, } from "../src/server/workspace-git-service.js"; @@ -94,7 +95,7 @@ function createTrackedSubscriber(failWorktreeRoot: string | null) { if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) { throw new Error("measurement watcher setup failure"); } - const subscription = await parcelWatcher.subscribe(...args); + const subscription = await subscribeToWorkspaceFileChanges(...args); subscriptions.add(subscription); return { unsubscribe: async () => { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 8decb8ca8..70e60fe16 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -6,7 +6,10 @@ import type pino from "pino"; import { afterEach, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { + subscribeToWorkspaceFileChanges, + WorkspaceGitServiceImpl, +} from "./workspace-git-service.js"; function createLogger(): pino.Logger { const logger = { @@ -83,7 +86,7 @@ test("native recursive observation updates tracked state and prunes ignored stor events: parcelWatcher.Event[]; }> = []; const subscribe: typeof parcelWatcher.subscribe = async (directory, callback, options) => { - const subscription = await parcelWatcher.subscribe( + const subscription = await subscribeToWorkspaceFileChanges( directory, (error, events) => { deliveredEvents.push({ directory, events }); @@ -194,9 +197,19 @@ test("native recursive observation updates tracked state and prunes ignored stor { timeout: 5_000 }, ); - // Parcel may deliver a startup batch before subscribe resolves. Let its - // already-scheduled consumer debounce finish inside the bootstrap phase. - await new Promise((resolve) => setTimeout(resolve, 250)); + writeFileSync(trackedPath, "base\n"); + await vi.waitFor( + () => { + const events = deliveredEvents.flatMap((batch) => batch.events); + expect(events.map((event) => event.path)).toContain(trackedPath); + expect(getCheckoutWorktreeState).toHaveBeenCalled(); + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + }); + }, + { timeout: 5_000 }, + ); getCheckoutSnapshotFacts.mockClear(); getCheckoutStatus.mockClear(); @@ -206,7 +219,6 @@ test("native recursive observation updates tracked state and prunes ignored stor runGitCommand.mockClear(); deliveredEvents.length = 0; - await new Promise((resolve) => setTimeout(resolve, 250)); expect(runGitCommand).not.toHaveBeenCalled(); expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); expect(getCheckoutDiff, JSON.stringify(deliveredEvents)).not.toHaveBeenCalled(); diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 77380d72b..f0669c2f1 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -19,6 +19,7 @@ import { } from "../utils/checkout-git.js"; import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js"; import { + getWorkspaceFileWatcherBackend, getWorkspaceGitSelfHealPhaseMs, WorkspaceGitServiceImpl, type WorkspaceGitRuntimeSnapshot, @@ -891,6 +892,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817); }); + test.each([ + ["darwin", "fs-events"], + ["linux", "inotify"], + ["win32", "windows"], + ] as const)("uses the native %s file watcher backend", (platform, backend) => { + expect(getWorkspaceFileWatcherBackend(platform)).toBe(backend); + }); + + test("rejects unsupported watcher platforms instead of using backend auto-detection", () => { + expect(() => getWorkspaceFileWatcherBackend("freebsd")).toThrow( + "No native workspace file watcher configured for freebsd", + ); + }); + test("self-heal staggers workspaces and settles into a 120 second cadence", async () => { vi.setSystemTime(0); const otherRepoCwd = resolvePath("/tmp/staggered-repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index f74c9670c..86ea5902b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -306,6 +306,32 @@ interface WorkspaceGitServiceOptions { deps?: Partial; } +export function getWorkspaceFileWatcherBackend( + platform: NodeJS.Platform, +): parcelWatcher.BackendType { + switch (platform) { + case "darwin": + return "fs-events"; + case "linux": + return "inotify"; + case "win32": + return "windows"; + default: + throw new Error(`No native workspace file watcher configured for ${platform}`); + } +} + +export function subscribeToWorkspaceFileChanges( + directory: string, + callback: parcelWatcher.SubscribeCallback, + options?: parcelWatcher.Options, +): Promise { + return parcelWatcher.subscribe(directory, callback, { + ...options, + backend: getWorkspaceFileWatcherBackend(process.platform), + }); +} + interface WorkspaceGitTarget { cwd: string; listeners: Set; @@ -375,7 +401,7 @@ interface WorkspaceForgePrStatusPollTarget { function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { return { - subscribe: parcelWatcher.subscribe, + subscribe: subscribeToWorkspaceFileChanges, getCheckoutSnapshotFacts, getCheckoutStatus, getCheckoutShortstat,