fix: bound message CLI shutdown hooks

This commit is contained in:
Peter Steinberger
2026-05-02 08:30:25 +01:00
parent f2782c941e
commit 2c14d6f99d
3 changed files with 59 additions and 2 deletions

View File

@@ -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.

View File

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

View File

@@ -16,6 +16,9 @@ export type MessageCliHelpers = {
runMessageAction: (action: string, opts: Record<string, unknown>) => Promise<void>;
};
const GATEWAY_STOP_TIMEOUT_MS = 2500;
const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]);
function normalizeMessageOptions(opts: Record<string, unknown>): Record<string, unknown> {
const { account, ...rest } = opts;
return {
@@ -25,11 +28,25 @@ function normalizeMessageOptions(opts: Record<string, unknown>): Record<string,
}
async function runPluginStopHooks(): Promise<void> {
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);
};