Skip to content

Improve heavy workspace switch latency - #4213

Closed
lawrencecchen wants to merge 4 commits into
mainfrom
feat-workspace-switch-perf
Closed

lawrencecchen wants to merge 4 commits into
mainfrom
feat-workspace-switch-perf

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

  • Keep visited workspaces mounted so the warm path does not rebuild terminal/browser surfaces.
  • Keep mounted workspace portal anchors alive but transparent, then switch visible workspace portals by z-priority during handoff.
  • Extend the workspace-switch benchmark to measure warm visible readiness, not just handoff completion.

Cloud benchmark:

  • Run: https://github.com/manaflow-ai/cmux-loader/actions/runs/25948772320
  • Host: cloud-mac-25948772320, macOS 26.4
  • Control layer: shell-debug via cmux socket and debug logs, CUA bootstrap timed out before benchmark.
  • Fixture: 6 workspaces, 5 panes/workspace, 2 surfaces/pane, 24 browsers, 36 terminals.
  • Command: scripts/perf-workspace-switch.py --tag cwarm3 --workspace-count 6 --panes-per-workspace 5 --surfaces-per-pane 2 --browser-panes-per-workspace 2 --terminal-scrollback-lines 200 --browser-dom-rows 240 --warmup-passes 1 --measure-passes 2 --output /tmp/cmux-switch-cwarm3.json
  • Result: visible_ready p95 16.74ms, handoff p95 16.79ms, portal_priority p95 16.74ms, 20/20 samples, budgets pass.

Cloud verification:

  • CMUX_SKIP_ZIG_BUILD=1 xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination "platform=macOS,arch=arm64" -derivedDataPath /tmp/cmux-cwarm3-tests-skipzig -only-testing:cmuxTests/WorkspaceMountPolicyTests -only-testing:cmuxTests/TabManagerWorkspaceSelectionPerfTests -only-testing:cmuxTests/WorkspacePortalRenderingTests test
  • CMUX_SKIP_ZIG_BUILD=1 xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination "platform=macOS,arch=arm64" -derivedDataPath /tmp/cmux-cwarm3-tests-skipzig -only-testing:cmuxTests/WorkspaceContentViewVisibilityTests/testNonSelectedNonRetiringWorkspaceKeepsPanelMountedButTransparent test

@vercel

vercel Bot commented May 15, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 16, 2026 2:08am
cmux-staging Building Building Preview, Comment May 16, 2026 2:08am

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented May 15, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Optimizes workspace selection and handoff, centralizes pinned mount computation, makes background priming timeout instance-configurable, triggers handoff on terminal-visibility notifications, updates portal-rendering state reporting, adds related tests, and introduces a workspace-switch performance harness.

Changes

Workspace Handoff, Selection, and Priming

Layer / File(s) Summary
Terminal visibility notification infrastructure
Sources/GhosttyTerminalAppearance.swift, Sources/GhosttyTerminalView.swift
New terminalVisibleInUI notification key and updated .terminalPortalVisibilityDidChange payload including the terminal-visible flag.
Workspace selection short-circuit optimization
Sources/TabManager.swift, cmuxTests/TabManagerUnitTests.swift
selectWorkspace, selectTab, and selectLastTab early-return when the target is already selected to avoid redundant cycle-hot activation; tests validate behavior.
Portal rendering state transition tracking
Sources/Workspace.swift, cmuxTests/WorkspaceUnitTests.swift
Workspace.setPortalRenderingEnabled now returns Bool indicating whether the state changed; tests assert no-op vs transition semantics.
Background workspace priming with configurable timeouts
Sources/BackgroundWorkspacePrimeCoordinator.swift, cmuxTests/WorkspaceUnitTests.swift
Coordinator stores timeoutSeconds per instance, refactors prime completion with a mutable reason and defer-based conditional mount release, and adds a DEBUG-only priming helper plus a timeout regression test.
Workspace mount policy centralization
Sources/ContentView.swift, cmuxTests/WorkspaceUnitTests.swift
WorkspaceMountPolicy.pinnedWorkspaceIds(...) centralizes pinned ID logic (retiring, background loads, debug pins); reconcileMountedWorkspaceIds uses it and tests cover pinning scenarios.
Workspace handoff completion on terminal visibility
Sources/ContentView.swift
ContentView observes .terminalPortalVisibilityDidChange and calls completeWorkspaceHandoffIfNeeded(..., reason: "terminal_visible") when the selected tab's terminal becomes visible.
Workspace switching performance benchmark harness
scripts/perf-workspace-switch.py
New Python runner builds synthetic fixtures, exercises workspace switches via CLI, correlates debug logs for handoff timings, computes metrics (avg/p50/p95/max), applies p95 budgets, and emits JSON/JUnit reports.

Sequence Diagram: high-level handoff & priming flow

sequenceDiagram
  participant User
  participant TabManager
  participant ContentView
  participant BackgroundWorkspacePrimeCoordinator
  participant Workspace
  participant GhosttySurfaceScrollView

  User->>TabManager: selectWorkspace(targetId)
  alt target already selected
    TabManager-->>User: return (no-op)
  else different workspace
    TabManager->>ContentView: activateWorkspaceCycleHotWindow()
    TabManager->>TabManager: set selectedTabId
    TabManager->>BackgroundWorkspacePrimeCoordinator: primeBackgroundWorkspaceIfNeeded(workspaceId)
    BackgroundWorkspacePrimeCoordinator->>Workspace: waitForBackgroundWorkspacePrimeCompletion(timeoutSeconds)
    Note right of BackgroundWorkspacePrimeCoordinator: defer may release mount based on reason
  end
  GhosttySurfaceScrollView->>ContentView: post .terminalPortalVisibilityDidChange (tabId, terminalVisibleInUI)
  ContentView->>ContentView: if selected && terminalVisibleInUI then completeWorkspaceHandoffIfNeeded(reason:"terminal_visible")
  ContentView->>Workspace: setPortalRenderingEnabled(enabled, reason:)
  Workspace-->>ContentView: return Bool (stateChanged)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through tabs and watched them glide,
Noticed terminals show, then handoffs slide,
Mounts time out, tests hum bright,
Benchmarks tick through day and night,
A little rabbit nods — all systems light!


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (5 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error BackgroundWorkspacePrimeCoordinator introduces new Combine usage with .sink() on @Published properties for async flow when async/await alternatives are available. Replace Combine .sink() subscriptions with async/await patterns. Refactor observation into structured concurrency using async iteration or AsyncSequence instead of publishers/subscribers.
Cmux Swift File And Package Boundaries ❌ Error Workspace.swift (14,496 lines) exceeds budget by 521 and mixes networking/UI/state code. GhosttyTerminalView.swift (13,777 lines) exceeds budget by 319 lines. Violates mixed responsibility rule. Extract remote daemon and portal logic from Workspace into packages. Split GhosttyTerminalView by concern. Update budgets only after extraction.
Cmux Swift Logging ❌ Error PR adds 6 new print() statements in GhosttyTerminalView.swift production code without DEBUG guards, violating swift-logging.md rule against print in app/runtime code. Replace print() with Logger or guard with #if DEBUG. Affected: "Failed to initialize ghostty", "Failed to create ghostty config/app", "Ghostty app not initialized", "Failed to create ghostty surface" error messages.
Cmux Swiftui State Layout ❌ Error TabItemView mutates rowHeight state in GeometryReader.onAppear/onChange (render-time mutation forbidden). SelectedWorkspaceDirectoryObserver uses ObservableObject/@published instead of @Observable. Use onGeometryChange instead of GeometryReader for layout. Replace ObservableObject/@published with @Observable.
Cmux Architecture Rethink ❌ Error PR adds terminalPortalVisibilityDidChange observer as 6th separate entry point for handoff completion, expanding symptom-patch architecture rather than establishing single source of truth. Consolidate to one handoff completion path: define invariant for "UI ready" then use only that signal. Remove competing paths (focus, first_responder, visibility). Use timeout as safety net only.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (10 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Actor isolation verified. Main classes @MainActor. @unchecked Sendable classes properly use NSLock. No implicit MainActor models or problematic protocols. UI properly isolated.
Cmux Swift Blocking Runtime ✅ Passed No new blocking synchronization added. All blocking patterns (NSLock, Task.sleep, asyncAfter, semaphores) pre-existed in main. Changes only add timeouts, notifications, guards without blocking.
Cmux No Hacky Sleeps ✅ Passed scripts/perf-workspace-switch.py is test-only benchmarking scaffolding with deterministic sleeps, which is allowed per the rule.
Cmux Swift @Concurrent ✅ Passed No violations of Swift @concurrent annotation rules detected. All async methods in @MainActor classes properly maintain actor isolation with explicit Task annotations.
Cmux User-Facing Error Privacy ✅ Passed No violations found. Production code adds only internal improvements with no new user-facing error messages, alerts, or sensitive data exposure. Test and development code properly excluded.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup declarations detected. The PR modifies workspace coordination and rendering logic without introducing standalone windows.
Title check ✅ Passed The title 'Improve heavy workspace switch latency' directly matches the main objective of this PR, which focuses on reducing workspace switching latency through several performance-related changes.
Description check ✅ Passed The pull request description covers all required template sections with substantial content: summary explains what changed and why, testing details both automated and manual verification steps, and a comprehensive checklist is completed.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-workspace-switch-perf

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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 15, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR improves heavy workspace-switch latency by keeping all visited workspaces mounted (maxMountedWorkspaces = Int.max), suppressing background prime mounts during active handoff/hot cycles, completing handoff earlier via terminal-visibility notifications and z-priority reordering, and making direct workspace selection immediately activate the hot-switch window.

  • Mount policy overhaul: maxMountedWorkspaces changed from 1 to Int.max; isPanelVisible is now always true for mounted panels (transparent via renderOpacity: 0 when not selected); background workspace mounts suppressed when retiringWorkspaceId != nil or isWorkspaceCycleHot.
  • Handoff completion path: retiring workspace no longer gets setPortalRenderingEnabled(false) — instead, prioritizePortalViewsForCurrentRenderedLayout lowers its z-priority to 0 while raising the selected workspace to 2; handoff now also completes when terminalPortalVisibilityDidChange fires for the selected tab.
  • Background prime coordinator: timeout is now injectable for testing; a defer block releases the mount on timeout/cancel while leaving the pending-load ID intact for retry.

Confidence Score: 4/5

Safe to merge with one issue to resolve in the benchmark script before using it in CI.

The Swift-side changes are well-tested and the logic is sound. The benchmark script has a percentile implementation that makes p95 equal to the maximum for the default 20-sample run, and the default 100 ms budget conflicts with the 279.99 ms p95 shown in the PR's own verification output.

scripts/perf-workspace-switch.py — percentile implementation and default budget threshold; Sources/ContentView.swift — the always-true isPanelVisible and unbounded mount cache warrant a second look as workspace counts grow.

Important Files Changed

Filename Overview
Sources/ContentView.swift Core workspace mount/handoff changes: maxMountedWorkspaces raised to Int.max, isPanelVisible now always true for mounted panels, background mounts suppressed during active handoff, handoff now uses z-priority instead of portal-rendering disable on retiring workspace.
Sources/BackgroundWorkspacePrimeCoordinator.swift Adds injectable timeout, defer-based mount release on timeout/cancel keeping pending-load IDs intact, and a #if DEBUG testing entry point.
Sources/TabManager.swift Guards added to selectWorkspace, selectTab(at:), and selectLastTab to short-circuit re-selection; activateWorkspaceCycleHotWindow() now called on direct selection.
Sources/Workspace.swift Adds prioritizePortalViewsForCurrentRenderedLayout(zPriority:) for z-ordering portal views during handoff; refactors setPortalRenderingEnabled to return Bool with early-return guard.
scripts/perf-workspace-switch.py New benchmark runner with a percentile function that makes p95 equal to the maximum for the default 20-sample run; default 100 ms budget inconsistent with the PR's reported 279.99 ms p95.

Reviews (3): Last reviewed commit: "Speed warm workspace switches" | Re-trigger Greptile

Comment on lines +384 to +394
def read_debug_lines(self, offset: int, timeout_s: float) -> tuple[list[str], int]:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if self.debug_log_path.exists() and self.debug_log_path.stat().st_size > offset:
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
return data[offset:].splitlines(), len(data)
time.sleep(0.01)
if self.debug_log_path.exists():
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
return data[offset:].splitlines(), len(data)
return [], offset

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Byte/character offset mismatch: stat().st_size returns a byte count but len(data) returns a character count, and data[offset:] slices by character. If the debug log ever contains a non-ASCII character (e.g., a Unicode workspace name), the bookmark drifts — subsequent calls see st_size > offset as false prematurely or skip/re-parse lines, potentially producing handoff_ms: None for a run without a real missing handoff event. Open the file in binary mode and track byte offsets throughout to keep the two consistent.

Suggested change
def read_debug_lines(self, offset: int, timeout_s: float) -> tuple[list[str], int]:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if self.debug_log_path.exists() and self.debug_log_path.stat().st_size > offset:
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
return data[offset:].splitlines(), len(data)
time.sleep(0.01)
if self.debug_log_path.exists():
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
return data[offset:].splitlines(), len(data)
return [], offset
def read_debug_lines(self, offset: int, timeout_s: float) -> tuple[list[str], int]:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if self.debug_log_path.exists() and self.debug_log_path.stat().st_size > offset:
raw = self.debug_log_path.read_bytes()
chunk = raw[offset:].decode("utf-8", errors="replace")
return chunk.splitlines(), len(raw)
time.sleep(0.01)
if self.debug_log_path.exists():
raw = self.debug_log_path.read_bytes()
chunk = raw[offset:].decode("utf-8", errors="replace")
return chunk.splitlines(), len(raw)
return [], offset

Comment thread Sources/TabManager.swift
Comment on lines 4400 to 4412
func selectWorkspace(_ workspace: Workspace) {
guard selectedTabId != workspace.id else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The debugPrimeWorkspaceSwitchTrigger call appears in both the early-return branch and the fall-through branch with identical arguments. The function already guards on selectedTabId != target internally — calling it once, before the guard, produces the same effect in both paths and removes the duplication. As written, a future reader has to know the internal logic of debugPrimeWorkspaceSwitchTrigger to confirm the two calls aren't accidentally doing different things.

Suggested change
func selectWorkspace(_ workspace: Workspace) {
guard selectedTabId != workspace.id else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}
func selectWorkspace(_ workspace: Workspace) {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
guard selectedTabId != workspace.id else { return }
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}

Comment thread Sources/TabManager.swift
Comment on lines 5462 to 5476
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
guard selectedTabId != tabId else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
selectedTabId = tabs[index].id
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same duplicated debug-trigger pattern as selectWorkspace. Since debugPrimeWorkspaceSwitchTrigger guards internally on selectedTabId != target, hoisting the call above the guard covers both paths with one block.

Suggested change
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
guard selectedTabId != tabId else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
selectedTabId = tabs[index].id
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
guard selectedTabId != tabId else { return }
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@scripts/perf-workspace-switch.py`:
- Around line 219-224: The current raise of PerfFailure in
scripts/perf-workspace-switch.py exposes raw CLI stdout/stderr (when
proc.returncode != 0), which must be sanitized; change the PerfFailure message
to a short, generic error that includes the failing command (from args) and the
return code only, and instruct operators to consult local logs for full output
instead of embedding proc.stdout/proc.stderr in the exception; ensure related
call sites like run_cli and other raised PerfFailure occurrences (e.g., the
blocks around lines 516-517 and 580-588) follow the same pattern and that the
full proc output is written to secure local logs (not the exception) for
debugging.
- Around line 455-465: Detect when the measured list is empty and fail the
benchmark immediately instead of computing summaries: in the block that builds
self.result["measurements"]["workspace_switch"] (where measured, cli_values,
handoff_values, async_values and summary(...) are used), add a guard like if not
measured: raise RuntimeError("no workspace switch measurements collected") (or
set an explicit failure marker used by apply_budgets()), so a misconfigured run
(e.g. --measure-passes 0) cannot silently pass; ensure the check runs before
calling summary() for cli_roundtrip/handoff/async_done and before computing
missing_handoff_samples.
- Around line 109-113: clean_persisted_state only deletes tag-scoped session
files but the launched app/CLI can still inherit ambient CMUX_* values; ensure
full isolation by (1) when launching the app and any CLI subprocesses, inject
the derived tag-scoped environment variables CMUX_TAG and CMUX_BUNDLE_ID (use
the existing bundle_id/tag_id values) into the subprocess env and (2)
scrub/unset any other ambient CMUX_* keys from that subprocess env so it cannot
attach to an existing session; also extend clean_persisted_state to remove both
the tag-scoped session files and any non-tagged session files that could be
reused, and apply these env-injection/scrubbing changes in the code paths that
start the app/CLI (refer to clean_persisted_state, tag_id, and bundle_id to
locate the logic).
🪄 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: 4662e844-5fe7-417a-83c2-10c100d325c2

📥 Commits

Reviewing files that changed from the base of the PR and between fd4899e and 7d26280.

📒 Files selected for processing (9)
  • Sources/BackgroundWorkspacePrimeCoordinator.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalAppearance.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WorkspaceUnitTests.swift
  • scripts/perf-workspace-switch.py

Comment thread scripts/perf-workspace-switch.py
Comment on lines +219 to +224
if check and proc.returncode != 0:
raise PerfFailure(
"cmux command failed: "
+ " ".join(args)
+ f"\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop surfacing raw CLI stdout/stderr in failures.

The exception text from run_cli() is copied into result["failures"], written to JUnit/JSON, printed, and then re-thrown. That makes any raw CLI error body user-visible, which is exactly the kind of upstream/internal leakage the review rules prohibit. Emit a short sanitized failure here and point operators to the local logs for full details instead.

Suggested fix
         if check and proc.returncode != 0:
             raise PerfFailure(
-                "cmux command failed: "
-                + " ".join(args)
-                + f"\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
+                f"cmux command failed: {' '.join(args)}. "
+                f"See {self.stdout_path} and {self.debug_log_path} for details."
             )

As per coding guidelines, "user-facing errors, alerts, command output, API error bodies, or recovery copy must not expose upstream vendor names, internal provider names, provider-specific flags, templates, snapshots, manifests, environment variables, database or migration details, raw upstream messages, billing item ids, billing customer ids, unrelated team ids, credentials, tokens, headers, private keys, refresh tokens, session ids, or unredacted payload dumps".

Also applies to: 516-517, 580-588

🤖 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 `@scripts/perf-workspace-switch.py` around lines 219 - 224, The current raise
of PerfFailure in scripts/perf-workspace-switch.py exposes raw CLI stdout/stderr
(when proc.returncode != 0), which must be sanitized; change the PerfFailure
message to a short, generic error that includes the failing command (from args)
and the return code only, and instruct operators to consult local logs for full
output instead of embedding proc.stdout/proc.stderr in the exception; ensure
related call sites like run_cli and other raised PerfFailure occurrences (e.g.,
the blocks around lines 516-517 and 580-588) follow the same pattern and that
the full proc output is written to secure local logs (not the exception) for
debugging.

Comment on lines +455 to +465
measured = [s for s in samples if s["phase"] == "measure"]
cli_values = [float(s["cli_roundtrip_ms"]) for s in measured]
handoff_values = [float(s["handoff_ms"]) for s in measured if s["handoff_ms"] is not None]
async_values = [float(s["async_done_ms"]) for s in measured if s["async_done_ms"] is not None]
self.result["measurements"]["workspace_switch"] = {
"samples": measured,
"cli_roundtrip": summary(cli_values),
"handoff": summary(handoff_values),
"async_done": summary(async_values),
"missing_handoff_samples": len(measured) - len(handoff_values),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail the benchmark when no measured switches were collected.

An empty measured set currently becomes summary([]) == 0 everywhere, and apply_budgets() will report a pass because missing_handoff_samples is also 0. A misconfigured run like --measure-passes 0 would therefore go green with no benchmark data.

Suggested fix
         measured = [s for s in samples if s["phase"] == "measure"]
+        if not measured:
+            raise PerfFailure("workspace switch benchmark collected no measured samples")
         cli_values = [float(s["cli_roundtrip_ms"]) for s in measured]
         handoff_values = [float(s["handoff_ms"]) for s in measured if s["handoff_ms"] is not None]
         async_values = [float(s["async_done_ms"]) for s in measured if s["async_done_ms"] is not None]
         self.result["measurements"]["workspace_switch"] = {
             "samples": measured,

Also applies to: 467-486

🤖 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 `@scripts/perf-workspace-switch.py` around lines 455 - 465, Detect when the
measured list is empty and fail the benchmark immediately instead of computing
summaries: in the block that builds
self.result["measurements"]["workspace_switch"] (where measured, cli_values,
handoff_values, async_values and summary(...) are used), add a guard like if not
measured: raise RuntimeError("no workspace switch measurements collected") (or
set an explicit failure marker used by apply_budgets()), so a misconfigured run
(e.g. --measure-passes 0) cannot silently pass; ensure the check runs before
calling summary() for cli_roundtrip/handoff/async_done and before computing
missing_handoff_samples.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/perf-workspace-switch.py">

<violation number="1" location="scripts/perf-workspace-switch.py:41">
P2: The percentile index formula is off by one for p95 and often returns the max sample instead of the 95th percentile, skewing reported latency and budget checks.</violation>

<violation number="2" location="scripts/perf-workspace-switch.py:389">
P2: Use byte-based reads and offsets in `read_debug_lines`. Mixing `st_size` byte offsets with decoded string indices can desynchronize log scanning on non-ASCII output and miss handoff completion events.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

if not sorted_values:
return 0.0
clamped = min(max(pct, 0.0), 1.0)
index = int(((len(sorted_values) - 1) * clamped) + 0.999999)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The percentile index formula is off by one for p95 and often returns the max sample instead of the 95th percentile, skewing reported latency and budget checks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/perf-workspace-switch.py, line 41:

<comment>The percentile index formula is off by one for p95 and often returns the max sample instead of the 95th percentile, skewing reported latency and budget checks.</comment>

<file context>
@@ -0,0 +1,606 @@
+    if not sorted_values:
+        return 0.0
+    clamped = min(max(pct, 0.0), 1.0)
+    index = int(((len(sorted_values) - 1) * clamped) + 0.999999)
+    return sorted_values[min(len(sorted_values) - 1, max(0, index))]
+
</file context>
Suggested change
index = int(((len(sorted_values) - 1) * clamped) + 0.999999)
index = max(0, min(len(sorted_values) - 1, int((len(sorted_values) * clamped) + 0.999999) - 1))

while time.monotonic() < deadline:
if self.debug_log_path.exists() and self.debug_log_path.stat().st_size > offset:
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
return data[offset:].splitlines(), len(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use byte-based reads and offsets in read_debug_lines. Mixing st_size byte offsets with decoded string indices can desynchronize log scanning on non-ASCII output and miss handoff completion events.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/perf-workspace-switch.py, line 389:

<comment>Use byte-based reads and offsets in `read_debug_lines`. Mixing `st_size` byte offsets with decoded string indices can desynchronize log scanning on non-ASCII output and miss handoff completion events.</comment>

<file context>
@@ -0,0 +1,606 @@
+        while time.monotonic() < deadline:
+            if self.debug_log_path.exists() and self.debug_log_path.stat().st_size > offset:
+                data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")
+                return data[offset:].splitlines(), len(data)
+            time.sleep(0.01)
+        if self.debug_log_path.exists():
</file context>

Comment thread Sources/ContentView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
scripts/perf-workspace-switch.py (3)

219-224: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize CLI failure messages to avoid exposing raw output.

The exception includes raw proc.stdout and proc.stderr, which could leak environment variables, internal paths, or upstream error messages. Per coding guidelines, user-facing errors must not expose raw upstream messages.

Proposed fix
         if check and proc.returncode != 0:
             raise PerfFailure(
-                "cmux command failed: "
-                + " ".join(args)
-                + f"\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
+                f"cmux command failed (exit {proc.returncode}): {' '.join(args)}. "
+                f"See {self.debug_log_path} for details."
             )

As per coding guidelines, "user-facing errors, alerts, command output, API error bodies, or recovery copy must not expose upstream vendor names, internal provider names, ... raw upstream messages."

🤖 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 `@scripts/perf-workspace-switch.py` around lines 219 - 224, The PerfFailure
raised in the block that checks "check and proc.returncode != 0" currently
includes raw proc.stdout and proc.stderr which can leak sensitive/internal info;
change the error to include only a sanitized summary (e.g., a short, redacted
message or first N characters with ellipsis, or a standardized failure token)
and include the full proc output only in a debug/internal log; update the raise
PerfFailure(...) call (referencing PerfFailure, proc.returncode, args,
proc.stdout, proc.stderr) to replace raw outputs with the sanitized summary and
ensure any detailed output is logged via a non-user-facing logger or attached to
diagnostics only.

455-465: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against empty measurement set to prevent silent pass.

If measured is empty (e.g., --measure-passes 0 or fixture creation fails silently), all summaries return zeros and apply_budgets() reports a pass with no actual benchmark data.

Proposed fix
         measured = [s for s in samples if s["phase"] == "measure"]
+        if not measured:
+            raise PerfFailure("workspace switch benchmark collected no measured samples")
         cli_values = [float(s["cli_roundtrip_ms"]) for s in measured]
🤖 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 `@scripts/perf-workspace-switch.py` around lines 455 - 465, The code currently
computes summaries from measured (the list comprehension filtering samples by
phase) and writes zero summaries when measured is empty; update the workspace
switch measurement block to explicitly handle an empty measured set: after
computing measured (variable name measured) check if not measured and then raise
a clear exception or set self.result["measurements"]["workspace_switch"] to
indicate no data (and ensure apply_budgets sees this as a failure) instead of
calling summary(...) on empty lists; reference the summary(...) calls and the
keys "cli_roundtrip", "handoff", "async_done", and "missing_handoff_samples" so
you modify that block to short-circuit and produce an error/result flag when
measured is empty.

153-158: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CLI environment lacks ambient variable scrubbing.

app_env() correctly scrubs ambient CMUX_* variables (lines 124-141), but cli_env() only adds a few variables without first removing inherited ones. If this script runs from within an existing cmux shell, CLI commands may inadvertently connect to the wrong session.

Proposed fix
     def cli_env(self) -> dict[str, str]:
         env = os.environ.copy()
+        for key in (
+            "CMUX_SOCKET",
+            "CMUX_SOCKET_PATH",
+            "CMUX_SOCKET_MODE",
+            "CMUX_TAB_ID",
+            "CMUX_PANEL_ID",
+            "CMUX_SURFACE_ID",
+            "CMUX_WORKSPACE_ID",
+            "CMUXD_UNIX_PATH",
+            "CMUX_TAG",
+            "CMUX_PORT",
+            "CMUX_PORT_END",
+            "CMUX_PORT_RANGE",
+            "CMUX_DEBUG_LOG",
+            "CMUX_BUNDLE_ID",
+            "CMUX_UI_TEST_MODE",
+        ):
+            env.pop(key, None)
         env["CMUX_SOCKET"] = str(self.socket_path)
         env["CMUX_SOCKET_PATH"] = str(self.socket_path)
+        env["CMUXD_UNIX_PATH"] = str(self.cmuxd_socket_path)
         env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = "30"
         return env
🤖 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 `@scripts/perf-workspace-switch.py` around lines 153 - 158, The cli_env()
function copies the current environment but fails to scrub ambient CMUX_*
variables first, which can cause CLI commands to bind to an existing cmux
session; update cli_env() to mirror app_env() by creating env =
os.environ.copy(), removing any inherited keys that start with "CMUX_" (or the
same explicit list used in app_env()), then set/override env["CMUX_SOCKET"],
env["CMUX_SOCKET_PATH"], and env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"]="30"
before returning env so the CLI uses only the intended socket and settings.
🤖 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 `@scripts/perf-workspace-switch.py`:
- Around line 219-224: The PerfFailure raised in the block that checks "check
and proc.returncode != 0" currently includes raw proc.stdout and proc.stderr
which can leak sensitive/internal info; change the error to include only a
sanitized summary (e.g., a short, redacted message or first N characters with
ellipsis, or a standardized failure token) and include the full proc output only
in a debug/internal log; update the raise PerfFailure(...) call (referencing
PerfFailure, proc.returncode, args, proc.stdout, proc.stderr) to replace raw
outputs with the sanitized summary and ensure any detailed output is logged via
a non-user-facing logger or attached to diagnostics only.
- Around line 455-465: The code currently computes summaries from measured (the
list comprehension filtering samples by phase) and writes zero summaries when
measured is empty; update the workspace switch measurement block to explicitly
handle an empty measured set: after computing measured (variable name measured)
check if not measured and then raise a clear exception or set
self.result["measurements"]["workspace_switch"] to indicate no data (and ensure
apply_budgets sees this as a failure) instead of calling summary(...) on empty
lists; reference the summary(...) calls and the keys "cli_roundtrip", "handoff",
"async_done", and "missing_handoff_samples" so you modify that block to
short-circuit and produce an error/result flag when measured is empty.
- Around line 153-158: The cli_env() function copies the current environment but
fails to scrub ambient CMUX_* variables first, which can cause CLI commands to
bind to an existing cmux session; update cli_env() to mirror app_env() by
creating env = os.environ.copy(), removing any inherited keys that start with
"CMUX_" (or the same explicit list used in app_env()), then set/override
env["CMUX_SOCKET"], env["CMUX_SOCKET_PATH"], and
env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"]="30" before returning env so the CLI
uses only the intended socket and settings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5e44eb5e-2a8b-4d43-95c8-b6bcc19b73b0

📥 Commits

Reviewing files that changed from the base of the PR and between 7d26280 and 8e6f5d8.

📒 Files selected for processing (2)
  • Sources/ContentView.swift
  • scripts/perf-workspace-switch.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/perf-workspace-switch.py">

<violation number="1" location="scripts/perf-workspace-switch.py:568">
P2: The new default handoff budget (`100ms`) is too strict for default runs and can cause the benchmark to fail by default.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic


parser.add_argument("--launch-timeout", type=float, default=45)
parser.add_argument("--switch-log-timeout", type=float, default=5)
parser.add_argument("--budget-handoff-p95-ms", type=float, default=100)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new default handoff budget (100ms) is too strict for default runs and can cause the benchmark to fail by default.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/perf-workspace-switch.py, line 568:

<comment>The new default handoff budget (`100ms`) is too strict for default runs and can cause the benchmark to fail by default.</comment>

<file context>
@@ -565,8 +565,8 @@ def parse_args() -> argparse.Namespace:
     parser.add_argument("--switch-log-timeout", type=float, default=5)
-    parser.add_argument("--budget-handoff-p95-ms", type=float, default=300)
-    parser.add_argument("--budget-cli-roundtrip-p95-ms", type=float, default=450)
+    parser.add_argument("--budget-handoff-p95-ms", type=float, default=100)
+    parser.add_argument("--budget-cli-roundtrip-p95-ms", type=float, default=0)
     return parser.parse_args()
</file context>
Suggested change
parser.add_argument("--budget-handoff-p95-ms", type=float, default=100)
parser.add_argument("--budget-handoff-p95-ms", type=float, default=300)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 323c736. Configure here.

)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser portal missing becameVisible reparenting check

Low Severity

BrowserWindowPortal.updateEntryVisibility only reparents the container view to the front when priorityIncreased is true, but TerminalWindowPortal.updateEntryVisibility reparents on becameVisible || priorityIncreased. This means a browser portal entry that transitions from invisible to visible without a z-priority increase won't be brought to the top of the subview hierarchy, unlike its terminal counterpart. While current callers generally change both visibility and priority together, this inconsistency could cause stale browser z-ordering in future code paths.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 323c736. Configure here.

Comment thread Sources/ContentView.swift
let didPrioritizeSelected = selected.flatMap { selectedId in
tabManager.tabs.first(where: { $0.id == selectedId })?
.prioritizePortalViewsForCurrentRenderedLayout(zPriority: 2, reason: "workspaceHandoff.selected")
} ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug-only variable usage causes Release build warnings

Low Severity

didLowerRetiring and didPrioritizeSelected are computed unconditionally but only read inside the #if DEBUG block. In Release builds, these let bindings are never read, which generates "immutable value was never used" compiler warnings. The side-effect calls to prioritizePortalViewsForCurrentRenderedLayout are needed in all configurations, but the return value capture creates unnecessary noise.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 323c736. Configure here.

Comment thread Sources/TabManager.swift
func selectLastTab() {
guard let lastTab = tabs.last else { return }
guard selectedTabId != lastTab.id else { return }
activateWorkspaceCycleHotWindow()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selectLastTab missing debug workspace switch tracking

Low Severity

selectLastTab now calls activateWorkspaceCycleHotWindow() (consistent with selectWorkspace and selectTab(at:)), but unlike those methods it omits the debugPrimeWorkspaceSwitchTrigger call. This means workspace-switch debug tracking and benchmark instrumentation won't capture switches triggered via selectLastTab, making the performance telemetry inconsistent across selection paths.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 323c736. Configure here.

Comment on lines +779 to +783
raise

if args.output:
output = pathlib.Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 percentile at p95 equals max for 20 samples; default budget inconsistent with reported results

The percentile function adds 0.999999 before truncating, which rounds every fractional index up to the next integer. For the default run (6 workspaces, 2 measure passes = 20 measured switches): int((20-1) * 0.95 + 0.999999) = int(19.05) = 19 → sorted_values[19] = the maximum value. So p95_ms is always the worst sample, not the 95th-percentile sample.

Separately, the PR description reports a verified handoff p95 of 279.99 ms, but the default --budget-handoff-p95-ms is 100 ms. Running the script as documented (--measure-passes 2 --workspace-count 6) would produce a budget failure on the very benchmark the PR claims passes. The verification must have used a custom budget that isn't shown in the command, leaving the default misleading for future users.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/BrowserWindowPortal.swift">

<violation number="1" location="Sources/BrowserWindowPortal.swift:2842">
P3: Include hidden→visible transitions in the reparent condition, not just z-priority increases. Otherwise a browser portal entry can become visible but remain behind older siblings with stale z-order.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic

entriesByWebViewId[webViewId] = entry
let priorityIncreased = zPriority > previousZPriority
if visibleInUI,
priorityIncreased,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Include hidden→visible transitions in the reparent condition, not just z-priority increases. Otherwise a browser portal entry can become visible but remain behind older siblings with stale z-order.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/BrowserWindowPortal.swift, line 2842:

<comment>Include hidden→visible transitions in the reparent condition, not just z-priority increases. Otherwise a browser portal entry can become visible but remain behind older siblings with stale z-order.</comment>

<file context>
@@ -2823,9 +2833,24 @@ final class WindowBrowserPortal: NSObject {
         entriesByWebViewId[webViewId] = entry
+        let priorityIncreased = zPriority > previousZPriority
+        if visibleInUI,
+           priorityIncreased,
+           let containerView = entry.containerView,
+           containerView.superview === hostView,
</file context>

Tip: Review your code locally with the cubic CLI to iterate faster.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

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

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants