Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,21 @@ class TerminalController {
continue
}

// Detect streaming command before normal processing.
// surface.stream takes over the socket connection for bidirectional
// PTY relay, so it must bypass the normal request/response loop.
if trimmed.contains("\"surface.stream\"") {
if let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let method = json["method"] as? String,
method == "surface.stream" {
let params = json["params"] as? [String: Any] ?? [:]
let id = json["id"]
v2SurfaceStream(id: id, params: params, socket: socket)
return // stream took over this connection
}
}

let response = processCommand(trimmed)
writeSocketResponse(response, to: socket)
}
Expand Down Expand Up @@ -2387,6 +2402,11 @@ class TerminalController {
case "surface.read_text":
return v2Result(id: id, self.v2SurfaceReadText(params: params))

case "surface.stream":
// surface.stream is handled in handleClient before processCommand;
// if we reach here the caller used the wrong transport path.
return v2Error(id: id, code: "invalid_request",
message: "surface.stream must be sent as the first command on a dedicated connection")

#if DEBUG
// Debug / test-only
Expand Down Expand Up @@ -2515,6 +2535,7 @@ class TerminalController {
"surface.read_text",
"surface.clear_history",
"surface.trigger_flash",
"surface.stream",
"pane.list",
"pane.focus",
"pane.surfaces",
Expand Down Expand Up @@ -6127,6 +6148,190 @@ class TerminalController {
return result
}

// MARK: - V2 Surface Stream

/// Resolves the target surface, sends a stream-start acknowledgement, and enters
/// a bidirectional PTY relay loop that owns the socket until the client disconnects.
/// Called from `handleClient` *before* the normal command dispatch so it can take
/// over the connection.
private func v2SurfaceStream(id: Any?, params: [String: Any], socket: Int32) {
guard let tabManager = v2ResolveTabManager(params: params) else {
let err = v2Error(id: id, code: "unavailable", message: "TabManager not available")
writeSocketResponse(err, to: socket)
return
}

var surfacePtr: ghostty_surface_t? = nil
var errorResponse: String? = nil

v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
errorResponse = v2Error(id: id, code: "not_found", message: "Workspace not found")
return
}
let surfaceId: UUID?
if params["surface_id"] != nil {
surfaceId = v2UUID(params, "surface_id")
guard surfaceId != nil else {
errorResponse = v2Error(id: id, code: "not_found",
message: "Surface not found for the given surface_id")
return
}
} else {
surfaceId = ws.focusedPanelId
}
guard let surfaceId else {
errorResponse = v2Error(id: id, code: "not_found", message: "No focused surface")
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
errorResponse = v2Error(id: id, code: "invalid_params",
message: "Surface is not a terminal",
data: ["surface_id": surfaceId.uuidString])
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)
Comment on lines +6191 to +6212
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().

}

/// Read the current visible terminal screen as plain text.
/// Used to send an initial snapshot before live PTY streaming begins.
private nonisolated func readScreenSnapshot(surface: ghostty_surface_t) -> String? {
let topLeft = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0, y: 0
)
let bottomRight = ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0, y: 0
)
let selection = ghostty_selection_s(
top_left: topLeft,
bottom_right: bottomRight,
rectangle: false
)
var text = ghostty_text_s()
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)
}

/// Bidirectional relay between a Unix socket and a Ghostty PTY tap.
/// Sends an initial screen snapshot, then streams live PTY output as
/// base64-encoded JSON frames. Reads JSON input from the socket and writes
/// it into the PTY via `ghostty_surface_pty_write`.
/// Blocks the calling thread until the client disconnects or an error occurs.
private nonisolated func enterStreamRelay(socket: Int32, surface: ghostty_surface_t) {
// Open PTY tap with a 64 KB ring buffer
guard ghostty_surface_pty_tap_open(surface, 65536) else {
let err = "{\"type\":\"error\",\"message\":\"Failed to open PTY tap\"}\n"
err.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) }
return
}

// Send initial screen snapshot so the phone sees existing content
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

@cubic-dev-ai cubic-dev-ai bot Apr 5, 2026

Choose a reason for hiding this comment

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

P2: Non-blocking socket writes ignore partial/EAGAIN results, so JSON frames can be truncated and desync the stream protocol.

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

<comment>Non-blocking socket writes ignore partial/EAGAIN results, so JSON frames can be truncated and desync the stream protocol.</comment>

<file context>
@@ -6127,6 +6148,190 @@ class TerminalController {
+            let b64 = snapshotData.base64EncodedString()
+            let frame = "{\"type\":\"snapshot\",\"data\":\"\(b64)\"}\n"
+            frame.withCString { ptr in
+                _ = write(socket, ptr, strlen(ptr))
+            }
+        }
</file context>
Fix with Cubic

}
}
Comment on lines +6254 to +6261
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.


// Set socket non-blocking so we can interleave reads from both directions
let origFlags = fcntl(socket, F_GETFL, 0)
fcntl(socket, F_SETFL, origFlags | O_NONBLOCK)
Comment on lines +6264 to +6265
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)


var readBuf = [UInt8](repeating: 0, count: 65536)
var inputBuffer = ""

// Bidirectional relay loop
while true {
// Poll socket for input with 16 ms timeout (~60 fps)
var pfd = pollfd(fd: socket, events: Int16(POLLIN), revents: 0)
poll(&pfd, 1, 16)

// Check for disconnect
if pfd.revents & Int16(POLLHUP) != 0 { break }
if pfd.revents & Int16(POLLERR) != 0 { break }

// --- PTY → socket ---
let n = ghostty_surface_pty_tap_read(surface, &readBuf, UInt32(readBuf.count))
if n > 0 {
let data = Data(bytes: readBuf, count: Int(n))
let b64 = data.base64EncodedString()
let frame = "{\"type\":\"output\",\"data\":\"\(b64)\"}\n"
frame.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
}
}

// --- socket → PTY ---
if pfd.revents & Int16(POLLIN) != 0 {
var inBuf = [UInt8](repeating: 0, count: 4096)
let bytesRead = read(socket, &inBuf, inBuf.count)
if bytesRead <= 0 { break }
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 5, 2026

Choose a reason for hiding this comment

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

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.

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

<comment>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.</comment>

<file context>
@@ -6127,6 +6148,190 @@ class TerminalController {
+            if pfd.revents & Int16(POLLIN) != 0 {
+                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) {
</file context>
Fix with Cubic


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)
}
}
Comment on lines +6293 to +6304
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") {

}
}

// Cleanup
ghostty_surface_pty_tap_close(surface)
fcntl(socket, F_SETFL, origFlags)
}

/// Handles a single newline-delimited JSON message from the stream client.
private nonisolated func handleStreamInput(_ jsonLine: String, surface: ghostty_surface_t) {
guard let data = jsonLine.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = obj["type"] as? String else { return }

switch type {
case "input":
guard let b64 = obj["data"] as? String,
let bytes = Data(base64Encoded: b64) else { return }
bytes.withUnsafeBytes { rawBuf in
guard let ptr = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return }
_ = ghostty_surface_pty_write(surface, ptr, UInt32(rawBuf.count))
}
case "resize":
// Future: handle terminal resize
break
default:
break
}
}

// MARK: - V2 Pane Methods

private func v2PaneList(params: [String: Any]) -> V2CallResult {
Expand Down