diff --git a/__tests__/dev-process.test.ts b/__tests__/dev-process.test.ts index 3d44cd4..9c1a24e 100644 --- a/__tests__/dev-process.test.ts +++ b/__tests__/dev-process.test.ts @@ -575,3 +575,105 @@ describe('httpStatus — the probe that replaced `curl -o /dev/null`', () => { expect(Date.now() - started).toBeLessThan(5000); }); }); + +describe('planTermination — the only gate between a stored number and a signal', () => { + const { planTermination } = require('../src/lib/dev-process'); + const self = { pid: 50_000, ppid: 49_999 }; + + it('refuses pid 1 on POSIX: `-1` is the kill(2) broadcast, not a group', () => { + // 2026-09-23: exactly this pid, sent through the real signal path by a test, + // SIGTERMed every process of the user and rebooted the Mac. + expect(planTermination(1, 'darwin', self).kind).toBe('refuse'); + expect(planTermination(1, 'linux', self).kind).toBe('refuse'); + }); + + it('refuses the Windows system pids 0 and 4, and allows the next one', () => { + expect(planTermination(4, 'win32', self).kind).toBe('refuse'); + expect(planTermination(8, 'win32', self)).toEqual({ kind: 'taskkill', target: 8 }); + }); + + it('refuses this CLI and its parent', () => { + expect(planTermination(self.pid, 'linux', self).kind).toBe('refuse'); + expect(planTermination(self.ppid, 'linux', self).kind).toBe('refuse'); + }); + + it('refuses what a corrupted state.json can hold', () => { + for (const pid of [0, -5, 1.5, Number.NaN, '4242', null, undefined]) { + expect(planTermination(pid, 'linux', self).kind).toBe('refuse'); + } + }); + + it('plans a group signal for an ordinary pid', () => { + expect(planTermination(2, 'linux', self)).toEqual({ kind: 'group', target: 2 }); + }); +}); + +describe('killProcessGroup / terminateProcessGroup — every signal injected, none real', () => { + const { killProcessGroup, terminateProcessGroup: terminate } = require('../src/lib/dev-process'); + + /** Records instead of acting. Nothing here may reach `process.kill` or `taskkill`. */ + const fake = (aliveChecks: boolean[] = []) => { + const signals: [number, string][] = []; + const runs: { args: string[]; command: string }[] = []; + let checks = 0; + return { + options: (platform: NodeJS.Platform) => ({ + isAlive: () => aliveChecks[Math.min(checks++, aliveChecks.length - 1)] ?? false, + platform, + run: (command: string, args: string[]) => { + runs.push({ args, command }); + return { status: 0 }; + }, + signal: (pid: number, sig: string) => { + signals.push([pid, sig]); + }, + }), + runs, + signals, + }; + }; + + it('sends nothing for pid 1 on POSIX', () => { + const f = fake(); + expect(killProcessGroup(1, f.options('linux'))).toBe(false); + expect(f.signals).toEqual([]); + }); + + it('sends SIGTERM to the group of an ordinary pid on POSIX', () => { + const f = fake(); + expect(killProcessGroup(4242, f.options('linux'))).toBe(true); + expect(f.signals).toEqual([[-4242, 'SIGTERM']]); + }); + + it('uses `taskkill /T /F` on Windows — `/F` is not optional', () => { + // Measured: `taskkill /PID /T` WITHOUT `/F` fails on the children and + // leaves the port bound — a refusal, not a graceful stop. + const f = fake(); + killProcessGroup(4242, f.options('win32')); + expect(f.runs).toEqual([{ args: ['/PID', '4242', '/T', '/F'], command: 'taskkill' }]); + expect(f.signals).toEqual([]); + }); + + it('on Windows, one `/T /F` ends it — there is no gentler first step to wait out', async () => { + // alive before, gone after the first taskkill + const f = fake([true, false]); + await expect(terminate(4242, 1000, f.options('win32'))).resolves.toBe(true); + expect(f.runs).toHaveLength(1); + }); + + it('on Windows, a survivor gets a second `/T /F` and an honest false', async () => { + const f = fake([true]); + await expect(terminate(4242, 200, f.options('win32'))).resolves.toBe(false); + expect(f.runs).toHaveLength(2); + expect(f.signals).toEqual([]); + }); + + it('on POSIX, a survivor is escalated from SIGTERM to SIGKILL on the group', async () => { + const f = fake([true]); + await expect(terminate(4242, 200, f.options('linux'))).resolves.toBe(false); + expect(f.signals).toEqual([ + [-4242, 'SIGTERM'], + [-4242, 'SIGKILL'], + ]); + }); +}); diff --git a/__tests__/signal-guard.test.ts b/__tests__/signal-guard.test.ts new file mode 100644 index 0000000..87b1412 --- /dev/null +++ b/__tests__/signal-guard.test.ts @@ -0,0 +1,65 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +import { signalVerdict, takeViolations } from './support/signal-guard'; + +/** + * The guard that stops a test from signalling a process it did not spawn. + * Background in `support/signal-guard.ts`: the 2026-09-23 broadcast SIGTERM. + * + * Nothing in this file may reach a live process if the guard were broken: the + * decision is tested as a pure function, and the wiring test aims at a pid far + * above any pid_max, so a missing guard yields a harmless ESRCH, not a signal. + */ +describe('signal guard', () => { + const spawned = new Set([4242]); + + it('refuses the broadcast and own-group targets whatever was spawned', () => { + expect(signalVerdict(-1, 'SIGTERM', spawned)).toMatch(/refused/); + expect(signalVerdict(0, 'SIGTERM', spawned)).toMatch(/refused/); + }); + + it('refuses a pid or group this worker did not spawn', () => { + expect(signalVerdict(1, 'SIGTERM', spawned)).toMatch(/refused/); + expect(signalVerdict(-4243, 'SIGKILL', spawned)).toMatch(/refused/); + }); + + it('allows a spawned child and its group, and any probe with signal 0', () => { + expect(signalVerdict(4242, 'SIGTERM', spawned)).toBeNull(); + expect(signalVerdict(-4242, 'SIGKILL', spawned)).toBeNull(); + expect(signalVerdict(1, 0, spawned)).toBeNull(); + }); + + it('is installed: a real process.kill on a foreign pid is intercepted', () => { + // Without the guard this is ESRCH (no such pid) — harmless by construction. + expect(() => process.kill(-9_999_999, 'SIGTERM')).toThrow(/signal-guard/); + expect(takeViolations()).toHaveLength(1); + }); + + it('is registered for every suite in package.json', () => { + const jest = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).jest; + expect(jest.setupFilesAfterEnv).toContain('/support/signal-guard.ts'); + }); + + it('src/ builds a negative pid in exactly one place, and only from a SignalTarget', () => { + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'templates') walk(path); + } else if (path.endsWith('.ts')) { + readFileSync(path, 'utf-8') + .split('\n') + .forEach((line, i) => { + if (/process\.kill\(\s*-/.test(line) || /\bsend\(\s*-/.test(line)) offenders.push(`${path}:${i + 1}`); + }); + } + } + }; + walk(join(__dirname, '..', 'src')); + // The one allowed site is `signalGroup`, whose parameter is a `SignalTarget`. + expect(offenders).toHaveLength(1); + expect(offenders[0]).toMatch(/src[\\/]lib[\\/]dev-process\.ts:\d+$/); + }); +}); diff --git a/__tests__/support/signal-guard.ts b/__tests__/support/signal-guard.ts new file mode 100644 index 0000000..70d09ef --- /dev/null +++ b/__tests__/support/signal-guard.ts @@ -0,0 +1,86 @@ +/** + * Jest `setupFilesAfterEnv`: a test may send a real signal only to a process it spawned. + * + * Why this exists: on 2026-09-23 at 09:37 a test in `dev-process.test.ts` called + * `killProcessGroup(1, { platform: 'linux' })`. Only the platform was injected; + * the signal path stayed real, `isValidPid(1)` let it through, and + * `process.kill(-1, 'SIGTERM')` is the kill(2) broadcast — every process of the + * user. It ended every terminal and Claude session on the machine, and the Mac + * rebooted at 09:39. The comment above the call said "a pid that exists but + * cannot be signalled"; that was true of `kill(1)`, not of `kill(-1)`. + * + * Reviews did not catch it and a naming convention would not have either. This + * guard does: `process.kill` with a real signal throws unless its target (or the + * group it names) is a child this worker spawned. Probing with signal 0 stays + * allowed — it delivers nothing. + * + * Scope: signals sent from inside the Jest worker. A CLI subprocess a test + * spawns runs without this guard. + */ +import { ChildProcess } from 'child_process'; + +const SPAWNED = Symbol.for('lt-cli.signal-guard.spawned'); + +interface Registry { + [SPAWNED]?: Set; +} + +/** + * Why a signal must not be sent, or null when it may. Pure, so the guard's own + * test never has to send anything real to prove the decision. + */ +export function signalVerdict(pid: unknown, signal: unknown, spawned: ReadonlySet): null | string { + if (signal === 0) return null; + if (typeof pid !== 'number' || !Number.isInteger(pid)) return `refused: pid ${String(pid)} is not an integer`; + if (pid === 0 || pid === -1) return `refused: pid ${pid} addresses every process of a group or of the user`; + if (!spawned.has(Math.abs(pid))) { + return `refused: ${pid < 0 ? 'process group' : 'pid'} ${Math.abs(pid)} was not spawned by this test worker`; + } + return null; +} + +/** Pids of children spawned in this worker, shared across test files. */ +function spawnedRegistry(): Set { + const proto = ChildProcess.prototype as unknown as Registry & { spawn: (...a: unknown[]) => unknown }; + if (!proto[SPAWNED]) { + const spawned = new Set(); + const original = proto.spawn; + // Every async child_process API (spawn, exec, execFile, fork) and cross-spawn + // ends in ChildProcess.prototype.spawn, so recording here sees all of them. + proto.spawn = function (this: ChildProcess, ...args: unknown[]) { + const result = original.apply(this, args); + if (typeof this.pid === 'number') spawned.add(this.pid); + return result; + }; + proto[SPAWNED] = spawned; + } + return proto[SPAWNED]; +} + +const spawned = spawnedRegistry(); +const realKill = process.kill.bind(process); +const violations: string[] = []; + +process.kill = ((pid: number, signal?: number | string) => { + const verdict = signalVerdict(pid, signal ?? 'SIGTERM', spawned); + if (verdict) { + const message = `signal-guard: process.kill(${pid}, ${String(signal ?? 'SIGTERM')}) ${verdict}`; + violations.push(message); + throw new Error(message); + } + return realKill(pid, signal); +}) as typeof process.kill; + +/** Drain recorded refusals — for the guard's own wiring test only. */ +export function takeViolations(): string[] { + return violations.splice(0); +} + +// Throwing alone is not enough: code under test that wraps `process.kill` in a +// try/catch (as every kill helper in `src/` does) would swallow the refusal and +// the test would pass. So a refused signal also fails the test it happened in. +afterEach(() => { + if (violations.length === 0) return; + const found = takeViolations(); + throw new Error(`${found.length} refused signal(s) in this test:\n${found.join('\n')}`); +}); diff --git a/package.json b/package.json index e8ddb1d..70ee73e 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,9 @@ "rootDir": "__tests__", "testTimeout": 60000, "workerIdleMemoryLimit": "512MB", + "setupFilesAfterEnv": [ + "/support/signal-guard.ts" + ], "testMatch": [ "/*.test.ts" ], diff --git a/src/commands/dev/down.ts b/src/commands/dev/down.ts index c670bc3..5aa83ca 100644 --- a/src/commands/dev/down.ts +++ b/src/commands/dev/down.ts @@ -3,18 +3,26 @@ import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; import { reloadCaddy, removeProjectBlock } from '../../lib/caddy'; import { clearEnvBridge } from '../../lib/dev-env-bridge'; -import { killProcessGroup } from '../../lib/dev-process'; +import { killProcessGroup, planTermination } from '../../lib/dev-process'; import { resolveLayout } from '../../lib/dev-project'; import { clearSession, detectSlugConflict, isPidAlive, loadSession } from '../../lib/dev-state'; import { hasTestSession, tearDownTestSession } from '../../lib/dev-test-session'; import { resolveDevIdentity } from '../../lib/dev-ticket'; +import { isWindows } from '../../lib/platform'; /** * Stop the processes started by `lt dev up` and remove the project's * Caddy block. * - * - SIGTERM is sent to the detached process GROUP (negative PID) so - * children (Vite, Nest watcher) receive the signal too. + * - POSIX: SIGTERM to the detached process GROUP (negative PID), so children + * (Vite, Nest watcher) receive it too and can shut down gracefully. No + * escalation — `down` is the polite stop. + * - Windows: `taskkill /T /F`, i.e. FORCED, while `up`'s reclaim keeps the + * two-phase `terminateProcessGroup`. Not a choice: Windows has no gentle step + * (`/T` without `/F` was measured to leave the tree and its port alive), so + * shutdown hooks do not run there. Details in `killWindowsTree`. + * - Either way the pid is verified gone afterwards; a survivor is reported, + * never listed as stopped. * - The Caddy block is removed and `caddy reload` is invoked, so the * subdomain stops resolving immediately. */ @@ -44,8 +52,26 @@ const DownCommand: GluegunCommand = { stopped.push(`${name} (pid ${pid}, already dead)`); continue; } - if (killProcessGroup(pid)) stopped.push(`${name} (pid ${pid})`); - else warning(`Failed to stop ${name} (pid ${pid})`); + // A pid the plan refuses (1, this CLI, a system pid — i.e. a corrupted + // state.json) is neither signalled nor offered as a copy-paste kill hint: + // `kill -9 -1` is the broadcast that rebooted a Mac on 2026-09-23. + const plan = planTermination(pid); + if (plan.kind === 'refuse') { + warning(`Not stopping ${name}: ${plan.reason} — .lt-dev/state.json looks corrupted.`); + continue; + } + killProcessGroup(pid); + // Verify rather than assume: `killProcessGroup` reports that the signal + // was delivered, not that the process went. A compiled API with shutdown + // hooks can sit on SIGTERM while it waits for Mongo; claiming "stopped" + // then sends the user into the next `lt dev up` with a port collision + // nobody can trace back. + if (await waitForExit(pid, 3000)) { + stopped.push(`${name} (pid ${pid})`); + } else { + warning(`${name} (pid ${pid}) did not stop — it may still hold its port.`); + info(colors.dim(` Check with \`lt dev status\`; force it with ${forceKillHint(pid)}`)); + } } clearSession(layout.root); } else { @@ -93,3 +119,20 @@ const DownCommand: GluegunCommand = { }; module.exports = DownCommand; + +/** The command that actually ends a process tree on this platform. */ +function forceKillHint(pid: number): string { + // `/F` is not optional on Windows: measured, `taskkill /PID /T` without it + // fails on the children and leaves the port bound. + return isWindows() ? `\`taskkill /PID ${pid} /T /F\`` : `\`kill -9 -${pid}\``; +} + +/** Poll until `pid` is gone, or the budget runs out. */ +async function waitForExit(pid: number, budgetMs: number): Promise { + const deadline = Date.now() + budgetMs; + while (Date.now() < deadline) { + if (!isPidAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return !isPidAlive(pid); +} diff --git a/src/lib/dev-process.ts b/src/lib/dev-process.ts index 34e27ee..c3e39c2 100644 --- a/src/lib/dev-process.ts +++ b/src/lib/dev-process.ts @@ -3,7 +3,8 @@ * * - `spawnDetached`: detached child whose stdout/stderr go to a log file. * The Claude Code session does NOT block waiting for it, and `lt dev down` - * can SIGTERM the entire process group via `process.kill(-pid, …)`. + * can SIGTERM the entire process group (`killProcessGroup`, gated by + * `planTermination`). * - `probePorts`: which of a set of ports has a listener (a TCP connect, so it * answers on every platform) and — where the platform can say — who holds it. */ @@ -15,7 +16,7 @@ import { Socket } from 'net'; import { dirname } from 'path'; import { isPidAlive, isValidPid } from './dev-state'; -import { isWindows, spawnCmd } from './platform'; +import { isWindows, spawnCmd, spawnCmdSync } from './platform'; /** * Who is bound to each of `ports`, and whether that could be established at all. @@ -76,12 +77,42 @@ export interface RunChildOptions { } +/** A pid `planTermination` has cleared for signalling. Not constructible elsewhere. */ +export type SignalTarget = number & { readonly __signalTarget: true }; + export interface SpawnOptions { cwd: string; env: NodeJS.ProcessEnv; logFile: string; } +/** + * Injection points, so both termination paths stay assertable from any host + * without a single real signal leaving the test process. + */ +export interface TerminateOptions { + /** Default: `isPidAlive`. */ + isAlive?: (pid: number) => boolean; + /** Default: `process.platform`. */ + platform?: NodeJS.Platform; + /** Default: spawn the command. Injected in tests. */ + run?: (command: string, args: string[]) => { status: null | number }; + /** Default: `process.kill`. Injected in tests. */ + signal?: (pid: number, signal: NodeJS.Signals) => void; +} + +/** + * What `planTermination` decided for a pid: whom to signal, or why nobody. + * + * `group` carries the pid as a `SignalTarget`, the only type `signalGroup` + * accepts — so no code path can put a minus in front of a number that did not + * pass the plan first. + */ +export type TerminationPlan = + | { kind: 'group'; target: SignalTarget } + | { kind: 'refuse'; reason: string } + | { kind: 'taskkill'; target: SignalTarget }; + /** * How a detached child is actually launched on this platform. * @@ -192,20 +223,45 @@ export function isPortBound(port: number, timeoutMs = 700): Promise { }); } -/** Send SIGTERM to a detached process group; falls back to single-PID kill. */ -export function killProcessGroup(pid: number): boolean { - if (!isValidPid(pid)) return false; - try { - process.kill(-pid, 'SIGTERM'); - return true; - } catch { - try { - process.kill(pid, 'SIGTERM'); - return true; - } catch { - return false; - } - } +/** + * End a detached process tree: SIGTERM to its group on POSIX (single-PID + * fallback), `taskkill /T /F` on Windows — forceful there, see `killWindowsTree`. + * + * Returns false without signalling anything when `planTermination` refuses the pid. + */ +export function killProcessGroup(pid: number, options: TerminateOptions = {}): boolean { + const plan = planTermination(pid, options.platform); + if (plan.kind === 'refuse') return false; + if (plan.kind === 'taskkill') return killWindowsTree(plan.target, options); + return signalGroup(plan.target, 'SIGTERM', options); +} + +/** + * Decide whether `pid` may be signalled, and how. Pure — the only gate between + * a number read from disk (`state.json`) or from `lsof` and a real signal. + * + * Refused, and why each one matters: + * - **Not a plausible pid** (`isValidPid`): 0, negative, fractional, NaN. + * - **pid 1** on POSIX: `-1` is not a process group, it is the kill(2) + * BROADCAST — every process this user may signal. On 2026-09-23 a test called + * `killProcessGroup(1)`; the SIGTERM took down every terminal and session and + * the Mac rebooted two minutes later. A corrupted `state.json` holding `1` + * would do the same through `lt dev down`. And pid 1 itself is launchd/init. + * - **pid ≤ 4** on Windows: 0 is the idle process, 4 is `System`. + * - **This process and its parent**: a group signal to either reaches the CLI + * itself and whatever launched it. + */ +export function planTermination( + pid: unknown, + platform: NodeJS.Platform = process.platform, + self: { pid: number; ppid: number } = { pid: process.pid, ppid: process.ppid }, +): TerminationPlan { + if (!isValidPid(pid)) return { kind: 'refuse', reason: `not a valid pid: ${String(pid)}` }; + const windows = isWindows(platform); + if (pid <= (windows ? 4 : 1)) return { kind: 'refuse', reason: `pid ${pid} is a system process` }; + if (pid === self.pid || pid === self.ppid) return { kind: 'refuse', reason: `pid ${pid} is this CLI or its parent` }; + const target = pid as SignalTarget; + return windows ? { kind: 'taskkill', target } : { kind: 'group', target }; } /** @@ -385,32 +441,37 @@ export function spawnDetached( * (only a hung process waits the full `graceMs`). Returns true if the process * is gone by the end, false if it somehow survived even SIGKILL. */ -export async function terminateProcessGroup(pid: number, graceMs = 4000): Promise { - if (!isValidPid(pid)) return false; - if (!isPidAlive(pid)) return true; +export async function terminateProcessGroup( + pid: number, + graceMs = 4000, + options: TerminateOptions = {}, +): Promise { + const plan = planTermination(pid, options.platform); + if (plan.kind === 'refuse') return false; + const isAlive = options.isAlive ?? isPidAlive; + if (!isAlive(plan.target)) return true; // Phase 1 — graceful: SIGTERM the group (single-PID fallback inside). - killProcessGroup(pid); + // On Windows there is no such thing: `killProcessGroup` is already `/T /F` + // there, so the two phases collapse into one. The polling stays, because the + // RETURN VALUE still has to be honest about whether the pid actually went. + killProcessGroup(plan.target, options); const deadline = Date.now() + Math.max(0, graceMs); while (Date.now() < deadline) { - if (!isPidAlive(pid)) return true; + if (!isAlive(plan.target)) return true; await delay(150); } // Phase 2 — forced: SIGKILL the group, then the single PID. - if (!isPidAlive(pid)) return true; - try { - process.kill(-pid, 'SIGKILL'); - } catch { - /* group already gone or pid is not a group leader */ - } - try { - process.kill(pid, 'SIGKILL'); - } catch { - /* already dead */ + if (!isAlive(plan.target)) return true; + if (plan.kind === 'taskkill') { + // Nothing harder exists; a second `/T /F` is the only escalation there is. + killWindowsTree(plan.target, options); + } else { + signalGroup(plan.target, 'SIGKILL', options); } await delay(150); - return !isPidAlive(pid); + return !isAlive(plan.target); } /** @@ -481,6 +542,38 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Windows has no process groups in this sense, and **no gentle step.** + * + * `taskkill /PID /T` without `/F` was measured on a Windows laptop (a node + * parent with two children holding port 3999): it fails on the children ("must be + * forcefully terminated") and leaves the port bound — i.e. it looks like a refusal, + * not like a graceful stop. Only `/T /F` actually ends the tree and frees the + * port. So on Windows this function is forceful where its POSIX twin is polite, + * and two consequences follow that a caller has to know: + * + * - **Shutdown hooks do not run.** No SIGTERM handler, no `onApplicationShutdown`, + * no ordered close of a Mongo connection. In practice the components write + * nothing on exit that the next start does not rebuild, but "in practice" is not + * "never": a project that holds something open at shutdown gets no chance there. + * - **`/T` walks the child TREE from a snapshot, not a process group.** A + * grandchild that has been re-parented (its parent exited first) is no longer in + * that tree and survives. The negative-PID kill on POSIX has no such hole, + * because group membership is inherited and does not change when a parent dies. + * + * The pid is the one `spawnDetached` recorded. On Windows that is cross-spawn's + * `cmd.exe`, and the package manager plus everything it started hang below it — so + * the tree walk should reach them. That part is NOT yet measured on a real + * `lt dev` stack — only on the synthetic tree above. + */ +function killWindowsTree(target: SignalTarget, options: TerminateOptions): boolean { + const run = options.run ?? ((command: string, args: string[]) => spawnCmdSync(command, args, { stdio: 'ignore' })); + const isAlive = options.isAlive ?? isPidAlive; + const result = run('taskkill', ['/PID', String(target), '/T', '/F']); + // taskkill exits non-zero when the pid is already gone, which is success for us. + return result.status === 0 || !isAlive(target); +} + /** `lsof` path. Parses the positional column layout. */ async function lsofPortOwners( ports: number[], @@ -520,6 +613,27 @@ async function portOwners( return isWindows(options.platform) ? windowsPortOwners(ports, capture) : lsofPortOwners(ports, capture); } +/** + * The one place in `src/` that signals a negative pid. It accepts only a + * `SignalTarget`, i.e. a pid `planTermination` has cleared — never a raw number. + * Returns true when either the group or the single pid took the signal. + */ +function signalGroup(target: SignalTarget, signal: NodeJS.Signals, options: TerminateOptions): boolean { + const send = options.signal ?? ((pid: number, sig: NodeJS.Signals) => process.kill(pid, sig)); + try { + send(-target, signal); + return true; + } catch { + // Not a group leader, or the group is gone: fall back to the pid itself. + try { + send(target, signal); + return true; + } catch { + return false; + } + } +} + /** `netstat -ano` + `tasklist` path. */ async function windowsPortOwners( ports: number[],