Skip to content

New io - #627

Merged
migueldeicaza merged 31 commits into
mainfrom
new-io
Aug 18, 2026
Merged

New io#627
migueldeicaza merged 31 commits into
mainfrom
new-io

Conversation

@migueldeicaza

Copy link
Copy Markdown
Owner

No description provided.

Ghostty's termio read pipeline is now SwiftTerm's IO system, verified end to
end. The MacTerminal sample is launched and running on the new pipeline if you
want to feel it out.

- TerminalIOPipeline.swift — the two-stage design from
  ghostty/src/termio/Exec.zig: a gather thread drains the kernel pty queue into
  a ring of 4×64 KiB buffers with Ghostty's bridging logic (spin up to 16 on
  EAGAIN, 1 ms poll, 3 ms per-batch budget, idle self-pipe so interactive
  trickles under 1 KiB are never delayed), and a parse thread hands batches to
  the delegate. Ring-slot exhaustion is the backpressure: the kernel queue fills
  and the child blocks in write().

- LocalProcess.swift — DispatchIO reads and the whole 4 MB pendingChunks
  machinery are gone (~200 lines deleted); writes now go through one persistent
  DispatchIO channel over a dup() of the master, which also fixes the old
  per-call DispatchIO.write ordering hazard. Public API and delegate threading
  are unchanged — no changes needed in the Mac view, HeadlessTerminal, or the
  sample app.

- io-next-steps.md — the Phase 2 roadmap (off-main parsing under a terminal
  lock, snapshot-based rendering for both CG and Metal, the API shim), written
  so it can be executed as the next effort.

Results:

- Throughput: 256 MB cat through the pty went from 87–108 MB/s to 154–166
  MB/s (~60% faster), measured with a temporary harness against the stashed
  baseline.

- Tests: 444 tests pass, including 6 new pty-backed pipeline tests (integrity
  over 16 MB, EOF ordering, producer-stall backpressure, shutdown wakeup,
  trickle latency, bridging). Three of the new tests initially flaked under
  parallel load from the perf suites — I rewrote them to assert the functional
  properties (e.g. "delivered without EOF" instead of "< 100 ms") and they've
  been clean across many consecutive full runs.

- Review: Codex implemented from the frozen spec; I reviewed the diff and ran
  an 8-angle /code-review on top. It surfaced 7 confirmed issues which I fixed:
  a recycled-fd window at EOF (childfd now cleared before the pipeline closes the
  descriptor), silently dropped input if dup() fails, an errno value clobbered by
  a mutex before its EINTR check, a hang if fcntl fails at start, a leaked write
  fd on restart, plus a dead branch and redundant revents clearing. One
  efficiency idea (reusable scratch buffer instead of a per-batch 64 KiB
  allocation) I deliberately deferred to Phase 2 since it changes what clients
  may retain from dataReceived.
Introduces TerminalLock (non-recursive NSLock wrapper with unconditional
owner tracking) on Terminal, a withTerminal accessor on TerminalView, and
threads lock acquisition through every terminal/selection/search touchpoint
in the view layer: feed, updateDisplay (getUpdateRange+clearUpdateRange now
one critical section), drawTerminalContents, the Metal draw span, scroll,
resize, input, queries, and accessibility. Internal lock-assuming helpers
use the Locked suffix with debug-friendly preconditions; display scheduling
is coalesced behind a thread-safe scheduleDisplay gate guarded by a new
viewStateLock.

Behavior-neutral: parsing still arrives on the main thread. This is the
first of three work orders toward off-main VT parsing (Ghostty's model).
TerminalDelegate callbacks fire while the terminal lock is held; this
prepares every handler for a background parse thread. Hot callbacks
coalesce: scrolled sets a dirty flag consumed by updateDisplay, and
showCursor/hideCursor become display passes (updateCursorPositionLocked
reconciles caret visibility from cursorHidden, ending per-toggle subview
churn). UI-touching handlers hop to main via a shared onMain helper with
payloads captured at fire time. Value-returning callbacks serve from
lock-free cached view state. clipboardRead replies to OSC 52 queries
asynchronously (returns nil off-main, then reads the pasteboard on main
and self-sends the reply) so it can never main-sync against a parse
thread holding the lock.

Also fixes a latent bug: setCursorColor's old signature never matched the
TerminalDelegate requirement, so OSC 12 cursor-color updates were silently
dropped; the conformance now works. Queued callback bodies are guarded
against the terminal-assignment race during off-main view construction.

Parsing still arrives on the main thread
LocalProcess gains a directDelivery option: when set, the pipeline's
io-reader thread calls dataReceived inline instead of synchronously
hopping to the delivery queue. LocalProcessTerminalView opts in, so VT
parsing for the local terminal now runs off the main thread under the
terminal lock, with UI work marshalled back to main (WO1/WO2). Default
LocalProcess behavior is unchanged for existing users.

The iOS SSH sample feeds directly from the NIO channel thread (feed has
been lock-safe since WO1) and gains a DEBUG flood harness. Adds
direct-delivery and background-feed stress tests, and a Thread Sanitizer
CI job over the concurrency-sensitive suites. Throughput is at parity
with the Phase 1 pipeline (~160 MB/s pty drain in the cat benchmark).

This completes Phase 2 Stage A of the Ghostty IO model port; Stage B
(renderer snapshotting) is documented in io-next-steps.md.
# Conflicts:
#	Sources/SwiftTerm/Apple/AppleTerminalView.swift
#	Sources/SwiftTerm/Apple/Metal/MetalTerminalRenderer.swift
#	Sources/SwiftTerm/LocalProcess.swift
#	Sources/SwiftTerm/Mac/MacTerminalView.swift
#	Sources/SwiftTerm/iOS/iOSTerminalView.swift
# Conflicts:
#	Sources/SwiftTerm/Apple/AppleTerminalView.swift
#	Sources/SwiftTerm/Apple/Metal/MetalTerminalRenderer.swift
#	Sources/SwiftTerm/Mac/MacTerminalView.swift
#	Sources/SwiftTerm/iOS/iOSTerminalView.swift
Terminal renderer checks now read terminal state only while the terminal lock
is held. This prevents lock precondition failures when tests inspect colors,
attributes, or display buffer content. The checks use the public feed path and
the locked string builder to preserve the same locking rules as production
code.

Synchronization waits allow up to ten seconds for main-queue timeout work.
This avoids false failures when Thread Sanitizer or parallel main-actor work
delays the timeout callback. The wait still exits as soon as terminal output
synchronization finishes.
The merge audit paid off. Features merged from main (bidi, DECSCNM
reverse-video, terminfo) were written before the lock existed, and the audit
found seven real contract violations: five unlocked reads of
terminal.reverseColors on draw paths (caret layer, Metal clear color, iOS draw,
IME overlay), and two genuinely dangerous unsynchronized writes —
bidiHostPolicy and the selected-text color setters calling
terminal.updateFullScreen() while the parse thread could be writing the update
range. The fix mirrors the reverse-video flag into viewStateLock-guarded view
state refreshed synchronously by the colorChanged callback (so DECSCNM is
visible the moment feed returns — no staleness window), and lock-wraps the
setters.

Test hygiene got a sweep. The crash we hit (mapColor called unlocked by a test
from main) was one instance of a pattern; Codex fixed 111 sites across nine
test files so everything goes through view.feed/withTerminal. That pattern is
worth enforcing in review for future PRs — any test doing
view.terminal.feed(...) or calling Locked helpers directly is one DEC 2026
sequence away from trapping.
Adds TerminalSnapshot: pooled deep copies of the visible rows refreshed
under the terminal lock only when a row's source line identity/generation
or its bidi paragraph revision changed (the pool doubles as the cross-
frame cache; rows hold their source line strongly so identity comparison
cannot false-match a recycled allocation). The snapshot also captures
cursor render data with pre-resolved colors and attributes, selection/
link/blink style, kitty placements (COW dictionary copies, immutable
payload bytes aliased), ansi colors, and the dirty-region math formerly
computed inline in updateDisplay.

buildAttributedString now has a pure snapshot+context form with no live
terminal, selection, or view-state reads; drawTerminalContents renders
from the snapshot without taking the terminal lock, and the caret's
CTLine shaping moved out of the lock, fed by snapshot cursor data. The
Metal renderer still uses a thin locked wrapper until WO-B2. Also
removes the dead commented-out block that spanned live control flow in
updateDisplay.
The Metal renderer no longer acquires the terminal lock: draw(in:) asks
the view for a snapshot refresh (the only lock hold, bounded by the
copy) and builds all draw data from the snapshot and a per-tick render
context. The row cache is re-keyed to (source line identity, source
generation, row revision) — generation pins content, revision folds in
style/bidi/image changes, and identity is safe against reuse because
snapshot rows hold their source lines strongly. The per-frame bidi
revision walks and the metalDirtyRange machinery are gone; snapshot
style diffing invalidates rows instead. Kitty texture creation, PNG
decode, cache pruning, and MTLBuffer creation in both buffering modes
now all run outside the lock.
… B WO-B3)

Adds FrameDriver with three backends: CADisplayLink on iOS, the
NSView.displayLink API on macOS 14+, and a CVDisplayLink fallback for
macOS 11-13 whose CoreVideo-thread callback holds a retained target with
a weak driver reference (safe against a callback in flight during driver
deallocation) and coalesces to one main hop per vsync, with screen-change
rebinding and occlusion pausing. A ManualTickSource backend makes the
driver unit-testable.

frameTick replaces updateDisplay: consume dirty, refresh the snapshot
under one short lock hold (or freeze during synchronized output), then
position the caret, deliver delegate and accessibility notifications,
and invalidate the renderers, all outside the lock. Every legacy
scheduler is gone: scheduleDisplay, queuePendingDisplay,
queueMetalDisplay, requestMetalDisplay, displayImmediately, and the iOS
step-driven display link. The 150 ms interactive-echo path maps to an
immediate coalesced tick that does not spin up the link; the driver
pauses after eight idle ticks and any dirty mark resumes it. The Mac
caret's CoreAnimation blink is self-driving and deliberately does not
tick the driver, so an idle focused terminal reaches zero wakeups.
Use FIFO ticket ordering for TerminalLock. This prevents the parser thread
from taking the lock again before a waiting main-thread operation can run.
The lock remains non-recursive and preserves ownership checks. A waiting
operation receives the lock after at most the current critical section, which
avoids long UI stalls when terminal input stays queued.

Process the leading ASCII part of terminal input with the fast path. Continue
with the normal parser when the input contains non-ASCII data or when insert
mode consumes only part of the input. This keeps mixed input correct while
improving performance for mostly ASCII input.

Make terminal error and log messages lazy. Release builds no longer construct
messages that silent logging discards. Disable the macOS application bell by
default, so terminal bell control sequences produce no audible bell in that
application.

Sadly, this still can block for very long periods of time when catting garbage
Add runtime measurements for terminal input, locking, parsing, refresh, and
frame delivery. Profiling is inactive unless SWIFTTERM_PROFILE=1 is set, so
normal terminal use does not produce profiling work.

Expose thread-safe TerminalView diagnostics for fed bytes, input batches,
ticks, rendered frames, idle ticks, pauses, and immediate frame requests.
Resetting the diagnostics starts a new measurement window. These counters make
it possible to compare input volume with frame output and to identify idle
frame activity or output coalescing.

Instrument the IO pipeline and terminal lock with intervals that identify batch
gaps, parse time, lock wait time, and lock hold time. Preserve the thread names
used by the trace data. The intervals help show whether a slow or hung display
comes from input starvation, lock contention, parsing, or refresh work.

Add repeatable Mac baseline runs for flood, bidirectional flood, and TUI loads.
Users can start a run from the debug actions or with
`--baseline flood|bidi|tui`. A scripted run prints a delimited report to
standard output and exits. An interactive run shows the report, copies it to
the clipboard, and displays it in an alert.
Instrumented a new test in the MacTerminal app, because flooding
the terminal with the random junk was killing the interactivitiy.

cat of of a 256MG /tmp/big.bin junk file: main thread stalled 3.2 s at
p99, 11 frames in 13 seconds.

Cause 1 — over a million main-queue hops, one per 256 bytes. That's
the frequency of a random byte being 0x07. Every BEL hopped to the
main queue before checking bellStyle, which the app sets to .none. A
million blocks the main queue can't drain. BellPolicy now gates before
marshalling and rate-limits the rest to one per 100 ms: hops 1,053,159
→ 9,417 (112x), stall p99 down 39%. The throttle resets on a style
change, so an explicit reconfiguration is never swallowed — that came
from an existing test failing, and the test's intent was right.

Cause 2 — the remaining 2.2 s is lock acquisition count, not hold
time.   That is not addressed.

Fixes:

BEL handling checks the bell style before it queues work.
It limits bells to one every 100 ms. This keeps binary input
from filling the main queue. A style change resets the limit,
so the next bell reflects the new setting.

Profiling records interval distributions in the process. Set
`SWIFTTERM_PROFILE=1` for signposts or
`SWIFTTERM_PROFILE_STATS=1` for statistics. Reports include IO,
lock, frame, draw, and callback data. Hosts can reset and read
these reports through `TerminalProfiling`.

MacTerminal provides flood, bidi, TUI, and binary runs. Select a
run with `SWIFTTERM_BASELINE` or `--baseline=...`. The watchdog
reports partial data when a load does not finish. Scripted runs
can exit on timeout. Select Metal with `SWIFTTERM_METAL=1` or
`--metal`.

The baseline documentation records operating rules and results.
It also records remaining IO and rendering work. The terminfo
entry and shared scheme support these workflows.
- Why phase 1 gates earlier than Ghostty. Ghostty's read path pushes a
  payloadless ring_bell into a BlockingQueue slot and forgets — cheap enough
  that debouncing after the queue is fine. SwiftTerm's marshal was
  DispatchQueue.main.async: an allocation plus a queue wakeup. At a BEL every 256
  bytes, "push and forget" would still have flooded the main queue. So the gate
  had to come first — until a real queue existed.

- Phase 2 (now done). With TerminalEventQueue, bell(source:) posts a
  payloadless .bell and returns; the style check and the 100 ms debounce moved
  to the drain, which is exactly where Surface.zig:1098 has them. Same constant.

- Phase 3 (planned). BellFeatures OptionSet mirroring the packed struct
  (system, audio, attention, title, border), plus bellAudioURL/bellAudioVolume
  at 0.5. BellStyle stays public and maps onto it — it can't currently express
  "flash the title but stay silent".  Title/border become persistent indicators
  cleared on focus or keypress, not flashes.

Now the binary cat no longer freezes:

  ┌─────────────────┬────────────┬─────────────────┬──────────────┐
  │                 │   Before   │ After bell gate │   After G6   │
  ├─────────────────┼────────────┼─────────────────┼──────────────┤
  │ Frames          │ 11 (0.8/s) │ 12 (1.0/s)      │ 633 (45.0/s) │
  ├─────────────────┼────────────┼─────────────────┼──────────────┤
  │ Main-queue hops │ 1,053,159  │ 9,417           │ 1            │
  ├─────────────────┼────────────┼─────────────────┼──────────────┤
  │ Stall p99       │ 3,214 ms   │ 2,236 ms        │ 23.1 ms      │
  ├─────────────────┼────────────┼─────────────────┼──────────────┤
  │ Stall max       │ 3,473 ms   │ 2,438 ms        │ 52.6 ms      │
  └─────────────────┴────────────┴─────────────────┴──────────────┘

139x on stall p99, 53x on frames. Throughput dipped 20.4 → 18.3 MB/s because it
now actually draws 633 frames instead of 12 — the right trade.

TerminalEventQueue is a bitmask plus a scheduled-drain flag: the parse thread
sets a bit, and only the first post per window schedules a main
hop. 30,000 posts collapse to one drain and three deliveries.

Two design corrections the tests forced, both worth keeping:

1. Posting always-async broke bellStyleGatesDelegate, which calls bell() on
main and checks the delegate immediately. The amplification is purely a
parse-thread problem, so main-thread posts now deliver inline — preserving the
long-standing synchronous contract, and deferring only while the view holds the
terminal lock (the same rule onMain applies).

2. My first inline path bailed when a drain was already scheduled, which made
delivery depend on the run loop being serviced — untrue in tests and a contract
this type shouldn't impose. It now flushes queued events inline, in order, and
the outstanding block no-ops.
  ┌───────────────────────────────┬────────────┬────────────┬────────────┬───────────────────┐
  │                               │   Before   │ Bell gate  │  G6 queue  │ + lock coalescing │
  ├───────────────────────────────┼────────────┼────────────┼────────────┼───────────────────┤
  │ Frames                        │ 11 (0.8/s) │ 12 (1.0/s) │ 633 (45/s) │ 927 (60.0/s)      │
  ├───────────────────────────────┼────────────┼────────────┼────────────┼───────────────────┤
  │ Main-queue hops               │ 1,053,159  │ 9,417      │ 1          │ 1                 │
  ├───────────────────────────────┼────────────┼────────────┼────────────┼───────────────────┤
  │ Main-thread lock acquisitions │ 9,448      │ 9,448      │ 5,171      │ 921               │
  ├───────────────────────────────┼────────────┼────────────┼────────────┼───────────────────┤
  │ Lock wait, total              │ —          │ 12,402 ms  │ 8,460 ms   │ 1,454 ms          │
  ├───────────────────────────────┼────────────┼────────────┼────────────┼───────────────────┤
  │ Stall p99                     │ 3,214 ms   │ 2,236 ms   │ 23.1 ms    │ 14.34 ms          │
  └───────────────────────────────┴────────────┴────────────┴────────────┴───────────────────┘

  224x on stall p99, 84x on frames. cat of 256 MB of random bytes now runs at a steady 60 fps instead of freezing for seconds, and the main thread
  takes the terminal lock once per frame (921 for 927 frames) instead of ~15 times.

  How the last step went. Rather than guess which paths were locking, I added #function attribution to withTerminal — same trick as the hop
  counter, no call-site churn. It named them immediately:

  ┌────────────────────┬──────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
  │     Call site      │ Acquisitions │                                         Fix                                          │
  ├────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
  │ updateScroller()   │ 2,216        │ Sets a flag; applied once per frame inside frameTick's existing lock                 │
  ├────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
  │ shouldTrackMouse() │ 1,611        │ Reads a cached mouse mode, captured when the terminal notified us under its own lock │
  ├────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
  │ frameTick()        │ 608          │ Unchanged — one per frame is correct                                                 │
  ├────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
  │ scrollPosition     │ 607          │ Captured inside frameTick's acquisition                                              │
  └────────────────────┴──────────────┴──────────────────────────────────────────────────────────────────────────────────────┘

  The mouse-mode cache also fixed something I'd introduced earlier: my first applyTerminalEvent read getTerminal().mouseMode without the lock,
  violating the contract. It now captures the value in mouseModeChanged, where the notifying thread already holds it.

  One thing that looked like a regression and wasn't. Bidi throughput read 13.3 MB/s against 20.0 earlier. Before recording it, I re-ran: 16.0 /
  16.0 / 13.3, with elapsed landing on 1.27 / 1.27 / 1.52 s — exactly one 0.25 s poll interval apart. The case was only ~1 s long, so
  quiet-detection granularity was ±20% of the measurement. I enlarged it from 20 MB to 80 MB; three runs now give 21.4 / 20.1 / 20.1. No
  regression. The doc now warns that any case under ~3 seconds can't be compared across builds.

  Other cases after all changes — flood 28.9 MB/s at 114 fps (p99 1.95 ms), bidi 20.1 MB/s at 69 fps (p99 6.87 ms), TUI 81 fps (p99 2.22 ms). Zero
  main-queue hops in all three.
One real bug the instrumentation exposed. The Metal path refreshed the snapshot
twice per frame: frameTick refreshed it, then MetalTerminalRenderer.draw called
refreshSnapshotForMetal() and refreshed it again, taking the terminal lock a
second time. 593 refreshes for 327 frames. frameTick now hands the renderer the
snapshot it just built — the mechanism (prepareSnapshotForImmediateDraw)
already existed, but only the test path used it.

  ┌─────────────────────────────┬──────────────┬──────────────┐
  │                             │    Before    │    After     │
  ├─────────────────────────────┼──────────────┼──────────────┤
  │ Refreshes per frame         │ 1.8          │ 1.0          │
  ├─────────────────────────────┼──────────────┼──────────────┤
  │ Lock.Wait owner=main, total │ 637 ms       │ 179 ms       │
  ├─────────────────────────────┼──────────────┼──────────────┤
  │ Stall p99                   │ 8.35 ms      │ 5.95 ms      │
  ├─────────────────────────────┼──────────────┼──────────────┤
  │ Frames                      │ 327 (72.0/s) │ 455 (81.8/s) │
  └─────────────────────────────┴──────────────┴──────────────┘
The bug: getAttributes(_:withUrl:context:) had no cache at all. The older
getAttributes(_:withUrl:) overload caches by Attribute — but the context-based
variant added for snapshot rendering dropped it, so every attribute run rebuilt
a String-keyed [NSAttributedString.Key: Any] from scratch. That's exactly the
objc_msgSend / __CFStringHash / __CFStringEqual signature the trace showed.

  ┌──────────────────────────────────┬──────────┬─────────────────┐
  │           Measurement            │  Before  │      After      │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Row.AttributedString p50 (Metal) │ 0.071 ms │ 0.044 ms (−38%) │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Metal.RowBuild p50               │ 0.156 ms │ 0.130 ms (−17%) │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Frame.Draw p50, Metal            │ 6.02 ms  │ 5.04 ms (−17%)  │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Frame.Draw p50, Core Graphics    │ 4.851 ms │ 3.879 ms (−20%) │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Stall p99, Metal bidi            │ 8.35 ms  │ 4.95 ms         │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Stall p99, CG bidi               │ 8.61 ms  │ 6.61 ms         │
  ├──────────────────────────────────┼──────────┼─────────────────┤
  │ Stall p99, flood                 │ 1.95 ms  │ 1.18 ms         │
  └──────────────────────────────────┴──────────┴─────────────────┘

Roughly 1 ms per frame off the main thread on both renderers, and it lands on
the CG path too since they share the code. Throughput unchanged (flood 28.9
MB/s, bidi 21.3 MB/s). Row.Shape stayed at 0.035 ms as expected — that's
CoreText, untouched.
…tructure

The buildAttributedString, getAttributes/buildAttributes, isColumnSelected,
shouldUnderlineLink and the attribute cache into a renderer-owned
SnapshotTextBuilder parameterised by the context. Mechanical, no behaviour
change, no threading. The CG path calls the same type, so one implementation
serves both.
…rom its

surface: device, pixel format, drawable size, bounds, contents scale, a
drawable, a render pass, a redraw request. MTKView conforms via an extension,
and the renderer now holds any MetalRenderTarget instead of a concrete view.
draw(in:) became a thin MTKViewDelegate shim over a new render() that any
driver can call — that method is what WO-F4 will call from a render thread.

2. TerminalMetalLayerView — a CAMetalLayer-backed view that deliberately
schedules nothing itself; the host decides when frames happen.
presentsWithTransaction = false matters specifically: it keeps a present from a
non-main thread out of the main thread's CA transaction, which is the whole
point of owning the layer.
Add an opt-in CAMetalLayer surface for the Metal terminal renderer. Set
SWIFTTERM_METAL_LAYER=1 with the Metal renderer enabled to use it. The
existing MTKView surface stays the default.

The new render loop prepares and draws coalesced frames on a dedicated
thread. The main thread captures all view state before it sends a frame to
the loop. It applies AppKit state, such as the scroller, caret, blink timer,
and accessibility updates, after the draw. This prevents the render thread
from read access to live view state and keeps frame preparation from blocking
the main thread.

Use a common render-target interface for MTKView and CAMetalLayer. Keep the
layer color space in sRGB and recreate a surface safely when its window moves
to a display with a different backing scale. The layer surface requires an
explicit draw call; this prevents a dirty-frame loop that does not render.

Move Metal cursor blinking to the main run loop and protect shared blink
state. Add render counts and render-loop coalescing counts so diagnostics can
detect ticks that do not produce a GPU submission.

Add surface-parity, render-loop, idle-cursor, resize-flood, and display-move
checks. The baseline harness also limits each command or baseline run to one
restored document window, so an extra hidden terminal does not change load
measurements.
Use a CAMetalLayer surface and a render loop for Metal rendering on
macOS. The layer surface is now the default. Frame preparation uses a
captured view state, so the render loop does not read AppKit state.
Apply scroll bar, cursor, and accessibility updates on the main thread.

Start an immediate, rate-limited frame when output resumes after idle.
This reduces the delay before the first new glyph without allowing a
producer to exceed the display rate.

Use SWIFTTERM_METAL=0 to select Core Graphics. Metal is selected by
default, or with --metal. Use SWIFTTERM_METAL_LAYER=0 to select the
MTKView Metal surface instead of the layer surface. The public surface
setting can also change the active Metal surface without a restart.
iOS continues to use MTKView.

Renderer changes wait for an active frame before they replace a surface.
If Metal setup fails during a change, use Core Graphics. Reports now name
the active renderer and separate drawn frames from render-loop work. This
helps find stalled frames, display-scale changes, and surface-switch faults.
No point wasting precious time on it
Run the DECSET 2026 synchronized-output timeout on a dedicated serial
I/O timer queue instead of the main queue. This timeout is a safety
valve for applications that enable synchronized output but do not
disable it.

The display can now unfreeze when the main queue is blocked. Timeout
handlers still take the terminal lock, and the serial queue keeps their
order defined. Its queue label also lets lock profiling identify timer
work.

Keep the timeout duration adjustable within the module so callers of
the internal terminal API can use a suitable value when they must hold
a synchronized-output state for longer than the normal safety limit.
TerminalView.send(data:) can now run on a transport or automation
thread while another thread feeds terminal output. It locks the
OSC 133 input-submission scanner with terminal parsing, then schedules
caret visibility work on the main thread.

Do not call send from a terminal delegate callback. That callback holds
the terminal lock, and send stops with a precondition failure to prevent
a deadlock. Hosts must still sequence concurrent sends when byte order
matters; concurrent writes can interleave at the pty.

LocalProcess also protects its send counters, so concurrent sends do not
race diagnostic accounting. This supports hosts that pass input to the
process without main-thread marshalling.
Queue cell-size changes during a live window drag and apply only the
latest change in the next render frame. The frame updates the terminal
before it creates its snapshot, then sends one size callback, updates
the scroller, and invalidates accessibility. This reduces repeated
terminal resizes and pty window-size updates during a drag.

A queued resize still reaches the host when synchronized output freezes
the snapshot. Changes outside a live resize remain synchronous, so hosts
that depend on immediate size callbacks continue to work.

Update the sample host and embedding guidance to set window frames
without animation and to compare target frames before setting them.
Animated resize callbacks can form a feedback loop and cause main-thread
stalls. Add a frame-resize profiling event to help diagnose this path.
@migueldeicaza
migueldeicaza merged commit 6b51164 into main Aug 18, 2026
2 of 4 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