diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 2711eafd7..089524490 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -204,6 +204,27 @@ function makeSnapshot(partial: Partial = {}): ConfigFileSnap }; } +function createReloaderHarness(readSnapshot: () => Promise) { + 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>() .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>() .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(); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index f92e74401..64f04b15e 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -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 {