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
This commit is contained in:
Mohamed Boudra
2026-08-04 13:09:30 +02:00
committed by GitHub
parent df32e6ddcd
commit 7b6965856b
4 changed files with 62 additions and 8 deletions
@@ -17,6 +17,7 @@ import {
} from "../src/utils/run-git-command.js"; } from "../src/utils/run-git-command.js";
import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js"; import { CheckoutDiffManager } from "../src/server/checkout-diff-manager.js";
import { import {
subscribeToWorkspaceFileChanges,
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
} from "../src/server/workspace-git-service.js"; } from "../src/server/workspace-git-service.js";
@@ -94,7 +95,7 @@ function createTrackedSubscriber(failWorktreeRoot: string | null) {
if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) { if (failWorktreeRoot && path.resolve(args[0]) === failWorktreeRoot) {
throw new Error("measurement watcher setup failure"); throw new Error("measurement watcher setup failure");
} }
const subscription = await parcelWatcher.subscribe(...args); const subscription = await subscribeToWorkspaceFileChanges(...args);
subscriptions.add(subscription); subscriptions.add(subscription);
return { return {
unsubscribe: async () => { unsubscribe: async () => {
@@ -6,7 +6,10 @@ import type pino from "pino";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.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 { function createLogger(): pino.Logger {
const logger = { const logger = {
@@ -83,7 +86,7 @@ test("native recursive observation updates tracked state and prunes ignored stor
events: parcelWatcher.Event[]; events: parcelWatcher.Event[];
}> = []; }> = [];
const subscribe: typeof parcelWatcher.subscribe = async (directory, callback, options) => { const subscribe: typeof parcelWatcher.subscribe = async (directory, callback, options) => {
const subscription = await parcelWatcher.subscribe( const subscription = await subscribeToWorkspaceFileChanges(
directory, directory,
(error, events) => { (error, events) => {
deliveredEvents.push({ directory, events }); deliveredEvents.push({ directory, events });
@@ -194,9 +197,19 @@ test("native recursive observation updates tracked state and prunes ignored stor
{ timeout: 5_000 }, { timeout: 5_000 },
); );
// Parcel may deliver a startup batch before subscribe resolves. Let its writeFileSync(trackedPath, "base\n");
// already-scheduled consumer debounce finish inside the bootstrap phase. await vi.waitFor(
await new Promise((resolve) => setTimeout(resolve, 250)); () => {
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(); getCheckoutSnapshotFacts.mockClear();
getCheckoutStatus.mockClear(); getCheckoutStatus.mockClear();
@@ -206,7 +219,6 @@ test("native recursive observation updates tracked state and prunes ignored stor
runGitCommand.mockClear(); runGitCommand.mockClear();
deliveredEvents.length = 0; deliveredEvents.length = 0;
await new Promise((resolve) => setTimeout(resolve, 250));
expect(runGitCommand).not.toHaveBeenCalled(); expect(runGitCommand).not.toHaveBeenCalled();
expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); expect(getCheckoutWorktreeState).not.toHaveBeenCalled();
expect(getCheckoutDiff, JSON.stringify(deliveredEvents)).not.toHaveBeenCalled(); expect(getCheckoutDiff, JSON.stringify(deliveredEvents)).not.toHaveBeenCalled();
@@ -19,6 +19,7 @@ import {
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js"; import { runGitCommand as runGitCommandReal } from "../utils/run-git-command.js";
import { import {
getWorkspaceFileWatcherBackend,
getWorkspaceGitSelfHealPhaseMs, getWorkspaceGitSelfHealPhaseMs,
WorkspaceGitServiceImpl, WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot, type WorkspaceGitRuntimeSnapshot,
@@ -891,6 +892,20 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817); 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 () => { test("self-heal staggers workspaces and settles into a 120 second cadence", async () => {
vi.setSystemTime(0); vi.setSystemTime(0);
const otherRepoCwd = resolvePath("/tmp/staggered-repo"); const otherRepoCwd = resolvePath("/tmp/staggered-repo");
@@ -306,6 +306,32 @@ interface WorkspaceGitServiceOptions {
deps?: Partial<WorkspaceGitServiceDependencies>; deps?: Partial<WorkspaceGitServiceDependencies>;
} }
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<parcelWatcher.AsyncSubscription> {
return parcelWatcher.subscribe(directory, callback, {
...options,
backend: getWorkspaceFileWatcherBackend(process.platform),
});
}
interface WorkspaceGitTarget { interface WorkspaceGitTarget {
cwd: string; cwd: string;
listeners: Set<WorkspaceGitListener>; listeners: Set<WorkspaceGitListener>;
@@ -375,7 +401,7 @@ interface WorkspaceForgePrStatusPollTarget {
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
return { return {
subscribe: parcelWatcher.subscribe, subscribe: subscribeToWorkspaceFileChanges,
getCheckoutSnapshotFacts, getCheckoutSnapshotFacts,
getCheckoutStatus, getCheckoutStatus,
getCheckoutShortstat, getCheckoutShortstat,