@danypops/pi-packed 0.26.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.26.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": {
@@ -27,7 +27,7 @@
27
27
  "typecheck": "bunx tsc --noEmit"
28
28
  },
29
29
  "dependencies": {
30
- "@danypops/armada": "^0.4.8",
30
+ "@danypops/armada": "^0.4.9",
31
31
  "@danypops/packed": "^0.7.0",
32
32
  "@danypops/pi-extension-harness": "^0.7.0",
33
33
  "@danypops/vehicle-client": "^0.8.1",
@@ -25,8 +25,10 @@
25
25
  * step isn't a real convention, so detection reads what's already true
26
26
  * on disk instead of asking for one more declaration.
27
27
  */
28
+ import { spawn } from "node:child_process";
28
29
  import { readdirSync, readFileSync } from "node:fs";
29
30
  import { join } from "node:path";
31
+ import { fileURLToPath } from "node:url";
30
32
  import { createVehicleRegistrar, type VehicleRegistrar, type VehicleRegistrationOutcome, type VehicleSpec } from "@danypops/armada";
31
33
  import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
32
34
  import {
@@ -439,6 +441,63 @@ const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
439
441
  * daemonOptions() -- vehicleName: PACKED_VEHICLE_NAME). One canonical name, not two. */
440
442
  export const PACKED_VEHICLE_NAME = "pi-packed";
441
443
  const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
444
+ const SELF_RESTART_SCHEDULED_REASON = "self-restart scheduled via a detached armada CLI process";
445
+ /** Long enough for the HTTP response carrying this reason back to the caller to flush before the
446
+ * detached child's `armada restart` stops this very process. */
447
+ const SELF_RESTART_DELAY_MS = 500;
448
+
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. */
481
+ export type SelfRestartScheduler = (spec: ServiceSpec) => void;
482
+
483
+ export const scheduleSelfRestart: SelfRestartScheduler = (_spec) => {
484
+ let cliPath: string;
485
+ try {
486
+ cliPath = fileURLToPath(import.meta.resolve("@danypops/armada/cli"));
487
+ } catch {
488
+ return;
489
+ }
490
+ setTimeout(() => {
491
+ try {
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" });
495
+ child.unref();
496
+ } catch {
497
+ /* best-effort -- a human can still restart the service by hand */
498
+ }
499
+ }, SELF_RESTART_DELAY_MS).unref();
500
+ };
442
501
 
443
502
  /**
444
503
  * Sweeps every installed Packed package (global scope, plus a project's own
@@ -557,7 +616,10 @@ export interface DaemonServiceInstaller {
557
616
  * path/native controller are resolved once, not on every call.
558
617
  */
559
618
  export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
560
- constructor(private readonly registrar: VehicleRegistrar = createVehicleRegistrar()) {}
619
+ constructor(
620
+ private readonly registrar: VehicleRegistrar = createVehicleRegistrar(),
621
+ private readonly selfRestart: SelfRestartScheduler = scheduleSelfRestart,
622
+ ) {}
561
623
 
562
624
  async listRegisteredVehicles(): Promise<readonly VehicleSpec[]> {
563
625
  return this.registrar.listRegistered();
@@ -600,7 +662,8 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
600
662
  const resolved = resolveDaemonServiceSpec(piHome, source);
601
663
  if (!resolved.ok) return resolved;
602
664
  if (resolved.spec.name === PACKED_VEHICLE_NAME) {
603
- return { ok: true, restarted: false, reason: SELF_REGISTRATION_REASON, spec: resolved.spec };
665
+ this.selfRestart(resolved.spec);
666
+ return { ok: true, restarted: true, reason: SELF_RESTART_SCHEDULED_REASON, spec: resolved.spec };
604
667
  }
605
668
  if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
606
669
  return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
@@ -9,8 +9,12 @@ import {
9
9
  detectVehicleDaemonService,
10
10
  listManagedPackages,
11
11
  listUnconfiguredDaemonDependencies,
12
+ PACKED_VEHICLE_NAME,
12
13
  RealDaemonServiceInstaller,
13
14
  resolveDaemonServiceSpec,
15
+ scheduleSelfRestart,
16
+ selfRestartCommand,
17
+ type SelfRestartScheduler,
14
18
  } from "../src/daemon/daemon-service.ts";
15
19
 
16
20
  const roots: string[] = [];
@@ -470,6 +474,62 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
470
474
  }
471
475
  });
472
476
 
477
+ it("restart() schedules an out-of-process self-restart for Packed's own Vehicle, never touching Armada in-process", async () => {
478
+ const piHome = fakePiHome();
479
+ writePackage(piHome, "@danypops/pi-packed", { binPath: "service/src/cli/cli.ts", args: ["serve"] });
480
+ const harness = await createArmadaTestHarness();
481
+ const scheduled: Array<{ name: string }> = [];
482
+ const selfRestart: SelfRestartScheduler = (spec) => {
483
+ scheduled.push({ name: spec.name });
484
+ };
485
+ try {
486
+ const installer = new RealDaemonServiceInstaller(harness.registrar, selfRestart);
487
+ const outcome = await installer.restart(piHome, "npm:@danypops/pi-packed");
488
+
489
+ expect(outcome).toMatchObject({ ok: true, restarted: true, spec: { name: "pi-packed" } });
490
+ expect(scheduled).toEqual([{ name: PACKED_VEHICLE_NAME }]);
491
+ // never asked Armada to stop/replace/start the very process running this test
492
+ expect(harness.events()).toEqual([]);
493
+ } finally {
494
+ await harness.dispose();
495
+ }
496
+ });
497
+
498
+ it("scheduleSelfRestart resolves armada's real CLI module -- proves the wiring is live, not just typechecked", () => {
499
+ expect(() => import.meta.resolve("@danypops/armada/cli")).not.toThrow();
500
+ expect(typeof scheduleSelfRestart).toBe("function");
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
+
473
533
  it("install() reports a non-daemon package without touching Armada", async () => {
474
534
  const piHome = fakePiHome();
475
535
  writePackage(piHome, "some-pkg", undefined);