diff --git a/src/plugins/runtime/channel-runtime-contexts.ts b/src/plugins/runtime/channel-runtime-contexts.ts index 78e02e264fbf..ec020b62f9ba 100644 --- a/src/plugins/runtime/channel-runtime-contexts.ts +++ b/src/plugins/runtime/channel-runtime-contexts.ts @@ -115,6 +115,9 @@ export function createChannelRuntimeContextRegistry(): ChannelRuntimeContextRegi return; } disposed = true; + // Detach before the token check: stale leases disposed after a replacement + // registered must still release their listener on long-lived signals. + params.abortSignal?.removeEventListener("abort", dispose); const current = runtimeContexts.get(normalized.mapKey); if (!current || current.token !== token) { return; diff --git a/src/plugins/runtime/runtime-channel.test.ts b/src/plugins/runtime/runtime-channel.test.ts index 0434828c9d50..ba82aa852331 100644 --- a/src/plugins/runtime/runtime-channel.test.ts +++ b/src/plugins/runtime/runtime-channel.test.ts @@ -1,4 +1,5 @@ // Runtime channel tests cover channel plugin runtime send, reply, and capability behavior. +import { getEventListeners } from "node:events"; import { describe, expect, it, vi } from "vitest"; import { createRuntimeChannel } from "./runtime-channel.js"; @@ -89,6 +90,60 @@ describe("runtimeContexts", () => { lease.dispose(); }); + it("removes its abort listener when the lease is disposed", () => { + const channel = createRuntimeChannel(); + const controller = new AbortController(); + const initialListenerCount = getEventListeners(controller.signal, "abort").length; + const lease = channel.runtimeContexts.register({ + channelId: "telegram", + accountId: "default", + capability: "approval.native", + context: { token: "abc" }, + abortSignal: controller.signal, + }); + + expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 1); + + lease.dispose(); + + expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount); + }); + + it("removes the stale lease abort listener after a replacement registration", () => { + const channel = createRuntimeChannel(); + const controller = new AbortController(); + const initialListenerCount = getEventListeners(controller.signal, "abort").length; + const staleLease = channel.runtimeContexts.register({ + channelId: "whatsapp", + accountId: "default", + capability: "connection.controller", + context: { token: "stale" }, + abortSignal: controller.signal, + }); + channel.runtimeContexts.register({ + channelId: "whatsapp", + accountId: "default", + capability: "connection.controller", + context: { token: "replacement" }, + abortSignal: controller.signal, + }); + + expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 2); + + // Channel plugins dispose the previous lease after registering its replacement, + // so the stale token check must not skip listener cleanup. + staleLease.dispose(); + + expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 1); + expect( + channel.runtimeContexts.get({ + channelId: "whatsapp", + accountId: "default", + capability: "connection.controller", + }), + ).toEqual({ token: "replacement" }); + }); + it("does not register contexts when the abort signal is already aborted", () => { const channel = createRuntimeChannel(); const onEvent = vi.fn();