From 1fdac0d2e1e3da30c83227d4522f1f17cdcb09ef Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 16:55:16 +0800 Subject: [PATCH] feat(update): zero-dep WebUI updater runner --- bin/updater.mjs | 125 ++++++++++++++++++++++++++++++ src/lib/server/updaterMjs.test.ts | 71 +++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 bin/updater.mjs create mode 100644 src/lib/server/updaterMjs.test.ts diff --git a/bin/updater.mjs b/bin/updater.mjs new file mode 100644 index 0000000..bed509e --- /dev/null +++ b/bin/updater.mjs @@ -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 --parent-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)); // /v/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); diff --git a/src/lib/server/updaterMjs.test.ts b/src/lib/server/updaterMjs.test.ts new file mode 100644 index 0000000..3014b8d --- /dev/null +++ b/src/lib/server/updaterMjs.test.ts @@ -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 = {}) { + 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"); + }); +});