File size: 4,724 Bytes
c0af099 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | import { spawnSync } from "node:child_process";
import process from "node:process";
/**
* Return true while Node still considers the child process active.
*
* Do not use ChildProcess#killed for cleanup decisions. In Node, `killed`
* only means a signal was sent successfully; it does not mean the process has
* exited. That distinction matters for dev launchers because uvx/npm
* wrappers can receive SIGTERM while their long-running child process keeps
* serving on the original port.
*/
export function isProcessRunning(proc) {
return proc.exitCode === null && proc.signalCode === null;
}
/**
* Add spawn options needed for safe service launches and process-tree cleanup.
*
* Arguments must bypass shell parsing so values such as version constraints
* containing `<` are forwarded literally. Callers that need shell behavior
* must invoke the shell explicitly as the command.
*
* On POSIX, `detached: true` makes the spawned service the leader of a new
* process group. Later we can signal `-pid` to terminate that whole group,
* including wrapper chains like:
*
* launcher -> uvx -> python agent-server
* launcher -> npm -> sh -> Vite
*
* Windows does not support POSIX process groups, so callers fall back to
* signaling the direct child process there.
*/
export function getProcessTreeSpawnOptions(options = {}) {
return {
...options,
shell: false,
detached: process.platform !== "win32",
};
}
/**
* Resolve a service command to a directly spawnable target on Windows.
*
* Services spawn without a shell so argument values reach the child verbatim.
* Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like
* `agent-client-protocol<0.11` is read as `<` input redirection and the spawn
* dies with "The system cannot find the file specified." Resolving to an
* absolute path lets callers spawn it shell-free.
*
* Returns `command` unchanged off Windows, when already a path, or if the lookup
* fails.
*/
export function resolveWindowsCommand(
command,
platform = process.platform,
lookup = whereCommandLookup,
) {
if (platform !== "win32") {
return command;
}
if (command.includes("/") || command.includes("\\")) {
return command;
}
return lookup(command) || command;
}
function whereCommandLookup(command) {
const result = spawnSync("where.exe", [command], { encoding: "utf8" });
if (result.status !== 0 || !result.stdout) {
return null;
}
return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null;
}
/**
* Signal the whole spawned service tree when possible.
*
* POSIX `process.kill(-pid, signal)` targets the process group whose id is
* `pid`; this only works because services are spawned with
* `getProcessTreeSpawnOptions()`. Without the negative pid, shutdown would
* often stop only the wrapper process and leave the actual server child
* listening on its port.
*/
export function signalProcessTree(proc, signal) {
if (!isProcessRunning(proc)) {
return false;
}
try {
if (process.platform === "win32" && proc.pid) {
killWindowsProcessTree(proc, signal);
} else if (!proc.pid) {
proc.kill(signal);
} else {
process.kill(-proc.pid, signal);
}
return true;
} catch (err) {
if (err?.code === "ESRCH") {
return false;
}
throw err;
}
}
/**
* Windows has no POSIX process groups: ChildProcess#kill reaches only the
* direct child (e.g. the uvx wrapper), leaving grandchildren — the actual
* python agent-server holding its port — running. `taskkill /t` walks the
* child tree instead. Windows also has no graceful tree signal (taskkill
* without /f posts WM_CLOSE, which console processes ignore), so SIGTERM and
* SIGKILL both map to the same forceful /f kill; callers' delayed SIGKILL
* pass skips already-exited trees via isProcessRunning, so the repeat is a
* no-op. A non-zero taskkill exit just means the tree already exited — only
* a failure to spawn taskkill itself falls back to the direct kill.
*/
function killWindowsProcessTree(proc, signal) {
const result = spawnSync(
"taskkill",
["/pid", String(proc.pid), "/t", "/f"],
// windowsHide avoids a console window flash when invoked from the
// packaged (GUI) Electron process.
{ stdio: "ignore", windowsHide: true },
);
if (result.error) {
proc.kill(signal);
}
}
export function createShutdownHookRegistry(onError) {
const hooks = new Set();
return {
add(hook) {
hooks.add(hook);
return () => hooks.delete(hook);
},
run() {
for (const hook of hooks) {
try {
hook();
} catch (err) {
onError?.(err);
}
}
},
};
}
|