Skip to content

Commit cb857d2

Browse files
authored
Merge pull request #22 from jeffklassen/fix/email-idle-loop-resource-leak
fix: await IDLE loop in email channel disconnect to prevent resource leak
2 parents a605cf3 + 4704b63 commit cb857d2

File tree

2 files changed

+51
-4
lines changed

2 files changed

+51
-4
lines changed

src/channels/__tests__/email.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ import { EmailChannel, type EmailChannelConfig } from "../email.ts";
55
const mockConnect = mock(() => Promise.resolve());
66
const mockLogout = mock(() => Promise.resolve());
77
const mockGetMailboxLock = mock(() => Promise.resolve({ release: () => {} }));
8-
const mockIdle = mock(() => new Promise(() => {}));
8+
const mockIdle = mock(
9+
(opts?: { abort?: AbortSignal }) =>
10+
new Promise<void>((_resolve, reject) => {
11+
if (opts?.abort) {
12+
if (opts.abort.aborted) {
13+
reject(new Error("abort"));
14+
return;
15+
}
16+
opts.abort.addEventListener("abort", () => reject(new Error("abort")), { once: true });
17+
}
18+
}),
19+
);
920
const mockFetch = mock(function* () {
1021
// Empty generator - no unread messages
1122
});
@@ -141,6 +152,34 @@ describe("EmailChannel", () => {
141152
expect(callArgs.text).toBe("Plain text content");
142153
});
143154

155+
test("disconnect awaits IDLE loop before logout", async () => {
156+
const channel = new EmailChannel(testConfig);
157+
await channel.connect();
158+
159+
// disconnect should complete without hanging — the IDLE loop
160+
// must terminate before logout is called
161+
await channel.disconnect();
162+
163+
// Verify logout was called (meaning IDLE loop finished first)
164+
expect(mockLogout).toHaveBeenCalledTimes(1);
165+
expect(channel.isConnected()).toBe(false);
166+
});
167+
168+
test("rapid disconnect and reconnect does not leak IDLE loops", async () => {
169+
const channel = new EmailChannel(testConfig);
170+
await channel.connect();
171+
172+
await channel.disconnect();
173+
mockGetMailboxLock.mockClear();
174+
175+
// Reconnect should work cleanly without competing for the lock
176+
await channel.connect();
177+
expect(channel.isConnected()).toBe(true);
178+
expect(mockGetMailboxLock).toHaveBeenCalledTimes(1);
179+
180+
await channel.disconnect();
181+
});
182+
144183
test("send generates unique message ID", async () => {
145184
const channel = new EmailChannel(testConfig);
146185
await channel.connect();

src/channels/email.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class EmailChannel implements Channel {
5151
private transporter: NodemailerTransport | null = null;
5252
private threads = new Map<string, EmailThread>();
5353
private idleAbort: AbortController | null = null;
54+
private idleLoopPromise: Promise<void> | null = null;
5455

5556
constructor(config: EmailChannelConfig) {
5657
this.config = config;
@@ -86,8 +87,8 @@ export class EmailChannel implements Channel {
8687
this.connectionState = "connected";
8788
console.log("[email] SMTP configured");
8889

89-
// Start IDLE listening
90-
void this.startIdleLoop();
90+
// Start IDLE listening (tracked so disconnect can await it)
91+
this.idleLoopPromise = this.startIdleLoop();
9192
} catch (err: unknown) {
9293
this.connectionState = "error";
9394
const msg = err instanceof Error ? err.message : String(err);
@@ -99,16 +100,23 @@ export class EmailChannel implements Channel {
99100
async disconnect(): Promise<void> {
100101
if (this.connectionState === "disconnected") return;
101102

103+
this.connectionState = "disconnected";
102104
this.idleAbort?.abort();
103105

106+
// Wait for the IDLE loop to finish and release the mailbox lock
107+
// before logging out, so a subsequent connect() won't race.
108+
if (this.idleLoopPromise) {
109+
await this.idleLoopPromise;
110+
this.idleLoopPromise = null;
111+
}
112+
104113
try {
105114
await this.imapClient?.logout();
106115
} catch (err: unknown) {
107116
const msg = err instanceof Error ? err.message : String(err);
108117
console.warn(`[email] Error during IMAP disconnect: ${msg}`);
109118
}
110119

111-
this.connectionState = "disconnected";
112120
console.log("[email] Disconnected");
113121
}
114122

0 commit comments

Comments
 (0)