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:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -306,6 +306,32 @@ interface WorkspaceGitServiceOptions {
|
||||
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 {
|
||||
cwd: string;
|
||||
listeners: Set<WorkspaceGitListener>;
|
||||
@@ -375,7 +401,7 @@ interface WorkspaceForgePrStatusPollTarget {
|
||||
|
||||
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
|
||||
return {
|
||||
subscribe: parcelWatcher.subscribe,
|
||||
subscribe: subscribeToWorkspaceFileChanges,
|
||||
getCheckoutSnapshotFacts,
|
||||
getCheckoutStatus,
|
||||
getCheckoutShortstat,
|
||||
|
||||
Reference in New Issue
Block a user