@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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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);
|