Skip to content

Commit 00b6fb7

Browse files
authored
fix event once listener cleanup ownership (#631)
1 parent 77411c2 commit 00b6fb7

2 files changed

Lines changed: 208 additions & 4 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { afterEach, describe, expect, test, vi } from 'vitest';
2+
import { Client, Collection } from 'discord.js';
3+
import { CommandKit } from '../../commandkit';
4+
import { CommandKitEventsChannel } from '../../events/CommandKitEventsChannel';
5+
import { ParsedEvent } from '../router';
6+
import { AppEventsHandler, EventListener, LoadedEvent } from './AppEventsHandler';
7+
8+
const clients: Client[] = [];
9+
10+
function createHandler() {
11+
CommandKit.instance = undefined;
12+
13+
const client = new Client({ intents: [] });
14+
clients.push(client);
15+
16+
const commandkit = new CommandKit({ client });
17+
commandkit.events = new CommandKitEventsChannel(commandkit);
18+
19+
return {
20+
client,
21+
commandkit,
22+
handler: new AppEventsHandler(commandkit),
23+
};
24+
}
25+
26+
function createLoadedEvent(
27+
name: string,
28+
listeners: EventListener[],
29+
namespace: string | null = null,
30+
): LoadedEvent {
31+
const event: ParsedEvent = {
32+
event: name,
33+
namespace,
34+
path: `/events/${namespace ? `${namespace}/` : ''}${name}`,
35+
listeners: [],
36+
};
37+
38+
return {
39+
name,
40+
namespace,
41+
event,
42+
listeners,
43+
};
44+
}
45+
46+
function seedLoadedEvent(handler: AppEventsHandler, event: LoadedEvent) {
47+
const key = `${event.namespace ? `${event.namespace}:` : ''}${event.name}`;
48+
const loadedEvents = (handler as any).loadedEvents as Collection<
49+
string,
50+
LoadedEvent
51+
>;
52+
53+
loadedEvents.set(key, event);
54+
}
55+
56+
function listener(
57+
handler: (...args: unknown[]) => void,
58+
options: Partial<Pick<EventListener, 'once' | 'parallel'>> = {},
59+
): EventListener {
60+
return {
61+
handler,
62+
once: options.once ?? false,
63+
parallel: options.parallel ?? false,
64+
};
65+
}
66+
67+
async function flushEventHandlers() {
68+
await new Promise((resolve) => setImmediate(resolve));
69+
}
70+
71+
afterEach(async () => {
72+
CommandKit.instance = undefined;
73+
74+
await Promise.all(clients.splice(0).map((client) => client.destroy()));
75+
});
76+
77+
describe('AppEventsHandler listener cleanup', () => {
78+
test('removes stale pending once wrapper when reloading mixed regular and once events', async () => {
79+
const { client, handler } = createHandler();
80+
const eventName = 'eventLifecycleTest';
81+
let oldRegular = 0;
82+
let oldOnce = 0;
83+
let newRegular = 0;
84+
let newOnce = 0;
85+
86+
seedLoadedEvent(
87+
handler,
88+
createLoadedEvent(eventName, [
89+
listener(() => oldRegular++),
90+
listener(() => oldOnce++, { once: true }),
91+
]),
92+
);
93+
94+
handler.registerAllClientEvents();
95+
expect(client.listenerCount(eventName)).toBe(2);
96+
97+
handler.unregisterAllClientListeners();
98+
99+
seedLoadedEvent(
100+
handler,
101+
createLoadedEvent(eventName, [
102+
listener(() => newRegular++),
103+
listener(() => newOnce++, { once: true }),
104+
]),
105+
);
106+
107+
handler.registerAllClientEvents();
108+
109+
expect(client.listenerCount(eventName)).toBe(2);
110+
111+
client.emit(eventName);
112+
await flushEventHandlers();
113+
114+
expect(oldRegular).toBe(0);
115+
expect(oldOnce).toBe(0);
116+
expect(newRegular).toBe(1);
117+
expect(newOnce).toBe(1);
118+
});
119+
120+
test('removes once-only generated wrapper with exact off cleanup', () => {
121+
const { client, handler } = createHandler();
122+
const eventName = 'eventLifecycleTest';
123+
const offSpy = vi.spyOn(client, 'off');
124+
const removeAllListenersSpy = vi.spyOn(client, 'removeAllListeners');
125+
126+
seedLoadedEvent(
127+
handler,
128+
createLoadedEvent(eventName, [listener(() => {}, { once: true })]),
129+
);
130+
131+
handler.registerAllClientEvents();
132+
expect(client.listenerCount(eventName)).toBe(1);
133+
134+
handler.unregisterAllClientListeners();
135+
136+
expect(offSpy).toHaveBeenCalledWith(eventName, expect.any(Function));
137+
expect(removeAllListenersSpy).not.toHaveBeenCalled();
138+
expect(client.listenerCount(eventName)).toBe(0);
139+
});
140+
141+
test('keeps regular-only cleanup exact-reference based', () => {
142+
const { client, handler } = createHandler();
143+
const eventName = 'eventLifecycleTest';
144+
const offSpy = vi.spyOn(client, 'off');
145+
const removeAllListenersSpy = vi.spyOn(client, 'removeAllListeners');
146+
147+
seedLoadedEvent(
148+
handler,
149+
createLoadedEvent(eventName, [listener(() => {})]),
150+
);
151+
152+
handler.registerAllClientEvents();
153+
expect(client.listenerCount(eventName)).toBe(1);
154+
155+
handler.unregisterAllClientListeners();
156+
157+
expect(offSpy).toHaveBeenCalledWith(eventName, expect.any(Function));
158+
expect(removeAllListenersSpy).not.toHaveBeenCalled();
159+
expect(client.listenerCount(eventName)).toBe(0);
160+
});
161+
162+
test('removes namespaced once wrapper with exact off cleanup', async () => {
163+
const { commandkit, handler } = createHandler();
164+
const eventName = 'eventLifecycleTest';
165+
const namespace = 'testNamespace';
166+
let calls = 0;
167+
const offSpy = vi.spyOn(commandkit.events, 'off');
168+
const removeAllListenersSpy = vi.spyOn(
169+
commandkit.events,
170+
'removeAllListeners',
171+
);
172+
173+
seedLoadedEvent(
174+
handler,
175+
createLoadedEvent(
176+
eventName,
177+
[listener(() => calls++, { once: true })],
178+
namespace,
179+
),
180+
);
181+
182+
handler.registerAllClientEvents();
183+
handler.unregisterAllClientListeners();
184+
185+
expect(offSpy).toHaveBeenCalledWith(
186+
namespace,
187+
eventName,
188+
expect.any(Function),
189+
);
190+
expect(removeAllListenersSpy).not.toHaveBeenCalled();
191+
192+
commandkit.events.emit(namespace, eventName);
193+
await flushEventHandlers();
194+
195+
expect(calls).toBe(0);
196+
});
197+
});

‎packages/commandkit/src/app/handlers/AppEventsHandler.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface LoadedEvent {
2727
event: ParsedEvent;
2828
listeners: EventListener[];
2929
mainListener?: EventListener;
30+
onceListener?: EventListener;
3031
executedOnceListeners?: Set<ListenerFunction>; // Track executed once listeners
3132
}
3233

@@ -406,6 +407,10 @@ export class AppEventsHandler {
406407
onListeners.length > 0
407408
? { handler: mainHandler, once: false, parallel: false }
408409
: undefined,
410+
onceListener:
411+
onceListeners.length > 0
412+
? { handler: onceHandler, once: true, parallel: false }
413+
: undefined,
409414
executedOnceListeners,
410415
});
411416

@@ -442,19 +447,21 @@ export class AppEventsHandler {
442447

443448
for (const [
444449
key,
445-
{ name, mainListener, namespace },
450+
{ name, mainListener, onceListener, namespace },
446451
] of this.loadedEvents.entries()) {
447452
if (mainListener) {
448453
if (namespace) {
449454
this.commandkit.events.off(namespace, name, mainListener.handler);
450455
} else {
451456
client.off(name, mainListener.handler);
452457
}
453-
} else {
458+
}
459+
460+
if (onceListener) {
454461
if (namespace) {
455-
this.commandkit.events.removeAllListeners(namespace, name);
462+
this.commandkit.events.off(namespace, name, onceListener.handler);
456463
} else {
457-
client.removeAllListeners(name);
464+
client.off(name, onceListener.handler);
458465
}
459466
}
460467

0 commit comments

Comments
 (0)