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

A Failure Restores What Was Confirmed

~13 min · rollback, atomicity, emergency-release, partial-failure

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A crash or failed post-write cannot prove who changed the OS state. Preserve the prepared entry rather than unhide somebody else's app." — a comment in Tinker's engine

No Half-Applied Presets

A preset is a single request from Dad's point of view: "editing mode". If the third of four apps refuses to hide, leaving the first two hidden would be a state nobody asked for — half of an editing mode nobody asked for. So the engine wraps the whole apply loop in a do/catch. Whatever goes wrong inside — a thrown refusal, an unconfirmed hide, a persistence failure — the catch calls the same restore routine that a normal "Editing done" would, for the same owner, and then rethrows the original error so the command still reports why it failed.

Rollback Uses the Ordinary Restore

There is no special rollback code path. The catch calls restoreImpl(id:), which walks this owner's receipts newest first: shared receipts lose this owner, applied receipts are undone and removed, and prepared receipts — including the one that just failed, if it is uncertain — are left in place and reported. Reusing the restore path means rollback has exactly the same guarantees as a normal finish, and is tested by the same cases. If the failing step proved it never reached the OS, its receipt was already dropped and the rollback leaves the journal clean; if not, the owner stays active because the uncertain record still names it.

Errors Are Collected, Not Short-Circuited

Restore does not stop at the first problem. If one app's unhide fails, the next receipt is still attempted; each error message is appended to a list, and only at the end is a single error thrown carrying all of them. A restore that gave up at the first failure would strand every change after it — the opposite of what a restore is for.

Emergency Release Interrupts From Outside

There is a second way an apply loop ends early: Dad chooses Stop and restore everything while it is running. releaseAll() sets stopped first, then waits for the running action to finish. The apply loop checks stopped before each change and throws "Emergency release requested." when it sees it, which sends it into its own rollback. Then release-all walks every remaining active owner and restores it. Tinker's tests freeze a hide mid-flight, request release, and check that the second app was never hidden and the first was restored — with the order of OS calls asserted, not merely the end state.

Code

The apply loop's outer catch: roll back through the normal restore·swift
var next = journal
next.active.insert(id)
try persist(next)
do {
  for (capability, changes) in plans {
    for change in changes {
      guard !stopped else { throw TinkerError("Emergency release requested.") }
      // ...share an existing receipt, or prepare -> apply -> applied
    }
  }
} catch {
  let original = error
  try? await restoreImpl(id: id)   // same path as a normal finish
  throw original                   // the caller still learns why
}
Restore collects every failure instead of stopping at the first·swift
private func restoreImpl(id: String) async throws {
  var errors: [String] = []
  for receipt in journal.changes.reversed() where receipt.owners.contains(id) {
    if receipt.owners.count > 1 { /* drop this owner only */ continue }
    guard receipt.phase == .applied else {
      errors.append("Uncertain change \(receipt.key): inspect it in Recovery."); continue
    }
    guard let capability = capabilities[receipt.capability] else {
      errors.append("Missing capability \(receipt.capability); change retained."); continue
    }
    do {
      _ = try await capability.undo(receipt.payload)
      var next = journal; next.changes.removeAll { $0.key == receipt.key }
      try persist(next)
    } catch { errors.append(error.localizedDescription) }
  }
  if !journal.changes.contains(where: { $0.owners.contains(id) }) {
    var next = journal; next.active.remove(id)
    do { try persist(next) } catch { errors.append(error.localizedDescription) }
  }
  if !errors.isEmpty { throw TinkerError(errors.joined(separator: " ")) }
}
Release-all: stop first, wait, then restore every owner·swift
public func releaseAll() async throws {
  stopped = true                                   // no new effects from here
  while executing { try await Task.sleep(for: .milliseconds(20)) }
  executing = true
  defer { executing = false }
  var errors: [String] = []
  for owner in journal.active.sorted() {
    do { try await restoreImpl(id: owner) } catch { errors.append(error.localizedDescription) }
  }
  if !errors.isEmpty { throw TinkerError(errors.joined(separator: " ")) }
}

External links

Exercise

The editing preset lists four running, visible apps. The third app's hide times out. Write down, in order, every journal state (active set and each receipt's owners and phase) from the start of apply to the end of the rollback, and the error text the command reports.
Hint
active gains edit; apps 1 and 2 go prepared → applied; app 3 goes prepared and stays prepared with its timeout message; the loop throws; rollback undoes 2 then 1 and removes their receipts, finds 3 prepared and adds 'Uncertain change …' to its errors; edit stays active because 3 still names it. The command reports the original timeout error, not the rollback's — the caller learns the cause first.

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.