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

Taking Turns Instead of Saying Busy

~13 min · swift-actor, continuations, queueing, flock, backpressure

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Requests take turns, so a status read behind a running command waits instead of failing." — Tinker README

The First Version Said "Busy"

The engine runs one action at a time; that is a safety property, not a performance shortcut. The first app door inherited it bluntly: a request that arrived while another was running was refused as busy. In practice the commonest second request is harmless — Pippa applies the editing preset and, a moment later, reads status to see how it went. Refusing that read made her retry in a loop or report a failure that was not one. The review pass replaced "busy" with a queue.

A Queue Built From an Actor

BridgeWorker is a Swift actor, so its stored properties are only ever touched by one task at a time. It keeps a busy flag and an array of suspended continuations. enter() takes the turn immediately if nobody holds it; otherwise it suspends the calling task with withCheckedContinuation and appends the continuation to the line. leave() hands the turn directly to the first waiter by resuming its continuation — busy stays true, because the turn passed without ever being free. Each exchange is bracketed with enter() and a deferred leave(), so a thrown error still passes the turn on. First come, first served, and no polling.

Bounded by the Listener

An unbounded queue is a slow memory leak and a way to make a caller wait forever. The bound lives one layer out, in the loopback HTTP listener: at most four connections at once, a 16 KiB request body limit, and an 80-second total deadline per request. A read stuck behind a long command times out at the listener with a clear error rather than hanging. Inside, the worker's 75-second watchdog terminates a worker still running at 75 seconds and kills it one second later, so no single exchange holds the turn much past that.

Belt and Braces on the Python Side

The worker also takes an exclusive flock on an actions.lock file in Tinker's data directory before running any invocation. Inside one agent the actor already serialises everything, so the lock is never contended there. It guards the case the actor cannot see: two workers started by two different agents, which the resident lock is supposed to make impossible. When two independent mechanisms both say "one at a time", a bug in either one is not enough to break the rule.

And Inside the Engine

The door's queue sits in front of the engine's own guard. The engine refuses an overlapping apply or restore with "Another action is in progress." Settings and the menu do not queue either; they simply disable their preset buttons while a command is running, and a shortcut pressed then is dropped (track 5). A door request never meets the guard at all, because it only reaches the engine on its turn.

Code

A first-come, first-served turn built from an actor and continuations·swift
actor BridgeWorker {
  private var busy = false
  private var waiting: [CheckedContinuation<Void, Never>] = []

  private func enter() async {
    if !busy { busy = true; return }
    await withCheckedContinuation { waiting.append($0) }
  }
  private func leave() {
    // Hand the turn straight to the next request; `busy` stays true for it.
    if waiting.isEmpty { busy = false } else { waiting.removeFirst().resume() }
  }

  func exchange(_ request: JSONValue, dispatch: @escaping Dispatch) async throws -> Response {
    await enter()
    defer { leave() }
    // ... launch the worker, exchange lines, return its result ...
  }
}
The bound lives in the listener·swift
let server = try HTTPListener(configuration: .init(
  port: port.port,
  limits: .init(bodyBytes: 16384, connections: 4, total: 80),
  allowedHosts: ["127.0.0.1"])) { request in
  // ... route /health, GET /api/app-actions, POST /api/app-actions/<name>
}
The worker's own lock around every invocation·python
import asyncio, fcntl

ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
with (ROOT / 'actions.lock').open('a') as lock:
    fcntl.flock(lock, fcntl.LOCK_EX)          # one invocation at a time, across processes
    status, body = asyncio.run(actions.invoke(request.get('name', ''),
                                              request.get('envelope', {})))
    emit({'type': 'result', 'status': status, 'body': body})

External links

Exercise

Pippa sends preset.apply, then status, then preset.restore within 100 ms of each other. Trace the order in which they reach the engine, which ones wait, and what status reports. Then change one thing: Settings' Apply button is clicked during the first request. What does Dad see?
Hint
All three door requests queue in arrival order: apply runs, status waits and then reports the preset active with its change count, restore runs last. The Settings button never fires: it is disabled while the model is busy with the door's apply, and it comes back when that finishes. Nothing is refused and nothing is recorded.

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.