@mjasnikovs/pi-task 0.40.49 → 0.40.50

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.
@@ -446,10 +446,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
446
446
  // invocation including its full prompt, and `opts`, along with everything
447
447
  // the caller's callbacks close over.
448
448
  let settled = false;
449
+ let endLeftoverWait;
449
450
  const cleanup = () => {
450
451
  stallProbe?.stop();
451
452
  streamWatch?.stop();
452
453
  signal?.removeEventListener('abort', onAbort);
454
+ if (endLeftoverWait)
455
+ signal?.removeEventListener('abort', endLeftoverWait);
453
456
  };
454
457
  const settle = (result) => {
455
458
  cleanup();
@@ -516,8 +519,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
516
519
  proc.stdout?.destroy?.();
517
520
  proc.stderr?.destroy?.();
518
521
  // Not settled before the leftovers are gone: the next phase needs their ports.
519
- const done = () => settle(result);
520
- void (leftovers?.reap() ?? Promise.resolve()).then(done, done);
522
+ // A cancel arriving meanwhile ends the wait, not the reap.
523
+ endLeftoverWait = () => settle(result);
524
+ if (!settled)
525
+ signal?.addEventListener('abort', endLeftoverWait, { once: true });
526
+ void (leftovers?.reap() ?? Promise.resolve()).then(endLeftoverWait, endLeftoverWait);
521
527
  };
522
528
  proc.once('error', () => {
523
529
  void leftovers?.reap();
@@ -5,7 +5,12 @@ export interface Leftovers {
5
5
  /** End whatever the child left running. Never rejects. */
6
6
  reap: () => Promise<void>;
7
7
  }
8
- /** Start tracking a child about to be spawned with `base` as its environment. */
8
+ /**
9
+ * Start tracking a child about to be spawned with `base` as its environment.
10
+ * `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
11
+ * `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
12
+ * windows runner).
13
+ */
9
14
  export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
10
15
  /**
11
16
  * A System32 executable by absolute path, so neither PATH nor the working directory
@@ -20,7 +20,12 @@ import * as path from 'node:path';
20
20
  export const LEFTOVER_TOKEN_ENV = 'PI_TASK_LEFTOVER_TOKEN';
21
21
  const SHELL_REGISTRY_ENV = 'PI_TASK_SHELL_REGISTRY';
22
22
  const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
23
- /** Start tracking a child about to be spawned with `base` as its environment. */
23
+ /**
24
+ * Start tracking a child about to be spawned with `base` as its environment.
25
+ * `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
26
+ * `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
27
+ * windows runner).
28
+ */
24
29
  export function trackLeftovers(platform, base, graceMs) {
25
30
  if (platform === 'win32')
26
31
  return trackShells(base);
@@ -28,6 +28,7 @@
28
28
  * It also closes an asymmetry: `BootDeps` carries twelve injectable probes for
29
29
  * the gate's boot half, while its command half had none.
30
30
  */
31
+ import { type ChildProcess, type SpawnOptions } from 'node:child_process';
31
32
  /** What one finished command looks like, stripped of how it was spawned. */
32
33
  export interface CommandRun {
33
34
  /** The runner binary itself never started (ENOENT, no POSIX shell). */
@@ -51,7 +52,7 @@ export interface CommandSpec {
51
52
  * rather than the live `process.env`.
52
53
  */
53
54
  env?: Record<string, string | undefined>;
54
- /** The caller's cancel. Kills the child; the run reads as `status: null`. */
55
+ /** The caller's cancel. Kills a running child, whose run then reads as `status: null`. */
55
56
  signal?: AbortSignal;
56
57
  }
57
58
  /**
@@ -66,6 +67,11 @@ export interface CommandSpec {
66
67
  * paint a single frame through it.
67
68
  */
68
69
  export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
70
+ /** What a runner spawns with. Tests hand in a fake child, and another platform's rules. */
71
+ export interface CommandSpawner {
72
+ spawn: (bin: string, args: string[], options: SpawnOptions) => ChildProcess;
73
+ platform: NodeJS.Platform;
74
+ }
69
75
  /**
70
76
  * The real runner: one bounded child, output collected, never rejects.
71
77
  *
@@ -83,7 +89,7 @@ export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
83
89
  * pipe. Waiting for `close` is therefore waiting for something no timeout can
84
90
  * reach. `exit` settles the run, and the deadline settles it itself.
85
91
  */
86
- export declare const spawnCommand: CommandRunner;
92
+ export declare const spawnCommand: (spec: CommandSpec, spawner?: CommandSpawner) => Promise<CommandRun>;
87
93
  /**
88
94
  * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
89
95
  * a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
@@ -29,7 +29,8 @@
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, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
32
+ import { EXIT_DRAIN_MS, KILL_GRACE_MS, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
33
+ import { trackLeftovers } from '../shared/leftovers.js';
33
34
  import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
34
35
  /**
35
36
  * How much of ONE stream may be held in the HOST process, and how it is split.
@@ -72,6 +73,7 @@ class BoundedOutput {
72
73
  : this.head + this.tail;
73
74
  }
74
75
  }
76
+ const HOST_SPAWNER = { spawn, platform: process.platform };
75
77
  /**
76
78
  * The real runner: one bounded child, output collected, never rejects.
77
79
  *
@@ -89,41 +91,56 @@ class BoundedOutput {
89
91
  * pipe. Waiting for `close` is therefore waiting for something no timeout can
90
92
  * reach. `exit` settles the run, and the deadline settles it itself.
91
93
  */
92
- export const spawnCommand = spec => new Promise(resolve => {
94
+ export const spawnCommand = (spec, spawner = HOST_SPAWNER) => new Promise(resolve => {
95
+ const { platform } = spawner;
93
96
  const out = new BoundedOutput();
94
97
  const err = new BoundedOutput();
98
+ let result;
95
99
  let settled = false;
100
+ let cutShort = false;
96
101
  let exitStatus = null;
97
102
  let exited = false;
98
103
  let killed = false;
99
104
  let endedStreams = 0;
100
105
  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.
103
- const child = spawn(spec.bin, spec.args, {
106
+ // Its own process group, and descendants found by what they inherit: a pretest
107
+ // that backgrounds a daemon, a build that leaves a watcher, would otherwise
108
+ // hold their ports into the boot check.
109
+ const leftovers = trackLeftovers(platform, spec.env ?? process.env, KILL_GRACE_MS);
110
+ const child = spawner.spawn(spec.bin, spec.args, {
104
111
  cwd: spec.cwd,
105
112
  // stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
106
113
  // stdio is a live pipe nobody ever ends, so a check that reads stdin —
107
114
  // a `cat`-style pipeline, a tool that prompts, a pager — would block
108
115
  // until the kill timer fires instead of returning at once.
109
116
  stdio: ['ignore', 'pipe', 'pipe'],
110
- ...ownGroupSpawnOptions(process.platform),
111
- ...(spec.env ? { env: spec.env } : {})
117
+ ...ownGroupSpawnOptions(platform),
118
+ env: leftovers.env
112
119
  });
113
- const done = (status, failure) => {
114
- if (settled)
120
+ const settle = () => {
121
+ if (settled || !result)
115
122
  return;
116
123
  settled = true;
117
124
  clearTimeout(timer);
118
- clearTimeout(drain);
119
125
  spec.signal?.removeEventListener('abort', killAndSettle);
120
- resolve({
126
+ resolve(result);
127
+ };
128
+ const done = (status, failure) => {
129
+ if (result)
130
+ return;
131
+ clearTimeout(drain);
132
+ result = {
121
133
  failedToStart: failure !== undefined,
122
134
  ...(failure === undefined ? {} : { failureMessage: failure }),
123
135
  status,
124
136
  stdout: out.toString(),
125
137
  stderr: err.toString()
126
- });
138
+ };
139
+ // Not settled before the leftovers are gone: the boot check binds their
140
+ // ports. The deadline and the cancel still end the wait.
141
+ void leftovers.reap().then(settle);
142
+ if (cutShort)
143
+ settle();
127
144
  };
128
145
  const expectedStreams = (child.stdout ? 1 : 0) + (child.stderr ? 1 : 0);
129
146
  const settleIfDrained = () => {
@@ -132,7 +149,7 @@ export const spawnCommand = spec => new Promise(resolve => {
132
149
  };
133
150
  const kill = () => {
134
151
  if (child.pid)
135
- reapProcessGroup(child.pid, 'SIGKILL', { leaderExited: exited });
152
+ reapProcessGroup(child.pid, 'SIGKILL', { platform, leaderExited: exited });
136
153
  try {
137
154
  child.kill('SIGKILL');
138
155
  }
@@ -143,9 +160,14 @@ export const spawnCommand = spec => new Promise(resolve => {
143
160
  /**
144
161
  * The deadline and the cancel both END the run. This cannot wait to observe
145
162
  * the kill's effect — it kills, gives the pipes one drain, and reports
146
- * `status: null` regardless.
163
+ * `status: null` regardless. A leader that already exited keeps its status.
147
164
  */
148
165
  const killAndSettle = () => {
166
+ cutShort = true;
167
+ if (result)
168
+ return settle();
169
+ if (exited)
170
+ return;
149
171
  killed = true;
150
172
  kill();
151
173
  clearTimeout(drain);
@@ -177,15 +199,16 @@ export const spawnCommand = spec => new Promise(resolve => {
177
199
  child.on('error', (e) => done(null, e.message));
178
200
  child.on('exit', (code) => {
179
201
  exited = true;
180
- // taskkill /F ends a win32 child with exit code 1, which reads as a failed check.
181
- exitStatus = killed ? null : code;
202
+ // A win32 kill ends the child with exit code 1, the code a failed check exits
203
+ // with too. A POSIX kill leaves no code, so a code there is the child's own.
204
+ exitStatus = killed && platform === 'win32' && code === 1 ? null : code;
182
205
  if (child.pid)
183
- reapGroupAfterExit(child.pid);
206
+ reapGroupAfterExit(child.pid, platform);
184
207
  // Both ends of the same question: settle now if the pipes are already
185
208
  // at EOF, otherwise settle after one short drain rather than waiting on
186
209
  // whoever else is holding them.
187
210
  settleIfDrained();
188
- if (!settled) {
211
+ if (!result) {
189
212
  clearTimeout(drain);
190
213
  drain = setTimeout(() => done(exitStatus), EXIT_DRAIN_MS);
191
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.49",
3
+ "version": "0.40.50",
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",