@mjasnikovs/pi-task 0.40.44 → 0.40.45

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.
@@ -67,7 +67,10 @@ export type OwnGroupSpawnOptions = {
67
67
  * backgrounded. Best-effort: a group already gone is not an error. Kept beside
68
68
  * the spawn shape so a platform change edits one file.
69
69
  */
70
- export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, platform?: NodeJS.Platform): void;
70
+ export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }?: {
71
+ platform?: NodeJS.Platform;
72
+ leaderExited?: boolean;
73
+ }): void;
71
74
  /**
72
75
  * Why runChild killed the child. Five sources converge on one kill path, and
73
76
  * each names itself here rather than in its own flag — so a consumer reads ONE
@@ -1,4 +1,5 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
+ import * as path from 'node:path';
2
3
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
3
4
  import { realStallTimerDeps, StallProbe } from './stall-probe.js';
4
5
  import { workerChannel } from '../workers/worker-channels.js';
@@ -34,10 +35,18 @@ export function ownGroupSpawnOptions(platform) {
34
35
  * backgrounded. Best-effort: a group already gone is not an error. Kept beside
35
36
  * the spawn shape so a platform change edits one file.
36
37
  */
37
- export function reapProcessGroup(pid, sig, platform = process.platform) {
38
+ export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited = false } = {}) {
39
+ // taskkill walks the tree down from a live leader. Once the leader has exited its
40
+ // pid may already be another process's, and /T /F would kill that one instead.
41
+ if (platform === 'win32' && leaderExited)
42
+ return;
38
43
  try {
39
44
  if (platform === 'win32') {
40
- spawnSyncDefault('taskkill', ['/pid', String(pid), '/T', '/F']);
45
+ // System32's taskkill by absolute path, as pi's killProcessTree finds it, so
46
+ // neither PATH nor the working directory can supply another. Synchronous,
47
+ // unlike pi's: runChild kills the leader next, and the walk must end first.
48
+ const taskkill = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
49
+ spawnSyncDefault(taskkill, ['/pid', String(pid), '/T', '/F']);
41
50
  }
42
51
  else {
43
52
  process.kill(-pid, sig);
@@ -285,27 +294,29 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
285
294
  proc.stdin?.end();
286
295
  }
287
296
  // Reap the child's whole process group — the child itself AND anything it
288
- // backgrounded. No-op unless the child owns a group (ownGroup) and we have a pid.
297
+ // backgrounded.
298
+ let leaderExited = false;
299
+ proc.once('exit', () => (leaderExited = true));
289
300
  const reapGroup = (sig) => {
290
301
  if (!ownGroup || !proc.pid)
291
302
  return;
292
- reapProcessGroup(proc.pid, sig, platform);
303
+ reapProcessGroup(proc.pid, sig, { platform, leaderExited });
293
304
  };
294
305
  // One kill path for every source: SIGTERM, then SIGKILL after a grace
295
306
  // period if the child ignored the term. For a group-owning (model) child,
296
307
  // ALSO sweep the group so anything it backgrounded dies with it —
297
- // proc.kill hits only the leader, reapGroup the grandchildren. The FIRST
298
- // cause wins: a stall kill's SIGTERM can trip the abort path behind it.
308
+ // proc.kill hits only the leader, reapGroup the grandchildren. The sweep
309
+ // goes first because win32's taskkill walks the tree down from a live
310
+ // leader. The FIRST cause wins: a stall kill's SIGTERM can trip the abort
311
+ // path behind it.
299
312
  const killProc = (cause) => {
300
313
  kill ??= cause;
314
+ reapGroup('SIGTERM');
301
315
  proc.kill('SIGTERM');
302
- if (ownGroup)
303
- reapGroup('SIGTERM');
304
316
  setTimeout(() => {
317
+ reapGroup('SIGKILL');
305
318
  if (!proc.killed)
306
319
  proc.kill('SIGKILL');
307
- if (ownGroup)
308
- reapGroup('SIGKILL');
309
320
  }, KILL_GRACE_MS);
310
321
  };
311
322
  // Stream watchdog (json-events children only): a silent stream is killed
@@ -164,8 +164,8 @@ export interface BootDeps {
164
164
  * fake pid would signal something else entirely.
165
165
  */
166
166
  killGroup?: (pid: number, signal: NodeJS.Signals) => void;
167
- /** Which platform's group options to spawn with. Tests drive the win32 arm
168
- * from a POSIX host; production leaves it to `process.platform`. */
167
+ /** Which platform's spawn options, served-app rule and reap to use. Tests drive
168
+ * the win32 arm from a POSIX host; production leaves it to `process.platform`. */
169
169
  platform?: NodeJS.Platform;
170
170
  }
171
171
  /** What `runBootCheck` passes to its spawn. */
@@ -505,10 +505,6 @@ function holderIsOurs(command, boot) {
505
505
  function defaultSpawnBoot(bin, args, o) {
506
506
  return spawn(bin, args, o);
507
507
  }
508
- /** The real group teardown; the spawn shape's twin lives beside it. */
509
- function defaultKillGroup(pid, sig) {
510
- reapProcessGroup(pid, sig);
511
- }
512
508
  /**
513
509
  * Exercise the start command ONCE. All four outcomes below were run against real
514
510
  * child processes in throwaway projects.
@@ -557,7 +553,8 @@ function defaultKillGroup(pid, sig) {
557
553
  * reports it that way — see isCommandNotFound) → skip.
558
554
  */
559
555
  export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
560
- const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
556
+ const platform = opts.deps?.platform ?? process.platform;
557
+ const expectServer = (opts.expectServer ?? false) && platform !== 'win32';
561
558
  const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
562
559
  const httpProbe = opts.deps?.httpProbe ?? defaultHttpProbe;
563
560
  const canEnumerate = expectServer ? (opts.deps?.enumerationCapable ?? canEnumerateListeners)() : true;
@@ -579,7 +576,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
579
576
  return new Promise(resolve => {
580
577
  const child = spawnBoot(runner.bin, args, {
581
578
  cwd,
582
- ...ownGroupSpawnOptions(opts.deps?.platform ?? process.platform),
579
+ ...ownGroupSpawnOptions(platform),
583
580
  stdio: ['ignore', 'pipe', 'pipe'],
584
581
  env: {
585
582
  ...runnerEnv(runner),
@@ -612,7 +609,9 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
612
609
  clearInterval(poll);
613
610
  resolve(r);
614
611
  };
615
- const reapGroup = opts.deps?.killGroup ?? defaultKillGroup;
612
+ let leaderExited = false;
613
+ const reapGroup = opts.deps?.killGroup
614
+ ?? ((pid, sig) => reapProcessGroup(pid, sig, { platform, leaderExited }));
616
615
  const killGroup = (sig) => {
617
616
  // Truthiness, deliberately: `process.kill(0, sig)` signals the
618
617
  // CALLER's own process group, so a pid of 0 turns a best-effort
@@ -725,6 +724,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
725
724
  let timer = setTimeout(onGrace, graceMs);
726
725
  child.on('error', () => settle({ outcome: 'skip', spawnFailed: true }));
727
726
  child.on('exit', (status, signal) => {
727
+ leaderExited = true;
728
728
  if (status === 0) {
729
729
  if (expectServer && !listenerSeen) {
730
730
  if (!canEnumerate) {
@@ -616,6 +616,7 @@ async function drive(url, bin, userDataDir, credentials, onFacts, quietMs, signa
616
616
  export async function launchBrowser(bin, userDataDir, { signal } = {}) {
617
617
  let child = null;
618
618
  let socket = null;
619
+ let reaped = false;
619
620
  const close = () => {
620
621
  try {
621
622
  socket?.close();
@@ -623,8 +624,13 @@ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
623
624
  catch {
624
625
  // socket already gone
625
626
  }
626
- if (child?.pid)
627
- reapProcessGroup(child.pid, 'SIGKILL');
627
+ // Once: a later pass would signal a pid the browser may have given up.
628
+ if (child?.pid && !reaped) {
629
+ reaped = true;
630
+ reapProcessGroup(child.pid, 'SIGKILL', {
631
+ leaderExited: child.exitCode !== null || child.signalCode !== null
632
+ });
633
+ }
628
634
  return Promise.resolve();
629
635
  };
630
636
  signal?.addEventListener('abort', () => void close(), { once: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.44",
3
+ "version": "0.40.45",
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",