Skip to content

renderer: thread-safe tokened forced draw for offscreen capture - #181

Merged
lawrencecchen merged 4 commits into
mainfrom
feat-tokened-offscreen-render
Aug 7, 2026
Merged

lawrencecchen merged 4 commits into
mainfrom
feat-tokened-offscreen-render

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Aug 6, 2026 •

Copy link
Copy Markdown

Adds ghostty_surface_request_render_with_token: queues one forced update+draw executed on the renderer thread via the existing draw_now async, acknowledged through the render-presented callback after the exact frame's main-thread IOSurface assignment.

Mechanism: a single-slot mutex-guarded pending FramePresentation on renderer.Thread; drawNowCallback consumes it, rebuilds frame data from current terminal state (updateFrame), and draws with drawFrameWithPresentation(sync=true). On Metal the presentation rides the command-buffer completion and is delivered on main in the same block that assigns the frame's IOSurface to the layer, so an embedder reading layer.contents from the callback observes at least that frame.

Why a new entry point: ghostty_surface_render_now_with_token performs the whole render cycle on the calling thread and is only safe when the embedder owns renderer state (iOS external-drain mode). On macOS the renderer OS thread is live, and draining the mailbox from a foreign thread mutates libxev loop timers unsafely. This API only fills the slot and rings draw_now, so it is safe from any thread.

The forced draw deliberately skips the flags.visible occlusion gate (but keeps the renderer_realized gate): its purpose is ground-truth capture of a surface whose window is occluded, on another Space, or never ordered front. cmux uses it for the debug.surface.screenshot debug-socket RPC (cmux PR to follow; the cmux submodule pointer bump depends on this PR merging first).

Note: branch is based on the SHA cmux main currently pins (19d03fa), which is itself ahead of this fork's main via a pending PR; the extra commits below mine will disappear from this diff once that lands.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds a thread-safe API to request a tokened offscreen render on the renderer thread, enabling ground-truth screenshots even when the window is occluded. Also reduces noisy opener logs and fixes a startup locale race.

  • New Features

    • Added ghostty_surface_request_render_with_token(...) to queue one forced update+draw on the renderer thread.
    • Uses draw_now and delivers the exact frame’s token via the render-presented callback after IOSurface assignment.
    • Ignores occlusion; rejects requests with no callback or while one is pending. If the renderer is unrealized or the surface is zero-sized/size-discarded, the draw is skipped and no callback fires.
  • Bug Fixes

    • drainStderr: ignore blank lines and bound repeats; tests for EOF and repeated blank output.
    • Initialize locale before crash reporting to avoid races during embed startup.
    • Use std.Io.Mutex for the pending presentation slot for Zig 0.16 compatibility.

Written for commit a52417b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added an API for requesting an asynchronous, forced render with a unique token.
    • Render completion callbacks now report only after the requested frame is presented.
    • Requests safely skip unavailable or already-pending renders and do not trigger callbacks when rendering is skipped.
  • Documentation

    • Clarified locale initialization requirements to improve crash-reporting reliability on macOS.

Add ghostty_surface_request_render_with_token: queue one forced
update+draw executed on the renderer thread via the existing draw_now
async, acknowledged through the render-presented callback after the
exact frame's main-thread IOSurface assignment. Unlike
ghostty_surface_render_now_with_token (which renders on the calling
thread and requires embedder-owned renderer state, i.e. iOS external
drain), this is safe from any thread while the renderer OS thread is
live, and deliberately skips the occlusion visibility gate so an
occluded window still renders a fresh ground-truth frame. cmux uses it
for the debug-socket offscreen surface screenshot RPC.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c65592d7-339e-4961-9aa4-95a952665e92

📥 Commits

Reviewing files that changed from the base of the PR and between 8062278 and a52417b.

📒 Files selected for processing (4)
  • include/ghostty.h
  • src/apprt/embedded.zig
  • src/global.zig
  • src/renderer/Thread.zig

📝 Walkthrough

Walkthrough

The change adds a C API for tokened forced rendering. Requests queue on the renderer thread, allow one pending token, bypass occlusion, and report presentation after the exact frame reaches the platform layer. It also documents Darwin locale initialization ordering.

Changes

Tokened rendering

Layer / File(s) Summary
Render request contract
include/ghostty.h, src/apprt/embedded.zig
The public API and embedded surface method accept tokened render requests. Requests return false without a presentation callback or when another tokened draw is pending.
Renderer-thread tokened draw
src/renderer/Thread.zig
The renderer stores one pending token, wakes the renderer thread, rebuilds frame data, skips unrealized renderers, and delivers the token after the forced draw reaches presentation.

Locale initialization

Layer / File(s) Summary
Locale initialization ordering
src/global.zig
The initialization comment documents Darwin locale mutation before Sentry background initialization.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EmbeddedSurface
  participant RendererThread
  participant PlatformLayer
  participant PresentationCallback
  EmbeddedSurface->>RendererThread: queue tokened forced render
  RendererThread->>RendererThread: consume token and rebuild frame data
  RendererThread->>PlatformLayer: submit exact frame for presentation
  PlatformLayer->>PresentationCallback: report presented token
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a thread-safe tokened forced draw for offscreen capture.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-tokened-offscreen-render

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.

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

🤖 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 `@src/os/open.zig`:
- Around line 197-198: Update the assertion in the relevant open-process logging
test so the blank-line-only child output requires logged to equal zero, rather
than merely being below ten; preserve the existing test setup and logging
behavior.
- Around line 130-134: Update the test “drainStderr drains blank lines without
reporting” to also assert that the reader is fully consumed by checking r.end -
r.seek equals zero after drainStderr returns, while preserving the existing
zero-count assertion.
- Around line 107-110: Normalize each line’s trailing carriage return before the
blank-line check in the opener stderr-reading flow, so CRLF-only lines are
treated as empty and skipped. Add a regression test covering blank CRLF output
and verify no diagnostic record is emitted for it.

In `@src/renderer/Thread.zig`:
- Around line 1838-1850: Prevent tokened draw requests from becoming stranded
when external drain is active: update Thread.zig lines 1838-1850 to have the
external renderer owner consume the request or reject it without retaining the
pending slot, and synchronize request acceptance with external-drain activation
in Thread.zig lines 1026-1043. Preserve the corrected acceptance result through
Surface.requestRenderWithToken in src/apprt/embedded.zig lines 1414-1429 and its
C ABI wrapper at lines 3149-3156, and document any platform or external-drain
restriction in include/ghostty.h lines 1394-1406.
🪄 Autofix

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 Plus

Run ID: b480837a-77f3-4ef3-a9fb-ef91c70e09c2

📥 Commits

Reviewing files that changed from the base of the PR and between 6dc6710 and 8062278.

📒 Files selected for processing (5)
  • include/ghostty.h
  • src/apprt/embedded.zig
  • src/global.zig
  • src/os/open.zig
  • src/renderer/Thread.zig

Comment thread src/os/open.zig
Comment on lines +107 to +110
// Blank lines contain no diagnostic information. Keep consuming them
// so the child can exit, but do not emit repeated `open stderr=`
// records if an opener writes blank output.
if (line.len == 0) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)open\.zig$|src/os/open\.zig$' || true

echo
echo "Relevant src/os/open.zig lines:"
if [ -f src/os/open.zig ]; then
  nl -ba src/os/open.zig | sed -n '80,140p'
fi

echo
echo "Search takeDelimiter and blank line handling:"
rg -n "takeDelimiter|line\.len|stderr=|Windows|crlf|\\r?\\n" -C 3 .

Repository: manaflow-ai/ghostty

Length of output: 254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant src/os/open.zig lines:"
sed -n '80,220p' src/os/open.zig | awk '{printf "%6d\t%s\n", NR+79, $0}'

echo
echo "Search takeDelimiter and blank line handling:"
rg -n "takeDelimiter|line\.len|stderr=|Windows|crlf|cr|\\\\r" -C 3 .

Repository: manaflow-ai/ghostty

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant src/os/open.zig lines:"
sed -n '80,220p' src/os/open.zig | awk '{printf "%6d\t%s\n", NR+79, $0}'

echo
echo "Focused search in src/os/open.zig and tests containing open:"
rg -n "takeDelimiter|drainStderr|line\.len|stderr=|Windows|os\.open|openStderr|skipWindows|skip_windows|\\\\r|\\\\n" src/os/open.zig src test -C 3 || true

echo
echo "Behavioral probe of CRLF-delimited line in Zig-like stream logic:"
python3 - <<'PY'
import re
data = b"open stderr=test\r\nfoo\r\n\r\n"
lines = data.split(b"\n")
for raw in lines:
    line = raw[:-1] if raw[-1:] == b"\r" else raw
    print(repr(raw), "len", len(raw), "stripped_len", len(line), "is_blank=", len(line) == 0)
PY

Repository: manaflow-ai/ghostty

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "src/os/open.zig first 240 lines:"
awk '{printf "%6d\t%s\n", NR, $0}' src/os/open.zig | sed -n '1,240p'

echo
echo "open.zig occurrences of delimiters/tests:"
rg -n "takeDelimiter|buf\.readUntilDelimiter|drainStderr|line\.len|stderr=|Windows|windows|\\\\r|\\\\n|crlf" src/os/open.zig -C 3

echo
echo "Behavior: empty CRLF vs LF line length after LF-delimited reads:"
python3 - <<'PY'
test_cases = b"a\r\n\r\n"
line_buf = bytearray()
for byte in test_cases:
    line_buf.append(byte)
    if byte == 10:  # '\n'
        raw = bytes(line_buf)[:-1] if len(line_buf) >= 1 and line_buf[-1] == 10 else bytes(line_buf)
        line_buf.clear()
        print(repr(raw), "line.len", len(raw), "is_zero_len", len(raw) == 0)
PY

Repository: manaflow-ai/ghostty

Length of output: 14749


Normalize CRLF lines before the blank-line check.

takeDelimiter('\n') leaves \r on Windows for a line like \r\n, so a blank CRLF line has line.len == 1 and is logged. Normalize the trailing \r before line.len == 0 and add a CRLF regression test.

🤖 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 `@src/os/open.zig` around lines 107 - 110, Normalize each line’s trailing
carriage return before the blank-line check in the opener stderr-reading flow,
so CRLF-only lines are treated as empty and skipped. Add a regression test
covering blank CRLF output and verify no diagnostic record is emitted for it.

Comment thread src/os/open.zig
Comment on lines +130 to +134
test "drainStderr drains blank lines without reporting" {
// The old `takeDelimiterExclusive` loop spun forever here: the seek position
// parks on the delimiter and every subsequent read returns an empty slice.
var r: std.Io.Reader = .fixed("\n\n\n");
try std.testing.expectEqual(@as(usize, 3), drainStderr(&r));
try std.testing.expectEqual(@as(usize, 0), drainStderr(&r));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert end-of-stream in the blank-line test.

The test checks only the reported count. A regression that stops after the first blank line can return 0 and still pass while leaving input unread. Also assert that r.end - r.seek is zero.

🤖 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 `@src/os/open.zig` around lines 130 - 134, Update the test “drainStderr drains
blank lines without reporting” to also assert that the reader is fully consumed
by checking r.end - r.seek equals zero after drainStderr returns, while
preserving the existing zero-count assertion.

Comment thread src/os/open.zig
Comment on lines +197 to +198
// A short burst of identical blank lines must not become a log flood.
try testing.expect(logged < 10);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert zero reported lines.

The child emits only blank lines, so logged must equal 0. The current logged < 10 assertion allows regressions that report up to nine blank lines.

🤖 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 `@src/os/open.zig` around lines 197 - 198, Update the assertion in the relevant
open-process logging test so the blank-line-only child output requires logged to
equal zero, rather than merely being below ten; preserve the existing test setup
and logging behavior.

Comment thread src/renderer/Thread.zig
Comment on lines 1838 to +1850
if (t.externalDrainActive()) return .rearm;

// cmux fork: a queued tokened draw takes this wake. It rebuilds frame data
// from current terminal state and skips the `flags.visible` gate on
// purpose (drawFrame's early-return): the whole point of the tokened path
// is ground-truth capture of a window the compositor considers occluded.
// The renderer-realized gate still applies (drawing unrealized GPU state
// is invalid); an unconsumed presentation is dropped and its callback
// never fires, which the embedder surfaces as a timeout.
if (t.takePendingDrawPresentation()) |presentation| {
drawPendingTokenedFrame(t, presentation);
return .rearm;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Reject or service tokened requests in external-drain mode.

When external_drain is active, Line 1838 returns before takePendingDrawPresentation. requestDrawWithPresentation can still return true and leave the pending slot occupied forever. The callback never receives the token, and all later requests return false.

  • src/renderer/Thread.zig#L1838-L1850: consume the request through the external renderer owner, or reject it without retaining the slot.
  • src/renderer/Thread.zig#L1026-L1043: synchronize rejection with external-drain activation so an accepted request cannot become stranded.
  • src/apprt/embedded.zig#L1414-L1429: preserve the corrected acceptance behavior in Surface.requestRenderWithToken.
  • src/apprt/embedded.zig#L3149-L3156: preserve the corrected acceptance behavior in the C ABI wrapper.
  • include/ghostty.h#L1394-L1406: document any platform or external-drain restriction.
📍 Affects 3 files
  • src/renderer/Thread.zig#L1838-L1850 (this comment)
  • src/renderer/Thread.zig#L1026-L1043
  • src/apprt/embedded.zig#L1414-L1429
  • src/apprt/embedded.zig#L3149-L3156
  • include/ghostty.h#L1394-L1406
🤖 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 `@src/renderer/Thread.zig` around lines 1838 - 1850, Prevent tokened draw
requests from becoming stranded when external drain is active: update Thread.zig
lines 1838-1850 to have the external renderer owner consume the request or
reject it without retaining the pending slot, and synchronize request acceptance
with external-drain activation in Thread.zig lines 1026-1043. Preserve the
corrected acceptance result through Surface.requestRenderWithToken in
src/apprt/embedded.zig lines 1414-1429 and its C ABI wrapper at lines 3149-3156,
and document any platform or external-drain restriction in include/ghostty.h
lines 1394-1406.

zig 0.16 removed std.Thread.Mutex; match the file's existing mutex idiom
(lockUncancelable(global.io()) / unlock(global.io())).
@lawrencecchen
lawrencecchen merged commit 46d82a5 into main Aug 7, 2026
104 of 105 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