29a5da3e4e
`claude plugin update` unpacks the new version into a new cache directory and says "Restart to apply changes". A Claude Code process that was already running does not: it keeps loading the OLD directory, hooks and all. Every session such a process opened then ran the OLD plugin-bootstrap, which saw a state.json naming a different root, concluded the server had to be replaced, repointed agenthub.service at itself and restarted the dashboard onto the version the user had just upgraded away from. The dashboard reported the old version and offered the update again — once per session, forever, while installed_plugins.json and the cache both said the new version was installed. A /agenthub-update from that session "succeeded" and changed nothing. - `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING cache directory is the recorded install, the bootstrap logs one line and returns "superseded" — no deps, no unit, no server. `--force` does not override it: the self-update chain forces from the NEW directory, which is never superseded, so the only --force refused is a deliberate downgrade. It cannot hand over either — the process it would replace is the one loading it — so the log line says to restart Claude Code. - Matched by sibling directory, not by the <plugin>@<marketplace> key: correct without parsing a cache path or trusting a name. A phantom entry (installPath that no longer exists) is ignored rather than wedging the bootstrap shut. Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy of the repo — node_modules and all — per release; twelve of them measured 1.7 GB on one machine. It runs after writeState, so a bad prune cannot cost the record of a good bootstrap, and it protects four independent readings of "in use": this build, every installPath Claude Code records, the directory agenthub.service names (`unitRoot`, foreign unit included), and any directory still named in the user's settings.json hooks — that last one is the un-restarted process above, seen from the other side. Plus the 2 newest survivors as a rollback cushion, and nothing at all outside ~/.claude/plugins/cache/.
214 lines
8.0 KiB
JavaScript
214 lines
8.0 KiB
JavaScript
/**
|
|
* @file Tests scripts/systemd-service.js, and specifically the split that
|
|
* exists because of one incident: the plugin self-update killed the dashboard
|
|
* and left it dead.
|
|
*
|
|
* The bootstrap that performs an update is spawned by the running server, so
|
|
* it lives in that server's systemd cgroup. The old code SIGTERM-ed the server
|
|
* from inside that cgroup, which (a) exits 0, and `Restart=on-failure` reads a
|
|
* clean exit as "meant to stop", and (b) trips `KillMode=control-group`, so
|
|
* systemd reaped the bootstrap before it could write state or repoint the unit
|
|
* at the new plugin root. The dashboard then stayed down until some later
|
|
* Claude Code session happened to bootstrap again.
|
|
*
|
|
* Hence: writing the unit must not start anything, restarting must not block,
|
|
* and a clean exit must no longer be read as "leave it stopped".
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
|
|
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-systemd-"));
|
|
const prevHome = process.env.HOME;
|
|
process.env.HOME = TMP_HOME;
|
|
|
|
const svc = require("../../scripts/systemd-service");
|
|
|
|
/** Every systemctl invocation this test saw, as flat argv strings. */
|
|
let calls = [];
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
// Each test starts from "no unit on disk" — otherwise a previous test's
|
|
// write turns the next one's `written` into `unchanged`.
|
|
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
|
svc.__setExecImpl((args) => {
|
|
// `--version` is the availability probe every entry point runs first, not
|
|
// an action — recording it would bury the calls these tests are about.
|
|
if (args[0] !== "--version") calls.push(args.join(" "));
|
|
return { status: 0 };
|
|
});
|
|
});
|
|
|
|
after(() => {
|
|
svc.__reset();
|
|
if (prevHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
const UNIT = {
|
|
name: "agenthub",
|
|
description: "AgentHub dashboard server (Claude Code plugin)",
|
|
workingDir: "/plugin/root/new",
|
|
execStart: "/usr/bin/node /plugin/root/new/server/index.js",
|
|
env: { NODE_ENV: "production" },
|
|
};
|
|
|
|
function unitFile() {
|
|
return fs.readFileSync(svc.unitPath("agenthub"), "utf8");
|
|
}
|
|
|
|
const linuxOnly = { skip: process.platform !== "linux" ? "systemd is Linux-only" : false };
|
|
|
|
describe("systemd-service — unit content", linuxOnly, () => {
|
|
it("restarts always, because a graceful SIGTERM exits 0", () => {
|
|
svc.writeUserService(UNIT);
|
|
const body = unitFile();
|
|
|
|
// `on-failure` is what let one stray kill (or an out-of-band stop during
|
|
// an update) leave the dashboard down indefinitely.
|
|
assert.match(body, /^Restart=always$/m);
|
|
assert.doesNotMatch(body, /^Restart=on-failure$/m);
|
|
});
|
|
|
|
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.match(unitFile(), /agenthub-managed-unit/);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — writeUserService", linuxOnly, () => {
|
|
it("writes and enables the unit but starts NOTHING", () => {
|
|
// The point of the split: the update must be able to repoint the unit at
|
|
// the new plugin root while the old server is still serving requests.
|
|
const result = svc.writeUserService(UNIT);
|
|
|
|
assert.equal(result, "written");
|
|
assert.ok(
|
|
calls.some((c) => c.startsWith("daemon-reload")),
|
|
"a rewritten unit must be reloaded"
|
|
);
|
|
assert.ok(
|
|
calls.some((c) => c === "enable agenthub.service"),
|
|
"it must still be enabled for boot"
|
|
);
|
|
assert.ok(
|
|
!calls.some((c) => c.includes("--now") || c.startsWith("restart") || c.startsWith("start")),
|
|
`nothing may be started here, got: ${JSON.stringify(calls)}`
|
|
);
|
|
});
|
|
|
|
it("reports an identical unit as unchanged and touches systemd not at all", () => {
|
|
svc.writeUserService(UNIT);
|
|
calls = [];
|
|
|
|
assert.equal(svc.writeUserService(UNIT), "unchanged");
|
|
assert.deepEqual(calls, [], "an unchanged unit needs no systemctl call");
|
|
});
|
|
|
|
it("refuses a unit it did not write", () => {
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nExecStart=/opt/mine\n", "utf8");
|
|
|
|
assert.equal(svc.writeUserService(UNIT), "foreign");
|
|
assert.equal(svc.unitIsOurs("agenthub"), false);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — restartUserService", linuxOnly, () => {
|
|
it("never blocks, because the caller is inside the cgroup being restarted", () => {
|
|
assert.equal(svc.restartUserService("agenthub"), "restarted");
|
|
assert.deepEqual(calls, ["restart --no-block agenthub.service"]);
|
|
});
|
|
|
|
it("reports failure rather than throwing when systemctl refuses", () => {
|
|
// Available, but the restart itself is rejected — distinct from "no
|
|
// systemd here", which must not be reported as a failure.
|
|
svc.__setExecImpl((args) => ({ status: args[0] === "--version" ? 0 : 1 }));
|
|
assert.equal(svc.restartUserService("agenthub"), "failed");
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsStale", linuxOnly, () => {
|
|
it("is true when the unit names a plugin root that is no longer the current one", () => {
|
|
// Invisible while the server runs, fatal at the next reboot: the unit
|
|
// starts a path Claude Code garbage-collected after the update.
|
|
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), true);
|
|
});
|
|
|
|
it("is false for a unit already naming the current root", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
|
});
|
|
|
|
it("is false when there is no unit, and for one we do not own", () => {
|
|
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/new"), false);
|
|
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
|
assert.equal(
|
|
svc.unitIsStale("agenthub", "/plugin/root/new"),
|
|
false,
|
|
"a foreign unit is not ours to call stale, or to rewrite"
|
|
);
|
|
});
|
|
|
|
it("spawns nothing — the bootstrap fast path runs it on every SessionStart", () => {
|
|
svc.writeUserService(UNIT);
|
|
calls = [];
|
|
|
|
svc.unitIsStale("agenthub", "/plugin/root/new");
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitRoot", linuxOnly, () => {
|
|
it("reports the directory the unit runs from", () => {
|
|
svc.writeUserService({ ...UNIT, workingDir: "/plugin/root/old" });
|
|
assert.equal(svc.unitRoot("agenthub"), "/plugin/root/old");
|
|
});
|
|
|
|
it("reports a FOREIGN unit's directory too", () => {
|
|
// Not ours to rewrite, but still a directory the cache pruner may not
|
|
// delete out from under a running service.
|
|
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
|
|
fs.writeFileSync(svc.unitPath("agenthub"), "[Service]\nWorkingDirectory=/opt/mine\n", "utf8");
|
|
assert.equal(svc.unitRoot("agenthub"), "/opt/mine");
|
|
});
|
|
|
|
it("is null when there is no unit", () => {
|
|
assert.equal(svc.unitRoot("agenthub"), null);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsActive", linuxOnly, () => {
|
|
it("is true only for `active`", () => {
|
|
svc.__setExecImpl((args) => ({ status: 0, stdout: args[0] === "is-active" ? "active\n" : "" }));
|
|
assert.equal(svc.unitIsActive("agenthub"), true);
|
|
|
|
svc.__setExecImpl((args) => ({
|
|
status: 3,
|
|
stdout: args[0] === "is-active" ? "inactive\n" : "",
|
|
}));
|
|
assert.equal(svc.unitIsActive("agenthub"), false);
|
|
});
|
|
});
|
|
|
|
describe("systemd-service — unitIsOurs", linuxOnly, () => {
|
|
it("is false when no unit exists at all", () => {
|
|
fs.rmSync(svc.unitPath("agenthub"), { force: true });
|
|
assert.equal(svc.unitIsOurs("agenthub"), false);
|
|
});
|
|
|
|
it("is true for a unit this code wrote", () => {
|
|
svc.writeUserService(UNIT);
|
|
assert.equal(svc.unitIsOurs("agenthub"), true);
|
|
});
|
|
});
|