Skip to content

Commit bc0b54e

Browse files
committed
fix: keep gateway shutdown runtime stable across updates
1 parent 4c68bfd commit bc0b54e

9 files changed

Lines changed: 88 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
5656
- Exec approvals: detect `env -S` split-string command-carrier risks when `-S`/`-s` is combined with other env short options, so approval explanations do not miss split payloads hidden behind `env -iS...`. Thanks @vincentkoc.
5757
- Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply.
5858
- Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc.
59+
- Gateway/update: keep the shutdown close path behind a stable runtime chunk and ship compatibility aliases for recent `server-close-*` hashes, so manual npm package replacement cannot leave an already-running Gateway unable to shut down cleanly. Fixes #77087. Thanks @westlife219.
5960
- Exec approvals: treat POSIX `exec` as a command carrier for inline eval, shell-wrapper, and eval/source detection, so approval explanations and command-risk checks do not miss payloads hidden behind `exec`. Thanks @vincentkoc.
6061
- Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model.
6162
- Diagnostics: handle missing session-tail files in cron recovery context without tripping extension test typecheck. Thanks @vincentkoc.

docs/install/updating.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve
9393
npm i -g openclaw@latest
9494
```
9595

96+
Prefer `openclaw update` for supervised installs because it can coordinate the
97+
package swap with the running Gateway service. If you update manually while a
98+
managed Gateway is running, restart the Gateway immediately after the package
99+
manager finishes so the old process does not keep serving from replaced package
100+
files.
101+
96102
When `openclaw update` manages a global npm install, it installs the target into
97103
a temporary npm prefix first, verifies the packaged `dist` inventory, then swaps
98104
the clean package tree into the real global prefix. That avoids npm overlaying a

scripts/runtime-postbuild.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [
3636
["route-reply.runtime-uzaOjbd1.js", "route-reply.runtime.js"],
3737
["runtime-plugins.runtime-CNAfmQRG.js", "runtime-plugins.runtime.js"],
3838
["tts.runtime-D-THXDsp.js", "tts.runtime.js"],
39+
// v2026.5.2 -> v2026.5.3-beta.3 gateway shutdown chunks. The running
40+
// gateway may resolve these only after an npm package tree replacement.
41+
["server-close-DsVPJDIx.js", "server-close.runtime.js"],
42+
["server-close-DvAvfgr8.js", "server-close.runtime.js"],
3943
];
4044
const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
4145
{
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./server-close.js";

src/gateway/server-close.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,22 @@ describe("createGatewayCloseHandler", () => {
246246
expect(stopChannel).toHaveBeenCalledTimes(2);
247247
});
248248

249+
it("uses caller-provided channel ids instead of the local channel registry", async () => {
250+
mocks.listChannelPlugins.mockReturnValue([]);
251+
const stopChannel = vi.fn(async (_id: string) => undefined);
252+
const close = createGatewayCloseHandler(
253+
createGatewayCloseTestDeps({
254+
channelIds: ["telegram", "discord"],
255+
stopChannel,
256+
}),
257+
);
258+
259+
await close({ reason: "test shutdown" });
260+
261+
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
262+
expect(stopChannel.mock.calls.map(([id]) => id)).toEqual(["telegram", "discord"]);
263+
});
264+
249265
it("unsubscribes lifecycle listeners and disposes bundle runtimes during shutdown", async () => {
250266
const lifecycleUnsub = vi.fn();
251267
const transcriptUnsub = vi.fn();

src/gateway/server-close.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function createGatewayCloseHandler(params: {
175175
canvasHost: CanvasHostHandler | null;
176176
canvasHostServer: CanvasHostServer | null;
177177
releasePluginRouteRegistry?: (() => void) | null;
178+
channelIds?: readonly ChannelId[];
178179
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
179180
pluginServices: PluginServicesHandle | null;
180181
disposeSessionMcpRuntimes?: () => Promise<void>;
@@ -270,8 +271,9 @@ export function createGatewayCloseHandler(params: {
270271
if (params.canvasHostServer) {
271272
await shutdownStep("canvas-host-server", () => params.canvasHostServer!.close(), warnings);
272273
}
273-
for (const plugin of listChannelPlugins()) {
274-
await shutdownStep(`channel/${plugin.id}`, () => params.stopChannel(plugin.id), warnings);
274+
const channelIds = params.channelIds ?? listChannelPlugins().map((plugin) => plugin.id);
275+
for (const channelId of channelIds) {
276+
await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings);
275277
}
276278
await shutdownStep("agent-harnesses", () => disposeRegisteredAgentHarnesses(), warnings);
277279
await Promise.all([

src/gateway/server.impl.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,10 @@ async function closeMcpLoopbackServerOnDemand(): Promise<void> {
169169
await closeMcpLoopbackServer();
170170
}
171171

172-
let gatewayCloseModulePromise: Promise<typeof import("./server-close.js")> | null = null;
172+
let gatewayCloseModulePromise: Promise<typeof import("./server-close.runtime.js")> | null = null;
173173

174-
function loadGatewayCloseModule(): Promise<typeof import("./server-close.js")> {
175-
gatewayCloseModulePromise ??= import("./server-close.js");
174+
function loadGatewayCloseModule(): Promise<typeof import("./server-close.runtime.js")> {
175+
gatewayCloseModulePromise ??= import("./server-close.runtime.js");
176176
return gatewayCloseModulePromise;
177177
}
178178

@@ -925,13 +925,15 @@ export async function startGatewayServer(
925925
});
926926
const createCloseHandler =
927927
() => async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
928+
const channelIds = listLoadedChannelPlugins().map((plugin) => plugin.id as ChannelId);
928929
const { createGatewayCloseHandler } = await loadGatewayCloseModule();
929930
await createGatewayCloseHandler({
930931
bonjourStop: runtimeState.bonjourStop,
931932
tailscaleCleanup: runtimeState.tailscaleCleanup,
932933
canvasHost,
933934
canvasHostServer,
934935
releasePluginRouteRegistry,
936+
channelIds,
935937
stopChannel,
936938
pluginServices: runtimeState.pluginServices,
937939
cron: runtimeState.cronState.cron,

test/scripts/runtime-postbuild.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,36 @@ describe("runtime postbuild static assets", () => {
197197
);
198198
});
199199

200+
it("rewrites gateway shutdown imports to stable runtime aliases", async () => {
201+
const rootDir = createTempDir("openclaw-runtime-postbuild-");
202+
const distDir = path.join(rootDir, "dist");
203+
await fs.mkdir(distDir, { recursive: true });
204+
await fs.writeFile(
205+
path.join(distDir, "server-close.runtime-AbCd1234.js"),
206+
"export const close = true;\n",
207+
"utf8",
208+
);
209+
await fs.writeFile(
210+
path.join(distDir, "server.impl-OldHash.js"),
211+
[
212+
'const closeModule = () => import("./server-close.runtime-AbCd1234.js");',
213+
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
214+
"",
215+
].join("\n"),
216+
"utf8",
217+
);
218+
219+
rewriteRootRuntimeImportsToStableAliases({ rootDir });
220+
221+
expect(await fs.readFile(path.join(distDir, "server.impl-OldHash.js"), "utf8")).toBe(
222+
[
223+
'const closeModule = () => import("./server-close.runtime.js");',
224+
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
225+
"",
226+
].join("\n"),
227+
);
228+
});
229+
200230
it("keeps hashed imports when a stable runtime alias would collide", async () => {
201231
const rootDir = createTempDir("openclaw-runtime-postbuild-");
202232
const distDir = path.join(rootDir, "dist");
@@ -274,6 +304,26 @@ describe("runtime postbuild static assets", () => {
274304
).toBe('export * from "./runtime-plugins.runtime.js";\n');
275305
});
276306

307+
it("writes compatibility aliases for previous gateway shutdown chunk names", async () => {
308+
const rootDir = createTempDir("openclaw-runtime-postbuild-");
309+
const distDir = path.join(rootDir, "dist");
310+
await fs.mkdir(distDir, { recursive: true });
311+
await fs.writeFile(
312+
path.join(distDir, "server-close.runtime.js"),
313+
'export * from "./server-close.runtime-NewHash.js";\n',
314+
"utf8",
315+
);
316+
317+
writeLegacyRootRuntimeCompatAliases({ rootDir });
318+
319+
expect(await fs.readFile(path.join(distDir, "server-close-DsVPJDIx.js"), "utf8")).toBe(
320+
'export * from "./server-close.runtime.js";\n',
321+
);
322+
expect(await fs.readFile(path.join(distDir, "server-close-DvAvfgr8.js"), "utf8")).toBe(
323+
'export * from "./server-close.runtime.js";\n',
324+
);
325+
});
326+
277327
it("writes legacy CLI exit compatibility chunks", async () => {
278328
const rootDir = createTempDir("openclaw-runtime-postbuild-");
279329

tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ function buildCoreDistEntries(): Record<string, string> {
204204
"agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts",
205205
"agents/models-config.runtime": "src/agents/models-config.runtime.ts",
206206
"cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts",
207+
"server-close.runtime": "src/gateway/server-close.runtime.ts",
207208
"plugins/memory-state": "src/plugins/memory-state.ts",
208209
"subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts",
209210
"task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",

0 commit comments

Comments
 (0)