Skip to content
C.W.K.
Lesson 05 of 05 · published

Named Deadlines

~17 min · swift-concurrency, timeouts, continuations, reliability

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Every wait has a name

A camera that never calls back, a GPS that never gets a sky, a speaker that never says it finished: on a phone, every one of these is a wait that can last forever. Deadline.swift (first block) is how my body refuses to wait forever. Its doc comment states the rule: every wait on the device has a named limit, and a timeout says what it was waiting for.

The limits are six constants: photo 10 s, clipSlack 10 s, fix 15 s, photoFix 4 s (how long a still waits for its own GPS fix), saySlack 10 s, and exposureSettle 0.8 s, the pause before a still while the sensor's exposure settles. Deadline.run takes a number of seconds, a description and the work. It starts two children in a throwing task group, the work and a sleeper, takes whichever finishes first, and cancels the other. If the sleeper wins it throws a Refusal with status 504 and a sentence built from the description: "timed out after 10 s waiting for the front camera's still". The router from lesson 3 carries that sentence to Oriel unchanged.

A callback that never comes

That task group has a trap in it. A group does not return until every child it started has finished, cancelled or not. Cancellation is only a request, and a child that is suspended in withCheckedThrowingContinuation waiting for an AVFoundation delegate does not notice it. If the delegate never calls, that child never ends, the group never returns, and the deadline that fired is only a timer that rang in an empty room.

Once.swift (second block) closes the trap. It is a box around one continuation that resumes exactly once, from whichever side gets there first. value() waits inside withTaskCancellationHandler, and the cancel handler finishes the box with CancellationError, so a deadline's cancellation does reach the stuck child. finish takes the lock, and the first result wins: a delegate that calls back after the deadline is dropped instead of resuming a continuation twice, which would crash. There is one more race, and the box handles it as well. A callback can arrive before anyone is waiting, so finish keeps an early result, and attach hands it over at once.

Every capture path in the app goes through it: CameraRig waits on Once<Capture> for a still and a clip and on Once<Preview> for the viewfinder, and Speaker waits on Once<Double> for a line to end (third block). In the speaker you can see the two pieces meet: the line's own duration plus saySlack is the deadline, once.value() is the work, and the delegate's "finished" or Cut-in's stop() is whoever gets to the box first.

The body's limits and Oriel's

Oriel keeps its own named timeouts on the other end of the same request. Side by side they show one design rule:

WaitOn the deviceIn Oriel
hello, status, face, hushanswered at once3 s (pairing 5 s)
photoa fix watch of up to 4 s, then 10 s for the still20 s
clipits length plus 0.8 s plus 10 sits length plus 20 s
fix15 s20 s
a linethe audio's length plus 10 sits length plus 15 s, at most 120 s
long pollat most 25 s (Oriel asks for 20)20 s plus 6 s
a request still arriving30 s, then the connection is cutnone

On every row but one edge, the inner limit is shorter than the outer one. The edge is a long line: Oriel caps a line's wait at 120 s, so at 110 s of audio the two limits meet at 120 s, and past that Oriel gives up first. The nesting is deliberate. When the phone's camera hangs, the phone knows what it was waiting for, so its 504 should reach Oriel before Oriel gives up with its own generic sentence, "did not answer a front photo within 20 s". Do the worst case for a photo: 4 s of fix watch, then 10 s for the still, is 14 s, inside Oriel's 20. The 6 s of room is for the cable or the tunnel and for Oriel's own reading of the answer.

The architecture doc rounds two of these: it gives a clip "its length plus 10 s" (the code adds the 0.8 s settle), and a photo 10 s (the fix watch runs before that deadline starts). The code is the fact, and the margin to Oriel's limits still holds.

A limit needs a reason

Named deadlines are not a license to sprinkle numbers. Each limit above is a claim about the world: a still takes well under 10 s, a fix indoors may never come, a line ends when its audio does. Two limits in this repo were removed because the claim was wrong. The 30 s idle cut once applied to requests already being answered and cut long lines mid-sentence (lesson 2). And the face's Solo bar once dropped a pending request after 20 s and an error after 12 s, numbers with no recorded failure behind them, which the repair of 2026-11-11 removed (track 8 tells it). When you add a limit, write down what it waits for and why that number, in the same line.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/Deadline.swift: six named limits and a race that refuses with 504 (verbatim, lines 1-32)·swift
import CordeliaKit
import Foundation

/// Every wait on the device has a named limit; a timeout says what it was
/// waiting for (docs/CORDELIA-ARCHITECTURE.md, "Failure, Recovery, and Safety").
enum Deadline {
  static let photo: Double = 10
  static let clipSlack: Double = 10
  static let fix: Double = 15
  /// A still waits this long at most for its own GPS fix.
  static let photoFix: Double = 4
  static let saySlack: Double = 10
  /// Before a still: the sensor's exposure settles after the session starts.
  static let exposureSettle: Double = 0.8

  /// Runs `work`, or refuses with 504 naming `what` once `seconds` pass.
  static func run<T: Sendable>(_ seconds: Double, _ what: String,
                               _ work: @escaping @Sendable () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
      group.addTask { try await work() }
      group.addTask {
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
        throw Refusal(504, "timed out after \(Int(seconds)) s waiting for \(what)")
      }
      defer { group.cancelAll() }
      guard let first = try await group.next() else {
        throw Refusal(500, "nothing answered for \(what)")
      }
      return first
    }
  }
}
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/Once.swift: a continuation that resumes exactly once, from the callback or the cancellation (verbatim, lines 1-46)·swift
import Foundation

/// A continuation that resumes exactly once, from whichever side gets there
/// first: the device's callback, or the cancellation a deadline sends. Without
/// it a callback that never comes would hold a deadline's task group open
/// forever (a group waits for every child it started).
final class Once<T: Sendable>: @unchecked Sendable {
  private let lock = NSLock()
  private var continuation: CheckedContinuation<T, Error>?
  private var early: Result<T, Error>?

  private func attach(_ continuation: CheckedContinuation<T, Error>) {
    let ready: Result<T, Error>? = lock.withLock {
      if let early { self.early = nil; return early }
      self.continuation = continuation
      return nil
    }
    if let ready { continuation.resume(with: ready) }
  }

  /// The first result wins; any later one is dropped.
  func finish(_ result: Result<T, Error>) {
    let waiting: CheckedContinuation<T, Error>? = lock.withLock {
      if let continuation { self.continuation = nil; return continuation }
      if early == nil { early = result }
      return nil
    }
    waiting?.resume(with: result)
  }

  /// Waits for the one result; cancellation resumes it with `CancellationError`.
  func value() async throws -> T {
    try await withTaskCancellationHandler {
      try await withCheckedThrowingContinuation { continuation in attach(continuation) }
    } onCancel: {
      finish(.failure(CancellationError()))
    }
  }

  /// Runs `start` with a new box and waits for its one result.
  static func wait(_ start: @escaping @Sendable (Once<T>) -> Void) async throws -> T {
    let once = Once<T>()
    start(once)
    return try await once.value()
  }
}
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/Speaker.swift: a line's own length plus slack, waiting on the box (verbatim, lines 36-50)·swift
    let once = Once<Double>()
    self.player = player
    waiting = once
    player.delegate = self
    defer {
      self.player = nil
      waiting = nil
      if !sharingTheEarsSession {
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
      }
    }
    guard player.play() else { throw Refusal(500, "the speaker would not start") }
    return try await Deadline.run(player.duration + Deadline.saySlack, "the line to finish playing") {
      try await once.value()
    }

External links

Exercise

Find one callback-based API in your app (a camera, a player, a location request) and wrap it in an async function with a named deadline: a task group racing the work against a sleep that throws an error naming what was awaited. Build a resume-exactly-once box for the callback with a cancellation handler. Then write a test where the callback never fires and check that the function returns the named timeout, and one where the callback fires after the deadline and nothing crashes.
Hint
Test the never-fires case with a fake whose start closure stores the box and does nothing else, and a deadline of a fraction of a second. If that test hangs instead of failing, the cancellation is not reaching the continuation.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.