fix(config): reuse in-memory gateway write reloads

This commit is contained in:
Peter Steinberger
2026-03-29 23:37:54 +01:00
parent 0e47ce58bc
commit a9984e2bf9
6 changed files with 176 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ export {
clearConfigCache,
ConfigRuntimeRefreshError,
clearRuntimeConfigSnapshot,
registerConfigWriteListener,
createConfigIO,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
@@ -17,6 +18,7 @@ export {
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
export type { ConfigWriteNotification } from "./io.js";
export { migrateLegacyConfig } from "./legacy-migrate.js";
export * from "./paths.js";
export * from "./runtime-overrides.js";

View File

@@ -6,6 +6,7 @@ import {
getRuntimeConfigSourceSnapshot,
loadConfig,
projectConfigOntoRuntimeSourceSnapshot,
registerConfigWriteListener,
resetConfigRuntimeState,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
@@ -249,4 +250,35 @@ describe("runtime config snapshot writes", () => {
}
});
});
it("notifies in-process write listeners with the refreshed runtime snapshot", async () => {
await withTempHome("openclaw-config-runtime-write-listener-", async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`);
const seen: Array<{ configPath: string; runtimeConfig: OpenClawConfig }> = [];
const unsubscribe = registerConfigWriteListener((event) => {
seen.push({
configPath: event.configPath,
runtimeConfig: event.runtimeConfig,
});
});
try {
expect(loadConfig().gateway?.port).toBe(18789);
await writeConfigFile({
...loadConfig(),
gateway: { port: 19003 },
});
expect(seen).toHaveLength(1);
expect(seen[0]?.configPath).toBe(configPath);
expect(seen[0]?.runtimeConfig.gateway?.port).toBe(19003);
} finally {
unsubscribe();
resetRuntimeConfigState();
}
});
});
});

View File

@@ -243,6 +243,12 @@ export type RuntimeConfigSnapshotRefreshHandler = {
clearOnRefreshFailure?: () => void;
};
export type ConfigWriteNotification = {
configPath: string;
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
};
export class ConfigRuntimeRefreshError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
@@ -2076,11 +2082,31 @@ const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set<string>();
let runtimeConfigSnapshot: OpenClawConfig | null = null;
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
const configWriteListeners = new Set<(event: ConfigWriteNotification) => void>();
function notifyConfigWriteListeners(event: ConfigWriteNotification): void {
for (const listener of configWriteListeners) {
try {
listener(event);
} catch {
// Best-effort observer path only; successful writes must still complete.
}
}
}
export function clearConfigCache(): void {
// Compat shim: runtime snapshot is the only in-process cache now.
}
export function registerConfigWriteListener(
listener: (event: ConfigWriteNotification) => void,
): () => void {
configWriteListeners.add(listener);
return () => {
configWriteListeners.delete(listener);
};
}
export function setRuntimeConfigSnapshot(
config: OpenClawConfig,
sourceConfig?: OpenClawConfig,
@@ -2207,6 +2233,16 @@ export async function writeConfigFile(
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
unsetPaths: options.unsetPaths,
});
const notifyCommittedWrite = () => {
if (!runtimeConfigSnapshot) {
return;
}
notifyConfigWriteListeners({
configPath: io.configPath,
sourceConfig: nextCfg,
runtimeConfig: runtimeConfigSnapshot,
});
};
// Keep the last-known-good runtime snapshot active until the specialized refresh path
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
const refreshHandler = runtimeConfigSnapshotRefreshHandler;
@@ -2214,6 +2250,7 @@ export async function writeConfigFile(
try {
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
if (refreshed) {
notifyCommittedWrite();
return;
}
} catch (error) {
@@ -2234,12 +2271,15 @@ export async function writeConfigFile(
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
const fresh = io.loadConfig();
setRuntimeConfigSnapshot(fresh, nextCfg);
notifyCommittedWrite();
return;
}
if (hadRuntimeSnapshot) {
const fresh = io.loadConfig();
setRuntimeConfigSnapshot(fresh);
notifyCommittedWrite();
return;
}
setRuntimeConfigSnapshot(io.loadConfig());
notifyCommittedWrite();
}

View File

@@ -2,7 +2,7 @@ import chokidar from "chokidar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ConfigFileSnapshot } from "../config/config.js";
import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
@@ -307,6 +307,15 @@ function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>)
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const onHotReload = vi.fn(async () => {});
const onRestart = vi.fn();
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
const subscribeToWrites = vi.fn((listener: (event: ConfigWriteNotification) => void) => {
writeListener = listener;
return () => {
if (writeListener === listener) {
writeListener = null;
}
};
});
const log = {
info: vi.fn(),
warn: vi.fn(),
@@ -315,12 +324,22 @@ function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>)
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
readSnapshot,
subscribeToWrites,
onHotReload,
onRestart,
log,
watchPath: "/tmp/openclaw.json",
});
return { watcher, onHotReload, onRestart, log, reloader };
return {
watcher,
onHotReload,
onRestart,
log,
reloader,
emitWrite(event: ConfigWriteNotification) {
writeListener?.(event);
},
};
}
describe("startGatewayConfigReloader", () => {
@@ -427,4 +446,45 @@ describe("startGatewayConfigReloader", () => {
await reloader.stop();
}
});
it("reuses in-process write notifications and suppresses watcher noise for the same write", async () => {
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
const harness = createReloaderHarness(readSnapshot);
harness.emitWrite({
configPath: "/tmp/openclaw.json",
sourceConfig: { gateway: { reload: { debounceMs: 0 } } },
runtimeConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
},
});
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).not.toHaveBeenCalled();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
harness.watcher.emit("change");
harness.watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).not.toHaveBeenCalled();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
readSnapshot.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 }, port: 19001 },
},
hash: "external-1",
}),
);
harness.watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
});

View File

@@ -1,6 +1,11 @@
import { isDeepStrictEqual } from "node:util";
import chokidar from "chokidar";
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
import type {
OpenClawConfig,
ConfigFileSnapshot,
ConfigWriteNotification,
GatewayReloadMode,
} from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { isPlainObject } from "../utils.js";
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
@@ -74,6 +79,7 @@ export function startGatewayConfigReloader(opts: {
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
subscribeToWrites?: (listener: (event: ConfigWriteNotification) => void) => () => void;
log: {
info: (msg: string) => void;
warn: (msg: string) => void;
@@ -89,6 +95,8 @@ export function startGatewayConfigReloader(opts: {
let stopped = false;
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: OpenClawConfig | null = null;
let suppressedWatchEventsRemaining = 0;
const scheduleAfter = (wait: number) => {
if (stopped) {
@@ -195,6 +203,13 @@ export function startGatewayConfigReloader(opts: {
debounceTimer = null;
}
try {
if (pendingInProcessConfig) {
const nextConfig = pendingInProcessConfig;
pendingInProcessConfig = null;
missingConfigRetries = 0;
await applySnapshot(nextConfig);
return;
}
const snapshot = await opts.readSnapshot();
if (handleMissingSnapshot(snapshot)) {
return;
@@ -220,9 +235,27 @@ export function startGatewayConfigReloader(opts: {
usePolling: Boolean(process.env.VITEST),
});
watcher.on("add", schedule);
watcher.on("change", schedule);
watcher.on("unlink", schedule);
const scheduleFromWatcher = () => {
if (suppressedWatchEventsRemaining > 0) {
suppressedWatchEventsRemaining -= 1;
return;
}
schedule();
};
const unsubscribeFromWrites =
opts.subscribeToWrites?.((event) => {
if (event.configPath !== opts.watchPath) {
return;
}
pendingInProcessConfig = event.runtimeConfig;
suppressedWatchEventsRemaining = 2;
scheduleAfter(0);
}) ?? (() => {});
watcher.on("add", scheduleFromWatcher);
watcher.on("change", scheduleFromWatcher);
watcher.on("unlink", scheduleFromWatcher);
let watcherClosed = false;
watcher.on("error", (err) => {
if (watcherClosed) {
@@ -241,6 +274,7 @@ export function startGatewayConfigReloader(opts: {
}
debounceTimer = null;
watcherClosed = true;
unsubscribeFromWrites();
await watcher.close().catch(() => {});
},
};

View File

@@ -16,6 +16,7 @@ import {
isNixMode,
loadConfig,
migrateLegacyConfig,
registerConfigWriteListener,
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
@@ -1409,6 +1410,7 @@ export async function startGatewayServer(
return startGatewayConfigReloader({
initialConfig: cfgAtStart,
readSnapshot: readConfigFileSnapshot,
subscribeToWrites: registerConfigWriteListener,
onHotReload: async (plan, nextConfig) => {
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
const prepared = await activateRuntimeSecrets(nextConfig, {