mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 19:30:22 +00:00
fix(process): harden graceful kill-tree cancellation semantics
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user