From 57542177c102f633818df7833484a6249159bba1 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 22:50:00 -0400 Subject: [PATCH] =?UTF-8?q?The=20undo=20rows=20stop=20hearing=20their=20ow?= =?UTF-8?q?n=20echo=20=E2=80=94=20a=20text=20manager=20answers=20canRedo?= =?UTF-8?q?=20by=20posting=20the=20checkpoint=20that=20re-invalidated=20th?= =?UTF-8?q?e=20row,=20forever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/UndoCommands.swift | 49 ++++++++++++++++++++--- KanbanTests/UndoCommandsTests.swift | 62 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/Kanban/App/UndoCommands.swift b/Kanban/App/UndoCommands.swift index 586bef4..25cba6c 100644 --- a/Kanban/App/UndoCommands.swift +++ b/Kanban/App/UndoCommands.swift @@ -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(_ 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() { diff --git a/KanbanTests/UndoCommandsTests.swift b/KanbanTests/UndoCommandsTests.swift index e24e25a..0959bd9 100644 --- a/KanbanTests/UndoCommandsTests.swift +++ b/KanbanTests/UndoCommandsTests.swift @@ -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") + } +}