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

Never Cancel a Logout

~14 min · apple-events, logout, terminateLater, deadline, run-loop-timer

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Logout, restart and shutdown are never held up: Tinker restores what it can within eight seconds and the journal keeps the rest." — Tinker README

The Bug in the Clever Quit

The ordinary-Quit design from the last lesson — answer .terminateCancel, restore, terminate again — has a hidden victim. When Dad logs out, restarts or shuts down, macOS asks every running app to quit by sending it a quit Apple event. An app that answers .terminateCancel to that request is not saying "one moment"; it is cancelling the logout. The first Tinker did exactly that, every time. The review pass on 2026-11-10 caught it: an actuator whose quit path could stop the Mac from restarting was holding the user hostage to its own tidiness.

Telling the Two Quits Apart

Inside applicationShouldTerminate, Tinker asks NSAppleEventManager for the Apple event currently being handled. If it is the core kAEQuitApplication event, this quit came from outside — logout, restart, shutdown, or a script that asked the app to quit. Anything else is Tinker's own Quit menu item. The two take different paths.

The External Path: Defer, Restore, Always Allow

For an external quit, Tinker returns .terminateLater. AppKit keeps the process alive and keeps the run loop turning while Tinker restores. Two things can then answer the deferred quit, and whichever comes first wins: the restore task finishing, or an eight-second Timer added to the main run loop in .common modes, so it fires even inside AppKit's nested loop and even if the restore itself is stuck. Either way the answer is reply(toApplicationShouldTerminate: true). A helper guarded by readyToTerminate makes sure it is sent exactly once. Whatever was not restored in time stays in the durable journal for the next login.

Why the Starvation Problem Doesn't Bite Here

The ordinary Quit avoided terminateLater because a restore task started from inside it could be starved. On the external path that risk is accepted on purpose and bounded: the timer does not depend on the task, so even a starved restore cannot hold the logout longer than eight seconds. The ordinary path optimises for a complete restore; the external path optimises for never blocking the person.

How It Was Tested — and What Wasn't

The GUI lab run ends by sending the app a quit Apple event addressed to its own process, so the deferred path is exercised for real, not mocked. In a debug build running as the GUI lab snapshot, Tinker writes which path it took and whether it released. The architecture is honest about the limit: a real logout is not exercised in the lab. Claims are scoped to what was run.

Code

Recognising a quit that came as an Apple event·swift
import AppKit

private static var handlingQuitEvent: Bool {
  guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false }
  return event.eventClass == AEEventClass(kCoreEventClass)
      && event.eventID == AEEventID(kAEQuitApplication)
}
The external path: defer, restore, answer once — with a run-loop deadline·swift
let external = Self.handlingQuitEvent
if external {
  // A run-loop timer still fires if the restore itself is stuck.
  let deadline = Timer(timeInterval: 8, repeats: false) { [weak self] _ in
    MainActor.assumeIsolated { self?.answerDeferredQuit(released: false) }
  }
  RunLoop.main.add(deadline, forMode: .common)
}
Task { @MainActor in
  _ = await model?.quit()
  if external { answerDeferredQuit(released: true); return }
  // ...ordinary Quit path from the previous lesson...
}
return external ? .terminateLater : .terminateCancel

/// Answers a deferred quit exactly once.
private func answerDeferredQuit(released: Bool) {
  guard !readyToTerminate else { return }
  readyToTerminate = true
  model?.stopServices()
  NSApp.reply(toApplicationShouldTerminate: true)
}
Sending the same kind of quit a script would (try it on any app)·bash
# A scripted quit arrives as a kAEQuitApplication Apple event, like logout's.
osascript -e 'tell application "Tinker" to quit'

External links

Exercise

Draw a timeline for a restart with editing mode active, where one app never confirms its unhide. Mark when the quit event arrives, what Tinker returns, what reply is sent and by whom, and what the journal contains at the next login. Then say when the eight-second timer would actually be the one to answer.
Hint
Quit event at t0 → .terminateLater; the restore runs; the stubborn app's unhide gives up after 1.2 s and its error is collected while the other apps are restored; the restore task then answers yes at around t1–t2, so the timer never fires and the restart proceeds. The stubborn app's receipt is still applied in the journal, so at the next login Tinker says 'Recovered saved changes. Restore them here when ready.' and offers it from the menu. The timer is the one to answer only when the restore task itself cannot finish — for instance if it never gets to run inside AppKit's nested loop.

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.