@danypops/pi-packed 0.27.0 → 0.27.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -446,12 +446,38 @@ const SELF_RESTART_SCHEDULED_REASON = "self-restart scheduled via a detached arm
446
446
  * detached child's `armada restart` stops this very process. */
447
447
  const SELF_RESTART_DELAY_MS = 500;
448
448
 
449
- /** Fire-and-forget: schedule Packed's own Vehicle to restart from OUTSIDE this process. Packed
450
- * can't safely stop+start itself in-process (the `stop` step would kill the very process handling
451
- * this request before it could `start` again) -- so this spawns a detached `armada restart
452
- * pi-packed` a beat later, after the response reporting "scheduled" has had time to flush, and lets
453
- * it outlive this process via detached+unref. Swallows a resolution failure silently: worst case is
454
- * the pre-existing behavior (a human restarts the service by hand), never a crash. */
449
+ /**
450
+ * Builds the command to launch armada's own `restart pi-packed` out-of-process. Plain
451
+ * `spawn(..., { detached: true }).unref()` is NOT enough on Linux: systemd's default
452
+ * KillMode=control-group kills every process in a unit's cgroup on `stop`, including a
453
+ * detached+unref'd child -- setsid() escapes a Unix process group, not a cgroup. Confirmed
454
+ * live: a bare detached spawn was killed mid-restart the instant the child's own `armada
455
+ * restart`'s `stop` step fired, because the child was still inside armada-pi-packed's own
456
+ * cgroup. `systemd-run --user --collect --unit=<name>` launches it as an independent
457
+ * transient unit with its own cgroup instead, immune to this unit's stop (confirmed live:
458
+ * a probe unit's child outlived its own service being stopped). launchd/Windows don't tear
459
+ * down a detached child's process tree the same way, so they use a plain spawn.
460
+ */
461
+ export function selfRestartCommand(
462
+ platform: NodeJS.Platform,
463
+ execPath: string,
464
+ cliPath: string,
465
+ unitName: string,
466
+ ): { readonly command: string; readonly arguments: readonly string[] } {
467
+ if (platform === "linux") {
468
+ return {
469
+ command: "systemd-run",
470
+ arguments: ["--user", "--collect", `--unit=${unitName}`, "--", execPath, cliPath, "restart", PACKED_VEHICLE_NAME, "--json"],
471
+ };
472
+ }
473
+ return { command: execPath, arguments: [cliPath, "restart", PACKED_VEHICLE_NAME, "--json"] };
474
+ }
475
+
476
+ /** Fire-and-forget: schedule Packed's own Vehicle to restart from OUTSIDE this process (see
477
+ * selfRestartCommand for why a plain detached spawn alone doesn't survive on Linux). Runs a
478
+ * beat later so the response reporting "scheduled" has time to flush first. Swallows a
479
+ * resolution/spawn failure silently: worst case is the pre-existing behavior (a human restarts
480
+ * the service by hand), never a crash. */
455
481
  export type SelfRestartScheduler = (spec: ServiceSpec) => void;
456
482
 
457
483
  export const scheduleSelfRestart: SelfRestartScheduler = (_spec) => {
@@ -463,10 +489,9 @@ export const scheduleSelfRestart: SelfRestartScheduler = (_spec) => {
463
489
  }
464
490
  setTimeout(() => {
465
491
  try {
466
- const child = spawn(process.execPath, [cliPath, "restart", PACKED_VEHICLE_NAME, "--json"], {
467
- detached: true,
468
- stdio: "ignore",
469
- });
492
+ const unitName = `armada-selfrestart-${PACKED_VEHICLE_NAME}-${process.pid}-${Date.now()}`;
493
+ const { command, arguments: arguments_ } = selfRestartCommand(process.platform, process.execPath, cliPath, unitName);
494
+ const child = spawn(command, arguments_, { detached: true, stdio: "ignore" });
470
495
  child.unref();
471
496
  } catch {
472
497
  /* best-effort -- a human can still restart the service by hand */
@@ -13,6 +13,7 @@ import {
13
13
  RealDaemonServiceInstaller,
14
14
  resolveDaemonServiceSpec,
15
15
  scheduleSelfRestart,
16
+ selfRestartCommand,
16
17
  type SelfRestartScheduler,
17
18
  } from "../src/daemon/daemon-service.ts";
18
19
 
@@ -499,6 +500,36 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
499
500
  expect(typeof scheduleSelfRestart).toBe("function");
500
501
  });
501
502
 
503
+ it("selfRestartCommand routes through systemd-run's own transient unit on Linux, not a bare detached spawn", () => {
504
+ // A bare `spawn(..., {detached:true}).unref()` alone does NOT escape systemd's
505
+ // KillMode=control-group -- confirmed live: it was killed mid-restart the instant the
506
+ // unit it was spawned from was stopped, because cgroups (unlike a Unix process group)
507
+ // aren't escaped by setsid(). systemd-run's own transient unit has an independent cgroup.
508
+ const result = selfRestartCommand("linux", "/usr/bin/node", "/opt/armada/dist/cli.js", "armada-selfrestart-pi-packed-123-456");
509
+ expect(result.command).toBe("systemd-run");
510
+ expect(result.arguments).toEqual([
511
+ "--user",
512
+ "--collect",
513
+ "--unit=armada-selfrestart-pi-packed-123-456",
514
+ "--",
515
+ "/usr/bin/node",
516
+ "/opt/armada/dist/cli.js",
517
+ "restart",
518
+ PACKED_VEHICLE_NAME,
519
+ "--json",
520
+ ]);
521
+ });
522
+
523
+ it("selfRestartCommand spawns the CLI directly on non-Linux platforms -- no systemd cgroup to escape", () => {
524
+ for (const platform of ["darwin", "win32"] as const) {
525
+ const result = selfRestartCommand(platform, "/usr/local/bin/node", "/opt/armada/dist/cli.js", "unused-on-this-platform");
526
+ expect(result).toEqual({
527
+ command: "/usr/local/bin/node",
528
+ arguments: ["/opt/armada/dist/cli.js", "restart", PACKED_VEHICLE_NAME, "--json"],
529
+ });
530
+ }
531
+ });
532
+
502
533
  it("install() reports a non-daemon package without touching Armada", async () => {
503
534
  const piHome = fakePiHome();
504
535
  writePackage(piHome, "some-pkg", undefined);