waitUntilListening() times out instead of reporting why the bind failed
Version: FlyingFox 0.27.1 (also present in earlier releases)
Platform: macOS 15 (Darwin/kQueue pool), but the code path is platform-independent
Summary
When HTTPServer.run() fails to bind its listening socket — typically because the port is already in use
— the continuations parked in waitUntilListening() are never
resumed. They wait out the full timeout and then throw a timeout error, so the caller learns
"something did not happen within 5 seconds" rather than the much. more specific EADDRINUSE.
This affects the startup pattern the README documents, so it is easy to hit while being hard to diagnose.
Why the error is lost
In HTTPServer.run(), the socket is created before state is assigned:
public func run() async throws {
guard state == nil else { … }
defer { state = nil }
do {
let socket = try await preparePoolAndSocket() // ← bind()/listen() throw here
let task = Task { try await start(on: socket, pool: config.pool) }
state = (socket: socket, task: task) // ← only this fires isListeningDidUpdate
try await task.getValue(cancelling: .whenParentIsCancelled)
} catch {
logger.logCritical("server error: \(error.localizedDescription)")
if let state = self.state { try? state.socket.close() }
throw error // ← goes only to run()'s own caller
}
}
The waiting continuations are resumed in exactly one place, isListeningDidUpdate(from:) in
HTTPServer+Listening.swift, which returns early unless isListening is true:
func isListeningDidUpdate(from previous: Bool) {
guard isListening else { return }
…
}
preparePoolAndSocket() is precisely the bind-and-listen step, so every failure that a caller most
wants to distinguish — EADDRINUSE, EACCES, a sandbox denial — happens while state is still
nil. The error is logged and rethrown to whoever awaits run(), but nothing reaches the waiters.
(A failure after a successful bind behaves fine: state is set, waiters resume, and the later
state = nil from the defer hits the guard and is correctly ignored.)
Reproducing
let handler = ClosureHTTPHandler { _ in HTTPResponse(statusCode: .ok) }
let first = HTTPServer(port: 8080, handler: handler)
Task { try await first.run() }
try await first.waitUntilListening()
// Same port, second server — the README's documented startup sequence:
let second = HTTPServer(port: 8080, handler: handler)
let task = Task { try await second.run() }
try await second.waitUntilListening() // hangs 5 s, then throws a timeout, not EADDRINUSE
The real reason is available, but only out of band: it appears in the log, and it is the value of
task, which the caller is not awaiting because, on success, run() never returns.
Why this is worth fixing upstream
- It is the documented path. The README pairs
Task { try await server.run() } with
try await server.waitUntilListening(). Following the README gives you a misleading error for the
single most common startup failure there is.
- The test suite is written the same way. The helper
startServer(_:) in
FlyingFox/Tests/HTTPServerTests.swift does Task { try await server.run() } then
try await server.waitUntilListening(). A port clash in CI (parallel tests, a stray process)
surfaces as an unexplained 5 s timeout in an unrelated-looking test.
waitUntilListing_ThrowsWhen_TimeoutExpires only asserts throws: (any Error).self, so the
suite cannot currently tell a genuine timeout from a swallowed bind error either.
- Callers who want to retry cannot. Port-scanning ("try 8080, then 8081, …") needs to
distinguish EADDRINUSE from a permission error that no other port will fix. Today that means
building a private error channel out of run(), or paying the timeout per candidate — 20
candidates at the default 5 s is 100 seconds to conclude "no free port".
Suggested fix
Resume the pending waiters with the caught error before rethrowing. Roughly, in run()'s catch:
} catch {
logger.logCritical("server error: \(error.localizedDescription)")
if let state = self.state { try? state.socket.close() }
failWaitingContinuations(with: error) // new
throw error
}
with the counterpart alongside isListeningDidUpdate(from:):
func failWaitingContinuations(with error: any Error) {
let waiting = self.waiting
self.waiting = [:]
for continuation in waiting.values {
continuation.resume(throwing: error)
}
}
This is additive: run() keeps throwing to its own caller exactly as it does now, isListening
semantics are unchanged, and the timeout still covers the case where the bind neither succeeds nor
fails. waitUntilListening() starts throwing the same SocketError that run() throws, which is
what callers already expect from a function documented as "wait until the server is listening".
Worth deciding as part of it: whether the guard state == nil "already started" path should also
fail its waiters (it currently throws SocketError.unsupportedAddress, which does not describe
"already started" very well).
Suggested tests: waitUntilListening throws SocketError with EADDRINUSE when the port is taken,
promptly rather than after timeout; and the existing timeout test tightened to assert the timeout
error type specifically, so the two cases stay distinguishable.
Context
Found while replacing a hand-written preview server in a macOS app (vdhamer/Photo-Club-Hub-HTML#249)
with FlyingFox. Working around this is most of the glue that remains: an out-of-band runFailure
property written by the run() task, a poll loop over isListening at 5 ms intervals, and a manual
deadline — about 45 lines whose only job is to recover the error that run() already has access to.
With the change above, the whole bind step becomes:
let task = Task { try? await server.run() }
do {
try await server.waitUntilListening(timeout: listenTimeout)
} catch {
task.cancel()
throw mapped(error) // EADDRINUSE → try the next port; anything else → report it
}
Note: this suggestion came out of adopting FlyingFox in
vdhamer/Photo-Club-Hub-HTML. I explicitly asked
Claude Code (Opus 5) for changes that would reduce the glue code on our side and would plausibly
benefit other FlyingFox users as well — the analysis above is its work against the 0.27.1
sources (yes, I did review it before filing). Offered as one user's suggestion; feel free to reject it if it does
not fit where you want the library to go.
waitUntilListening()times out instead of reporting why the bind failedVersion: FlyingFox 0.27.1 (also present in earlier releases)
Platform: macOS 15 (Darwin/kQueue pool), but the code path is platform-independent
Summary
When
HTTPServer.run()fails to bind its listening socket — typically because the port is already in use— the continuations parked in
waitUntilListening()are neverresumed. They wait out the full
timeoutand then throw a timeout error, so the caller learns"something did not happen within 5 seconds" rather than the much. more specific
EADDRINUSE.This affects the startup pattern the README documents, so it is easy to hit while being hard to diagnose.
Why the error is lost
In
HTTPServer.run(), the socket is created beforestateis assigned:The
waitingcontinuations are resumed in exactly one place,isListeningDidUpdate(from:)inHTTPServer+Listening.swift, which returns early unlessisListeningis true:preparePoolAndSocket()is precisely the bind-and-listen step, so every failure that a caller mostwants to distinguish —
EADDRINUSE,EACCES, a sandbox denial — happens whilestateis stillnil. The error is logged and rethrown to whoever awaitsrun(), but nothing reaches the waiters.(A failure after a successful bind behaves fine:
stateis set, waiters resume, and the laterstate = nilfrom thedeferhits theguardand is correctly ignored.)Reproducing
The real reason is available, but only out of band: it appears in the log, and it is the value of
task, which the caller is not awaiting because, on success,run()never returns.Why this is worth fixing upstream
Task { try await server.run() }withtry await server.waitUntilListening(). Following the README gives you a misleading error for thesingle most common startup failure there is.
startServer(_:)inFlyingFox/Tests/HTTPServerTests.swiftdoesTask { try await server.run() }thentry await server.waitUntilListening(). A port clash in CI (parallel tests, a stray process)surfaces as an unexplained 5 s timeout in an unrelated-looking test.
waitUntilListing_ThrowsWhen_TimeoutExpiresonly assertsthrows: (any Error).self, so thesuite cannot currently tell a genuine timeout from a swallowed bind error either.
distinguish
EADDRINUSEfrom a permission error that no other port will fix. Today that meansbuilding a private error channel out of
run(), or paying the timeout per candidate — 20candidates at the default 5 s is 100 seconds to conclude "no free port".
Suggested fix
Resume the pending waiters with the caught error before rethrowing. Roughly, in
run()'scatch:with the counterpart alongside
isListeningDidUpdate(from:):This is additive:
run()keeps throwing to its own caller exactly as it does now,isListeningsemantics are unchanged, and the timeout still covers the case where the bind neither succeeds nor
fails.
waitUntilListening()starts throwing the sameSocketErrorthatrun()throws, which iswhat callers already expect from a function documented as "wait until the server is listening".
Worth deciding as part of it: whether the
guard state == nil"already started" path should alsofail its waiters (it currently throws
SocketError.unsupportedAddress, which does not describe"already started" very well).
Suggested tests:
waitUntilListeningthrowsSocketErrorwithEADDRINUSEwhen the port is taken,promptly rather than after
timeout; and the existing timeout test tightened to assert the timeouterror type specifically, so the two cases stay distinguishable.
Context
Found while replacing a hand-written preview server in a macOS app (vdhamer/Photo-Club-Hub-HTML#249)
with FlyingFox. Working around this is most of the glue that remains: an out-of-band
runFailureproperty written by the
run()task, a poll loop overisListeningat 5 ms intervals, and a manualdeadline — about 45 lines whose only job is to recover the error that
run()already has access to.With the change above, the whole bind step becomes:
Note: this suggestion came out of adopting FlyingFox in
vdhamer/Photo-Club-Hub-HTML. I explicitly asked
Claude Code (Opus 5) for changes that would reduce the glue code on our side and would plausibly
benefit other FlyingFox users as well — the analysis above is its work against the 0.27.1
sources (yes, I did review it before filing). Offered as one user's suggestion; feel free to reject it if it does
not fit where you want the library to go.