refactor: split config reload flow and test harness
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user