@mjasnikovs/pi-task 0.40.44 → 0.40.46
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 +21 -7
- package/dist/shared/child-process.js +86 -35
- package/dist/shared/leftovers.d.ts +45 -0
- package/dist/shared/leftovers.js +237 -0
- package/dist/task/boot-probe.d.ts +4 -3
- package/dist/task/boot-probe.js +12 -11
- package/dist/task/command-run.js +3 -8
- package/dist/task/deep-render-check.js +8 -2
- 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. */
|
|
@@ -27,8 +34,9 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
|
27
34
|
cwd: string;
|
|
28
35
|
shell: boolean;
|
|
29
36
|
stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
|
|
30
|
-
/** Set
|
|
31
|
-
* `GIT_INDEX_FILE` throwaway index
|
|
37
|
+
/** Set when the invocation needs env overrides — git-state-guard's
|
|
38
|
+
* `GIT_INDEX_FILE` throwaway index — and for every model child, which
|
|
39
|
+
* carries what leftovers.ts finds its descendants by. Absent → the child
|
|
32
40
|
* inherits this process's environment. */
|
|
33
41
|
env?: NodeJS.ProcessEnv;
|
|
34
42
|
/** true → give the child its own process group (POSIX `detached`), so any
|
|
@@ -53,8 +61,8 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
|
53
61
|
* `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
|
|
54
62
|
* that its descendants inherit. Either/or is load-bearing — Windows ignores
|
|
55
63
|
* CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
|
|
56
|
-
* which walks the live tree and needs no flag
|
|
57
|
-
*
|
|
64
|
+
* which walks the live tree and needs no flag. Neither reap reaches what pi's bash
|
|
65
|
+
* tool backgrounds, since it detaches every command; leftovers.ts finds that.
|
|
58
66
|
*/
|
|
59
67
|
export declare function ownGroupSpawnOptions(platform: NodeJS.Platform): OwnGroupSpawnOptions;
|
|
60
68
|
export type OwnGroupSpawnOptions = {
|
|
@@ -64,10 +72,16 @@ export type OwnGroupSpawnOptions = {
|
|
|
64
72
|
};
|
|
65
73
|
/**
|
|
66
74
|
* 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.
|
|
75
|
+
* backgrounded that is still in its group. Best-effort: a group already gone is
|
|
76
|
+
* not an error. Kept beside the spawn shape so a platform change edits one file.
|
|
77
|
+
*
|
|
78
|
+
* `leaderExited` has no default: whether the leader still lives decides whether its
|
|
79
|
+
* pid is still its own. Returns whether a reap went out.
|
|
69
80
|
*/
|
|
70
|
-
export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, platform
|
|
81
|
+
export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }: {
|
|
82
|
+
platform?: NodeJS.Platform;
|
|
83
|
+
leaderExited: boolean;
|
|
84
|
+
}): boolean;
|
|
71
85
|
/**
|
|
72
86
|
* Why runChild killed the child. Five sources converge on one kill path, and
|
|
73
87
|
* each names itself here rather than in its own flag — so a consumer reads ONE
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
|
|
2
|
+
import { system32, trackLeftovers } from './leftovers.js';
|
|
2
3
|
import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
|
|
3
4
|
import { realStallTimerDeps, StallProbe } from './stall-probe.js';
|
|
4
5
|
import { workerChannel } from '../workers/worker-channels.js';
|
|
5
6
|
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
6
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;
|
|
7
15
|
/** Base flags shared by all child pi invocations. */
|
|
8
16
|
export const CHILD_BASE_ARGS = [
|
|
9
17
|
'--print',
|
|
@@ -23,28 +31,38 @@ export const CHILD_BASE_ARGS = [
|
|
|
23
31
|
* `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
|
|
24
32
|
* that its descendants inherit. Either/or is load-bearing — Windows ignores
|
|
25
33
|
* CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
|
|
26
|
-
* which walks the live tree and needs no flag
|
|
27
|
-
*
|
|
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.
|
|
28
36
|
*/
|
|
29
37
|
export function ownGroupSpawnOptions(platform) {
|
|
30
38
|
return platform === 'win32' ? { windowsHide: true } : { detached: true };
|
|
31
39
|
}
|
|
32
40
|
/**
|
|
33
41
|
* Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
|
|
34
|
-
* backgrounded. Best-effort: a group already gone is
|
|
35
|
-
* 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.
|
|
36
47
|
*/
|
|
37
|
-
export function reapProcessGroup(pid, sig, platform = process.platform) {
|
|
48
|
+
export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited }) {
|
|
49
|
+
// taskkill walks the tree down from a live leader. Once the leader has exited its
|
|
50
|
+
// pid may already be another process's, and /T /F would kill that one instead.
|
|
51
|
+
if (platform === 'win32' && leaderExited)
|
|
52
|
+
return false;
|
|
38
53
|
try {
|
|
39
54
|
if (platform === 'win32') {
|
|
40
|
-
|
|
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']);
|
|
41
58
|
}
|
|
42
59
|
else {
|
|
43
60
|
process.kill(-pid, sig);
|
|
44
61
|
}
|
|
62
|
+
return true;
|
|
45
63
|
}
|
|
46
64
|
catch {
|
|
47
|
-
|
|
65
|
+
return false;
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
/** The cause a signal was aborted with, when its owner attached one. */
|
|
@@ -259,17 +277,19 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
259
277
|
const usesStdin = invocation.stdin !== undefined;
|
|
260
278
|
// Model children (json-events) run arbitrary bash — they can `bun run dev &`
|
|
261
279
|
// a server that outlives the child and holds a port, wrecking the final gate
|
|
262
|
-
// with a self-inflicted EADDRINUSE.
|
|
263
|
-
//
|
|
280
|
+
// with a self-inflicted EADDRINUSE. They get their OWN process group, so a
|
|
281
|
+
// kill takes the child's tree with it, and leftovers.ts finds what escaped it.
|
|
264
282
|
// Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
|
|
265
283
|
const ownGroup = opts?.mode === 'json-events';
|
|
266
284
|
const platform = (ownGroup && opts.platform) || process.platform;
|
|
285
|
+
const leftovers = ownGroup ? trackLeftovers(platform, invocation.env ?? process.env, KILL_GRACE_MS) : null;
|
|
286
|
+
const env = leftovers?.env ?? invocation.env;
|
|
267
287
|
const proc = spawn(invocation.command, invocation.args, {
|
|
268
288
|
cwd,
|
|
269
289
|
shell: false,
|
|
270
290
|
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
271
291
|
...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
|
|
272
|
-
...(
|
|
292
|
+
...(env ? { env } : {})
|
|
273
293
|
});
|
|
274
294
|
if (usesStdin) {
|
|
275
295
|
// A child killed before it read the prompt leaves the pipe broken, and an
|
|
@@ -285,27 +305,27 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
285
305
|
proc.stdin?.end();
|
|
286
306
|
}
|
|
287
307
|
// Reap the child's whole process group — the child itself AND anything it
|
|
288
|
-
// backgrounded.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
return;
|
|
292
|
-
reapProcessGroup(proc.pid, sig, platform);
|
|
293
|
-
};
|
|
308
|
+
// backgrounded. Set by 'exit' or 'close', whichever a spawn reports first.
|
|
309
|
+
let leaderExited = false;
|
|
310
|
+
const reapGroup = (sig) => ownGroup && !!proc.pid && reapProcessGroup(proc.pid, sig, { platform, leaderExited });
|
|
294
311
|
// One kill path for every source: SIGTERM, then SIGKILL after a grace
|
|
295
312
|
// period if the child ignored the term. For a group-owning (model) child,
|
|
296
313
|
// ALSO sweep the group so anything it backgrounded dies with it —
|
|
297
|
-
// proc.kill hits only the leader, reapGroup the grandchildren. The
|
|
298
|
-
//
|
|
314
|
+
// proc.kill hits only the leader, reapGroup the grandchildren. The sweep
|
|
315
|
+
// goes first because win32's taskkill walks the tree down from a live
|
|
316
|
+
// leader. The FIRST cause wins: a stall kill's SIGTERM can trip the abort
|
|
317
|
+
// path behind it. A leader that already exited gave its own verdict.
|
|
299
318
|
const killProc = (cause) => {
|
|
319
|
+
if (leaderExited)
|
|
320
|
+
return;
|
|
300
321
|
kill ??= cause;
|
|
322
|
+
reapGroup('SIGTERM');
|
|
301
323
|
proc.kill('SIGTERM');
|
|
302
|
-
if (ownGroup)
|
|
303
|
-
reapGroup('SIGTERM');
|
|
304
324
|
setTimeout(() => {
|
|
305
|
-
|
|
325
|
+
reapGroup('SIGKILL');
|
|
326
|
+
// Not `proc.killed`: that turns true once SIGTERM is delivered.
|
|
327
|
+
if (!leaderExited)
|
|
306
328
|
proc.kill('SIGKILL');
|
|
307
|
-
if (ownGroup)
|
|
308
|
-
reapGroup('SIGKILL');
|
|
309
329
|
}, KILL_GRACE_MS);
|
|
310
330
|
};
|
|
311
331
|
// Stream watchdog (json-events children only): a silent stream is killed
|
|
@@ -421,15 +441,43 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
421
441
|
settled = true;
|
|
422
442
|
resolve(result);
|
|
423
443
|
};
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
444
|
+
// 'close' waits on every holder of the pipes, and a grandchild that inherited
|
|
445
|
+
// them holds them for as long as it lives. The run ends with the leader: at
|
|
446
|
+
// once if its pipes are already at EOF, otherwise after one drain.
|
|
447
|
+
let finished = false;
|
|
448
|
+
let exitCode = null;
|
|
449
|
+
let endedStreams = 0;
|
|
450
|
+
const expectedStreams = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0);
|
|
451
|
+
let drain;
|
|
452
|
+
const finishIfDrained = () => {
|
|
453
|
+
if (leaderExited && endedStreams >= expectedStreams)
|
|
454
|
+
finish(exitCode);
|
|
455
|
+
};
|
|
456
|
+
proc.stdout?.on('end', () => {
|
|
457
|
+
endedStreams++;
|
|
458
|
+
finishIfDrained();
|
|
459
|
+
});
|
|
460
|
+
proc.stderr?.on('end', () => {
|
|
461
|
+
endedStreams++;
|
|
462
|
+
finishIfDrained();
|
|
463
|
+
});
|
|
464
|
+
proc.once('exit', (code) => {
|
|
465
|
+
leaderExited = true;
|
|
466
|
+
exitCode = code;
|
|
467
|
+
finishIfDrained();
|
|
468
|
+
if (!finished)
|
|
469
|
+
drain = setTimeout(() => finish(exitCode), EXIT_DRAIN_MS);
|
|
470
|
+
});
|
|
471
|
+
proc.once('close', (code) => finish(code ?? exitCode));
|
|
472
|
+
const finish = (code) => {
|
|
473
|
+
if (finished)
|
|
474
|
+
return;
|
|
475
|
+
finished = true;
|
|
476
|
+
leaderExited = true;
|
|
477
|
+
clearTimeout(drain);
|
|
478
|
+
cleanup();
|
|
479
|
+
if (reapGroup('SIGTERM'))
|
|
480
|
+
setTimeout(() => reapGroup('SIGKILL'), KILL_GRACE_MS).unref();
|
|
433
481
|
if (sink)
|
|
434
482
|
sink.flush();
|
|
435
483
|
const text = sink ? sink.text : undefined;
|
|
@@ -437,7 +485,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
437
485
|
// killed means it ran on a prompt that never finished arriving. Its own
|
|
438
486
|
// exit describes that half-spec, so it cannot stand as the verdict.
|
|
439
487
|
const truncated = kill === undefined ? stdinError : undefined;
|
|
440
|
-
|
|
488
|
+
const result = {
|
|
441
489
|
stdout,
|
|
442
490
|
stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
|
|
443
491
|
exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
|
|
@@ -445,9 +493,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
445
493
|
...(kill ? { kill } : {}),
|
|
446
494
|
text,
|
|
447
495
|
modelError: sink?.modelError
|
|
448
|
-
}
|
|
449
|
-
|
|
496
|
+
};
|
|
497
|
+
// Not settled before the leftovers are gone: the next phase needs their ports.
|
|
498
|
+
void (leftovers?.reap() ?? Promise.resolve()).then(() => settle(result));
|
|
499
|
+
};
|
|
450
500
|
proc.once('error', () => {
|
|
501
|
+
void leftovers?.reap();
|
|
451
502
|
settle({
|
|
452
503
|
stdout,
|
|
453
504
|
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,237 @@
|
|
|
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);
|
|
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: () => {
|
|
46
|
+
// Found again for the SIGKILL, never remembered: a pid that died in the
|
|
47
|
+
// grace period may already be someone else's.
|
|
48
|
+
if (signalEach(find(), 'SIGTERM') > 0) {
|
|
49
|
+
setTimeout(() => signalEach(find(), 'SIGKILL'), graceMs).unref();
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
|
|
56
|
+
export function linuxPidsWith(marker, procRoot = '/proc') {
|
|
57
|
+
const inner = Buffer.from(`\0${marker}\0`);
|
|
58
|
+
const first = Buffer.from(`${marker}\0`);
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = fs.readdirSync(procRoot);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const pids = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!/^\d+$/.test(name))
|
|
69
|
+
continue;
|
|
70
|
+
try {
|
|
71
|
+
const environ = fs.readFileSync(path.join(procRoot, name, 'environ'));
|
|
72
|
+
if (environ.includes(inner) || environ.subarray(0, first.length).equals(first)) {
|
|
73
|
+
pids.push(Number(name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// exited meanwhile, or not ours to read
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return pids;
|
|
81
|
+
}
|
|
82
|
+
/** darwin's `ps -E` appends each process's environment to its command line. */
|
|
83
|
+
function darwinPsTable() {
|
|
84
|
+
const r = spawnSync('/bin/ps', ['-Aww', '-o', 'pid=,ppid=,pgid=,lstart=,command=', '-E'], {
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
maxBuffer: 64 * 1024 * 1024
|
|
87
|
+
});
|
|
88
|
+
return r.stdout ?? '';
|
|
89
|
+
}
|
|
90
|
+
/** Pids of the `ps -E` rows that carry `marker`. The pid is each row's first field. */
|
|
91
|
+
export function pidsInPsTable(table, marker) {
|
|
92
|
+
return table
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter(row => row.includes(marker))
|
|
95
|
+
.map(row => Number.parseInt(row, 10))
|
|
96
|
+
.filter(pid => pid > 0);
|
|
97
|
+
}
|
|
98
|
+
function signalEach(pids, sig) {
|
|
99
|
+
let sent = 0;
|
|
100
|
+
for (const pid of pids) {
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, sig);
|
|
103
|
+
sent++;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// already gone
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return sent;
|
|
110
|
+
}
|
|
111
|
+
// ─── win32: the shells `BASH_ENV` recorded ──────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Sourced by every bash the child starts, after the user's own BASH_ENV so that one
|
|
114
|
+
* cannot replace the exit trap. Builtins only: a fork costs tens of milliseconds
|
|
115
|
+
* under MSYS. `/proc/$$/winpid` maps MSYS's pid to the Windows one.
|
|
116
|
+
*/
|
|
117
|
+
export const BASH_ENV_SCRIPT = [
|
|
118
|
+
`if [ -n "\${${USER_BASH_ENV}:-}" ]; then . "$${USER_BASH_ENV}"; fi`,
|
|
119
|
+
'__pi_task_winpid=$$',
|
|
120
|
+
'if [ -r /proc/$$/winpid ]; then read -r __pi_task_winpid < /proc/$$/winpid; fi',
|
|
121
|
+
`printf 'ran %s %s\\n' "$__pi_task_winpid" "\${EPOCHREALTIME:-}" >> "$${SHELL_REGISTRY_ENV}"`,
|
|
122
|
+
`trap 'printf "exited %s %s\\n" "$__pi_task_winpid" "\${EPOCHREALTIME:-}" >> "$${SHELL_REGISTRY_ENV}"' EXIT`,
|
|
123
|
+
''
|
|
124
|
+
].join('\n');
|
|
125
|
+
function trackShells(base) {
|
|
126
|
+
let dir;
|
|
127
|
+
try {
|
|
128
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-task-shells-'));
|
|
129
|
+
fs.writeFileSync(path.join(dir, 'bash-env.sh'), BASH_ENV_SCRIPT);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { env: base, reap: () => Promise.resolve() };
|
|
133
|
+
}
|
|
134
|
+
const registry = path.join(dir, 'shells');
|
|
135
|
+
const env = {
|
|
136
|
+
...base,
|
|
137
|
+
BASH_ENV: forwardSlashes(path.join(dir, 'bash-env.sh')),
|
|
138
|
+
[SHELL_REGISTRY_ENV]: forwardSlashes(registry)
|
|
139
|
+
};
|
|
140
|
+
if (base.BASH_ENV)
|
|
141
|
+
env[USER_BASH_ENV] = base.BASH_ENV;
|
|
142
|
+
return {
|
|
143
|
+
env,
|
|
144
|
+
reap: async () => {
|
|
145
|
+
try {
|
|
146
|
+
const shells = parseShells(fs.existsSync(registry) ? fs.readFileSync(registry, 'utf8') : '');
|
|
147
|
+
if (shells.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
const started = startedByShells(parseProcessTable(await processTable()), shells);
|
|
150
|
+
await Promise.all(started.map(taskkillTree));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// best-effort, like every other reap
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Git Bash reads MSYS paths; forward slashes are the spelling both sides accept. */
|
|
162
|
+
function forwardSlashes(p) {
|
|
163
|
+
return p.replace(/\\/g, '/');
|
|
164
|
+
}
|
|
165
|
+
/** FILETIME counts 100ns ticks from 1601; the Unix epoch is this many ticks in. */
|
|
166
|
+
const UNIX_EPOCH_FILETIME = 116444736000000000n;
|
|
167
|
+
/** `EPOCHREALTIME` as a FILETIME. Its decimal mark follows bash's locale. */
|
|
168
|
+
function filetimeOf(epochRealtime) {
|
|
169
|
+
const m = /^(\d+)[.,](\d{6})$/.exec(epochRealtime);
|
|
170
|
+
return m ? (BigInt(m[1]) * 1000000n + BigInt(m[2])) * 10n + UNIX_EPOCH_FILETIME : undefined;
|
|
171
|
+
}
|
|
172
|
+
/** Rows are `ran <pid> <time>` and `exited <pid> <time>`. Each exit closes its pid's latest run. */
|
|
173
|
+
export function parseShells(text) {
|
|
174
|
+
const shells = [];
|
|
175
|
+
for (const row of text.split('\n')) {
|
|
176
|
+
const m = /^(ran|exited) (\d+) (\S+)$/.exec(row.trim());
|
|
177
|
+
const at = m ? filetimeOf(m[3]) : undefined;
|
|
178
|
+
if (!m || at === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
const pid = Number(m[2]);
|
|
181
|
+
if (m[1] === 'ran') {
|
|
182
|
+
shells.push({ pid, ranAt: at });
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const open = shells.findLast(s => s.pid === pid && s.exitedAt === undefined);
|
|
186
|
+
if (open)
|
|
187
|
+
open.exitedAt = at;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return shells;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The processes a recorded shell started: those created while it lived. Windows
|
|
194
|
+
* hands a dead shell's pid to the next process, whose children carry the same
|
|
195
|
+
* parent pid, so only the shell's lifetime tells them apart.
|
|
196
|
+
*/
|
|
197
|
+
export function startedByShells(rows, shells) {
|
|
198
|
+
const startedBy = (shell, row) => {
|
|
199
|
+
if (shell.pid !== row.ppid || row.createdAt < shell.ranAt)
|
|
200
|
+
return false;
|
|
201
|
+
if (shell.exitedAt !== undefined)
|
|
202
|
+
return row.createdAt <= shell.exitedAt;
|
|
203
|
+
// No exit on record: a process holding the pid since before the run is the shell.
|
|
204
|
+
return rows.some(r => r.pid === shell.pid && r.createdAt <= shell.ranAt);
|
|
205
|
+
};
|
|
206
|
+
return rows.filter(row => shells.some(shell => startedBy(shell, row))).map(row => row.pid);
|
|
207
|
+
}
|
|
208
|
+
export function parseProcessTable(text) {
|
|
209
|
+
const rows = [];
|
|
210
|
+
for (const line of text.split('\n')) {
|
|
211
|
+
const m = /^(\d+) (\d+) (\d+)$/.exec(line.trim());
|
|
212
|
+
if (m)
|
|
213
|
+
rows.push({ pid: Number(m[1]), ppid: Number(m[2]), createdAt: BigInt(m[3]) });
|
|
214
|
+
}
|
|
215
|
+
return rows;
|
|
216
|
+
}
|
|
217
|
+
/** Every process as `pid ppid creation-FILETIME`, one per line. */
|
|
218
|
+
function processTable() {
|
|
219
|
+
const query = 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate'
|
|
220
|
+
+ ' | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToFileTimeUtc())" }';
|
|
221
|
+
return new Promise(resolve => {
|
|
222
|
+
let out = '';
|
|
223
|
+
const ps = spawn(system32('WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', query], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
224
|
+
ps.stdout.on('data', (d) => (out += d.toString()));
|
|
225
|
+
ps.on('error', () => resolve(''));
|
|
226
|
+
ps.on('close', () => resolve(out));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function taskkillTree(pid) {
|
|
230
|
+
return new Promise(resolve => {
|
|
231
|
+
const tk = spawn(system32('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], {
|
|
232
|
+
stdio: 'ignore'
|
|
233
|
+
});
|
|
234
|
+
tk.on('error', () => resolve());
|
|
235
|
+
tk.on('close', () => resolve());
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -163,9 +163,9 @@ 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;
|
|
167
|
-
/** Which platform's
|
|
168
|
-
* from a POSIX host; production leaves it to `process.platform`. */
|
|
166
|
+
killGroup?: (pid: number, signal: NodeJS.Signals, leaderExited: boolean) => void;
|
|
167
|
+
/** Which platform's spawn options, served-app rule and reap to use. Tests drive
|
|
168
|
+
* the win32 arm from a POSIX host; production leaves it to `process.platform`. */
|
|
169
169
|
platform?: NodeJS.Platform;
|
|
170
170
|
}
|
|
171
171
|
/** What `runBootCheck` passes to its spawn. */
|
|
@@ -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,14 +501,11 @@ 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);
|
|
507
508
|
}
|
|
508
|
-
/** The real group teardown; the spawn shape's twin lives beside it. */
|
|
509
|
-
function defaultKillGroup(pid, sig) {
|
|
510
|
-
reapProcessGroup(pid, sig);
|
|
511
|
-
}
|
|
512
509
|
/**
|
|
513
510
|
* Exercise the start command ONCE. All four outcomes below were run against real
|
|
514
511
|
* child processes in throwaway projects.
|
|
@@ -557,7 +554,8 @@ function defaultKillGroup(pid, sig) {
|
|
|
557
554
|
* reports it that way — see isCommandNotFound) → skip.
|
|
558
555
|
*/
|
|
559
556
|
export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
|
|
560
|
-
const
|
|
557
|
+
const platform = opts.deps?.platform ?? process.platform;
|
|
558
|
+
const expectServer = (opts.expectServer ?? false) && platform !== 'win32';
|
|
561
559
|
const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
|
|
562
560
|
const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
|
|
563
561
|
const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
|
|
@@ -579,7 +577,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
579
577
|
return new Promise(resolve => {
|
|
580
578
|
const child = spawnBoot(runner.bin, args, {
|
|
581
579
|
cwd,
|
|
582
|
-
...ownGroupSpawnOptions(
|
|
580
|
+
...ownGroupSpawnOptions(platform),
|
|
583
581
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
584
582
|
env: {
|
|
585
583
|
...runnerEnv(runner),
|
|
@@ -612,7 +610,9 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
612
610
|
clearInterval(poll);
|
|
613
611
|
resolve(r);
|
|
614
612
|
};
|
|
615
|
-
|
|
613
|
+
let leaderExited = false;
|
|
614
|
+
const reapGroup = opts.deps?.killGroup
|
|
615
|
+
?? ((pid, sig, exited) => reapProcessGroup(pid, sig, { platform, leaderExited: exited }));
|
|
616
616
|
const killGroup = (sig) => {
|
|
617
617
|
// Truthiness, deliberately: `process.kill(0, sig)` signals the
|
|
618
618
|
// CALLER's own process group, so a pid of 0 turns a best-effort
|
|
@@ -620,17 +620,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
620
620
|
// `spawnBoot` is a seam now and a fake or future child could.
|
|
621
621
|
if (!child.pid)
|
|
622
622
|
return;
|
|
623
|
-
reapGroup(child.pid, sig);
|
|
623
|
+
reapGroup(child.pid, sig, leaderExited);
|
|
624
624
|
};
|
|
625
625
|
const passAndKill = (renderNote) => {
|
|
626
626
|
settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
|
|
627
627
|
killGroup('SIGTERM');
|
|
628
|
-
setTimeout(() => killGroup('SIGKILL'),
|
|
628
|
+
setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
|
|
629
629
|
};
|
|
630
630
|
const failAndKill = (detail) => {
|
|
631
631
|
settle({ outcome: 'fail', detail });
|
|
632
632
|
killGroup('SIGTERM');
|
|
633
|
-
setTimeout(() => killGroup('SIGKILL'),
|
|
633
|
+
setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
|
|
634
634
|
};
|
|
635
635
|
// Served apps only: poll for a listening socket owned by our process group.
|
|
636
636
|
// As soon as one appears the boot has demonstrably served → run the render
|
|
@@ -717,7 +717,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
717
717
|
detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
|
|
718
718
|
});
|
|
719
719
|
killGroup('SIGTERM');
|
|
720
|
-
setTimeout(() => killGroup('SIGKILL'),
|
|
720
|
+
setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
|
|
721
721
|
return;
|
|
722
722
|
}
|
|
723
723
|
passAndKill();
|
|
@@ -725,6 +725,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
725
725
|
let timer = setTimeout(onGrace, graceMs);
|
|
726
726
|
child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
|
|
727
727
|
child.on('exit', (status, signal) => {
|
|
728
|
+
leaderExited = true;
|
|
728
729
|
if (status === 0) {
|
|
729
730
|
if (expectServer && !listenerSeen) {
|
|
730
731
|
if (!canEnumerate) {
|
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 } 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
|
*
|
|
@@ -147,7 +142,7 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
147
142
|
const killAndSettle = () => {
|
|
148
143
|
kill();
|
|
149
144
|
clearTimeout(drain);
|
|
150
|
-
drain = setTimeout(() => done(null),
|
|
145
|
+
drain = setTimeout(() => done(null), EXIT_DRAIN_MS);
|
|
151
146
|
};
|
|
152
147
|
// NOT unref'd. This timer is the only bound left on every gate command,
|
|
153
148
|
// repo-health command and ACCEPT-debt re-run, and an unref'd timer is only
|
|
@@ -182,7 +177,7 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
182
177
|
settleIfDrained();
|
|
183
178
|
if (!settled) {
|
|
184
179
|
clearTimeout(drain);
|
|
185
|
-
drain = setTimeout(() => done(exitStatus),
|
|
180
|
+
drain = setTimeout(() => done(exitStatus), EXIT_DRAIN_MS);
|
|
186
181
|
}
|
|
187
182
|
});
|
|
188
183
|
});
|
|
@@ -616,6 +616,7 @@ async function drive(url, bin, userDataDir, credentials, onFacts, quietMs, signa
|
|
|
616
616
|
export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
617
617
|
let child = null;
|
|
618
618
|
let socket = null;
|
|
619
|
+
let reaped = false;
|
|
619
620
|
const close = () => {
|
|
620
621
|
try {
|
|
621
622
|
socket?.close();
|
|
@@ -623,8 +624,13 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
|
623
624
|
catch {
|
|
624
625
|
// socket already gone
|
|
625
626
|
}
|
|
626
|
-
|
|
627
|
-
|
|
627
|
+
// Once: a later pass would signal a pid the browser may have given up.
|
|
628
|
+
if (child?.pid && !reaped) {
|
|
629
|
+
reaped = true;
|
|
630
|
+
reapProcessGroup(child.pid, 'SIGKILL', {
|
|
631
|
+
leaderExited: child.exitCode !== null || child.signalCode !== null
|
|
632
|
+
});
|
|
633
|
+
}
|
|
628
634
|
return Promise.resolve();
|
|
629
635
|
};
|
|
630
636
|
signal?.addEventListener('abort', () => void close(), { once: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.46",
|
|
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",
|