Skip to content

Commit 962ba79

Browse files
committed
fix(gateway): log chat-abort terminal persistence failures instead of swallowing
The terminal session-store persistence promise in registerChatAbortController's cleanup path caught rejections with an empty handler, silently dropping both the error and any operator signal while still deleting the abort controller. In production, terminal states could fail to persist with zero trace in logs or telemetry, while the swallowed error masked database / file-system health issues. Surfaces the failure via the gateway/chat-abort subsystem logger (error level), including the run id and the underlying error, and preserves the existing controller cleanup so idempotent retry cleanup is not blocked. Fixes #97795
1 parent 1052652 commit 962ba79

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

src/gateway/chat-abort.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ import {
1616
updateChatRunProvider,
1717
} from "./chat-abort.js";
1818

19+
// Capture the subsystem logger's error spy so the terminal-persistence failure
20+
// path can be asserted to emit a log line instead of swallowing it.
21+
const chatAbortLogError = vi.hoisted(() => vi.fn());
22+
vi.mock("../logging/subsystem.js", () => ({
23+
createSubsystemLogger: vi.fn(() => ({
24+
warn: vi.fn(),
25+
info: vi.fn(),
26+
error: chatAbortLogError,
27+
debug: vi.fn(),
28+
trace: vi.fn(),
29+
fatal: vi.fn(),
30+
raw: vi.fn(),
31+
child: vi.fn(),
32+
isEnabled: vi.fn(() => false),
33+
subsystem: "test",
34+
})),
35+
}));
36+
1937
type ChatAbortPayload = {
2038
runId: string;
2139
sessionKey: string;
@@ -265,6 +283,46 @@ describe("registerChatAbortController", () => {
265283
expect(chatAbortControllers.has("run-persisting")).toBe(false);
266284
});
267285

286+
it("logs an error and still clears the entry when terminal persistence fails", async () => {
287+
chatAbortLogError.mockClear();
288+
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
289+
const registration = registerChatAbortController({
290+
chatAbortControllers,
291+
runId: "run-persist-failed",
292+
sessionId: "sess-1",
293+
sessionKey: "main",
294+
timeoutMs: 60_000,
295+
});
296+
let rejectPersistence: (err: Error) => void = () => undefined;
297+
const persistence = new Promise<void>((_resolve, reject) => {
298+
rejectPersistence = reject;
299+
});
300+
if (!registration.entry) {
301+
throw new Error("expected registered entry");
302+
}
303+
registration.entry.projectSessionActive = false;
304+
registration.entry.projectSessionTerminalPersistence = persistence;
305+
306+
registration.cleanup();
307+
308+
// Entry is retained while persistence is still in flight.
309+
expect(chatAbortControllers.has("run-persist-failed")).toBe(true);
310+
expect(chatAbortLogError).not.toHaveBeenCalled();
311+
312+
const failure = new Error("disk full");
313+
rejectPersistence(failure);
314+
// Allow the rejection to propagate to the .catch() handler.
315+
await expect(persistence).rejects.toBe(failure);
316+
await Promise.resolve();
317+
318+
// Entry must still be cleaned up (no leak) AND the failure must be logged
319+
// rather than silently swallowed — the regression this guards against.
320+
expect(chatAbortControllers.has("run-persist-failed")).toBe(false);
321+
expect(chatAbortLogError).toHaveBeenCalledTimes(1);
322+
expect(chatAbortLogError.mock.calls[0][0]).toContain("run-persist-failed");
323+
expect(chatAbortLogError.mock.calls[0][0]).toContain("disk full");
324+
});
325+
268326
it("retains registrations when terminal lifecycle was observed before caller cleanup", () => {
269327
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
270328
const registration = registerChatAbortController({

src/gateway/chat-abort.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js";
1111
import type { OpenClawConfig } from "../config/types.openclaw.js";
1212
import { emitAgentEvent, getAgentEventLifecycleGeneration } from "../infra/agent-events.js";
1313
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
14+
import { createSubsystemLogger } from "../logging/subsystem.js";
1415
import { projectLiveAssistantBufferedText } from "./live-chat-projector.js";
1516
import { createChatAbortMarker, type ChatAbortMarker } from "./server-chat-state.js";
1617

18+
const log = createSubsystemLogger("gateway/chat-abort");
19+
1720
const DEFAULT_CHAT_RUN_ABORT_GRACE_MS = 60_000;
1821

1922
export type ChatAbortControllerEntry = {
@@ -173,7 +176,15 @@ export function registerChatAbortController(params: {
173176
params.chatAbortControllers.delete(params.runId);
174177
}
175178
})
176-
.catch(() => {
179+
.catch((err) => {
180+
// The terminal session-store update is the persistence contract for a
181+
// run's final state. If it fails we still must drop the abort controller
182+
// (otherwise it leaks and blocks idempotent retry cleanup), but the
183+
// failure must be visible to operators — otherwise terminal states can
184+
// fail to persist in production with no trace in logs or telemetry.
185+
log.error(
186+
`chat-abort: failed to persist terminal session state for run ${params.runId}: ${err}`,
187+
);
177188
if (params.chatAbortControllers.get(params.runId)?.controller === controller) {
178189
params.chatAbortControllers.delete(params.runId);
179190
}

0 commit comments

Comments
 (0)