mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
fix(config): reuse in-memory gateway write reloads
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user