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

Trust the State, Not the Return Value

~14 min · confirmation, isHidden, run-loop, appkit, lab-finding

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"The lab observed hide() returning false while the target processed its DoHide event. Judge the same process's resulting state, not this immediate return value." — a comment in Tinker's visibility adapter

A Boolean That Means "Maybe"

NSRunningApplication.hide() returns a Bool, and the natural reading is "true means hidden, false means failed". Tinker's lab disproved that reading on real apps. Hiding another process is not a function call into that process; it is a request delivered to it, which the target app handles on its own event loop. The lab saw hide() return false while the target was still processing the hide event — and then the app hid anyway. Taking the return value at face value would have recorded a failure for a change that had in fact happened, which in Tinker's terms is the worst kind of lie: an uncertain receipt that was not uncertain, or worse, a dropped one.

Ask the Process, Not the Call

So the adapter keeps the return value only for its error message ("request accepted" or "request not acknowledged") and decides the outcome by observation: it re-finds the same process by its three-fact identity and reads isHidden. If the process has vanished mid-change, that is an error — and because the request had already been made, it leaves the receipt uncertain. If isHidden matches the wanted value, the change is confirmed.

Why It Polls, and Why Every 15 ms

There is a second subtlety. AppKit caches isHidden and similar properties for a running application and refreshes them on the main run loop, so reading the property twice in the same turn of the run loop returns the same stale value no matter what the target did. A short Task.sleep on the main actor yields back to the run loop, which gives AppKit a turn to refresh; each sleep is one more chance to see the real state. The loop runs up to 80 times at 15 ms — a 1.2 second deadline. The original loop polled every 40 ms inside the same deadline; the review pass shortened the interval so that a hide that lands quickly is confirmed quickly, without making the deadline any longer.

When the Deadline Passes

If 1.2 seconds go by and the state still does not match, the adapter throws an ordinary error naming the app and whether the request had been accepted. It does not retry and it does not guess. The engine keeps the prepared receipt with that message, rolls back the confirmed changes of the same preset, and the Recovery tab shows the app as uncertain: "Inspect this app manually first."

Code

Request, then confirm by observing the same process·swift
public func setHidden(_ value: Bool, for identity: ApplicationIdentity) async throws {
  if value, let refusal = hideRefusal() { throw ChangeNotAttempted(refusal) }
  guard let app = match(identity) else {
    throw ChangeNotAttempted("\(identity.name) stopped or its process identity changed; visibility was left alone.")
  }
  let requestAccepted = value ? app.hide() : app.unhide()
  // isHidden refreshes on the main run loop: each sleep yields one turn.
  for _ in 0..<80 {                                   // 80 x 15 ms = 1.2 s
    try await Task.sleep(for: .milliseconds(15))
    guard let current = match(identity) else {
      throw TinkerError("\(identity.name) quit during the change.")
    }
    if current.isHidden == value { return }           // confirmed
  }
  throw TinkerError("\(identity.name) did not confirm its visibility change " +
    "(request \(requestAccepted ? "accepted" : "not acknowledged")).")
}
Three outcomes after the request, and what each leaves in the journal·text
observed isHidden == wanted within 1.2 s   -> receipt marked applied
process vanished after the request         -> receipt stays prepared (uncertain)
deadline passed, state never matched       -> receipt stays prepared (uncertain)

(before the request: refusal or identity mismatch
                                           -> ChangeNotAttempted, receipt dropped)

External links

Exercise

Find another API in your own work whose return value only says a request was accepted, not that the effect happened (a network send, a message queue publish, a UI animation start). Write the confirmation step you would add: what state would you observe, how often, for how long, and what would you record if the deadline passed?
Hint
The shape is always the same: observe the effect on the target itself, poll with a yield between reads, bound the wait, and on timeout record 'requested but unconfirmed' rather than either success or failure. The hardest part is usually naming that third outcome in your data model.

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.