Repository navigation
Prevent detached replacements from displacing rearmed Dock portal hosts - #8310
Conversation
📝 WalkthroughWalkthroughPortal host claiming and replacement now use a shared ChangesPortal host lease policy
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Host as Portal host
participant Claim as claimPortalHost
participant Policy as PortalHostLeasePolicy
participant Lease as Active portal lease
Host->>Claim: Submit bounds and ownership
Claim->>Policy: Calculate area and evaluate usability
Policy-->>Claim: Return lease metrics and replacement decision
Claim->>Lease: Preserve or update active host lease
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 25✅ Passed checks (25 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR fixes a SwiftUI Dock portal lifecycle race where a detached, zero-sized replacement host could displace the still-live rearmed host, leaving the terminal surface blank. It introduces
Confidence Score: 5/5Safe to merge after the dogfood checklist passes; the change tightens portal ownership and cannot produce a worse outcome than the existing blank-portal bug. The core invariant is cleanly expressed in one place and verified by new targeted tests. No production logic was added that introduces new state, side channels, or timing dependencies. No files require special attention beyond verifying the dogfood results for cross-pane terminal and browser pane drags. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([claimPortalHost called]) --> B{activePortalHostLease exists?}
B -- No --> C{consumesPendingDistinctReplacement?}
C -- Yes --> D{isUsable candidate?}
D -- No --> E([return false / skip])
D -- Yes --> F[consume pending\nclear + lock host]
F --> G([set lease / return true])
C -- No --> G
B -- Yes --> H{current.hostId == hostId?\nself-refresh}
H -- Yes --> I{pendingDistinct for this pane\n&& isUsable?}
I -- Yes --> J[consume pending\nset lock]
J --> K([update lease / return true])
I -- No --> K
H -- No --> L[compute\nallowsSamePaneReplacement]
L --> M[leasePolicy.shouldReplace]
M --> N{isUsable candidate?}
N -- No --> O([return false\ncause=detachedOrTiny])
N -- Yes --> P{cross-pane?\ncurrent.paneId != candidate.paneId}
P -- Yes --> Q([replace / return true])
P -- No --> R{!isUsable current\nOR allowsSamePane?}
R -- Yes --> Q
R -- No --> S([return false\ncause=ownerPreferred])
%%{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([claimPortalHost called]) --> B{activePortalHostLease exists?}
B -- No --> C{consumesPendingDistinctReplacement?}
C -- Yes --> D{isUsable candidate?}
D -- No --> E([return false / skip])
D -- Yes --> F[consume pending\nclear + lock host]
F --> G([set lease / return true])
C -- No --> G
B -- Yes --> H{current.hostId == hostId?\nself-refresh}
H -- Yes --> I{pendingDistinct for this pane\n&& isUsable?}
I -- Yes --> J[consume pending\nset lock]
J --> K([update lease / return true])
I -- No --> K
H -- No --> L[compute\nallowsSamePaneReplacement]
L --> M[leasePolicy.shouldReplace]
M --> N{isUsable candidate?}
N -- No --> O([return false\ncause=detachedOrTiny])
N -- Yes --> P{cross-pane?\ncurrent.paneId != candidate.paneId}
P -- Yes --> Q([replace / return true])
P -- No --> R{!isUsable current\nOR allowsSamePane?}
R -- Yes --> Q
R -- No --> S([return false\ncause=ownerPreferred])
Reviews (3): Last reviewed commit: "fix: consume surviving browser portal ha..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Sources/Panels/BrowserPanel.swift`:
- Around line 3452-3458: Update the shouldReplace ownership-change path to clear
pendingDistinctPortalHostReplacementPaneId after every successful distinct
replacement. Capture whether the replacement consumed the pending request before
clearing it, and set lockedPortalHost only for that consumed-request case;
preserve the existing unlock behavior for matching non-forced locks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 41a18ce5-99d2-4c01-9442-4487a3983ba2
📒 Files selected for processing (4)
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PortalLease.swiftPackages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/PortalHostLeasePolicy.swiftSources/Panels/BrowserPanel.swiftcmuxTests/DockPortalReconcileTests.swift
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Sources/Panels/BrowserPanel.swift (1)
3452-3458: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winClear superseded pending replacement state after every successful ownership change.
A non-forced replacement currently leaves
pendingDistinctPortalHostReplacementPaneIdintact if it doesn't match the pending pane ID. If ownership later returns to that pending pane, the stale value can authorize an unrelated host. Clear the pending transition whenever an ownership change succeeds, then set the lock only when that replacement consumed the pending request.
Sources/Panels/BrowserPanel.swift#L3452-L3458: Clear the pending state unconditionally before checkingconsumesPendingDistinctReplacementon the replacement path.Sources/Panels/BrowserPanel.swift#L3500-L3504: Clear the pending state unconditionally on the vacant-lease claim path as well.This is a regression of a previously flagged issue that was introduced during the policy refactor. As per path instructions, portal ownership must remain derived from one authoritative, structured source without stale fallback state.
Proposed fixes
Sources/Panels/BrowserPanel.swift#L3452-L3458:
if shouldReplace { - if consumesPendingDistinctReplacement { - pendingDistinctPortalHostReplacementPaneId = nil - lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) - } else if lockedPortalHost?.hostId == current.hostId && - lockedPortalHost?.paneId == current.paneId { - lockedPortalHost = nil - } + pendingDistinctPortalHostReplacementPaneId = nil + if consumesPendingDistinctReplacement { + lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) + } else if lockedPortalHost?.hostId == current.hostId && + lockedPortalHost?.paneId == current.paneId { + lockedPortalHost = nil + }Sources/Panels/BrowserPanel.swift#L3500-L3504:
- if consumesPendingDistinctReplacement { - pendingDistinctPortalHostReplacementPaneId = nil - lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) - } + pendingDistinctPortalHostReplacementPaneId = nil + if consumesPendingDistinctReplacement { + lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id) + } activePortalHostLease = next🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/Panels/BrowserPanel.swift` around lines 3452 - 3458, In the ownership-change logic around the replacement path at Sources/Panels/BrowserPanel.swift lines 3452-3458, clear pendingDistinctPortalHostReplacementPaneId unconditionally before evaluating consumesPendingDistinctReplacement, then set lockedPortalHost only when that replacement consumed the pending request. Apply the same unconditional pending-state clear on the vacant-lease claim path at Sources/Panels/BrowserPanel.swift lines 3500-3504; retain the existing ownership and lock behavior otherwise.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@Sources/Panels/BrowserPanel.swift`:
- Around line 3452-3458: In the ownership-change logic around the replacement
path at Sources/Panels/BrowserPanel.swift lines 3452-3458, clear
pendingDistinctPortalHostReplacementPaneId unconditionally before evaluating
consumesPendingDistinctReplacement, then set lockedPortalHost only when that
replacement consumed the pending request. Apply the same unconditional
pending-state clear on the vacant-lease claim path at
Sources/Panels/BrowserPanel.swift lines 3500-3504; retain the existing ownership
and lock behavior otherwise.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 57a0ed0f-979e-4146-8a96-574cfb48f936
📒 Files selected for processing (2)
Sources/Panels/BrowserPanel.swiftcmuxTests/DockPortalReconcileTests.swift
…est heal - Rows re-wrap continuously during divider/window resize: per width tick the visible pure-AppKit rows re-measure at the live width (manual frame math, bounded by viewport size); heightOfRow falls back to a content-matched entry at another width mid-drag, and the settle pass forces a full re-measure even when the drag ends where it started. - Double-click rename: the single-click action fires for both clicks, so click 2's queued coalesced selection landed after the rename field took the field editor, re-activated the workspace, and end-editing committed the untouched title. didDoubleClickTableRow now drops the queued selection before beginning the edit, and logs whether the field took first responder. - DEBUG probes across the rename/color paint chain (write, snapshot refresh, table apply, title paint, geometry-resize gate transitions) to localize the still-reported reactivity delay with evidence. - Heal main: DockPortalReconcileTests calls preparePortalHostReplacementIfOwned without the instanceSerial that #8310 added (merged during the CI-advisory week); all four unit-test shards fail to compile on main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cing, behavior tests (#8390) * Lawrence Sidebar round 2, batch A: popover refresh, one-pass diff, explicit resize signal, metadata/markdown toggles - Checklist popover refreshes while open: the row's configure pass now forwards fresh models into an open popover (update rebuilds content and resizes) instead of showing creation-time items until reopen. - One equivalence pass per table apply: the height cache reuses the controller's reconfigure diff (skippingEquivalenceCheckAt) instead of re-running row equality over all 128 rows a second time. - Explicit resize-completion: the portal registry posts cmuxInteractiveGeometryResizeDidEnd from its single end path (tracker, legacy gesture, and cursor failsafe all funnel there); the table re-measures immediately on it. The 120ms trailing task remains only as a fallback for width churn without an end signal (window live resize). - Metadata show-more/less and markdown show-details toggles (legacy parity, same localized keys): expansion state is container-owned and flows through the model so heights re-measure via the normal apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add SidebarLayoutModel + width applier wrappers (unwired scaffolding) Canonical width storage outside ContentView state so divider ticks stop re-evaluating the whole window body; only the tiny applier wrappers observe it. Wiring of the read/write sites follows in the next commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move sidebar width out of ContentView state (SidebarLayoutModel wiring) Divider ticks no longer re-evaluate ContentView's body: canonical width lives in an unobserved SidebarLayoutModel, and only the small SidebarWidthReader / width-modifier wrappers observe it (sidebar panel, terminal leading padding, resizer overlay, titlebar inset, chrome border). Writes keep their call sites via a computed alias; the width sanitizer moves from onChange to onReceive(removeDuplicates) since ContentView no longer tracks the value in body. Behavior-identical storage move; both flag paths keep the same values and layout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Settle-pass width handler + deterministic coalescer with unit tests The width sanitizer's move to onReceive regressed drags: Combine delivered it synchronously inside every width write (7.6ms/event measured), running persistence and a portal resync per drag tick. The handler now hops to the runloop, skips entirely mid-drag (the tracking loop and portal anchor callbacks own live geometry), and the full settle (sanitize/persist/portal resync/cursor band) runs once on the registry's drag-end notification. SidebarSelectionCoalescer becomes generic over Clock with all timing from the injected clock, making it deterministic under test; adds SidebarSelectionCoalescerTests (manual clock: leading edge, last-wins trailing, quiet-window reset, cancel) wired into the pbxproj. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route the sidebar flag into the portal from its single evaluation site lint-feature-flags requires one evaluating file per flag; the portal's anchor-failsafe gate from round 1 read it directly. ContentView's sidebar dispatcher (the legit site) now pushes a plain bool into WindowTerminalPortal.usesCoalescedAnchorFailsafe on branch mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * No implicit animations in sidebar cells; group headers join the fast selection path Dogfood video showed rails and text visibly interpolating during and after divider resizes, and group-header clicks feeling like the old selection path. - noteHeightOfRows now runs in a zero-duration animation group (legacy never animates row geometry), and both cell types disable implicit layer actions in applyModel and manual layout — color and frame changes snap exactly like the SwiftUI sidebar. - Group-header clicks route through the same coalescer as workspace rows (headers focus their anchor workspace), with an optimistic anchor-active press treatment and the same visible-row deselection sweep; chevron and plus presses are excluded (they don't select). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Behavior tests for the AppKit row cell (hover enforcement, optimistic paint) Fixture factory for SidebarWorkspaceRowModel plus a DEBUG applyModel probe on the cell. Covers: hover enforcement short-circuits when already correct and re-applies the full model otherwise; optimistic selection paints a flipped model while the stored model stays authoritative; optimistic deselection no-ops on unselected rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix test compile: Foundation import + explicit continuation type Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix cmuxTests compile: hoist mutating gate calls out of #expect The whole cmuxTests target failed to compile (blocking every unit test run) because #expect captures its expression into a closure with an immutable parameter, rejecting mutating calls on the captured var. Pre-existing on main since the CI-advisory window; surfaced by the first unit-test run against this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Allowlist pre-existing determinism findings so unit tests run again WorkspaceForkConversationContextMenuTests landed 7 sleep/duration findings during the CI-advisory window; the determinism gate fails the whole pipeline before any unit test executes, on every branch. Regenerated via check-test-determinism.py --write-allowlist (the gate's migration path); fixing that test's waits properly stays with its owners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix cmuxTests compile: fork-conversation test isolation and inference errors Five compile errors landed with this file during the CI-advisory window; since then the whole cmuxTests target has failed to build on CI's own toolchain, so no unit test in the repo could run. Mechanical fixes: explicit continuation element type; two main-actor local functions converted to @sendable closures (they're called from @sendable indexLoader closures); a main-actor snapshot builder hoisted out of a withLock closure; a discarded Set.insert result inside withLock to settle generic inference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Self-healing row freeze: rebuild once at drag end (stale-rename fix) Dogfood report: sidebar value updates (renames especially) sometimes never rendered. Root cause: row building freezes while the interactive resize registry is active (sidebar AND split-divider drags), so an apply during a drag serves frozen rows and consumes the fresh content without rendering it — stale until the next unrelated sidebar change. The scroll area now invalidates the frozen box and forces one fresh rebuild on the registry's drag-end notification, so mid-drag mutations always render. The rename data path itself was verified sound (customTitle is @published and in the sidebar observation composition). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Zero out the five over-budget Swift warnings from the fork/attachment files The warning-budget gate has per-file zero budgets for these buckets; the warnings landed during the CI-advisory window. Mechanical: two redundant awaits on same-actor calls, one var never mutated, two unused guard bindings replaced with nil tests (identical short-circuit semantics), and an explicit 'as Any' for the QLPreviewPanel! coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix the remaining QLPreviewPanel coercion warning (second call site) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deliver sidebar observation on DispatchQueue.main (modal/menu stall fix) Dogfood reports: renames (the Cmd+Shift+R flow especially) and workspace color changes reached the sidebar UI with long delays. RunLoop.main as a Combine scheduler delivers only in the default runloop mode, so every hop in the sidebar observation pipeline (container observations, merged extension stream, per-cell pump) stalled during modal panels, context menus, and drag tracking - exactly where renames and color picks happen. The snapshot-refresh coalescer beneath already used .common modes; all publisher hops now schedule on DispatchQueue.main, which is runloop-mode-agnostic. customTitle, customColor, description, pin, and todo state all ride these publishers, so one fix covers both reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route indexnow jobs through vars.LINUX_RUNNER The runner guard forbids bare GitHub-hosted runners; indexnow.yml landed on main with ubuntu-latest during the CI-advisory week and fails workflow-guard-tests on every gate run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Round-2 dogfood fixes: live width reflow, double-click rename, main test heal - Rows re-wrap continuously during divider/window resize: per width tick the visible pure-AppKit rows re-measure at the live width (manual frame math, bounded by viewport size); heightOfRow falls back to a content-matched entry at another width mid-drag, and the settle pass forces a full re-measure even when the drag ends where it started. - Double-click rename: the single-click action fires for both clicks, so click 2's queued coalesced selection landed after the rename field took the field editor, re-activated the workspace, and end-editing committed the untouched title. didDoubleClickTableRow now drops the queued selection before beginning the edit, and logs whether the field took first responder. - DEBUG probes across the rename/color paint chain (write, snapshot refresh, table apply, title paint, geometry-resize gate transitions) to localize the still-reported reactivity delay with evidence. - Heal main: DockPortalReconcileTests calls preparePortalHostReplacementIfOwned without the instanceSerial that #8310 added (merged during the CI-advisory week); all four unit-test shards fail to compile on main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mount sidebar observations on the shared scroll-area parent Root cause of the delayed rename/color reactivity, proven with the debug probes: every workspace publisher observation (sidebarWorkspaceObservations, process-title, agent-runtime) plus the initial refreshWorkspaceSnapshots hung off legacyWorkspaceScrollArea's view chain. With the AppKit sidebar flag on that subtree never mounts, so no workspace publisher was observed at all: workspaceSnapshotsById stayed empty, renames/colors/pins/descriptions produced no sidebar invalidation, and rows only repainted when an unrelated change rebuilt the body (probe: title write 19:25:01.279, paint 19:25:08.481, zero snapshot flushes in between). The observation block now lives on the shared parent Group so both implementations use one refresh path. Also: a fast row drag consumed the press without any selection commit, so the optimistic press highlight lingered on the grabbed row and every other visible row stayed peeled; drag-session begin now drops the queued selection and restores visible cells from stored models. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: peel header optimistic highlight, width settle on main queue CodeRabbit: previewSelection's peel loop only reset workspace cells, so a pending group-header preview replaced by a new press kept its anchor-active paint until its model next changed. Headers now clear via clearOptimisticAnchorActive (re-applies the stored model). Greptile: the width-settle onReceive still hopped through RunLoop.main; same default-mode stall class this PR fixes elsewhere, now DispatchQueue.main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Why this is a draft
An earlier tagged dogfood flow also exposed stale browser placement and duplicate hover targets during pane dragging. Those are not claimed as fixed by this terminal lease change. The PR stays draft until fresh terminal/browser drag dogfood confirms this patch is isolated and does not reproduce those behaviors.
Regression sequence
04936dbad9— adds the failing behavioral regression test only.f7e08c0353— rejects ownership acquisition by a distinct detached/tiny host until it becomes usable.The test-only commit was pushed first and the manual CI workflow was dispatched. Current
origin/mainblocks macOS tests earlier inworkflow-guard-testsbecauseWorkspaceTodoNotificationRegressionTests.swiftis not wired into thecmuxTeststarget, so CI could not record the intended regression failure. Run: https://github.com/manaflow-ai/cmux/actions/runs/29552840750Verification
origin/main: 13 tests inDockPortalReconcileTestspassed../scripts/reload.sh --tag detached-replacement-portalsucceeded onf7e08c0353.git diff --checkpassed.Dogfood checklist before ready for review
Summary by CodeRabbit