Skip to content

Commit 2fa2a1f

Browse files
fix(channels): prevent lifecycle listener buildup
1 parent cf029ea commit 2fa2a1f

4 files changed

Lines changed: 34 additions & 0 deletions

File tree

extensions/discord/src/monitor/message-handler.queue.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Discord tests cover message handler.queue plugin behavior.
2+
import { getEventListeners } from "node:events";
23
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
34
import { beforeEach, describe, expect, it, vi } from "vitest";
45
import { DiscordRetryableInboundError } from "./inbound-dedupe.js";
@@ -440,6 +441,22 @@ describe("createDiscordMessageHandler queue behavior", () => {
440441
expect(setStatus.mock.calls.length).toBe(callsBeforeStop);
441442
});
442443

444+
it("removes lifecycle abort listeners after handler deactivation", () => {
445+
const abortController = new AbortController();
446+
const initialListenerCount = getEventListeners(abortController.signal, "abort").length;
447+
const handler = createDiscordMessageHandler(
448+
createDiscordHandlerParams({ abortSignal: abortController.signal }),
449+
);
450+
451+
expect(getEventListeners(abortController.signal, "abort")).toHaveLength(
452+
initialListenerCount + 2,
453+
);
454+
455+
handler.deactivate();
456+
457+
expect(getEventListeners(abortController.signal, "abort")).toHaveLength(initialListenerCount);
458+
});
459+
443460
it("skips queued runs that have not started yet after deactivation", async () => {
444461
preflightDiscordMessageMock.mockReset();
445462
processDiscordMessageMock.mockReset();

extensions/discord/src/monitor/message-run-queue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export function createDiscordMessageRunQueue(
100100
let lifecycleActive = !params.abortSignal?.aborted;
101101

102102
const cleanupSkippedQueuedMessages = () => {
103+
params.abortSignal?.removeEventListener("abort", cleanupSkippedQueuedMessages);
103104
// These callbacks represent jobs accepted into the queue but not started.
104105
// Running jobs remove their callback before processDiscordMessage owns cleanup.
105106
if (!lifecycleActive && skippedCleanup.size === 0) {

src/channels/run-state-machine.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Run state machine tests cover channel run lifecycle transitions and terminal states.
2+
import { getEventListeners } from "node:events";
23
import { describe, expect, it, vi } from "vitest";
34
import { createRunStateMachine } from "./run-state-machine.js";
45

@@ -38,4 +39,18 @@ describe("createRunStateMachine", () => {
3839
machine.onRunEnd();
3940
expect(setStatus.mock.calls.length).toBe(callsBeforeAbort);
4041
});
42+
43+
it("removes its abort listener on manual deactivation", () => {
44+
const abortController = new AbortController();
45+
const initialListenerCount = getEventListeners(abortController.signal, "abort").length;
46+
const machine = createRunStateMachine({ abortSignal: abortController.signal });
47+
48+
expect(getEventListeners(abortController.signal, "abort")).toHaveLength(
49+
initialListenerCount + 1,
50+
);
51+
52+
machine.deactivate();
53+
54+
expect(getEventListeners(abortController.signal, "abort")).toHaveLength(initialListenerCount);
55+
});
4156
});

src/channels/run-state-machine.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function createRunStateMachine(params: RunStateMachineParams) {
6161
const deactivate = () => {
6262
lifecycleActive = false;
6363
clearHeartbeat();
64+
params.abortSignal?.removeEventListener("abort", onAbort);
6465
};
6566

6667
const onAbort = () => {

0 commit comments

Comments
 (0)