From cde21ec92afd2920b85cd15248d244e72f9e05e1 Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Thu, 16 Apr 2026 14:24:39 +0800 Subject: [PATCH] fix(gateway): capture config hash after plugin auto-enable to prevent restart loop The startup internal-write hash was only recorded when auth-token generation or control-UI origin seeding persisted changes, missing the earlier plugin auto-enable write performed inside loadGatewayStartupConfigSnapshot(). When chokidar detected the auto-enable write the hash guard had nothing to compare against, so it proceeded with a full config reload that triggered SIGUSR1, restarting the gateway in a 3-6 minute loop that corrupted manifest.db mid-write. Always read the final config hash after all startup writes complete so the reloader can suppress the self-induced reload. --- src/gateway/config-reload.test.ts | 28 ++++++++++++++++++++++++++++ src/gateway/server.impl.ts | 9 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 4e802a89cb5..d08e263e1c0 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -620,6 +620,34 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); + + it("does not dedupe when initialInternalWriteHash is null (#67436)", async () => { + const readSnapshot = vi + .fn<() => Promise>() + .mockResolvedValueOnce( + makeSnapshot({ + config: { + gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "startup" } }, + }, + hash: "startup-internal-1", + }), + ); + const harness = createReloaderHarness(readSnapshot, { + initialInternalWriteHash: null, + }); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledTimes(1); + // With a null hash the guard is a no-op, so the reload proceeds and + // detects a config diff → restart. This is the pre-fix regression + // scenario from #67436 where plugin auto-enable was the only startup + // writer and the hash was never captured. + expect(harness.onRestart).toHaveBeenCalledTimes(1); + + await harness.reloader.stop(); + }); }); describe("shouldInvalidateSkillsSnapshotForPaths", () => { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index c99c48099ac..df43a301298 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -284,7 +284,14 @@ export async function startGatewayServer( log, }); cfgAtStart = controlUiSeed.config; - if (authBootstrap.persistedGeneratedToken || controlUiSeed.persistedAllowedOriginsSeed) { + // Always capture the final config hash after all startup writes (plugin + // auto-enable, auth token generation, control-UI origin seeding) so the + // config reloader can recognize its own startup writes and suppress the + // spurious hot-reload that would otherwise trigger a SIGUSR1 restart loop. + // Previously the hash was only captured when auth or control-UI persisted + // changes, missing the plugin auto-enable write performed earlier inside + // loadGatewayStartupConfigSnapshot(). See #67436. + { const startupSnapshot = await readConfigFileSnapshot(); startupInternalWriteHash = startupSnapshot.hash ?? null; }