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:
@@ -133,7 +133,9 @@ enum UndoCommandRouting {
|
|||||||
/// manager's stack changes, which is how typing into a field editor retitles the row that is routing
|
/// 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
|
/// 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-
|
/// `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
|
/// ### The known residual, which is cosmetic
|
||||||
///
|
///
|
||||||
@@ -154,6 +156,33 @@ final class UndoCommandTicker {
|
|||||||
/// nothing.
|
/// nothing.
|
||||||
private(set) var revision = 0
|
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 var observers: [any NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@@ -181,6 +210,7 @@ final class UndoCommandTicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bump() {
|
private func bump() {
|
||||||
|
guard !isSilenced else { return }
|
||||||
revision &+= 1
|
revision &+= 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,11 +266,17 @@ private struct UndoMenuRow: View {
|
|||||||
stack: stack,
|
stack: stack,
|
||||||
firstResponder: NSApp.keyWindow?.firstResponder
|
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()
|
cross()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("z", modifiers: .command)
|
.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
|
/// **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,
|
stack: stack,
|
||||||
firstResponder: NSApp.keyWindow?.firstResponder
|
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()
|
cross()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("z", modifiers: [.command, .shift])
|
.keyboardShortcut("z", modifiers: [.command, .shift])
|
||||||
.disabled(!UndoCommandRouting.canRedo(manager))
|
.disabled(!isLive)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cross() {
|
private func cross() {
|
||||||
|
|||||||
@@ -167,3 +167,65 @@ struct UndoCommandTitleTests {
|
|||||||
#expect(UndoCommandRouting.canUndo(manager), "and resumes when it clears")
|
#expect(UndoCommandRouting.canUndo(manager), "and resumes when it clears")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The ticker's silenced window
|
||||||
|
|
||||||
|
/// **The reads that post, kept out of the ticker they would otherwise re-enter** — the livelock
|
||||||
|
/// regression (diagnosed 2026-08-08 by sampling a frozen instance). A plain `NSUndoManager` fires
|
||||||
|
/// `NSUndoManagerCheckpoint` synchronously from `canRedo` — and from the composed redo title through
|
||||||
|
/// it (`canUndo` posts nothing, an asymmetry pinned below); the ticker subscribes to exactly that
|
||||||
|
/// notification; and a bump landing mid-`body` re-invalidates the row whose read posted it, forever
|
||||||
|
/// (`UndoCommandTicker.silencingReadEchoes`). `BoardUndoManager` never echoes — its overrides answer
|
||||||
|
/// from the provider — which is why every fixture here is the plain text-manager shape the rows
|
||||||
|
/// route to while an editor holds the keyboard.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Undo commands ▸ ticker")
|
||||||
|
struct UndoCommandTickerTests {
|
||||||
|
|
||||||
|
@Test("A checkpoint reaches the ticker — the subscription the silence guards against is live")
|
||||||
|
func aGenuineCheckpointBumps() {
|
||||||
|
let before = UndoCommandTicker.shared.revision
|
||||||
|
|
||||||
|
// `canUndo` answers without posting — the asymmetry that made the redo side the loop's
|
||||||
|
// fuel in the wild, pinned so a platform change is noticed here first.
|
||||||
|
_ = UndoManager().canUndo
|
||||||
|
#expect(UndoCommandTicker.shared.revision == before)
|
||||||
|
|
||||||
|
// `canRedo` posts the checkpoint synchronously before answering — the very side effect the
|
||||||
|
// silenced window exists for, here arriving unsilenced.
|
||||||
|
_ = UndoManager().canRedo
|
||||||
|
#expect(UndoCommandTicker.shared.revision > before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A row's own derivation leaves the ticker still — the livelock regression")
|
||||||
|
func silencedReadsDoNotBump() {
|
||||||
|
let text = UndoManager()
|
||||||
|
let before = UndoCommandTicker.shared.revision
|
||||||
|
|
||||||
|
let answers = UndoCommandTicker.shared.silencingReadEchoes {
|
||||||
|
(undo: UndoCommandRouting.undoTitle(of: text),
|
||||||
|
redo: UndoCommandRouting.redoTitle(of: text),
|
||||||
|
canUndo: UndoCommandRouting.canUndo(text),
|
||||||
|
canRedo: UndoCommandRouting.canRedo(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(UndoCommandTicker.shared.revision == before, "the reads' own echoes never land")
|
||||||
|
#expect(answers == (undo: "Undo", redo: "Redo", canUndo: false, canRedo: false),
|
||||||
|
"and the answers themselves are untouched by the silence")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The silence is scoped to the read, and nests")
|
||||||
|
func silenceIsScopedAndNests() {
|
||||||
|
let before = UndoCommandTicker.shared.revision
|
||||||
|
|
||||||
|
UndoCommandTicker.shared.silencingReadEchoes {
|
||||||
|
UndoCommandTicker.shared.silencingReadEchoes { _ = UndoManager().canRedo }
|
||||||
|
// Still inside the outer window after the inner one closes.
|
||||||
|
_ = UndoManager().canRedo
|
||||||
|
}
|
||||||
|
#expect(UndoCommandTicker.shared.revision == before, "nothing inside the window lands")
|
||||||
|
|
||||||
|
_ = UndoManager().canRedo
|
||||||
|
#expect(UndoCommandTicker.shared.revision > before, "and the window closes behind the read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user