fix: avoid duplicate gateway config loads

This commit is contained in:
Peter Steinberger
2026-05-02 15:49:05 +01:00
parent 10ebcbdb99
commit 5980040894
6 changed files with 101 additions and 23 deletions

View File

@@ -27,6 +27,10 @@ const configState = vi.hoisted(() => ({
cfg: {} as Record<string, unknown>,
snapshot: { exists: false } as Record<string, unknown>,
}));
const readBestEffortConfig = vi.fn(async () => configState.cfg);
const readConfigFileSnapshotWithPluginMetadata = vi.fn(async () => ({
snapshot: configState.snapshot,
}));
const recoverConfigFromLastKnownGood = vi.fn<(params?: unknown) => Promise<boolean>>(
async (_params?: unknown) => false,
);
@@ -52,8 +56,9 @@ const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeC
vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
readBestEffortConfig: async () => configState.cfg,
readBestEffortConfig: () => readBestEffortConfig(),
readConfigFileSnapshot: async () => configState.snapshot,
readConfigFileSnapshotWithPluginMetadata: () => readConfigFileSnapshotWithPluginMetadata(),
recoverConfigFromLastKnownGood: (params: unknown) => recoverConfigFromLastKnownGood(params),
recoverConfigFromJsonRootSuffix: (snapshot: unknown) => recoverConfigFromJsonRootSuffix(snapshot),
}));
@@ -186,6 +191,8 @@ describe("gateway run option collisions", () => {
resetRuntimeCapture();
configState.cfg = {};
configState.snapshot = { exists: false };
readBestEffortConfig.mockClear();
readConfigFileSnapshotWithPluginMetadata.mockClear();
controlUiState.root = "/tmp/openclaw-control-ui";
gatewayLogMessages.length = 0;
recoverConfigFromLastKnownGood.mockReset();
@@ -306,10 +313,15 @@ describe("gateway run option collisions", () => {
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(readBestEffortConfig).not.toHaveBeenCalled();
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
expect.objectContaining({
bind: "loopback",
startupConfigSnapshotRead: {
snapshot: configState.snapshot,
},
}),
);
});
@@ -339,7 +351,7 @@ describe("gateway run option collisions", () => {
expect(writeDiagnosticStabilityBundleForFailureSync).not.toHaveBeenCalled();
});
it("blocks startup when the observed snapshot loses gateway.mode even if loadConfig still says local", async () => {
it("blocks startup when the observed snapshot loses gateway.mode", async () => {
configState.cfg = {
gateway: {
mode: "local",
@@ -365,6 +377,7 @@ describe("gateway run option collisions", () => {
`Config write audit: ${path.join("/tmp", "logs", "config-audit.jsonl")}`,
);
expect(startGatewayServer).not.toHaveBeenCalled();
expect(readBestEffortConfig).not.toHaveBeenCalled();
});
it("restores last-known-good config before startup when the effective config is invalid", async () => {

View File

@@ -7,6 +7,7 @@ import type {
GatewayAuthMode,
GatewayBindMode,
GatewayTailscaleMode,
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../../config/config.js";
import { formatConfigIssueSummary } from "../../config/issue-format.js";
import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
@@ -271,18 +272,21 @@ function getGatewayStartGuardErrors(params: {
async function readGatewayStartupConfig(params: {
startupTrace: ReturnType<typeof createGatewayCliStartupTrace>;
}): Promise<{ cfg: OpenClawConfig; snapshot: ConfigFileSnapshot | null }> {
}): Promise<{
cfg: OpenClawConfig;
snapshot: ConfigFileSnapshot | null;
startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
}> {
const {
readBestEffortConfig,
readConfigFileSnapshot,
readConfigFileSnapshotWithPluginMetadata,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
} = await import("../../config/config.js");
let cfg = await params.startupTrace.measure("cli.config-load", () => readBestEffortConfig());
let snapshot: ConfigFileSnapshot | null = await params.startupTrace.measure(
"cli.config-snapshot",
() => readConfigFileSnapshot().catch(() => null),
);
let snapshotRead: ReadConfigFileSnapshotWithPluginMetadataResult | null =
await params.startupTrace.measure("cli.config-snapshot", () =>
readConfigFileSnapshotWithPluginMetadata().catch(() => null),
);
let snapshot: ConfigFileSnapshot | null = snapshotRead?.snapshot ?? null;
if (snapshot?.exists && !snapshot.valid) {
const invalidSnapshot = snapshot;
const recovered = await params.startupTrace.measure("cli.config-recovery", () =>
@@ -317,9 +321,10 @@ async function readGatewayStartupConfig(params: {
`gateway: failed to persist config auto-recovery notice: ${formatErrorMessage(err)}`,
);
}
snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshot().catch(() => null),
snapshotRead = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshotWithPluginMetadata().catch(() => null),
);
snapshot = snapshotRead?.snapshot ?? null;
} else {
const repaired = await params.startupTrace.measure("cli.config-prefix-recovery", () =>
recoverConfigFromJsonRootSuffix(invalidSnapshot),
@@ -328,16 +333,19 @@ async function readGatewayStartupConfig(params: {
gatewayLog.warn(
`gateway: repaired invalid effective config by stripping a non-JSON prefix: ${invalidSnapshot.path}`,
);
snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshot().catch(() => null),
snapshotRead = await params.startupTrace.measure("cli.config-snapshot-reload", () =>
readConfigFileSnapshotWithPluginMetadata().catch(() => null),
);
snapshot = snapshotRead?.snapshot ?? null;
}
}
}
if (snapshot?.valid) {
cfg = snapshot.config;
}
return { cfg, snapshot };
const cfg = snapshot?.config ?? {};
return {
cfg,
snapshot,
...(snapshotRead ? { startupConfigSnapshotRead: snapshotRead } : {}),
};
}
function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts {
@@ -563,7 +571,9 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
}
gatewayLog.info("loading configuration…");
const { cfg, snapshot } = await readGatewayStartupConfig({ startupTrace });
const { cfg, snapshot, startupConfigSnapshotRead } = await readGatewayStartupConfig({
startupTrace,
});
void maybeLogPendingControlUiBuild(cfg).catch((err) => {
gatewayLog.warn(`Control UI asset check failed: ${String(err)}`);
});
@@ -842,6 +852,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
auth: authOverride,
tailscale: tailscaleOverride,
startupStartedAt,
...(startupConfigSnapshotRead ? { startupConfigSnapshotRead } : {}),
}),
});

View File

@@ -39,7 +39,10 @@ export type {
ConfigWriteFollowUp,
RuntimeConfigSnapshotMetadata,
} from "./runtime-snapshot.js";
export type { ConfigWriteNotification } from "./io.js";
export type {
ConfigWriteNotification,
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "./io.js";
export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js";
export * from "./paths.js";
export * from "./recovery-policy.js";

View File

@@ -244,6 +244,42 @@ describe("gateway startup config recovery", () => {
expect(log.info).not.toHaveBeenCalled();
});
it("reuses a CLI preflight snapshot without rereading config", async () => {
const snapshot = buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify(validConfig)}\n`,
parsed: validConfig,
valid: true,
config: validConfig,
issues: [],
legacyIssues: [],
});
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: false,
log,
initialSnapshotRead: {
snapshot,
pluginMetadataSnapshot,
},
}),
).resolves.toEqual({
snapshot,
wroteConfig: false,
pluginMetadataSnapshot,
});
expect(configIo.readConfigFileSnapshotWithPluginMetadata).not.toHaveBeenCalled();
expect(applyPluginAutoEnable).toHaveBeenCalledWith({
config: validConfig,
env: process.env,
manifestRegistry: pluginManifestRegistry,
});
});
it("preserves empty model allowlist entries through startup auto-enable writes", async () => {
const sourceConfig = {
agents: {

View File

@@ -1,6 +1,7 @@
import { loadAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
type ReadConfigFileSnapshotWithPluginMetadataResult,
readConfigFileSnapshotWithPluginMetadata,
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
@@ -202,11 +203,14 @@ export async function loadGatewayStartupConfigSnapshot(params: {
minimalTestGateway: boolean;
log: GatewayStartupLog;
measure?: GatewayStartupConfigMeasure;
initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
}): Promise<GatewayStartupConfigSnapshotLoadResult> {
const measure = params.measure ?? (async (_name, run) => await run());
let snapshotRead = await measure("config.snapshot.read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
);
let snapshotRead =
params.initialSnapshotRead ??
(await measure("config.snapshot.read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
));
let configSnapshot = snapshotRead.snapshot;
let pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
let wroteConfig = false;

View File

@@ -13,6 +13,8 @@ import {
readConfigFileSnapshot,
recoverConfigFromLastKnownGood,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../config/io.js";
import { replaceConfigFile } from "../config/mutate.js";
import { isNixMode } from "../config/paths.js";
@@ -456,6 +458,11 @@ export type GatewayServerOptions = {
* Optional startup timestamp used for concise readiness logging.
*/
startupStartedAt?: number;
/**
* Config snapshot already read by the CLI gateway preflight. Passing it avoids
* reparsing openclaw.json during server startup.
*/
startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
};
type SetupWizardRunner = NonNullable<GatewayServerOptions["wizardRunner"]>;
@@ -491,6 +498,9 @@ export async function startGatewayServer(
minimalTestGateway,
log,
measure: (name, run) => startupTrace.measure(name, run),
...(opts.startupConfigSnapshotRead
? { initialSnapshotRead: opts.startupConfigSnapshotRead }
: {}),
}),
);
const configSnapshot = startupConfigLoad.snapshot;
@@ -585,6 +595,7 @@ export async function startGatewayServer(
startupInternalWriteHash = startupSnapshot.hash ?? null;
startupLastGoodSnapshot = startupSnapshot;
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", () =>
prepareGatewayPluginBootstrap({
cfgAtStart,