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:
@@ -167,3 +167,65 @@ struct UndoCommandTitleTests {
|
||||
#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