mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Use tree-kill for daemon shutdown (#788)
* Use tree-kill for daemon shutdown * fix: update lockfile signatures and Nix hash * Use tree-kill for daemon shutdown --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -124,7 +124,7 @@ jobs:
|
|||||||
src/utils/spawn.launch-regression.test.ts
|
src/utils/spawn.launch-regression.test.ts
|
||||||
src/utils/spawn.percent-escape.test.ts
|
src/utils/spawn.percent-escape.test.ts
|
||||||
src/utils/spawn.test.ts
|
src/utils/spawn.test.ts
|
||||||
src/utils/process-tree.test.ts
|
src/utils/tree-kill.test.ts
|
||||||
src/utils/run-git-command.test.ts
|
src/utils/run-git-command.test.ts
|
||||||
src/utils/checkout-git-rev-parse.test.ts
|
src/utils/checkout-git-rev-parse.test.ts
|
||||||
src/terminal/worker-terminal-manager.test.ts
|
src/terminal/worker-terminal-manager.test.ts
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-uwVCaND/FlOpcwB4/XOsyMi9moD/UuPQjMNOgswmz1U=";
|
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -36354,7 +36354,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
@@ -38681,6 +38680,7 @@
|
|||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
@@ -38878,6 +38878,7 @@
|
|||||||
"sherpa-onnx": "1.12.28",
|
"sherpa-onnx": "1.12.28",
|
||||||
"sherpa-onnx-node": "1.12.28",
|
"sherpa-onnx-node": "1.12.28",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||||
|
import treeKill from "tree-kill";
|
||||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||||
|
|
||||||
export interface DaemonStartOptions {
|
export interface DaemonStartOptions {
|
||||||
@@ -272,27 +273,40 @@ function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
|
async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> {
|
||||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
return new Promise((resolve, reject) => {
|
||||||
return signalProcessSafely(pid, signal);
|
treeKill(pid, signal, (err) => {
|
||||||
}
|
if (!err) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = readNodeErrnoCode(err);
|
||||||
|
if (code === "ESRCH") {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === "EPERM") {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signalProcessTreeOrOwnerSafely(
|
||||||
|
pid: number,
|
||||||
|
signal: NodeJS.Signals,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, signal);
|
return await signalProcessTreeSafely(pid, signal);
|
||||||
return true;
|
} catch {
|
||||||
} catch (err) {
|
return signalProcessSafely(pid, signal);
|
||||||
const code = readNodeErrnoCode(err);
|
|
||||||
if (code === "ESRCH") {
|
|
||||||
return signalProcessSafely(pid, signal);
|
|
||||||
}
|
|
||||||
if (code === "EPERM") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +544,7 @@ export async function stopLocalDaemon(
|
|||||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
||||||
let forced = false;
|
let forced = false;
|
||||||
if (!lifecycleRequested) {
|
if (!lifecycleRequested) {
|
||||||
const signaled = signalProcessSafely(pid, "SIGTERM");
|
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
|
||||||
if (!signaled) {
|
if (!signaled) {
|
||||||
return {
|
return {
|
||||||
action: "not_running",
|
action: "not_running",
|
||||||
@@ -545,7 +559,7 @@ export async function stopLocalDaemon(
|
|||||||
let stopped = await waitForPidExit(pid, timeoutMs);
|
let stopped = await waitForPidExit(pid, timeoutMs);
|
||||||
if (!stopped && options.force) {
|
if (!stopped && options.force) {
|
||||||
forced = true;
|
forced = true;
|
||||||
signalProcessGroupSafely(pid, "SIGKILL");
|
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
|
||||||
stopped = await waitForPidExit(pid, killTimeoutMs);
|
stopped = await waitForPidExit(pid, killTimeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface StopResult {
|
|||||||
action: "stopped" | "not_running";
|
action: "stopped" | "not_running";
|
||||||
home: string;
|
home: string;
|
||||||
pid: string;
|
pid: string;
|
||||||
|
forced: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export async function runStopCommand(
|
|||||||
action: result.action,
|
action: result.action,
|
||||||
home: result.home,
|
home: result.home,
|
||||||
pid: result.pid === null ? "-" : String(result.pid),
|
pid: result.pid === null ? "-" : String(result.pid),
|
||||||
|
forced: result.forced,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
},
|
},
|
||||||
schema: stopResultSchema,
|
schema: stopResultSchema,
|
||||||
|
|||||||
176
packages/cli/tests/33-daemon-stop-tree-kill.test.ts
Normal file
176
packages/cli/tests/33-daemon-stop-tree-kill.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: forced daemon stop must kill descendants even when they started
|
||||||
|
* their own process group. The pid lock still points at the daemon owner; the
|
||||||
|
* child stays linked by PPID but would survive a process-group-only kill.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { $ } from "zx";
|
||||||
|
|
||||||
|
$.verbose = false;
|
||||||
|
|
||||||
|
const testEnv = {
|
||||||
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||||
|
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||||
|
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
check: () => Promise<boolean> | boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (await check()) return;
|
||||||
|
if (Date.now() >= deadline) throw new Error(message);
|
||||||
|
await sleep(50);
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPidFileNumber(filePath: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const raw = (await readFile(filePath, "utf-8")).trim();
|
||||||
|
const pid = Number.parseInt(raw, 10);
|
||||||
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killIfRunning(pid: number | null): void {
|
||||||
|
if (!pid || !isProcessRunning(pid)) return;
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== Daemon Stop Tree Kill Regression ===\n");
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
console.log("Skipping separate process-group regression on Windows");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-tree-kill-"));
|
||||||
|
const childPidPath = join(paseoHome, "descendant.pid");
|
||||||
|
let ownerProcess: ChildProcess | null = null;
|
||||||
|
let descendantPid: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(paseoHome, { recursive: true });
|
||||||
|
|
||||||
|
console.log("Test 1: start daemon-owner fixture with a detached descendant");
|
||||||
|
ownerProcess = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"-e",
|
||||||
|
`
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
process.on("SIGTERM", () => {});
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
"-e",
|
||||||
|
${JSON.stringify(`
|
||||||
|
const fs = require("node:fs");
|
||||||
|
process.on("SIGTERM", () => {});
|
||||||
|
fs.writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`)}
|
||||||
|
], { detached: true, stdio: "ignore" });
|
||||||
|
child.unref();
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(ownerProcess.pid, "owner pid should exist");
|
||||||
|
await writeFile(
|
||||||
|
join(paseoHome, "paseo.pid"),
|
||||||
|
JSON.stringify({
|
||||||
|
pid: ownerProcess.pid,
|
||||||
|
listen: "127.0.0.1:1",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
async () => {
|
||||||
|
descendantPid = await readPidFileNumber(childPidPath);
|
||||||
|
return (
|
||||||
|
isProcessRunning(ownerProcess?.pid ?? -1) &&
|
||||||
|
descendantPid !== null &&
|
||||||
|
isProcessRunning(descendantPid)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
"owner descendant did not become running in time",
|
||||||
|
);
|
||||||
|
console.log(`✓ owner ${ownerProcess.pid} started descendant ${descendantPid}\n`);
|
||||||
|
|
||||||
|
console.log("Test 2: forced daemon stop kills owner and separate-PGID descendant");
|
||||||
|
const stopResult =
|
||||||
|
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json --timeout 1 --force --kill-timeout 2`.nothrow();
|
||||||
|
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
|
||||||
|
const parsed = JSON.parse(stopResult.stdout) as {
|
||||||
|
action?: unknown;
|
||||||
|
forced?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
action: parsed.action,
|
||||||
|
forced: parsed.forced,
|
||||||
|
message: parsed.message,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "stopped",
|
||||||
|
forced: true,
|
||||||
|
message: "Daemon owner process was force-stopped",
|
||||||
|
},
|
||||||
|
`stop should report forced tree cleanup: ${stopResult.stdout}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ownerPid = ownerProcess.pid;
|
||||||
|
await waitFor(
|
||||||
|
() => !isProcessRunning(ownerPid ?? -1) && !isProcessRunning(descendantPid ?? -1),
|
||||||
|
5000,
|
||||||
|
`owner (${ownerPid}) or descendant (${descendantPid}) survived forced stop`,
|
||||||
|
);
|
||||||
|
console.log("✓ forced stop killed the full process tree\n");
|
||||||
|
} finally {
|
||||||
|
killIfRunning(ownerProcess?.pid ?? null);
|
||||||
|
killIfRunning(descendantPid);
|
||||||
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== Daemon stop tree kill regression test passed ===");
|
||||||
@@ -112,4 +112,46 @@ describe("daemon-manager commands", () => {
|
|||||||
|
|
||||||
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes running desktop daemon stops through CLI daemon stop", async () => {
|
||||||
|
mocks.runCliJsonCommand
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
localDaemon: "running",
|
||||||
|
serverId: "server-1",
|
||||||
|
pid: 4242,
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
desktopManaged: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ action: "stopped" })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
localDaemon: "stopped",
|
||||||
|
serverId: "",
|
||||||
|
});
|
||||||
|
const handlers = createDaemonCommandHandlers();
|
||||||
|
|
||||||
|
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
|
||||||
|
serverId: "",
|
||||||
|
status: "stopped",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: null,
|
||||||
|
home: "/tmp/paseo-home",
|
||||||
|
version: null,
|
||||||
|
desktopManaged: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(1, ["daemon", "status", "--json"]);
|
||||||
|
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(2, [
|
||||||
|
"daemon",
|
||||||
|
"stop",
|
||||||
|
"--json",
|
||||||
|
"--timeout",
|
||||||
|
"5",
|
||||||
|
"--force",
|
||||||
|
"--kill-timeout",
|
||||||
|
"5",
|
||||||
|
]);
|
||||||
|
expect(mocks.runCliJsonCommand).toHaveBeenNthCalledWith(3, ["daemon", "status", "--json"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,11 +43,8 @@ import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.j
|
|||||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||||
|
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
const PID_POLL_INTERVAL_MS = 100;
|
|
||||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||||
const STOP_TIMEOUT_MS = 15_000;
|
|
||||||
const KILL_TIMEOUT_MS = 3_000;
|
|
||||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||||
|
|
||||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||||
@@ -136,52 +133,12 @@ function isProcessRunning(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
|
|
||||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
||||||
try {
|
|
||||||
process.kill(pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if (typeof err === "object" && err !== null && "code" in err) {
|
|
||||||
if (err.code === "ESRCH") return false;
|
|
||||||
if (err.code === "EPERM") return true;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
|
|
||||||
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
||||||
if (process.platform === "win32") return signalProcessSafely(pid, signal);
|
|
||||||
try {
|
|
||||||
process.kill(-pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if (typeof err === "object" && err !== null && "code" in err) {
|
|
||||||
if (err.code === "ESRCH") return signalProcessSafely(pid, signal);
|
|
||||||
if (err.code === "EPERM") return true;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(resolve, ms);
|
setTimeout(resolve, ms);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
async function poll(): Promise<boolean> {
|
|
||||||
if (!isProcessRunning(pid)) return true;
|
|
||||||
if (Date.now() >= deadline) return !isProcessRunning(pid);
|
|
||||||
await sleep(PID_POLL_INTERVAL_MS);
|
|
||||||
return poll();
|
|
||||||
}
|
|
||||||
return poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tailFile(filePath: string, lines = 50): string {
|
function tailFile(filePath: string, lines = 50): string {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(filePath, "utf-8");
|
const content = readFileSync(filePath, "utf-8");
|
||||||
@@ -449,19 +406,7 @@ export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
const status = await resolveDesktopDaemonStatus();
|
const status = await resolveDesktopDaemonStatus();
|
||||||
if (status.status !== "running" || !status.pid) return status;
|
if (status.status !== "running" || !status.pid) return status;
|
||||||
|
|
||||||
const pid = status.pid;
|
await stopDesktopDaemonViaCli();
|
||||||
signalProcessSafely(pid, "SIGTERM");
|
|
||||||
|
|
||||||
let stopped = await waitForPidExit(pid, STOP_TIMEOUT_MS);
|
|
||||||
if (!stopped) {
|
|
||||||
signalProcessGroupSafely(pid, "SIGKILL");
|
|
||||||
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stopped) {
|
|
||||||
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await resolveDesktopDaemonStatus();
|
return await resolveDesktopDaemonStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"sherpa-onnx": "1.12.28",
|
"sherpa-onnx": "1.12.28",
|
||||||
"sherpa-onnx-node": "1.12.28",
|
"sherpa-onnx-node": "1.12.28",
|
||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
|
"tree-kill": "^1.2.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"which": "^5.0.0",
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { terminateProcessTree } from "../../../utils/process-tree.js";
|
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||||
@@ -787,7 +787,7 @@ class CodexAppServerClient {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
const result = await terminateProcessTree(this.child, {
|
const result = await terminateWithTreeKill(this.child, {
|
||||||
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||||
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||||
onForceSignal: () => {
|
onForceSignal: () => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { terminateProcessTree } from "../../../utils/process-tree.js";
|
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||||
@@ -975,7 +975,7 @@ export class OpenCodeServerManager {
|
|||||||
if (server.process.killed) {
|
if (server.process.killed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await terminateProcessTree(server.process, {
|
const result = await terminateWithTreeKill(server.process, {
|
||||||
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||||
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||||
onForceSignal: () => {
|
onForceSignal: () => {
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
import { execFile, spawn } from "node:child_process";
|
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { signalProcessTree, terminateProcessTree, type ProcessTreeTarget } from "./process-tree.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
describe("signalProcessTree", () => {
|
|
||||||
test.each(["darwin", "linux"] as const)("signals the %s process group", (platform) => {
|
|
||||||
const child = createChild({ pid: 1234 });
|
|
||||||
const kill = vi.fn(() => true);
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGTERM", { platform, kill });
|
|
||||||
|
|
||||||
expect(kill).toHaveBeenCalledWith(-1234, "SIGTERM");
|
|
||||||
expect(child.kill).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to direct child signaling when POSIX process group signaling fails", () => {
|
|
||||||
const child = createChild({ pid: 1234 });
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGTERM", {
|
|
||||||
platform: "darwin",
|
|
||||||
kill: vi.fn(() => {
|
|
||||||
throw new Error("no process group");
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses taskkill for Windows process trees", () => {
|
|
||||||
const child = createChild({ pid: 1234 });
|
|
||||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGTERM", { platform: "win32", execFile: execFileMock });
|
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
"taskkill",
|
|
||||||
["/pid", "1234", "/T"],
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(child.kill).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forces Windows process tree cleanup for SIGKILL", () => {
|
|
||||||
const child = createChild({ pid: 1234 });
|
|
||||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
|
||||||
callback(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGKILL", { platform: "win32", execFile: execFileMock });
|
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
"taskkill",
|
|
||||||
["/pid", "1234", "/T", "/F"],
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to direct child signaling when taskkill cannot run", () => {
|
|
||||||
const child = createChild({ pid: 1234 });
|
|
||||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
|
||||||
callback(Object.assign(new Error("taskkill failed"), { code: "ENOENT" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGTERM", { platform: "win32", execFile: execFileMock });
|
|
||||||
|
|
||||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not signal an exited child", () => {
|
|
||||||
const child = createChild({ pid: 1234, exitCode: 0 });
|
|
||||||
const kill = vi.fn(() => true);
|
|
||||||
|
|
||||||
signalProcessTree(child, "SIGTERM", { platform: "darwin", kill });
|
|
||||||
|
|
||||||
expect(kill).not.toHaveBeenCalled();
|
|
||||||
expect(child.kill).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("terminateProcessTree returns after graceful exit", async () => {
|
|
||||||
const child = createEventedChild({ pid: 1234 });
|
|
||||||
const kill = vi.fn(() => true);
|
|
||||||
|
|
||||||
const resultPromise = terminateProcessTree(child, {
|
|
||||||
platform: "darwin",
|
|
||||||
gracefulTimeoutMs: 100,
|
|
||||||
forceTimeoutMs: 100,
|
|
||||||
kill,
|
|
||||||
});
|
|
||||||
child.emit("exit");
|
|
||||||
|
|
||||||
await expect(resultPromise).resolves.toBe("terminated");
|
|
||||||
expect(kill).toHaveBeenCalledWith(-1234, "SIGTERM");
|
|
||||||
expect(kill).not.toHaveBeenCalledWith(-1234, "SIGKILL");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("terminateProcessTree sends SIGKILL after graceful timeout", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const child = createEventedChild({ pid: 1234 });
|
|
||||||
const kill = vi.fn(() => true);
|
|
||||||
const onForceSignal = vi.fn();
|
|
||||||
|
|
||||||
const resultPromise = terminateProcessTree(child, {
|
|
||||||
platform: "darwin",
|
|
||||||
gracefulTimeoutMs: 100,
|
|
||||||
forceTimeoutMs: 100,
|
|
||||||
kill,
|
|
||||||
onForceSignal,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(onForceSignal).toHaveBeenCalledTimes(1);
|
|
||||||
expect(kill).toHaveBeenNthCalledWith(1, -1234, "SIGTERM");
|
|
||||||
expect(kill).toHaveBeenNthCalledWith(2, -1234, "SIGKILL");
|
|
||||||
|
|
||||||
child.emit("exit");
|
|
||||||
await expect(resultPromise).resolves.toBe("killed");
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("terminateProcessTree reports when forced cleanup does not exit", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const child = createEventedChild({ pid: 1234 });
|
|
||||||
const kill = vi.fn(() => true);
|
|
||||||
|
|
||||||
const resultPromise = terminateProcessTree(child, {
|
|
||||||
platform: "darwin",
|
|
||||||
gracefulTimeoutMs: 100,
|
|
||||||
forceTimeoutMs: 100,
|
|
||||||
kill,
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
|
||||||
|
|
||||||
await expect(resultPromise).resolves.toBe("kill-timeout");
|
|
||||||
expect(kill).toHaveBeenNthCalledWith(1, -1234, "SIGTERM");
|
|
||||||
expect(kill).toHaveBeenNthCalledWith(2, -1234, "SIGKILL");
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(process.platform === "win32")("kills a detached POSIX process group", async () => {
|
|
||||||
const child = spawn("/bin/sh", ["-c", "sleep 60 & wait"], {
|
|
||||||
detached: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
const pid = child.pid;
|
|
||||||
if (typeof pid !== "number") {
|
|
||||||
throw new Error("spawned shell did not expose a pid");
|
|
||||||
}
|
|
||||||
|
|
||||||
let childPids: number[] = [];
|
|
||||||
try {
|
|
||||||
childPids = await waitForChildren(pid);
|
|
||||||
await expect(
|
|
||||||
terminateProcessTree(child, {
|
|
||||||
gracefulTimeoutMs: 1000,
|
|
||||||
forceTimeoutMs: 1000,
|
|
||||||
}),
|
|
||||||
).resolves.toMatch(/^(terminated|killed)$/);
|
|
||||||
await waitForNoLivePids(childPids);
|
|
||||||
} finally {
|
|
||||||
cleanupPidGroup(pid);
|
|
||||||
for (const childPid of childPids) {
|
|
||||||
cleanupPid(childPid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createChild(options: { pid?: number; exitCode?: number | null } = {}): ProcessTreeTarget {
|
|
||||||
return {
|
|
||||||
pid: options.pid,
|
|
||||||
exitCode: options.exitCode ?? null,
|
|
||||||
signalCode: null,
|
|
||||||
kill: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEventedChild(options: { pid?: number }): ProcessTreeTarget & EventEmitter {
|
|
||||||
const child = new EventEmitter() as ProcessTreeTarget & EventEmitter;
|
|
||||||
child.pid = options.pid;
|
|
||||||
child.exitCode = null;
|
|
||||||
child.signalCode = null;
|
|
||||||
child.kill = vi.fn(() => true);
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForChildren(parentPid: number): Promise<number[]> {
|
|
||||||
return waitFor(async () => {
|
|
||||||
const { stdout } = await execFileAsync("pgrep", ["-P", String(parentPid)]).catch(() => ({
|
|
||||||
stdout: "",
|
|
||||||
}));
|
|
||||||
const pids = stdout
|
|
||||||
.trim()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((value) => Number.parseInt(value, 10))
|
|
||||||
.filter((value) => Number.isInteger(value) && value > 0);
|
|
||||||
return pids.length > 0 ? pids : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForNoLivePids(pids: number[]): Promise<void> {
|
|
||||||
await waitFor(() => (pids.every((pid) => !isPidAlive(pid)) ? true : null));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitFor<T>(probe: () => T | null | Promise<T | null>): Promise<T> {
|
|
||||||
const deadline = Date.now() + 3000;
|
|
||||||
let lastError: unknown;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const value = await probe();
|
|
||||||
if (value !== null) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
throw lastError instanceof Error ? lastError : new Error("timed out waiting for condition");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPidAlive(pid: number): boolean {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPidGroup(pid: number): void {
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup races
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupPid(pid: number): void {
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup races
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
|
|
||||||
export interface ProcessTreeTarget {
|
|
||||||
pid?: number;
|
|
||||||
killed?: boolean;
|
|
||||||
exitCode?: number | null;
|
|
||||||
signalCode?: NodeJS.Signals | null;
|
|
||||||
kill(signal?: NodeJS.Signals | number): boolean;
|
|
||||||
once?(event: "exit", listener: () => void): unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SignalProcessTreeOptions {
|
|
||||||
platform?: NodeJS.Platform;
|
|
||||||
kill?: (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
|
||||||
execFile?: (
|
|
||||||
file: string,
|
|
||||||
args: string[],
|
|
||||||
callback: (error: NodeJS.ErrnoException | null) => void,
|
|
||||||
) => unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminateProcessTreeOptions extends SignalProcessTreeOptions {
|
|
||||||
gracefulSignal?: NodeJS.Signals;
|
|
||||||
forceSignal?: NodeJS.Signals;
|
|
||||||
gracefulTimeoutMs: number;
|
|
||||||
forceTimeoutMs?: number;
|
|
||||||
onForceSignal?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TerminateProcessTreeResult =
|
|
||||||
| "already-exited"
|
|
||||||
| "terminated"
|
|
||||||
| "killed"
|
|
||||||
| "kill-timeout";
|
|
||||||
|
|
||||||
export async function terminateProcessTree(
|
|
||||||
child: ProcessTreeTarget,
|
|
||||||
options: TerminateProcessTreeOptions,
|
|
||||||
): Promise<TerminateProcessTreeResult> {
|
|
||||||
if (isProcessExited(child)) {
|
|
||||||
return "already-exited";
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitPromise = waitForProcessExit(child);
|
|
||||||
signalProcessTree(child, options.gracefulSignal ?? "SIGTERM", options);
|
|
||||||
if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
|
|
||||||
return "terminated";
|
|
||||||
}
|
|
||||||
|
|
||||||
options.onForceSignal?.();
|
|
||||||
signalProcessTree(child, options.forceSignal ?? "SIGKILL", options);
|
|
||||||
if (options.forceTimeoutMs === undefined) {
|
|
||||||
return "killed";
|
|
||||||
}
|
|
||||||
return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
|
|
||||||
? "killed"
|
|
||||||
: "kill-timeout";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signalProcessTree(
|
|
||||||
child: ProcessTreeTarget,
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
options: SignalProcessTreeOptions = {},
|
|
||||||
): void {
|
|
||||||
if (isProcessExited(child)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pid = child.pid;
|
|
||||||
if (typeof pid === "number" && pid > 0) {
|
|
||||||
const platform = options.platform ?? process.platform;
|
|
||||||
if (platform === "win32") {
|
|
||||||
signalWindowsProcessTree(child, pid, signal, options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
(options.kill ?? process.kill.bind(process))(-pid, signal);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// Fall back to the direct child when no separate process group exists.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
signalDirectChild(child, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalWindowsProcessTree(
|
|
||||||
child: ProcessTreeTarget,
|
|
||||||
pid: number,
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
options: SignalProcessTreeOptions,
|
|
||||||
): void {
|
|
||||||
const args = ["/pid", String(pid), "/T"];
|
|
||||||
if (signal === "SIGKILL") {
|
|
||||||
args.push("/F");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
(options.execFile ?? execFile)("taskkill", args, (error) => {
|
|
||||||
if (error) {
|
|
||||||
signalDirectChild(child, signal);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
signalDirectChild(child, signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalDirectChild(child: ProcessTreeTarget, signal: NodeJS.Signals): void {
|
|
||||||
try {
|
|
||||||
child.kill(signal);
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup races.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProcessExited(child: ProcessTreeTarget): boolean {
|
|
||||||
return (
|
|
||||||
(child.exitCode !== null && child.exitCode !== undefined) ||
|
|
||||||
(child.signalCode !== null && child.signalCode !== undefined)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForProcessExit(child: ProcessTreeTarget): Promise<void> {
|
|
||||||
if (isProcessExited(child)) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
if (!child.once) {
|
|
||||||
return new Promise(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
child.once?.("exit", resolve);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForExitOrTimeout(
|
|
||||||
exitPromise: Promise<void>,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
exitPromise.then(() => true),
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
packages/server/src/utils/tree-kill.test.ts
Normal file
184
packages/server/src/utils/tree-kill.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||||
|
|
||||||
|
const pollIntervalMs = 50;
|
||||||
|
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
let ownerProcess: ChildProcess | null = null;
|
||||||
|
let descendantPid: number | null = null;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunning(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
check: () => Promise<boolean> | boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
if (await check()) return;
|
||||||
|
if (Date.now() >= deadline) throw new Error(message);
|
||||||
|
await sleep(pollIntervalMs);
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPidFileNumber(filePath: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const raw = (await readFile(filePath, "utf-8")).trim();
|
||||||
|
const pid = Number.parseInt(raw, 10);
|
||||||
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killIfRunning(pid: number | null | undefined): void {
|
||||||
|
if (!pid || !isProcessRunning(pid)) return;
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup races.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnOwnerWithDescendant(options: {
|
||||||
|
childPidPath: string;
|
||||||
|
detachedDescendant: boolean;
|
||||||
|
}): ChildProcess {
|
||||||
|
const descendantOptions = options.detachedDescendant
|
||||||
|
? '{ detached: true, stdio: "ignore" }'
|
||||||
|
: '{ stdio: "ignore" }';
|
||||||
|
const childUnref = options.detachedDescendant ? "child.unref();" : "";
|
||||||
|
|
||||||
|
return spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"-e",
|
||||||
|
`
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
process.on("SIGTERM", () => {});
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
"-e",
|
||||||
|
${JSON.stringify(`
|
||||||
|
const fs = require("node:fs");
|
||||||
|
process.on("SIGTERM", () => {});
|
||||||
|
fs.writeFileSync(${JSON.stringify(options.childPidPath)}, String(process.pid));
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`)}
|
||||||
|
], ${descendantOptions});
|
||||||
|
${childUnref}
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFixtureReady(childPidPath: string): Promise<void> {
|
||||||
|
await waitFor(
|
||||||
|
async () => {
|
||||||
|
descendantPid = await readPidFileNumber(childPidPath);
|
||||||
|
return (
|
||||||
|
isProcessRunning(ownerProcess?.pid ?? -1) &&
|
||||||
|
descendantPid !== null &&
|
||||||
|
isProcessRunning(descendantPid)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
"owner descendant did not become running in time",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectOwnerAndDescendantStopped(message: string): Promise<void> {
|
||||||
|
await waitFor(
|
||||||
|
() => !isProcessRunning(ownerProcess?.pid ?? -1) && !isProcessRunning(descendantPid ?? -1),
|
||||||
|
5000,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
killIfRunning(ownerProcess?.pid);
|
||||||
|
killIfRunning(descendantPid);
|
||||||
|
ownerProcess = null;
|
||||||
|
descendantPid = null;
|
||||||
|
|
||||||
|
if (tempDir) {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
tempDir = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("terminateWithTreeKill", () => {
|
||||||
|
test.runIf(process.platform === "win32")(
|
||||||
|
"kills Windows descendants through taskkill tree cleanup",
|
||||||
|
async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "paseo-server-tree-kill-"));
|
||||||
|
const childPidPath = join(tempDir, "descendant.pid");
|
||||||
|
|
||||||
|
ownerProcess = spawnOwnerWithDescendant({
|
||||||
|
childPidPath,
|
||||||
|
detachedDescendant: false,
|
||||||
|
});
|
||||||
|
expect(ownerProcess.pid).toBeTypeOf("number");
|
||||||
|
await waitForFixtureReady(childPidPath);
|
||||||
|
|
||||||
|
const result = await terminateWithTreeKill(ownerProcess, {
|
||||||
|
gracefulTimeoutMs: 2000,
|
||||||
|
forceTimeoutMs: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// tree-kill uses taskkill /T /F on Windows, so the first signal is already forceful.
|
||||||
|
expect(result).toBe("terminated");
|
||||||
|
await expectOwnerAndDescendantStopped(
|
||||||
|
"owner or Windows descendant survived terminateWithTreeKill",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(process.platform !== "win32")(
|
||||||
|
"force-kills descendants that started their own process group",
|
||||||
|
async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "paseo-server-tree-kill-"));
|
||||||
|
const childPidPath = join(tempDir, "descendant.pid");
|
||||||
|
|
||||||
|
ownerProcess = spawnOwnerWithDescendant({
|
||||||
|
childPidPath,
|
||||||
|
detachedDescendant: true,
|
||||||
|
});
|
||||||
|
expect(ownerProcess.pid).toBeTypeOf("number");
|
||||||
|
await waitForFixtureReady(childPidPath);
|
||||||
|
|
||||||
|
const result = await terminateWithTreeKill(ownerProcess, {
|
||||||
|
gracefulTimeoutMs: 100,
|
||||||
|
forceTimeoutMs: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe("killed");
|
||||||
|
await expectOwnerAndDescendantStopped(
|
||||||
|
"owner or separate-process-group descendant survived terminateWithTreeKill",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
115
packages/server/src/utils/tree-kill.ts
Normal file
115
packages/server/src/utils/tree-kill.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import treeKill from "tree-kill";
|
||||||
|
|
||||||
|
export interface TreeKillTarget {
|
||||||
|
pid?: number;
|
||||||
|
exitCode?: number | null;
|
||||||
|
signalCode?: NodeJS.Signals | null;
|
||||||
|
kill(signal?: NodeJS.Signals | number): boolean;
|
||||||
|
once?(event: "exit", listener: () => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminateWithTreeKillOptions {
|
||||||
|
gracefulSignal?: NodeJS.Signals;
|
||||||
|
forceSignal?: NodeJS.Signals;
|
||||||
|
gracefulTimeoutMs: number;
|
||||||
|
forceTimeoutMs?: number;
|
||||||
|
onForceSignal?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TerminateWithTreeKillResult =
|
||||||
|
| "already-exited"
|
||||||
|
| "terminated"
|
||||||
|
| "killed"
|
||||||
|
| "kill-timeout";
|
||||||
|
|
||||||
|
export async function terminateWithTreeKill(
|
||||||
|
child: TreeKillTarget,
|
||||||
|
options: TerminateWithTreeKillOptions,
|
||||||
|
): Promise<TerminateWithTreeKillResult> {
|
||||||
|
if (isProcessExited(child)) {
|
||||||
|
return "already-exited";
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitPromise = waitForProcessExit(child);
|
||||||
|
await signalTreeOrChild(child, options.gracefulSignal ?? "SIGTERM");
|
||||||
|
if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
|
||||||
|
return "terminated";
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onForceSignal?.();
|
||||||
|
await signalTreeOrChild(child, options.forceSignal ?? "SIGKILL");
|
||||||
|
if (options.forceTimeoutMs === undefined) {
|
||||||
|
return "killed";
|
||||||
|
}
|
||||||
|
return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
|
||||||
|
? "killed"
|
||||||
|
: "kill-timeout";
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalTreeOrChild(child: TreeKillTarget, signal: NodeJS.Signals): Promise<void> {
|
||||||
|
if (isProcessExited(child)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pid = child.pid;
|
||||||
|
if (typeof pid !== "number" || pid <= 0) {
|
||||||
|
signalDirectChild(child, signal);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
treeKill(pid, signal, (error) => {
|
||||||
|
if (error) {
|
||||||
|
signalDirectChild(child, signal);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalDirectChild(child: TreeKillTarget, signal: NodeJS.Signals): void {
|
||||||
|
try {
|
||||||
|
child.kill(signal);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup races.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessExited(child: TreeKillTarget): boolean {
|
||||||
|
return (
|
||||||
|
(child.exitCode !== null && child.exitCode !== undefined) ||
|
||||||
|
(child.signalCode !== null && child.signalCode !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForProcessExit(child: TreeKillTarget): Promise<void> {
|
||||||
|
if (isProcessExited(child)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (!child.once) {
|
||||||
|
return new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
child.once?.("exit", resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExitOrTimeout(
|
||||||
|
exitPromise: Promise<void>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
exitPromise.then(() => true),
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user