From 2c14d6f99d28a48a123ac49d38b80e6b2e8671e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 08:30:25 +0100 Subject: [PATCH] fix: bound message CLI shutdown hooks --- CHANGELOG.md | 3 +++ src/cli/program/message/helpers.test.ts | 35 +++++++++++++++++++++++++ src/cli/program/message/helpers.ts | 23 ++++++++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac73260280..caf1eb0cd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ Docs: https://docs.openclaw.ai - Web search/Brave: add `plugins.entries.brave.config.webSearch.baseUrl` for Brave-compatible proxies, including endpoint-aware cache keys for both web and LLM Context modes. Fixes #19075. Thanks @jkoprax and @vishnukool. - Web search/config: validate explicit `tools.web.search.provider` values against bundled and installed plugin manifests, while warning for stale third-party plugin config. Fixes #53092. Thanks @TinyTb. - Web search/SearXNG: retry empty non-general category searches once with the general category, so unsupported category engines do not return empty results when general search has matches. Fixes #73552. Thanks @Loukky. +- CLI/message: skip gateway-stop hooks for read-only `message read` and bound + stop-hook shutdown for other message actions, so one-shot Discord reads cannot + hang behind plugin lifecycle cleanup. - Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan. - Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval. - Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying `thread/start` on a closed client. Thanks @vincentkoc. diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index f11655aa509..6939731064d 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -144,6 +144,41 @@ describe("runMessageAction", () => { expect(exitMock).toHaveBeenCalledWith(0); }); + it("skips gateway_stop hooks for read-only message reads", async () => { + hasHooksMock.mockReturnValueOnce(true); + const runMessageAction = createRunMessageAction(); + + await expect( + runMessageAction("read", { + channel: "discord", + target: "channel:123", + limit: 1, + }), + ).rejects.toThrow("exit"); + + expect(runGlobalGatewayStopSafelyMock).not.toHaveBeenCalled(); + expect(runGatewayStopMock).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + it("bounds gateway_stop hooks so message actions still exit", async () => { + vi.useFakeTimers(); + try { + hasHooksMock.mockReturnValueOnce(true); + runGatewayStopMock.mockImplementationOnce(() => new Promise(() => undefined)); + const runMessageAction = createRunMessageAction(); + + const pending = expect(runMessageAction("send", baseSendOptions)).rejects.toThrow("exit"); + await vi.advanceTimersByTimeAsync(2500); + await pending; + + expect(errorMock).toHaveBeenCalledWith("gateway_stop hook exceeded 2500ms; continuing"); + expect(exitMock).toHaveBeenCalledWith(0); + } finally { + vi.useRealTimers(); + } + }); + it("calls exit(1) when message delivery fails", async () => { messageCommandMock.mockRejectedValueOnce(new Error("send failed")); await runSendAction(); diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index 013d7dc28e4..2ef5a4c0c79 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -16,6 +16,9 @@ export type MessageCliHelpers = { runMessageAction: (action: string, opts: Record) => Promise; }; +const GATEWAY_STOP_TIMEOUT_MS = 2500; +const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]); + function normalizeMessageOptions(opts: Record): Record { const { account, ...rest } = opts; return { @@ -25,11 +28,25 @@ function normalizeMessageOptions(opts: Record): Record { - await runGlobalGatewayStopSafely({ + let timeout: NodeJS.Timeout | null = null; + const hookRun = runGlobalGatewayStopSafely({ event: { reason: "cli message action complete" }, ctx: {}, onError: (err) => defaultRuntime.error(danger(`gateway_stop hook failed: ${String(err)}`)), }); + const bounded = new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), GATEWAY_STOP_TIMEOUT_MS); + timeout.unref?.(); + }); + const result = await Promise.race([hookRun.then(() => "done" as const), bounded]); + if (timeout) { + clearTimeout(timeout); + } + if (result === "timeout") { + defaultRuntime.error( + danger(`gateway_stop hook exceeded ${GATEWAY_STOP_TIMEOUT_MS}ms; continuing`), + ); + } } function resolveMessagePluginLoadOptions( @@ -85,7 +102,9 @@ export function createMessageCliHelpers( defaultRuntime.error(danger(String(err))); }, ); - await runPluginStopHooks(); + if (!ACTIONS_WITHOUT_STOP_HOOKS.has(action)) { + await runPluginStopHooks(); + } defaultRuntime.exit(failed ? 1 : 0); };