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:
- Print the error inside
.waiting, somewhere you can read it from a device. It is the entire diagnosis and the default place for it is unreachable in the field. - Do not read
.waitingas "still trying." It also means "the host said no." Switch on the POSIX code. - Keep the patience where it belongs.
EHOSTUNREACH,ENETUNREACHandENETDOWNare the cases the state was designed for, and treating those as terminal will break reconnection after a wake. - On iOS and macOS, rule out Local Network permission
first. While that prompt is unanswered or access is denied, the
path reports
.localNetworkDenied; checkconnection.currentPath?.unsatisfiedReasonbefore treating any.waitingreason as final. I have only tested this triage on tvOS, which has no such prompt. - Check your timeout message names the failure. A string about network paths, printed because your timer expired, sends people to re-check working hardware.
- Guard the continuation.
.waitingrepeats; resuming twice is a crash.
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.