NWConnection reports a refused connection as waiting, not failed

A user wrote in because the camera view in my tvOS app (AmbientCast, which puts a 3D printer's camera on a television) sat at "Connecting…" and never moved. He attached a diagnostics export, and every connection attempt in it read the same:

rtsp: session ended after 8.2s -- never reached PLAY
      (connectionFailed("Timed out waiting for network path")) -- reconnecting in 3s

Ten of them, 8.1 to 8.3 seconds each. His network was not the problem: the same app held an MQTT connection to the same printer, at the same address, in the same second. The message blamed the one thing that provably worked.

The timeout was mine

That 8.1-to-8.3-second spread is not a duration the network chose. It is my own fixed eight-second connect timeout firing, every attempt. The string describes the mechanism of my waiting rather than anything that failed, and it reads as a verdict on the user's Wi-Fi. He went and checked his Wi-Fi.

The real reason was one state transition away, in a place he could not get at.

case .waiting(let error):
    logger.notice("connection waiting: \(error.localizedDescription)")

That error is the real NWError — refused, unreachable, a TLS handshake that never completes — and logger is os_log. The app's exportable diagnostics file never saw it, and on a physical Apple TV there is no command-line route to os_log at all. Failures with entirely different fixes were all printing one sentence.

ELI5: what's os_log?

Apple's system logging facility. It is fast and structured, and it is the right place for an app's chatter — but the log lives on the device and you need a Mac attached, or a tool that can reach the device, to read it back. A log file the app writes itself is worse technology and can be emailed to you by a stranger.

Writing that line into the exported file as well took one commit. The next log off hardware said this:

16:08:57Z rtsp: waiting for <printer>:322 -- Network.NWError error 61 - Connection refused
16:09:05Z rtsp: session ended after 8.2s -- never reached PLAY ... -- reconnecting in 3s

The answer arrives at :57. I give up at :05.

A refusal is an answer, and Network.framework does not call it a failure

POSIX 61 is ECONNREFUSED, which is as final as a network answer gets. Network.framework delivers it as .waiting(NWError) rather than .failed(NWError), and Apple's documentation for that case is explicit about what it means:

The connection is waiting for a network path change.

Connections that are waiting will indicate the reason that the connection couldn’t be established in the associated error. These errors are not fatal.

ELI5: what does "connection refused" mean?

The machine you dialed is switched on, reachable, and listening to you — it just has nothing running on the port you asked for, so it immediately says no. That is different from a timeout, where nothing answers at all and you cannot tell whether the host is off, firewalled, or simply slow. A refusal takes microseconds; a timeout takes as long as you are willing to wait.

For this error, "not fatal" is wrong in the only sense that matters to the caller. The host received the connection and sent back a reset because nothing is listening on that port. There is no network path change coming that will fix it, and no amount of waiting inside the same attempt will improve the answer.

The patience is right most of the time, which is why it survives

.waiting is not a mistake in general. It is exactly what an NWConnection reports while Wi-Fi and DNS settle after the device wakes, and on iOS and macOS while the Local Network permission prompt is still unanswered. (tvOS has no such prompt: Apple's technote on local network privacy lists it as unsupported there.) In both of those the connection really does reach .ready on its own, with no help from me.

So the state is doing two jobs. One of them is "hold on, this may come good," and the other is "the host has told you no." They arrive through the same case, and only the associated error tells them apart.

Triage the error yourself

The split that matters is whether a host demonstrably answered. If it did, the attempt is over. If the complaint is about a path that is not there yet, patience is the whole point of the state and should be left alone.

/// Whether a `.waiting` reason is a final answer rather than a
/// condition that may clear on its own.
func isTerminal(_ error: NWError) -> Bool {
    guard case .posix(let code) = error else { return false }
    switch code {
    // A host answered. Nothing is pending.
    case .ECONNREFUSED, .ECONNRESET:
        return true
    // No answer, but the path is there: the host itself is not
    // responding, and no path change will fix that.
    case .EHOSTDOWN:
        return true
    // Deliberately absent: .EHOSTUNREACH, .ENETUNREACH, .ENETDOWN.
    // Those describe a path that isn't there yet, which is the case
    // the patient behavior exists for.
    default:
        return false
    }
}

Wiring it into the state handler is four lines, and the ordering is load-bearing: log first, decide second, and only then touch the continuation.

// State updates arrive serially on the queue passed to start(queue:),
// so this flag is never touched concurrently.
nonisolated(unsafe) var resumed = false

connection.stateUpdateHandler = { state in
    switch state {
    case .ready:
        guard !resumed else { return }
        resumed = true
        continuation.resume()

    case .failed(let error):
        guard !resumed else { return }
        resumed = true
        continuation.resume(throwing: error)

    case .waiting(let error):
        writeToExportableLog("waiting: \(error)")   // the entire diagnosis
        guard isTerminal(error) else { return }     // may still come good
        guard !resumed else { return }
        resumed = true
        connection.cancel()
        continuation.resume(throwing: error)

    default:
        break
    }
}
ELI5: what's a continuation?

A bridge between old-style callback code and Swift's async/await. You suspend the awaiting function, hand out a token, and whoever holds the token resumes it later with a result. The rule is that it must be resumed exactly once — never twice, and never not at all.

Both guards earn their place. .waiting fires repeatedly while NWConnection retries on its own, so a bare continuation.resume in that case traps the second time through with a continuation misuse. And the cancel() matters because throwing out of the await does not stop the connection; left alone it keeps retrying on its queue behind you.

Measured on the tvOS 27 simulator against a refused port, same scenario before and after:

session ended after 8.1s   ->   session ended after 0.0s
guidance appears in ~33s   ->   ~7s

The second line is the one a person feels. The app waits for three consecutive failures before it tells the user which printer setting is probably responsible, and at eleven seconds an attempt — eight dead, three of backoff — that advice took the better part of a minute to appear.

Two failures that had been printing one sentence

The logging fix came with a result I did not expect. I reproduced the problem on my own printer by turning its camera setting off, got the refusal above, and assumed I had reproduced the user's fault.

I had not. Under the new logging, his situation produces no reason reported — because .waiting never fired with an error at all. His printer went silent; mine refuses. A closed port and a path that swallows packets are different causes with different fixes, and before the log line they were indistinguishable.

Count attempts, not seconds

One more thing fell out of this, a week later and the wrong way round. With refusals now instant, the comment next to that three-attempt threshold looked wrong: it priced three attempts as "about half a minute," and three attempts had just become seven seconds. I changed the threshold. Then I reverted it.

The constant had not rotted. A count prices the two cases correctly because it does not try to tell them apart. ECONNREFUSED is definitive, so three of those genuinely is worth only seven seconds of patience; a timeout is ambiguous and still costs its full eight, so three of those is still half a minute. Had I written the threshold as a duration, it would have been wrong for one of them whichever number I picked.

What I'd tell you to check first

If an NWConnection is sitting there and your own timeout is what eventually fires:

The general version: a framework state that bundles "retry may help" with "you have your answer" pushes the triage onto you, and if you skip it the cost shows up as latency rather than as an error. Nothing was broken in that log. Every attempt was simply being charged full price for an answer that had already arrived.

Observed on an Apple TV 4K running tvOS 27, with a reproduction on the tvOS 27 simulator; the app builds back to tvOS 17. Apple's own DTS engineer has agreed on the forums that .waiting is "a poor match" for an error of this kind, in a 2022 thread about a different POSIX code, and recommended filing a bug. That thread has no answer in it.