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