From c8b38bf0cc170dd15db515613aaa0e50c8d5e641 Mon Sep 17 00:00:00 2001 From: Cline Date: Fri, 8 May 2026 00:42:41 +0000 Subject: [PATCH] feat(desktop): schedule runtime auto updates --- packages/desktop/scripts/stage-cli.mjs | 12 +- packages/desktop/src/main.ts | 39 ++ packages/desktop/src/preload.ts | 33 +- packages/desktop/src/runtime-auto-update.ts | 257 +++++++++++ .../desktop/test/runtime-auto-update.test.ts | 426 ++++++++++++++++++ 5 files changed, 762 insertions(+), 5 deletions(-) create mode 100644 packages/desktop/src/runtime-auto-update.ts create mode 100644 packages/desktop/test/runtime-auto-update.test.ts diff --git a/packages/desktop/scripts/stage-cli.mjs b/packages/desktop/scripts/stage-cli.mjs index e482e269b..969e38cb6 100644 --- a/packages/desktop/scripts/stage-cli.mjs +++ b/packages/desktop/scripts/stage-cli.mjs @@ -5,7 +5,7 @@ * was skipped. */ -import { cpSync, existsSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +16,9 @@ const distDir = resolve(repoRoot, "dist"); const webUiIndex = resolve(distDir, "web-ui/index.html"); const cliEntry = resolve(distDir, "cli.js"); const stageDir = resolve(desktopRoot, "cli"); +const runtimeVersion = JSON.parse( + readFileSync(resolve(repoRoot, "package.json"), "utf8"), +).version; function fail(message) { console.error(`\n[stage:cli] ERROR: ${message}\n`); @@ -47,9 +50,12 @@ cpSync(distDir, stageDir, { recursive: true }); // `import` statement at module top. Drop a minimal package.json next to // the staged cli.js so Node treats it as ESM regardless of what lives // further up the tree. +// Embed the runtime's version next to the staged cli.js so the desktop +// shell can read the actual bundled-runtime version at boot (separate +// from `app.getVersion()`, which returns the Electron shell version). writeFileSync( resolve(stageDir, "package.json"), - `${JSON.stringify({ type: "module" }, null, 2)}\n`, + `${JSON.stringify({ type: "module", version: runtimeVersion }, null, 2)}\n`, ); -console.log(`[stage:cli] Staged ${distDir} → ${stageDir}`); +console.log(`[stage:cli] Staged ${distDir} → ${stageDir} (runtime ${runtimeVersion})`); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e5d79a207..d6c70d2e2 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -9,6 +9,7 @@ import { parseProtocolUrl, registerProtocol, } from "./protocol-handler.js"; +import { createRuntimeAutoUpdate } from "./runtime-auto-update.js"; import { RuntimeOrchestrator } from "./runtime-orchestrator.js"; import { WindowFactory } from "./window-factory.js"; import { WindowRegistry } from "./window-registry.js"; @@ -34,11 +35,21 @@ let isQuitting = false; const registry = new WindowRegistry(); +const autoUpdate = createRuntimeAutoUpdate({ + isPackaged: app.isPackaged, + userData: app.getPath("userData"), + resourcesPath: process.resourcesPath, + shellVersion: app.getVersion(), + broadcast: broadcastToAllRenderers, +}); + const orchestrator = new RuntimeOrchestrator({ host: DEFAULT_HOST, port: DEFAULT_PORT, healthTimeoutMs: HEALTH_TIMEOUT_MS, resolveCliShimPath, + resolveCliEntryOverride: autoUpdate?.resolveCliEntryOverride, + onCliEntryOverrideFailed: autoUpdate?.onCliEntryOverrideFailed, }); const windowFactory = new WindowFactory({ @@ -83,6 +94,27 @@ orchestrator.on("url-changed", (url) => { }); orchestrator.on("crashed", () => windowFactory.showDisconnectedScreen()); +/** + * Fan an IPC notification out to every renderer. Update banners are + * global facts and should appear regardless of focused window. Uses + * `BrowserWindow.getAllWindows()` (not the registry) so transient + * windows like the OAuth popup are also covered. Best-effort: a + * destroyed-but-not-reaped window can throw synchronously. + */ +function broadcastToAllRenderers(channel: string, ...args: unknown[]): void { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.webContents.send(channel, ...args); + } catch (err) { + console.warn( + `[desktop] IPC broadcast on ${channel} failed for one window:`, + err instanceof Error ? err.message : err, + ); + } + } +} + function handleProtocolUrl(raw: string): void { const parsed = parseProtocolUrl(raw); if (!parsed) { @@ -264,6 +296,8 @@ function wireAppLifecycle(): void { windowFactory.showDisconnectedScreen(); } + // Background runtime-update checks. Packaged-only. + autoUpdate?.scheduleChecks(); }); app.on("window-all-closed", () => { @@ -286,6 +320,11 @@ function wireAppLifecycle(): void { // kill any post-teardown spawn. event.preventDefault(); try { + // Stop the update timers before shutdown so a check can't + // fire mid-teardown. Any extract already past pacote.extract + // finishes cleanly and writes the pointer; an earlier-stage + // one gets dropped — its `.partial/` is swept next boot. + autoUpdate?.stop(); await orchestrator.shutdown(); } catch (err) { console.error( diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 071f0b3af..9baf5d9e6 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,5 +1,22 @@ import { contextBridge, ipcRenderer } from "electron"; +/** + * Subscribe to a main→renderer channel and return a detach function. + * Returning detach (instead of exposing `removeListener` directly) + * prevents one renderer from removing listeners installed by another. + */ +function subscribe( + channel: string, + listener: (...args: T) => void, +): () => void { + const wrapped = (_e: Electron.IpcRendererEvent, ...args: T): void => + listener(...args); + ipcRenderer.on(channel, wrapped); + return () => { + ipcRenderer.removeListener(channel, wrapped); + }; +} + const desktopApi = { platform: process.platform, @@ -10,8 +27,20 @@ const desktopApi = { restartRuntime(): void { ipcRenderer.send("restart-runtime"); }, + + /** Fires after the background updater stages a new runtime. The + * renderer should surface a "Restart to apply " banner. */ + onUpdateStaged(listener: (version: string) => void): () => void { + return subscribe<[string]>("runtime:update-staged", listener); + }, + + /** Fires after a staged runtime failed startup and was rolled back. + * Payload is the demoted version (or `null` if unknown). */ + onRuntimeRolledBack( + listener: (demotedVersion: string | null) => void, + ): () => void { + return subscribe<[string | null]>("runtime:rolled-back", listener); + }, } as const; contextBridge.exposeInMainWorld("desktop", desktopApi); - -export type DesktopApi = typeof desktopApi; diff --git a/packages/desktop/src/runtime-auto-update.ts b/packages/desktop/src/runtime-auto-update.ts new file mode 100644 index 000000000..bd779b2f0 --- /dev/null +++ b/packages/desktop/src/runtime-auto-update.ts @@ -0,0 +1,257 @@ +/** + * Wires runtime-store + runtime-update into the orchestrator's + * `cliEntryOverride` callbacks and a 30s/30min background check + * schedule. Packaged-only — returns `null` in dev so the orchestrator + * just spawns the bundled cli. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import semver from "semver"; + +import { + cleanupPartials, + clearPointer, + markBadVersion, + pointerFileExists, + readPointer, + removeVersionDir, + resolvePointerCliEntry, + versionFromCliEntry, +} from "./runtime-store.js"; +import { checkAndStageLatestRuntime } from "./runtime-update.js"; + +const FIRST_CHECK_DELAY_MS = 30_000; +const CHECK_INTERVAL_MS = 30 * 60_000; + +export interface RuntimeAutoUpdate { + resolveCliEntryOverride: () => string | null; + /** + * `cliEntry` must be exactly the path returned by an earlier + * `resolveCliEntryOverride()` call — i.e. captured at spawn time + * by the orchestrator. We use it to roll back the version that + * actually ran, even if a concurrent background stage has since + * advanced the pointer to a newer version. + */ + onCliEntryOverrideFailed: (reason: string, cliEntry: string) => void; + scheduleChecks(): void; + stop(): void; +} + +export interface RuntimeAutoUpdateDeps { + isPackaged: boolean; + userData: string; + resourcesPath: string; + shellVersion: string; + broadcast: (channel: string, ...args: unknown[]) => void; +} + +export function createRuntimeAutoUpdate( + deps: RuntimeAutoUpdateDeps, +): RuntimeAutoUpdate | null { + if (!deps.isPackaged) return null; + + const unpacked = path.join(deps.resourcesPath, "app.asar.unpacked"); + const bundledVersion = + readBundledVersion(path.join(unpacked, "cli")) ?? deps.shellVersion; + // `node-pty` lives under `app.asar.unpacked/node_modules/` after + // `electron-builder install-app-deps` rebuilds it for this Electron. + const nativeDepsSource = path.join(unpacked, "node_modules"); + + // Sweep stale `.partial/` from interrupted extracts before any + // new staging can collide with them. + try { + cleanupPartials(deps.userData); + } catch (e) { + warn("cleanupPartials", e); + } + + let firstTimer: NodeJS.Timeout | null = null; + let interval: NodeJS.Timeout | null = null; + let inFlight = false; + + const dropPointer = (why: string): void => { + console.warn(`[desktop] ${why} — clearing pointer.`); + try { + clearPointer(deps.userData); + } catch (e) { + warn("clearPointer", e); + } + }; + + // Effective launch version is `max(pointer, bundled)`. A pointer + // at-or-below bundled is stale (e.g. user upgraded the shell while + // userData still pointed at an older staged runtime); without this + // guard we'd keep launching the older runtime forever. Also + // self-repairs pointers whose `cliEntry` no longer exists, and + // removes invalid `current.json` files (corrupt JSON, non-canonical + // path, non-absolute path) so they can't linger as renderer-visible + // state forever. + const loadOverride = (): string | null => { + const pointer = readPointer(deps.userData); + if (!pointer) { + if (pointerFileExists(deps.userData)) { + dropPointer("Invalid current.json"); + } + return null; + } + if (semver.lte(pointer.version, bundledVersion)) { + dropPointer( + `Staged ${pointer.version} <= bundled ${bundledVersion}`, + ); + return null; + } + const cli = resolvePointerCliEntry(deps.userData); + if (cli) return cli; + dropPointer("Staged cliEntry missing"); + return null; + }; + + // Rollback for the version that *actually ran* — derived from the + // captured cliEntry, not from re-reading the pointer. The orchestrator + // runs the readiness probe asynchronously after spawn; in the + // meantime, a background `runCheck()` may have completed a successful + // staging and replaced the pointer with a newer version. Rolling back + // "whatever the pointer says now" would mark/remove the *new* + // version that hasn't even been tried yet. + const onFailed = (reason: string, cliEntry: string): void => { + const failedVersion = versionFromCliEntry(deps.userData, cliEntry); + const current = readPointer(deps.userData); + const pointerStillFailed = + current !== null && current.cliEntry === cliEntry; + console.warn( + `[desktop] Staged runtime failed (${reason})${ + failedVersion ? `; rolling back ${failedVersion}` : "" + }${pointerStillFailed ? "" : " (pointer already advanced)"}.`, + ); + // `markBadVersion` is the critical step: without it the next + // `runCheck` would just re-stage the same broken version. We + // gate `clearPointer` on it so a transient write failure (disk + // full, EPERM) doesn't leave the system in a state where the + // pointer is dropped *and* the version isn't blacklisted — + // which would loop on every `runCheck` ad infinitum. With this + // gating, the user still launches successfully (via the + // orchestrator's same-launch fallback to bundled), and we + // retry `markBadVersion` on every subsequent boot until it + // succeeds. + let marked = false; + if (failedVersion) { + try { + markBadVersion(deps.userData, failedVersion); + marked = true; + } catch (e) { + warn("markBadVersion", e); + } + if (marked) { + try { + removeVersionDir(deps.userData, failedVersion); + } catch (e) { + warn("removeVersionDir", e); + } + } + } + // Two independent gates on `clearPointer`: + // - `marked`: don't drop the pointer if we couldn't blacklist + // the failed version (see comment above). + // - `pointerStillFailed`: a concurrent `runCheck` may have + // already advanced the pointer to a newer presumed-good + // version; don't clobber that. + if (marked && pointerStillFailed) { + try { + clearPointer(deps.userData); + } catch (e) { + warn("clearPointer", e); + } + } + deps.broadcast("runtime:rolled-back", failedVersion ?? null); + }; + + const runCheck = async (): Promise => { + // Single-flight: a slow extract racing the periodic interval + // would otherwise re-enter pacote.extract on the same partial. + if (inFlight) return; + inFlight = true; + try { + // Side effect: drops a stale-or-broken pointer so the + // version gate below sees an accurate `max(pointer, bundled)`. + loadOverride(); + const ptr = readPointer(deps.userData); + const currentVersion = + ptr && semver.gt(ptr.version, bundledVersion) + ? ptr.version + : bundledVersion; + const outcome = await checkAndStageLatestRuntime({ + userData: deps.userData, + currentVersion, + nativeDepsSource, + }); + if (outcome.kind === "staged") { + console.log( + `[desktop] Staged kanban@${outcome.version} — restart to apply.`, + ); + deps.broadcast("runtime:update-staged", outcome.version); + } else if (outcome.kind === "bad-version") { + console.log( + `[desktop] Skipping kanban@${outcome.version}: previously failed startup.`, + ); + } + } catch (e) { + console.warn( + "[desktop] Runtime update check failed:", + e instanceof Error ? e.message : e, + ); + } finally { + inFlight = false; + } + }; + + return { + resolveCliEntryOverride: loadOverride, + onCliEntryOverrideFailed: onFailed, + scheduleChecks() { + firstTimer = setTimeout(() => void runCheck(), FIRST_CHECK_DELAY_MS); + firstTimer.unref(); + interval = setInterval(() => void runCheck(), CHECK_INTERVAL_MS); + interval.unref(); + }, + stop() { + if (firstTimer) { + clearTimeout(firstTimer); + firstTimer = null; + } + if (interval) { + clearInterval(interval); + interval = null; + } + }, + }; +} + +/** + * Read `version` from `/package.json` and validate as semver. + * Defends against a corrupt/hand-edited `cli/package.json`: a non-string + * or non-semver `version` field would otherwise propagate into + * `bundledVersion`, and the very first `semver.lte/gt` against it (in + * `loadOverride` or `runCheck`) would throw a TypeError on the hot + * startup path. Returning `null` here lets the caller fall back to + * `shellVersion` instead. + */ +function readBundledVersion(cliDir: string): string | null { + try { + const parsed = JSON.parse( + readFileSync(path.join(cliDir, "package.json"), "utf8"), + ) as { version?: unknown }; + if (typeof parsed.version !== "string") return null; + return semver.valid(parsed.version) ? parsed.version : null; + } catch { + return null; + } +} + +function warn(label: string, err: unknown): void { + console.warn( + `[desktop] ${label} failed:`, + err instanceof Error ? err.message : err, + ); +} diff --git a/packages/desktop/test/runtime-auto-update.test.ts b/packages/desktop/test/runtime-auto-update.test.ts new file mode 100644 index 000000000..b15e930b9 --- /dev/null +++ b/packages/desktop/test/runtime-auto-update.test.ts @@ -0,0 +1,426 @@ +/** + * Direct tests for `createRuntimeAutoUpdate()` — the wiring layer + * between the orchestrator's cliEntryOverride callbacks and the + * runtime-store/runtime-update modules. + * + * `checkAndStageLatestRuntime` is mocked via `vi.mock()` because the + * real implementation hits the npm registry. Everything else (the + * pointer/bad-versions store, the file-existence check, the rollback + * sequence) runs against a real userData tmpdir so the on-disk + * invariants are exercised. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + cliEntryFor, + isBadVersion, + readPointer, + versionDir, + writePointer, +} from "../src/runtime-store.js"; +import { checkAndStageLatestRuntime } from "../src/runtime-update.js"; +import { createRuntimeAutoUpdate } from "../src/runtime-auto-update.js"; + +vi.mock("../src/runtime-update.js", () => ({ + checkAndStageLatestRuntime: vi.fn(), +})); + +const stagedManifest = checkAndStageLatestRuntime as unknown as ReturnType< + typeof vi.fn +>; + +let userData: string; +let resourcesPath: string; +// `broadcast` matches `RuntimeAutoUpdateDeps.broadcast`; vi.fn() with no +// arg type infers to `Mock`, which TS won't +// assign to a fixed signature without an explicit cast. +type Broadcast = (channel: string, ...args: unknown[]) => void; +let broadcast: Broadcast & { mock: ReturnType["mock"] }; + +const SHELL_VERSION = "0.1.70"; +const BUNDLED_VERSION = "0.1.70"; + +function setBundled(version: string): void { + const cliDir = path.join(resourcesPath, "app.asar.unpacked", "cli"); + mkdirSync(cliDir, { recursive: true }); + writeFileSync( + path.join(cliDir, "package.json"), + JSON.stringify({ name: "kanban", version }), + ); +} + +/** Lay out `versions//dist/cli.js` so a pointer to it is valid. */ +function stageVersion(version: string): string { + const cliEntry = cliEntryFor(userData, version); + mkdirSync(path.dirname(cliEntry), { recursive: true }); + writeFileSync(cliEntry, "// runtime"); + return cliEntry; +} + +beforeEach(() => { + userData = mkdtempSync(path.join(tmpdir(), "auto-update-userData-")); + resourcesPath = mkdtempSync(path.join(tmpdir(), "auto-update-resources-")); + setBundled(BUNDLED_VERSION); + broadcast = vi.fn() as unknown as typeof broadcast; + stagedManifest.mockReset(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + rmSync(userData, { recursive: true, force: true }); + rmSync(resourcesPath, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function buildAutoUpdate(overrides: { + isPackaged?: boolean; + shellVersion?: string; +} = {}) { + return createRuntimeAutoUpdate({ + isPackaged: overrides.isPackaged ?? true, + userData, + resourcesPath, + shellVersion: overrides.shellVersion ?? SHELL_VERSION, + broadcast, + }); +} + +describe("createRuntimeAutoUpdate", () => { + it("returns null when not packaged (dev runs the bundled cli)", () => { + expect(buildAutoUpdate({ isPackaged: false })).toBeNull(); + }); +}); + +describe("createRuntimeAutoUpdate: resolveCliEntryOverride", () => { + it("returns null and leaves the store untouched when no pointer exists", () => { + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it("returns the staged cliEntry when pointer.version > bundled", () => { + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBe(cliEntry); + // Pointer must remain on disk — this is the happy path, not a + // self-repair branch. + expect(readPointer(userData)).not.toBeNull(); + }); + + it("clears a stale pointer whose version <= bundled (older staged + newer shell)", () => { + // Regression: previously loadOverride() ignored pointer.version + // and would keep launching an older staged runtime forever after + // a shell upgrade. + const cliEntry = stageVersion("0.1.69"); + writePointer(userData, { version: "0.1.69", cliEntry }); + + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + expect(readPointer(userData)).toBeNull(); + }); + + it("clears a pointer whose cliEntry no longer exists on disk", () => { + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + rmSync(versionDir(userData, "0.1.71"), { recursive: true }); + + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + expect(readPointer(userData)).toBeNull(); + }); + + it("clears a non-canonical pointer file (deletes it, not just rejects it)", () => { + // A tampered current.json with cliEntry pointing outside the + // runtime-store should never be honored — and the bad file + // itself must be removed, otherwise it lingers as visible state + // forever even though every loadOverride() call ignores it. + const pointerFile = path.join( + userData, + "runtime-store", + "current.json", + ); + mkdirSync(path.dirname(pointerFile), { recursive: true }); + writeFileSync( + pointerFile, + JSON.stringify({ version: "0.1.71", cliEntry: "/etc/passwd" }), + ); + + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + expect(existsSync(pointerFile)).toBe(false); + }); + + it("clears a corrupt-JSON pointer file", () => { + const pointerFile = path.join( + userData, + "runtime-store", + "current.json", + ); + mkdirSync(path.dirname(pointerFile), { recursive: true }); + writeFileSync(pointerFile, "{not-json"); + + const auto = buildAutoUpdate(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + expect(existsSync(pointerFile)).toBe(false); + }); + + it("falls back to shellVersion as bundled when app.asar.unpacked/cli/package.json is missing", () => { + rmSync(path.join(resourcesPath, "app.asar.unpacked"), { + recursive: true, + force: true, + }); + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + + const auto = buildAutoUpdate({ shellVersion: "0.1.71" }); + // shellVersion 0.1.71 == pointer 0.1.71 → still <= bundled, so cleared. + expect(auto?.resolveCliEntryOverride()).toBeNull(); + }); + + it("falls back to shellVersion when cli/package.json has a non-semver version (no TypeError on hot path)", () => { + // Regression: `readBundledVersion` previously returned any string + // `version` field unchecked, so a corrupt/hand-edited + // `cli/package.json` (e.g. truncated mid-write, or a packaging + // bug producing a placeholder) would propagate "abc" into + // `bundledVersion`. The very first `semver.lte/gt` against it + // would then throw TypeError on the hot startup path. We now + // validate-and-fall-back to `shellVersion`, so this is safe. + const cliDir = path.join(resourcesPath, "app.asar.unpacked", "cli"); + mkdirSync(cliDir, { recursive: true }); + writeFileSync( + path.join(cliDir, "package.json"), + JSON.stringify({ name: "kanban", version: "abc" }), + ); + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + + // shellVersion 0.1.71 == pointer 0.1.71 → cleared, no TypeError. + const auto = buildAutoUpdate({ shellVersion: "0.1.71" }); + expect(() => auto?.resolveCliEntryOverride()).not.toThrow(); + expect(auto?.resolveCliEntryOverride()).toBeNull(); + }); + + it("falls back to shellVersion when cli/package.json's version is not a string", () => { + const cliDir = path.join(resourcesPath, "app.asar.unpacked", "cli"); + mkdirSync(cliDir, { recursive: true }); + writeFileSync( + path.join(cliDir, "package.json"), + JSON.stringify({ name: "kanban", version: 42 }), + ); + const cliEntry = stageVersion("0.1.72"); + writePointer(userData, { version: "0.1.72", cliEntry }); + + // shellVersion 0.1.71 < pointer 0.1.72 → returns the staged cli. + const auto = buildAutoUpdate({ shellVersion: "0.1.71" }); + expect(auto?.resolveCliEntryOverride()).toBe(cliEntry); + }); +}); + +describe("createRuntimeAutoUpdate: onCliEntryOverrideFailed (rollback)", () => { + it("marks the failed version bad, removes its dir, clears pointer, and broadcasts", () => { + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + + const auto = buildAutoUpdate(); + auto?.onCliEntryOverrideFailed("spawn ENOENT", cliEntry); + + expect(isBadVersion(userData, "0.1.71")).toBe(true); + expect(readPointer(userData)).toBeNull(); + // Version dir is removed so a future staging of the same version + // can extract cleanly without a leftover-files conflict. + expect(() => readFileSync(cliEntry, "utf8")).toThrow(); + expect(broadcast).toHaveBeenCalledWith("runtime:rolled-back", "0.1.71"); + }); + + it("does NOT clear the pointer when a concurrent stage already advanced it", () => { + // Race: orchestrator spawned the staged 0.1.71 cli; while its + // readiness probe is still running, runCheck() finishes staging + // 0.1.72 and writes the pointer. The probe then fails. Rolling + // back "whatever the pointer says now" would mark/remove 0.1.72 + // — a version we haven't even tried yet. The captured failed + // cliEntry is the source of truth. + const failedCli = stageVersion("0.1.71"); + const newerCli = stageVersion("0.1.72"); + writePointer(userData, { version: "0.1.72", cliEntry: newerCli }); + + const auto = buildAutoUpdate(); + auto?.onCliEntryOverrideFailed("spawn ENOENT", failedCli); + + // The failed (older) version is rolled back... + expect(isBadVersion(userData, "0.1.71")).toBe(true); + expect(existsSync(versionDir(userData, "0.1.71"))).toBe(false); + // ...but the newer pointer is left intact. + expect(readPointer(userData)?.version).toBe("0.1.72"); + expect(isBadVersion(userData, "0.1.72")).toBe(false); + expect(existsSync(versionDir(userData, "0.1.72"))).toBe(true); + // Broadcast still names the version that *failed*, not the one in pointer. + expect(broadcast).toHaveBeenCalledWith("runtime:rolled-back", "0.1.71"); + }); + + it("broadcasts rollback with null when cliEntry doesn't fit the canonical layout", () => { + // Defensive: the orchestrator should only ever pass cliEntry + // values that came from `resolveCliEntryOverride()`, but if + // something exotic gets through, we still want the renderer to + // hear *some* rollback signal rather than silently swallow. + const auto = buildAutoUpdate(); + auto?.onCliEntryOverrideFailed("bundled spawn failed", "/exotic/path"); + expect(broadcast).toHaveBeenCalledWith("runtime:rolled-back", null); + }); + + it("does NOT clear the pointer when markBadVersion fails (avoid re-stage loop)", () => { + // Regression: `clearPointer` previously fired unconditionally after + // `markBadVersion`'s try/catch. If `markBadVersion` throws + // (disk full, EPERM on bad-versions.json), the pointer would + // still be dropped — and since the version isn't blacklisted, + // the next `runCheck()` would re-extract the same broken + // version, write the pointer again, and crash on the next + // launch. Loop forever until disk frees. + // + // Fix: gate `clearPointer` on `markBadVersion` succeeding. The + // user still launches successfully via the orchestrator's + // same-launch retry to bundled; the pointer just stays in place + // so the failure keeps retrying `markBadVersion` rather than + // looping `runCheck → re-extract → fail`. + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + + // Force the bad-versions write to fail by replacing its parent + // (the runtime-store dir) with a regular file. Atomic-write's + // mkdirSync(recursive) → writeFileSync → renameSync chain will + // trip on whichever step hits the file-where-dir-is-expected. + // We create the file *after* the pointer write above, since + // that needs the dir to exist. + const badVersions = path.join( + userData, + "runtime-store", + "bad-versions.json", + ); + // Replace bad-versions.json's would-be location with a directory + // so writeFileSync at that path EISDIRs. + mkdirSync(badVersions, { recursive: true }); + + const auto = buildAutoUpdate(); + auto?.onCliEntryOverrideFailed("spawn ENOENT", cliEntry); + + // Pointer must remain — without it, a future `runCheck()` would + // re-stage the same broken version (since !isBadVersion is true). + expect(readPointer(userData)).not.toBeNull(); + expect(isBadVersion(userData, "0.1.71")).toBe(false); + // Version dir cleanup is gated on markBad too — both stay so + // the next attempt has the same starting state to retry against. + expect(existsSync(versionDir(userData, "0.1.71"))).toBe(true); + // Renderer still hears the rollback so the UI can react. + expect(broadcast).toHaveBeenCalledWith("runtime:rolled-back", "0.1.71"); + }); +}); + +describe("createRuntimeAutoUpdate: runCheck (background updater)", () => { + async function tick(): Promise { + // Yield long enough for setTimeout(0) + the awaited mock to settle. + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("broadcasts runtime:update-staged on a successful staged outcome", async () => { + stagedManifest.mockResolvedValueOnce({ kind: "staged", version: "0.1.72" }); + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + + await tick(); + + expect(stagedManifest).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith("runtime:update-staged", "0.1.72"); + }); + + it("uses max(pointer, bundled) as currentVersion (not the stale older pointer)", async () => { + // User just upgraded the shell: bundled is 0.1.70, but userData + // still has a pointer from before that says 0.1.69. The check + // must compare 'latest' against bundled (0.1.70), not 0.1.69 — + // otherwise we'd think 0.1.70 is "newer" and re-stage every interval. + const cliEntry = stageVersion("0.1.69"); + writePointer(userData, { version: "0.1.69", cliEntry }); + stagedManifest.mockResolvedValueOnce({ kind: "up-to-date" }); + + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + await tick(); + + expect(stagedManifest).toHaveBeenCalledWith( + expect.objectContaining({ currentVersion: BUNDLED_VERSION }), + ); + }); + + it("uses the staged version as currentVersion when pointer > bundled", async () => { + const cliEntry = stageVersion("0.1.71"); + writePointer(userData, { version: "0.1.71", cliEntry }); + stagedManifest.mockResolvedValueOnce({ kind: "already-staged" }); + + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + await tick(); + + expect(stagedManifest).toHaveBeenCalledWith( + expect.objectContaining({ currentVersion: "0.1.71" }), + ); + }); + + it("does not broadcast on up-to-date / already-staged / bad-version outcomes", async () => { + stagedManifest.mockResolvedValueOnce({ kind: "up-to-date" }); + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + await tick(); + + expect(broadcast).not.toHaveBeenCalledWith( + "runtime:update-staged", + expect.anything(), + ); + }); + + it("swallows pacote/network errors so the timer keeps firing", async () => { + stagedManifest.mockRejectedValueOnce(new Error("ENOTFOUND registry")); + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + await tick(); + // No throw, no broadcast — the next interval tick can run normally. + expect(broadcast).not.toHaveBeenCalled(); + }); + + it("stop() prevents pending and future checks from running", async () => { + stagedManifest.mockResolvedValue({ kind: "up-to-date" }); + const auto = buildAutoUpdate(); + auto?.scheduleChecks(); + auto?.stop(); + + await vi.advanceTimersByTimeAsync(30 * 60_000 * 5); + expect(stagedManifest).not.toHaveBeenCalled(); + }); +});