diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 1634ae56c33b..04fa3580fc4d 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -281,6 +281,45 @@ describe("createGatewayCloseHandler", () => { expect(stopChannel).toHaveBeenCalledWith("discord"); }); + it("clears the secrets runtime snapshot only after channels stop (#112681)", async () => { + const events: string[] = []; + const stopChannel = vi.fn(async (channelId: string) => { + events.push(`channel:${channelId}`); + }); + const clearSecretsRuntimeSnapshot = vi.fn(() => { + events.push("clear-secrets"); + }); + const close = createGatewayCloseHandler( + createGatewayCloseTestDeps({ + channelIds: ["telegram"], + stopChannel, + clearSecretsRuntimeSnapshot, + }), + ); + + await close({ reason: "test" }); + + expect(events).toEqual(["channel:telegram", "clear-secrets"]); + }); + + it("clears the secrets runtime snapshot even when a channel stop fails", async () => { + const clearSecretsRuntimeSnapshot = vi.fn(); + const close = createGatewayCloseHandler( + createGatewayCloseTestDeps({ + channelIds: ["telegram"], + stopChannel: vi.fn(async () => { + throw new Error("stop failed"); + }), + clearSecretsRuntimeSnapshot, + }), + ); + + const result = await close({ reason: "test" }); + + expect(clearSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1); + expect(result.warnings).toContain("channel/telegram"); + }); + it("awaits post-ready sidecars before plugin services and channels", async () => { const events: string[] = []; let releaseSidecar!: () => void; diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 2dd9fa6ec5f3..a27f6d09477a 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -619,7 +619,6 @@ export async function runGatewayClosePrelude(params: { disposeBrowserAuthRateLimiter: () => void; stopChannelHealthMonitor?: () => Promise; stopReadinessEventLoopHealth?: () => void; - clearSecretsRuntimeSnapshot?: () => void; closeMcpServer?: () => Promise; }): Promise { params.stopDiagnostics?.(); @@ -629,7 +628,6 @@ export async function runGatewayClosePrelude(params: { params.disposeBrowserAuthRateLimiter(); await params.stopChannelHealthMonitor?.(); params.stopReadinessEventLoopHealth?.(); - params.clearSecretsRuntimeSnapshot?.(); await params.closeMcpServer?.().catch(() => {}); } @@ -674,6 +672,7 @@ export function createGatewayCloseHandler( bonjourStop: (() => Promise) | null; tailscaleCleanup: (() => Promise) | null; releasePluginRouteRegistry?: (() => void) | null; + clearSecretsRuntimeSnapshot?: (() => void) | null; channelIds?: readonly ChannelId[]; stopChannel: (name: ChannelId, accountId?: string) => Promise; pluginServices: PluginServicesHandle | null; @@ -1043,6 +1042,13 @@ export function createGatewayCloseHandler( } catch { /* ignore */ } + // Channel and plugin teardown still resolve account credentials. Keep the + // active snapshot until every teardown owner is done, then always scrub it. + try { + params.clearSecretsRuntimeSnapshot?.(); + } catch { + /* ignore */ + } } const durationMs = Date.now() - start; diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index f78cbcacf3ff..ae54d03f7240 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -370,7 +370,6 @@ export async function prepareGatewayLifecycle(params: { await monitor?.waitForIdle(); }, stopReadinessEventLoopHealth: readinessEventLoopHealth.stop, - clearSecretsRuntimeSnapshot, closeMcpServer: closeMcpLoopbackServerOnDemand, }); }; @@ -407,6 +406,7 @@ export async function prepareGatewayLifecycle(params: { bonjourStop: runtimeState.bonjourStop, tailscaleCleanup: runtimeState.tailscaleCleanup, releasePluginRouteRegistry, + clearSecretsRuntimeSnapshot, channelIds, stopChannel, pluginServices: runtimeState.pluginServices,