Recover daemon workers stalled by workspace Git pressure (#3033)

* fix(server): bound workspace git pipelines

* fix(server): bound workspace git watcher setup

* fix(server): recover unresponsive daemon workers

* fix(server): tolerate transient daemon heartbeat delays

A five-second watchdog threshold can replace a healthy worker during a short event-loop delay. Keep recovery bounded while allowing a 15-second heartbeat gap, and preserve process-level coverage for workers that ignore graceful termination.

* test(server): normalize workspace fixture paths

Workspace Git entry points resolve paths before invoking dependencies. Keep admission-control fixtures keyed by the same canonical paths so their assertions and deferred gates work on Windows.

---------

Co-authored-by: Timi Ajiboye <trypio00@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
维她命@
2026-08-09 03:01:34 +02:00
committed by GitHub
co-authored by Timi Ajiboye Mohamed Boudra
parent 3bca9b7a92
commit 01a1d3b2b6
9 changed files with 755 additions and 24 deletions
@@ -13,9 +13,11 @@ const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
timeoutMs?: number;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
elapsedMs: number;
log: string;
stdout: string;
stderr: string;
@@ -47,6 +49,7 @@ async function runSupervisorFixture(options: {
`,
);
const startedAt = Date.now();
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
@@ -71,7 +74,7 @@ async function runSupervisorFixture(options: {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("supervisor fixture timed out"));
}, 10000);
}, options.timeoutMs ?? 10_000);
child.on("error", (error) => {
clearTimeout(timeout);
@@ -84,7 +87,7 @@ async function runSupervisorFixture(options: {
});
const log = await readFile(logPath, "utf8");
return { code, signal, log, stdout, stderr };
return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr };
}
describe("supervisor durable logging", () => {
@@ -186,6 +189,152 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain('"workerPid":');
});
test("keeps a worker alive while heartbeats continue", async () => {
const result = await runSupervisorFixture({
timeoutMs: 10_000,
workerSource: `
setInterval(() => {
process.send?.({ type: "paseo:worker-heartbeat" });
}, 250);
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "healthy_heartbeat_test_complete" });
}, 6000);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"healthy_heartbeat_test_complete"');
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
}, 10_000);
test("tolerates a transient seven-second heartbeat pause", async () => {
const result = await runSupervisorFixture({
timeoutMs: 10_000,
workerSource: `
import { existsSync, writeFileSync } from "node:fs";
const marker = process.argv[1] + ".started";
if (!existsSync(marker)) {
writeFileSync(marker, "started");
let heartbeatCount = 0;
const heartbeat = setInterval(() => {
process.send?.({ type: "paseo:worker-heartbeat" });
heartbeatCount += 1;
if (heartbeatCount === 3) clearInterval(heartbeat);
}, 100);
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "heartbeat_pause_tolerated" });
}, 7_000);
setInterval(() => {}, 1_000);
} else {
process.send?.({ type: "paseo:shutdown", reason: "unexpected_heartbeat_restart" });
setInterval(() => {}, 1_000);
}
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"heartbeat_pause_tolerated"');
expect(result.log).not.toContain('"reason":"unexpected_heartbeat_restart"');
expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"');
}, 10_000);
test.skipIf(isPlatform("win32"))(
"forces shutdown when a worker ignores SIGTERM",
async () => {
const result = await runSupervisorFixture({
timeoutMs: 15_000,
workerSource: `
process.on("SIGTERM", () => {});
process.send?.({ type: "paseo:shutdown", reason: "stalled_worker_shutdown" });
setInterval(() => {}, 1_000);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"reason":"stalled_worker_shutdown"');
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
expect(result.log).toContain('"signal":"SIGKILL"');
},
20_000,
);
// POSIX-only: the watchdog uses SIGKILL after its graceful shutdown window.
test.skipIf(isPlatform("win32"))(
"restarts a worker that stops heartbeating",
async () => {
const result = await runSupervisorFixture({
timeoutMs: 35_000,
workerSource: `
import { existsSync, writeFileSync } from "node:fs";
const marker = process.argv[1] + ".started";
if (!existsSync(marker)) {
writeFileSync(marker, "started");
let heartbeatCount = 0;
const heartbeat = setInterval(() => {
process.send?.({ type: "paseo:worker-heartbeat" });
heartbeatCount += 1;
if (heartbeatCount === 3) clearInterval(heartbeat);
}, 100);
process.on("SIGTERM", () => {});
setInterval(() => {}, 1000);
} else {
process.send?.({ type: "paseo:shutdown", reason: "watchdog_test_complete" });
setInterval(() => {}, 1000);
}
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker heartbeat timed out; restarting worker"');
expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"');
expect(result.log).toContain('"signal":"SIGKILL"');
},
40_000,
);
test.skipIf(isPlatform("win32"))(
"restarts after worker exit while a descendant retains the worker stdio",
async () => {
const result = await runSupervisorFixture({
timeoutMs: 7_000,
workerSource: `
import { spawn } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
const marker = process.argv[1] + ".started";
if (!existsSync(marker)) {
writeFileSync(marker, "started");
const descendant = spawn(
process.execPath,
["-e", "process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 4000)"],
{ detached: true, stdio: ["ignore", "inherit", "inherit"] },
);
descendant.unref();
process.on("SIGTERM", () => process.exit(0));
process.send?.({ type: "paseo:restart", reason: "stdio_descendant" });
setInterval(() => {}, 1000);
} else {
process.send?.({ type: "paseo:shutdown", reason: "stdio_restart_complete" });
setInterval(() => {}, 1000);
}
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.elapsedMs).toBeLessThan(2_500);
expect(result.log).toContain('"reason":"stdio_descendant"');
expect(result.log).toContain("Restarting worker");
},
7_000,
);
// POSIX-only: Windows reports the worker self-kill as an exit code, not SIGKILL.
test.skipIf(isPlatform("win32"))(
"logs worker signal exits even when the worker cannot log",
+81 -2
View File
@@ -2,6 +2,11 @@ import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import { signalProcessTree } from "../src/utils/tree-kill.js";
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
const WORKER_HEARTBEAT_TIMEOUT_MS = 15_000;
const WORKER_TERMINATION_GRACE_MS = 10_000;
interface SupervisorLogFileOptions {
path: string;
@@ -29,6 +34,10 @@ interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface WorkerHeartbeatMessage {
type: "paseo:worker-heartbeat";
}
interface SupervisorOptions {
name: string;
startupMessage: string;
@@ -84,6 +93,15 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return null;
}
function isWorkerHeartbeatMessage(msg: unknown): msg is WorkerHeartbeatMessage {
return (
typeof msg === "object" &&
msg !== null &&
"type" in msg &&
(msg as { type?: unknown }).type === "paseo:worker-heartbeat"
);
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
@@ -120,6 +138,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let restarting = false;
let shuttingDown = false;
let exiting = false;
let forceKillTimer: NodeJS.Timeout | null = null;
const logStream = createSupervisorLogStream(options.logFile);
const writeDurableChunk = (chunk: string | Buffer): void => {
@@ -169,6 +188,40 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
});
};
const clearForceKillTimer = (): void => {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
};
const scheduleForceKill = (reason: string): void => {
if (!child) {
return;
}
const currentChild = child;
clearForceKillTimer();
forceKillTimer = setTimeout(() => {
forceKillTimer = null;
if (child !== currentChild) {
return;
}
writeLifecycleLog("Worker did not exit after SIGTERM; forcing SIGKILL", {
reason,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
void signalProcessTree(currentChild, "SIGKILL").catch((error) => {
writeLifecycleLog("Failed to force-kill worker process tree", {
error: error instanceof Error ? error.message : String(error),
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
});
}, WORKER_TERMINATION_GRACE_MS);
forceKillTimer.unref();
};
const spawnWorker = () => {
let workerEntry: string;
try {
@@ -197,6 +250,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
}
const currentChild = child;
let lastWorkerHeartbeatAt = Date.now();
const heartbeat = setInterval(() => {
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
if (currentChild.connected) {
@@ -210,9 +264,26 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
} else {
writeLifecycleLog("Worker heartbeat skipped because IPC channel is disconnected");
}
}, 1000);
}, WORKER_HEARTBEAT_INTERVAL_MS);
heartbeat.unref();
const workerWatchdog = setInterval(() => {
if (child !== currentChild || restarting || shuttingDown) {
return;
}
const heartbeatAgeMs = Date.now() - lastWorkerHeartbeatAt;
if (heartbeatAgeMs < WORKER_HEARTBEAT_TIMEOUT_MS) {
return;
}
writeLifecycleLog("Worker heartbeat timed out; restarting worker", {
heartbeatAgeMs,
supervisorPid: process.pid,
workerPid: currentChild.pid ?? null,
});
requestRestart("worker_heartbeat_timeout");
}, WORKER_HEARTBEAT_INTERVAL_MS);
workerWatchdog.unref();
child.on("disconnect", () => {
writeLifecycleLog("Worker IPC channel disconnected");
});
@@ -228,6 +299,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
});
child.on("message", (msg: unknown) => {
if (isWorkerHeartbeatMessage(msg)) {
lastWorkerHeartbeatAt = Date.now();
return;
}
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
return;
@@ -256,8 +331,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
requestRestart(reason);
});
child.on("close", (code, signal) => {
child.on("exit", (code, signal) => {
clearInterval(heartbeat);
clearInterval(workerWatchdog);
clearForceKillTimer();
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
@@ -308,6 +385,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`);
signalWorker("SIGTERM", reason);
scheduleForceKill(reason);
};
const requestShutdown = (reason: string) => {
@@ -323,6 +401,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
return;
}
signalWorker("SIGTERM", reason);
scheduleForceKill(reason);
};
const forwardSignal = (signal: NodeJS.Signals) => {
@@ -27,6 +27,10 @@ interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface WorkerHeartbeatMessage {
type: "paseo:worker-heartbeat";
}
interface BootstrapResult {
paseoHome: string;
logger: ReturnType<typeof createRootLogger>;
@@ -228,6 +232,13 @@ async function main() {
const supervisorPid = process.ppid;
let lastSupervisorHeartbeatAt = Date.now();
let supervisorExitRequested = false;
const sendWorkerHeartbeat = () => {
try {
process.send?.({ type: "paseo:worker-heartbeat" } satisfies WorkerHeartbeatMessage);
} catch {
// The disconnect handler below owns supervisor-loss shutdown.
}
};
const exitAfterSupervisorLoss = (reason: string) => {
if (supervisorExitRequested) {
return;
@@ -261,6 +272,10 @@ async function main() {
});
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
sendWorkerHeartbeat();
const workerHeartbeat = setInterval(sendWorkerHeartbeat, 1_000);
workerHeartbeat.unref();
const timer = setInterval(() => {
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
@@ -82,6 +82,10 @@ export function createNoopWorkspaceGitService(
workingTreeWatchSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
workspaceRefreshAdmissionActiveCount: 0,
workspaceRefreshAdmissionPendingCount: 0,
workspaceObservationSetupAdmissionActiveCount: 0,
workspaceObservationSetupAdmissionPendingCount: 0,
fetchInFlightCount: 0,
snapshotUpdatedListenerCount: 0,
watcherErrorCallbackCount: 0,
@@ -246,6 +246,10 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
workingTreeWatchSetupInFlightCount: 0,
workspaceRefreshInFlightCount: 0,
workspaceRefreshQueuedCount: 0,
workspaceRefreshAdmissionActiveCount: 0,
workspaceRefreshAdmissionPendingCount: 0,
workspaceObservationSetupAdmissionActiveCount: 0,
workspaceObservationSetupAdmissionPendingCount: 0,
fetchInFlightCount: 0,
snapshotUpdatedListenerCount: 0,
watcherErrorCallbackCount: 0,
@@ -1093,7 +1093,8 @@ describe("WorkspaceGitService checkout observation", () => {
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(35_000);
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(1);
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
openedSubscription.resolve({ unsubscribe: erroredUnsubscribe });
@@ -9,6 +9,9 @@ import type {
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
WORKSPACE_GIT_REFRESH_CONCURRENCY,
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
} from "./workspace-git-service.js";
@@ -507,6 +510,279 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("bounds refresh generations and re-enqueues hot workspace successors fairly", async () => {
const hotCwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY }, (_, index) =>
path.resolve(`/tmp/hot-repo-${index}`),
);
const fifthCwd = path.resolve("/tmp/fifth-repo");
const firstReadGates = new Map(
[...hotCwds, fifthCwd].map((cwd) => [cwd, createDeferred<CheckoutStatusGit>()]),
);
const pendingFirstReads = new Set(firstReadGates.keys());
const startedReads: string[] = [];
const getCheckoutStatus = vi.fn((cwd: string) => {
startedReads.push(cwd);
const firstRead = firstReadGates.get(cwd);
if (firstRead && pendingFirstReads.delete(cwd)) {
return firstRead.promise;
}
return Promise.resolve(createCheckoutStatus(cwd, { currentBranch: "successor" }));
});
const service = createService({ getCheckoutStatus });
const hotInitialReads = hotCwds.map((cwd) => service.getSnapshot(cwd, { includeForge: false }));
await vi.waitFor(() => {
expect(startedReads).toEqual(hotCwds);
});
const fifthRead = service.getSnapshot(fifthCwd, { includeForge: false });
const hotSuccessorReads = hotCwds.map((cwd) =>
service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "hot-successor",
}),
);
expect(service.getMetrics()).toMatchObject({
workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY,
workspaceRefreshAdmissionPendingCount: 1,
workspaceRefreshQueuedCount: WORKSPACE_GIT_REFRESH_CONCURRENCY,
});
firstReadGates.get(hotCwds[0])?.resolve(createCheckoutStatus(hotCwds[0]));
await vi.waitFor(() => {
expect(startedReads).toContain(fifthCwd);
});
expect(startedReads.filter((cwd) => cwd === hotCwds[0])).toHaveLength(1);
for (const [cwd, gate] of firstReadGates) {
gate.resolve(createCheckoutStatus(cwd));
}
await Promise.all([...hotInitialReads, fifthRead, ...hotSuccessorReads]);
for (const cwd of hotCwds) {
expect(startedReads.filter((startedCwd) => startedCwd === cwd)).toHaveLength(2);
}
expect(startedReads.filter((cwd) => cwd === fifthCwd)).toHaveLength(1);
service.dispose();
});
test("bounds workspace observation setup pipelines", async () => {
const cwds = Array.from(
{ length: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY + 2 },
(_, index) => `/tmp/observation-repo-${index}`,
);
const setupGate = createDeferred<void>();
let activeSetups = 0;
let maxActiveSetups = 0;
const subscribe = vi.fn(async () => {
activeSetups += 1;
maxActiveSetups = Math.max(maxActiveSetups, activeSetups);
await setupGate.promise;
activeSetups -= 1;
return createAsyncSubscription();
});
const runGitCommand = vi.fn(async (_args: string[], options: { cwd: string }) => ({
stdout: `${options.cwd}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService({ subscribe, runGitCommand });
await Promise.all(cwds.map((cwd) => service.getSnapshot(cwd, { includeForge: false })));
const subscriptions = cwds.map((cwd) => service.registerWorkspace({ cwd }, vi.fn()));
await vi.waitFor(() => {
expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY);
});
expect(maxActiveSetups).toBe(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY);
expect(service.getMetrics()).toMatchObject({
workspaceObservationSetupAdmissionActiveCount: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
workspaceObservationSetupAdmissionPendingCount: 2,
});
setupGate.resolve();
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
expect(maxActiveSetups).toBe(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY);
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
service.dispose();
});
test("watcher subscription deadlines release observation admission slots", async () => {
const cwds = Array.from(
{ length: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY + 2 },
(_, index) => path.resolve(`/tmp/deadline-repo-${index}`),
);
const stalledSubscriptions = Array.from(
{ length: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY },
() => createDeferred<ReturnType<typeof createAsyncSubscription>>(),
);
const subscribedPaths: string[] = [];
const subscribe = vi.fn((watchPath: string) => {
subscribedPaths.push(watchPath);
const stalled = stalledSubscriptions.shift();
return stalled?.promise ?? Promise.resolve(createAsyncSubscription());
});
const runGitCommand = vi.fn(async (_args: string[], options: { cwd: string }) => ({
stdout: `${options.cwd}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService({ subscribe, runGitCommand });
await Promise.all(cwds.map((cwd) => service.getSnapshot(cwd, { includeForge: false })));
const subscriptions = cwds.map((cwd) => service.registerWorkspace({ cwd }, vi.fn()));
await vi.waitFor(() => {
expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY);
});
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS);
await vi.waitFor(() => {
expect(subscribedPaths).toEqual(expect.arrayContaining(cwds));
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
for (const subscription of subscriptions) {
subscription.unsubscribe();
}
service.dispose();
});
test("dispose settles queued refresh and observation work and cleans late watchers", async () => {
const cwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY + 1 }, (_, index) =>
path.resolve(`/tmp/dispose-repo-${index}`),
);
const refreshGates = new Map(cwds.map((cwd) => [cwd, createDeferred<CheckoutStatusGit>()]));
const getCheckoutStatus = vi.fn((cwd: string) => {
const gate = refreshGates.get(cwd);
if (!gate) {
throw new Error(`Unexpected cwd: ${cwd}`);
}
return gate.promise;
});
const watcherGates = Array.from({ length: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY }, () =>
createDeferred<ReturnType<typeof createAsyncSubscription>>(),
);
const lateSubscriptions = watcherGates.map(() => createAsyncSubscription());
const lateUnsubscribes = lateSubscriptions.map(({ unsubscribe }) => unsubscribe);
const subscribe = vi.fn(
() => watcherGates[subscribe.mock.calls.length - 1]?.promise ?? Promise.reject(),
);
const runGitCommand = vi.fn(async (_args: string[], options: { cwd: string }) => ({
stdout: `${options.cwd}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const service = createService({ getCheckoutStatus, subscribe, runGitCommand });
cwds.forEach((cwd) => service.registerWorkspace({ cwd }, vi.fn()));
await vi.waitFor(() => {
expect(service.getMetrics()).toMatchObject({
workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY,
workspaceRefreshAdmissionPendingCount: 1,
workspaceObservationSetupAdmissionActiveCount: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
workspaceObservationSetupAdmissionPendingCount:
cwds.length - WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
});
});
const queuedRefreshSettlement = service.getSnapshot(cwds.at(-1)!).then(
() => ({ status: "fulfilled" as const, error: null }),
(error: unknown) => ({ status: "rejected" as const, error }),
);
service.dispose();
await expect(queuedRefreshSettlement).resolves.toMatchObject({
status: "rejected",
error: { name: "AbortError" },
});
await vi.waitFor(() => {
expect(service.getMetrics()).toMatchObject({
workspaceRefreshAdmissionPendingCount: 0,
workspaceObservationSetupAdmissionActiveCount: 0,
workspaceObservationSetupAdmissionPendingCount: 0,
});
});
expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY);
watcherGates.forEach((gate, index) => gate.resolve(lateSubscriptions[index]!));
refreshGates.forEach((gate, cwd) => gate.resolve(createCheckoutStatus(cwd)));
await vi.waitFor(() => {
for (const unsubscribe of lateUnsubscribes) {
expect(unsubscribe).toHaveBeenCalledTimes(1);
}
expect(service.getMetrics().workspaceRefreshAdmissionActiveCount).toBe(0);
});
});
test("cleans a watcher subscription that resolves after its deadline", async () => {
const lateWatcher = createDeferred<ReturnType<typeof createAsyncSubscription>>();
const lateSubscription = createAsyncSubscription();
const subscribe = vi
.fn()
.mockImplementationOnce(() => lateWatcher.promise)
.mockResolvedValue(createAsyncSubscription());
const service = createService({ subscribe });
await service.getSnapshot(REPO_CWD, { includeForge: false });
const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(subscribe).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS);
await vi.waitFor(() => {
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
});
lateWatcher.resolve(lateSubscription);
await vi.waitFor(() => {
expect(lateSubscription.unsubscribe).toHaveBeenCalledTimes(1);
});
workspaceSubscription.unsubscribe();
service.dispose();
expect(lateSubscription.unsubscribe).toHaveBeenCalledTimes(1);
});
test("rejects new work after disposal", async () => {
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd));
const runGitCommand = vi.fn();
const service = createService({ getCheckoutSnapshotFacts, runGitCommand });
service.dispose();
expect(() => service.registerWorkspace({ cwd: REPO_CWD }, vi.fn())).toThrow(
"WorkspaceGitService is disposed",
);
await expect(service.getSnapshot(REPO_CWD)).rejects.toThrow("WorkspaceGitService is disposed");
await expect(service.refresh(REPO_CWD)).rejects.toThrow("WorkspaceGitService is disposed");
await expect(service.requestWorkingTreeWatch(REPO_CWD, vi.fn())).rejects.toThrow(
"WorkspaceGitService is disposed",
);
expect(() => service.scheduleRefreshForCwd(REPO_CWD)).toThrow(
"WorkspaceGitService is disposed",
);
expect(() => service.onWorkspaceStateMayHaveChanged(REPO_CWD)).toThrow(
"WorkspaceGitService is disposed",
);
expect(() => service.invalidateForge(REPO_CWD)).toThrow("WorkspaceGitService is disposed");
expect(getCheckoutSnapshotFacts).not.toHaveBeenCalled();
expect(runGitCommand).not.toHaveBeenCalled();
});
test("multiple listeners on the same workspace share one observation setup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd));
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import parcelWatcher from "@parcel/watcher";
import { LRUCache } from "lru-cache";
import pLimit from "p-limit";
import type pino from "pino";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
@@ -58,6 +59,11 @@ const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000;
// Keep whole workspace pipelines below the lower-level Git process pool so daemon control work
// retains subprocess and event-loop headroom during large workspace reconciliation bursts.
export const WORKSPACE_GIT_REFRESH_CONCURRENCY = 4;
export const WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY = 2;
export const WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS = 10_000;
const WATCH_RECOVERY_BASE_DELAY_MS = 30_000;
const WATCH_RECOVERY_MAX_ATTEMPTS = 3;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
@@ -196,6 +202,10 @@ export interface WorkspaceGitServiceMetrics {
workingTreeWatchSetupInFlightCount: number;
workspaceRefreshInFlightCount: number;
workspaceRefreshQueuedCount: number;
workspaceRefreshAdmissionActiveCount: number;
workspaceRefreshAdmissionPendingCount: number;
workspaceObservationSetupAdmissionActiveCount: number;
workspaceObservationSetupAdmissionPendingCount: number;
fetchInFlightCount: number;
snapshotUpdatedListenerCount: number;
watcherErrorCallbackCount: number;
@@ -340,6 +350,22 @@ export function subscribeToWorkspaceFileChanges(
});
}
class WorkspaceGitServiceDisposedError extends Error {
constructor() {
super("WorkspaceGitService is disposed");
this.name = "WorkspaceGitServiceDisposedError";
}
}
class WorkspaceGitWatcherSubscriptionTimeoutError extends Error {
constructor(watchPath: string) {
super(
`Watcher subscription for ${watchPath} timed out after ${WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS}ms`,
);
this.name = "WorkspaceGitWatcherSubscriptionTimeoutError";
}
}
interface WorkspaceGitTarget {
cwd: string;
listeners: Set<WorkspaceGitListener>;
@@ -460,6 +486,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly worktreesRoot: string | undefined;
private readonly deps: WorkspaceGitServiceDependencies;
private readonly forgeResolver: ForgeResolver;
private readonly workspaceRefreshLimit = pLimit({
concurrency: WORKSPACE_GIT_REFRESH_CONCURRENCY,
rejectOnClear: true,
});
private readonly workspaceObservationSetupLimit = pLimit({
concurrency: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
rejectOnClear: true,
});
private readonly disposeController = new AbortController();
private disposed = false;
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
@@ -507,6 +543,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
resolveForge(cwd: string): Promise<ForgeResolution | null> {
this.assertNotDisposed();
return this.forgeResolver.resolve(resolve(cwd));
}
@@ -514,6 +551,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription {
this.assertNotDisposed();
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
@@ -533,6 +571,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription {
this.assertNotDisposed();
this.snapshotUpdatedListeners.add(listener);
return {
unsubscribe: () => {
@@ -583,6 +622,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workingTreeWatchSetupInFlightCount: this.workingTreeWatchSetups.size,
workspaceRefreshInFlightCount,
workspaceRefreshQueuedCount,
workspaceRefreshAdmissionActiveCount: this.workspaceRefreshLimit.activeCount,
workspaceRefreshAdmissionPendingCount: this.workspaceRefreshLimit.pendingCount,
workspaceObservationSetupAdmissionActiveCount:
this.workspaceObservationSetupLimit.activeCount,
workspaceObservationSetupAdmissionPendingCount:
this.workspaceObservationSetupLimit.pendingCount,
fetchInFlightCount,
snapshotUpdatedListenerCount: this.snapshotUpdatedListeners.size,
watcherErrorCallbackCount: this.watcherErrorCallbackCount,
@@ -593,6 +638,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot> {
this.assertNotDisposed();
cwd = resolve(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
@@ -604,6 +650,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
@@ -640,6 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
@@ -689,6 +737,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
ref: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const normalizedRef = ref.trim();
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
@@ -698,6 +747,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const normalizedBranch = branch.trim();
const ref = `refs/heads/${normalizedBranch}`;
@@ -717,6 +767,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitBranchSuggestionsOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchSuggestion[]> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const query = options?.query ?? "";
const limit = options?.limit;
@@ -731,6 +782,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]> {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const paseoOnly = options?.paseoOnly !== false;
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
@@ -747,6 +799,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitWorktreeInfo[]> {
this.assertNotDisposed();
const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options);
const key = JSON.stringify(["worktrees", repoRoot]);
return this.readAuxiliaryCache(this.worktreeListCache, key, options, () =>
@@ -773,6 +826,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<string> {
this.assertNotDisposed();
const cwd = resolve(cwdOrRepoRoot);
const key = JSON.stringify(["default-branch", cwd]);
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
@@ -798,6 +852,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, {
@@ -816,6 +871,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = await this.ensureWorkingTreeWatchTarget(cwd);
target.listeners.add(onChange);
@@ -829,6 +885,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
scheduleRefreshForCwd(cwd: string): void {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
@@ -837,6 +894,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
this.assertNotDisposed();
const normalizedCwd = resolve(cwd);
const target = this.workspaceTargets.get(normalizedCwd);
if (!target || target.closed) {
@@ -856,10 +914,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
* git mutations to force a fresh forge status on the next refresh.
*/
invalidateForge(cwd: string): void {
this.assertNotDisposed();
this.forgeResolver.invalidate(resolve(cwd));
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.disposeController.abort(new WorkspaceGitServiceDisposedError());
this.workspaceRefreshLimit.clearQueue();
this.workspaceObservationSetupLimit.clearQueue();
for (const target of this.workspaceTargets.values()) {
this.closeWorkspaceTarget(target);
}
@@ -880,7 +947,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.snapshotUpdatedListeners.clear();
}
private assertNotDisposed(): void {
if (this.disposed) {
throw new WorkspaceGitServiceDisposedError();
}
}
private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
this.assertNotDisposed();
const existingTarget = this.workspaceTargets.get(cwd);
if (existingTarget) {
return existingTarget;
@@ -895,6 +969,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options: WorkspaceGitReadOptions | undefined,
load: () => Promise<T>,
): Promise<T> {
this.assertNotDisposed();
if (options?.force && !options.reason) {
throw new Error("WorkspaceGitService forced read requires a reason");
}
@@ -951,6 +1026,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private ensureWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
this.assertNotDisposed();
const targetCwd = this.workingTreeWatchAliases.get(cwd);
if (targetCwd) {
const existingTarget = this.workingTreeWatchTargets.get(targetCwd);
@@ -1067,9 +1143,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
target.observationSetupPromise = Promise.resolve()
.then(() => this.setupWorkspaceObservation(target))
target.observationSetupPromise = this.workspaceObservationSetupLimit(async () => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
await this.setupWorkspaceObservation(target);
})
.catch((error) => {
if (this.disposed || !this.isActiveObservedWorkspaceTarget(target)) {
return;
}
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to set up workspace git observation",
@@ -1183,6 +1266,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchTargets.set(cwd, target);
this.workingTreeWatchAliases.set(cwd, cwd);
await this.startWorkingTreeSubscription(target);
this.assertNotDisposed();
if (repoRoot === null) {
this.startWorkingTreeWatchFallback(target, "not_a_git_checkout");
@@ -1191,6 +1275,95 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return target;
}
private async subscribeWithDeadline(
watchPath: string,
callback: parcelWatcher.SubscribeCallback,
options: parcelWatcher.Options,
onSubscribeSettled: () => void,
shouldAbandonSubscription: () => boolean,
): Promise<parcelWatcher.AsyncSubscription> {
this.assertNotDisposed();
const signal = this.disposeController.signal;
let outcome: "pending" | "accepted" | "expired" = "pending";
let timeout: NodeJS.Timeout | null = null;
let removeAbortListener = () => {};
let unsubscribePromise: Promise<void> | null = null;
let subscriptionPromise: Promise<parcelWatcher.AsyncSubscription>;
try {
subscriptionPromise = this.deps
.subscribe(watchPath, callback, options)
.finally(onSubscribeSettled);
} catch (error) {
onSubscribeSettled();
throw error;
}
void subscriptionPromise.then(
(subscription) => {
if ((outcome === "expired" || signal.aborted) && !shouldAbandonSubscription()) {
unsubscribePromise ??= this.unsubscribeWatcherSubscription(subscription, watchPath);
return unsubscribePromise;
}
return undefined;
},
() => undefined,
);
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
outcome = "expired";
reject(new WorkspaceGitWatcherSubscriptionTimeoutError(watchPath));
}, WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS);
});
const disposalPromise = new Promise<never>((_resolve, reject) => {
const rejectForDisposal = () => {
outcome = "expired";
reject(signal.reason);
};
if (signal.aborted) {
rejectForDisposal();
return;
}
signal.addEventListener("abort", rejectForDisposal, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", rejectForDisposal);
});
try {
const subscription = await Promise.race([
subscriptionPromise,
timeoutPromise,
disposalPromise,
]);
if (signal.aborted) {
outcome = "expired";
if (!shouldAbandonSubscription()) {
unsubscribePromise ??= this.unsubscribeWatcherSubscription(subscription, watchPath);
await unsubscribePromise;
}
throw signal.reason;
}
outcome = "accepted";
return subscription;
} finally {
if (outcome === "pending") {
outcome = "expired";
}
if (timeout) {
clearTimeout(timeout);
}
removeAbortListener();
}
}
private async unsubscribeWatcherSubscription(
subscription: parcelWatcher.AsyncSubscription,
watchPath: string,
): Promise<void> {
try {
await subscription.unsubscribe();
} catch (error) {
this.logger.warn({ err: error, watchPath }, "Failed to stop watcher subscription");
}
}
private async startWorkingTreeSubscription(
target: WorkingTreeWatchTarget,
options?: { replaceFallback?: boolean },
@@ -1198,8 +1371,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const ignore = [join(target.watchPath, ".git"), ...target.ignoredDirectories];
let watcherErrored = false;
let subscribeSettled = false;
const markSubscribeSettled = () => {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleWorkingTreeWatchRecovery(target);
}
};
try {
const subscription = await this.deps.subscribe(
const subscription = await this.subscribeWithDeadline(
target.watchPath,
(error, events) => {
if (error) {
@@ -1227,10 +1406,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.notifyWorkingTreeChanged(target, "working-tree-watch");
},
{ ignore },
markSubscribeSettled,
() => watcherErrored,
);
subscribeSettled = true;
if (watcherErrored) {
this.scheduleWorkingTreeWatchRecovery(target);
return false;
}
if (
@@ -1238,7 +1417,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
(target.fallbackPolling && !options?.replaceFallback) ||
target.subscription
) {
await subscription.unsubscribe();
await this.unsubscribeWatcherSubscription(subscription, target.watchPath);
return false;
}
target.subscription = subscription;
@@ -1251,11 +1430,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
return true;
} catch (error) {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleWorkingTreeWatchRecovery(target);
return false;
}
if (this.disposed || target.closed) {
throw error;
}
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to start working tree watcher; using degraded polling",
@@ -1271,7 +1451,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason,
): void {
if (target.fallbackPolling) {
if (this.disposed || target.closed || target.fallbackPolling) {
return;
}
target.fallbackPolling = true;
@@ -1606,8 +1786,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
let watcherErrored = false;
let subscribeSettled = false;
const markSubscribeSettled = () => {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleRepoMetadataWatchRecovery(target);
}
};
try {
const subscription = await this.deps.subscribe(
const subscription = await this.subscribeWithDeadline(
target.repoGitRoot,
(error, events) => {
if (error) {
@@ -1644,10 +1830,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
},
{ ignore },
markSubscribeSettled,
() => watcherErrored,
);
subscribeSettled = true;
if (watcherErrored) {
this.scheduleRepoMetadataWatchRecovery(target);
return false;
}
if (
@@ -1655,7 +1841,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
(target.fallbackPolling && !options?.replaceFallback) ||
this.repoTargets.get(target.repoGitRoot) !== target
) {
await subscription.unsubscribe();
await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
return false;
}
target.subscription = subscription;
@@ -1668,11 +1854,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
return true;
} catch (error) {
subscribeSettled = true;
if (watcherErrored) {
this.scheduleRepoMetadataWatchRecovery(target);
return false;
}
if (this.disposed || target.closed) {
throw error;
}
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot },
"Failed to start repository metadata watcher; using degraded polling",
@@ -2280,6 +2467,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try {
await this.requestWorkspaceSnapshot(target, request);
} catch (error) {
if (this.disposed || target.closed) {
return;
}
this.logger.warn(
{ err: error, cwd: target.cwd, reason: request.reason },
"Failed to refresh workspace git snapshot",
@@ -2419,7 +2609,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
});
}
try {
snapshot = await this.refreshSnapshot(target, request);
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return null;
}
return this.refreshSnapshot(target, request);
});
if (!admittedSnapshot) {
break;
}
snapshot = admittedSnapshot;
this.rememberSnapshot(target, snapshot, {
notify: request.notify,
forceEmit: request.force,
@@ -2429,6 +2628,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
failure = { error };
}
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
break;
}
const state = target.refreshState;
if (state.status !== "in-flight" || !state.queued) {
break;
+3 -3
View File
@@ -38,13 +38,13 @@ export async function terminateWithTreeKill(
}
const exitPromise = waitForProcessExit(child);
await signalTreeOrChild(child, options.gracefulSignal ?? "SIGTERM");
await signalProcessTree(child, options.gracefulSignal ?? "SIGTERM");
if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
return "terminated";
}
options.onForceSignal?.();
await signalTreeOrChild(child, options.forceSignal ?? "SIGKILL");
await signalProcessTree(child, options.forceSignal ?? "SIGKILL");
if (options.forceTimeoutMs === undefined) {
return "killed";
}
@@ -53,7 +53,7 @@ export async function terminateWithTreeKill(
: "kill-timeout";
}
function signalTreeOrChild(child: TreeKillTarget, signal: NodeJS.Signals): Promise<void> {
export function signalProcessTree(child: TreeKillTarget, signal: NodeJS.Signals): Promise<void> {
if (isProcessExited(child)) {
return Promise.resolve();
}