fix(update): finish post-core package updates

This commit is contained in:
Vincent Koc
2026-05-04 13:07:05 -07:00
parent ef0dbcf49d
commit 3fb8c405ed
2 changed files with 102 additions and 17 deletions

View File

@@ -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();

View File

@@ -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<number>((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<void> {
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",