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

The Solo Bar

~17 min · safety, monotonic-clocks, receipts, swift, queues

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

What the bar is for

While I play Solo, my face shows a bar: "PIPPA SOLO · coached" (or true when I play unattended, or broadcast in the broadcast's Solo Play stage), the game's title, the time left, "+" and Stop. Dad starts the session on his own phone, and this bar can only add time or end it. Stop is the reason it exists. It needs no brain anywhere in its path, and it sits in the pinned row outside the scroll beside the red eye, so it stays reachable at any text size, while "+" scrolls with the rest. Both are drawn by one view, SoloActionButton, from the same SoloBar value, so pinning Stop did not create a second arming.

Everything below is one night's safety repair, Coop 1111-30 on 2026-11-11: failing tests at 23:34 (eed556d), then three fixes in the next sixteen minutes. Each fix answers a question a Stop button has to get right.

Counting down on someone else's clock

The first bar counted expiresAt - now, with now in epoch seconds from the phone. That mixes the engine's deadline with the phone's wall clock, so any skew between the phone and Pippa's home Mac became a wrong countdown, and a clock correction made it jump. Now Oriel relays the engine's own now beside expires_at, the face notes when it received them (receivedAt), and it advances only by local elapsed time from there. A missing engine clock returns nil, unknown, and the wall clock is never the substitute.

The first repair (a4aaeb0, 23:43) measured that elapsed time with ProcessInfo.systemUptime. Seven minutes later c120f15 replaced it with SoloClock on ContinuousClock, the first block. Apple documents the difference in one line each: system uptime is the time the system has been awake, and a continuous clock does not stop incrementing while the system is asleep. Any stretch the phone spent asleep would have gone missing from the count, and the face would have shown that much extra time. Oriel helps from its side: its face push compares everything but now, so the clock passing alone never resends the face, while any real change re-anchors it.

Stop goes first

The first two-press bar refused every press while any request waited, for up to 20 s. A "+" that Oriel had not answered put Stop in a queue behind it. Now tap opens with one guard, if action == .extend, asked != nil || stopUnconfirmed: "+" waits for answers, and Stop never does. Stop's own guard is canStop, which is true while I play, while the state is unknown, and while an earlier Stop is unconfirmed.

The outbox, the third block, gives the same priority on the way out. A stop removes every unsent solo_play item, a waiting "+" or an earlier stop, and goes to the front. Trimming to most (20 items) never drops a stop, and neither does the sweep that drops items nobody took within keep (90 s). stopSurvivesTheOutboxAndPreemptsQueuedWork and unsentStopNeverExpiresSilently hold both.

Stopped means released

The second block opens with the rule: state and release are separate receipts. When a session closes, its slot empties before the controller's inputs are released, so an empty slot proves nothing. Oriel answers a Stop with released from the play session's close receipt, and keeps a pending Stop's receipt ahead of an older extension's answer. The face says "Stop requested — engine release not confirmed" until it holds both facts: an answer with released true, and a state with open false. Then it says "Stopped — engine released inputs" and keeps saying it until a new Solo session arrives.

Every other outcome keeps Stop in reach. A released false says "Stop not confirmed: engine did not confirm input release", and the button reads Retry Stop. A state Oriel cannot read (open nil) shows "state unknown:" with the reason, keeps Stop, and wipes any earlier "Stopped", so an old completion never masks a new unknown. Losing a status read does not even disarm a Stop already pressed once: the bar disarms only when canStop itself flips.

The caps that had no reason

The old bar also had two timers: a request with no answer for 20 s was declared failed, and an error vanished after 12 s. Neither had a recorded failure behind it. Twenty seconds measured nothing in particular, and calling a Stop failed while the engine may still be releasing is a guess dressed as a fact. Both are gone. expire now expires armings and nothing else, and unknownNeverAcknowledgesStopOrHidesIt checks that at 10,000 s a Stop is still waiting for its answer. On Air's 15 s and Wrap up's 20 s remain; they belong to other rules. None of this bar is field-checked on a phone yet, because it shows only while I play Solo and Dad's phone cannot start true Solo yet.

Code

cwkCordelia, CordeliaKit/Sources/CordeliaKit/Solo.swift: a clock that counts through sleep, and engine time advanced by it (excerpt, lines 7-163)·swift
/// Elapsed time including device sleep, unaffected by wall-clock corrections.
public enum SoloClock {
  private static let origin = ContinuousClock().now
  public static var now: Double {
    let elapsed = origin.duration(to: ContinuousClock().now).components
    return Double(elapsed.seconds) + Double(elapsed.attoseconds) / 1e18
  }
}
// …
  /// Engine time anchored at receipt, advanced only by local monotonic elapsed time.
  /// A missing clock stays unknown; the phone's wall clock is never a substitute.
  public func remaining(at now: Double) -> Int? {
    guard open == true, solo, let expiresAt, let engineNow,
          expiresAt.isFinite, engineNow.isFinite, now.isFinite else { return nil }
    let seconds = max(0, (expiresAt - engineNow - max(0, now - receivedAt)).rounded())
    guard seconds < Double(Int.max) else { return nil }
    return Int(seconds)
  }
cwkCordelia, CordeliaKit/Sources/CordeliaKit/Solo.swift: a Stop is done only with a release receipt and a closed slot (verbatim, lines 280-315)·swift
  /// State and release are separate receipts. `open == nil` proves neither.
  public mutating func adopt(_ next: SoloPlayState, at now: Double) {
    let couldStop = canStop
    state = next
    if unknown { stopped = false }
    if running {
      lastSolo = next
      stopped = false
    }
    if let asked {
      let answer = next.answered.flatMap { $0.id == asked.id ? $0 : nil }
      if let error = answer?.error {
        self.asked = nil
        problem = asked.action == .stop ? "Stop not confirmed: \(error)" : "Could not add time: \(error)"
      } else if asked.action == .stop {
        if answer?.released == true { releaseConfirmed = true }
        if answer?.released == false {
          problem = "Stop not confirmed: engine did not confirm input release"
        }
        if next.open == false && releaseConfirmed {
          self.asked = nil
          stopUnconfirmed = false
          stopped = true
          problem = nil
        }
      } else if next.open == false || answer != nil ||
                  (next.open == true && (next.expiresAt ?? 0) > (asked.expiresAt ?? .infinity)) {
        self.asked = nil
      }
    }
    // Losing a status read does not disarm an already deliberate Stop.
    if couldStop != canStop {
      stop.disarm()
      extend.disarm()
    }
  }
cwkCordelia, CordeliaKit/Sources/CordeliaKit/Ears.swift: the outbox puts a Stop first and never trims it (verbatim, lines 630-649)·swift
  /// Hands the item to a waiting poll, or keeps it for the next one.
  public func put(_ item: Outgoing) {
    if let (id, taker) = takers.first {
      takers[id] = nil
      taker.resume(returning: item)
      return
    }
    if item.isSoloStop {
      // A stop supersedes unsent Solo requests and goes ahead of ordinary
      // work. A retry replaces the unsent stop, not a second future stop.
      waiting.removeAll { $0.item.kind == "solo_play" }
      waiting.insert((item, clock()), at: 0)
    } else {
      waiting.append((item, clock()))
    }
    while waiting.count > Self.most,
          let oldest = waiting.firstIndex(where: { !$0.item.isSoloStop }) {
      waiting.remove(at: oldest)
    }
  }

External links

Exercise

Find a stop or cancel control in a project of yours that talks to a remote service. Give it a priority path: no other pending request may block it, and an outgoing queue puts it first and never trims it. Then separate 'the remote says the thing stopped' from 'the remote says the side effects are released', show 'requested' until both arrive, and delete any timer that declares the stop failed or hides its error.
Hint
Write the failing tests first: a Stop pressed while an extension is unanswered, a Stop whose state goes unknown, a late answer to the extension arriving after the Stop, and a check far in the future that the Stop is still pending. Each one names a way the old code would have lied.

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.