@mjasnikovs/pi-task 0.40.46 → 0.40.48
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.
|
|
@@ -95,8 +95,11 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
95
95
|
let settled = false;
|
|
96
96
|
let exitStatus = null;
|
|
97
97
|
let exited = false;
|
|
98
|
+
let killed = false;
|
|
98
99
|
let endedStreams = 0;
|
|
99
100
|
let drain;
|
|
101
|
+
// Its own process group: a pretest that backgrounds a daemon, a build that
|
|
102
|
+
// leaves a watcher, would otherwise hold their ports into the boot check.
|
|
100
103
|
const child = spawn(spec.bin, spec.args, {
|
|
101
104
|
cwd: spec.cwd,
|
|
102
105
|
// stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
|
|
@@ -104,6 +107,7 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
104
107
|
// a `cat`-style pipeline, a tool that prompts, a pager — would block
|
|
105
108
|
// until the kill timer fires instead of returning at once.
|
|
106
109
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
...ownGroupSpawnOptions(process.platform),
|
|
107
111
|
...(spec.env ? { env: spec.env } : {})
|
|
108
112
|
});
|
|
109
113
|
const done = (status, failure) => {
|
|
@@ -127,6 +131,8 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
127
131
|
done(exitStatus);
|
|
128
132
|
};
|
|
129
133
|
const kill = () => {
|
|
134
|
+
if (child.pid)
|
|
135
|
+
reapProcessGroup(child.pid, 'SIGKILL', { leaderExited: exited });
|
|
130
136
|
try {
|
|
131
137
|
child.kill('SIGKILL');
|
|
132
138
|
}
|
|
@@ -135,11 +141,12 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
135
141
|
}
|
|
136
142
|
};
|
|
137
143
|
/**
|
|
138
|
-
* The deadline and the cancel both END the run.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
144
|
+
* The deadline and the cancel both END the run. This cannot wait to observe
|
|
145
|
+
* the kill's effect — it kills, gives the pipes one drain, and reports
|
|
146
|
+
* `status: null` regardless.
|
|
141
147
|
*/
|
|
142
148
|
const killAndSettle = () => {
|
|
149
|
+
killed = true;
|
|
143
150
|
kill();
|
|
144
151
|
clearTimeout(drain);
|
|
145
152
|
drain = setTimeout(() => done(null), EXIT_DRAIN_MS);
|
|
@@ -170,7 +177,10 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
170
177
|
child.on('error', (e) => done(null, e.message));
|
|
171
178
|
child.on('exit', (code) => {
|
|
172
179
|
exited = true;
|
|
173
|
-
|
|
180
|
+
// taskkill /F ends a win32 child with exit code 1, which reads as a failed check.
|
|
181
|
+
exitStatus = killed ? null : code;
|
|
182
|
+
if (child.pid)
|
|
183
|
+
reapGroupAfterExit(child.pid);
|
|
174
184
|
// Both ends of the same question: settle now if the pipes are already
|
|
175
185
|
// at EOF, otherwise settle after one short drain rather than waiting on
|
|
176
186
|
// 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.48",
|
|
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",
|