From de03430104a7144e402f2cfc2fbbf61da45ab569 Mon Sep 17 00:00:00 2001 From: YangManBOBO Date: Sat, 18 Jul 2026 01:16:38 +0800 Subject: [PATCH] fix(gateway): config hot-reload permanently disables after sporadic recovered watcher errors (#109682) * fix(gateway): stop disabling config hot-reload after recovered watcher errors * test(gateway): prove watcher retry budget resets per episode --------- Co-authored-by: Peter Steinberger --- src/gateway/config-reload.test.ts | 57 +++++++++++++++++++++++++++++++ src/gateway/config-reload.ts | 12 +++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 54ee16dad7ae..29c5c6c087cf 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -3804,6 +3804,63 @@ describe("startGatewayConfigReloader watcher error recovery", () => { await reloader.stop(); }); + it("keeps hot-reload active when transient errors are separated by working watchers", async () => { + const originalVitest = process.env.VITEST; + const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING; + delete process.env.VITEST; + delete process.env.CHOKIDAR_USEPOLLING; + let reloader: { stop: () => Promise; hotReloadStatus: () => string } | undefined; + try { + // One initial watcher plus one re-create per error/recovery round. + const watchers = Array.from({ length: 5 }, () => createWatcherMock()); + const started = startReloaderWithWatchers(watchers); + const { watchSpy, log } = started; + reloader = started.reloader; + const watchOptions = (index: number) => + watchSpy.mock.calls[index]?.[1] as { usePolling?: boolean } | undefined; + + // The initial error consumes one attempt. Every replacement then proves + // itself before failing, so each new episode must restart at attempt one. + watchers[0]?.emit("error"); + expect(log.warn).toHaveBeenLastCalledWith( + expect.stringContaining("re-creating watcher (attempt 1/3 in 500ms)"), + ); + await vi.advanceTimersByTimeAsync(500); + + for (let round = 1; round < 4; round += 1) { + watchers[round]?.emit("change"); + await vi.advanceTimersByTimeAsync(0); + watchers[round]?.emit("error"); + expect(log.warn).toHaveBeenLastCalledWith( + expect.stringContaining("re-creating watcher (attempt 1/3 in 500ms)"), + ); + await vi.advanceTimersByTimeAsync(500); + expect(watchSpy).toHaveBeenCalledTimes(round + 2); + } + + // Hot-reload survives in native mode: no polling degradation, no disable. + expect(reloader.hotReloadStatus()).toBe("active"); + expect(log.warn).toHaveBeenCalledTimes(4); + expect(watchOptions(4)?.usePolling).toBe(false); + expect(log.warn).not.toHaveBeenCalledWith( + expect.stringContaining("degrading to polling mode"), + ); + expect(log.error).not.toHaveBeenCalled(); + } finally { + if (originalVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = originalVitest; + } + if (originalChokidarPolling === undefined) { + delete process.env.CHOKIDAR_USEPOLLING; + } else { + process.env.CHOKIDAR_USEPOLLING = originalChokidarPolling; + } + await reloader?.stop(); + } + }); + it("degrades to polling then disables after both native and polling retries are exhausted", async () => { const originalVitest = process.env.VITEST; const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING; diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 66c9d35a51f8..a0b1f74a71f0 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -913,9 +913,15 @@ export function startGatewayConfigReloader(opts: { awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, usePolling, }); - next.on("add", scheduleExternalRefresh); - next.on("change", scheduleExternalRefresh); - next.on("unlink", scheduleExternalRefresh); + // A file event proves this watcher recovered. Reset only here so plugin + // metadata refreshes and consecutive watcher errors cannot refill the budget. + const scheduleFromWatcherEvent = () => { + watcherRecreateRetries = 0; + scheduleExternalRefresh(); + }; + next.on("add", scheduleFromWatcherEvent); + next.on("change", scheduleFromWatcherEvent); + next.on("unlink", scheduleFromWatcherEvent); next.on("error", (err) => { handleWatcherError(next, err); });