Skip to content

Commit bd7b0ad

Browse files
fix(plugins): remove abort listener when channel runtime context is disposed (#109708)
1 parent 62db322 commit bd7b0ad

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

src/plugins/runtime/channel-runtime-contexts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export function createChannelRuntimeContextRegistry(): ChannelRuntimeContextRegi
115115
return;
116116
}
117117
disposed = true;
118+
// Detach before the token check: stale leases disposed after a replacement
119+
// registered must still release their listener on long-lived signals.
120+
params.abortSignal?.removeEventListener("abort", dispose);
118121
const current = runtimeContexts.get(normalized.mapKey);
119122
if (!current || current.token !== token) {
120123
return;

src/plugins/runtime/runtime-channel.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Runtime channel tests cover channel plugin runtime send, reply, and capability behavior.
2+
import { getEventListeners } from "node:events";
23
import { describe, expect, it, vi } from "vitest";
34
import { createRuntimeChannel } from "./runtime-channel.js";
45

@@ -89,6 +90,60 @@ describe("runtimeContexts", () => {
8990
lease.dispose();
9091
});
9192

93+
it("removes its abort listener when the lease is disposed", () => {
94+
const channel = createRuntimeChannel();
95+
const controller = new AbortController();
96+
const initialListenerCount = getEventListeners(controller.signal, "abort").length;
97+
const lease = channel.runtimeContexts.register({
98+
channelId: "telegram",
99+
accountId: "default",
100+
capability: "approval.native",
101+
context: { token: "abc" },
102+
abortSignal: controller.signal,
103+
});
104+
105+
expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 1);
106+
107+
lease.dispose();
108+
109+
expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount);
110+
});
111+
112+
it("removes the stale lease abort listener after a replacement registration", () => {
113+
const channel = createRuntimeChannel();
114+
const controller = new AbortController();
115+
const initialListenerCount = getEventListeners(controller.signal, "abort").length;
116+
const staleLease = channel.runtimeContexts.register({
117+
channelId: "whatsapp",
118+
accountId: "default",
119+
capability: "connection.controller",
120+
context: { token: "stale" },
121+
abortSignal: controller.signal,
122+
});
123+
channel.runtimeContexts.register({
124+
channelId: "whatsapp",
125+
accountId: "default",
126+
capability: "connection.controller",
127+
context: { token: "replacement" },
128+
abortSignal: controller.signal,
129+
});
130+
131+
expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 2);
132+
133+
// Channel plugins dispose the previous lease after registering its replacement,
134+
// so the stale token check must not skip listener cleanup.
135+
staleLease.dispose();
136+
137+
expect(getEventListeners(controller.signal, "abort")).toHaveLength(initialListenerCount + 1);
138+
expect(
139+
channel.runtimeContexts.get({
140+
channelId: "whatsapp",
141+
accountId: "default",
142+
capability: "connection.controller",
143+
}),
144+
).toEqual({ token: "replacement" });
145+
});
146+
92147
it("does not register contexts when the abort signal is already aborted", () => {
93148
const channel = createRuntimeChannel();
94149
const onEvent = vi.fn();

0 commit comments

Comments
 (0)