The undo rows stop hearing their own echo — a text manager answers canRedo by posting the checkpoint that re-invalidated the row, forever

Diagnosed by sampling a live frozen instance: with a text surface focused,
RedoMenuRow's body reads the routed manager's title, NSUndoManager.canRedo
posts NSUndoManagerCheckpoint synchronously, UndoCommandTicker bumps its
observed revision mid-body, and SwiftUI schedules the re-evaluation whose
own read posts the next checkpoint — the main thread never returns to the
event loop (~99% CPU, app frozen). Board-routed reads never echo, because
BoardUndoManager's overrides answer from the provider without posting —
which is why the board-only live probe (21/21) never met the loop.

The rows now derive title and enablement inside
UndoCommandTicker.silencingReadEchoes, a synchronous main-actor window in
which bump() drops what arrives: a read cannot change the state it reads,
so the echo carries no information and dropping it loses nothing. Genuine
checkpoints — registration closing a group, a crossing — still land.

Three regression tests pin the mechanism, including the asymmetry that
made the redo side the fuel: canUndo answers silently, canRedo posts.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 22:50:00 -04:00
parent 7651e40318
commit 57542177c1
2 changed files with 106 additions and 5 deletions
+44 -5
View File
@@ -133,7 +133,9 @@ enum UndoCommandRouting {
/// manager's stack changes, which is how typing into a field editor retitles the row that is routing
/// to it. The did-undo/did-redo pair covers a crossing that changes which direction is live, the
/// `NSText` editing pair covers the field editor arriving and leaving, and `NSMenu`'s did-begin-
/// tracking is a re-derive a moment before the Edit menu draws itself.
/// tracking is a re-derive a moment before the Edit menu draws itself. The checkpoint is also what a
/// text manager posts back when a row merely *reads* it an echo the rows silence, because heard it
/// is a livelock (`silencingReadEchoes`).
///
/// ### The known residual, which is cosmetic
///
@@ -154,6 +156,33 @@ final class UndoCommandTicker {
/// nothing.
private(set) var revision = 0
/// Runs a row's derivation with the ticker deaf to it.
///
/// Reading a plain `NSUndoManager`'s enablement or composed title is not passive: `canRedo`
/// and the composed redo title through it posts `NSUndoManagerCheckpoint` synchronously as a
/// side effect (documented `NSUndoManager` behavior; `BoardUndoManager`'s overrides answer from
/// the provider and post nothing, so a board-routed read never echoes only a *text* manager's
/// can). Un-silenced, that echo closes a feedback loop through this ticker: the row's `body`
/// reads a title, the read posts a checkpoint, `bump()` lands mid-`body`, the observation
/// invalidates the row, and the re-derived `body` reads the title again the main thread never
/// returns to the event loop. Diagnosed 2026-08-08 by sampling a frozen instance; entered
/// whenever the rows rendered while a text surface held the keyboard, which is why the board-only
/// live probe never saw it. A read cannot change the state it reads, so the echo carries no
/// information and dropping it loses nothing.
///
/// The window is synchronous and main-actor, so nothing else can slip a *genuine* checkpoint
/// into it and one posted from off the main thread hops through `Task` and lands after it
/// closes.
func silencingReadEchoes<T>(_ read: () -> T) -> T {
let outer = isSilenced
isSilenced = true
defer { isSilenced = outer }
return read()
}
/// See `silencingReadEchoes` bookkeeping about observation, never state a view reads.
@ObservationIgnored private var isSilenced = false
private var observers: [any NSObjectProtocol] = []
private init() {
@@ -181,6 +210,7 @@ final class UndoCommandTicker {
}
private func bump() {
guard !isSilenced else { return }
revision &+= 1
}
}
@@ -236,11 +266,17 @@ private struct UndoMenuRow: View {
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.undoTitle(of: manager)) {
// Silenced because these reads *post*: a text manager answers its title and enablement by
// firing a checkpoint, and a checkpoint bumping the ticker from inside `body` is the row
// invalidating itself, forever (`UndoCommandTicker.silencingReadEchoes`).
let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
(UndoCommandRouting.undoTitle(of: manager), UndoCommandRouting.canUndo(manager))
}
Button(title) {
cross()
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!UndoCommandRouting.canUndo(manager))
.disabled(!isLive)
}
/// **Routed again here**, rather than closing over what the row rendered with: focus and stacks
@@ -273,11 +309,14 @@ private struct RedoMenuRow: View {
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.redoTitle(of: manager)) {
let (title, isLive) = UndoCommandTicker.shared.silencingReadEchoes {
(UndoCommandRouting.redoTitle(of: manager), UndoCommandRouting.canRedo(manager))
}
Button(title) {
cross()
}
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!UndoCommandRouting.canRedo(manager))
.disabled(!isLive)
}
private func cross() {