Skip to content

Commit e088bb5

Browse files
committed
fix(restart-sentinel): log DB errors instead of silently swallowing them
Replace empty catch blocks in clearRestartSentinel, readRestartSentinel, and hasRestartSentinel with sentinelLog.warn(...) calls so SQLite errors are visible in diagnostics while preserving best-effort fallback semantics. Tests use deterministic fault injection via vi.mock on openclaw-state-db.js with hoisted throw flags, replacing the previous non-deterministic state-dir removal approach. Each error path asserts both the warning message and the correct fallback return value.
1 parent 9e2a6b1 commit e088bb5

2 files changed

Lines changed: 105 additions & 8 deletions

File tree

src/infra/restart-sentinel.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
// Covers restart sentinel persistence, summaries, and messages.
5+
6+
const { mockWarn, mockThrowOpen, mockThrowWrite } = vi.hoisted(() => ({
7+
mockWarn: vi.fn(),
8+
mockThrowOpen: vi.fn(),
9+
mockThrowWrite: vi.fn(),
10+
}));
11+
12+
vi.mock("../logging/subsystem.js", () => ({
13+
createSubsystemLogger: () => ({ warn: mockWarn }),
14+
}));
15+
16+
vi.mock("../state/openclaw-state-db.js", async (importOriginal) => {
17+
const actual = await importOriginal<typeof import("../state/openclaw-state-db.js")>();
18+
return {
19+
...actual,
20+
openOpenClawStateDatabase: (...args: Parameters<typeof actual.openOpenClawStateDatabase>) => {
21+
mockThrowOpen();
22+
return actual.openOpenClawStateDatabase(...args);
23+
},
24+
runOpenClawStateWriteTransaction: (
25+
...args: Parameters<typeof actual.runOpenClawStateWriteTransaction>
26+
) => {
27+
mockThrowWrite();
28+
return actual.runOpenClawStateWriteTransaction(...args);
29+
},
30+
};
31+
});
32+
533
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
634
import {
735
closeOpenClawStateDatabaseForTest,
@@ -34,6 +62,12 @@ import {
3462
} from "./update-control-plane-sentinel.js";
3563
import { buildUpdateRestartSentinelPayload } from "./update-restart-sentinel-payload.js";
3664

65+
beforeEach(() => {
66+
mockWarn.mockClear();
67+
mockThrowOpen.mockReset();
68+
mockThrowWrite.mockReset();
69+
});
70+
3771
async function withRestartSentinelStateDir(run: () => Promise<void>): Promise<void> {
3872
await withTempDir({ prefix: "openclaw-sentinel-" }, async (tempDir) => {
3973
try {
@@ -462,6 +496,59 @@ describe("restart sentinel", () => {
462496
});
463497
});
464498

499+
describe("restart sentinel error visibility", () => {
500+
afterEach(() => {
501+
mockWarn.mockClear();
502+
mockThrowOpen.mockReset();
503+
mockThrowWrite.mockReset();
504+
});
505+
506+
it("logs a warning when clearRestartSentinel DB write fails", async () => {
507+
mockThrowWrite.mockImplementationOnce(() => {
508+
throw new Error("SQLITE_IOERR: disk I/O error");
509+
});
510+
511+
await withRestartSentinelStateDir(async () => {
512+
await expect(clearRestartSentinel()).resolves.toBeUndefined();
513+
514+
expect(mockWarn).toHaveBeenCalledTimes(1);
515+
expect(mockWarn).toHaveBeenCalledWith(
516+
expect.stringContaining("Failed to clear restart sentinel"),
517+
);
518+
});
519+
});
520+
521+
it("logs a warning and returns null when readRestartSentinel DB read fails", async () => {
522+
mockThrowOpen.mockImplementationOnce(() => {
523+
throw new Error("SQLITE_CORRUPT: database disk image is malformed");
524+
});
525+
526+
await withRestartSentinelStateDir(async () => {
527+
await expect(readRestartSentinel()).resolves.toBeNull();
528+
529+
expect(mockWarn).toHaveBeenCalledTimes(1);
530+
expect(mockWarn).toHaveBeenCalledWith(
531+
expect.stringContaining("Failed to read restart sentinel"),
532+
);
533+
});
534+
});
535+
536+
it("logs a warning and returns false when hasRestartSentinel DB read fails", async () => {
537+
mockThrowOpen.mockImplementationOnce(() => {
538+
throw new Error("SQLITE_BUSY: database is locked");
539+
});
540+
541+
await withRestartSentinelStateDir(async () => {
542+
await expect(hasRestartSentinel()).resolves.toBe(false);
543+
544+
expect(mockWarn).toHaveBeenCalledTimes(1);
545+
expect(mockWarn).toHaveBeenCalledWith(
546+
expect.stringContaining("Failed to check restart sentinel"),
547+
);
548+
});
549+
});
550+
});
551+
465552
describe("restart success continuation", () => {
466553
it("does not infer an agent turn from session context alone", () => {
467554
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toBeNull();

src/infra/restart-sentinel.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,37 @@ import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-c
55
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
66
import { formatCliCommand } from "../cli/command-format.js";
77
import { resolveStateDir } from "../config/paths.js";
8+
import { createSubsystemLogger } from "../logging/subsystem.js";
89
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
910
import {
1011
openOpenClawStateDatabase,
1112
runOpenClawStateWriteTransaction,
1213
} from "../state/openclaw-state-db.js";
1314
import { resolveRuntimeServiceVersion } from "../version.js";
15+
import { formatErrorMessage } from "./errors.js";
1416
import {
1517
executeSqliteQuerySync,
1618
executeSqliteQueryTakeFirstSync,
1719
getNodeSqliteKysely,
1820
} from "./kysely-sync.js";
1921

20-
type RestartSentinelLog = {
22+
const sentinelLog = createSubsystemLogger("restart-sentinel");
23+
24+
export type RestartSentinelLog = {
2125
stdoutTail?: string | null;
2226
stderrTail?: string | null;
2327
exitCode?: number | null;
2428
};
2529

26-
type RestartSentinelStep = {
30+
export type RestartSentinelStep = {
2731
name: string;
2832
command: string;
2933
cwd?: string | null;
3034
durationMs?: number | null;
3135
log?: RestartSentinelLog | null;
3236
};
3337

34-
type RestartSentinelStats = {
38+
export type RestartSentinelStats = {
3539
mode?: string;
3640
root?: string;
3741
requiresRestart?: boolean;
@@ -72,7 +76,7 @@ export type RestartSentinelPayload = {
7276
stats?: RestartSentinelStats | null;
7377
};
7478

75-
type RestartSentinel = {
79+
export type RestartSentinel = {
7680
version: 1;
7781
payload: RestartSentinelPayload;
7882
};
@@ -225,7 +229,11 @@ export async function clearRestartSentinel(env: NodeJS.ProcessEnv = process.env)
225229
},
226230
{ env },
227231
);
228-
} catch {}
232+
} catch (err) {
233+
// Clearing the sentinel is best-effort during shutdown/cleanup, but a
234+
// failure here may leave the gateway believing a restart is still pending.
235+
sentinelLog.warn(`Failed to clear restart sentinel: ${formatErrorMessage(err)}`);
236+
}
229237
await removeLegacyRestartSentinel(env);
230238
}
231239

@@ -298,7 +306,8 @@ export async function readRestartSentinel(
298306
return null;
299307
}
300308
return { version: 1, payload };
301-
} catch {
309+
} catch (err) {
310+
sentinelLog.warn(`Failed to read restart sentinel: ${formatErrorMessage(err)}`);
302311
return null;
303312
}
304313
}
@@ -318,7 +327,8 @@ export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env):
318327
return true;
319328
}
320329
return Boolean(await importLegacyRestartSentinel(env));
321-
} catch {
330+
} catch (err) {
331+
sentinelLog.warn(`Failed to check restart sentinel: ${formatErrorMessage(err)}`);
322332
return false;
323333
}
324334
}

0 commit comments

Comments
 (0)