Skip to content

Commit 0ad48da

Browse files
committed
fix(deadcode): move restart intents to sqlite
1 parent b50a5ae commit 0ad48da

2 files changed

Lines changed: 179 additions & 90 deletions

File tree

src/infra/restart-intent.test.ts

Lines changed: 89 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { afterEach, describe, expect, it } from "vitest";
6+
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
7+
import {
8+
closeOpenClawStateDatabaseForTest,
9+
openOpenClawStateDatabase,
10+
} from "../state/openclaw-state-db.js";
11+
import {
12+
executeSqliteQuerySync,
13+
executeSqliteQueryTakeFirstSync,
14+
getNodeSqliteKysely,
15+
} from "./kysely-sync.js";
616
import {
717
consumeGatewayRestartIntentPayloadSync,
818
consumeGatewayRestartIntentSync,
919
writeGatewayRestartIntentSync,
1020
} from "./restart.js";
1121

1222
const tempDirs: string[] = [];
23+
type GatewayRestartIntentDatabase = Pick<OpenClawStateKyselyDatabase, "gateway_restart_intent">;
1324

1425
function createIntentEnv(): NodeJS.ProcessEnv {
1526
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-restart-intent-"));
@@ -20,12 +31,54 @@ function createIntentEnv(): NodeJS.ProcessEnv {
2031
};
2132
}
2233

23-
function intentPath(env: NodeJS.ProcessEnv): string {
34+
function legacyIntentPath(env: NodeJS.ProcessEnv): string {
2435
return path.join(env.OPENCLAW_STATE_DIR ?? "", "gateway-restart-intent.json");
2536
}
2637

38+
function readIntentRow(env: NodeJS.ProcessEnv) {
39+
const { db } = openOpenClawStateDatabase({ env });
40+
const stateDb = getNodeSqliteKysely<GatewayRestartIntentDatabase>(db);
41+
return executeSqliteQueryTakeFirstSync(
42+
db,
43+
stateDb
44+
.selectFrom("gateway_restart_intent")
45+
.select(["intent_key", "kind", "pid", "created_at", "reason", "force", "wait_ms"])
46+
.where("intent_key", "=", "gateway-restart"),
47+
);
48+
}
49+
50+
function insertIntentRow(
51+
env: NodeJS.ProcessEnv,
52+
values: {
53+
kind?: string;
54+
pid?: number;
55+
createdAt?: number;
56+
reason?: string | null;
57+
force?: number | null;
58+
waitMs?: number | null;
59+
},
60+
) {
61+
const { db } = openOpenClawStateDatabase({ env });
62+
const stateDb = getNodeSqliteKysely<GatewayRestartIntentDatabase>(db);
63+
const now = Date.now();
64+
executeSqliteQuerySync(
65+
db,
66+
stateDb.insertInto("gateway_restart_intent").values({
67+
intent_key: "gateway-restart",
68+
kind: values.kind ?? "gateway-restart",
69+
pid: values.pid ?? process.pid,
70+
created_at: values.createdAt ?? now,
71+
reason: values.reason ?? null,
72+
force: values.force ?? null,
73+
wait_ms: values.waitMs ?? null,
74+
updated_at_ms: now,
75+
}),
76+
);
77+
}
78+
2779
describe("gateway restart intent", () => {
2880
afterEach(() => {
81+
closeOpenClawStateDatabaseForTest();
2982
for (const dir of tempDirs.splice(0)) {
3083
fs.rmSync(dir, { force: true, recursive: true });
3184
}
@@ -37,7 +90,8 @@ describe("gateway restart intent", () => {
3790
expect(writeGatewayRestartIntentSync({ env, targetPid: process.pid })).toBe(true);
3891

3992
expect(consumeGatewayRestartIntentSync(env)).toBe(true);
40-
expect(fs.existsSync(intentPath(env))).toBe(false);
93+
expect(readIntentRow(env)).toBeUndefined();
94+
expect(fs.existsSync(legacyIntentPath(env))).toBe(false);
4195
});
4296

4397
it("rejects an intent for a different process", () => {
@@ -46,23 +100,24 @@ describe("gateway restart intent", () => {
46100
expect(writeGatewayRestartIntentSync({ env, targetPid: process.pid + 1 })).toBe(true);
47101

48102
expect(consumeGatewayRestartIntentSync(env)).toBe(false);
49-
expect(fs.existsSync(intentPath(env))).toBe(false);
103+
expect(readIntentRow(env)).toBeUndefined();
104+
expect(fs.existsSync(legacyIntentPath(env))).toBe(false);
50105
});
51106

52-
it("rejects oversized intent files before parsing", () => {
107+
it("rejects expired intents before restart", () => {
53108
const env = createIntentEnv();
54-
fs.writeFileSync(intentPath(env), "x".repeat(2048), { encoding: "utf8", mode: 0o600 });
109+
insertIntentRow(env, { createdAt: Date.now() - 120_000 });
55110

56111
expect(consumeGatewayRestartIntentSync(env)).toBe(false);
57-
expect(fs.existsSync(intentPath(env))).toBe(false);
112+
expect(readIntentRow(env)).toBeUndefined();
58113
});
59114

60-
it("writes intent files with owner-only permissions", () => {
115+
it("drops malformed intent rows before restart", () => {
61116
const env = createIntentEnv();
117+
insertIntentRow(env, { kind: "bad-intent" });
62118

63-
expect(writeGatewayRestartIntentSync({ env, targetPid: process.pid })).toBe(true);
64-
65-
expect(fs.statSync(intentPath(env)).mode & 0o777).toBe(0o600);
119+
expect(consumeGatewayRestartIntentSync(env)).toBe(false);
120+
expect(readIntentRow(env)).toBeUndefined();
66121
});
67122

68123
it("round-trips restart reason, force, and wait options", () => {
@@ -82,23 +137,33 @@ describe("gateway restart intent", () => {
82137
force: true,
83138
waitMs: 12_345,
84139
});
85-
expect(fs.existsSync(intentPath(env))).toBe(false);
140+
expect(readIntentRow(env)).toBeUndefined();
141+
expect(fs.existsSync(legacyIntentPath(env))).toBe(false);
86142
});
87143

88-
it("does not follow an existing intent-path symlink when writing", () => {
144+
it("overwrites the previous pending intent row", () => {
89145
const env = createIntentEnv();
90-
const targetPath = path.join(env.OPENCLAW_STATE_DIR ?? "", "attacker-target.txt");
91-
fs.writeFileSync(targetPath, "keep", "utf8");
92-
try {
93-
fs.symlinkSync(targetPath, intentPath(env));
94-
} catch {
95-
return;
96-
}
97-
98-
expect(writeGatewayRestartIntentSync({ env, targetPid: process.pid })).toBe(true);
146+
expect(
147+
writeGatewayRestartIntentSync({
148+
env,
149+
targetPid: process.pid + 1,
150+
reason: "first",
151+
}),
152+
).toBe(true);
153+
expect(
154+
writeGatewayRestartIntentSync({
155+
env,
156+
targetPid: process.pid,
157+
reason: "second",
158+
}),
159+
).toBe(true);
99160

100-
expect(fs.readFileSync(targetPath, "utf8")).toBe("keep");
101-
expect(fs.lstatSync(intentPath(env)).isSymbolicLink()).toBe(false);
102-
expect(consumeGatewayRestartIntentSync(env)).toBe(true);
161+
expect(readIntentRow(env)).toMatchObject({
162+
intent_key: "gateway-restart",
163+
kind: "gateway-restart",
164+
pid: process.pid,
165+
reason: "second",
166+
});
167+
expect(consumeGatewayRestartIntentPayloadSync(env)).toEqual({ reason: "second" });
103168
});
104169
});

src/infra/restart.ts

Lines changed: 90 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
// Coordinates gateway restart requests across supported supervisors.
22
import { spawnSync } from "node:child_process";
3-
import fs from "node:fs";
43
import os from "node:os";
54
import path from "node:path";
65
import { getRuntimeConfig } from "../config/config.js";
7-
import { resolveStateDir } from "../config/paths.js";
86
import {
97
resolveGatewayLaunchAgentLabel,
108
resolveGatewaySystemdServiceName,
119
} from "../daemon/constants.js";
1210
import { createSubsystemLogger } from "../logging/subsystem.js";
1311
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
14-
import { replaceFileAtomicSync } from "./replace-file.js";
12+
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
13+
import {
14+
openOpenClawStateDatabase,
15+
runOpenClawStateWriteTransaction,
16+
} from "../state/openclaw-state-db.js";
17+
import {
18+
executeSqliteQuerySync,
19+
executeSqliteQueryTakeFirstSync,
20+
getNodeSqliteKysely,
21+
} from "./kysely-sync.js";
1522
import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js";
1623
import type { RestartAttempt } from "./restart.types.js";
1724
import { relaunchGatewayScheduledTask } from "./windows-task-restart.js";
@@ -25,11 +32,11 @@ const DEFAULT_DEFERRAL_STILL_PENDING_WARN_MS = 30_000;
2532
export const DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS = 300_000;
2633
const RESTART_COOLDOWN_MS = 30_000;
2734
const LAUNCHCTL_ALREADY_LOADED_EXIT_CODE = 37;
28-
const GATEWAY_RESTART_INTENT_FILENAME = "gateway-restart-intent.json";
35+
const GATEWAY_RESTART_INTENT_KEY = "gateway-restart";
2936
const GATEWAY_RESTART_INTENT_TTL_MS = 60_000;
30-
const GATEWAY_RESTART_INTENT_MAX_BYTES = 1024;
3137

3238
const restartLog = createSubsystemLogger("restart");
39+
type GatewayRestartIntentDatabase = Pick<OpenClawStateKyselyDatabase, "gateway_restart_intent">;
3340

3441
export { findGatewayPidsOnPortSync };
3542

@@ -137,23 +144,6 @@ export type GatewayRestartIntent = {
137144
waitMs?: number;
138145
};
139146

140-
function resolveGatewayRestartIntentPath(env: NodeJS.ProcessEnv = process.env): string {
141-
return path.join(resolveStateDir(env), GATEWAY_RESTART_INTENT_FILENAME);
142-
}
143-
144-
function unlinkGatewayRestartIntentFileSync(intentPath: string): boolean {
145-
try {
146-
const stat = fs.lstatSync(intentPath);
147-
if (!stat.isFile() || stat.nlink > 1) {
148-
return false;
149-
}
150-
fs.unlinkSync(intentPath);
151-
return true;
152-
} catch {
153-
return false;
154-
}
155-
}
156-
157147
function normalizeRestartIntentPid(pid: number | undefined): number | null {
158148
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
159149
}
@@ -170,26 +160,46 @@ export function writeGatewayRestartIntentSync(opts: {
170160
}
171161
const env = opts.env ?? process.env;
172162
try {
173-
const intentPath = resolveGatewayRestartIntentPath(env);
174163
const reason = normalizeRestartIntentReason(opts.reason ?? opts.intent?.reason);
175-
const payload: GatewayRestartIntentPayload = {
176-
kind: "gateway-restart",
177-
pid: targetPid,
178-
createdAt: Date.now(),
179-
...(reason ? { reason } : {}),
180-
...(opts.intent?.force ? { force: true } : {}),
181-
...(typeof opts.intent?.waitMs === "number" &&
164+
const waitMs =
165+
typeof opts.intent?.waitMs === "number" &&
182166
Number.isFinite(opts.intent.waitMs) &&
183167
opts.intent.waitMs >= 0
184-
? { waitMs: Math.floor(opts.intent.waitMs) }
185-
: {}),
186-
};
187-
replaceFileAtomicSync({
188-
filePath: intentPath,
189-
content: `${JSON.stringify(payload)}\n`,
190-
mode: 0o600,
191-
tempPrefix: ".gateway-restart-intent",
192-
});
168+
? Math.floor(opts.intent.waitMs)
169+
: null;
170+
const createdAt = Date.now();
171+
runOpenClawStateWriteTransaction(
172+
({ db }) => {
173+
const stateDb = getNodeSqliteKysely<GatewayRestartIntentDatabase>(db);
174+
executeSqliteQuerySync(
175+
db,
176+
stateDb
177+
.insertInto("gateway_restart_intent")
178+
.values({
179+
intent_key: GATEWAY_RESTART_INTENT_KEY,
180+
kind: "gateway-restart",
181+
pid: targetPid,
182+
created_at: createdAt,
183+
reason: reason ?? null,
184+
force: opts.intent?.force ? 1 : null,
185+
wait_ms: waitMs,
186+
updated_at_ms: createdAt,
187+
})
188+
.onConflict((conflict) =>
189+
conflict.column("intent_key").doUpdateSet({
190+
kind: (eb) => eb.ref("excluded.kind"),
191+
pid: (eb) => eb.ref("excluded.pid"),
192+
created_at: (eb) => eb.ref("excluded.created_at"),
193+
reason: (eb) => eb.ref("excluded.reason"),
194+
force: (eb) => eb.ref("excluded.force"),
195+
wait_ms: (eb) => eb.ref("excluded.wait_ms"),
196+
updated_at_ms: (eb) => eb.ref("excluded.updated_at_ms"),
197+
}),
198+
),
199+
);
200+
},
201+
{ env },
202+
);
193203
return true;
194204
} catch (err) {
195205
restartLog.warn(`failed to write gateway restart intent: ${String(err)}`);
@@ -198,31 +208,57 @@ export function writeGatewayRestartIntentSync(opts: {
198208
}
199209

200210
export function clearGatewayRestartIntentSync(env: NodeJS.ProcessEnv = process.env): void {
201-
unlinkGatewayRestartIntentFileSync(resolveGatewayRestartIntentPath(env));
211+
try {
212+
runOpenClawStateWriteTransaction(
213+
({ db }) => {
214+
const stateDb = getNodeSqliteKysely<GatewayRestartIntentDatabase>(db);
215+
executeSqliteQuerySync(
216+
db,
217+
stateDb
218+
.deleteFrom("gateway_restart_intent")
219+
.where("intent_key", "=", GATEWAY_RESTART_INTENT_KEY),
220+
);
221+
},
222+
{ env },
223+
);
224+
} catch {}
202225
}
203226

204-
function parseGatewayRestartIntent(raw: string): GatewayRestartIntentPayload | null {
227+
function readGatewayRestartIntentPayloadSync(
228+
env: NodeJS.ProcessEnv,
229+
): GatewayRestartIntentPayload | null {
205230
try {
206-
const parsed = JSON.parse(raw) as Partial<GatewayRestartIntentPayload>;
231+
const { db } = openOpenClawStateDatabase({ env });
232+
const stateDb = getNodeSqliteKysely<GatewayRestartIntentDatabase>(db);
233+
const parsed = executeSqliteQueryTakeFirstSync(
234+
db,
235+
stateDb
236+
.selectFrom("gateway_restart_intent")
237+
.select(["kind", "pid", "created_at", "reason", "force", "wait_ms"])
238+
.where("intent_key", "=", GATEWAY_RESTART_INTENT_KEY),
239+
);
207240
if (
208-
parsed.kind === "gateway-restart" &&
241+
parsed?.kind === "gateway-restart" &&
209242
typeof parsed.pid === "number" &&
210243
Number.isFinite(parsed.pid) &&
211-
typeof parsed.createdAt === "number" &&
212-
Number.isFinite(parsed.createdAt) &&
213-
(parsed.reason === undefined || typeof parsed.reason === "string") &&
214-
(parsed.force === undefined || typeof parsed.force === "boolean") &&
215-
(parsed.waitMs === undefined ||
216-
(typeof parsed.waitMs === "number" && Number.isFinite(parsed.waitMs) && parsed.waitMs >= 0))
244+
typeof parsed.created_at === "number" &&
245+
Number.isFinite(parsed.created_at) &&
246+
(parsed.reason === null || typeof parsed.reason === "string") &&
247+
(parsed.force === null ||
248+
(typeof parsed.force === "number" && Number.isFinite(parsed.force))) &&
249+
(parsed.wait_ms === null ||
250+
(typeof parsed.wait_ms === "number" &&
251+
Number.isFinite(parsed.wait_ms) &&
252+
parsed.wait_ms >= 0))
217253
) {
218-
const reason = normalizeRestartIntentReason(parsed.reason);
254+
const reason = normalizeRestartIntentReason(parsed.reason ?? undefined);
219255
return {
220256
kind: "gateway-restart",
221257
pid: parsed.pid,
222-
createdAt: parsed.createdAt,
258+
createdAt: parsed.created_at,
223259
...(reason ? { reason } : {}),
224260
...(parsed.force ? { force: true } : {}),
225-
...(typeof parsed.waitMs === "number" ? { waitMs: Math.floor(parsed.waitMs) } : {}),
261+
...(typeof parsed.wait_ms === "number" ? { waitMs: Math.floor(parsed.wait_ms) } : {}),
226262
};
227263
}
228264
} catch {
@@ -240,20 +276,8 @@ export function consumeGatewayRestartIntentPayloadSync(
240276
env: NodeJS.ProcessEnv = process.env,
241277
now = Date.now(),
242278
): GatewayRestartIntent | null {
243-
const intentPath = resolveGatewayRestartIntentPath(env);
244-
let raw: string;
245-
try {
246-
const stat = fs.lstatSync(intentPath);
247-
if (!stat.isFile() || stat.size > GATEWAY_RESTART_INTENT_MAX_BYTES) {
248-
return null;
249-
}
250-
raw = fs.readFileSync(intentPath, "utf8");
251-
} catch {
252-
return null;
253-
} finally {
254-
clearGatewayRestartIntentSync(env);
255-
}
256-
const payload = parseGatewayRestartIntent(raw);
279+
const payload = readGatewayRestartIntentPayloadSync(env);
280+
clearGatewayRestartIntentSync(env);
257281
if (!payload) {
258282
return null;
259283
}

0 commit comments

Comments
 (0)