@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
- stdout: EventEmitter | null;
26
- stderr: EventEmitter | null;
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
- const sink = sinkOpts ? new JsonEventSink(sinkOpts, hit => killProc({ by: 'loop', hit })) : null;
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 (reapGroup('SIGTERM'))
480
- setTimeout(() => reapGroup('SIGKILL'), KILL_GRACE_MS).unref();
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
- void (leftovers?.reap() ?? Promise.resolve()).then(() => settle(result));
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(): Promise<void>;
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;
@@ -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
- // 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
- }
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
- `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`,
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 started = startedByShells(parseProcessTable(await processTable()), shells);
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
- finally {
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
- /** Every process as `pid ppid creation-FILETIME`, one per line. */
218
- function processTable() {
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', () => resolve(out));
253
+ ps.on('close', () => {
254
+ clearTimeout(giveUp);
255
+ resolve(out);
256
+ });
227
257
  });
228
258
  }
229
259
  function taskkillTree(pid) {
@@ -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. The kill only reaches the
139
- * direct child, so this cannot wait to observe its effect it kills, gives
140
- * the pipes one drain, and reports `status: null` regardless.
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
- exitStatus = code;
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.46",
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",