diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 2eff8dc0ba1..a6d18208648 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -116,6 +116,63 @@ describe("infra runtime", () => { } }); + it("uses the latest preparation hook when scheduled restarts coalesce", async () => { + const firstBeforeEmit = vi.fn(async () => {}); + const latestBeforeEmit = vi.fn(async () => {}); + const emitSpy = vi.spyOn(process, "emit"); + const handler = () => {}; + process.on("SIGUSR1", handler); + try { + const first = scheduleGatewaySigusr1Restart({ + delayMs: 1_000, + reason: "first", + emitHooks: { beforeEmit: firstBeforeEmit }, + }); + const second = scheduleGatewaySigusr1Restart({ + delayMs: 1_000, + reason: "second", + emitHooks: { beforeEmit: latestBeforeEmit }, + }); + + expect(first.coalesced).toBe(false); + expect(second.coalesced).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(firstBeforeEmit).not.toHaveBeenCalled(); + expect(latestBeforeEmit).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith("SIGUSR1"); + } finally { + process.removeListener("SIGUSR1", handler); + } + }); + + it("keeps existing preparation hook when a hookless restart coalesces", async () => { + const beforeEmit = vi.fn(async () => {}); + const emitSpy = vi.spyOn(process, "emit"); + const handler = () => {}; + process.on("SIGUSR1", handler); + try { + scheduleGatewaySigusr1Restart({ + delayMs: 1_000, + emitHooks: { beforeEmit }, + }); + const second = scheduleGatewaySigusr1Restart({ + delayMs: 1_000, + reason: "hookless", + }); + + expect(second.coalesced).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(beforeEmit).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith("SIGUSR1"); + } finally { + process.removeListener("SIGUSR1", handler); + } + }); + it("rolls back prepared restart state when emission is rejected", async () => { const beforeEmit = vi.fn(async () => {}); const afterEmitRejected = vi.fn(async () => {}); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 03c45fc5350..886ed415162 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -36,6 +36,7 @@ let lastRestartEmittedAt = 0; let pendingRestartTimer: ReturnType | null = null; let pendingRestartDueAt = 0; let pendingRestartReason: string | undefined; +let pendingRestartEmitHooks: RestartEmitHooks | undefined; const activeDeferralPolls = new Set>(); function hasUnconsumedRestartSignal(): boolean { @@ -49,6 +50,7 @@ function clearPendingScheduledRestart(): void { pendingRestartTimer = null; pendingRestartDueAt = 0; pendingRestartReason = undefined; + pendingRestartEmitHooks = undefined; } function clearActiveDeferralPolls(): void { @@ -202,6 +204,12 @@ export type RestartEmitHooks = { afterEmitRejected?: () => Promise; }; +function updatePendingRestartEmitHooks(hooks?: RestartEmitHooks): void { + if (hooks) { + pendingRestartEmitHooks = hooks; + } +} + async function emitPreparedGatewayRestart(hooks?: RestartEmitHooks): Promise { try { await hooks?.beforeEmit?.(); @@ -482,6 +490,7 @@ export function scheduleGatewaySigusr1Restart(opts?: { restartLog.warn( `restart request coalesced (already scheduled) reason=${reason ?? "unspecified"} pendingReason=${pendingRestartReason ?? "unspecified"} delayMs=${remainingMs} ${formatRestartAudit(opts?.audit)}`, ); + updatePendingRestartEmitHooks(opts?.emitHooks); return { ok: true, pid: process.pid, @@ -497,21 +506,24 @@ export function scheduleGatewaySigusr1Restart(opts?: { pendingRestartDueAt = requestedDueAt; pendingRestartReason = reason; + pendingRestartEmitHooks = opts?.emitHooks; pendingRestartTimer = setTimeout( () => { pendingRestartTimer = null; pendingRestartDueAt = 0; pendingRestartReason = undefined; + const emitHooks = pendingRestartEmitHooks; + pendingRestartEmitHooks = undefined; const pendingCheck = preRestartCheck; if (!pendingCheck) { - void emitPreparedGatewayRestart(opts?.emitHooks); + void emitPreparedGatewayRestart(emitHooks); return; } const cfg = getRuntimeConfig(); deferGatewayRestartUntilIdle({ getPendingCount: pendingCheck, maxWaitMs: cfg.gateway?.reload?.deferralTimeoutMs, - emitHooks: opts?.emitHooks, + emitHooks, }); }, Math.max(0, requestedDueAt - nowMs),