Files
Niko1221 cb73b620a2 host: ending a child that never started no longer signals the caller's own process group on macOS
On macOS (Node 24.12) child.kill() on a child whose spawn failed (no pid, the 'error' event not yet delivered) sends the signal to pid 0, which is the
caller's whole process group. folder-dialog.test.mjs's Linux case let the real portalFolder spawn a missing gdbus and end it, so on a Mac the SIGTERM
reached the test runner and every file after it was cancelled ('Promise resolution is still pending'). Linux and Windows do nothing for such a child.

- process-tree.endTree only calls child.kill() when the child has a pid; a new test runs a never-started child through endTree/endTreeThenKill in a
  group of its own and checks the caller survives (fails on the Mac without the fix, passes with it).
- folder-dialog.test.mjs: the Linux cases pass portal: async () => null like external-env.test.mjs does, so no real gdbus is started.
- load-pictures, packaged-control and the native fixture build their temp folders under the resolved temp path (/private/var on a Mac), as chat-files
  already does; the host reports resolved paths.

m1001 (M4 Mini, macOS 27.2, Node 24.12.0): npm test went from killed after 2 s (722 of 1632 run, 172 cancelled) to 1632 tests, 1522 pass, 0 fail, 110 skipped.
The time-skip tests (timeskip-real, time-skip, timeskip-fork, timeskip-compare-server, tune-and-timeskip-routes, process-tree) passed 20 of 20 runs
before the change and 20 of 20 after.
2026-11-08 01:41:16 +01:00

45 lines
2.3 KiB
JavaScript

// End a program this host started together with everything that program started (#4529 #4570).
//
// Windows: `taskkill /T` walks the tree. Linux and macOS have no such call, and signalling the child alone leaves its own children running (they are
// re-parented to init). A child started with `detached: ownGroup()` leads a process group of its own, so a signal to the negative pid reaches the
// whole group and nothing else: the group is the ownership record, no other process (a same-named one included) is ever signalled by name.
import { spawnSync } from 'node:child_process';
/** The spawn option that makes a child lead its own process group on Linux and macOS (Windows needs none: taskkill /T). */
export const ownGroup = () => process.platform !== 'win32';
/** Whether the group the child leads still has a member. */
export function groupAlive(child) {
if (process.platform === 'win32' || !child?.pid) return false;
try { process.kill(-child.pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
}
/**
* Signal the child's whole tree once. A child that does not lead its own group (it was not started with ownGroup()) is signalled alone, which is what
* every caller did before.
*/
export function endTree(child, signal = 'SIGTERM') {
if (!child) return;
try {
if (process.platform === 'win32') {
if (child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 });
return;
}
if (child.pid) {
try { process.kill(-child.pid, signal); return; } catch { /* no such group: it did not lead one, or it is gone */ }
}
// A child that never started has no pid. On macOS, kill() on it right after a failed spawn signals the caller's own process group (pid 0), so
// it is left alone: there is nothing to end.
if (child.pid) child.kill(signal);
} catch { /* already gone */ }
}
/** SIGTERM to the tree, then SIGKILL to whatever of its group is still there after `graceMs`. Returns the timer so a caller may clear it. */
export function endTreeThenKill(child, graceMs = 2000) {
endTree(child, 'SIGTERM');
if (process.platform === 'win32' || !child?.pid) return null;
const timer = setTimeout(() => { if (groupAlive(child)) endTree(child, 'SIGKILL'); }, graceMs);
timer.unref?.();
return timer;
}