@mjasnikovs/pi-task 0.40.45 → 0.40.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,13 @@ import type { CommandKillReason } from './command-watchdog.js';
3
3
  import type { LoopHit } from '../task/loop-detector.js';
4
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
5
5
  export declare const KILL_GRACE_MS = 5000;
6
+ /**
7
+ * After a child EXITS, how long its pipes may still deliver buffered data before
8
+ * the run is reported. Not a wait for the pipes to CLOSE, which a grandchild holding
9
+ * them can put off for as long as it lives: just the turn or two the reader needs
10
+ * to hand over what it has.
11
+ */
12
+ export declare const EXIT_DRAIN_MS = 50;
6
13
  /** Base flags shared by all child pi invocations. */
7
14
  export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-extensions", "--no-prompt-templates", "--no-context-files", "--no-session"];
8
15
  /** The subset of a child's stdin we use: write the prompt, then close it. */
@@ -15,8 +22,13 @@ export interface WritableLike {
15
22
  export interface ProcLike extends EventEmitter {
16
23
  /** Present only when the child was spawned with stdin 'pipe' (prompt delivery). */
17
24
  stdin: WritableLike | null;
18
- stdout: EventEmitter | null;
19
- stderr: EventEmitter | null;
25
+ /** `destroy` is a real pipe's; a fake without one is never held open by a grandchild. */
26
+ stdout: (EventEmitter & {
27
+ destroy?: () => unknown;
28
+ }) | null;
29
+ stderr: (EventEmitter & {
30
+ destroy?: () => unknown;
31
+ }) | null;
20
32
  killed: boolean;
21
33
  /** OS pid; used to signal the child's whole process GROUP (orphan reaping). May
22
34
  * be undefined for a mock spawn or a spawn that failed. */
@@ -27,8 +39,9 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
27
39
  cwd: string;
28
40
  shell: boolean;
29
41
  stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
30
- /** Set only when the invocation needs env overrides — git-state-guard's
31
- * `GIT_INDEX_FILE` throwaway index is the one caller. Absent the child
42
+ /** Set when the invocation needs env overrides — git-state-guard's
43
+ * `GIT_INDEX_FILE` throwaway index and for every model child, which
44
+ * carries what leftovers.ts finds its descendants by. Absent → the child
32
45
  * inherits this process's environment. */
33
46
  env?: NodeJS.ProcessEnv;
34
47
  /** true → give the child its own process group (POSIX `detached`), so any
@@ -53,8 +66,8 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
53
66
  * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
54
67
  * that its descendants inherit. Either/or is load-bearing — Windows ignores
55
68
  * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
56
- * which walks the live tree and needs no flag; unlike a POSIX group kill it
57
- * cannot catch what the child left behind after it exited.
69
+ * which walks the live tree and needs no flag. Neither reap reaches what pi's bash
70
+ * tool backgrounds, since it detaches every command; leftovers.ts finds that.
58
71
  */
59
72
  export declare function ownGroupSpawnOptions(platform: NodeJS.Platform): OwnGroupSpawnOptions;
60
73
  export type OwnGroupSpawnOptions = {
@@ -64,13 +77,22 @@ export type OwnGroupSpawnOptions = {
64
77
  };
65
78
  /**
66
79
  * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
67
- * backgrounded. Best-effort: a group already gone is not an error. Kept beside
68
- * the spawn shape so a platform change edits one file.
80
+ * backgrounded that is still in its group. Best-effort: a group already gone is
81
+ * not an error. Kept beside the spawn shape so a platform change edits one file.
82
+ *
83
+ * `leaderExited` has no default: whether the leader still lives decides whether its
84
+ * pid is still its own. Returns whether a reap went out.
69
85
  */
70
- export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }?: {
86
+ export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }: {
71
87
  platform?: NodeJS.Platform;
72
- leaderExited?: boolean;
73
- }): void;
88
+ leaderExited: boolean;
89
+ }): boolean;
90
+ /**
91
+ * The sweep every runner makes once its leader exited: SIGTERM the group now, and
92
+ * SIGKILL what ignored it after the grace. A group whose last member is gone
93
+ * answers ESRCH, and POSIX keeps the pgid reserved while any member lives.
94
+ */
95
+ export declare function reapGroupAfterExit(pid: number, platform?: NodeJS.Platform): void;
74
96
  /**
75
97
  * Why runChild killed the child. Five sources converge on one kill path, and
76
98
  * each names itself here rather than in its own flag — so a consumer reads ONE
@@ -1,10 +1,17 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
- import * as path from 'node:path';
2
+ import { system32, trackLeftovers } from './leftovers.js';
3
3
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
4
4
  import { realStallTimerDeps, StallProbe } from './stall-probe.js';
5
5
  import { workerChannel } from '../workers/worker-channels.js';
6
6
  /** Grace period between SIGTERM and SIGKILL (ms). */
7
7
  export const KILL_GRACE_MS = 5000;
8
+ /**
9
+ * After a child EXITS, how long its pipes may still deliver buffered data before
10
+ * the run is reported. Not a wait for the pipes to CLOSE, which a grandchild holding
11
+ * them can put off for as long as it lives: just the turn or two the reader needs
12
+ * to hand over what it has.
13
+ */
14
+ export const EXIT_DRAIN_MS = 50;
8
15
  /** Base flags shared by all child pi invocations. */
9
16
  export const CHILD_BASE_ARGS = [
10
17
  '--print',
@@ -24,38 +31,50 @@ export const CHILD_BASE_ARGS = [
24
31
  * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
25
32
  * that its descendants inherit. Either/or is load-bearing — Windows ignores
26
33
  * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
27
- * which walks the live tree and needs no flag; unlike a POSIX group kill it
28
- * cannot catch what the child left behind after it exited.
34
+ * which walks the live tree and needs no flag. Neither reap reaches what pi's bash
35
+ * tool backgrounds, since it detaches every command; leftovers.ts finds that.
29
36
  */
30
37
  export function ownGroupSpawnOptions(platform) {
31
38
  return platform === 'win32' ? { windowsHide: true } : { detached: true };
32
39
  }
33
40
  /**
34
41
  * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
35
- * backgrounded. Best-effort: a group already gone is not an error. Kept beside
36
- * the spawn shape so a platform change edits one file.
42
+ * backgrounded that is still in its group. Best-effort: a group already gone is
43
+ * not an error. Kept beside the spawn shape so a platform change edits one file.
44
+ *
45
+ * `leaderExited` has no default: whether the leader still lives decides whether its
46
+ * pid is still its own. Returns whether a reap went out.
37
47
  */
38
- export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited = false } = {}) {
48
+ export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited }) {
39
49
  // taskkill walks the tree down from a live leader. Once the leader has exited its
40
50
  // pid may already be another process's, and /T /F would kill that one instead.
41
51
  if (platform === 'win32' && leaderExited)
42
- return;
52
+ return false;
43
53
  try {
44
54
  if (platform === 'win32') {
45
- // System32's taskkill by absolute path, as pi's killProcessTree finds it, so
46
- // neither PATH nor the working directory can supply another. Synchronous,
47
- // unlike pi's: runChild kills the leader next, and the walk must end first.
48
- const taskkill = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
49
- spawnSyncDefault(taskkill, ['/pid', String(pid), '/T', '/F']);
55
+ // Synchronous, unlike pi's killProcessTree: runChild kills the leader
56
+ // next, and the walk must end first.
57
+ spawnSyncDefault(system32('taskkill.exe'), ['/pid', String(pid), '/T', '/F']);
50
58
  }
51
59
  else {
52
60
  process.kill(-pid, sig);
53
61
  }
62
+ return true;
54
63
  }
55
64
  catch {
56
- // group already gone
65
+ return false;
57
66
  }
58
67
  }
68
+ /**
69
+ * The sweep every runner makes once its leader exited: SIGTERM the group now, and
70
+ * SIGKILL what ignored it after the grace. A group whose last member is gone
71
+ * answers ESRCH, and POSIX keeps the pgid reserved while any member lives.
72
+ */
73
+ export function reapGroupAfterExit(pid, platform = process.platform) {
74
+ if (!reapProcessGroup(pid, 'SIGTERM', { platform, leaderExited: true }))
75
+ return;
76
+ setTimeout(() => reapProcessGroup(pid, 'SIGKILL', { platform, leaderExited: true }), KILL_GRACE_MS).unref();
77
+ }
59
78
  /** The cause a signal was aborted with, when its owner attached one. */
60
79
  function abortCause(reason) {
61
80
  const tagged = reason;
@@ -268,17 +287,19 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
268
287
  const usesStdin = invocation.stdin !== undefined;
269
288
  // Model children (json-events) run arbitrary bash — they can `bun run dev &`
270
289
  // a server that outlives the child and holds a port, wrecking the final gate
271
- // with a self-inflicted EADDRINUSE. Spawn them in their
272
- // OWN process group so every such grandchild can be reaped as a unit on exit.
290
+ // with a self-inflicted EADDRINUSE. They get their OWN process group, so a
291
+ // kill takes the child's tree with it, and leftovers.ts finds what escaped it.
273
292
  // Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
274
293
  const ownGroup = opts?.mode === 'json-events';
275
294
  const platform = (ownGroup && opts.platform) || process.platform;
295
+ const leftovers = ownGroup ? trackLeftovers(platform, invocation.env ?? process.env, KILL_GRACE_MS) : null;
296
+ const env = leftovers?.env ?? invocation.env;
276
297
  const proc = spawn(invocation.command, invocation.args, {
277
298
  cwd,
278
299
  shell: false,
279
300
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
280
301
  ...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
281
- ...(invocation.env ? { env: invocation.env } : {})
302
+ ...(env ? { env } : {})
282
303
  });
283
304
  if (usesStdin) {
284
305
  // A child killed before it read the prompt leaves the pipe broken, and an
@@ -294,28 +315,26 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
294
315
  proc.stdin?.end();
295
316
  }
296
317
  // Reap the child's whole process group — the child itself AND anything it
297
- // backgrounded.
318
+ // backgrounded. Set by 'exit' or 'close', whichever a spawn reports first.
298
319
  let leaderExited = false;
299
- proc.once('exit', () => (leaderExited = true));
300
- const reapGroup = (sig) => {
301
- if (!ownGroup || !proc.pid)
302
- return;
303
- reapProcessGroup(proc.pid, sig, { platform, leaderExited });
304
- };
320
+ const reapGroup = (sig) => ownGroup && !!proc.pid && reapProcessGroup(proc.pid, sig, { platform, leaderExited });
305
321
  // One kill path for every source: SIGTERM, then SIGKILL after a grace
306
322
  // period if the child ignored the term. For a group-owning (model) child,
307
323
  // ALSO sweep the group so anything it backgrounded dies with it —
308
324
  // proc.kill hits only the leader, reapGroup the grandchildren. The sweep
309
325
  // goes first because win32's taskkill walks the tree down from a live
310
326
  // leader. The FIRST cause wins: a stall kill's SIGTERM can trip the abort
311
- // path behind it.
327
+ // path behind it. A leader that already exited gave its own verdict.
312
328
  const killProc = (cause) => {
329
+ if (leaderExited)
330
+ return;
313
331
  kill ??= cause;
314
332
  reapGroup('SIGTERM');
315
333
  proc.kill('SIGTERM');
316
334
  setTimeout(() => {
317
335
  reapGroup('SIGKILL');
318
- if (!proc.killed)
336
+ // Not `proc.killed`: that turns true once SIGTERM is delivered.
337
+ if (!leaderExited)
319
338
  proc.kill('SIGKILL');
320
339
  }, KILL_GRACE_MS);
321
340
  };
@@ -353,7 +372,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
353
372
  }
354
373
  }
355
374
  : opts;
356
- const sink = sinkOpts ? new JsonEventSink(sinkOpts, hit => killProc({ by: 'loop', hit })) : null;
375
+ // A loop hit is the child's own verdict, not a kill from outside, so it stands
376
+ // even when the tail flushed after its exit is what carried it.
377
+ const sink = sinkOpts ?
378
+ new JsonEventSink(sinkOpts, hit => {
379
+ kill ??= { by: 'loop', hit };
380
+ killProc(kill);
381
+ })
382
+ : null;
357
383
  // Dead-backend stall guard (json-events children only; see the option docs).
358
384
  const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
359
385
  const stallProbe = stall ?
@@ -432,15 +458,43 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
432
458
  settled = true;
433
459
  resolve(result);
434
460
  };
435
- proc.once('close', (code) => {
436
- // The child has exited, but anything it backgrounded (a dev server) may
437
- // still hold its process group and a port reap the group so the next
438
- // gate's boot check does not collide with our own orphan. Best-effort:
439
- // SIGTERM now, SIGKILL shortly after for anything that ignored it.
440
- if (ownGroup) {
441
- reapGroup('SIGTERM');
442
- setTimeout(() => reapGroup('SIGKILL'), 1_000).unref();
443
- }
461
+ // 'close' waits on every holder of the pipes, and a grandchild that inherited
462
+ // them holds them for as long as it lives. The run ends with the leader: at
463
+ // once if its pipes are already at EOF, otherwise after one drain.
464
+ let finished = false;
465
+ let exitCode = null;
466
+ let endedStreams = 0;
467
+ const expectedStreams = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0);
468
+ let drain;
469
+ const finishIfDrained = () => {
470
+ if (leaderExited && endedStreams >= expectedStreams)
471
+ finish(exitCode);
472
+ };
473
+ proc.stdout?.on('end', () => {
474
+ endedStreams++;
475
+ finishIfDrained();
476
+ });
477
+ proc.stderr?.on('end', () => {
478
+ endedStreams++;
479
+ finishIfDrained();
480
+ });
481
+ proc.once('exit', (code) => {
482
+ leaderExited = true;
483
+ exitCode = code;
484
+ finishIfDrained();
485
+ if (!finished)
486
+ drain = setTimeout(() => finish(exitCode), EXIT_DRAIN_MS);
487
+ });
488
+ proc.once('close', (code) => finish(code ?? exitCode));
489
+ const finish = (code) => {
490
+ if (finished)
491
+ return;
492
+ finished = true;
493
+ leaderExited = true;
494
+ clearTimeout(drain);
495
+ cleanup();
496
+ if (ownGroup && proc.pid)
497
+ reapGroupAfterExit(proc.pid, platform);
444
498
  if (sink)
445
499
  sink.flush();
446
500
  const text = sink ? sink.text : undefined;
@@ -448,7 +502,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
448
502
  // killed means it ran on a prompt that never finished arriving. Its own
449
503
  // exit describes that half-spec, so it cannot stand as the verdict.
450
504
  const truncated = kill === undefined ? stdinError : undefined;
451
- settle({
505
+ const result = {
452
506
  stdout,
453
507
  stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
454
508
  exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
@@ -456,9 +510,19 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
456
510
  ...(kill ? { kill } : {}),
457
511
  text,
458
512
  modelError: sink?.modelError
459
- });
460
- });
513
+ };
514
+ // A grandchild that inherited the pipes writes into them for as long as
515
+ // it lives; with the read ends closed that lands nowhere.
516
+ proc.stdout?.destroy?.();
517
+ proc.stderr?.destroy?.();
518
+ // Not settled before the leftovers are gone: the next phase needs their ports.
519
+ const done = () => settle(result);
520
+ void (leftovers?.reap() ?? Promise.resolve()).then(done, done);
521
+ };
461
522
  proc.once('error', () => {
523
+ void leftovers?.reap();
524
+ proc.stdout?.destroy?.();
525
+ proc.stderr?.destroy?.();
462
526
  settle({
463
527
  stdout,
464
528
  stderr,
@@ -0,0 +1,45 @@
1
+ export declare const LEFTOVER_TOKEN_ENV = "PI_TASK_LEFTOVER_TOKEN";
2
+ export interface Leftovers {
3
+ /** The environment to spawn the child with. */
4
+ env: NodeJS.ProcessEnv;
5
+ /** End whatever the child left running. Never rejects. */
6
+ reap: () => Promise<void>;
7
+ }
8
+ /** Start tracking a child about to be spawned with `base` as its environment. */
9
+ export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
10
+ /**
11
+ * A System32 executable by absolute path, so neither PATH nor the working directory
12
+ * can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
13
+ */
14
+ export declare function system32(...segments: string[]): string;
15
+ /** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
16
+ export declare function linuxPidsWith(marker: string, procRoot?: string): number[];
17
+ /** Pids of the `ps -E` rows that carry `marker`. The pid is each row's first field. */
18
+ export declare function pidsInPsTable(table: string, marker: string): number[];
19
+ /**
20
+ * Sourced by every bash the child starts, after the user's own BASH_ENV so that one
21
+ * cannot replace the exit trap. Builtins only: a fork costs tens of milliseconds
22
+ * under MSYS. `/proc/$$/winpid` maps MSYS's pid to the Windows one.
23
+ */
24
+ export declare const BASH_ENV_SCRIPT: string;
25
+ /** A shell the registry recorded: its Windows pid and when it ran, as FILETIMEs. */
26
+ export interface Shell {
27
+ pid: number;
28
+ ranAt: bigint;
29
+ /** Absent when the shell was killed, exec'd into its command, or is still running. */
30
+ exitedAt?: bigint;
31
+ }
32
+ /** Rows are `ran <pid> <time>` and `exited <pid> <time>`. Each exit closes its pid's latest run. */
33
+ export declare function parseShells(text: string): Shell[];
34
+ export interface ProcessRow {
35
+ pid: number;
36
+ ppid: number;
37
+ createdAt: bigint;
38
+ }
39
+ /**
40
+ * The processes a recorded shell started: those created while it lived. Windows
41
+ * hands a dead shell's pid to the next process, whose children carry the same
42
+ * parent pid, so only the shell's lifetime tells them apart.
43
+ */
44
+ export declare function startedByShells(rows: ProcessRow[], shells: Shell[]): number[];
45
+ export declare function parseProcessTable(text: string): ProcessRow[];
@@ -0,0 +1,267 @@
1
+ /**
2
+ * leftovers — what a model child started that is still running after it ended.
3
+ *
4
+ * A process-group reap misses it. pi's bash tool starts every command detached, in
5
+ * a group of its own, so a server a command backgrounds is in no group of the
6
+ * child's; on win32 the tree it hung from is gone once its shell exits. Measured
7
+ * with pi's own bash tool: the server outlived the group reap and `taskkill /T`,
8
+ * of the exited child and of the live one, on linux and on the windows runner.
9
+ *
10
+ * So descendants are found by what they inherit. On linux and darwin that is an
11
+ * environment token, read back from the process table. Windows cannot read another
12
+ * process's environment, so there `BASH_ENV` has every bash the child runs record
13
+ * its pid and lifetime, and whatever those shells started is found by parent pid.
14
+ */
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { randomUUID } from 'node:crypto';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ export const LEFTOVER_TOKEN_ENV = 'PI_TASK_LEFTOVER_TOKEN';
21
+ const SHELL_REGISTRY_ENV = 'PI_TASK_SHELL_REGISTRY';
22
+ const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
23
+ /** Start tracking a child about to be spawned with `base` as its environment. */
24
+ export function trackLeftovers(platform, base, graceMs) {
25
+ if (platform === 'win32')
26
+ return trackShells(base, graceMs);
27
+ if (platform === 'linux' || platform === 'darwin')
28
+ return trackToken(platform, base, graceMs);
29
+ return { env: base, reap: () => Promise.resolve() };
30
+ }
31
+ /**
32
+ * A System32 executable by absolute path, so neither PATH nor the working directory
33
+ * can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
34
+ */
35
+ export function system32(...segments) {
36
+ return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', ...segments);
37
+ }
38
+ // ─── linux, darwin: the environment token ───────────────────────────────────
39
+ function trackToken(platform, base, graceMs) {
40
+ const token = randomUUID();
41
+ const marker = `${LEFTOVER_TOKEN_ENV}=${token}`;
42
+ const find = () => platform === 'linux' ? linuxPidsWith(marker) : pidsInPsTable(darwinPsTable(), marker);
43
+ return {
44
+ env: { ...base, [LEFTOVER_TOKEN_ENV]: token },
45
+ reap: () => new Promise(resolve => {
46
+ const started = performance.now();
47
+ let killed = false;
48
+ signalEach(find(), 'SIGTERM');
49
+ // Found again on every pass, never remembered: a pid that died in
50
+ // the grace period may already be someone else's. Each pass waits
51
+ // as long as the scan before it took, so the wait costs half a core
52
+ // at most and no invented interval.
53
+ const poll = () => {
54
+ const scanStart = performance.now();
55
+ const left = find();
56
+ const waited = performance.now() - started;
57
+ // A process SIGKILL cannot end (uninterruptible sleep) gets one
58
+ // more grace, then the run goes on without it.
59
+ if (left.length === 0 || waited >= 2 * graceMs)
60
+ return resolve();
61
+ if (!killed && waited >= graceMs) {
62
+ signalEach(left, 'SIGKILL');
63
+ killed = true;
64
+ }
65
+ setTimeout(poll, performance.now() - scanStart).unref();
66
+ };
67
+ poll();
68
+ })
69
+ };
70
+ }
71
+ /** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
72
+ export function linuxPidsWith(marker, procRoot = '/proc') {
73
+ const inner = Buffer.from(`\0${marker}\0`);
74
+ const first = Buffer.from(`${marker}\0`);
75
+ let names;
76
+ try {
77
+ names = fs.readdirSync(procRoot);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const pids = [];
83
+ for (const name of names) {
84
+ if (!/^\d+$/.test(name))
85
+ continue;
86
+ try {
87
+ const environ = fs.readFileSync(path.join(procRoot, name, 'environ'));
88
+ if (environ.includes(inner) || environ.subarray(0, first.length).equals(first)) {
89
+ pids.push(Number(name));
90
+ }
91
+ }
92
+ catch {
93
+ // exited meanwhile, or not ours to read
94
+ }
95
+ }
96
+ return pids;
97
+ }
98
+ /** darwin's `ps -E` appends each process's environment to its command line. */
99
+ function darwinPsTable() {
100
+ const r = spawnSync('/bin/ps', ['-Aww', '-o', 'pid=,ppid=,pgid=,lstart=,command=', '-E'], {
101
+ encoding: 'utf8',
102
+ maxBuffer: 64 * 1024 * 1024
103
+ });
104
+ return r.stdout ?? '';
105
+ }
106
+ /** Pids of the `ps -E` rows that carry `marker`. The pid is each row's first field. */
107
+ export function pidsInPsTable(table, marker) {
108
+ return table
109
+ .split('\n')
110
+ .filter(row => row.includes(marker))
111
+ .map(row => Number.parseInt(row, 10))
112
+ .filter(pid => pid > 0);
113
+ }
114
+ function signalEach(pids, sig) {
115
+ let sent = 0;
116
+ for (const pid of pids) {
117
+ try {
118
+ process.kill(pid, sig);
119
+ sent++;
120
+ }
121
+ catch {
122
+ // already gone
123
+ }
124
+ }
125
+ return sent;
126
+ }
127
+ // ─── win32: the shells `BASH_ENV` recorded ──────────────────────────────────
128
+ /**
129
+ * Sourced by every bash the child starts, after the user's own BASH_ENV so that one
130
+ * cannot replace the exit trap. Builtins only: a fork costs tens of milliseconds
131
+ * under MSYS. `/proc/$$/winpid` maps MSYS's pid to the Windows one.
132
+ */
133
+ export const BASH_ENV_SCRIPT = [
134
+ `if [ -n "\${${USER_BASH_ENV}:-}" ]; then . "$${USER_BASH_ENV}"; fi`,
135
+ '__pi_task_winpid=$$',
136
+ 'if [ -r /proc/$$/winpid ]; then read -r __pi_task_winpid < /proc/$$/winpid; fi',
137
+ // EPOCHREALTIME is bash 5.0; bash 4 has whole seconds, so the run is read at
138
+ // the start of its second and the exit at the end of it.
139
+ '__pi_task_now() { if [ -n "${EPOCHREALTIME:-}" ]; then __pi_task_t=$EPOCHREALTIME; else printf -v __pi_task_t "%(%s)T" -1; __pi_task_t="$((__pi_task_t + $1)).000000"; fi; }',
140
+ `__pi_task_now 0; printf 'ran %s %s\\n' "$__pi_task_winpid" "$__pi_task_t" >> "$${SHELL_REGISTRY_ENV}"`,
141
+ `trap '__pi_task_now 1; printf "exited %s %s\\n" "$__pi_task_winpid" "$__pi_task_t" >> "$${SHELL_REGISTRY_ENV}"' EXIT`,
142
+ ''
143
+ ].join('\n');
144
+ function trackShells(base, graceMs) {
145
+ let dir;
146
+ try {
147
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-task-shells-'));
148
+ fs.writeFileSync(path.join(dir, 'bash-env.sh'), BASH_ENV_SCRIPT);
149
+ }
150
+ catch {
151
+ return { env: base, reap: () => Promise.resolve() };
152
+ }
153
+ const registry = path.join(dir, 'shells');
154
+ const env = {
155
+ ...base,
156
+ BASH_ENV: forwardSlashes(path.join(dir, 'bash-env.sh')),
157
+ [SHELL_REGISTRY_ENV]: forwardSlashes(registry)
158
+ };
159
+ if (base.BASH_ENV)
160
+ env[USER_BASH_ENV] = base.BASH_ENV;
161
+ return {
162
+ env,
163
+ reap: async () => {
164
+ try {
165
+ const shells = parseShells(fs.existsSync(registry) ? fs.readFileSync(registry, 'utf8') : '');
166
+ if (shells.length === 0)
167
+ return;
168
+ const table = await processTable(graceMs);
169
+ const started = startedByShells(parseProcessTable(table), shells);
170
+ await Promise.all(started.map(taskkillTree));
171
+ }
172
+ catch {
173
+ // best-effort, like every other reap
174
+ }
175
+ try {
176
+ fs.rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ catch {
179
+ // a handle still open in there; the run must not wait on it
180
+ }
181
+ }
182
+ };
183
+ }
184
+ /** Git Bash reads MSYS paths; forward slashes are the spelling both sides accept. */
185
+ function forwardSlashes(p) {
186
+ return p.replace(/\\/g, '/');
187
+ }
188
+ /** FILETIME counts 100ns ticks from 1601; the Unix epoch is this many ticks in. */
189
+ const UNIX_EPOCH_FILETIME = 116444736000000000n;
190
+ /** `EPOCHREALTIME` as a FILETIME. Its decimal mark follows bash's locale. */
191
+ function filetimeOf(epochRealtime) {
192
+ const m = /^(\d+)[.,](\d{6})$/.exec(epochRealtime);
193
+ return m ? (BigInt(m[1]) * 1000000n + BigInt(m[2])) * 10n + UNIX_EPOCH_FILETIME : undefined;
194
+ }
195
+ /** Rows are `ran <pid> <time>` and `exited <pid> <time>`. Each exit closes its pid's latest run. */
196
+ export function parseShells(text) {
197
+ const shells = [];
198
+ for (const row of text.split('\n')) {
199
+ const m = /^(ran|exited) (\d+) (\S+)$/.exec(row.trim());
200
+ const at = m ? filetimeOf(m[3]) : undefined;
201
+ if (!m || at === undefined)
202
+ continue;
203
+ const pid = Number(m[2]);
204
+ if (m[1] === 'ran') {
205
+ shells.push({ pid, ranAt: at });
206
+ }
207
+ else {
208
+ const open = shells.findLast(s => s.pid === pid && s.exitedAt === undefined);
209
+ if (open)
210
+ open.exitedAt = at;
211
+ }
212
+ }
213
+ return shells;
214
+ }
215
+ /**
216
+ * The processes a recorded shell started: those created while it lived. Windows
217
+ * hands a dead shell's pid to the next process, whose children carry the same
218
+ * parent pid, so only the shell's lifetime tells them apart.
219
+ */
220
+ export function startedByShells(rows, shells) {
221
+ const startedBy = (shell, row) => {
222
+ if (shell.pid !== row.ppid || row.createdAt < shell.ranAt)
223
+ return false;
224
+ if (shell.exitedAt !== undefined)
225
+ return row.createdAt <= shell.exitedAt;
226
+ // No exit on record: a process holding the pid since before the run is the shell.
227
+ return rows.some(r => r.pid === shell.pid && r.createdAt <= shell.ranAt);
228
+ };
229
+ return rows.filter(row => shells.some(shell => startedBy(shell, row))).map(row => row.pid);
230
+ }
231
+ export function parseProcessTable(text) {
232
+ const rows = [];
233
+ for (const line of text.split('\n')) {
234
+ const m = /^(\d+) (\d+) (\d+)$/.exec(line.trim());
235
+ if (m)
236
+ rows.push({ pid: Number(m[1]), ppid: Number(m[2]), createdAt: BigInt(m[3]) });
237
+ }
238
+ return rows;
239
+ }
240
+ /**
241
+ * Every process as `pid ppid creation-FILETIME`, one per line. A CIM query that
242
+ * has not answered within the kill grace is given up on: the run is waiting.
243
+ */
244
+ function processTable(graceMs) {
245
+ const query = 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate'
246
+ + ' | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToFileTimeUtc())" }';
247
+ return new Promise(resolve => {
248
+ let out = '';
249
+ const ps = spawn(system32('WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', query], { stdio: ['ignore', 'pipe', 'ignore'] });
250
+ const giveUp = setTimeout(() => ps.kill(), graceMs);
251
+ ps.stdout.on('data', (d) => (out += d.toString()));
252
+ ps.on('error', () => resolve(''));
253
+ ps.on('close', () => {
254
+ clearTimeout(giveUp);
255
+ resolve(out);
256
+ });
257
+ });
258
+ }
259
+ function taskkillTree(pid) {
260
+ return new Promise(resolve => {
261
+ const tk = spawn(system32('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], {
262
+ stdio: 'ignore'
263
+ });
264
+ tk.on('error', () => resolve());
265
+ tk.on('close', () => resolve());
266
+ });
267
+ }
@@ -163,7 +163,7 @@ export interface BootDeps {
163
163
  * a fake child has no group to kill and a real `process.kill(pid)` against a
164
164
  * fake pid would signal something else entirely.
165
165
  */
166
- killGroup?: (pid: number, signal: NodeJS.Signals) => void;
166
+ killGroup?: (pid: number, signal: NodeJS.Signals, leaderExited: boolean) => void;
167
167
  /** Which platform's spawn options, served-app rule and reap to use. Tests drive
168
168
  * the win32 arm from a POSIX host; production leaves it to `process.platform`. */
169
169
  platform?: NodeJS.Platform;
@@ -249,6 +249,7 @@ export declare function defaultFindPortHolder(port: number): {
249
249
  pid: number;
250
250
  command: string;
251
251
  } | null;
252
+ export declare const BOOT_KILL_GRACE_MS = 2000;
252
253
  /**
253
254
  * Exercise the start command ONCE. All four outcomes below were run against real
254
255
  * child processes in throwaway projects.
@@ -501,6 +501,7 @@ function holderIsOurs(command, boot) {
501
501
  return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
502
502
  && (c.includes(` ${script}`) || c.endsWith(script)));
503
503
  }
504
+ export const BOOT_KILL_GRACE_MS = 2_000;
504
505
  /** The real spawn. Kept beside the seam so the default is one line to read. */
505
506
  function defaultSpawnBoot(bin, args, o) {
506
507
  return spawn(bin, args, o);
@@ -611,7 +612,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
611
612
  };
612
613
  let leaderExited = false;
613
614
  const reapGroup = opts.deps?.killGroup
614
- ?? ((pid, sig) => reapProcessGroup(pid, sig, { platform, leaderExited }));
615
+ ?? ((pid, sig, exited) => reapProcessGroup(pid, sig, { platform, leaderExited: exited }));
615
616
  const killGroup = (sig) => {
616
617
  // Truthiness, deliberately: `process.kill(0, sig)` signals the
617
618
  // CALLER's own process group, so a pid of 0 turns a best-effort
@@ -619,17 +620,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
619
620
  // `spawnBoot` is a seam now and a fake or future child could.
620
621
  if (!child.pid)
621
622
  return;
622
- reapGroup(child.pid, sig);
623
+ reapGroup(child.pid, sig, leaderExited);
623
624
  };
624
625
  const passAndKill = (renderNote) => {
625
626
  settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
626
627
  killGroup('SIGTERM');
627
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
628
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
628
629
  };
629
630
  const failAndKill = (detail) => {
630
631
  settle({ outcome: 'fail', detail });
631
632
  killGroup('SIGTERM');
632
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
633
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
633
634
  };
634
635
  // Served apps only: poll for a listening socket owned by our process group.
635
636
  // As soon as one appears the boot has demonstrably served → run the render
@@ -716,7 +717,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
716
717
  detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
717
718
  });
718
719
  killGroup('SIGTERM');
719
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
720
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
720
721
  return;
721
722
  }
722
723
  passAndKill();
@@ -29,6 +29,7 @@
29
29
  * the gate's boot half, while its command half had none.
30
30
  */
31
31
  import { spawn } from 'node:child_process';
32
+ import { EXIT_DRAIN_MS, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
32
33
  import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
33
34
  /**
34
35
  * How much of ONE stream may be held in the HOST process, and how it is split.
@@ -71,12 +72,6 @@ class BoundedOutput {
71
72
  : this.head + this.tail;
72
73
  }
73
74
  }
74
- /**
75
- * After the child EXITS, how long its pipes may still deliver buffered data
76
- * before the run is reported. Not a wait for the pipes to CLOSE — that is the
77
- * bug below — just the turn or two the reader needs to hand over what it has.
78
- */
79
- const DRAIN_MS = 50;
80
75
  /**
81
76
  * The real runner: one bounded child, output collected, never rejects.
82
77
  *
@@ -102,6 +97,8 @@ export const spawnCommand = spec => new Promise(resolve => {
102
97
  let exited = false;
103
98
  let endedStreams = 0;
104
99
  let drain;
100
+ // Its own process group: a pretest that backgrounds a daemon, a build that
101
+ // leaves a watcher, would otherwise hold their ports into the boot check.
105
102
  const child = spawn(spec.bin, spec.args, {
106
103
  cwd: spec.cwd,
107
104
  // stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
@@ -109,6 +106,7 @@ export const spawnCommand = spec => new Promise(resolve => {
109
106
  // a `cat`-style pipeline, a tool that prompts, a pager — would block
110
107
  // until the kill timer fires instead of returning at once.
111
108
  stdio: ['ignore', 'pipe', 'pipe'],
109
+ ...ownGroupSpawnOptions(process.platform),
112
110
  ...(spec.env ? { env: spec.env } : {})
113
111
  });
114
112
  const done = (status, failure) => {
@@ -132,6 +130,8 @@ export const spawnCommand = spec => new Promise(resolve => {
132
130
  done(exitStatus);
133
131
  };
134
132
  const kill = () => {
133
+ if (child.pid)
134
+ reapProcessGroup(child.pid, 'SIGKILL', { leaderExited: exited });
135
135
  try {
136
136
  child.kill('SIGKILL');
137
137
  }
@@ -140,14 +140,14 @@ export const spawnCommand = spec => new Promise(resolve => {
140
140
  }
141
141
  };
142
142
  /**
143
- * The deadline and the cancel both END the run. The kill only reaches the
144
- * direct child, so this cannot wait to observe its effect it kills, gives
145
- * the pipes one drain, and reports `status: null` regardless.
143
+ * The deadline and the cancel both END the run. This cannot wait to observe
144
+ * the kill's effect it kills, gives the pipes one drain, and reports
145
+ * `status: null` regardless.
146
146
  */
147
147
  const killAndSettle = () => {
148
148
  kill();
149
149
  clearTimeout(drain);
150
- drain = setTimeout(() => done(null), DRAIN_MS);
150
+ drain = setTimeout(() => done(null), EXIT_DRAIN_MS);
151
151
  };
152
152
  // NOT unref'd. This timer is the only bound left on every gate command,
153
153
  // repo-health command and ACCEPT-debt re-run, and an unref'd timer is only
@@ -176,13 +176,15 @@ export const spawnCommand = spec => new Promise(resolve => {
176
176
  child.on('exit', (code) => {
177
177
  exited = true;
178
178
  exitStatus = code;
179
+ if (child.pid)
180
+ reapGroupAfterExit(child.pid);
179
181
  // Both ends of the same question: settle now if the pipes are already
180
182
  // at EOF, otherwise settle after one short drain rather than waiting on
181
183
  // whoever else is holding them.
182
184
  settleIfDrained();
183
185
  if (!settled) {
184
186
  clearTimeout(drain);
185
- drain = setTimeout(() => done(exitStatus), DRAIN_MS);
187
+ drain = setTimeout(() => done(exitStatus), EXIT_DRAIN_MS);
186
188
  }
187
189
  });
188
190
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.45",
3
+ "version": "0.40.47",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",