From f72a2eebe80776c057c3bdf3a89ed432af8de77e Mon Sep 17 00:00:00 2001 From: Sam Schickler Date: Sat, 4 Jul 2026 08:41:18 -0700 Subject: [PATCH 1/2] Guard: ban synchronous onReceive delivery in sidebar row views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row subscribing a Combine publisher without a .receive(on:) hop gets the CurrentValueSubject bridge's synchronous subscribe-time replay while the LazyVStack is realizing the row — inside an in-flight SwiftUI layout transaction — and its willSet-time emission delivers mid-update. Either path lets the onReceive action write row @State inside the transaction being laid out: the #2586/#6556 write-during-layout livelock family. Stable v0.64.17 shipped exactly this shape in TabItemView via tabManager.selectedTabIdPublisher; the guard goes red on this commit by design (two-commit red/green policy) and the fix lands in the next commit. Co-Authored-By: Claude Fable 5 --- scripts/check-sidebar-lazy-layout.py | 61 ++++++++++++++++++++++ tests/test_ci_sidebar_lazy_layout_guard.py | 50 ++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/scripts/check-sidebar-lazy-layout.py b/scripts/check-sidebar-lazy-layout.py index 5fcf09649192..07fc7c57ccb2 100755 --- a/scripts/check-sidebar-lazy-layout.py +++ b/scripts/check-sidebar-lazy-layout.py @@ -139,6 +139,56 @@ "inside a row is the #5323 feedback shape)"), ) +# `.onReceive(` in a row view. Combine delivery into a sidebar row must hop +# through `.receive(on:)`: the TabManager bridges are `CurrentValueSubject`s +# that replay the current value SYNCHRONOUSLY at subscribe time -- and a lazy +# row subscribes while the LazyVStack realizes it, inside an in-flight SwiftUI +# layout transaction -- and they emit during `willSet`, so a selection change +# made mid-update delivers mid-update. Either path lets the `onReceive` action +# write row `@State` inside the transaction being laid out: the same +# write-during-layout family as the #2586/#6556 livelocks (a row shipped this +# exact shape in stable v0.64.17 via `selectedTabIdPublisher`, observed +# livelocked in the wild on 2026-07-02/03). `NotificationCenter` publishers +# deliver synchronously on the posting thread and need the same hop. +ONRECEIVE_CALL = re.compile(r"\.onReceive\s*\(") + +ROW_SYNC_ONRECEIVE_MESSAGE = ( + ".onReceive( without .receive(on:) in a row (synchronous publisher " + "delivery -- a CurrentValueSubject replays on subscribe while the " + "LazyVStack is realizing the row and emits during willSet -- writes row " + "@State inside the in-flight layout transaction, the #2586/#6556 " + "write-during-layout livelock family; route row subscriptions through " + ".receive(on: RunLoop.main))" +) + + +def find_sync_onreceive(region): + """Return True if ``region`` (neutralized Swift) contains an + ``.onReceive(`` whose publisher argument lacks a ``.receive(on:`` hop. + + The publisher expression is the balanced-parenthesis argument list of the + ``.onReceive(`` call; the action trailing closure sits outside it, so a + ``.receive(on:)`` inside the action cannot mask a synchronous publisher. + """ + for match in ONRECEIVE_CALL.finditer(region): + i = match.end() - 1 # at the opening '(' of the argument list + depth = 0 + start = i + n = len(region) + while i < n: + ch = region[i] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + break + i += 1 + publisher_expr = re.sub(r"\s+", "", region[start:i + 1]) + if ".receive(on:" not in publisher_expr: + return True + return False + # Lazy-fill primitives the #6188 fix depends on. Each must remain present in the # named function (after comments/strings are stripped). REQUIRED_PRIMITIVES = ( @@ -476,6 +526,11 @@ def check_source( "row-wrapper file contains forbidden per-row geometry " "feedback: {0}".format(description) ) + if find_sync_onreceive(neutralized): + violations.append( + "row-wrapper file contains forbidden synchronous delivery: " + "{0}".format(ROW_SYNC_ONRECEIVE_MESSAGE) + ) for name in sorted(custom_layout_names): if re.search(r"\b" + re.escape(name) + r"\b", neutralized): violations.append( @@ -501,6 +556,12 @@ def check_source( "{0} contains forbidden per-row geometry feedback: " "{1}".format(type_name, description) ) + if find_sync_onreceive(body): + violations.append( + "{0} contains forbidden synchronous delivery: {1}".format( + type_name, ROW_SYNC_ONRECEIVE_MESSAGE + ) + ) for name in sorted(custom_layout_names): if re.search(r"\b" + re.escape(name) + r"\b", body): violations.append( diff --git a/tests/test_ci_sidebar_lazy_layout_guard.py b/tests/test_ci_sidebar_lazy_layout_guard.py index 02fce5dafccf..c13a28d7ce7b 100755 --- a/tests/test_ci_sidebar_lazy_layout_guard.py +++ b/tests/test_ci_sidebar_lazy_layout_guard.py @@ -24,6 +24,13 @@ wild on 2026-07-02. (l) Per-row `.anchorPreference` (the #5323 virtualization defeat) fails. (m) A required row type missing from its file fails loudly (no silent skip). + (o) An `.onReceive(` in a row whose publisher lacks a `.receive(on:)` hop + fails — a CurrentValueSubject bridge replays synchronously while the + LazyVStack realizes the row (and emits during willSet), so the action + writes row @State inside the in-flight layout transaction. This exact + shape shipped in stable v0.64.17 via `selectedTabIdPublisher`. + (p) The same subscription routed through `.receive(on: RunLoop.main)` + passes, including when `.receive(on:)` spans multiple lines. """ import importlib.util @@ -313,6 +320,49 @@ def row_fixture(row_body): False, "per-row .anchorPreference (#5323 shape) fails", ) else 1 + # (o) An .onReceive( whose publisher chain has no .receive(on:) hop: + # the CurrentValueSubject bridge replays synchronously during lazy row + # realization and emits during willSet, so the action's @State write + # lands inside the in-flight layout transaction (the #2586/#6556 + # family). This shape shipped in stable v0.64.17 and livelocked in the + # wild on 2026-07-02/03. A .receive(on:) inside the ACTION closure + # (outside the publisher argument) must not mask the violation. + sync_onreceive_row = row_fixture( + " HStack { Text(tab.title) }\n" + " .onReceive(\n" + " tabManager.selectedTabIdPublisher\n" + " .map { $0 == tab.id }\n" + " .removeDuplicates()\n" + " ) { isSelected in\n" + " observedIsActive = isSelected\n" + " }" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "SyncOnReceiveRow.swift", sync_onreceive_row)), + False, "row .onReceive without .receive(on:) fails", + ) else 1 + + # (p) The same subscription with a .receive(on: RunLoop.main) hop in + # the publisher chain passes -- also with the hop split across lines, + # since the real call sites chain one operator per line. + deferred_onreceive_row = row_fixture( + " HStack { Text(tab.title) }\n" + " .onReceive(\n" + " tabManager.selectedTabIdPublisher\n" + " .map { $0 == tab.id }\n" + " .removeDuplicates()\n" + " .receive(\n" + " on: RunLoop.main\n" + " )\n" + " ) { isSelected in\n" + " observedIsActive = isSelected\n" + " }" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "DeferredOnReceiveRow.swift", deferred_onreceive_row)), + True, "row .onReceive with .receive(on:) passes", + ) else 1 + # (n) --file on a row-view source (no container functions) must not # emit false "could not locate func" violations; row scanning still # applies. (Greptile P2 on #7221.) From 2d87b4758585d05d4f9b5aaeb42e57fd368457a0 Mon Sep 17 00:00:00 2001 From: Sam Schickler Date: Sat, 4 Jul 2026 08:42:06 -0700 Subject: [PATCH 2/2] Sidebar: hop selectedTabId row delivery to RunLoop.main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabItemView's .onReceive of tabManager.selectedTabIdPublisher was the one row subscription without a .receive(on:) hop (its two sibling publishers below it both have one). The publisher is a CurrentValueSubject bridge, so it replays the current value synchronously at subscribe time — and a lazy sidebar row subscribes while the LazyVStack realizes it, inside an in-flight SwiftUI layout transaction — and it emits during selectedTabId's willSet, so a selection change made mid-update also delivers mid-update. Either path writes the row's @State observedIsActive inside the transaction being laid out: the #2586/#6556 write-during-layout family that livelocked stable v0.64.17 in the wild (62-minute flushTransactions hang, force-quit; see PR body for captures). Delivering on RunLoop.main keeps the write out of the transaction. No visual change: first render reads the live fallback (observedIsActive ?? (tabManager.selectedTabId == tab.id)) and row onAppear seeds the same value, so the deferred replay is deduplicated by updateObservedActiveState's equality guard. ContentView.swift grows by 7 lines (operator + constraint comment); budget updated 16427 -> 16434 to match. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 2 +- Sources/ContentView.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..281693b3c7bb 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34499 CLI/cmux.swift 17954 Sources/AppDelegate.swift -16427 Sources/ContentView.swift +16434 Sources/ContentView.swift 14270 Sources/TerminalController.swift 13172 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5378a63140e3..2f6c7b16f413 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14068,6 +14068,13 @@ struct TabItemView: View, Equatable { tabManager.selectedTabIdPublisher .map { $0 == tab.id } .removeDuplicates() + // The CurrentValueSubject replays synchronously on subscribe — + // which happens while the LazyVStack realizes this row, inside + // an in-flight layout transaction — and emits during willSet. + // Hop to RunLoop.main so the @State write below never lands in + // the transaction being laid out (#2586/#6556 livelock family); + // first render is covered by the live selectedTabId fallback. + .receive(on: RunLoop.main) ) { isSelected in updateObservedActiveState(isSelected) }