fix: handle intentional signal daemon shutdown on abort (#23379) (thanks @frankekn)

This commit is contained in:
Peter Steinberger
2026-02-22 10:59:06 +01:00
parent 1051f42f96
commit 602a1ebd55
3 changed files with 48 additions and 2 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths.
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).

View File

@@ -246,6 +246,43 @@ describe("monitorSignalProvider tool results", () => {
).rejects.toThrow(/signal daemon exited/i);
});
it("treats daemon exit after user abort as clean shutdown", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig();
const abortController = new AbortController();
let exited = false;
let resolveExit!: (value: { code: number | null; signal: NodeJS.Signals | null }) => void;
const exitedPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve) => {
resolveExit = resolve;
},
);
const stop = vi.fn(() => {
if (exited) {
return;
}
exited = true;
resolveExit({ code: null, signal: "SIGTERM" });
});
spawnSignalDaemonMock.mockReturnValueOnce({
stop,
exited: exitedPromise,
isExited: () => exited,
});
streamMock.mockImplementationOnce(async () => {
abortController.abort(new Error("stop"));
});
await expect(
runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
runtime,
abortSignal: abortController.signal,
}),
).resolves.toBeUndefined();
});
it("skips tool summaries with responsePrefix", async () => {
replyMock.mockResolvedValue({ text: "final reply" });

View File

@@ -330,6 +330,11 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
const daemonAbortController = new AbortController();
const mergedAbort = mergeAbortSignals(opts.abortSignal, daemonAbortController.signal);
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
let daemonStopRequested = false;
const stopDaemon = () => {
daemonStopRequested = true;
daemonHandle?.stop();
};
if (autoStart) {
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
@@ -347,6 +352,9 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
runtime,
});
void daemonHandle.exited.then((exit) => {
if (daemonStopRequested || opts.abortSignal?.aborted) {
return;
}
daemonExitError = new Error(
`signal daemon exited (code=${String(exit.code ?? "null")} signal=${String(exit.signal ?? "null")})`,
);
@@ -357,7 +365,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
}
const onAbort = () => {
daemonHandle?.stop();
stopDaemon();
};
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
@@ -426,6 +434,6 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
} finally {
mergedAbort.dispose();
opts.abortSignal?.removeEventListener("abort", onAbort);
daemonHandle?.stop();
stopDaemon();
}
}