A Swift concurrency shape that appeared three separate times in one codebase during the Aposemati build and cost two full fix rounds. It produces an absolute hang that swallows even test-level timeouts.
The shape
// WRONG — nothing can interrupt thistry await withCheckedThrowingContinuation { c in connection.send(...) { error in c.resume(...) }}
raced against a deadline inside a task group:
await withTaskGroup { group in group.addTask { try await exchange() } // the above group.addTask { try await Task.sleep(...) } // the "timeout" ... group.cancelAll() // does nothing to child 1}
group.cancelAll()cannot interrupt a bare continuation — there is no cancellation handler, so the callback is the only thing that can ever resume it. And withTaskGroup awaits every child before returning. The timeout branch “winning” changes nothing. It is a cycle.
Why it stayed hidden
Two correct fixes combined into a hang
The deadlock was always latent. What had been rescuing it was the QUIC idle timer — the connection died on its own, the callback fired with an error, and the continuation resumed.
Then a keepalive was added (a correct fix for a real problem: a quiet link dying after 30 s of silence between shutter presses). PING frames meant the idle timer never fired, the accidental rescue disappeared, and the latent deadlock became a permanent hang.
Measured against a mute peer with a 3 s handshake timeout:
Config
Result
keepAlive nil, idle 4 s
clean failure at 4.012 s
keepAlive 1 s, idle 4 s
still blocked at 120 s
keepAlive 10 s, idle 30 s (production)
still blocked past 200 s
What it looks like in production
On the phone:connect() never returns and never throws. A spinner, not a failure — which is precisely the observability property the security model required it not to have.
On the Mac: a silent peer pins a Task and an NWConnection indefinitely, needs no credentials, and is repeatable. A trivially cheap resource-exhaustion vector.
In tests: running the regression tests against the pre-fix base hangs absolutely — killed at 6:40 with no output, and the tests’ own 150 s internal bound never fired either, because the deadlock swallows test-level timeouts too.
The fix
Take the general fix, not the narrow one. Wrapping every raw continuation is what stops the shape recurring:
try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { c in connection.send(...) { error in c.resume(...) } }} onCancel: { connection.cancel() // makes the callback fire}
The narrow alternative — cancelling the connection in the timeout branch before the group drains — works for one call site and leaves the trap armed everywhere else.
No double-resume risk
Only the Network callback resumes; onCancel only calls connection.cancel(). Stress-verified at 60 rounds with randomized 1 µs–40 ms timeouts aimed at the install window, 12 simultaneous mute connects, and 6 rounds at 120 ms — no CheckedContinuation fatalError. A cancelled connect() returns in 0.0008 s.
Where it appeared, all three times
Site
Status
NetworkTransport raw send/receive — the pairing exchange
Fixed with withTaskCancellationHandler
NetworkTransport.collect (data-transfer report, 2 s bound)
Fixed in the same round, pre-emptively
DeviceCamera.captureStill (iOS app)
Fixed — a cancelled task hung forever and skipped a defer { monitor.ended() }, so the pending count stuck
AposematiProbe.receiveChunk / .transmit
STILL PRESENT. Diagnostic executable, not the shipped transport — but it is the same bug
For Agents
When auditing a Swift codebase for this: grep -n "withCheckedContinuation\|withCheckedThrowingContinuation" and check each hit for an enclosing withTaskCancellationHandler. Any hit that wraps a callback-based API and can be raced against a deadline is a hang waiting for its accidental rescue to be removed.
Related knowledge from the same build
collect is stronger than its shape suggests: both arms deliver on the same serial queue so double-resume is structurally impossible, and the timer always fires so never-resume is impossible. Shape alone is not proof of a bug — but it is enough to demand one.
Semantics change worth deciding deliberately: cancelling send() now tears down the whole transport. Defensible — a half-written frame desyncs a length-prefixed stream — but a cancelled connect() then reports pairingExchangeFailed rather than a cancellation-shaped error, so a caller cannot tell “I cancelled” from “the peer went quiet”.