fix: avoid startup gateway reload loop (#58678) (thanks @yelog)

This commit is contained in:
Peter Steinberger 2026-04-01 16:46:10 +09:00
parent 71f341c4b4
commit 7cf8ccf9b3
No known key found for this signature in database
5 changed files with 76 additions and 6 deletions

View File

@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/reload: ignore startup config writes by persisted hash in the config reloader so generated auth tokens and seeded Control UI origins do not trigger a restart loop, while real `gateway.auth.*` edits still require restart. (#58678) Thanks @yelog
- Discord/inbound media: pass Discord attachment and sticker downloads through the shared idle-timeout and worker-abort path so slow or stuck inbound media fetches stop hanging message processing. (#58593) Thanks @aquaright1
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
- Tasks/gateway: re-check the current task record before maintenance marks runs lost or prunes them, so a task heartbeat or cleanup update that lands during a sweep no longer gets overwritten by stale snapshot state.

View File

@ -199,6 +199,18 @@ describe("buildGatewayReloadPlan", () => {
expect(plan.noopPaths).toContain("diagnostics.stuckSessionWarnMs");
});
it("restarts for gateway.auth.token changes", () => {
const plan = buildGatewayReloadPlan(["gateway.auth.token"]);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toContain("gateway.auth.token");
});
it("restarts for gateway.auth.mode changes", () => {
const plan = buildGatewayReloadPlan(["gateway.auth.mode"]);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toContain("gateway.auth.mode");
});
it("defaults unknown paths to restart", () => {
const plan = buildGatewayReloadPlan(["unknownField"]);
expect(plan.restartGateway).toBe(true);
@ -228,6 +240,11 @@ describe("buildGatewayReloadPlan", () => {
expectRestartGateway: false,
expectNoopPath: "gateway.remote.url",
},
{
path: "gateway.auth.token",
expectRestartGateway: true,
expectRestartReason: "gateway.auth.token",
},
{
path: "unknownField",
expectRestartGateway: true,
@ -304,7 +321,10 @@ function makeSnapshot(partial: Partial<ConfigFileSnapshot> = {}): ConfigFileSnap
};
}
function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>) {
function createReloaderHarness(
readSnapshot: () => Promise<ConfigFileSnapshot>,
options: { initialInternalWriteHash?: string | null } = {},
) {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const onHotReload = vi.fn(async () => {});
@ -325,6 +345,7 @@ function createReloaderHarness(readSnapshot: () => Promise<ConfigFileSnapshot>)
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
initialInternalWriteHash: options.initialInternalWriteHash,
readSnapshot,
subscribeToWrites,
onHotReload,
@ -515,4 +536,43 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("dedupes the first watcher reread for startup internal writes", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } },
},
hash: "startup-internal-1",
}),
)
.mockResolvedValueOnce(
makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 }, port: 19001 },
},
hash: "external-after-startup-1",
}),
);
const harness = createReloaderHarness(readSnapshot, {
initialInternalWriteHash: "startup-internal-1",
});
harness.watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(harness.onHotReload).not.toHaveBeenCalled();
expect(harness.onRestart).not.toHaveBeenCalled();
harness.watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onRestart).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
});

View File

@ -76,6 +76,7 @@ export type GatewayConfigReloader = {
export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialInternalWriteHash?: string | null;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
@ -96,7 +97,7 @@ export function startGatewayConfigReloader(opts: {
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: OpenClawConfig | null = null;
let lastAppliedWriteHash: string | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
const scheduleAfter = (wait: number) => {
if (stopped) {

View File

@ -505,6 +505,7 @@ export async function startGatewayServer(
});
let cfgAtStart: OpenClawConfig;
let startupInternalWriteHash: string | null = null;
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
const authBootstrap = await prepareGatewayStartupConfig({
configSnapshot,
@ -539,11 +540,16 @@ export async function startGatewayServer(
);
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
// non-loopback installs that upgraded to v2026.2.26+ without required origins.
cfgAtStart = await maybeSeedControlUiAllowedOriginsAtStartup({
const controlUiSeed = await maybeSeedControlUiAllowedOriginsAtStartup({
config: cfgAtStart,
writeConfig: writeConfigFile,
log,
});
cfgAtStart = controlUiSeed.config;
if (authBootstrap.persistedGeneratedToken || controlUiSeed.persistedAllowedOriginsSeed) {
const startupSnapshot = await readConfigFileSnapshot();
startupInternalWriteHash = startupSnapshot.hash ?? null;
}
await runStartupMatrixMigration({
cfg: cfgAtStart,
env: process.env,
@ -1436,6 +1442,7 @@ export async function startGatewayServer(
return startGatewayConfigReloader({
initialConfig: cfgAtStart,
initialInternalWriteHash: startupInternalWriteHash,
readSnapshot: readConfigFileSnapshot,
subscribeToWrites: registerConfigWriteListener,
onHotReload: async (plan, nextConfig) => {

View File

@ -8,20 +8,21 @@ export async function maybeSeedControlUiAllowedOriginsAtStartup(params: {
config: OpenClawConfig;
writeConfig: (config: OpenClawConfig) => Promise<void>;
log: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<OpenClawConfig> {
}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> {
const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config);
if (!seeded.seededOrigins || !seeded.bind) {
return params.config;
return { config: params.config, persistedAllowedOriginsSeed: false };
}
try {
await params.writeConfig(seeded.config);
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
return { config: seeded.config, persistedAllowedOriginsSeed: true };
} catch (err) {
params.log.warn(
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
);
}
return seeded.config;
return { config: seeded.config, persistedAllowedOriginsSeed: false };
}
function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string {