Skip to content

waitUntilListening() times out instead of reporting why the bind failed #239

Description

@vdhamer

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions