fix(process): harden graceful kill-tree cancellation semantics

This commit is contained in:
Sebastian
2026-02-16 20:35:00 -05:00
parent 7b172d61cd
commit fb996031bc
4 changed files with 255 additions and 37 deletions

View File

@@ -36,9 +36,11 @@ describe("createPtyAdapter", () => {
spawnMock.mockReset();
ptyKillMock.mockReset();
killProcessTreeMock.mockReset();
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
vi.clearAllMocks();
});
@@ -79,6 +81,53 @@ describe("createPtyAdapter", () => {
expect(ptyKillMock).not.toHaveBeenCalled();
});
it("wait does not settle immediately on SIGKILL", async () => {
vi.useFakeTimers();
spawnMock.mockReturnValue(createStubPty());
const { createPtyAdapter } = await import("./pty.js");
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
const waitPromise = adapter.wait();
const settled = vi.fn();
void waitPromise.then(() => settled());
adapter.kill();
await Promise.resolve();
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3999);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await expect(waitPromise).resolves.toEqual({ code: null, signal: "SIGKILL" });
});
it("prefers real PTY exit over SIGKILL fallback settle", async () => {
vi.useFakeTimers();
const stub = createStubPty();
spawnMock.mockReturnValue(stub);
const { createPtyAdapter } = await import("./pty.js");
const adapter = await createPtyAdapter({
shell: "bash",
args: ["-lc", "sleep 10"],
});
const waitPromise = adapter.wait();
adapter.kill();
stub.emitExit({ exitCode: 0, signal: 9 });
await expect(waitPromise).resolves.toEqual({ code: 0, signal: 9 });
await vi.advanceTimersByTimeAsync(10_000);
await expect(adapter.wait()).resolves.toEqual({ code: 0, signal: 9 });
});
it("resolves wait when exit fires before wait is called", async () => {
const stub = createStubPty();
spawnMock.mockReturnValue(stub);

View File

@@ -1,7 +1,9 @@
import { killProcessTree } from "../../kill-tree.js";
import type { ManagedRunStdin } from "../types.js";
import { killProcessTree } from "../../kill-tree.js";
import { toStringEnv } from "./env.js";
const FORCE_KILL_WAIT_FALLBACK_MS = 4000;
type PtyExitEvent = { exitCode: number; signal?: number };
type PtyDisposable = { dispose: () => void };
type PtySpawnHandle = {
@@ -70,11 +72,21 @@ export async function createPtyAdapter(params: {
| null = null;
let waitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | number | null }> | null =
null;
let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null;
const clearForceKillWaitFallback = () => {
if (!forceKillWaitFallbackTimer) {
return;
}
clearTimeout(forceKillWaitFallbackTimer);
forceKillWaitFallbackTimer = null;
};
const settleWait = (value: { code: number | null; signal: NodeJS.Signals | number | null }) => {
if (waitResult) {
return;
}
clearForceKillWaitFallback();
waitResult = value;
if (resolveWait) {
const resolve = resolveWait;
@@ -83,6 +95,16 @@ export async function createPtyAdapter(params: {
}
};
const scheduleForceKillWaitFallback = (signal: NodeJS.Signals) => {
clearForceKillWaitFallback();
// Some PTY hosts fail to emit onExit after kill; use a delayed fallback
// so callers can still unblock without marking termination immediately.
forceKillWaitFallbackTimer = setTimeout(() => {
settleWait({ code: null, signal });
}, FORCE_KILL_WAIT_FALLBACK_MS);
forceKillWaitFallbackTimer.unref();
};
exitListener =
pty.onExit((event) => {
const signal = event.signal && event.signal !== 0 ? event.signal : null;
@@ -151,9 +173,10 @@ export async function createPtyAdapter(params: {
} catch {
// ignore kill errors
}
// Some PTY hosts do not emit `onExit` reliably after kill.
// Ensure waiters can progress on forced termination.
settleWait({ code: null, signal });
if (signal === "SIGKILL") {
scheduleForceKillWaitFallback(signal);
}
};
const dispose = () => {
@@ -167,6 +190,7 @@ export async function createPtyAdapter(params: {
} catch {
// ignore disposal errors
}
clearForceKillWaitFallback();
dataListener = null;
exitListener = null;
settleWait({ code: null, signal: null });