@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.
- package/dist/shared/child-process.d.ts +33 -11
- package/dist/shared/child-process.js +102 -38
- package/dist/shared/leftovers.d.ts +45 -0
- package/dist/shared/leftovers.js +267 -0
- package/dist/task/boot-probe.d.ts +2 -1
- package/dist/task/boot-probe.js +6 -5
- package/dist/task/command-run.js +13 -11
- package/package.json +1 -1
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
|
31
|
-
* `GIT_INDEX_FILE` throwaway index
|
|
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
|
|
57
|
-
*
|
|
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
|
|
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
|
|
73
|
-
}):
|
|
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
|
|
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
|
|
28
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
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.
|
|
272
|
-
//
|
|
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
|
-
...(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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.
|
package/dist/task/boot-probe.js
CHANGED
|
@@ -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'),
|
|
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'),
|
|
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'),
|
|
720
|
+
setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
|
|
720
721
|
return;
|
|
721
722
|
}
|
|
722
723
|
passAndKill();
|
package/dist/task/command-run.js
CHANGED
|
@@ -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.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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),
|
|
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),
|
|
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.
|
|
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",
|