From b9222df738e246b72a545afdb808a5a5149dda39 Mon Sep 17 00:00:00 2001 From: Jeff Klassen Date: Wed, 1 Apr 2026 09:02:36 +0300 Subject: [PATCH 1/2] fix: await IDLE loop in email channel disconnect to prevent resource leak startIdleLoop() was fire-and-forget, so disconnect() could return before the loop released the mailbox lock. A rapid disconnect/reconnect cycle could spawn concurrent IDLE loops competing for the same lock. Track the loop promise and await it in disconnect(), and set connectionState before aborting so the loop exits immediately. --- src/channels/__tests__/email.test.ts | 42 +++++++++++++++++++++++++++- src/channels/email.ts | 14 ++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/channels/__tests__/email.test.ts b/src/channels/__tests__/email.test.ts index ee9fa8b..0ee7af2 100644 --- a/src/channels/__tests__/email.test.ts +++ b/src/channels/__tests__/email.test.ts @@ -5,7 +5,18 @@ import { EmailChannel, type EmailChannelConfig } from "../email.ts"; const mockConnect = mock(() => Promise.resolve()); const mockLogout = mock(() => Promise.resolve()); const mockGetMailboxLock = mock(() => Promise.resolve({ release: () => {} })); -const mockIdle = mock(() => new Promise(() => {})); +const mockIdle = mock( + (opts?: { abort?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (opts?.abort) { + if (opts.abort.aborted) { + reject(new Error("abort")); + return; + } + opts.abort.addEventListener("abort", () => reject(new Error("abort")), { once: true }); + } + }), +); const mockFetch = mock(function* () { // Empty generator - no unread messages }); @@ -141,6 +152,35 @@ describe("EmailChannel", () => { expect(callArgs.text).toBe("Plain text content"); }); + test("disconnect awaits IDLE loop before logout", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + + // disconnect should complete without hanging — the IDLE loop + // must terminate before logout is called + await channel.disconnect(); + + // Verify logout was called (meaning IDLE loop finished first) + expect(mockLogout).toHaveBeenCalledTimes(1); + expect(channel.isConnected()).toBe(false); + }); + + test("rapid disconnect and reconnect does not leak IDLE loops", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + const lockCallsBefore = mockGetMailboxLock.mock.calls.length; + + await channel.disconnect(); + mockGetMailboxLock.mockClear(); + + // Reconnect should work cleanly without competing for the lock + await channel.connect(); + expect(channel.isConnected()).toBe(true); + expect(mockGetMailboxLock).toHaveBeenCalledTimes(1); + + await channel.disconnect(); + }); + test("send generates unique message ID", async () => { const channel = new EmailChannel(testConfig); await channel.connect(); diff --git a/src/channels/email.ts b/src/channels/email.ts index f0d283f..d5b4ebb 100644 --- a/src/channels/email.ts +++ b/src/channels/email.ts @@ -51,6 +51,7 @@ export class EmailChannel implements Channel { private transporter: NodemailerTransport | null = null; private threads = new Map(); private idleAbort: AbortController | null = null; + private idleLoopPromise: Promise | null = null; constructor(config: EmailChannelConfig) { this.config = config; @@ -86,8 +87,8 @@ export class EmailChannel implements Channel { this.connectionState = "connected"; console.log("[email] SMTP configured"); - // Start IDLE listening - void this.startIdleLoop(); + // Start IDLE listening (tracked so disconnect can await it) + this.idleLoopPromise = this.startIdleLoop(); } catch (err: unknown) { this.connectionState = "error"; const msg = err instanceof Error ? err.message : String(err); @@ -99,8 +100,16 @@ export class EmailChannel implements Channel { async disconnect(): Promise { if (this.connectionState === "disconnected") return; + this.connectionState = "disconnected"; this.idleAbort?.abort(); + // Wait for the IDLE loop to finish and release the mailbox lock + // before logging out, so a subsequent connect() won't race. + if (this.idleLoopPromise) { + await this.idleLoopPromise; + this.idleLoopPromise = null; + } + try { await this.imapClient?.logout(); } catch (err: unknown) { @@ -108,7 +117,6 @@ export class EmailChannel implements Channel { console.warn(`[email] Error during IMAP disconnect: ${msg}`); } - this.connectionState = "disconnected"; console.log("[email] Disconnected"); } From 577a52eacb54648c08d9efd1c768b2895bec8532 Mon Sep 17 00:00:00 2001 From: Jeff Klassen Date: Wed, 1 Apr 2026 11:54:17 +0300 Subject: [PATCH 2/2] fix: remove unused variable to pass typecheck --- src/channels/__tests__/email.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/channels/__tests__/email.test.ts b/src/channels/__tests__/email.test.ts index 0ee7af2..85a326b 100644 --- a/src/channels/__tests__/email.test.ts +++ b/src/channels/__tests__/email.test.ts @@ -168,7 +168,6 @@ describe("EmailChannel", () => { test("rapid disconnect and reconnect does not leak IDLE loops", async () => { const channel = new EmailChannel(testConfig); await channel.connect(); - const lockCallsBefore = mockGetMailboxLock.mock.calls.length; await channel.disconnect(); mockGetMailboxLock.mockClear();