Skip to content

Commit dae143c

Browse files
authored
Add server listening address property in NIOHTTPServer (#32)
Motivation: Currently, when specifying port 0 in `HTTPServerConfiguration`, there is no 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 an internal `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 028f1e7 commit dae143c

File tree

4 files changed

+309
-2
lines changed

4 files changed

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

Sources/HTTPServer/NIOHTTPServer.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
//
1212
//===----------------------------------------------------------------------===//
1313

14-
public import Logging
1514
import HTTPTypes
15+
public import Logging
1616
import NIOCertificateReloading
17+
import NIOConcurrencyHelpers
1718
import NIOCore
1819
import NIOHTTP1
1920
import NIOHTTP2
@@ -22,9 +23,9 @@ import NIOHTTPTypesHTTP1
2223
import NIOHTTPTypesHTTP2
2324
import NIOPosix
2425
import NIOSSL
25-
import X509
2626
import SwiftASN1
2727
import Synchronization
28+
import X509
2829

2930
/// A generic HTTP server that can handle incoming HTTP requests.
3031
///
@@ -82,6 +83,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
8283
private let logger: Logger
8384
private let configuration: HTTPServerConfiguration
8485

86+
var listeningAddressState: NIOLockedValueBox<State>
87+
8588
/// Create a new ``HTTPServer`` implemented over `SwiftNIO`.
8689
/// - Parameters:
8790
/// - logger: A logger instance for recording server events and debugging information.
@@ -92,6 +95,10 @@ public struct NIOHTTPServer: HTTPServerProtocol {
9295
) {
9396
self.logger = logger
9497
self.configuration = configuration
98+
99+
// TODO: If we allow users to pass in an event loop, use that instead of the singleton MTELG.
100+
let eventLoopGroup: MultiThreadedEventLoopGroup = .singletonMultiThreadedEventLoopGroup
101+
self.listeningAddressState = .init(.idle(eventLoopGroup.any().makePromise()))
95102
}
96103

97104
/// Starts an HTTP server with the specified request handler.
@@ -133,6 +140,15 @@ public struct NIOHTTPServer: HTTPServerProtocol {
133140
/// )
134141
/// ```
135142
public func serve(handler: some HTTPServerRequestHandler<RequestReader, ResponseWriter>) async throws {
143+
defer {
144+
switch self.listeningAddressState.withLockedValue({ $0.close() }) {
145+
case .failPromise(let promise, let error):
146+
promise.fail(error)
147+
case .doNothing:
148+
()
149+
}
150+
}
151+
136152
let asyncChannelConfiguration: NIOAsyncChannel<HTTPRequestPart, HTTPResponsePart>.Configuration
137153
switch self.configuration.backpressureStrategy.backing {
138154
case .watermark(let low, let high):
@@ -305,6 +321,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
305321
}
306322
}
307323

324+
try self.addressBound(serverChannel.channel.localAddress)
325+
308326
try await withThrowingDiscardingTaskGroup { group in
309327
try await serverChannel.executeThenClose { inbound in
310328
for try await http1Channel in inbound {
@@ -367,6 +385,8 @@ public struct NIOHTTPServer: HTTPServerProtocol {
367385
}
368386
}
369387

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

0 commit comments

Comments
 (0)