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,