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 this
try 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:

ConfigResult
keepAlive nil, idle 4 sclean failure at 4.012 s
keepAlive 1 s, idle 4 sstill 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

SiteStatus
NetworkTransport raw send/receive — the pairing exchangeFixed 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 / .transmitSTILL 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.

  • 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”.