DocsWorking on Yarrix

Terminal PTY notes: Bun.Terminal fitness (spike T1)

View source on GitHubdocs/terminal-pty-notes.md

On this page

Verdict: GO on Bun.Terminal. The node-pty fallback is not needed. All four checks pass on the pinned runtime, with the caveats under "What this does not cover".

Environment: Bun 1.4.4, macOS (darwin arm64). Code: spikes/pty/measure.ts (throwaway). Re-run with bun spikes/pty/measure.ts <fd|rss|paste|resize>.

The PTY child was sh (or yes), not ssh. A PTY master behaves the same regardless of the child, and the spike measures the hub-side PTY, not sshd.

API surface found#

Bun.spawn(cmd, { terminal: { cols, rows, data(terminal, bytes) } }) returns a process with proc.terminal. Terminal exposes write, resize, close, closed, setRawMode, ref/unref and the termios flag getters. There is no pause or resume for reads. data() fires as fast as the child writes. Backpressure must be handled by dropping or shedding in the hub, as designed in ARCHITECTURE section 10.5.

1. fd stability over 500 sessions: PASS#

Each session is open, read its output, wait for exit, terminal.close(). Baseline is taken after 5 warm-up sessions. Counts come from lsof -p <hub pid>.

Variantfds beforefds after 500DeltaPer session
Child exits on its own, close() called1010015 ms
Child still running, SIGHUP, then close()1010019 ms
Control: same, close() never called15 / 3429 / 53+14 / +1918-20 ms

Threshold is a delta of 5 or less. The result is 0, against hatch's node-pty 1.1.0 prebuild leaking 3 fds per session (1,500 for this run).

The control shows the check can fail: skipping close() leaves fds open until GC finalizers get to them (+14 to +19, non-deterministic). The backend must call terminal.close() on every exit path and not rely on GC.

2. RSS under a stalled consumer: PASS#

Setup: a real Bun.serve WebSocket in the same process, yes in a PTY, and a client that completes the upgrade handshake and then pauses its socket (it never reads). The hub forwards each data() chunk with sendBinary. Shedding policy as in section 10.5: stop forwarding above 1 MB buffered, and shed (close 1013, SIGHUP, terminal.close()) if still above after 2 s or if above 8 MB. 30 s per run. Base RSS was 8 MB.

RunPeak RSS growthFinal RSSResult
With shedding+5 MB (13 MB peak)8 MBShed at 2.5 s with soft-2s. Peak WS buffered 1 MB.
Another with-shedding run+10 MB (18 MB peak)10 MBShed at 2.0 s. About 165 MB came out of yes in that window, 1.5 MB was forwarded and the rest dropped.
Control, no shedding+22 MB (30 MB peak)9 MBPeak WS buffered stopped at 16 MB while 1,766 MB were read from the PTY.

Threshold is growth under 50 MB: met in every run. Memory stays bounded because the data() callback drops chunks rather than queueing them, and the session is killed after at most 2 s.

Two findings:

  • With no read pause, the child keeps running at full speed during the up-to-2 s soft window. The cost is CPU and dropped bytes, not memory.
  • In the control, Bun's WebSocket buffered amount plateaued at 16 MB (the default backpressure limit) while hub-side reads continued. Bytes past the limit were not buffered, so without shedding the stream is silently corrupted instead of growing memory. This is a second reason to shed explicitly. I did not separately verify sendBinary's return codes. The T2 server should check them and treat a drop as a shed trigger.

3. 112 KB bracketed paste: PASS#

Payload: ESC[200~ + 114,688 bytes (112 KiB, UTF-8 with multi-byte characters) + ESC[201~, 114,700 bytes total, sent as a single terminal.write(). The receiver was stty raw -echo; dd bs=1 count=114700, which is how a TUI sees input.

ModeSentwrite() returnArrivedSHA-256 matchClosing ESC[201~
Raw (TUI-like)114,700114,700114,700yespresent
Cooked (canonical line discipline)114,700114,700114,644nomissing

Bun.Terminal.write() accepts the whole buffer and returns the full length, and the data arrived intact in raw mode. It does not show the ~1 KB short-write that hatch hit with the node-pty fd (TRAP 4), so Bun queues the remainder itself. The explicit drain queue in section 10.5 is therefore a safeguard in the backend (cap, ordering) rather than a workaround for truncation.

The cooked-mode loss (56 bytes) is the kernel's canonical-mode line buffer limit on a child that is not reading. That is a property of the tty, not of Bun, and it matches hatch's behaviour. It does not apply when the remote side is ssh -tt into a TUI or shell in raw mode. Keep the T2 round-trip test in raw mode.

4. Resize: PASS#

A child shell printed stty size and trapped SIGWINCH. Starting at 80x24, terminal.resize(132, 43) gave:

  • before: 24 80
  • after: 43 132
  • SIGWINCH delivered: yes

The resize reaches the child's tty and its foreground process group.

What this does not cover#

  • Only macOS arm64 was measured. T2's CI container run (Linux, openpty) should repeat checks 1-4 against an sshd container. The spike script runs unchanged there.
  • The child was not ssh. Nothing about check 1 should depend on that, but the T2 end-to-end fd check with ssh as the child is the real proof.
  • A single stalled client per run, not several concurrent stalled sessions.

Decision for T2#

Use Bun.Terminal behind the PtyBackend interface. Requirements that come out of this spike:

  1. Call terminal.close() on every exit path (normal exit, shed, error, socket close).
  2. Implement shedding in the data() callback as designed (drop above 1 MB, shed after 2 s or above 8 MB). There is no read pause, and Bun's own WebSocket buffer appeared to stop growing at 16 MB while reads continued.
  3. Keep the fallback documented but unbuilt: node-pty with the 3-fd leak patch, a sized readSync pump and a write drain queue.