From 3fb8c405eda48af76bb8b8d7d3f8e5b0ee3c049c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 13:07:05 -0700 Subject: [PATCH] fix(update): finish post-core package updates --- src/cli/update-cli.test.ts | 27 ++++++++ src/cli/update-cli/update-command.ts | 92 +++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 59dfa45883f..14c7a949df6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -623,6 +623,33 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); + it("finishes package updates when the post-core process writes a result but keeps handles open", async () => { + setupUpdatedRootRefresh(); + const kill = vi.fn(); + spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => { + const resultPath = (options as { env?: NodeJS.ProcessEnv }).env + ?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH; + if (!resultPath) { + throw new Error("missing post-core result path"); + } + queueMicrotask(() => { + void fs.writeFile(resultPath, `${JSON.stringify({ status: "ok" })}\n`, "utf-8"); + }); + const child = new EventEmitter() as EventEmitter & { + kill: typeof kill; + once: EventEmitter["once"]; + }; + child.kill = kill; + return child; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(kill).toHaveBeenCalledTimes(1); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("does not carry gateway service markers into the post-core update process", async () => { setupUpdatedRootRefresh(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 758438c7992..29aa81745a5 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -110,6 +110,7 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE"; const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL"; const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; +const POST_CORE_UPDATE_RESULT_POLL_MS = 100; const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV = "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ @@ -1608,6 +1609,25 @@ async function readPostCorePluginUpdateResultFile( return undefined; } +function stopPostCoreUpdateChild(child: ChildProcess): void { + if (process.platform === "win32" && child.pid) { + try { + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => { + child.kill(); + }); + return; + } catch { + child.kill(); + return; + } + } + child.kill(); +} + async function continuePostCoreUpdateInFreshProcess(params: { root: string; channel: "stable" | "beta" | "dev"; @@ -1632,11 +1652,8 @@ async function continuePostCoreUpdateInFreshProcess(params: { if (params.opts.timeout) { argv.push("--timeout", params.opts.timeout); } - const resultDir = - params.opts.json === true - ? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")) - : null; - const resultPath = resultDir ? path.join(resultDir, "plugins.json") : null; + const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-")); + const resultPath = path.join(resultDir, "plugins.json"); try { const child = spawn(resolveNodeRunner(), argv, { @@ -1648,24 +1665,65 @@ async function continuePostCoreUpdateInFreshProcess(params: { ...(params.requestedChannel ? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel } : {}), - ...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}), + [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath, }, }); - const exitCode = await new Promise((resolve, reject) => { - child.once("error", reject); + const childResult = await new Promise< + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult } + >((resolve, reject) => { + let settled = false; + const finish = ( + result: + | { kind: "exit"; exitCode: number } + | { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult }, + ) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + resolve(result); + }; + const resultPoll = setInterval(() => { + void readPostCorePluginUpdateResultFile(resultPath) + .then((pluginUpdate) => { + if (!pluginUpdate) { + return; + } + stopPostCoreUpdateChild(child); + finish({ kind: "plugin-update", pluginUpdate }); + }) + .catch(() => undefined); + }, POST_CORE_UPDATE_RESULT_POLL_MS); + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearInterval(resultPoll); + reject(error); + }); child.once("exit", (code, signal) => { + if (settled) { + return; + } if (signal) { + settled = true; + clearInterval(resultPoll); reject(new Error(`post-update process terminated by signal ${signal}`)); return; } - resolve(code ?? 1); + finish({ kind: "exit", exitCode: code ?? 1 }); }); }); - const pluginUpdate = resultPath - ? await readPostCorePluginUpdateResultFile(resultPath) - : undefined; + const pluginUpdate = + childResult.kind === "plugin-update" + ? childResult.pluginUpdate + : await readPostCorePluginUpdateResultFile(resultPath); + const exitCode = childResult.kind === "exit" ? childResult.exitCode : 0; if (exitCode !== 0) { if (pluginUpdate) { return { resumed: true, pluginUpdate }; @@ -1675,9 +1733,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { } return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) }; } finally { - if (resultDir) { - await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); - } + await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined); } } @@ -1752,11 +1808,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { opts, timeoutMs: updateStepTimeoutMs, }); - if (opts.json) { + if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { await writePostCorePluginUpdateResultFile( process.env[POST_CORE_UPDATE_RESULT_PATH_ENV], pluginUpdate, ); + } + if (opts.json) { if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { const result: UpdateRunResult = { status: pluginUpdate.status === "error" ? "error" : "ok",