Skip to content

Speed up iOS terminal scroll rendering#6035

Open
azooz2003-bit wants to merge 2 commits into
mainfrom
task-fast-ios-terminal-scroll
Open

Speed up iOS terminal scroll rendering#6035
azooz2003-bit wants to merge 2 commits into
mainfrom
task-fast-ios-terminal-scroll

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds per-surface ACK-based terminal output backpressure for the iOS Ghostty mirror.
  • Coalesces obsolete full-viewport render-grid frames before VT synthesis, while preserving raw byte barriers for fallback hosts.
  • Removes noisy scroll debug logs from the gesture hot path and updates test collectors to ACK consumed output.

Testing

  • swift test --package-path Packages/CmuxMobileShell passed, 99 Swift Testing tests.
  • ios/scripts/reload.sh --tag scrl passed for iPhone 17 simulator, bundle dev.cmux.ios.scrl.
  • Launched simulator bundle and captured screenshot at cmux-assets/task-fast-ios-terminal-scroll/ios-simulator/20260612-223448/cmux-scrl-final.png.

Notes

  • Physical iPhone reload was not run because connected devices are currently reported unavailable by xcrun devicectl list devices.
  • swift test --package-path ios/cmuxPackage and swift test --package-path Packages/CmuxMobileTerminal are blocked by existing SwiftPM platform-manifest resolution errors: the UI/terminal libraries default to macOS 10.13 while dependencies require macOS 14.0.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Speeds up iOS terminal scrolling by adding per-surface ACK-based output backpressure and collapsing obsolete viewport frames during fast gestures. Also removes scroll debug logs from the hot path.

  • New Features

    • Added a per-surface output delivery queue with ACKs. Only one chunk in flight; replaceable render-grid viewport patches coalesce while older output applies.
    • Deliver render-grid frames directly and synthesize VT bytes at yield time; raw PTY bytes remain nonreplaceable barriers.
    • Exposed processOutputAndWait on the iOS surface to await application; the UI now awaits and ACKs via terminalOutputDidProcess.
    • Added tests for queue behavior and updated collectors to ACK; moved hardware key handling into TerminalHardwareKeyResolver to satisfy file length limits.
  • Migration

    • If you consume terminalOutputStream directly, call terminalOutputDidProcess(surfaceID:) after applying each chunk. GhosttySurfaceRepresentable already does this.

Written for commit 5e8a16d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Backpressure-aware, per-surface terminal output queuing for smoother delivery
    • Render-grid frames delivered directly and viewport updates coalesced to avoid redundant work
    • Basic hardware-key (external keyboard) support and key-mapping helpers
  • Behavior Changes

    • Surfaces now await processing of each output chunk and must acknowledge processed chunks to advance queues
  • Tests

    • New unit tests covering delivery queue ordering, coalescing and replaceability semantics
  • Documentation

    • Updated docs describing the per-chunk acknowledgement requirement

@vercel

vercel Bot commented Jun 13, 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 13, 2026 5:53am
cmux-staging Building Building Preview, Comment Jun 13, 2026 5:53am

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Per-surface terminal output is now queued with backpressure and optional replaceable render-grid coalescing. UI surfaces await processing and call terminalOutputDidProcess(surfaceID:) to advance queues; replay and live render-grid events are routed through the queue instead of falling back to raw-byte delivery.

Changes

Terminal Output Queuing System

Layer / File(s) Summary
Queue infrastructure & replaceability logic
Packages/CmuxMobileShell/Sources/.../TerminalOutputDelivery.swift, Packages/CmuxMobileShell/Sources/.../MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift, Packages/CmuxMobileShell/Tests/.../TerminalOutputDeliveryQueueTests.swift
TerminalOutputDelivery wraps raw bytes or render-grid frames with replaceable metadata; TerminalOutputDeliveryQueue implements single-stream backpressure with pending coalescing and compaction; isReplaceableViewportPatchForMobileDelivery decides when a render-grid delta is replaceable.
Output sinking protocol update
Packages/CmuxMobileShellModel/Sources/.../MobileTerminalOutputSinking.swift
Adds @MainActor func terminalOutputDidProcess(surfaceID: String) and updates docs to require per-chunk acknowledgement after processing.
MobileShellComposite queue lifecycle & delivery routing
Packages/CmuxMobileShell/Sources/.../MobileShellComposite.swift, Packages/CmuxMobileShell/Sources/.../MobileShellComposite+TerminalOutputDelivery.swift
Adds per-surface terminalOutputQueuesBySurfaceID lifecycle, deliverTerminalBytes/deliverTerminalRenderGrid, private deliverTerminalOutput dispatcher, and terminalOutputDidProcess(surfaceID:) to advance queues; replay/live render-grid paths route frames through the queue.
GhosttySurfaceView output API & minor cleanup
Packages/CmuxMobileTerminal/Sources/.../GhosttySurfaceView.swift
Adds processOutputAndWait(_:) async and internal processOutput(_:completion:), ensures completion is always invoked, removes scroll debug logging and file-local key mapper types.
Stream consumption and acknowledgement loop
Packages/CmuxMobileShellUI/Sources/.../GhosttySurfaceRepresentable.swift, Packages/CmuxMobileShell/Tests/.../MobileShellRenderGridLivenessTestSupport.swift, ios/cmuxPackage/Tests/.../cmuxFeatureTests.swift
Updates stream consumer to await per-chunk processing via processOutputAndWait(data) and call store.terminalOutputDidProcess(surfaceID:) after each chunk; tests updated to signal acknowledgement.
UIKit hardware key resolver
Packages/CmuxMobileTerminal/Sources/.../TerminalHardwareKeyCommand.swift, Packages/CmuxMobileTerminal/Sources/.../TerminalHardwareKeyResolver.swift
Adds UIKit-only TerminalHardwareKeyCommand and TerminalHardwareKeyResolver to build UIKeyCommand lists and encode key inputs+modifiers to terminal Data.

Sequence Diagram

sequenceDiagram
  participant StreamSource as terminalOutputStream
  participant Coordinator as GhosttySurfaceRepresentable
  participant View as GhosttySurfaceView
  participant Store as MobileShellComposite
  participant Queue as TerminalOutputDeliveryQueue
  StreamSource->>Coordinator: emit data chunk
  Coordinator->>View: await processOutputAndWait(data)
  View->>View: main-actor UI updates (render grid / bytes)
  View-->>Coordinator: return from await
  Coordinator->>Store: terminalOutputDidProcess(surfaceID)
  Store->>Queue: completeInFlight()
  Queue-->>Store: next delivery bytes (if any)
  Store-->>Coordinator: yield next bytes via continuation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#5869: Changes render-grid consumption and acknowledgement paths, related to queue advancement and delivery routing.

Suggested reviewers

  • jesstelford

Poem

🐰 A queue hops in with backpressure grace,
Render grids coalesce in their rightful place.
Each chunk acknowledged, the next one freed,
Frames and bytes align with careful speed.
Hooray — the mobile terminal hums indeed!


Important

Pre-merge checks failed

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

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Concurrency ❌ Error New GhosttySurfaceView.processOutputAndWait uses custom background DispatchQueue (outputQueue.async) and DispatchQueue.main.async hops, matching forbidden legacy async patterns. Refactor the new async path to avoid custom DispatchQueue usage (use Task/actors/@mainactor for hops) and keep completion bridging only where unavoidable.
Cmux Swift Logging ❌ Error MobileShellComposite.swift has @MainActor and a file-scoped private let mobileShellLog = Logger(...) without nonisolated, violating .github/review-bot-rules/swift-logging.md. Update the logger declaration to nonisolated private let mobileShellLog = Logger(...) (or otherwise move it to a nonisolated scope), per swift-logging.md.
Cmux Swiftui State Layout ❌ Error GhosttySurfaceRepresentable.makeUIView calls coordinator.attach, which creates Task { @MainActor ... } that processes terminalOutputStream and calls store.terminalOutputDidProcess—render-time state... Avoid starting/awaiting terminalOutputStream with @MainActor Tasks during makeUIView/updateUIView; move the stream consumption/ACK into GhosttySurfaceView (e.g., didMoveToWindow) or a non-render lifecycle callback.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (17 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: optimizing iOS terminal scroll rendering through backpressure and frame coalescing.
Description check ✅ Passed The PR description covers the summary of changes, testing performed, and notes on limitations. All required template sections are addressed with substantive detail.
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 Reviewed production changes: MobileShellComposite is @MainActor, MobileTerminalOutputSinking methods (incl. terminalOutputDidProcess) are @MainActor, and GhosttySurfaceRepresentable calls them from...
Cmux Swift Blocking Runtime ✅ Passed PR production Swift changes do not introduce or materially expand blocking/timing primitives (no new DispatchSemaphore/waits, Task.sleep, asyncAfter, main.sync, NSLock/pthread locks, or polling wai...
Cmux Expensive Synchronous Load ✅ Passed PR-touched iOS/CMuxMobileShell Swift files contain no RestorableAgentSessionIndex.load/ sysctl calls. Existing RestorableAgentSessionIndex.load call sites elsewhere use SharedLiveAgentIndex.shared...
Cmux Cache Substitution Correctness ✅ Passed In MobileShellComposite, new per-surface output queue is in-memory and cleared on resetTerminalOutputTracking; render-grid replay/live delivery uses seq freshness guards and doesn’t replace persist...
Cmux No Hacky Sleeps ✅ Passed PR #6035 modifies only Swift sources/tests (no TS/JS/shell/build/runtime script files); therefore no production “hacky sleeps” were added.
Cmux Algorithmic Complexity ✅ Passed Queueing uses constant-time head/tail ops with bounded compaction (headIndex>32) and no full scans; replay render_grid bypasses deliverBytes (deliverBytes=nil -> deliverTerminalRenderGrid + early r...
Cmux Swift @Concurrent ✅ Passed Searches found @concurrent/nonisolated async only in CmuxGit files; none in the PR’s Swift changes (incl. GhosttySurfaceView.processOutputAndWait), so no swift-concurrent-annotation violations dete...
Cmux Swift File And Package Boundaries ✅ Passed New production code is split into focused small Swift files (<400 LOC, e.g., TerminalOutputDelivery.swift 96), and it lives in SwiftPM library targets (CmuxMobileShell/CmuxMobileTerminal) rather th...
Cmux User-Facing Error Privacy ✅ Passed In git diff main..HEAD, added lines in the changed production Swift files include no user-facing alerts/errors (0 hits for UIAlert/present/show/localizedDescription) and no added sensitive string l...
Cmux Full Internationalization ✅ Passed Touched code is terminal output queueing/ACK plumbing; no production UI/alert/menu strings, Info.plist, or web next-intl content changes detected. No i18n catalog edits.
Cmux Architecture Rethink ✅ Passed New iOS delivery adds per-surface ACK queueing via AsyncStream continuations (no sleeps/polling/locks/observers in production paths); queue coalescing is unit-tested.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Changed files are terminal output delivery/ACK and render-grid logic only; no NSWindow/NSPanel/NSWindowController/SwiftUI Window/WindowGroup or cmuxAuxiliaryWindowIdentifiers identifiers added (0 h...
Cmux Source Artifacts ✅ Passed PR #6035 changed paths are only 12 .swift source/test files (per GitHub files page); none match the prohibited artifact categories (logs/screenshots/temp/DerivedData) in source-control-artifacts.md.
✨ 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 task-fast-ios-terminal-scroll

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 Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds per-surface ACK-based backpressure to the iOS terminal output path. A new TerminalOutputDeliveryQueue coalesces replaceable full-viewport render-grid frames during fast scrolling so only the latest frame is sent while the iOS surface is still processing the prior chunk, and raw PTY byte chunks remain ordered barriers that are never coalesced.

  • New queue + delivery layer: TerminalOutputDelivery and TerminalOutputDeliveryQueue (value types, Sendable) replace a direct continuation.yield with an enqueue/ACK protocol; GhosttySurfaceRepresentable now awaits processOutputAndWait and ACKs via terminalOutputDidProcess(surfaceID:).
  • File splits: TerminalHardwareKeyCommand and TerminalHardwareKeyResolver are moved to their own files; scroll-pan debug logs are removed from the gesture hot path.
  • Test coverage: Six new @Test functions cover queue idle delivery, viewport coalescing, barrier preservation, raw-backlog ordering, and the replaceability predicate; both existing test collectors are updated to ACK consumed output.

Confidence Score: 4/5

The backpressure queue logic and async handshake are structurally sound; the open items from prior reviews (stale-ACK after reconnect reset, double-ACK guard, ACK-when-skipped) remain unresolved and are the main reason to hold back from merging without a second look at the reset path.

The queue invariants are well-tested and the processOutputAndWait continuation is correctly guarded against leaks. The one new finding — a dropped guard !bytes.isEmpty — is low-impact in practice but removes a cheap safety net. The more substantive concerns flagged in earlier review rounds are still open; if those land during a fast reconnect they would transiently defeat the one-in-flight guarantee this PR is designed to provide.

The interaction between resetTerminalOutputTracking in MobileShellComposite.swift and the new queue in MobileShellComposite+TerminalOutputDelivery.swift deserves a second look before merge.

Important Files Changed

Filename Overview
Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift New backpressure queue implementation. Logic is sound; reset() method is defined but never called by the reset path in MobileShellComposite.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift New extension handling delivery through the queue. Removes the previous guard !bytes.isEmpty guard from the live render-grid path without a replacement check.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds two internal dictionaries for continuations and queues. resetTerminalOutputTracking clears the queue dict without resetting continuations, creating a stale-ACK window (flagged in prior review).
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift Updated to await processOutputAndWait and ACK unconditionally. ACK-when-skipped (nil surfaceView) was flagged in a prior review thread.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Adds processOutputAndWait wrapping the existing background-queue processing with a checked continuation; all code paths call the completion, so no hang risk.
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift Protocol updated to include terminalOutputDidProcess(surfaceID:); clean additive change.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift Comprehensive new tests covering idle delivery, viewport coalescing, barrier preservation, raw-backlog ordering, and the replaceability predicate.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift Trivial file split — moves TerminalHardwareKeyCommand struct to its own file.
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift Trivial file split — moves TerminalHardwareKeyResolver to its own file; supportedModifierFlags was already unused before the PR.

Sequence Diagram

sequenceDiagram
    participant Mac as Mac Host
    participant MSC as MobileShellComposite
    participant Q as DeliveryQueue
    participant Stream as AsyncStream
    participant GSR as GhosttySurfaceRepresentable
    participant GSV as GhosttySurfaceView
    participant LG as libghostty

    Mac->>MSC: deliverTerminalRenderGrid(frame)
    MSC->>Q: enqueue(delivery)
    alt queue idle
        Q-->>MSC: return delivery immediately
        MSC->>Stream: continuation.yield(bytes)
        GSR->>Stream: for await data
        Stream-->>GSR: data
        GSR->>GSV: await processOutputAndWait(data)
        GSV->>LG: ghostty_surface_process_output (background queue)
        LG-->>GSV: done
        GSV-->>GSR: resume continuation
        GSR->>MSC: terminalOutputDidProcess(surfaceID)
        MSC->>Q: completeInFlight()
        alt pending items exist
            Q-->>MSC: next delivery
            MSC->>Stream: continuation.yield(next.bytes)
        else queue empty
            Q-->>MSC: nil
        end
    else queue busy
        Q->>Q: appendPending(delivery)
        note over Q: replaceable frames coalesce with last pending replaceable
        Q-->>MSC: nil
    end
Loading

Reviews (2): Last reviewed commit: "Satisfy Swift file length budget" | Re-trigger Greptile

Comment on lines +135 to 143
outputTask = Task { @MainActor [weak surfaceView, weak store] in
guard let store else { return }
for await data in store.terminalOutputStream(surfaceID: surfaceID) {
guard !Task.isCancelled else { return }
surfaceView?.processOutput(data)
if let surfaceView {
await surfaceView.processOutputAndWait(data)
}
store.terminalOutputDidProcess(surfaceID: surfaceID)
}

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 ACK is sent even when output was not applied to libghostty

When surfaceView is nil, processOutputAndWait is skipped but store.terminalOutputDidProcess(surfaceID:) is still called unconditionally. The chunk has been dequeued from AsyncStream and discarded without ever reaching ghostty_surface_process_output, so the libghostty surface state does not reflect that data. The queue continues draining at full speed for as long as the view is nil, potentially skipping a run of frames. In practice the cold-attach replay on re-register catches up the surface, but the backpressure design assumes each ACK corresponds to an applied chunk — silently dropping chunks without an explicit comment or counter-measure makes this contract harder to audit and could mask future regressions where surfaceView goes nil unexpectedly mid-scroll.

Comment on lines +61 to +72
mutating func completeInFlight() -> TerminalOutputDelivery? {
guard pendingHeadIndex < pending.count else {
inFlight = false
pending.removeAll(keepingCapacity: true)
pendingHeadIndex = 0
return nil
}
let next = pending[pendingHeadIndex]
pendingHeadIndex += 1
compactPendingStorageIfNeeded()
return next
}

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 completeInFlight() has no guard against being called when inFlight is already false

If terminalOutputDidProcess is called spuriously — e.g. a second ACK from the same surface — completeInFlight() runs with inFlight == false and pending empty: the no-pending branch sets inFlight = false again (harmless today). But if inFlight is false and pending somehow has entries, the function would pop and return a pending item without ever setting inFlight = true, corrupting the queue invariant. A precondition(inFlight, "completeInFlight called with no item in flight") would make unintended double-ACKs loudly visible in debug builds.

@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 `@ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift`:
- Around line 28-31: The Task's loop captures [weak self] but still calls
store.terminalOutputDidProcess(surfaceID:) even when self is nil; change the
loop in the Task { `@MainActor` [weak self] in for await data in
store.terminalOutputStream(surfaceID: surfaceID) { ... } } to guard the weak
self at the top of each iteration (e.g. guard let self = self else { break } )
so that you only append to self.lines and call
store.terminalOutputDidProcess(surfaceID:) when self is present, preventing
acknowledgement when the collector has been deallocated.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 4308-4324: terminalOutputDidProcess(surfaceID:) currently advances
whichever queue is stored under terminalOutputQueuesBySurfaceID[surfaceID],
which allows stale ACKs to advance a replaced queue; fix by introducing a
per-stream generation/delivery token and validating it on ACK: add a generation
counter or UUID field to the terminal output queue (e.g. a generation on the
struct stored in terminalOutputQueuesBySurfaceID) and include that same token
with the continuation in terminalByteContinuationsBySurfaceID when a stream is
started; update resetTerminalOutputTracking() (and any remount/reset code) to
increment/rotate the generation; then change
terminalOutputDidProcess(surfaceID:) to load the queue, compare the
continuation's token to the queue.generation and only call
queue.completeInFlight() and continuation.yield(next.bytes) if the tokens match
(otherwise ignore the ACK). This ties ACKs to the exact stream generation and
prevents stale consumers from releasing the replacement queue.
🪄 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: 3d1c0c6f-d616-456d-9ec3-f6ac6dee5efc

📥 Commits

Reviewing files that changed from the base of the PR and between 1e3b103 and 6c9417b.

📒 Files selected for processing (9)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift

Comment on lines 28 to +31
task = Task { @MainActor [weak self] in
for await data in store.terminalOutputStream(surfaceID: surfaceID) {
self?.lines.append(String(data: data, encoding: .utf8) ?? "")
store.terminalOutputDidProcess(surfaceID: surfaceID)

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 | 🟡 Minor | ⚡ Quick win

Guard self availability before acknowledging output consumption.

The task captures [weak self], so if the collector is deallocated mid-stream, line 30's optional chaining silently skips appending the chunk to lines, but line 31 unconditionally calls store.terminalOutputDidProcess(surfaceID:). This tells the store the output was consumed even though the test helper didn't actually collect it, violating the backpressure contract.

🔒 Proposed fix to ensure atomic append-then-acknowledge
 task = Task { `@MainActor` [weak self] in
     for await data in store.terminalOutputStream(surfaceID: surfaceID) {
-        self?.lines.append(String(data: data, encoding: .utf8) ?? "")
+        guard let self else { return }
+        self.lines.append(String(data: data, encoding: .utf8) ?? "")
         store.terminalOutputDidProcess(surfaceID: surfaceID)
     }
 }
🤖 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 `@ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift` around lines
28 - 31, The Task's loop captures [weak self] but still calls
store.terminalOutputDidProcess(surfaceID:) even when self is nil; change the
loop in the Task { `@MainActor` [weak self] in for await data in
store.terminalOutputStream(surfaceID: surfaceID) { ... } } to guard the weak
self at the top of each iteration (e.g. guard let self = self else { break } )
so that you only append to self.lines and call
store.terminalOutputDidProcess(surfaceID:) when self is present, preventing
acknowledgement when the collector has been deallocated.

Comment on lines +4308 to +4324
/// Mark the current yielded terminal-output chunk as applied by the iOS surface.
///
/// The mobile terminal render path keeps at most one chunk in flight per
/// mounted surface. Calling this method releases the next buffered chunk, if
/// any, and lets replaceable render-grid viewport frames collapse while
/// Ghostty is still processing older output.
/// - Parameter surfaceID: The terminal surface identifier.
public func terminalOutputDidProcess(surfaceID: String) {
guard var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return }
let next = queue.completeInFlight()
terminalOutputQueuesBySurfaceID[surfaceID] = queue
guard let next,
let continuation = terminalByteContinuationsBySurfaceID[surfaceID] else {
return
}
continuation.yield(next.bytes)
}

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 | 🏗️ Heavy lift

Key output ACKs to the stream generation, not just surfaceID.

terminalOutputDidProcess(surfaceID:) advances whichever queue currently lives under that surface. After resetTerminalOutputTracking() or a remount replaces terminalOutputQueuesBySurfaceID[surfaceID], the previous consumer can still be finishing its last chunk because the downstream loop ACKs only after awaiting surface application. That stale ACK will complete the replacement queue's new in-flight item and release buffered frames early, breaking the one-chunk backpressure contract during reconnect/remount.

This needs a per-stream or per-delivery token carried through the stream and ACK path so only the consumer that received a chunk can release its successor. Based on review stack context and the downstream ACK contract in Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift:128-145, queue advancement must stay tied to the same stream generation.

🤖 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 `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 4308 - 4324, terminalOutputDidProcess(surfaceID:) currently
advances whichever queue is stored under
terminalOutputQueuesBySurfaceID[surfaceID], which allows stale ACKs to advance a
replaced queue; fix by introducing a per-stream generation/delivery token and
validating it on ACK: add a generation counter or UUID field to the terminal
output queue (e.g. a generation on the struct stored in
terminalOutputQueuesBySurfaceID) and include that same token with the
continuation in terminalByteContinuationsBySurfaceID when a stream is started;
update resetTerminalOutputTracking() (and any remount/reset code) to
increment/rotate the generation; then change
terminalOutputDidProcess(surfaceID:) to load the queue, compare the
continuation's token to the queue.generation and only call
queue.completeInFlight() and continuation.yield(next.bytes) if the tokens match
(otherwise ignore the ACK). This ties ACKs to the exact stream generation and
prevents stale consumers from releasing the replacement queue.

Comment on lines +2165 to +2183
/// Process terminal output and return after the output has been applied.
///
/// The call still performs libghostty output processing on the serial
/// background output queue. The returned async boundary lets callers apply
/// per-surface backpressure without blocking the main actor while Ghostty
/// consumes the chunk.
/// - Parameter data: VT or PTY bytes to feed into the surface.
public func processOutputAndWait(_ data: Data) async {
await withCheckedContinuation { continuation in
processOutput(data) {
continuation.resume()
}
}
}

private func processOutput(
_ data: Data,
completion: (@MainActor @Sendable () -> Void)?
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Split the output-delivery lifecycle out of GhosttySurfaceView.

This file is already a multi-thousand-line surface that owns rendering, input, geometry, accessibility, composer docking, and platform-bridge behavior. Adding another public output/ack API here pushes further against the repo rule for oversized mixed-responsibility production Swift files; please extract the output-processing/backpressure coordination into a dedicated helper type in this package instead of growing this view further.

As per coding guidelines, Flag Swift production files that exceed 400 lines without a clear single responsibility, or exceed 800 lines even with mostly coherent responsibility and Flag files that mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one place.

Source: Coding guidelines

@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: 1

♻️ Duplicate comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift (1)

34-43: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Tie ACKs to a stream generation, not just surfaceID.

This still lets a stale consumer release a replacement queue. resetTerminalOutputTracking() drops the queue map while leaving the continuation alive, and registerTerminalOutput() overwrites the queue again on remount; after either transition, terminalOutputDidProcess(surfaceID:) will complete whichever queue currently sits under that surface. That breaks the one-chunk backpressure guarantee by either advancing a new queue from an old ACK or letting new deliveries yield immediately while the previous chunk is still being processed.

  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift#L34-L43: make terminalOutputDidProcess validate a per-stream/per-queue token before calling completeInFlight().
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift#L23-L31: carry that same token alongside the queued delivery/continuation path so a recreated queue cannot be mistaken for the previous stream.
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L2970-L2983: rotate or invalidate the token when terminal output tracking is reset.
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift#L4233-L4249: assign a fresh token on register/remount and clear it on unregister so late ACKs from the old mount are ignored.
🤖 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
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+TerminalOutputDelivery.swift
around lines 34 - 43, The ACK handling must be tied to a per-stream token so
stale ACKs cannot affect a replaced queue: update the terminal output queue and
continuation storage to carry a streamToken alongside the queue/continuation
(modify the structures touched in
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
around lines 23-31), then in terminalOutputDidProcess(surfaceID:) (lines 34-43)
verify the token matches before calling completeInFlight() and yielding the
continuation; do not operate on the queue if the token differs. In
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift at
2970-2983, rotate or invalidate the token in resetTerminalOutputTracking() so
old tokens can no longer be used, and in the register/unregister flow at
4233-4249 assign a fresh token on register/remount and clear it on unregister so
late ACKs from the old mount are ignored; keep function names
terminalOutputDidProcess, resetTerminalOutputTracking, and
registerTerminalOutput as the anchor points for these changes.
🤖 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
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift`:
- Around line 11-34: Add unit tests that verify
TerminalHardwareKeyResolver.data(input:modifierFlags:) maps UIKit inputs to the
same VT sequences produced by the TerminalKeyEncoder tables and ignores
unsupported modifier bits; create a new test case (e.g.,
TerminalHardwareKeyResolverTests) that: (1) checks navigation keys
(UIKeyCommand.inputUpArrow/inputDownArrow/inputLeftArrow/inputRightArrow/inputHome/inputEnd/inputPageUp/inputPageDown/inputDelete/inputEscape
and "\t" with and without .shift) produce the expected byte sequences, (2)
asserts controlInputs (letters "a".."z", "[", "]", "\\", " ", "2".."7", "3"?"4"?
— use the same string used in the implementation) with .control produce the
expected control bytes, (3) asserts shiftedControlInputs ("@","^","_","?") with
[.control, .shift] produce the expected bytes, and (4) verifies that adding an
unsupported modifier like .command does not change the resulting data; use
TerminalHardwareKeyResolver.data(input:modifierFlags:) to get actual output and
compare it against the authoritative expected sequences (reuse the mappings from
existing TerminalKeyEncoder tests or hardcode the known VT byte arrays) for each
case.

---

Duplicate comments:
In
`@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+TerminalOutputDelivery.swift:
- Around line 34-43: The ACK handling must be tied to a per-stream token so
stale ACKs cannot affect a replaced queue: update the terminal output queue and
continuation storage to carry a streamToken alongside the queue/continuation
(modify the structures touched in
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
around lines 23-31), then in terminalOutputDidProcess(surfaceID:) (lines 34-43)
verify the token matches before calling completeInFlight() and yielding the
continuation; do not operate on the queue if the token differs. In
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift at
2970-2983, rotate or invalidate the token in resetTerminalOutputTracking() so
old tokens can no longer be used, and in the register/unregister flow at
4233-4249 assign a fresh token on register/remount and clear it on unregister so
late ACKs from the old mount are ignored; keep function names
terminalOutputDidProcess, resetTerminalOutputTracking, and
registerTerminalOutput as the anchor points for these changes.
🪄 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: aba2306d-6240-40fb-bd2d-94964dd0975d

📥 Commits

Reviewing files that changed from the base of the PR and between 6c9417b and 5e8a16d.

📒 Files selected for processing (5)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift
💤 Files with no reviewable changes (1)
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

Comment on lines +11 to +34
private static let keyCommands: [TerminalHardwareKeyCommand] = {
let navigation = [
TerminalHardwareKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [.alternate]),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [.alternate]),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputHome, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputEnd, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputPageUp, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputPageDown, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputDelete, modifierFlags: []),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputDelete, modifierFlags: [.alternate]),
TerminalHardwareKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: []),
TerminalHardwareKeyCommand(input: "\t", modifierFlags: []),
TerminalHardwareKeyCommand(input: "\t", modifierFlags: [.shift]),
]
let controlInputs = Array("abcdefghijklmnopqrstuvwxyz[]\\ 234567/").map(String.init)
.map { TerminalHardwareKeyCommand(input: $0, modifierFlags: [.control]) }
let shiftedControlInputs = Array("@^_?").map(String.init)
.map { TerminalHardwareKeyCommand(input: $0, modifierFlags: [.control, .shift]) }
return navigation + controlInputs + shiftedControlInputs
}()

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for tests covering TerminalHardwareKeyResolver encoding logic.

rg -n --type swift -C3 'TerminalHardwareKeyResolver' --glob '*Test*.swift'

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect resolver file to find the actual encoding/mapping behavior beyond keyCommands.
RESOLVER="Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift"
echo "=== Resolver file (head/tail + key sections) ==="
ls -l "$RESOLVER"
sed -n '1,200p' "$RESOLVER" | nl -ba | sed -n '1,200p'
echo
sed -n '200,400p' "$RESOLVER" | nl -ba

# 2) Search for encoder/mapping APIs used by the resolver.
echo
echo "=== Search for related types/usages in code ==="
rg -n --type swift "TerminalHardwareKeyResolver|makeKeyCommands|keyCommands|TerminalHardwareKeyCommand" "$RESOLVER" Packages/CmuxMobileTerminal -S

# 3) Search for any tests that touch hardware key mapping/VT escape sequences indirectly.
echo
echo "=== Search for tests mentioning hardware keys / escape sequences ==="
rg -n --type swift "(HardwareKey|TerminalHardwareKey|UIKeyCommand\\.input|VT|\\x1b|escape sequence|CSI|\\[A|\\[B|\\[C|\\[D|\\[H|\\[F|\\[5~|\\[6~|\\[3~)" \
  --glob '*Test*.swift' Packages -S

Repository: manaflow-ai/cmux

Length of output: 308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

RESOLVER="Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift"

echo "=== Resolver file (line-numbered excerpts) ==="
sed -n '1,120p' "$RESOLVER" | cat -n
echo
sed -n '120,240p' "$RESOLVER" | cat -n
echo
sed -n '240,360p' "$RESOLVER" | cat -n

echo
echo "=== Search for related types/usages in code ==="
rg -n --type swift "TerminalHardwareKeyResolver|makeKeyCommands|keyCommands|TerminalHardwareKeyCommand" "$RESOLVER" Packages/CmuxMobileTerminal -S

echo
echo "=== Search for any tests mentioning hardware keys / escape sequences ==="
rg -n --type swift "(HardwareKey|TerminalHardwareKey|UIKeyCommand\\.input|VT|\\x1b|escape sequence|CSI|\\[A|\\[B|\\[C|\\[D|\\[H|\\[F|\\[5~|\\[6~|\\[3~)" \
  --glob '*Test*.swift' Packages -S

Repository: manaflow-ai/cmux

Length of output: 21993


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate encoder + its tests
echo "=== Find TerminalKeyEncoder implementation ==="
rg -n --type swift "struct TerminalKeyEncoder|enum TerminalKeyEncoder|class TerminalKeyEncoder|TerminalKeyEncoder" Packages/CmuxMobileTerminalKit/Sources -S

echo
echo "=== Show TerminalKeyEncoderTests ==="
TEST="Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift"
ls -l "$TEST"
sed -n '1,260p' "$TEST" | cat -n

echo
echo "=== Show TerminalArrowRepeatServiceTests (related escape sequences) ==="
TEST2="Packages/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift"
ls -l "$TEST2"
sed -n '1,220p' "$TEST2" | cat -n

Repository: manaflow-ai/cmux

Length of output: 8532


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for resolver-specific coverage in tests ==="
rg -n --type swift "TerminalHardwareKeyResolver\\.data\\(|TerminalHardwareKeyResolver\\.makeKeyCommands\\(|TerminalHardwareKeyResolver" \
  --glob '*Test*.swift' Packages -S

echo
echo "=== Search for data(input:modifierFlags:) call sites (non-tests) ==="
rg -n --type swift "TerminalHardwareKeyResolver\\.data\\(input:|TerminalHardwareKeyResolver\\.data\\(text:|TerminalHardwareKeyResolver\\.data\\(" \
  Packages/CmuxMobileTerminal -S

Repository: manaflow-ai/cmux

Length of output: 115


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== List Packages/CmuxMobileTerminal tests ==="
find Packages/CmuxMobileTerminal -maxdepth 3 -type d -name Tests -print

echo
echo "=== Search for UIKit-dependent tests patterns in CmuxMobileTerminal-related test targets ==="
rg -n --type swift "import UIKit|UIKeyModifierFlags|UIKeyCommand\\.input" Packages/*/Tests -S

echo
echo "=== Check for any TerminalHardwareKeyResolver tests scaffolding ==="
find Packages -maxdepth 4 -type f -name '*TerminalHardwareKey*' -print

Repository: manaflow-ai/cmux

Length of output: 201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

set +e
echo "=== ls Packages ==="
ls -la Packages | head -n 200

echo
echo "=== ls Packages/CmuxMobileTerminal ==="
ls -la Packages/CmuxMobileTerminal 2>&1

echo
echo "=== find test dirs under Packages/* ==="
find Packages -maxdepth 4 -type d -name Tests -print 2>&1 || true

echo
echo "=== Search for any UIKit usage inside any Swift test targets ==="
rg -n --type swift "import UIKit|UIKeyModifierFlags|UIKeyCommand\\.input" Packages/*/Tests -S 2>&1 || true

echo
echo "=== Find any test files mentioning TerminalHardwareKeyResolver ==="
rg -n --type swift "TerminalHardwareKeyResolver" Packages -S --glob '*Test*.swift' 2>&1 || true

echo
echo "=== Find any TerminalHardwareKey* files (impl + tests) ==="
find Packages -maxdepth 6 -type f -name '*TerminalHardwareKey*' -print 2>&1 || true

Repository: manaflow-ai/cmux

Length of output: 4853


Add unit tests for TerminalHardwareKeyResolver UIKeyCommand→VT encoding mapping

TerminalKeyEncoderTests already cover the VT byte tables for special keys/control characters, so the remaining gap is ensuring TerminalHardwareKeyResolver.data(input:modifierFlags:) maps UIKit inputs (arrows/home/end/page/delete/escape/tab) + modifiers (.alternate, .control, .control+.shift for @^_?) to the correct TerminalKeyEncoder outputs, and ignores unsupported modifier bits (e.g., .command).

🤖 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
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift`
around lines 11 - 34, Add unit tests that verify
TerminalHardwareKeyResolver.data(input:modifierFlags:) maps UIKit inputs to the
same VT sequences produced by the TerminalKeyEncoder tables and ignores
unsupported modifier bits; create a new test case (e.g.,
TerminalHardwareKeyResolverTests) that: (1) checks navigation keys
(UIKeyCommand.inputUpArrow/inputDownArrow/inputLeftArrow/inputRightArrow/inputHome/inputEnd/inputPageUp/inputPageDown/inputDelete/inputEscape
and "\t" with and without .shift) produce the expected byte sequences, (2)
asserts controlInputs (letters "a".."z", "[", "]", "\\", " ", "2".."7", "3"?"4"?
— use the same string used in the implementation) with .control produce the
expected control bytes, (3) asserts shiftedControlInputs ("@","^","_","?") with
[.control, .shift] produce the expected bytes, and (4) verifies that adding an
unsupported modifier like .command does not change the resulting data; use
TerminalHardwareKeyResolver.data(input:modifierFlags:) to get actual output and
compare it against the authoritative expected sequences (reuse the mappings from
existing TerminalKeyEncoder tests or hardcode the known VT byte arrays) for each
case.

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.

1 participant