refactor: split config reload flow and test harness

This commit is contained in:
Peter Steinberger
2026-02-22 15:38:07 +01:00
parent 53adae9cec
commit 39be5e44df
2 changed files with 93 additions and 92 deletions

View File

@@ -204,6 +204,27 @@ function makeSnapshot(partial: Partial<ConfigFileSnapshot> = {}): ConfigFileSnap
};
}
function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>) {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const onHotReload = vi.fn(async () => {});
const onRestart = vi.fn();
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
readSnapshot,
onHotReload,
onRestart,
log,
watchPath: "/tmp/openclaw.json",
});
return { watcher, onHotReload, onRestart, log, reloader };
}
describe("startGatewayConfigReloader", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -215,9 +236,6 @@ describe("startGatewayConfigReloader", () => {
});
it("retries missing snapshots and reloads once config file reappears", async () => {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(makeSnapshot({ exists: false, raw: null, hash: "missing-1" }))
@@ -230,23 +248,7 @@ describe("startGatewayConfigReloader", () => {
hash: "next-1",
}),
);
const onHotReload = vi.fn(async () => {});
const onRestart = vi.fn();
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
readSnapshot,
onHotReload,
onRestart,
log,
watchPath: "/tmp/openclaw.json",
});
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
watcher.emit("unlink");
await vi.runOnlyPendingTimersAsync();
@@ -262,29 +264,10 @@ describe("startGatewayConfigReloader", () => {
});
it("caps missing-file retries and skips reload after retry budget is exhausted", async () => {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValue(makeSnapshot({ exists: false, raw: null, hash: "missing" }));
const onHotReload = vi.fn(async () => {});
const onRestart = vi.fn();
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
readSnapshot,
onHotReload,
onRestart,
log,
watchPath: "/tmp/openclaw.json",
});
const { watcher, onHotReload, onRestart, log, reloader } = createReloaderHarness(readSnapshot);
watcher.emit("unlink");
await vi.runAllTimersAsync();

View File

@@ -286,6 +286,73 @@ export function startGatewayConfigReloader(opts: {
const schedule = () => {
scheduleAfter(settings.debounceMs);
};
const queueRestart = (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
if (restartQueued) {
return;
}
restartQueued = true;
opts.onRestart(plan, nextConfig);
};
const handleMissingSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
if (snapshot.exists) {
missingConfigRetries = 0;
return false;
}
if (missingConfigRetries < MISSING_CONFIG_MAX_RETRIES) {
missingConfigRetries += 1;
opts.log.info(
`config reload retry (${missingConfigRetries}/${MISSING_CONFIG_MAX_RETRIES}): config file not found`,
);
scheduleAfter(MISSING_CONFIG_RETRY_DELAY_MS);
return true;
}
opts.log.warn("config reload skipped (config file not found)");
return true;
};
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
if (snapshot.valid) {
return false;
}
const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", ");
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
return true;
};
const applySnapshot = async (nextConfig: OpenClawConfig) => {
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
currentConfig = nextConfig;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
return;
}
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
return;
}
if (settings.mode === "restart") {
queueRestart(plan, nextConfig);
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", ",
)})`,
);
return;
}
queueRestart(plan, nextConfig);
return;
}
await opts.onHotReload(plan, nextConfig);
};
const runReload = async () => {
if (stopped) {
@@ -302,62 +369,13 @@ export function startGatewayConfigReloader(opts: {
}
try {
const snapshot = await opts.readSnapshot();
if (!snapshot.exists) {
if (missingConfigRetries < MISSING_CONFIG_MAX_RETRIES) {
missingConfigRetries += 1;
opts.log.info(
`config reload retry (${missingConfigRetries}/${MISSING_CONFIG_MAX_RETRIES}): config file not found`,
);
scheduleAfter(MISSING_CONFIG_RETRY_DELAY_MS);
return;
}
opts.log.warn("config reload skipped (config file not found)");
if (handleMissingSnapshot(snapshot)) {
return;
}
missingConfigRetries = 0;
if (!snapshot.valid) {
const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", ");
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
if (handleInvalidSnapshot(snapshot)) {
return;
}
const nextConfig = snapshot.config;
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
currentConfig = nextConfig;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
return;
}
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
return;
}
if (settings.mode === "restart") {
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
}
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", ",
)})`,
);
return;
}
if (!restartQueued) {
restartQueued = true;
opts.onRestart(plan, nextConfig);
}
return;
}
await opts.onHotReload(plan, nextConfig);
await applySnapshot(snapshot.config);
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
} finally {