Skip to content

Commit 3fb8c40

Browse files
committed
fix(update): finish post-core package updates
1 parent ef0dbcf commit 3fb8c40

2 files changed

Lines changed: 102 additions & 17 deletions

File tree

src/cli/update-cli.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,33 @@ describe("update-cli", () => {
623623
expect(runDaemonRestart).not.toHaveBeenCalled();
624624
});
625625

626+
it("finishes package updates when the post-core process writes a result but keeps handles open", async () => {
627+
setupUpdatedRootRefresh();
628+
const kill = vi.fn();
629+
spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => {
630+
const resultPath = (options as { env?: NodeJS.ProcessEnv }).env
631+
?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
632+
if (!resultPath) {
633+
throw new Error("missing post-core result path");
634+
}
635+
queueMicrotask(() => {
636+
void fs.writeFile(resultPath, `${JSON.stringify({ status: "ok" })}\n`, "utf-8");
637+
});
638+
const child = new EventEmitter() as EventEmitter & {
639+
kill: typeof kill;
640+
once: EventEmitter["once"];
641+
};
642+
child.kill = kill;
643+
return child;
644+
});
645+
646+
await updateCommand({ yes: true, restart: false });
647+
648+
expect(kill).toHaveBeenCalledTimes(1);
649+
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
650+
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
651+
});
652+
626653
it("does not carry gateway service markers into the post-core update process", async () => {
627654
setupUpdatedRootRefresh();
628655

src/cli/update-cli/update-command.ts

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn } from "node:child_process";
1+
import { spawn, type ChildProcess } from "node:child_process";
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
@@ -110,6 +110,7 @@ const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
110110
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
111111
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
112112
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
113+
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
113114
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
114115
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
115116
const SERVICE_REFRESH_PATH_ENV_KEYS = [
@@ -1608,6 +1609,25 @@ async function readPostCorePluginUpdateResultFile(
16081609
return undefined;
16091610
}
16101611

1612+
function stopPostCoreUpdateChild(child: ChildProcess): void {
1613+
if (process.platform === "win32" && child.pid) {
1614+
try {
1615+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
1616+
stdio: "ignore",
1617+
windowsHide: true,
1618+
});
1619+
killer.once("error", () => {
1620+
child.kill();
1621+
});
1622+
return;
1623+
} catch {
1624+
child.kill();
1625+
return;
1626+
}
1627+
}
1628+
child.kill();
1629+
}
1630+
16111631
async function continuePostCoreUpdateInFreshProcess(params: {
16121632
root: string;
16131633
channel: "stable" | "beta" | "dev";
@@ -1632,11 +1652,8 @@ async function continuePostCoreUpdateInFreshProcess(params: {
16321652
if (params.opts.timeout) {
16331653
argv.push("--timeout", params.opts.timeout);
16341654
}
1635-
const resultDir =
1636-
params.opts.json === true
1637-
? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-"))
1638-
: null;
1639-
const resultPath = resultDir ? path.join(resultDir, "plugins.json") : null;
1655+
const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-"));
1656+
const resultPath = path.join(resultDir, "plugins.json");
16401657

16411658
try {
16421659
const child = spawn(resolveNodeRunner(), argv, {
@@ -1648,24 +1665,65 @@ async function continuePostCoreUpdateInFreshProcess(params: {
16481665
...(params.requestedChannel
16491666
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
16501667
: {}),
1651-
...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}),
1668+
[POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath,
16521669
},
16531670
});
16541671

1655-
const exitCode = await new Promise<number>((resolve, reject) => {
1656-
child.once("error", reject);
1672+
const childResult = await new Promise<
1673+
| { kind: "exit"; exitCode: number }
1674+
| { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult }
1675+
>((resolve, reject) => {
1676+
let settled = false;
1677+
const finish = (
1678+
result:
1679+
| { kind: "exit"; exitCode: number }
1680+
| { kind: "plugin-update"; pluginUpdate: PostCorePluginUpdateResult },
1681+
) => {
1682+
if (settled) {
1683+
return;
1684+
}
1685+
settled = true;
1686+
clearInterval(resultPoll);
1687+
resolve(result);
1688+
};
1689+
const resultPoll = setInterval(() => {
1690+
void readPostCorePluginUpdateResultFile(resultPath)
1691+
.then((pluginUpdate) => {
1692+
if (!pluginUpdate) {
1693+
return;
1694+
}
1695+
stopPostCoreUpdateChild(child);
1696+
finish({ kind: "plugin-update", pluginUpdate });
1697+
})
1698+
.catch(() => undefined);
1699+
}, POST_CORE_UPDATE_RESULT_POLL_MS);
1700+
child.once("error", (error) => {
1701+
if (settled) {
1702+
return;
1703+
}
1704+
settled = true;
1705+
clearInterval(resultPoll);
1706+
reject(error);
1707+
});
16571708
child.once("exit", (code, signal) => {
1709+
if (settled) {
1710+
return;
1711+
}
16581712
if (signal) {
1713+
settled = true;
1714+
clearInterval(resultPoll);
16591715
reject(new Error(`post-update process terminated by signal ${signal}`));
16601716
return;
16611717
}
1662-
resolve(code ?? 1);
1718+
finish({ kind: "exit", exitCode: code ?? 1 });
16631719
});
16641720
});
16651721

1666-
const pluginUpdate = resultPath
1667-
? await readPostCorePluginUpdateResultFile(resultPath)
1668-
: undefined;
1722+
const pluginUpdate =
1723+
childResult.kind === "plugin-update"
1724+
? childResult.pluginUpdate
1725+
: await readPostCorePluginUpdateResultFile(resultPath);
1726+
const exitCode = childResult.kind === "exit" ? childResult.exitCode : 0;
16691727
if (exitCode !== 0) {
16701728
if (pluginUpdate) {
16711729
return { resumed: true, pluginUpdate };
@@ -1675,9 +1733,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
16751733
}
16761734
return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) };
16771735
} finally {
1678-
if (resultDir) {
1679-
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
1680-
}
1736+
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
16811737
}
16821738
}
16831739

@@ -1752,11 +1808,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
17521808
opts,
17531809
timeoutMs: updateStepTimeoutMs,
17541810
});
1755-
if (opts.json) {
1811+
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
17561812
await writePostCorePluginUpdateResultFile(
17571813
process.env[POST_CORE_UPDATE_RESULT_PATH_ENV],
17581814
pluginUpdate,
17591815
);
1816+
}
1817+
if (opts.json) {
17601818
if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
17611819
const result: UpdateRunResult = {
17621820
status: pluginUpdate.status === "error" ? "error" : "ok",

0 commit comments

Comments
 (0)