Skip to content

Commit f26ed0a

Browse files
committed
Motivation:
Currently, when specifying port 0 in `HTTPServerConfiguration`, there is no public API to determine what port the `NIOHTTPServer` has actually been bound to. Modifications: - Added a `SocketAddress` type for representing a host address and port. - Added an async `listeningAddress` property on `NIOHTTPServer` that returns a `SocketAddress` once the server has started listening (or throws an error if the address couldn't be bound to or if the server has closed). - Added a `State` type to `NIOHTTPServer` for representing the idle, listening, and closed states and managing transitions between them. Result: Users can determine the address and port the `NIOHTTPServer` is listening at.
1 parent e3c41e2 commit f26ed0a

File tree

4 files changed

+271
-0
lines changed

4 files changed

+271
-0
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import NIOConcurrencyHelpers
2+
import NIOCore
3+
import NIOPosix
4+
5+
enum ListeningAddressError: CustomStringConvertible, Error {
6+
case addressOrPortNotAvailable
7+
case unsupportedAddressType
8+
case serverClosed
9+
10+
var description: String {
11+
switch self {
12+
case .addressOrPortNotAvailable:
13+
return "Unable to retrieve the bound address or port from the underlying socket"
14+
case .unsupportedAddressType:
15+
return "Unsupported address type: only IPv4 and IPv6 are supported"
16+
case .serverClosed:
17+
return """
18+
There is no listening address bound for this server: there may have been \
19+
an error which caused the server to close, or it may have shut down.
20+
"""
21+
}
22+
}
23+
}
24+
25+
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
26+
extension NIOHTTPServer {
27+
func addressBound(_ address: NIOCore.SocketAddress?) throws {
28+
switch self.listeningAddressState.withLockedValue({ $0.addressBound(address) }) {
29+
case .succeedPromise(let promise, let boundAddress):
30+
promise.succeed(boundAddress)
31+
case .failPromise(let promise, let error):
32+
promise.fail(error)
33+
}
34+
}
35+
36+
/// The address the server is listening from.
37+
///
38+
/// It is an `async` property because it will only return once the address has been successfully bound.
39+
///
40+
/// - Throws: An error will be thrown if the address could not be bound or is not bound any longer because the
41+
/// server isn't listening anymore.
42+
public var listeningAddress: SocketAddress {
43+
get async throws {
44+
return try await self.listeningAddressState
45+
.withLockedValue { try $0.listeningAddressFuture }
46+
.get()
47+
}
48+
}
49+
}
50+
51+
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
52+
extension NIOHTTPServer {
53+
enum State {
54+
case idle(EventLoopPromise<SocketAddress>)
55+
case listening(EventLoopFuture<SocketAddress>)
56+
case closedOrInvalidAddress(ListeningAddressError)
57+
58+
var listeningAddressFuture: EventLoopFuture<SocketAddress> {
59+
get throws {
60+
switch self {
61+
case .idle(let eventLoopPromise):
62+
return eventLoopPromise.futureResult
63+
case .listening(let eventLoopFuture):
64+
return eventLoopFuture
65+
case .closedOrInvalidAddress(let error):
66+
throw error
67+
}
68+
}
69+
}
70+
71+
enum OnBound {
72+
case succeedPromise(_ promise: EventLoopPromise<SocketAddress>, address: SocketAddress)
73+
case failPromise(_ promise: EventLoopPromise<SocketAddress>, error: ListeningAddressError)
74+
}
75+
76+
mutating func addressBound(_ address: NIOCore.SocketAddress?) -> OnBound {
77+
switch self {
78+
case .idle(let listeningAddressPromise):
79+
do {
80+
let socketAddress = try SocketAddress(address)
81+
self = .listening(listeningAddressPromise.futureResult)
82+
return .succeedPromise(listeningAddressPromise, address: socketAddress)
83+
}
84+
catch {
85+
self = .closedOrInvalidAddress(error)
86+
return .failPromise(listeningAddressPromise, error: error)
87+
}
88+
89+
case .listening, .closedOrInvalidAddress:
90+
fatalError("Invalid state: addressBound should only be called once and when in idle state")
91+
}
92+
}
93+
94+
enum OnClose {
95+
case failPromise(_ promise: EventLoopPromise<SocketAddress>, error: ListeningAddressError)
96+
case doNothing
97+
}
98+
99+
mutating func close() -> OnClose {
100+
switch self {
101+
case .idle(let listeningAddressPromise):
102+
self = .closedOrInvalidAddress(.serverClosed)
103+
return .failPromise(listeningAddressPromise, error: .serverClosed)
104+
105+
case .listening:
106+
self = .closedOrInvalidAddress(.serverClosed)
107+
return .doNothing
108+
109+
case .closedOrInvalidAddress:
110+
return .doNothing
111+
}
112+
}
113+
}
114+
}
115+
116+
extension SocketAddress {
117+
fileprivate init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) {
118+
guard let address, let port = address.port else {
119+
throw ListeningAddressError.addressOrPortNotAvailable
120+
}
121+
122+
switch address {
123+
case .v4(let ipv4Address):
124+
self.init(base: .ipv4(.init(host: ipv4Address.host, port: port)))
125+
case .v6(let ipv6Address):
126+
self.init(base: .ipv6(.init(host: ipv6Address.host, port: port)))
127+
case .unixDomainSocket:
128+
throw ListeningAddressError.unsupportedAddressType
129+
}
130+
}
131+
}

Sources/HTTPServer/NIOHTTPServer.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
public import Logging
22
import HTTPTypes
33
import NIOCertificateReloading
4+
import NIOConcurrencyHelpers
45
import NIOCore
56
import NIOHTTP1
67
import NIOHTTP2
@@ -69,6 +70,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
6970
private let logger: Logger
7071
private let configuration: HTTPServerConfiguration
7172

73+
var listeningAddressState: NIOLockedValueBox<State>
74+
7275
/// Create a new ``HTTPServer`` implemented over `SwiftNIO`.
7376
/// - Parameters:
7477
/// - logger: A logger instance for recording server events and debugging information.
@@ -79,6 +82,10 @@ public struct NIOHTTPServer: HTTPServerProtocol {
7982
) {
8083
self.logger = logger
8184
self.configuration = configuration
85+
86+
// TODO: If we allow users to pass in an event loop, use that instead of the singleton MTELG.
87+
let eventLoopGroup: MultiThreadedEventLoopGroup = .singletonMultiThreadedEventLoopGroup
88+
self.listeningAddressState = .init(.idle(eventLoopGroup.any().makePromise()))
8289
}
8390

8491
/// Starts an HTTP server with the specified request handler.
@@ -120,6 +127,15 @@ public struct NIOHTTPServer: HTTPServerProtocol {
120127
/// )
121128
/// ```
122129
public func serve(handler: some HTTPServerRequestHandler<RequestReader, ResponseWriter>) async throws {
130+
defer {
131+
switch self.listeningAddressState.withLockedValue({ $0.close() }) {
132+
case .failPromise(let promise, let error):
133+
promise.fail(error)
134+
case .doNothing:
135+
()
136+
}
137+
}
138+
123139
let asyncChannelConfiguration: NIOAsyncChannel<HTTPRequestPart, HTTPResponsePart>.Configuration
124140
switch self.configuration.backpressureStrategy.backing {
125141
case .watermark(let low, let high):
@@ -292,6 +308,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
292308
}
293309
}
294310

311+
try self.addressBound(serverChannel.channel.localAddress)
312+
295313
try await withThrowingDiscardingTaskGroup { group in
296314
try await serverChannel.executeThenClose { inbound in
297315
for try await http1Channel in inbound {
@@ -354,6 +372,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
354372
}
355373
}
356374

375+
try self.addressBound(serverChannel.channel.localAddress)
376+
357377
try await withThrowingDiscardingTaskGroup { group in
358378
try await serverChannel.executeThenClose { inbound in
359379
for try await upgradeResult in inbound {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import NIOCore
2+
3+
/// Represents an IPv4 address.
4+
public struct IPv4: Hashable, Sendable {
5+
/// The resolved host address.
6+
public var host: String
7+
/// The port to connect to.
8+
public var port: Int
9+
10+
/// Creates a new IPv4 address.
11+
///
12+
/// - Parameters:
13+
/// - host: Resolved host address.
14+
/// - port: Port to connect to.
15+
public init(host: String, port: Int) {
16+
self.host = host
17+
self.port = port
18+
}
19+
}
20+
21+
/// Represents an IPv6 address.
22+
public struct IPv6: Hashable, Sendable {
23+
/// The resolved host address.
24+
public var host: String
25+
/// The port to connect to.
26+
public var port: Int
27+
28+
/// Creates a new IPv6 address.
29+
///
30+
/// - Parameters:
31+
/// - host: Resolved host address.
32+
/// - port: Port to connect to.
33+
public init(host: String, port: Int) {
34+
self.host = host
35+
self.port = port
36+
}
37+
}
38+
39+
/// An address to which a socket may connect or bind to.
40+
public struct SocketAddress: Hashable, Sendable {
41+
enum Base: Hashable, Sendable {
42+
case ipv4(IPv4)
43+
case ipv6(IPv6)
44+
}
45+
46+
let base: Base
47+
48+
/// Creates an IPv4 socket address.
49+
public static func ipv4(host: String, port: Int) -> Self {
50+
Self(base: .ipv4(.init(host: host, port: port)))
51+
}
52+
53+
/// Creates an IPv6 socket address.
54+
public static func ipv6(host: String, port: Int) -> Self {
55+
Self(base: .ipv6(.init(host: host, port: port)))
56+
}
57+
58+
/// Returns the address as an IPv4 address, if possible.
59+
public var ipv4: IPv4? {
60+
guard case .ipv4(let address) = self.base else {
61+
return nil
62+
}
63+
64+
return address
65+
}
66+
67+
/// Returns the address as an IPv6 address, if possible.
68+
public var ipv6: IPv6? {
69+
guard case .ipv6(let address) = self.base else {
70+
return nil
71+
}
72+
73+
return address
74+
}
75+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
@testable import HTTPServer
2+
import HTTPTypes
3+
import Logging
4+
import NIOCore
5+
import NIOHTTP1
6+
import NIOHTTPTypes
7+
import NIOPosix
8+
import Testing
9+
10+
#if canImport(Dispatch)
11+
import Dispatch
12+
#endif
13+
14+
@Suite
15+
struct NIOHTTPServerTests {
16+
@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *)
17+
@Test("Obtain the listening address correctly")
18+
func testListeningAddress() async throws {
19+
let server = NIOHTTPServer(
20+
logger: Logger(label: "Test"),
21+
configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 1234))
22+
)
23+
24+
try await withThrowingTaskGroup { group in
25+
group.addTask {
26+
try await server.serve { _, _, _, _ in
27+
return
28+
}
29+
}
30+
31+
let serverAddress = try await server.listeningAddress
32+
33+
let address = try #require(serverAddress.ipv4)
34+
#expect(address.host == "127.0.0.1")
35+
#expect(address.port == 1234)
36+
37+
group.cancelAll()
38+
}
39+
40+
// Now that the server has shut down, try obtaining the listening address. This should result in an error.
41+
await #expect(throws: ListeningAddressError.serverClosed) {
42+
try await server.listeningAddress
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)