Skip to content

Add surface.stream command for real-time PTY streaming#2612

Open
mahmudulturan wants to merge 3 commits intomanaflow-ai:mainfrom
mahmudulturan:feat/surface-stream
Open

Add surface.stream command for real-time PTY streaming#2612
mahmudulturan wants to merge 3 commits intomanaflow-ai:mainfrom
mahmudulturan:feat/surface-stream

Conversation

@mahmudulturan
Copy link
Copy Markdown

@mahmudulturan mahmudulturan commented Apr 5, 2026

Summary

What changed?

Added a new surface.stream socket command that enables real-time bidirectional PTY streaming over the existing Unix socket. When a client sends surface.stream, the connection switches from request-response to a persistent streaming relay:

  • Server → Client: Raw PTY output as base64-encoded JSON frames {"type":"output","data":"<b64>"}
  • Client → Server: Input written directly to PTY {"type":"input","data":"<b64>"}
  • Initial snapshot: Current visible screen content sent as {"type":"snapshot","data":"<b64>"} on connect so clients see existing terminal state

The streaming loop runs at ~60fps using poll() with 16ms timeout.

Why?

Addresses long-standing feature requests for remote terminal access:

This gives any external client (SSH tools, web UIs, mobile apps) the ability to observe and interact with existing cmux terminal sessions in real time, with full ANSI color and escape sequence support.

Protocol

The stream uses the same newline-delimited JSON framing as the existing socket API. A dedicated socket connection is required per stream (one surface per connection).

Output frames (cmux → client):

{"type":"snapshot","data":"<base64 initial screen content>"}
{"type":"output","data":"<base64 raw PTY bytes with ANSI>"}
{"type":"error","message":"..."}

Input frames (client → cmux):

{"type":"input","data":"<base64 raw bytes>"}
{"type":"resize","cols":80,"rows":24}

Depends on

Testing

How did you test this change?

  • Built tagged dev build: ./scripts/reload.sh --tag pty-stream --launch
  • Connected external WebSocket client via a bridge relay to the socket
  • Verified real-time colored output streaming (ANSI escape sequences preserved)
  • Verified bidirectional input (typed on client → appeared in cmux terminal)
  • Verified initial snapshot (connected to terminal with existing content → snapshot received)
  • Verified Ctrl+C/Ctrl+D and special keys work through the stream
  • Verified cleanup on client disconnect (tap closed, socket flags restored)
  • Verified existing surface.read_text and surface.send_text still work unchanged

What did you verify manually?

  • Stream connect → receive snapshot → live output with colors
  • Type input on client → appears in terminal
  • Client disconnect → clean teardown, no resource leaks
  • Multiple sequential stream connections to same surface
  • Stream to different surfaces in different workspaces

Checklist

  • Tested locally with tagged dev build
  • Existing socket commands unaffected
  • Clean resource cleanup on disconnect
  • No main-thread blocking (relay runs on per-client background thread)
  • Follows socket command threading policy (off-main processing)
  • Follows socket focus policy (no focus stealing)

Summary by CodeRabbit

Release Notes

  • New Features
    • Added terminal surface streaming capability enabling real-time bidirectional communication of terminal content and input commands over dedicated streaming connections.

Add a new socket command that takes over the connection for bidirectional
PTY relay. The client sends {"method":"surface.stream"} and receives
base64-encoded terminal output frames while being able to inject input
back into the PTY. This is the cmux-side foundation for remote terminal
streaming to the mobile app.

- Detect surface.stream in handleClient before normal dispatch
- Resolve surface via the standard v2 pattern (workspace/surface_id)
- enterStreamRelay: poll-based loop at ~60fps for PTY tap read/write
- handleStreamInput: decode base64 input frames and write to PTY
- Register in processV2Command switch and capabilities list
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 5, 2026

@mahmudulturan is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

Added a pre-dispatch fast path and new streaming flow for a JSON-V2 surface.stream command in TerminalController, implementing socket-to-PTY bidirectional relay with initial viewport snapshot and newline-delimited JSON frames.

Changes

Cohort / File(s) Summary
PTY Streaming Feature
Sources/TerminalController.swift
Added early detection of JSON-V2 surface.stream and processV2Command branch; implemented v2SurfaceStream, enterStreamRelay, handleStreamInput, and writeAll to establish a bidirectional, non-blocking socket↔PTY relay, send initial base64 snapshot and subsequent newline-delimited JSON output frames, and accept input frames written to the PTY.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Socket Client
    participant TermCtrl as TerminalController
    participant Surface as Terminal Surface
    participant PTY as PTY Device

    Client->>TermCtrl: Send JSON-V2 `surface.stream` (first message)
    TermCtrl->>TermCtrl: Pre-dispatch detects `surface.stream`
    TermCtrl->>Surface: Resolve workspace/surface on main thread
    TermCtrl->>Client: Send initial {"ok":{"stream":true}} ack
    TermCtrl->>PTY: Open Ghostty PTY tap & capture snapshot
    TermCtrl->>Client: Send initial `snapshot` frame (base64)
    
    loop Bidirectional relay
        par PTY -> Client
            PTY->>TermCtrl: PTY output bytes
            TermCtrl->>TermCtrl: Base64-encode newline-delimited frames
            TermCtrl->>Client: Send `output` JSON frames
        and Client -> PTY
            Client->>TermCtrl: Send newline-delimited JSON `input`/`resize`
            TermCtrl->>TermCtrl: Decode base64 `input`
            TermCtrl->>PTY: ghostty_surface_pty_write(bytes)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped a socket, tail a-fluff,

I tunneled bytes through pty and puff,
Snapshot first, then frames parade,
Base64 crumbs along the glade,
Relay hums — the stream's enough! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add surface.stream command for real-time PTY streaming' clearly and accurately summarizes the main change: introducing a new socket command for streaming PTY output.
Description check ✅ Passed The PR description comprehensively covers all required template sections: detailed summary of what changed and why, thorough testing methodology and verification, protocol documentation, and a completed checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 5, 2026

Greptile Summary

This PR adds a surface.stream socket command that converts a Unix socket connection into a persistent bidirectional PTY relay, sending base64-encoded output frames at ~60 fps and accepting JSON input frames in return. The architecture is sound and follows the existing threading/focus policies.

  • P1: readScreenSnapshot calls ghostty_surface_read_text from the nonisolated relay thread, bypassing the v2MainSync dispatch that every other ghostty_surface_read_text call site uses — this is a data race / crash risk.
  • P1: inputBuffer has no size cap; a client sending data without newline delimiters can grow it without bound.

Confidence Score: 4/5

Not safe to merge: readScreenSnapshot calls ghostty_surface_read_text off the main thread, which is a data race / potential crash against the existing threading contract.

Two P1 defects remain: off-main-thread ghostty API access (crash risk) and unbounded inputBuffer (OOM risk). Neither is speculative — the threading violation is directly observable by comparing against v2SurfaceReadText which uses v2MainSync for the same API.

Sources/TerminalController.swift — specifically enterStreamRelay (readScreenSnapshot threading, inputBuffer cap) and the unchecked fcntl return.

Important Files Changed

Filename Overview
Sources/TerminalController.swift Adds v2SurfaceStream / enterStreamRelay / handleStreamInput / readScreenSnapshot; readScreenSnapshot calls ghostty_surface_read_text off-main (threading violation) and inputBuffer is unbounded

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant SC as handleClient (bg thread)
    participant M as Main Thread
    participant G as Ghostty PTY

    C->>SC: {"method":"surface.stream",...}
    SC->>M: v2MainSync: resolve surface ptr
    M-->>SC: surfacePtr
    SC->>SC: writeSocketResponse(stream:true)
    SC->>SC: enterStreamRelay(socket, surface)
    SC->>G: ghostty_surface_pty_tap_open()
    Note over SC: readScreenSnapshot() ⚠️ called off-main
    SC->>G: ghostty_surface_read_text() [off-main!]
    G-->>SC: screen text
    SC->>C: {"type":"snapshot","data":"..."}
    loop ~60 fps relay
        SC->>G: ghostty_surface_pty_tap_read()
        G-->>SC: PTY bytes (n>0)
        SC->>C: {"type":"output","data":"<b64>"}
        C->>SC: {"type":"input","data":"<b64>"}
        SC->>G: ghostty_surface_pty_write(bytes)
    end
    C--xSC: disconnect (POLLHUP/read<=0)
    SC->>G: ghostty_surface_pty_tap_close()
    SC->>SC: restore socket flags
Loading

Reviews (1): Last reviewed commit: "feat: send initial screen snapshot befor..." | Re-trigger Greptile

Comment on lines +6254 to +6261
if let snapshot = readScreenSnapshot(surface: surface) {
let snapshotData = Data(snapshot.utf8)
let b64 = snapshotData.base64EncodedString()
let frame = "{\"type\":\"snapshot\",\"data\":\"\(b64)\"}\n"
frame.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
}
}
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 readScreenSnapshot called off the main thread

ghostty_surface_read_text is invoked here from enterStreamRelay, which is nonisolated and runs on the background client thread. Every other call site for this API (e.g., readTerminalTextBase64 called inside v2MainSync {} in v2SurfaceReadText) dispatches to the main thread first. Calling it off-main risks a data race or crash in Ghostty's terminal state machine.

Fix: wrap the call in DispatchQueue.main.sync (mirroring v2MainSync) before entering the relay loop, or restructure so the snapshot is read inside the existing v2MainSync block in v2SurfaceStream and passed in as a parameter.

Comment on lines +6293 to +6304
var inBuf = [UInt8](repeating: 0, count: 4096)
let bytesRead = read(socket, &inBuf, inBuf.count)
if bytesRead <= 0 { break }

if let str = String(bytes: inBuf[0..<bytesRead], encoding: .utf8) {
inputBuffer += str
while let newlineIdx = inputBuffer.firstIndex(of: "\n") {
let line = String(inputBuffer[..<newlineIdx])
inputBuffer = String(inputBuffer[inputBuffer.index(after: newlineIdx)...])
handleStreamInput(line, surface: surface)
}
}
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 Unbounded inputBuffer growth

inputBuffer accumulates raw bytes from the socket and is only drained when a \n delimiter is found. A client that sends a large payload (or a corrupt/malicious stream) without a newline will grow this buffer without bound until the connection closes or the process OOMs. Add a hard cap and break the loop when it is exceeded:

                inputBuffer += str
                if inputBuffer.count > 1_048_576 { break } // 1 MB safety cap
                while let newlineIdx = inputBuffer.firstIndex(of: "\n") {

Comment on lines +6264 to +6265
let origFlags = fcntl(socket, F_GETFL, 0)
fcntl(socket, F_SETFL, origFlags | O_NONBLOCK)
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 Unchecked fcntl return value

fcntl(socket, F_GETFL, 0) returns -1 on failure. Applying F_SETFL with -1 | O_NONBLOCK sets all flag bits, which is undefined behaviour. The return value should be validated before use:

Suggested change
let origFlags = fcntl(socket, F_GETFL, 0)
fcntl(socket, F_SETFL, origFlags | O_NONBLOCK)
let origFlags = fcntl(socket, F_GETFL, 0)
guard origFlags != -1 else {
ghostty_surface_pty_tap_close(surface)
return
}
fcntl(socket, F_SETFL, origFlags | O_NONBLOCK)

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
Sources/TerminalController.swift (2)

6327-6329: Resize handling is stubbed out.

The "resize" message type is accepted but not implemented. If this is intentional for a future PR, consider adding a TODO comment or logging to indicate it's not yet functional.

         case "resize":
-            // Future: handle terminal resize
+            // TODO(surface.stream): Implement terminal resize via ghostty_surface_resize
+            // Expected format: {"type":"resize","cols":80,"rows":24}
             break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 6327 - 6329, The "resize"
branch in TerminalController's message handling (the case "resize") is a stub;
replace the bare break with a clear TODO and/or runtime logging so callers know
resize is unimplemented: update the case "resize" in TerminalController to add a
TODO comment and emit a log (e.g., processLogger.info or OSLog) stating "resize
not implemented" (or similar) so it's explicit at runtime and in source that
resize handling is pending.

6282-6289: Consider handling partial writes to prevent frame corruption.

The write() calls at lines 6259 and 6287 ignore return values. While unlikely for small JSON frames over Unix sockets, partial writes would corrupt the newline-delimited framing. A retry loop or buffered write helper would be more robust.

♻️ Suggested helper pattern
+    /// Writes all bytes to the socket, retrying on partial writes.
+    private nonisolated func writeAll(_ socket: Int32, _ data: String) {
+        data.withCString { ptr in
+            var total = 0
+            let len = strlen(ptr)
+            while total < len {
+                let n = write(socket, ptr.advanced(by: total), len - total)
+                if n <= 0 { break }
+                total += n
+            }
+        }
+    }

Then replace:

-                frame.withCString { ptr in
-                    _ = write(socket, ptr, strlen(ptr))
-                }
+                writeAll(socket, frame)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 6282 - 6289, The write(...)
calls that send JSON frames (the one inside the n > 0 branch creating frame and
the earlier write at the other output site) ignore the return value and can
produce partial writes; replace them with a small buffered-write helper (e.g.,
writeFully(socket: Int32, buffer: UnsafePointer<CChar>, length: Int) -> Bool)
that checks for -1/EINTR, advances the pointer by the number of bytes written,
retries until all bytes are written or an irrecoverable error occurs, and
returns success/failure; call that helper wherever you currently call
write(socket, ptr, strlen(ptr)) so newline-delimited frames are sent atomically.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TerminalController.swift`:
- Around line 6191-6212: The captured raw pointer surfacePtr can dangle if the
TerminalPanel is closed while enterStreamRelay is running; fix by holding a
strong reference to the owning object (e.g., capture/preserve TerminalPanel or
its TerminalSurface instance) for the lifetime of the relay and/or add a
cancellation signal checked inside enterStreamRelay. Concretely, stop passing
the raw ghostty_surface_t alone from the block that sets surfacePtr and instead
retain a strong reference to the TerminalPanel/TerminalSurface (the same
instance used to call surface.teardownSurface() in TerminalPanel.close()),
update enterStreamRelay to accept that object or a CancellationToken, and ensure
enterStreamRelay watches for panel removal or the token to exit and release the
surface before calling ghostty_surface_pty_tap_read().

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Around line 6327-6329: The "resize" branch in TerminalController's message
handling (the case "resize") is a stub; replace the bare break with a clear TODO
and/or runtime logging so callers know resize is unimplemented: update the case
"resize" in TerminalController to add a TODO comment and emit a log (e.g.,
processLogger.info or OSLog) stating "resize not implemented" (or similar) so
it's explicit at runtime and in source that resize handling is pending.
- Around line 6282-6289: The write(...) calls that send JSON frames (the one
inside the n > 0 branch creating frame and the earlier write at the other output
site) ignore the return value and can produce partial writes; replace them with
a small buffered-write helper (e.g., writeFully(socket: Int32, buffer:
UnsafePointer<CChar>, length: Int) -> Bool) that checks for -1/EINTR, advances
the pointer by the number of bytes written, retries until all bytes are written
or an irrecoverable error occurs, and returns success/failure; call that helper
wherever you currently call write(socket, ptr, strlen(ptr)) so newline-delimited
frames are sent atomically.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2797daa-14f4-457f-9d37-a2302da64e0e

📥 Commits

Reviewing files that changed from the base of the PR and between 2bf4367 and d6394fe.

📒 Files selected for processing (1)
  • Sources/TerminalController.swift

Comment on lines +6191 to +6212
return
}
surfacePtr = terminalPanel.surface.surface
}

if let errorResponse {
writeSocketResponse(errorResponse, to: socket)
return
}

guard let surface = surfacePtr else {
let err = v2Error(id: id, code: "not_found", message: "Terminal surface not ready")
writeSocketResponse(err, to: socket)
return
}

// Send stream-start acknowledgement
let startMsg = v2Ok(id: id, result: ["stream": true])
writeSocketResponse(startMsg, to: socket)

// Enter the blocking relay loop (runs on the current background thread)
enterStreamRelay(socket: socket, surface: surface)
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for existing patterns handling surface lifetime in streaming/long-running contexts
rg -n -C5 'ghostty_surface.*close|surface.*invalidat|surface.*cancel' --type swift

Repository: manaflow-ai/cmux

Length of output: 1919


🏁 Script executed:

#!/bin/bash
# Get the full v2SurfaceStream function (starting before line 6151)
sed -n '6140,6230p' Sources/TerminalController.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 4670


🏁 Script executed:

#!/bin/bash
# Search for enterStreamRelay implementation
rg -n 'func enterStreamRelay' -A 50 Sources/TerminalController.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 2540


🏁 Script executed:

#!/bin/bash
# Check if there's any reference holding or cancellation token for streaming
rg -n 'streaming|stream.*cancel|stream.*invalid' -B 2 -A 2 Sources/TerminalController.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 1118


🏁 Script executed:

#!/bin/bash
# Check how TerminalPanel is managed in workspace (is it removed on close?)
rg -n 'terminalPanel|removePanel|removeTerminal' Sources/TerminalController.swift | head -30

Repository: manaflow-ai/cmux

Length of output: 2713


🏁 Script executed:

#!/bin/bash
# Check if there's a reference to the streaming socket/relay in TerminalPanel
rg -n 'class TerminalPanel|struct TerminalPanel' -A 30 Sources/GhosttyTerminalView.swift | head -60

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Search for panel lifecycle and what holds it alive
rg -n 'workspace\.panels|panels\[' Sources/TerminalController.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 1724


🏁 Script executed:

#!/bin/bash
# Search for panel removal/closing patterns
rg -n 'panels\.removeValue|panels\[.*\] = nil|removePanel|closePanel' Sources/TerminalController.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 470


🏁 Script executed:

#!/bin/bash
# Find TerminalPanel class definition
fd -e swift -e swiftui | xargs grep -l 'class TerminalPanel\|struct TerminalPanel' 2>/dev/null

Repository: manaflow-ai/cmux

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Check what reference v2SurfaceStream passes to enterStreamRelay
# Does it keep any strong reference beyond the raw pointer?
sed -n '6150,6250p' Sources/TerminalController.swift | grep -A 5 -B 5 'enterStreamRelay'

Repository: manaflow-ai/cmux

Length of output: 1348


🏁 Script executed:

#!/bin/bash
# Read TerminalPanel class definition
wc -l Sources/Panels/TerminalPanel.swift

Repository: manaflow-ai/cmux

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Check if TerminalPanel has any streaming-aware reference management
rg -n 'stream|surface\.surface|ghostty_surface_t' Sources/Panels/TerminalPanel.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 175


🏁 Script executed:

#!/an/bin/bash
# Check what happens when a panel is closed - is there streaming cleanup?
rg -n 'func closePanel' -A 30 Sources/TerminalController.swift | head -60

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Read full TerminalPanel.swift
cat -n Sources/Panels/TerminalPanel.swift

Repository: manaflow-ai/cmux

Length of output: 11851


🏁 Script executed:

#!/bin/bash
# Search for closePanel implementation across all files
rg -n 'func closePanel' -A 20 Sources/ | head -80

Repository: manaflow-ai/cmux

Length of output: 5789


Surface pointer becomes dangling if terminal panel closes during active streaming.

The raw ghostty_surface_t pointer is captured at line 6193 and passed to enterStreamRelay, which runs an indefinite blocking relay loop on a background thread. If the user closes the terminal panel while streaming is active, the TerminalPanel will be removed from workspace.panels and deallocated. This triggers TerminalPanel.close()surface.teardownSurface(), freeing the Ghostty surface. The relay loop then calls ghostty_surface_pty_tap_read() on a dangling pointer, causing undefined behavior or a crash.

Hold a strong reference to TerminalPanel (or TerminalSurface) for the duration of the relay, or implement a cancellation mechanism (e.g., subscribe to panel removal and signal the relay to exit cleanly).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 6191 - 6212, The captured raw
pointer surfacePtr can dangle if the TerminalPanel is closed while
enterStreamRelay is running; fix by holding a strong reference to the owning
object (e.g., capture/preserve TerminalPanel or its TerminalSurface instance)
for the lifetime of the relay and/or add a cancellation signal checked inside
enterStreamRelay. Concretely, stop passing the raw ghostty_surface_t alone from
the block that sets surfacePtr and instead retain a strong reference to the
TerminalPanel/TerminalSurface (the same instance used to call
surface.teardownSurface() in TerminalPanel.close()), update enterStreamRelay to
accept that object or a CancellationToken, and ensure enterStreamRelay watches
for panel removal or the token to exit and release the surface before calling
ghostty_surface_pty_tap_read().

Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

2 issues found across 1 file

You’re at about 85% of the monthly review limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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/TerminalController.swift">

<violation number="1" location="Sources/TerminalController.swift:6259">
P2: Non-blocking socket writes ignore partial/EAGAIN results, so JSON frames can be truncated and desync the stream protocol.</violation>

<violation number="2" location="Sources/TerminalController.swift:6295">
P2: Non-blocking read() treats transient errors (EAGAIN/EWOULDBLOCK/EINTR) as disconnects, so the stream can terminate unexpectedly. Handle these errno cases and continue instead of breaking.</violation>
</file>

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

- Move readScreenSnapshot to main thread via v2MainSync (P1: data race fix)
- Cap inputBuffer at 64KB to prevent OOM from malicious clients (P1)
- Add writeAll helper for retry on partial writes and EINTR (P2)
- Handle EAGAIN/EWOULDBLOCK/EINTR in non-blocking read (P2)
- Suppress fcntl return value warnings
- Add TODO comment for resize stub
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/TerminalController.swift (1)

6353-6355: Resize implementation is stubbed.

The resize message type is parsed but not implemented. Per the PR objectives, this should call a Ghostty API like ghostty_surface_set_size.

Would you like me to help implement the resize handler, or should I open an issue to track this?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 6353 - 6355, The "resize" case
currently stubs terminal resizing; implement it by parsing the incoming message
payload for the new dimensions (e.g., width/height or cols/rows fields) and
calling the Ghostty API to apply them (use ghostty_surface_set_size or the
project's wrapper around it) on the active surface/stream object used in
TerminalController.swift (locate the switch handling message types and the "case
\"resize\"" branch and the surface/stream variable name). Validate and coerce
the parsed values to integers, handle errors (log via existing logger) and
return or send an appropriate ack/error message back to the sender as done by
other cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TerminalController.swift`:
- Around line 6246-6260: The writeAll(_ socket: Int32, _ data: String) loop
currently only retries on EINTR and will break on EAGAIN/EWOULDBLOCK for
non-blocking sockets; update writeAll to treat EAGAIN and EWOULDBLOCK as
transient by waiting for the socket to become writable (e.g., use poll/ppoll or
select with the socket FD) and then retry the write, while continuing to handle
EINTR as before and still returning/ breaking on other errno values; keep the
same function signature and behavior of writing until all bytes are sent or an
unrecoverable error occurs.
- Line 6243: Replace the non-failable String(decoding:as:) call used in the
return expression (currently returning String(decoding: Data(bytes: ptr, count:
Int(text.text_len)), as: UTF8.self)) with the failable initializer
String(bytes:encoding:), e.g. construct Data(bytes: ptr, count:
Int(text.text_len)) then call String(bytes: data, encoding: .utf8) and
explicitly handle the nil case (for example by returning an optional, throwing
an error, or falling back to the previous replacement behavior with
String(decoding:as:) if you want graceful degradation); update the surrounding
function signature/flow accordingly (the expression referencing ptr and
text.text_len should be the same).

---

Nitpick comments:
In `@Sources/TerminalController.swift`:
- Around line 6353-6355: The "resize" case currently stubs terminal resizing;
implement it by parsing the incoming message payload for the new dimensions
(e.g., width/height or cols/rows fields) and calling the Ghostty API to apply
them (use ghostty_surface_set_size or the project's wrapper around it) on the
active surface/stream object used in TerminalController.swift (locate the switch
handling message types and the "case \"resize\"" branch and the surface/stream
variable name). Validate and coerce the parsed values to integers, handle errors
(log via existing logger) and return or send an appropriate ack/error message
back to the sender as done by other cases.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9de5e5b2-8c0b-4235-a4c0-f02f3598f82a

📥 Commits

Reviewing files that changed from the base of the PR and between d6394fe and d00034c.

📒 Files selected for processing (1)
  • Sources/TerminalController.swift

guard ghostty_surface_read_text(surface, selection, &text) else { return nil }
defer { ghostty_surface_free_text(surface, &text) }
guard let ptr = text.text, text.text_len > 0 else { return nil }
return String(decoding: Data(bytes: ptr, count: Int(text.text_len)), as: UTF8.self)
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

Consider using failable String(bytes:encoding:) initializer.

String(decoding:as:) silently replaces invalid UTF-8 sequences with replacement characters. If the terminal contains malformed sequences, this could mask issues. The failable initializer would allow explicit handling.

Optional fix
-        return String(decoding: Data(bytes: ptr, count: Int(text.text_len)), as: UTF8.self)
+        return String(bytes: Data(bytes: ptr, count: Int(text.text_len)), encoding: .utf8)

That said, the replacement behavior may be acceptable here since terminal output can contain arbitrary bytes and graceful degradation is reasonable.

🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 6243-6243: Prefer failable String(bytes:encoding:) initializer when converting Data to String

(optional_data_string_conversion)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` at line 6243, Replace the non-failable
String(decoding:as:) call used in the return expression (currently returning
String(decoding: Data(bytes: ptr, count: Int(text.text_len)), as: UTF8.self))
with the failable initializer String(bytes:encoding:), e.g. construct
Data(bytes: ptr, count: Int(text.text_len)) then call String(bytes: data,
encoding: .utf8) and explicitly handle the nil case (for example by returning an
optional, throwing an error, or falling back to the previous replacement
behavior with String(decoding:as:) if you want graceful degradation); update the
surrounding function signature/flow accordingly (the expression referencing ptr
and text.text_len should be the same).

Comment on lines +6246 to +6260
/// Writes all bytes to the socket, retrying on partial writes and EINTR.
private nonisolated func writeAll(_ socket: Int32, _ data: String) {
data.withCString { ptr in
var total = 0
let len = strlen(ptr)
while total < len {
let n = Darwin.write(socket, ptr.advanced(by: total), len - total)
if n < 0 {
if errno == EINTR { continue }
break // Real error
}
total += n
}
}
}
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

writeAll doesn't handle EAGAIN/EWOULDBLOCK on non-blocking socket.

The socket is set to non-blocking at line 6283, but writeAll only retries on EINTR. When the socket send buffer is full, write() returns -1 with EAGAIN/EWOULDBLOCK, causing the loop to break and potentially lose data.

Proposed fix
 private nonisolated func writeAll(_ socket: Int32, _ data: String) {
     data.withCString { ptr in
         var total = 0
         let len = strlen(ptr)
         while total < len {
             let n = Darwin.write(socket, ptr.advanced(by: total), len - total)
             if n < 0 {
-                if errno == EINTR { continue }
+                if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK {
+                    // Brief yield before retry to avoid busy-spinning
+                    usleep(1000) // 1ms
+                    continue
+                }
                 break // Real error
             }
             total += n
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalController.swift` around lines 6246 - 6260, The writeAll(_
socket: Int32, _ data: String) loop currently only retries on EINTR and will
break on EAGAIN/EWOULDBLOCK for non-blocking sockets; update writeAll to treat
EAGAIN and EWOULDBLOCK as transient by waiting for the socket to become writable
(e.g., use poll/ppoll or select with the socket FD) and then retry the write,
while continuing to handle EINTR as before and still returning/ breaking on
other errno values; keep the same function signature and behavior of writing
until all bytes are sent or an unrecoverable error occurs.

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