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.
This commit is contained in:
openperf
2026-04-16 14:24:39 +08:00
parent c3c7a9953f
commit cde21ec92a
2 changed files with 36 additions and 1 deletions

View File

@@ -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<ConfigFileSnapshot>>()
.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", () => {

View File

@@ -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;
}