From 41bcdfbeae145565e14fd31542115e00920d90fe Mon Sep 17 00:00:00 2001 From: Kwang Moo Yi Date: Sat, 4 Apr 2026 16:45:03 +0900 Subject: [PATCH] Fix HappyEyeballsConnector leaked-promise crash on failed IMAP connect When TCP connection fails (e.g. no network during IDLE reconnect), NIO's HappyEyeballsConnector can be deallocated with unfulfilled internal promises, triggering a fatal assertion in debug builds (EventLoopFuture.deinit). Bypass the connector entirely by resolving DNS up-front with SocketAddress.makeAddressResolvingHost() and using bootstrap.connect(to:) instead of bootstrap.connect(host:port:). Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SwiftMail/IMAP/IMAPConnection.swift | 17 ++++- .../IMAPConnectionDNSResolutionTests.swift | 73 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 Tests/SwiftIMAPTests/IMAPConnectionDNSResolutionTests.swift diff --git a/Sources/SwiftMail/IMAP/IMAPConnection.swift b/Sources/SwiftMail/IMAP/IMAPConnection.swift index 72286ce9..605e9dc3 100644 --- a/Sources/SwiftMail/IMAP/IMAPConnection.swift +++ b/Sources/SwiftMail/IMAP/IMAPConnection.swift @@ -225,9 +225,24 @@ final class IMAPConnection { } } + // Resolve the address up-front so we can use connect(to:) instead of + // connect(host:port:). The latter goes through HappyEyeballsConnector which + // has a debug-mode bug: when TCP connection fails, internal promises inside the + // connector may not be fulfilled before it is deallocated, triggering a + // "leaking promise" fatal error in NIO's EventLoopFuture.deinit. + // Resolving first and connecting to a concrete SocketAddress bypasses the + // connector entirely. + let address: SocketAddress + do { + address = try SocketAddress.makeAddressResolvingHost(host, port: port) + } catch { + greetingPromise.fail(error) + throw error + } + let channel: Channel do { - channel = try await bootstrap.connect(host: host, port: port).get() + channel = try await bootstrap.connect(to: address).get() } catch { // Fail the greeting promise before rethrowing — prevents NIO "leaking promise" // fatal error when TCP connection fails (e.g. no internet). diff --git a/Tests/SwiftIMAPTests/IMAPConnectionDNSResolutionTests.swift b/Tests/SwiftIMAPTests/IMAPConnectionDNSResolutionTests.swift new file mode 100644 index 00000000..026603d2 --- /dev/null +++ b/Tests/SwiftIMAPTests/IMAPConnectionDNSResolutionTests.swift @@ -0,0 +1,73 @@ +import NIO +import Testing +@testable import SwiftMail + +#if os(macOS) +@Suite(.serialized, .timeLimit(.minutes(1))) +struct IMAPConnectionDNSResolutionTests { + + private func makeGroup() -> MultiThreadedEventLoopGroup { + MultiThreadedEventLoopGroup(numberOfThreads: 1) + } + + private func shutdownGroup(_ group: MultiThreadedEventLoopGroup) { + try? group.syncShutdownGracefully() + } + + /// Connecting to an unresolvable host should throw an error (not crash via + /// HappyEyeballsConnector's leaked-promise assertion). + @Test + func connectToUnresolvableHostThrowsWithoutCrash() async { + let group = makeGroup() + defer { shutdownGroup(group) } + + let connection = IMAPConnection( + host: "this-host-does-not-exist.invalid", + port: 993, + useTLS: false, + group: group, + loggerLabel: "test.dns", + outboundLabel: "test.dns.out", + inboundLabel: "test.dns.in", + connectionID: "test-dns-unresolvable", + connectionRole: "test" + ) + + do { + try await connection.connect() + Issue.record("Expected connect to unresolvable host to throw") + } catch { + // Any error is acceptable — the point is it doesn't crash. + #expect(!connection.isConnected) + } + } + + /// Connecting to a host that resolves but refuses the TCP connection should + /// throw cleanly without leaking NIO promises. + @Test + func connectToRefusedPortThrowsWithoutCrash() async { + let group = makeGroup() + defer { shutdownGroup(group) } + + // Port 1 is almost certainly not listening. + let connection = IMAPConnection( + host: "127.0.0.1", + port: 1, + useTLS: false, + group: group, + loggerLabel: "test.refused", + outboundLabel: "test.refused.out", + inboundLabel: "test.refused.in", + connectionID: "test-refused-port", + connectionRole: "test" + ) + + do { + try await connection.connect() + Issue.record("Expected connect to refused port to throw") + } catch { + #expect(!connection.isConnected) + } + } +} +#endif