fix: keep gateway shutdown runtime stable across updates

This commit is contained in:
Peter Steinberger
2026-05-04 06:46:16 +01:00
parent 4c68bfdb6c
commit bc0b54e844
9 changed files with 88 additions and 5 deletions

View File

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Diagnostics: handle missing session-tail files in cron recovery context without tripping extension test typecheck. Thanks @vincentkoc.

View File

@@ -93,6 +93,12 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve
npm i -g openclaw@latest
```
Prefer `openclaw update` for supervised installs because it can coordinate the
package swap with the running Gateway service. If you update manually while a
managed Gateway is running, restart the Gateway immediately after the package
manager finishes so the old process does not keep serving from replaced package
files.
When `openclaw update` manages a global npm install, it installs the target into
a temporary npm prefix first, verifies the packaged `dist` inventory, then swaps
the clean package tree into the real global prefix. That avoids npm overlaying a

View File

@@ -36,6 +36,10 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [
["route-reply.runtime-uzaOjbd1.js", "route-reply.runtime.js"],
["runtime-plugins.runtime-CNAfmQRG.js", "runtime-plugins.runtime.js"],
["tts.runtime-D-THXDsp.js", "tts.runtime.js"],
// v2026.5.2 -> v2026.5.3-beta.3 gateway shutdown chunks. The running
// gateway may resolve these only after an npm package tree replacement.
["server-close-DsVPJDIx.js", "server-close.runtime.js"],
["server-close-DvAvfgr8.js", "server-close.runtime.js"],
];
const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
{

View File

@@ -0,0 +1 @@
export * from "./server-close.js";

View File

@@ -246,6 +246,22 @@ describe("createGatewayCloseHandler", () => {
expect(stopChannel).toHaveBeenCalledTimes(2);
});
it("uses caller-provided channel ids instead of the local channel registry", async () => {
mocks.listChannelPlugins.mockReturnValue([]);
const stopChannel = vi.fn(async (_id: string) => undefined);
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
channelIds: ["telegram", "discord"],
stopChannel,
}),
);
await close({ reason: "test shutdown" });
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
expect(stopChannel.mock.calls.map(([id]) => id)).toEqual(["telegram", "discord"]);
});
it("unsubscribes lifecycle listeners and disposes bundle runtimes during shutdown", async () => {
const lifecycleUnsub = vi.fn();
const transcriptUnsub = vi.fn();

View File

@@ -175,6 +175,7 @@ export function createGatewayCloseHandler(params: {
canvasHost: CanvasHostHandler | null;
canvasHostServer: CanvasHostServer | null;
releasePluginRouteRegistry?: (() => void) | null;
channelIds?: readonly ChannelId[];
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null;
disposeSessionMcpRuntimes?: () => Promise<void>;
@@ -270,8 +271,9 @@ export function createGatewayCloseHandler(params: {
if (params.canvasHostServer) {
await shutdownStep("canvas-host-server", () => params.canvasHostServer!.close(), warnings);
}
for (const plugin of listChannelPlugins()) {
await shutdownStep(`channel/${plugin.id}`, () => params.stopChannel(plugin.id), warnings);
const channelIds = params.channelIds ?? listChannelPlugins().map((plugin) => plugin.id);
for (const channelId of channelIds) {
await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings);
}
await shutdownStep("agent-harnesses", () => disposeRegisteredAgentHarnesses(), warnings);
await Promise.all([

View File

@@ -169,10 +169,10 @@ async function closeMcpLoopbackServerOnDemand(): Promise<void> {
await closeMcpLoopbackServer();
}
let gatewayCloseModulePromise: Promise<typeof import("./server-close.js")> | null = null;
let gatewayCloseModulePromise: Promise<typeof import("./server-close.runtime.js")> | null = null;
function loadGatewayCloseModule(): Promise<typeof import("./server-close.js")> {
gatewayCloseModulePromise ??= import("./server-close.js");
function loadGatewayCloseModule(): Promise<typeof import("./server-close.runtime.js")> {
gatewayCloseModulePromise ??= import("./server-close.runtime.js");
return gatewayCloseModulePromise;
}
@@ -925,6 +925,7 @@ export async function startGatewayServer(
});
const createCloseHandler =
() => async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
const channelIds = listLoadedChannelPlugins().map((plugin) => plugin.id as ChannelId);
const { createGatewayCloseHandler } = await loadGatewayCloseModule();
await createGatewayCloseHandler({
bonjourStop: runtimeState.bonjourStop,
@@ -932,6 +933,7 @@ export async function startGatewayServer(
canvasHost,
canvasHostServer,
releasePluginRouteRegistry,
channelIds,
stopChannel,
pluginServices: runtimeState.pluginServices,
cron: runtimeState.cronState.cron,

View File

@@ -197,6 +197,36 @@ describe("runtime postbuild static assets", () => {
);
});
it("rewrites gateway shutdown imports to stable runtime aliases", async () => {
const rootDir = createTempDir("openclaw-runtime-postbuild-");
const distDir = path.join(rootDir, "dist");
await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(
path.join(distDir, "server-close.runtime-AbCd1234.js"),
"export const close = true;\n",
"utf8",
);
await fs.writeFile(
path.join(distDir, "server.impl-OldHash.js"),
[
'const closeModule = () => import("./server-close.runtime-AbCd1234.js");',
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
"",
].join("\n"),
"utf8",
);
rewriteRootRuntimeImportsToStableAliases({ rootDir });
expect(await fs.readFile(path.join(distDir, "server.impl-OldHash.js"), "utf8")).toBe(
[
'const closeModule = () => import("./server-close.runtime.js");',
'const ordinaryChunk = () => import("./server-close-OldHash.js");',
"",
].join("\n"),
);
});
it("keeps hashed imports when a stable runtime alias would collide", async () => {
const rootDir = createTempDir("openclaw-runtime-postbuild-");
const distDir = path.join(rootDir, "dist");
@@ -274,6 +304,26 @@ describe("runtime postbuild static assets", () => {
).toBe('export * from "./runtime-plugins.runtime.js";\n');
});
it("writes compatibility aliases for previous gateway shutdown chunk names", async () => {
const rootDir = createTempDir("openclaw-runtime-postbuild-");
const distDir = path.join(rootDir, "dist");
await fs.mkdir(distDir, { recursive: true });
await fs.writeFile(
path.join(distDir, "server-close.runtime.js"),
'export * from "./server-close.runtime-NewHash.js";\n',
"utf8",
);
writeLegacyRootRuntimeCompatAliases({ rootDir });
expect(await fs.readFile(path.join(distDir, "server-close-DsVPJDIx.js"), "utf8")).toBe(
'export * from "./server-close.runtime.js";\n',
);
expect(await fs.readFile(path.join(distDir, "server-close-DvAvfgr8.js"), "utf8")).toBe(
'export * from "./server-close.runtime.js";\n',
);
});
it("writes legacy CLI exit compatibility chunks", async () => {
const rootDir = createTempDir("openclaw-runtime-postbuild-");

View File

@@ -204,6 +204,7 @@ function buildCoreDistEntries(): Record<string, string> {
"agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts",
"agents/models-config.runtime": "src/agents/models-config.runtime.ts",
"cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts",
"server-close.runtime": "src/gateway/server-close.runtime.ts",
"plugins/memory-state": "src/plugins/memory-state.ts",
"subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts",
"task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",