feat(update): zero-dep WebUI updater runner
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
// Standalone WebUI updater. ZERO dependencies (node builtins only): this file
|
||||
// is copied into the staging dir and run while the @evoscientist/webui package
|
||||
// is being replaced on disk.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { appendFileSync, copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PID_WAIT_TIMEOUT_MS = 300_000;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { plan: null, parentPid: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === "--plan") args.plan = argv[++i];
|
||||
if (argv[i] === "--parent-pid") args.parentPid = parseInt(argv[++i], 10);
|
||||
}
|
||||
if (!args.plan || !args.parentPid) {
|
||||
console.error("usage: node updater.mjs --plan <plan.json> --parent-pid <pid>");
|
||||
process.exit(2);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function pidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
spawnSync(process.execPath, ["-e", `setTimeout(()=>{},${ms})`]);
|
||||
}
|
||||
|
||||
function waitForPidExit(pid, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!pidAlive(pid)) return true;
|
||||
const wait = Math.min(500, deadline - Date.now());
|
||||
if (wait > 0) sleepSync(wait);
|
||||
}
|
||||
return !pidAlive(pid);
|
||||
}
|
||||
|
||||
function appendLog(logPath, text) {
|
||||
appendFileSync(logPath, text);
|
||||
}
|
||||
|
||||
function writeResult(plan, staging, status, stage, message) {
|
||||
const payload = {
|
||||
status,
|
||||
stage,
|
||||
version: plan.version,
|
||||
previous_version: plan.previous_version ?? "",
|
||||
message,
|
||||
log: plan.log_path,
|
||||
};
|
||||
writeFileSync(plan.result_path, JSON.stringify(payload, null, 2));
|
||||
copyFileSync(plan.result_path, join(staging, "last-result.json"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { plan: planPath, parentPid } = parseArgs(process.argv);
|
||||
const plan = JSON.parse(readFileSync(planPath, "utf-8"));
|
||||
const staging = dirname(dirname(plan.result_path)); // <staging>/v<ver>/update-result.json
|
||||
appendLog(plan.log_path, `updater start: pid=${process.pid} parent=${parentPid}\n`);
|
||||
|
||||
if (!waitForPidExit(parentPid, PID_WAIT_TIMEOUT_MS)) {
|
||||
writeResult(plan, staging, "failed", "wait", `parent ${parentPid} still alive`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const install = spawnSync(plan.install_command[0], plan.install_command.slice(1), {
|
||||
cwd: plan.cwd,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
appendLog(
|
||||
plan.log_path,
|
||||
`install rc=${install.status}\n${install.stdout ?? ""}\n${install.stderr ?? ""}\n`
|
||||
);
|
||||
if (install.status !== 0) {
|
||||
writeResult(
|
||||
plan,
|
||||
staging,
|
||||
"failed",
|
||||
"install",
|
||||
(install.stderr ?? "").trim() || "install failed"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { plan, staging };
|
||||
}
|
||||
|
||||
// spawn() reports a missing binary via an async "error" event, not by
|
||||
// throwing — yield to the event loop before deciding the spawn worked.
|
||||
async function respawn(plan, staging) {
|
||||
let spawnError = null;
|
||||
try {
|
||||
const child = spawn(plan.respawn_command[0], plan.respawn_command.slice(1), {
|
||||
cwd: plan.cwd,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
spawnError = err;
|
||||
});
|
||||
child.unref();
|
||||
} catch (err) {
|
||||
spawnError = err;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (spawnError) {
|
||||
writeResult(plan, staging, "installed_restart_failed", "restart", String(spawnError));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeResult(plan, staging, "success", null, "update applied");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { plan, staging } = main();
|
||||
await respawn(plan, staging);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const UPDATER = resolve(__dirname, "../../../bin/updater.mjs");
|
||||
|
||||
function makePlan(dir: string, over: Record<string, unknown> = {}) {
|
||||
const plan = {
|
||||
version: "0.1.9",
|
||||
artifact: join(dir, "pkg.tgz"),
|
||||
install_command: [process.execPath, "-e", "process.exit(0)"],
|
||||
respawn_command: [process.execPath, "-e", "setTimeout(()=>{},50)"],
|
||||
cwd: dir,
|
||||
previous_version: "0.1.8",
|
||||
result_path: join(dir, "v0.1.9", "update-result.json"),
|
||||
log_path: join(dir, "v0.1.9", "updater.log"),
|
||||
...over,
|
||||
};
|
||||
mkdirSync(join(dir, "v0.1.9"), { recursive: true });
|
||||
const planPath = join(dir, "v0.1.9", "plan.json");
|
||||
writeFileSync(planPath, JSON.stringify(plan));
|
||||
return planPath;
|
||||
}
|
||||
|
||||
// A pid that does not exist makes process.kill(pid, 0) throw immediately,
|
||||
// so the updater treats the parent as already gone.
|
||||
const DEAD_PID = "4194303";
|
||||
|
||||
describe("bin/updater.mjs", () => {
|
||||
it("installs, respawns and writes success result", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "updater-"));
|
||||
const planPath = makePlan(dir);
|
||||
const res = spawnSync(process.execPath, [UPDATER, "--plan", planPath, "--parent-pid", DEAD_PID], {
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(res.status).toBe(0);
|
||||
const result = JSON.parse(readFileSync(join(dir, "v0.1.9", "update-result.json"), "utf-8"));
|
||||
expect(result.status).toBe("success");
|
||||
const last = JSON.parse(readFileSync(join(dir, "last-result.json"), "utf-8"));
|
||||
expect(last.version).toBe("0.1.9");
|
||||
});
|
||||
|
||||
it("reports install failure", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "updater-"));
|
||||
const planPath = makePlan(dir, {
|
||||
install_command: [process.execPath, "-e", "process.exit(3)"],
|
||||
});
|
||||
const res = spawnSync(process.execPath, [UPDATER, "--plan", planPath, "--parent-pid", DEAD_PID], {
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(res.status).toBe(1);
|
||||
const result = JSON.parse(readFileSync(join(dir, "v0.1.9", "update-result.json"), "utf-8"));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.stage).toBe("install");
|
||||
});
|
||||
|
||||
it("reports restart failure when respawn binary is missing", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "updater-"));
|
||||
const planPath = makePlan(dir, {
|
||||
respawn_command: ["/nonexistent/evosci-webui-binary"],
|
||||
});
|
||||
const res = spawnSync(process.execPath, [UPDATER, "--plan", planPath, "--parent-pid", DEAD_PID], {
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(res.status).toBe(1);
|
||||
const result = JSON.parse(readFileSync(join(dir, "v0.1.9", "update-result.json"), "utf-8"));
|
||||
expect(result.status).toBe("installed_restart_failed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user