Skip to content

Commit fd05996

Browse files
authored
Frames cross processes as mach send-rights; delete the global-IOSurface patch (#16)
kIOSurfaceIsGlobal (patch 0002) made every headless render target world-readable: any local process that guessed a uint32 surface ID could read live terminal pixels. Replace it with Apple's sanctioned mechanism for passing a surface between tasks (IOSurfaceRef.h: "atomically or securely"): IOSurfaceCreateMachPort in the host, IOSurfaceLookupFromMachPort in the parent — an unguessable capability. Electron's parentPort can't carry mach send-rights, so the addon grows a small channel (~180 lines): machChannelCreate(name) parent: receive port, bootstrap_register machSenderOpen(name) host: bootstrap_look_up (name via env) machSendSurface(s, h, n) host: wrap surface, MOVE_SEND + inline seq machChannelReceiveSurface parent: bounded mach_msg receive -> +1 ref Sequencing keeps it thread-free: the host sends the mach message BEFORE the 'frame' JS message, and flow control is one-frame-in-flight, so when the parent handles the JS message exactly one mach message is already queued — the receive is immediate, no dedicated receive thread. A live port holds +1 on the surface's global use count, so an in-flight frame can't be recycled even across a resize; both sides deallocate rights promptly (send-timeout failure path included, so a dead parent doesn't leak use counts in the host). patches/0002 is reverted from the vendor checkout and deleted — the fork shrinks back to patch 0001 (headless platform) alone, which also cleans up the upstream story. surfaceLookup/surfaceId are gone from the addon surface; docs updated (security caveat removed, mechanism documented in both READMEs). Verified: all 7 utility-engine protocol scenarios, 7 e2e, 6 integration tests pass over the mach-port path.
1 parent 4b4b320 commit fd05996

6 files changed

Lines changed: 324 additions & 204 deletions

File tree

README.md

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -145,26 +145,27 @@ embedding is the main open workstream.
145145
└───────────────────┘
146146
```
147147

148-
Two small patches to a pinned ghostty checkout make this possible
149-
(`patches/`, applied by `npm run setup:ghostty`):
150-
151-
1. **`0001` — headless apprt platform.** `GHOSTTY_PLATFORM_HEADLESS`
152-
(a surface with no NSView; the Metal backend's IOSurfaceLayer works
153-
standalone) plus `ghostty_surface_headless_frame()` returning the
154-
last presented IOSurface. This is the piece worth proposing
155-
upstream — small, additive, independently useful for screenshots,
156-
testing, and embedding.
157-
2. **`0002` — global IOSurfaces for headless targets.** Frames must be
158-
representable cross-process; `kIOSurfaceIsGlobal` makes a frame a
159-
lookup-able `uint32`. ⚠️ Deprecated by Apple and **insecure** (any
160-
local process that guesses an ID can read the terminal's pixels) —
161-
acceptable for research, not for shipping. The production path is a
162-
mach-port handoff (`IOSurfaceCreateMachPort`), which needs a small
163-
native channel since Electron's `parentPort` can't carry mach
164-
send-rights.
165-
166-
The N-API addon (`packages/electron-ghostty/src/addon.c`, ~700 lines)
167-
is pure marshalling around `ghostty.h` — ghostty does the work.
148+
One small patch to a pinned ghostty checkout makes this possible
149+
(`patches/0001`, applied by `npm run setup:ghostty`): the headless
150+
apprt platform — `GHOSTTY_PLATFORM_HEADLESS` (a surface with no
151+
NSView; the Metal backend's IOSurfaceLayer works standalone) plus
152+
`ghostty_surface_headless_frame()` returning the last presented
153+
IOSurface. This is the piece worth proposing upstream — small,
154+
additive, independently useful for screenshots, testing, and
155+
embedding.
156+
157+
Frames cross the engine→main process boundary as **mach send-rights**
158+
(`IOSurfaceCreateMachPort` in the host, a bootstrap-registered channel
159+
— Electron's `parentPort` can't carry mach rights —
160+
`IOSurfaceLookupFromMachPort` in the parent). That's Apple's
161+
recommended mechanism for passing a surface "atomically or securely to
162+
another task": an unguessable capability, no fork patch needed. Each
163+
in-flight port holds +1 on the surface's global use count, so a frame
164+
can't be recycled mid-transfer.
165+
166+
The N-API addon (`packages/electron-ghostty/src/addon.c`, ~900 lines)
167+
is marshalling around `ghostty.h` plus that mach channel — ghostty
168+
does the work.
168169

169170
## Platform matrix (honest)
170171

@@ -228,8 +229,6 @@ native/renderer-poc/ Linux EGL/headless probe experiments
228229
## Caveats
229230

230231
- Research-grade. The package is not published and its API will change.
231-
- Patch `0002`'s global IOSurfaces mean terminal pixels are readable by
232-
any local process — do not ship this as-is (see above).
233232
- ghostty is pinned (`scripts/setup-ghostty.sh`); the embedding pokes
234233
one private detail (the IOSurfaceLayer `contents` property) that an
235234
upstream API should replace.

packages/electron-ghostty/README.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ loop, forward input, ship each presented frame.
1818
By default the engine doesn't even run in the Electron main process:
1919
`host.js` runs the whole terminal in an Electron **utilityProcess**, so
2020
a busy or crashed terminal can't stall window management. Frames cross
21-
the process boundary as global IOSurface IDs (a `uint32` over
22-
`parentPort`); the main process re-derives a local `IOSurfaceRef` via
23-
`IOSurfaceLookup` and imports it into `sharedTexture` — still zero-copy,
24-
the pixels never leave the GPU. Set `engine: 'main'` to run the engine
25-
in-process (the original mode; tests use it for synchronous pixel
26-
assertions).
21+
the process boundary as **mach send-rights**
22+
(`IOSurfaceCreateMachPort` → bootstrap channel →
23+
`IOSurfaceLookupFromMachPort`) — an unguessable capability, per
24+
Apple's recommended mechanism for passing surfaces between tasks; the
25+
main process imports each received surface into `sharedTexture`
26+
still zero-copy, the pixels never leave the GPU. Set `engine: 'main'`
27+
to run the engine in-process (the original mode; tests use it for
28+
synchronous pixel assertions).
2729

2830
macOS-only for now (Metal + IOSurface). Linux needs an EGL/GBM
2931
presenter (probe experiments in `native/renderer-poc/`); Windows has
@@ -32,11 +34,6 @@ iteration had a working D3D11/DirectWrite producer, last at repo
3234
commit `1a4357c`. See `docs/ghostty-renderer-reuse.md` at the repo
3335
root.
3436

35-
⚠️ **Security caveat:** frames cross the engine→main process boundary
36-
as *global* IOSurfaces (`kIOSurfaceIsGlobal`, patch `0002`), meaning
37-
any local process that guesses a surface ID can read the terminal's
38-
pixels. Acceptable for research, not for shipping; the production path
39-
is an `IOSurfaceCreateMachPort` handoff.
4037

4138
## Usage
4239

packages/electron-ghostty/host.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,32 @@
55
* Runs the entire terminal engine (ghostty: PTY + shell, VT parsing,
66
* input encoding, fonts, Metal rendering) OUTSIDE the Electron main
77
* process, so a busy or crashing terminal can't stall window
8-
* management. The main process stays a thin presenter: we post the
9-
* global IOSurfaceID of each presented frame over parentPort; the main
10-
* side re-derives a local IOSurfaceRef via IOSurfaceLookup and imports
11-
* it into sharedTexture — still zero-copy, frames never leave the GPU.
8+
* management. The main process stays a thin presenter: it receives
9+
* each presented frame as a mach send-right and imports it into
10+
* sharedTexture — still zero-copy, frames never leave the GPU.
11+
*
12+
* Frame handoff: per frame, the IOSurface crosses the process
13+
* boundary as a mach send-right (IOSurfaceCreateMachPort) over a
14+
* bootstrap-registered channel — an unguessable capability, unlike
15+
* the deprecated global-IOSurface IDs. The mach message is sent
16+
* FIRST, then a 'frame' JS message with the metadata; the parent
17+
* receives the (already queued) port on handling the JS message.
1218
*
1319
* Frame flow control: one frame in flight — we don't post the next
1420
* frame until the parent acks the previous one, so a slow presenter
1521
* degrades to skipped frames instead of a queue of stale messages.
22+
* The in-flight frame's port holds +1 on the surface's global use
23+
* count, so it can't be recycled mid-transfer.
1624
*
1725
* Protocol (parentPort messages, all {type, ...}):
1826
* in : create {opts} | frame-ack | key/text/mouse-* {..} |
1927
* resize {widthPx,heightPx} | draw | read-pixels {id} |
2028
* size {id} | destroy
21-
* out: created | frame {surfaceId,width,height,scale} | exit |
29+
* out: created | frame {seq,width,height,scale} | exit |
2230
* reply {id, result} | error {message}
31+
*
32+
* The mach channel name arrives in ELECTRON_GHOSTTY_MACH_CHANNEL
33+
* (set by the parent before fork).
2334
*/
2435
const { load } = require('./addon');
2536

@@ -28,11 +39,15 @@ const PRESENT_INTERVAL_MS = 8; // ~120Hz poll of ghostty's swap chain
2839
const addon = load();
2940
addon.init();
3041

42+
const sender = addon.machSenderOpen(
43+
process.env.ELECTRON_GHOSTTY_MACH_CHANNEL, 5000);
44+
3145
let session = null;
3246
let presentTimer = null;
3347
let lastFramePtr = null;
3448
let awaitingAck = false;
3549
let exited = false;
50+
let frameSeq = 0;
3651

3752
function post(msg) {
3853
process.parentPort.postMessage(msg);
@@ -57,10 +72,15 @@ function presentTick() {
5772
const ptr = frame.handle.toString('hex');
5873
if (ptr === lastFramePtr) return;
5974
lastFramePtr = ptr;
75+
// Mach message first (the port right travels out-of-band), then the
76+
// JS metadata message. If the mach send fails (parent gone, queue
77+
// full) skip this frame — the swap chain will produce another.
78+
const seq = ++frameSeq;
79+
if (!addon.machSendSurface(sender, frame.handle, seq)) return;
6080
awaitingAck = true;
6181
post({
6282
type: 'frame',
63-
surfaceId: frame.surfaceId,
83+
seq,
6484
width: frame.width,
6585
height: frame.height,
6686
scale: frame.scale,

packages/electron-ghostty/index.js

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
*
2222
* Engine placement — opts.engine:
2323
* 'utility' (default) the whole engine runs in an Electron
24-
* utilityProcess (host.js); the main process only re-derives
25-
* each presented frame from its global IOSurfaceID
26-
* (IOSurfaceLookup) and imports it into sharedTexture. A busy
27-
* or crashed terminal can't stall window management, and the
28-
* path stays zero-copy — frames never leave the GPU.
24+
* utilityProcess (host.js); each presented frame arrives in the
25+
* main process as a mach send-right (IOSurfaceCreateMachPort)
26+
* and is imported into sharedTexture. A busy or crashed
27+
* terminal can't stall window management, and the path stays
28+
* zero-copy — frames never leave the GPU.
2929
* 'main' the engine runs in this process (the original mode; also
3030
* what tests use to assert on pixels synchronously).
3131
*/
@@ -134,25 +134,35 @@ class LocalEngine {
134134
}
135135
}
136136

137+
let machChannelSeq = 0;
138+
137139
/**
138-
* Engine in an Electron utilityProcess (host.js). Frames arrive as
139-
* global IOSurfaceIDs; we re-derive a local IOSurfaceRef via
140-
* IOSurfaceLookup (addon, no ghostty init needed) and present it.
141-
* State queries (size/readPixels) become async — the sync accessors
142-
* serve the last known size, tests use the async variants.
140+
* Engine in an Electron utilityProcess (host.js). Frames cross the
141+
* process boundary as mach send-rights (IOSurfaceCreateMachPort over
142+
* a bootstrap channel — Electron's parentPort can't carry mach
143+
* rights); each 'frame' JS message means exactly one mach message is
144+
* already queued, so the receive here is immediate. State queries
145+
* (size/readPixels) become async — the sync accessors serve the last
146+
* known size, tests use the async variants.
143147
*/
144148
class UtilityEngine {
145149
constructor(term, opts) {
146150
this._term = term;
147-
this._addon = load(); // only for surfaceLookup/surfaceRelease
151+
this._addon = load(); // mach channel + surfaceRelease
148152
this._exited = false;
149153
this._lastSize = null;
150154
this._replies = new Map(); // id -> resolve
151155
this._replySeq = 0;
156+
const channelName =
157+
`electron-ghostty.${process.pid}.${++machChannelSeq}`;
158+
this._machChannel = this._addon.machChannelCreate(channelName);
152159
this._child = utilityProcess.fork(
153160
path.join(__dirname, 'host.js'),
154161
[],
155-
{ serviceName: 'electron-ghostty engine' },
162+
{
163+
serviceName: 'electron-ghostty engine',
164+
env: { ...process.env, ELECTRON_GHOSTTY_MACH_CHANNEL: channelName },
165+
},
156166
);
157167
this._child.on('message', (msg) => this._onMessage(msg));
158168
this._child.on('exit', () => {
@@ -177,16 +187,23 @@ class UtilityEngine {
177187
_onMessage(msg) {
178188
switch (msg.type) {
179189
case 'frame': {
180-
const surf = this._addon.surfaceLookup(msg.surfaceId);
181-
if (surf) {
190+
// One-frame-in-flight: this JS message means exactly one mach
191+
// message is queued on our channel. Drain any stale frames
192+
// (shouldn't happen; belt-and-suspenders) and keep the newest.
193+
let recv = this._addon.machChannelReceiveSurface(this._machChannel, 250);
194+
while (recv && recv.seq < msg.seq) {
195+
this._addon.surfaceRelease(recv.handle);
196+
recv = this._addon.machChannelReceiveSurface(this._machChannel, 250);
197+
}
198+
if (recv) {
199+
const surf = recv.handle;
182200
const ok = this._term._presentFrame(
183201
{ ioSurface: surf }, msg.width, msg.height);
184202
const release = () => this._addon.surfaceRelease(surf);
185203
if (ok && typeof ok.then === 'function') ok.then(release, release);
186204
else release();
187205
}
188-
// Ack regardless — a failed lookup (surface already recycled,
189-
// e.g. right after a resize) must not wedge the frame flow.
206+
// Ack regardless — a missed receive must not wedge the flow.
190207
this._child.postMessage({ type: 'frame-ack' });
191208
break;
192209
}

0 commit comments

Comments
 (0)