c8ctl-plugin-nano 1.35.1 → 1.35.2

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/c8ctl-plugin.js +78 -1
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -4404,6 +4404,16 @@ async function workAgent(req, flags) {
4404
4404
  // path via NANO_SUPERVISOR_ACTIVITY_FILE; a standalone `nano work` has no such
4405
4405
  // env var and writes nothing (this is entirely advisory).
4406
4406
  const activityFile = process.env.NANO_SUPERVISOR_ACTIVITY_FILE || null;
4407
+ // Supervised workers (spawned by the daemon, hence NANO_SUPERVISOR_ACTIVITY_FILE)
4408
+ // arm a parent-death watchdog: if the daemon dies *ungracefully* (SIGKILL /
4409
+ // crash / its host force-killed) it can't run its child-reaping shutdown, and
4410
+ // this worker would otherwise idle forever as an orphan reparented to init.
4411
+ // The watchdog makes the child self-exit the moment it is reparented. A
4412
+ // standalone `nano work` (no activity file) keeps classic nohup semantics.
4413
+ if (activityFile) {
4414
+ const daemonPid = Number.parseInt(process.env.NANO_SUPERVISOR_DAEMON_PID ?? '', 10);
4415
+ installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4416
+ }
4407
4417
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4408
4418
  const writeActivity = () => {
4409
4419
  if (!activityFile) return;
@@ -5549,6 +5559,63 @@ function waitForChildExit(child, timeoutMs) {
5549
5559
  });
5550
5560
  }
5551
5561
 
5562
+ /**
5563
+ * Self-terminate a supervised worker if its parent daemon dies *ungracefully*.
5564
+ *
5565
+ * The daemon reaps its `nano work` children on a clean stop/SIGTERM/SIGINT (see
5566
+ * `shutdown`). But a `SIGKILL`, a crash, or the daemon's host process being
5567
+ * force-killed cannot run that path, and the children — spawned attached, with
5568
+ * no controlling TTY — would otherwise survive forever as orphans reparented to
5569
+ * init (ppid 1). That is exactly how a test/agent run that force-kills its
5570
+ * supervisor leaks a whole idle worker fleet.
5571
+ *
5572
+ * The watchdog closes that gap from the child's side. The parent to watch is the
5573
+ * daemon pid the spawn recorded (`parentPid`, forwarded via
5574
+ * NANO_SUPERVISOR_DAEMON_PID) rather than a late `process.ppid` sample: a worker
5575
+ * whose ~async startup (an 8k-line dynamic import) is outrun by the daemon's
5576
+ * death is *already* reparented to init by the time it arms, so a bare
5577
+ * `process.ppid` read would miss the very race that leaks. Given the known pid,
5578
+ * the worker self-reaps whenever it is reparented away from it OR that pid is
5579
+ * gone — and does so *immediately* if it was orphaned during its own startup.
5580
+ *
5581
+ * The poll timer is unref'd so it never keeps an otherwise-idle worker alive;
5582
+ * the real work loop (or, in tests, an explicit keep-alive) holds the event loop
5583
+ * open while the worker is meant to run.
5584
+ *
5585
+ * Unix-only: reparent-to-init is a POSIX semantic; Windows job objects are the
5586
+ * equivalent lifecycle tie and are out of scope here (matching the daemon's
5587
+ * other `win32` guards). Returns a canceller so callers/tests can stop it.
5588
+ */
5589
+ function installParentDeathWatchdog({ intervalMs = 2000, parentPid, onOrphan, readPpid } = {}) {
5590
+ if (osPlatform() === 'win32') return () => {};
5591
+ // `readPpid` is an injectable seam (defaults to the live value) so the pid-1
5592
+ // container case — which a normal test process can't reproduce, since its own
5593
+ // ppid is never 1 — is unit-testable.
5594
+ const ppid = typeof readPpid === 'function' ? readPpid : () => process.ppid;
5595
+ // Prefer the daemon pid the spawn recorded (`explicit`); fall back to the
5596
+ // current parent. An explicit pid is authoritative even if it is 1 — the
5597
+ // daemon may legitimately run as PID 1 (a container entrypoint) — so we must
5598
+ // NOT treat that as "orphaned". A *fallback* ppid of 1, by contrast, means we
5599
+ // were already reparented to init with no daemon left to watch.
5600
+ const explicit = Number.isInteger(parentPid) && parentPid > 0;
5601
+ const watched = explicit ? parentPid : ppid();
5602
+ const orphaned = typeof onOrphan === 'function' ? onOrphan : () => process.exit(0);
5603
+ const isOrphan = () => {
5604
+ // Reparented away from the daemon (typically to init, pid 1) → orphaned.
5605
+ if (ppid() !== watched) return true;
5606
+ // Defensive: the daemon pid is gone even though ppid still names it (a
5607
+ // zombie/racey read). ESRCH ⇒ gone; EPERM ⇒ alive but not ours to signal.
5608
+ try { process.kill(watched, 0); return false; } catch (err) { return err.code !== 'EPERM'; }
5609
+ };
5610
+ // Orphaned already — self-reap now rather than idle forever. Either we fell
5611
+ // back to ppid and it is already init (no known parent), or the recorded
5612
+ // daemon is verifiably gone/reparented (e.g. it died before we armed).
5613
+ if ((!explicit && watched === 1) || isOrphan()) { orphaned(); return () => {}; }
5614
+ const timer = setInterval(() => { if (isOrphan()) { clearInterval(timer); orphaned(); } }, intervalMs);
5615
+ if (typeof timer.unref === 'function') timer.unref();
5616
+ return () => { try { clearInterval(timer); } catch { /* ignore */ } };
5617
+ }
5618
+
5552
5619
  // --- Daemon ----------------------------------------------------------------
5553
5620
 
5554
5621
  /**
@@ -5622,8 +5689,12 @@ async function runSupervisorDaemon() {
5622
5689
  // supervisor id, so the same profile launched twice is distinct end-to-end.
5623
5690
  // NANO_SUPERVISOR_ACTIVITY_FILE tells the child where to report per-job
5624
5691
  // activity for `supervisor status` (idle vs the job key it is servicing).
5692
+ // NANO_SUPERVISOR_DAEMON_PID hands the child our pid so its parent-death
5693
+ // watchdog can self-reap if we die ungracefully (SIGKILL/crash) and can't
5694
+ // run the child-draining shutdown — even if we die mid-startup, before the
5695
+ // child reparents to init.
5625
5696
  const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
5626
- env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile },
5697
+ env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile, NANO_SUPERVISOR_DAEMON_PID: String(process.pid) },
5627
5698
  stdio: ['ignore', fd, fd],
5628
5699
  });
5629
5700
  if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
@@ -5889,6 +5960,11 @@ async function runSupervisorDaemon() {
5889
5960
 
5890
5961
  process.once('SIGTERM', () => shutdown('SIGTERM'));
5891
5962
  process.once('SIGINT', () => shutdown('SIGINT'));
5963
+ // SIGHUP (controlling terminal/session gone) must also drain children rather
5964
+ // than let Node's default terminate the daemon and orphan the fleet. SIGKILL
5965
+ // and hard crashes still can't run this — the per-worker parent-death
5966
+ // watchdog is the backstop for those.
5967
+ process.once('SIGHUP', () => shutdown('SIGHUP'));
5892
5968
  dlog(`supervisor daemon up (pid ${process.pid}) — control ${socketPath}`);
5893
5969
  persist();
5894
5970
 
@@ -7998,6 +8074,7 @@ export {
7998
8074
  supervisorJobCell,
7999
8075
  supervisorWorkerActivityFile,
8000
8076
  WORK_FORWARD_FLAGS,
8077
+ installParentDeathWatchdog,
8001
8078
  runSupervisorDaemon,
8002
8079
  startSupervisorDaemon,
8003
8080
  supervisorRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.35.1",
3
+ "version": "1.35.2",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.1",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.1",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.1",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.1",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.1",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.1",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.1"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.2",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.2",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.2",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.2",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.2",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.2",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.2"
67
67
  }
68
68
  }