@mjasnikovs/pi-task 0.40.45 → 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 +20 -9
- package/dist/shared/child-process.js +77 -37
- package/dist/shared/leftovers.d.ts +45 -0
- package/dist/shared/leftovers.js +237 -0
- package/dist/task/boot-probe.d.ts +2 -1
- package/dist/task/boot-probe.js +6 -5
- package/dist/task/command-run.js +3 -8
- 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,13 +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, leaderExited }
|
|
81
|
+
export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }: {
|
|
71
82
|
platform?: NodeJS.Platform;
|
|
72
|
-
leaderExited
|
|
73
|
-
}):
|
|
83
|
+
leaderExited: boolean;
|
|
84
|
+
}): boolean;
|
|
74
85
|
/**
|
|
75
86
|
* Why runChild killed the child. Five sources converge on one kill path, and
|
|
76
87
|
* 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,36 +31,38 @@ 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
|
}
|
|
59
68
|
/** The cause a signal was aborted with, when its owner attached one. */
|
|
@@ -268,17 +277,19 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
268
277
|
const usesStdin = invocation.stdin !== undefined;
|
|
269
278
|
// Model children (json-events) run arbitrary bash — they can `bun run dev &`
|
|
270
279
|
// a server that outlives the child and holds a port, wrecking the final gate
|
|
271
|
-
// with a self-inflicted EADDRINUSE.
|
|
272
|
-
//
|
|
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.
|
|
273
282
|
// Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
|
|
274
283
|
const ownGroup = opts?.mode === 'json-events';
|
|
275
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;
|
|
276
287
|
const proc = spawn(invocation.command, invocation.args, {
|
|
277
288
|
cwd,
|
|
278
289
|
shell: false,
|
|
279
290
|
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
280
291
|
...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
|
|
281
|
-
...(
|
|
292
|
+
...(env ? { env } : {})
|
|
282
293
|
});
|
|
283
294
|
if (usesStdin) {
|
|
284
295
|
// A child killed before it read the prompt leaves the pipe broken, and an
|
|
@@ -294,28 +305,26 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
294
305
|
proc.stdin?.end();
|
|
295
306
|
}
|
|
296
307
|
// Reap the child's whole process group — the child itself AND anything it
|
|
297
|
-
// backgrounded.
|
|
308
|
+
// backgrounded. Set by 'exit' or 'close', whichever a spawn reports first.
|
|
298
309
|
let leaderExited = false;
|
|
299
|
-
proc.
|
|
300
|
-
const reapGroup = (sig) => {
|
|
301
|
-
if (!ownGroup || !proc.pid)
|
|
302
|
-
return;
|
|
303
|
-
reapProcessGroup(proc.pid, sig, { platform, leaderExited });
|
|
304
|
-
};
|
|
310
|
+
const reapGroup = (sig) => ownGroup && !!proc.pid && reapProcessGroup(proc.pid, sig, { platform, leaderExited });
|
|
305
311
|
// One kill path for every source: SIGTERM, then SIGKILL after a grace
|
|
306
312
|
// period if the child ignored the term. For a group-owning (model) child,
|
|
307
313
|
// ALSO sweep the group so anything it backgrounded dies with it —
|
|
308
314
|
// proc.kill hits only the leader, reapGroup the grandchildren. The sweep
|
|
309
315
|
// goes first because win32's taskkill walks the tree down from a live
|
|
310
316
|
// leader. The FIRST cause wins: a stall kill's SIGTERM can trip the abort
|
|
311
|
-
// path behind it.
|
|
317
|
+
// path behind it. A leader that already exited gave its own verdict.
|
|
312
318
|
const killProc = (cause) => {
|
|
319
|
+
if (leaderExited)
|
|
320
|
+
return;
|
|
313
321
|
kill ??= cause;
|
|
314
322
|
reapGroup('SIGTERM');
|
|
315
323
|
proc.kill('SIGTERM');
|
|
316
324
|
setTimeout(() => {
|
|
317
325
|
reapGroup('SIGKILL');
|
|
318
|
-
|
|
326
|
+
// Not `proc.killed`: that turns true once SIGTERM is delivered.
|
|
327
|
+
if (!leaderExited)
|
|
319
328
|
proc.kill('SIGKILL');
|
|
320
329
|
}, KILL_GRACE_MS);
|
|
321
330
|
};
|
|
@@ -432,15 +441,43 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
432
441
|
settled = true;
|
|
433
442
|
resolve(result);
|
|
434
443
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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();
|
|
444
481
|
if (sink)
|
|
445
482
|
sink.flush();
|
|
446
483
|
const text = sink ? sink.text : undefined;
|
|
@@ -448,7 +485,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
448
485
|
// killed means it ran on a prompt that never finished arriving. Its own
|
|
449
486
|
// exit describes that half-spec, so it cannot stand as the verdict.
|
|
450
487
|
const truncated = kill === undefined ? stdinError : undefined;
|
|
451
|
-
|
|
488
|
+
const result = {
|
|
452
489
|
stdout,
|
|
453
490
|
stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
|
|
454
491
|
exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
|
|
@@ -456,9 +493,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
456
493
|
...(kill ? { kill } : {}),
|
|
457
494
|
text,
|
|
458
495
|
modelError: sink?.modelError
|
|
459
|
-
}
|
|
460
|
-
|
|
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
|
+
};
|
|
461
500
|
proc.once('error', () => {
|
|
501
|
+
void leftovers?.reap();
|
|
462
502
|
settle({
|
|
463
503
|
stdout,
|
|
464
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,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 } 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
|
});
|
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",
|