Skip to content

Expose manual embedded IO for iOS - #53

Merged
lawrencecchen merged 1 commit into
mainfrom
cmux-ios-manual-io-minimal-clean-20260501
May 1, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
cmux-ios-manual-io-minimal-clean-20260501

Conversation

@lawrencecchen

Copy link
Copy Markdown

Summary

  • expose manual embedded surface IO through the C API
  • wire the existing manual termio backend into embedded surfaces
  • add synchronous render and committed text input entrypoints for libghostty iOS clients

Verification

  • zig build test
  • CMUX_GHOSTTYKIT_NO_PREBUILT=1 ./scripts/ensure-ghosttykit.sh from cmux parent worktree

cmux iOS requirement

This keeps the old iOS app API surface available without taking stale xcframework/build-system changes from the old branch.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

@coderabbitai

coderabbitai Bot commented May 1, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@lawrencecchen has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 minutes and 30 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b6f47121-8873-492a-9ec4-34495ac6bf69

📥 Commits

Reviewing files that changed from the base of the PR and between 4953167 and 22fa801.

📒 Files selected for processing (10)
  • include/ghostty.h
  • src/Surface.zig
  • src/apprt/embedded.zig
  • src/input.zig
  • src/input/text.zig
  • src/renderer/Thread.zig
  • src/termio.zig
  • src/termio/Manual.zig
  • src/termio/Termio.zig
  • src/termio/backend.zig
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cmux-ios-manual-io-minimal-clean-20260501

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
Review rate limit: 0/1 reviews remaining, refill in 48 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 10 files

Prompt for AI agents (unresolved issues)

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


<file name="src/renderer/Thread.zig">

<violation number="1" location="src/renderer/Thread.zig:202">
P1: `renderNow` executes renderer and mailbox processing on an arbitrary caller thread while the dedicated renderer thread is still active, introducing thread-affinity violations and race conditions.</violation>
</file>

<file name="src/termio/Termio.zig">

<violation number="1" location="src/termio/Termio.zig:448">
P2: Manual backend drops `.selection_scroll` messages, so selection autoscroll ticks are never generated.</violation>

<violation number="2" location="src/termio/Termio.zig:452">
P1: Manual backend ignores `.start_synchronized_output`, removing the watchdog reset for synchronized-output mode.</violation>
</file>

<file name="src/Surface.zig">

<violation number="1" location="src/Surface.zig:2557">
P2: `applyPendingResizeIfNeeded` returns too early and can skip pixel-dimension updates when grid size is unchanged.</violation>
</file>

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

Comment thread src/renderer/Thread.zig
/// This bypasses the xev event loop, which is necessary on iOS where
/// xev async notifications do not reach the renderer thread.
pub fn renderNow(self: *Thread) void {
self.drainMailbox() catch |err|

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: renderNow executes renderer and mailbox processing on an arbitrary caller thread while the dedicated renderer thread is still active, introducing thread-affinity violations and race conditions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/renderer/Thread.zig, line 202:

<comment>`renderNow` executes renderer and mailbox processing on an arbitrary caller thread while the dedicated renderer thread is still active, introducing thread-affinity violations and race conditions.</comment>

<file context>
@@ -195,6 +195,24 @@ pub fn deinit(self: *Thread) void {
+/// This bypasses the xev event loop, which is necessary on iOS where
+/// xev async notifications do not reach the renderer thread.
+pub fn renderNow(self: *Thread) void {
+    self.drainMailbox() catch |err|
+        log.err("renderNow: error draining mailbox err={}", .{err});
+
</file context>

Comment thread src/termio/Termio.zig
.jump_to_prompt => |v| self.jumpToPrompt(v) catch |err| {
log.warn("manual inline jump_to_prompt failed err={}", .{err});
},
.start_synchronized_output => {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Manual backend ignores .start_synchronized_output, removing the watchdog reset for synchronized-output mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/termio/Termio.zig, line 452:

<comment>Manual backend ignores `.start_synchronized_output`, removing the watchdog reset for synchronized-output mode.</comment>

<file context>
@@ -394,13 +398,96 @@ pub fn queueMessage(
+        .jump_to_prompt => |v| self.jumpToPrompt(v) catch |err| {
+            log.warn("manual inline jump_to_prompt failed err={}", .{err});
+        },
+        .start_synchronized_output => {},
+        .linefeed_mode => |v| self.manual_linefeed_mode.store(v, .monotonic),
+        .focused => |v| self.focusGained(&td, v) catch |err| {
</file context>

Comment thread src/termio/Termio.zig
log.warn("manual inline clear_screen failed err={}", .{err});
},
.scroll_viewport => |v| self.scrollViewport(v),
.selection_scroll => {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Manual backend drops .selection_scroll messages, so selection autoscroll ticks are never generated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/termio/Termio.zig, line 448:

<comment>Manual backend drops `.selection_scroll` messages, so selection autoscroll ticks are never generated.</comment>

<file context>
@@ -394,13 +398,96 @@ pub fn queueMessage(
+            log.warn("manual inline clear_screen failed err={}", .{err});
+        },
+        .scroll_viewport => |v| self.scrollViewport(v),
+        .selection_scroll => {},
+        .jump_to_prompt => |v| self.jumpToPrompt(v) catch |err| {
+            log.warn("manual inline jump_to_prompt failed err={}", .{err});
</file context>

Comment thread src/Surface.zig
defer self.renderer_state.mutex.unlock();
const t = self.renderer_state.terminal;

if (t.cols == grid_size.columns and t.rows == grid_size.rows) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: applyPendingResizeIfNeeded returns too early and can skip pixel-dimension updates when grid size is unchanged.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Surface.zig, line 2557:

<comment>`applyPendingResizeIfNeeded` returns too early and can skip pixel-dimension updates when grid size is unchanged.</comment>

<file context>
@@ -2517,6 +2543,32 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void {
+    defer self.renderer_state.mutex.unlock();
+    const t = self.renderer_state.terminal;
+
+    if (t.cols == grid_size.columns and t.rows == grid_size.rows) return;
+
+    t.resize(
</file context>
Suggested change
if (t.cols == grid_size.columns and t.rows == grid_size.rows) return;
if (t.cols == grid_size.columns and t.rows == grid_size.rows and t.width_px == grid_size.columns * self.size.cell.width and t.height_px == grid_size.rows * self.size.cell.height) return;

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR exposes a manual embedded IO mode for iOS through the C API, allowing a host app to own the PTY/session while Ghostty handles only rendering and input encoding. It wires a new Manual termio backend into Surface init, adds synchronous renderNow and textInputCallback entry points, and routes all write messages inline (bypassing the xev event loop that doesn't fire reliably on iOS).

  • P1 — latent crash in queueMessageManual: td.loop is set to undefined (line 419 of Termio.zig); no current path dereferences it, but any future change adding td.loop usage to resize, focusGained, or clearScreen in the manual dispatch path will silently segfault.
  • P2 — ghostty_surface_process_output missing mode guard: the function can be called on exec-mode surfaces, interleaving injected bytes with PTY reads and corrupting terminal state.

Confidence Score: 3/5

The P1 td.loop = undefined issue requires a comment or assertion before merge; the overall design is sound but has one latent crash risk.

One P1 (undefined loop pointer used as a stack local without any guard or assertion) pulls the score below the P1 ceiling of 4. The P2 mode-guard omission on process_output and the silent message drops add marginal risk. Core logic is well-structured.

src/termio/Termio.zig — queueMessageManual undefined loop field; src/apprt/embedded.zig — ghostty_surface_process_output missing manual-mode check

Important Files Changed

Filename Overview
include/ghostty.h Adds ghostty_surface_io_mode_e enum, ghostty_io_write_cb typedef, io config fields, and three new API functions (render_now, text_input, process_output); types are consistent with Zig definitions
src/Surface.zig Wires manual IO backend selection at init time and adds applyPendingResizeIfNeeded, textInputCallback, and completeTextInput; logic is sound though applyPendingResizeIfNeeded is effectively redundant with queueMessageManual inline resize handling
src/apprt/embedded.zig Adds IoMode/IoWriteCallback types, stores them on Surface, exposes ioMode/ioWriteCallback/ioWriteUserdata getters, and implements renderNow, textInputCallback, and ghostty_surface_process_output; the process_output export lacks a manual-mode guard
src/termio/Termio.zig Adds manual_linefeed_mode atomic, inline queueMessageManual dispatch, and queueWriteManual; td.loop = undefined is a latent crash risk; selection_scroll and start_synchronized_output are silently dropped without explanation
src/termio/Manual.zig Trivial changes: migrates ArrayList to ArrayList.empty + explicit allocator API; logic unchanged and test passes
src/input/text.zig New module implementing \n→\r normalization for committed text input; clean design with tests covering const, mutable, and no-newline cases
src/renderer/Thread.zig Adds renderNow to run a full render cycle synchronously from the calling thread, bypassing the xev loop; correct for iOS single-threaded rendering but caller must ensure the renderer thread is idle
src/termio/backend.zig Adds manual variant to all Backend switch expressions; getProcessInfo correctly returns null for manual backend

Sequence Diagram

sequenceDiagram
    participant iOS as iOS Client
    participant CAPI as C API (embedded.zig)
    participant Surface as Surface.zig
    participant Termio as Termio.zig
    participant Manual as Manual Backend
    participant Renderer as Renderer Thread

    Note over iOS,Renderer: Surface creation with manual IO
    iOS->>CAPI: ghostty_surface_new(io_mode=MANUAL, io_write_cb)
    CAPI->>Surface: init(use_manual_io=true)
    Surface->>Manual: Manual.init(write_cb, write_userdata)

    Note over iOS,Renderer: Input path (keystrokes to PTY)
    iOS->>CAPI: ghostty_surface_text_input(text)
    CAPI->>Surface: textInputCallback(text)
    Surface->>Surface: completeTextInput encode newline to CR
    Surface->>Termio: queueMessage(write_req)
    Termio->>Termio: queueMessageManual inline no xev
    Termio->>Manual: queueWrite(data, linefeed_mode)
    Manual->>iOS: io_write_cb(userdata, ptr, len)

    Note over iOS,Renderer: Output path (PTY bytes to terminal)
    iOS->>CAPI: ghostty_surface_process_output(bytes)
    CAPI->>Termio: processOutput(buf)
    Termio->>Termio: acquire renderer_state.mutex
    Termio->>Termio: terminal_stream.nextSlice(buf)

    Note over iOS,Renderer: Synchronous render cycle
    iOS->>CAPI: ghostty_surface_render_now()
    CAPI->>Surface: applyPendingResizeIfNeeded()
    Surface->>Termio: terminal.resize if size mismatch
    CAPI->>Renderer: renderNow()
    Renderer->>Renderer: drainMailbox()
    Renderer->>Renderer: updateFrame()
    Renderer->>Renderer: drawFrame(true)
Loading

Reviews (1): Last reviewed commit: "Expose manual embedded IO for iOS" | Re-trigger Greptile

Comment thread src/termio/Termio.zig
Comment on lines +417 to +424
var td: ThreadData = .{
.alloc = self.alloc,
.loop = undefined,
.renderer_state = self.renderer_state,
.surface_mailbox = self.surface_mailbox,
.backend = .{ .manual = .{} },
.mailbox = &self.mailbox,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 td.loop is set to undefined — latent segfault risk

queueMessageManual constructs a stack ThreadData with .loop = undefined. Currently no code path in this function reaches td.loop because Manual.queueWrite ignores td, but any future change that adds td.loop usage inside resize, focusGained, clearScreen, or sizeReport will silently dereference a garbage pointer. A comment documenting this invariant — or an explicit comptime assertion that the manual backend's downstream calls never touch loop — would prevent this footgun.

Comment thread src/apprt/embedded.zig
Comment on lines 1913 to 1926
surface.preeditCallback(if (len == 0) null else ptr[0..len]);
}

/// Process output bytes as if they were read from the PTY.
export fn ghostty_surface_process_output(
surface: *Surface,
ptr: [*]const u8,
len: usize,
) void {
if (len == 0) return;
surface.core_surface.io.processOutput(ptr[0..len]);
}

/// Returns true if the surface currently has mouse capturing

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 ghostty_surface_process_output lacks a manual-mode guard

processOutput is called unconditionally regardless of io_mode. On an exec-mode surface the PTY read thread is already calling processOutput concurrently; injecting additional bytes from the C API would interleave with PTY data and corrupt the terminal parse state. A guard avoids accidental misuse:

export fn ghostty_surface_process_output(
    surface: *Surface,
    ptr: [*]const u8,
    len: usize,
) void {
    if (len == 0) return;
    if (surface.io_mode != .manual) return;
    surface.core_surface.io.processOutput(ptr[0..len]);
}

Comment thread src/termio/Termio.zig
Comment on lines +448 to +452
.selection_scroll => {},
.jump_to_prompt => |v| self.jumpToPrompt(v) catch |err| {
log.warn("manual inline jump_to_prompt failed err={}", .{err});
},
.start_synchronized_output => {},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Silently dropped messages without explanation

.selection_scroll => {} and .start_synchronized_output => {} are no-ops with no comment. For selection_scroll, auto-scrolling during text selection won't work in manual mode. For start_synchronized_output, the synchronized-output timer that prevents partial-update flicker won't start. If these are intentionally unsupported on iOS (xev timer not available), a short comment clarifying that would help future maintainers distinguish "deliberate omission" from "forgotten case".

@lawrencecchen
lawrencecchen force-pushed the cmux-ios-manual-io-minimal-clean-20260501 branch from 1eafdc6 to 22fa801 Compare May 1, 2026 08:58
@lawrencecchen
lawrencecchen merged commit 41ab6c5 into main May 1, 2026
150 checks passed
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