@mjasnikovs/pi-task 0.40.46 → 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.
|
@@ -22,8 +22,13 @@ export interface WritableLike {
|
|
|
22
22
|
export interface ProcLike extends EventEmitter {
|
|
23
23
|
/** Present only when the child was spawned with stdin 'pipe' (prompt delivery). */
|
|
24
24
|
stdin: WritableLike | null;
|
|
25
|
-
|
|
26
|
-
|
|
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;
|
|
27
32
|
killed: boolean;
|
|
28
33
|
/** OS pid; used to signal the child's whole process GROUP (orphan reaping). May
|
|
29
34
|
* be undefined for a mock spawn or a spawn that failed. */
|
|
@@ -82,6 +87,12 @@ export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { pla
|
|
|
82
87
|
platform?: NodeJS.Platform;
|
|
83
88
|
leaderExited: boolean;
|
|
84
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;
|
|
85
96
|
/**
|
|
86
97
|
* Why runChild killed the child. Five sources converge on one kill path, and
|
|
87
98
|
* each names itself here rather than in its own flag — so a consumer reads ONE
|
|
@@ -65,6 +65,16 @@ export function reapProcessGroup(pid, sig, { platform = process.platform, leader
|
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
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
|
+
}
|
|
68
78
|
/** The cause a signal was aborted with, when its owner attached one. */
|
|
69
79
|
function abortCause(reason) {
|
|
70
80
|
const tagged = reason;
|
|
@@ -362,7 +372,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
362
372
|
}
|
|
363
373
|
}
|
|
364
374
|
: opts;
|
|
365
|
-
|
|
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;
|
|
366
383
|
// Dead-backend stall guard (json-events children only; see the option docs).
|
|
367
384
|
const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
|
|
368
385
|
const stallProbe = stall ?
|
|
@@ -476,8 +493,8 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
476
493
|
leaderExited = true;
|
|
477
494
|
clearTimeout(drain);
|
|
478
495
|
cleanup();
|
|
479
|
-
if (
|
|
480
|
-
|
|
496
|
+
if (ownGroup && proc.pid)
|
|
497
|
+
reapGroupAfterExit(proc.pid, platform);
|
|
481
498
|
if (sink)
|
|
482
499
|
sink.flush();
|
|
483
500
|
const text = sink ? sink.text : undefined;
|
|
@@ -494,11 +511,18 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
494
511
|
text,
|
|
495
512
|
modelError: sink?.modelError
|
|
496
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?.();
|
|
497
518
|
// Not settled before the leftovers are gone: the next phase needs their ports.
|
|
498
|
-
|
|
519
|
+
const done = () => settle(result);
|
|
520
|
+
void (leftovers?.reap() ?? Promise.resolve()).then(done, done);
|
|
499
521
|
};
|
|
500
522
|
proc.once('error', () => {
|
|
501
523
|
void leftovers?.reap();
|
|
524
|
+
proc.stdout?.destroy?.();
|
|
525
|
+
proc.stderr?.destroy?.();
|
|
502
526
|
settle({
|
|
503
527
|
stdout,
|
|
504
528
|
stderr,
|
|
@@ -3,7 +3,7 @@ export interface Leftovers {
|
|
|
3
3
|
/** The environment to spawn the child with. */
|
|
4
4
|
env: NodeJS.ProcessEnv;
|
|
5
5
|
/** End whatever the child left running. Never rejects. */
|
|
6
|
-
reap()
|
|
6
|
+
reap: () => Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
/** Start tracking a child about to be spawned with `base` as its environment. */
|
|
9
9
|
export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
|
package/dist/shared/leftovers.js
CHANGED
|
@@ -23,7 +23,7 @@ const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
|
|
|
23
23
|
/** Start tracking a child about to be spawned with `base` as its environment. */
|
|
24
24
|
export function trackLeftovers(platform, base, graceMs) {
|
|
25
25
|
if (platform === 'win32')
|
|
26
|
-
return trackShells(base);
|
|
26
|
+
return trackShells(base, graceMs);
|
|
27
27
|
if (platform === 'linux' || platform === 'darwin')
|
|
28
28
|
return trackToken(platform, base, graceMs);
|
|
29
29
|
return { env: base, reap: () => Promise.resolve() };
|
|
@@ -42,14 +42,30 @@ function trackToken(platform, base, graceMs) {
|
|
|
42
42
|
const find = () => platform === 'linux' ? linuxPidsWith(marker) : pidsInPsTable(darwinPsTable(), marker);
|
|
43
43
|
return {
|
|
44
44
|
env: { ...base, [LEFTOVER_TOKEN_ENV]: token },
|
|
45
|
-
reap: () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
})
|
|
53
69
|
};
|
|
54
70
|
}
|
|
55
71
|
/** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
|
|
@@ -118,11 +134,14 @@ export const BASH_ENV_SCRIPT = [
|
|
|
118
134
|
`if [ -n "\${${USER_BASH_ENV}:-}" ]; then . "$${USER_BASH_ENV}"; fi`,
|
|
119
135
|
'__pi_task_winpid=$$',
|
|
120
136
|
'if [ -r /proc/$$/winpid ]; then read -r __pi_task_winpid < /proc/$$/winpid; fi',
|
|
121
|
-
|
|
122
|
-
|
|
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`,
|
|
123
142
|
''
|
|
124
143
|
].join('\n');
|
|
125
|
-
function trackShells(base) {
|
|
144
|
+
function trackShells(base, graceMs) {
|
|
126
145
|
let dir;
|
|
127
146
|
try {
|
|
128
147
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-task-shells-'));
|
|
@@ -146,15 +165,19 @@ function trackShells(base) {
|
|
|
146
165
|
const shells = parseShells(fs.existsSync(registry) ? fs.readFileSync(registry, 'utf8') : '');
|
|
147
166
|
if (shells.length === 0)
|
|
148
167
|
return;
|
|
149
|
-
const
|
|
168
|
+
const table = await processTable(graceMs);
|
|
169
|
+
const started = startedByShells(parseProcessTable(table), shells);
|
|
150
170
|
await Promise.all(started.map(taskkillTree));
|
|
151
171
|
}
|
|
152
172
|
catch {
|
|
153
173
|
// best-effort, like every other reap
|
|
154
174
|
}
|
|
155
|
-
|
|
175
|
+
try {
|
|
156
176
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
157
177
|
}
|
|
178
|
+
catch {
|
|
179
|
+
// a handle still open in there; the run must not wait on it
|
|
180
|
+
}
|
|
158
181
|
}
|
|
159
182
|
};
|
|
160
183
|
}
|
|
@@ -214,16 +237,23 @@ export function parseProcessTable(text) {
|
|
|
214
237
|
}
|
|
215
238
|
return rows;
|
|
216
239
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
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) {
|
|
219
245
|
const query = 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate'
|
|
220
246
|
+ ' | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToFileTimeUtc())" }';
|
|
221
247
|
return new Promise(resolve => {
|
|
222
248
|
let out = '';
|
|
223
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);
|
|
224
251
|
ps.stdout.on('data', (d) => (out += d.toString()));
|
|
225
252
|
ps.on('error', () => resolve(''));
|
|
226
|
-
ps.on('close', () =>
|
|
253
|
+
ps.on('close', () => {
|
|
254
|
+
clearTimeout(giveUp);
|
|
255
|
+
resolve(out);
|
|
256
|
+
});
|
|
227
257
|
});
|
|
228
258
|
}
|
|
229
259
|
function taskkillTree(pid) {
|
package/dist/task/command-run.js
CHANGED
|
@@ -29,7 +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
|
+
import { EXIT_DRAIN_MS, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
|
|
33
33
|
import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
34
34
|
/**
|
|
35
35
|
* How much of ONE stream may be held in the HOST process, and how it is split.
|
|
@@ -97,6 +97,8 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
97
97
|
let exited = false;
|
|
98
98
|
let endedStreams = 0;
|
|
99
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.
|
|
100
102
|
const child = spawn(spec.bin, spec.args, {
|
|
101
103
|
cwd: spec.cwd,
|
|
102
104
|
// stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
|
|
@@ -104,6 +106,7 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
104
106
|
// a `cat`-style pipeline, a tool that prompts, a pager — would block
|
|
105
107
|
// until the kill timer fires instead of returning at once.
|
|
106
108
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
109
|
+
...ownGroupSpawnOptions(process.platform),
|
|
107
110
|
...(spec.env ? { env: spec.env } : {})
|
|
108
111
|
});
|
|
109
112
|
const done = (status, failure) => {
|
|
@@ -127,6 +130,8 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
127
130
|
done(exitStatus);
|
|
128
131
|
};
|
|
129
132
|
const kill = () => {
|
|
133
|
+
if (child.pid)
|
|
134
|
+
reapProcessGroup(child.pid, 'SIGKILL', { leaderExited: exited });
|
|
130
135
|
try {
|
|
131
136
|
child.kill('SIGKILL');
|
|
132
137
|
}
|
|
@@ -135,9 +140,9 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
135
140
|
}
|
|
136
141
|
};
|
|
137
142
|
/**
|
|
138
|
-
* The deadline and the cancel both END the run.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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.
|
|
141
146
|
*/
|
|
142
147
|
const killAndSettle = () => {
|
|
143
148
|
kill();
|
|
@@ -171,6 +176,8 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
171
176
|
child.on('exit', (code) => {
|
|
172
177
|
exited = true;
|
|
173
178
|
exitStatus = code;
|
|
179
|
+
if (child.pid)
|
|
180
|
+
reapGroupAfterExit(child.pid);
|
|
174
181
|
// Both ends of the same question: settle now if the pipes are already
|
|
175
182
|
// at EOF, otherwise settle after one short drain rather than waiting on
|
|
176
183
|
// whoever else is holding them.
|
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",
|