iterate-skill-installer 3.4.4 → 3.5.0

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.
Files changed (2) hide show
  1. package/lib/installer.js +58 -8
  2. package/package.json +1 -1
package/lib/installer.js CHANGED
@@ -492,40 +492,90 @@ class TailBuffer {
492
492
  // take minutes on slow networks, but a wedged child must never hang the
493
493
  // installer forever.
494
494
  const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
495
+ // Grace period between the SIGKILL and giving up on 'close'. A grandchild that
496
+ // inherited our stdio pipe keeps that pipe open, so Node withholds 'close'
497
+ // even though the leader is already dead — without this the promise would
498
+ // never settle and the installer would hang despite the timeout having fired.
499
+ const POST_KILL_SETTLE_MS = 5 * 1000;
495
500
  // Hard cap on retained stdout/stderr per command (memory bound).
496
501
  const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
497
502
 
503
+ // True only on POSIX: Windows has no process groups keyed by negative pid and
504
+ // `detached` there means "new console window", not "new process group".
505
+ const IS_POSIX = process.platform !== 'win32';
506
+
507
+ function killProcessTree(child) {
508
+ // The child was spawned detached (POSIX), so it is its own process-group
509
+ // leader and its negative pid addresses the WHOLE group. A bare
510
+ // `child.kill()` only signals the immediate process, so a `pip` that had
511
+ // already spawned a compiler/download helper outlived the kill and kept
512
+ // running (and holding our stdio pipe) after the installer "timed out".
513
+ if (IS_POSIX && child.pid) {
514
+ try {
515
+ process.kill(-child.pid, 'SIGKILL');
516
+ return;
517
+ } catch (err) {
518
+ // ESRCH: the group is already gone. EPERM: fall through to the leader.
519
+ if (err && err.code === 'ESRCH') return;
520
+ }
521
+ }
522
+ try {
523
+ child.kill('SIGKILL');
524
+ } catch {
525
+ // Already exited — nothing left to signal.
526
+ }
527
+ }
528
+
498
529
  function runCommand(bin, args, options = {}) {
499
530
  const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
500
531
  return new Promise((resolve, reject) => {
501
- const child = spawn(bin, args, { stdio: 'pipe', ...spawnOptions });
532
+ // `detached` on POSIX puts the child in its own process group so the
533
+ // timeout can kill the entire tree, not just the leader. It does not
534
+ // change how we await the child (we never unref it).
535
+ const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
502
536
  const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
503
537
  const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
504
538
  child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
505
539
  child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
506
540
  let killed = false;
541
+ let settled = false;
542
+ let killFallback = null;
543
+ const settle = (fn, value) => {
544
+ if (settled) return;
545
+ settled = true;
546
+ fn(value);
547
+ };
507
548
  const timer =
508
549
  timeout > 0
509
550
  ? setTimeout(() => {
510
551
  killed = true;
511
- child.kill('SIGKILL');
552
+ killProcessTree(child);
553
+ // The leader may be dead while a grandchild still holds our stdio
554
+ // pipe open, in which case 'close' never arrives. Guarantee the
555
+ // promise settles either way.
556
+ killFallback = setTimeout(() => {
557
+ settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
558
+ }, POST_KILL_SETTLE_MS);
512
559
  }, timeout)
513
560
  : null;
514
- child.on('close', (code) => {
561
+ const finish = (code) => {
515
562
  if (timer) clearTimeout(timer);
563
+ if (killFallback) clearTimeout(killFallback);
516
564
  if (killed) {
517
- reject(new InstallerError(`${bin} timed out after ${timeout} ms`));
565
+ settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
518
566
  return;
519
567
  }
520
568
  if (code === 0) {
521
- resolve(stdoutTail.toString());
569
+ settle(resolve, stdoutTail.toString());
522
570
  } else {
523
- reject(new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
571
+ settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
524
572
  }
525
- });
573
+ };
574
+ child.on('close', finish);
526
575
  child.on('error', (err) => {
527
576
  if (timer) clearTimeout(timer);
528
- reject(err);
577
+ if (killFallback) clearTimeout(killFallback);
578
+ settle(reject, err);
529
579
  });
530
580
  });
531
581
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.4.4",
3
+ "version": "3.5.0",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [