Skip to content

Guard workspace sidebar LazyVStack against layout re-livelock (#6384) - #6870

Merged
austinywang merged 4 commits into
mainfrom
issue-6384-main-thread-hang-1s-beachball-in
Jun 29, 2026
Merged

austinywang merged 4 commits into
mainfrom
issue-6384-main-thread-hang-1s-beachball-in

Conversation

@austinywang

@austinywang austinywang commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #6384

Investigation

#6384 reports a ~1s main-thread beachball with many surfaces open: the main thread is 100% on-CPU (not blocked) inside GraphHost.flushTransactions(), recomputing layout for a ScrollView + LazyVStack + ForEach tree — the workspace sidebar.

The authoritative follow-up comment (from a second reporter on 0.64.16) pinpoints the cmux-owned frames as SidebarRowsFillLayout.placeSubviews / .sizeThatFits driving LazyStack → ForEachList.applyNodes → measureEstimates, and notes this is the force-measure root-caused in #6210 and removed in #6188 (merged 2026-06-16, ~14h after v0.64.16 was tagged — so the stable build both reporters hit still shipped it).

I confirmed on current main:

Because this is a layout/timing livelock that only manifests at agent-heavy scale on the reporter's stable build, it isn't cleanly reproducible locally or unit-testable as a SwiftUI layout pass; the structural fix already landed in #6188.

What this PR adds

This exact class of bug has regressed four times — #2586 → #5764 → #5845 → #6033/#6210/#6384 — and the lazy-at-measure-time contract is currently defended only by inline comments, which CI cannot enforce. This PR adds the missing CI regression guard so a re-introduction fails the build instead of shipping another beachball:

  • scripts/check-sidebar-lazy-layout.py — neutralizes comments and string literals (the guarded functions deliberately name the forbidden anti-patterns in their explanatory comments), extracts the bodies of workspaceScrollContent and workspaceRows from Sources/ContentView.swift, and fails if either:
    • reintroduces a whole-list measurement signature: GeometryReader, ProposedViewSize(…, nil), .sizeThatFits(, or SidebarRowsFillLayout; or
    • drops a lazy-fill primitive the fix relies on: LazyVStack( in workspaceRows, .frame(minHeight:) in workspaceScrollContent.
    • A renamed/removed guarded function fails loudly (non-zero) rather than silently skipping, so the guard can't rot into a no-op.
  • tests/test_ci_sidebar_lazy_layout_guard.py — proves the guard catches the bug: passes the real repo and a clean fixture whose comments/strings name every forbidden token, and fails synthetic fixtures for each regression mode (force-measure, reintroduced custom Layout, GeometryReader, eager VStack, missing minHeight, renamed function).
  • Wires the self-test into the workflow-guard-tests CI job, matching the repo's existing guard-script convention (lint-pbxproj-test-wiring, check-workspace-package-groups, etc.).

The drag-only rowsWithGatedDropTargetReader is intentionally not scanned — its GeometryReader resolves per-row drop anchors and only runs during an active drag (#5325), never in the steady-state layout this guard protects.

Note on the two-commit regression policy

The red/green two-commit structure is for adding a new code fix. Here the fix already shipped in #6188, so the guard passes on main precisely because main is correct. The proof that the guard catches the bug is instead permanent and in-CI: the negative cases in test_ci_sidebar_lazy_layout_guard.py reintroduce each anti-pattern and assert the guard fails.

Validation

$ python3 scripts/check-sidebar-lazy-layout.py
check-sidebar-lazy-layout: ok (Sources/ContentView.swift)

$ python3 tests/test_ci_sidebar_lazy_layout_guard.py
[PASS] real repo passes
[PASS] clean body, anti-patterns only in comments/strings
[PASS] force-measure sizeThatFits(ProposedViewSize(height: nil)) fails
[PASS] reintroduced SidebarRowsFillLayout fails
[PASS] GeometryReader in scroll content fails
[PASS] eager VStack (no LazyVStack) fails
[PASS] missing .frame(minHeight:) fails
[PASS] renamed guarded function fails (no silent skip)
test_ci_sidebar_lazy_layout_guard: all cases passed

No app/runtime behavior changes (CI-only). No user-facing strings, settings, menus, shortcuts, docs, or web messages touched — localization audit: N/A.

🤖 Generated with Claude Code


Summary by cubic

Adds a CI guard to keep the workspace sidebar LazyVStack lazy at measure time and prevent reintroducing whole‑list measurement behind #6384. Now scans repo-owned Sources/ and Packages/ for any custom Layout used with the rows, handles Swift multi-line strings, and remains CI-only.

  • New Features
    • Added scripts/check-sidebar-lazy-layout.py to scan workspaceScrollContent and workspaceRows in Sources/ContentView.swift; fails on GeometryReader, .sizeThatFits(, ProposedViewSize(..., nil), or any Layout-conforming type applied to the rows (discovered across repo-owned Sources/ and Packages/, excluding .build, .git, and vendor dirs); requires LazyVStack( and .frame(minHeight:); neutralizes comments, strings, and """..."""; fails loudly if the guarded functions are renamed.
    • Added tests/test_ci_sidebar_lazy_layout_guard.py with pass/fail fixtures, including a renamed custom Layout, a multi-line string with a bare ", and a discovery check that includes Packages/ and excludes vendored build trees; wired into CI via .github/workflows/ci.yml.

Written for commit 3cb88e6. Summary will update on new commits.

Review in cubic

The workspace-sidebar rows render as a LazyVStack inside a vertical
ScrollView. Keeping that stack lazy at *measure time* is load-bearing:
the sidebar is re-diffed on every workspace/telemetry update, so any code
that forces SwiftUI to realize and measure the whole row list on each
layout pass turns a routine update into a multi-second
GraphHost.flushTransactions() main-thread livelock once enough
workspaces/surfaces are open. That is exactly the ~1s beachball reported
in #6384.

The root cause -- SidebarRowsFillLayout, a custom Layout that called
subviews.first?.sizeThatFits(ProposedViewSize(width:, height: nil)) on
the LazyVStack every pass -- was removed in #6188 (#6210), and the rows
are lazy again in main. But this same class of bug has now regressed four
times (#2586, #5764, #5845, #6033 -> #6210/#6384) and is defended only by
inline comments, which CI cannot enforce.

Add a source-scan regression guard so the contract fails CI on
re-introduction:

- scripts/check-sidebar-lazy-layout.py neutralizes comments/string
  literals (the guarded functions deliberately *name* the forbidden
  anti-patterns in explanatory comments), extracts the bodies of
  workspaceScrollContent and workspaceRows from Sources/ContentView.swift,
  and fails if either reintroduces a whole-list measurement signature
  (GeometryReader, ProposedViewSize(..., nil), .sizeThatFits(, or
  SidebarRowsFillLayout) or drops a lazy-fill primitive the fix relies on
  (LazyVStack( in workspaceRows, .frame(minHeight:) in
  workspaceScrollContent). A renamed/removed guarded function fails loudly
  rather than silently skipping.

- tests/test_ci_sidebar_lazy_layout_guard.py proves the guard catches the
  bug: it passes the real repo and a clean fixture whose comments/strings
  name every forbidden token, and fails synthetic fixtures for each
  regression mode (force-measure, reintroduced custom Layout,
  GeometryReader, eager VStack, missing minHeight, renamed function).

- Wire the self-test into the workflow-guard-tests CI job.

The drag-only drop-target reader (rowsWithGatedDropTargetReader) is not
scanned: it intentionally uses a GeometryReader to resolve per-row drop
anchors and is gated behind an active drag (#5325), so it never runs
during the steady-state layout this guard protects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 26, 2026 3:29pm
cmux-staging Building Building Preview, Comment Jun 26, 2026 3:29pm

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@austinywang, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 1 minute and 46 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 354e7b6f-ad30-40f3-9354-3c2fe32f45b3

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6c701 and 3cb88e6.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • scripts/check-sidebar-lazy-layout.py
  • tests/test_ci_sidebar_lazy_layout_guard.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6384-main-thread-hang-1s-beachball-in

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This CI-only PR adds a regression guard to keep the workspace sidebar's LazyVStack lazy at measure time, preventing a re-introduction of the ~1s main-thread beachball (bug #6384) that was fixed in #6188 but has regressed four times. No runtime or user-facing changes are made.

  • scripts/check-sidebar-lazy-layout.py scans workspaceScrollContent and workspaceRows in Sources/ContentView.swift for forbidden whole-list measurement patterns (GeometryReader, .sizeThatFits(, ProposedViewSize(…, nil), any custom Layout-conforming type) and verifies required lazy primitives (LazyVStack(, .frame(minHeight:)) are still present; it also fails loudly on guarded-function rename, closing the "rot into a no-op" escape hatch.
  • tests/test_ci_sidebar_lazy_layout_guard.py exercises 9 positive/negative cases — including the newly added multi-line string and renamed-Layout cases — and the test is wired into the existing workflow-guard-tests CI job.

Confidence Score: 5/5

CI-only additions with no runtime changes; the guard logic is correct for all current and historically regressed patterns.

All three files are pure build tooling — no Swift sources, no runtime paths, no user-facing behavior is touched. The guard's core state machine (comment/string neutralization, brace-matched function extraction, forbidden-pattern scanning) is well-tested across 9 positive and negative cases. The two gaps identified (generic-constraint false positive in CUSTOM_LAYOUT_DECL and unhandled raw string literals) would, if triggered, produce visible CI noise rather than silent regressions, and neither is plausible in the guarded functions today.

No files require special attention for merge safety; the two noted edge cases in scripts/check-sidebar-lazy-layout.py are low-probability and would manifest as obvious CI false positives rather than missed real bugs.

Important Files Changed

Filename Overview
.github/workflows/ci.yml Adds a single step to run the sidebar lazy-layout guard self-test in the existing workflow-guard-tests job; matches the repo's established pattern for guard-script CI wiring.
scripts/check-sidebar-lazy-layout.py Well-structured guard with comment/string neutralization (including the newly added multi-line-string state), brace-matched function extraction, and cross-file Layout-type discovery; two minor regex gaps — generic-constraint false positives in CUSTOM_LAYOUT_DECL and unhandled raw-string literals — could cause spurious CI failures in unlikely but possible future code patterns.
tests/test_ci_sidebar_lazy_layout_guard.py Comprehensive self-test covering 9 pass/fail cases (real repo, clean comments/strings, force-measure, SidebarRowsFillLayout, GeometryReader, eager VStack, missing minHeight, renamed function, and cross-tree Layout discovery); the new multi-line string (b2) and renamed-Layout (d2/i) cases are well-exercised.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([check-sidebar-lazy-layout.py]) --> B[Read Sources/ContentView.swift]
    A --> C[Scan Sources/ + Packages/ for Layout-conforming types]
    B --> D[neutralize_swift: strip comments, strings, multi-line strings]
    C --> D2[neutralize_swift each file]
    D2 --> E[CUSTOM_LAYOUT_DECL regex → custom_layout_names set]
    D --> F[extract_function_body: workspaceScrollContent]
    D --> G[extract_function_body: workspaceRows]
    F --> F1{function found?}
    G --> G1{function found?}
    F1 -- No --> FAIL([FAIL: rename detected])
    G1 -- No --> FAIL
    F1 -- Yes --> H[Check FORBIDDEN_PATTERNS in body]
    G1 -- Yes --> H
    E --> H
    H --> H1{GeometryReader / sizeThatFits / ProposedViewSize nil / SidebarRowsFillLayout / any custom Layout name?}
    H1 -- Found --> FAIL
    H1 -- Clean --> I[Check REQUIRED_PRIMITIVES]
    I --> I1{LazyVStack in workspaceRows?}
    I1 -- Missing --> FAIL
    I1 -- Present --> I2{.frame minHeight in workspaceScrollContent?}
    I2 -- Missing --> FAIL
    I2 -- Present --> PASS([PASS: ok])
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A([check-sidebar-lazy-layout.py]) --> B[Read Sources/ContentView.swift]
    A --> C[Scan Sources/ + Packages/ for Layout-conforming types]
    B --> D[neutralize_swift: strip comments, strings, multi-line strings]
    C --> D2[neutralize_swift each file]
    D2 --> E[CUSTOM_LAYOUT_DECL regex → custom_layout_names set]
    D --> F[extract_function_body: workspaceScrollContent]
    D --> G[extract_function_body: workspaceRows]
    F --> F1{function found?}
    G --> G1{function found?}
    F1 -- No --> FAIL([FAIL: rename detected])
    G1 -- No --> FAIL
    F1 -- Yes --> H[Check FORBIDDEN_PATTERNS in body]
    G1 -- Yes --> H
    E --> H
    H --> H1{GeometryReader / sizeThatFits / ProposedViewSize nil / SidebarRowsFillLayout / any custom Layout name?}
    H1 -- Found --> FAIL
    H1 -- Clean --> I[Check REQUIRED_PRIMITIVES]
    I --> I1{LazyVStack in workspaceRows?}
    I1 -- Missing --> FAIL
    I1 -- Present --> I2{.frame minHeight in workspaceScrollContent?}
    I2 -- Missing --> FAIL
    I2 -- Present --> PASS([PASS: ok])
Loading

Reviews (4): Last reviewed commit: "Guard: discover custom Layouts across Pa..." | Re-trigger Greptile

Comment thread scripts/check-sidebar-lazy-layout.py
@blacksmith-sh

This comment has been minimized.

Greptile P2 (#6870 review): neutralize_swift treated every `"` as a
regular string boundary, so a Swift multi-line string `"""..."""` parsed
as two empty strings plus an unclosed string. A bare `"` inside such a
literal (e.g. `"""... he said "GeometryReader" ..."""`) would close the
outer string early and expose the remaining content -- including a
forbidden token named in prose -- as apparent code, tripping the guard
with a false positive and a spurious CI failure.

Add a MULTILINE_STRING tokenizer state: `"""` opens it, only a closing
`"""` ends it, and a lone `"` inside is neutralized like any other string
content. Add self-test case (b2) with a multi-line string containing a
bare quote plus GeometryReader / sizeThatFits(ProposedViewSize(height:
nil)) / SidebarRowsFillLayout, asserting the guard still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@austinywang

Copy link
Copy Markdown
Contributor Author

Thanks @greptile — addressed the P2 in 73903a2: neutralize_swift now has a dedicated multi-line-string state (""" opens it, only a closing """ ends it, a lone " inside is neutralized), so a bare quote in a """...""" literal can no longer close the literal early and expose a prose-named forbidden token as code. Added self-test case (b2) covering a multi-line string that contains a bare quote plus GeometryReader / sizeThatFits(ProposedViewSize(height: nil)) / SidebarRowsFillLayout, asserting the guard still passes.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

The fix is solid. The MULTILINE_STRING state is correct: """ opens it, only a closing """ ends it, and a lone " inside is blitted as a space — exactly the right semantics. The test case (b2) exercises the exact failure mode (bare quote inside """...""" with forbidden tokens in prose) and asserts the guard still passes.

LGTM — this addresses the false-positive concern completely.

cmux and others added 2 commits June 26, 2026 04:23
… name

Codex autoreview P2 (#6870): the guard only banned the literal deleted
type name `SidebarRowsFillLayout`, so a future regression could wrap
`workspaceRows(...)` in a differently named custom `Layout` (with the
`subview.sizeThatFits(ProposedViewSize(... height: nil ...))` body living
outside the two scanned functions) and CI would pass -- the exact #6033
shape under a new name.

Generalize the guard: discover every type conforming to SwiftUI's
`Layout` protocol across the whole Sources/ tree (comment/string-
neutralized, pre-filtered to files that mention `Layout`), then fail if
ANY of those type names is applied within `workspaceScrollContent` /
`workspaceRows`. A custom Layout wrapping the LazyVStack measures it on
every pass regardless of the type's name; rows must be sized by
`.frame(minHeight:)` instead. The literal-name and direct force-measure
token bans are kept as belt-and-suspenders.

Add self-test case (d2): a `struct RowsFillLayout: Layout` (NOT the old
name) whose force-measure lives in the layout type, applied to the rows
in `workspaceScrollContent`; the guard must fail it. Without the
generalization this is a false negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex autoreview P2 (#6870): custom-Layout discovery scanned only
Sources/, but cmux migrates app code into Packages/. A force-measuring
sidebar layout defined in a repo-owned package and applied in
workspaceScrollContent would not be discovered, leaving the guard
bypassable exactly where code is moving.

- Replace the Sources-only glob with repo_owned_swift_files(), which walks
  both Sources/ and Packages/ and prunes build/VCS/vendored dirs
  (.build, .git, DerivedData, Vendor, Pods, Carthage, node_modules, ...).
  External-dependency Layouts remain out of scope by design.
- Add self-test case (i): repo_owned_swift_files() covers Sources/ and
  Packages/, discovers their Layout types, and excludes a .build/checkouts
  vendored Layout.
- Document the guard's scope boundary: it protects the rows layout as
  expressed in workspaceScrollContent/workspaceRows and does not chase a
  force-measure relocated into an arbitrary transitively-called helper
  (fragile to track in a lint; such an extraction should re-review this
  guard). Custom Layout types are the exception chased across files, since
  a renamed force-measuring layout is the concrete #6033 regression.

Real-repo scan stays ~2s (Layout-substring pre-filter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

@austinywang
austinywang merged commit 78de114 into main Jun 29, 2026
47 of 50 checks passed
lawrencecchen added a commit that referenced this pull request Jul 3, 2026
…-view guard (#7221)

* Extend sidebar lazy-layout guard to the row views (TabItemView, group header)

The source-scan guard from #6870 protected only the two container
functions, but four of the five historical livelock regressions entered
through the row views: the #2586/#6556 GeometryReader -> @State
rowHeight probes lived in TabItemView and
SidebarWorkspaceGroupHeaderView (removed by #6111, reintroduced by
#4385, removed again by #7117) and shipped in stable v0.64.17, which
livelocked in the wild on 2026-07-02 with exactly that signature
(#2586 (comment)).

Scan the TabItemView region of ContentView.swift and the whole group
header file for per-row geometry feedback: GeometryReader,
onGeometryChange, manual sizeThatFits, ProposedViewSize(nil),
per-row anchorPreference/overlayPreferenceValue, and any discovered
custom Layout. Rows must stay measurement-free; the only sanctioned
geometry path is the container's drag-gated reader. Missing row types
fail loudly so a rename cannot rot the guard into a no-op.

Verified the extended guard retroactively flags both v0.64.17 row
views. New self-test cases (j)-(m) cover clean-pass, the #6556 probe
shape, the #5323 anchorPreference shape, and rename protection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add behavioral scale gate for the sidebar lazy-layout contract

The lazy-layout contract (sidebar layout/diff work stays O(visible
rows), never O(all workspaces)) has regressed five times through five
different mechanisms (#5323 anchorPreference aggregation, #5764 String
ids, #5845 animated height interpolation, #6210 force-measuring custom
Layout, #6556 GeometryReader -> @State feedback), each shipping to
stable before detection because nothing exercises the sidebar at the
100+ workspace scale where O(N) per pass livelocks the main thread
(#2586).

SidebarLazyLayoutScaleTests mounts the real VerticalTabsSidebar with
300 workspaces in an NSHostingView and counts actual row body
evaluations through a DEBUG-only environment probe
(SidebarLazyContractProbe, same pattern as
MinimalModeInvalidationProbe):

- mount must realize only viewport rows (catches any virtualization
  defeat, present or future, regardless of mechanism)
- a 40-burst unread-model storm (the sidebar's highest-frequency
  whole-body invalidation path) must stay row-scoped and go quiet when
  the burst stops (catches feedback loops the way #6556 manifested)
- a harness canary reproduces the GeometryReader -> @State shape in
  divergent form and asserts the harness detects it, so the gate
  cannot silently rot

This is the mechanism-independent backstop behind the source-shape
scan in scripts/check-sidebar-lazy-layout.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix scale-test autorelease avalanche that hung/crashed the app host

Creating 300 workspaces inside one main-actor job accumulated every
autoreleased object from the O(N)-per-add snapshot work into a single
autorelease pool; the closing objc_autoreleasePoolPop then crashed CI
(Signal 11 in AutoreleasePoolPage::releaseUntil, masked as a green run,
see #5641) and hung for hours
when reproduced on an AWS M4 Pro (sampled: main thread pinned in
releaseUntil). The app never does this; real workspace creation happens
one per event-loop turn with AppKit popping the pool between turns.

Make the harness match real cadence: per-iteration autoreleasepool
around addWorkspace and a run-loop turn every 20 creations. Also hoist
the RunLoop.run call into a synchronous helper (fixes the Swift 6
unavailable-from-async warning) wrapped in its own pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix harness NSWindow double-release that killed the app host

NSZombies named the corpse: "-[NSKVONotifying_NSWindow release]:
message sent to deallocated instance". The harness windows used the
NSWindow default isReleasedWhenClosed=true, so tearDown's close()
performed AppKit's own release on top of ARC's; the double-release
SEGV'd the host at the next autorelease-pool pop, before the pass was
recorded, and CI masked the crash as a green run
(#5641 (comment)).
With zombies absorbing the over-release, all assertions pass in under
a second, isolating the crash entirely to window teardown.

Set isReleasedWhenClosed = false on both harness windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard --file mode: require container functions only when one is present

Addresses Greptile P2 on the PR: --file against a row-view source (no
workspaceScrollContent/workspaceRows) emitted false could-not-locate
violations that masked real row findings. In --file mode the container
checks now apply only when at least one guarded function exists in the
source, so ad-hoc row-view scans are clean while a fixture that renamed
one function still fails loudly. Self-test cases added for both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split probe env key + extension into their own files (Aziz policy)

One major type per Swift file, matching the MinimalModeInvalidationProbe
three-file layout exactly. No content changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover the group-header row wrapper (guard target + grouped scale fixture)

Codex review found the blind spot: the group-header row is assembled by
sidebarWorkspaceGroupHeader(...) in VerticalTabsSidebar+WorkspaceGroups.swift,
where modifiers wrap the header before it enters the LazyVStack — a
GeometryReader or anchorPreference added there defeats laziness exactly
like one inside the row view (the #4385 regression entered through the
header path). The guard now scans that whole file for the row-forbidden
shapes with a rename-protected marker, and the scale fixture groups the
first 20 workspaces into 5 groups so group-header realization and
convergence are asserted by the behavioral backstop (bounds on
groupHeaderBodies at mount and in the quiet check).

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s, no host restarts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the scale harness hermetic against persisted sidebar provider

Codex review: VerticalTabsSidebar selects between the workspace list and
extension/built-in sidebars via
@AppStorage(CmuxExtensionSidebarSelection.defaultsKey), so a host with a
persisted non-default provider would mount the wrong sidebar and the
probes would never fire. Use a scratch UserDefaults suite pinned to the
default provider via .defaultAppStorage, cleaned in tearDown — the
WorkspaceContentViewVisibilityTests pattern.

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mochiexists pushed a commit to mochiexists/cmux-mochi that referenced this pull request Jul 7, 2026
…-view guard (manaflow-ai#7221)

* Extend sidebar lazy-layout guard to the row views (TabItemView, group header)

The source-scan guard from manaflow-ai#6870 protected only the two container
functions, but four of the five historical livelock regressions entered
through the row views: the manaflow-ai#2586/manaflow-ai#6556 GeometryReader -> @State
rowHeight probes lived in TabItemView and
SidebarWorkspaceGroupHeaderView (removed by manaflow-ai#6111, reintroduced by
livelocked in the wild on 2026-07-02 with exactly that signature
(manaflow-ai#2586 (comment)).

Scan the TabItemView region of ContentView.swift and the whole group
header file for per-row geometry feedback: GeometryReader,
onGeometryChange, manual sizeThatFits, ProposedViewSize(nil),
per-row anchorPreference/overlayPreferenceValue, and any discovered
custom Layout. Rows must stay measurement-free; the only sanctioned
geometry path is the container's drag-gated reader. Missing row types
fail loudly so a rename cannot rot the guard into a no-op.

Verified the extended guard retroactively flags both v0.64.17 row
views. New self-test cases (j)-(m) cover clean-pass, the manaflow-ai#6556 probe
shape, the manaflow-ai#5323 anchorPreference shape, and rename protection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add behavioral scale gate for the sidebar lazy-layout contract

The lazy-layout contract (sidebar layout/diff work stays O(visible
rows), never O(all workspaces)) has regressed five times through five
different mechanisms (manaflow-ai#5323 anchorPreference aggregation, manaflow-ai#5764 String
ids, manaflow-ai#5845 animated height interpolation, manaflow-ai#6210 force-measuring custom
Layout, manaflow-ai#6556 GeometryReader -> @State feedback), each shipping to
stable before detection because nothing exercises the sidebar at the
100+ workspace scale where O(N) per pass livelocks the main thread
(manaflow-ai#2586).

SidebarLazyLayoutScaleTests mounts the real VerticalTabsSidebar with
300 workspaces in an NSHostingView and counts actual row body
evaluations through a DEBUG-only environment probe
(SidebarLazyContractProbe, same pattern as
MinimalModeInvalidationProbe):

- mount must realize only viewport rows (catches any virtualization
  defeat, present or future, regardless of mechanism)
- a 40-burst unread-model storm (the sidebar's highest-frequency
  whole-body invalidation path) must stay row-scoped and go quiet when
  the burst stops (catches feedback loops the way manaflow-ai#6556 manifested)
- a harness canary reproduces the GeometryReader -> @State shape in
  divergent form and asserts the harness detects it, so the gate
  cannot silently rot

This is the mechanism-independent backstop behind the source-shape
scan in scripts/check-sidebar-lazy-layout.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix scale-test autorelease avalanche that hung/crashed the app host

Creating 300 workspaces inside one main-actor job accumulated every
autoreleased object from the O(N)-per-add snapshot work into a single
autorelease pool; the closing objc_autoreleasePoolPop then crashed CI
(Signal 11 in AutoreleasePoolPage::releaseUntil, masked as a green run,
see manaflow-ai#5641) and hung for hours
when reproduced on an AWS M4 Pro (sampled: main thread pinned in
releaseUntil). The app never does this; real workspace creation happens
one per event-loop turn with AppKit popping the pool between turns.

Make the harness match real cadence: per-iteration autoreleasepool
around addWorkspace and a run-loop turn every 20 creations. Also hoist
the RunLoop.run call into a synchronous helper (fixes the Swift 6
unavailable-from-async warning) wrapped in its own pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix harness NSWindow double-release that killed the app host

NSZombies named the corpse: "-[NSKVONotifying_NSWindow release]:
message sent to deallocated instance". The harness windows used the
NSWindow default isReleasedWhenClosed=true, so tearDown's close()
performed AppKit's own release on top of ARC's; the double-release
SEGV'd the host at the next autorelease-pool pop, before the pass was
recorded, and CI masked the crash as a green run
(manaflow-ai#5641 (comment)).
With zombies absorbing the over-release, all assertions pass in under
a second, isolating the crash entirely to window teardown.

Set isReleasedWhenClosed = false on both harness windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Guard --file mode: require container functions only when one is present

Addresses Greptile P2 on the PR: --file against a row-view source (no
workspaceScrollContent/workspaceRows) emitted false could-not-locate
violations that masked real row findings. In --file mode the container
checks now apply only when at least one guarded function exists in the
source, so ad-hoc row-view scans are clean while a fixture that renamed
one function still fails loudly. Self-test cases added for both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split probe env key + extension into their own files (Aziz policy)

One major type per Swift file, matching the MinimalModeInvalidationProbe
three-file layout exactly. No content changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover the group-header row wrapper (guard target + grouped scale fixture)

Codex review found the blind spot: the group-header row is assembled by
sidebarWorkspaceGroupHeader(...) in VerticalTabsSidebar+WorkspaceGroups.swift,
where modifiers wrap the header before it enters the LazyVStack — a
GeometryReader or anchorPreference added there defeats laziness exactly
like one inside the row view (the manaflow-ai#4385 regression entered through the
header path). The guard now scans that whole file for the row-forbidden
shapes with a rename-protected marker, and the scale fixture groups the
first 20 workspaces into 5 groups so group-header realization and
convergence are asserted by the behavioral backstop (bounds on
groupHeaderBodies at mount and in the quiet check).

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s, no host restarts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the scale harness hermetic against persisted sidebar provider

Codex review: VerticalTabsSidebar selects between the workspace list and
extension/built-in sidebars via
@AppStorage(CmuxExtensionSidebarSelection.defaultsKey), so a host with a
persisted non-default provider would mount the wrong sidebar and the
probes would never fire. Use a scratch UserDefaults suite pinned to the
default provider via .defaultAppStorage, cleaned in tearDown — the
WorkspaceContentViewVisibilityTests pattern.

Verified on the AWS M4 Pro runner: 3/3 pass in 2.7s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2071529)

This branch was successfully deployed

1 active deployment
Preview – cmux — 3cb88e66 Deployed Jun 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Main-thread hang (~1s beachball) in SwiftUI layout pass with many surfaces open

1 participant