Skip to content

Chapter 06: Termio owns PTY bytes

Let’s keep the child alive long enough to talk to it

Section titled “Let’s keep the child alive long enough to talk to it”

Last chapter waited for a child to finish. A terminal cannot work that way—we need to read some bytes, send some input, read more bytes, and only wait when the child finally exits. One Termio object will own that whole conversation.

VISIBLE RESULTIncremental reads and writes across one live PTY session
chapter-06-termio
$ zig build run
[entry] ghostty
[main] process started
[app] created
[runtime] initialized
[surface 1] created
[runtime] tick
[pipe child stdout]
child: hello
stdin_tty=no
stdout_tty=no
stderr_tty=no
[pipe child] exited 0
[termio] started
[termio read ready] 72 65 61 64 79 3E
[termio write] 68 65 6C 6C 6F 0A
[termio read reply] 68 65 6C 6C 6F 0D 0A 1B 5B 33 32 6D 72 65 70 6C 79 3A 68 65 6C 6C 6F 1B 5B 30 6D 0D 0A
[termio] child exited 0
[termio] stopped
[surface 1] destroyed
[runtime] terminated
[app] destroyed
[main] process exiting

Every byte is rendered as hexadecimal. ESC, CR, and LF remain data rather than being interpreted by the browser or the terminal showing this lesson.

The reply contains bytes that mean “turn green,” but we will not color anything yet. Termio is the byte-moving layer. Understanding the bytes belongs to the next lesson.

Chapter 05 blocked until a finite child exited and collected its entire output into one fixed buffer. An interactive terminal must read and write during the same child lifetime.

BYTE OWNERSHIPTermio keeps syscall chunks out of the protocol contract
  1. Child shellready>blocks waiting for input
  2. PTY master3 bytes · 3 bytesread boundaries are arbitrary
  3. TermioreadUntil("ready>")owns PTY + child + phase buffer
  4. TermiowriteAll("hello\n")writes while session remains alive
  5. PTY masterecho + ESC [ 32 m …opaque bytes, not styled text yet
  6. Child exitwait → 0Termio closes the session

Let’s gather the pieces that live for the whole child session under one owner:

PTY master
+ live child handle
+ incremental read buffers
+ writes
+ child exit
+ shutdown order

DEPENDENCY FRONTIER

Build only what the current result needs

  1. 00Process entryReconstructed
  2. 01App lifecycleReconstructed
  3. 02Runtime + SurfaceReconstructed
  4. 03Child process + PTYReconstructed
  5. 04I/O + parserYou are here
  6. 05Terminal stateNext limitation
  7. 06Window + GPU + fontsNot introduced

This chapter stops before the parser half of that frontier.

Real Ghostty eventually pulled these jobs together

Section titled “Real Ghostty eventually pulled these jobs together”

By November 2022 Ghostty’s window/surface code had accumulated PTY, child, read, write, terminal-state, and rendering wakeup responsibilities. Commit 35c1decd began extracting an I/O backend and thread. Later the same day, a8e7c520 started continuous PTY reads and explicit write-request ownership.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2022-11-03Extract the I/O owner

    Exec owned PTY, Command, terminal state, and process teardown while a new thread boundary prepared continuous reads and writes.

    35c1decdOpen first backend
  2. 02
    RECONSTRUCTION · CHAPTER 06Keep one live session

    Termio owns a finite interactive exchange and proves protocol phases do not align with three-byte read buffers.

    chapter-06-termio
  3. 03
    NOW · PINNED MAINCoordinate every byte consumer

    Current Termio owns backend, terminal, parser stream, renderer state, Surface mailbox, writer mailbox, config, and thread-enter state.

    6ad1fe7Open current Termio

The historical extraction happened after real concurrency pressure existed. Our reconstruction does not add a thread yet; it first makes the ownership and incremental API concrete synchronously.

First, stop treating start and wait as one operation

Section titled “First, stop treating start and wait as one operation”

Command.startPty now returns immediately with a child handle instead of reading until exit. Child.wait is a later explicit operation.

Now Termio has time to do useful work in the middle:

fork/exec ───────────────────────────── wait
read → write → read → ...
ZIG SYNTAX BRIDGE

Accumulate arbitrary reads into one owned buffer

Start with the C model, then inspect each highlighted Zig line.

Open in the cumulative Zig/C reference
C MENTAL MODEL

fixed storage and length

This is an inline 4096-byte array plus a separate initialized-length field, like a bounded C buffer struct.

Zig difference: The array storage belongs to Termio; undefined bytes cannot be read until phase_len includes them.

MEMORY / LIFETIME FLOW
  1. PTY kernel queuearbitrary byte availability
  2. read stack frame3-byte temporary chunk
  3. Termio4096-byte reusable phase buffer
  4. callerborrowed initialized slice

Text version: Termio owns a fixed 4096-byte array and tracks its initialized prefix separately. Each PTY read fills an arbitrary prefix of a three-byte temporary. chunk[0..count] and phase_bytes[0..phase_len] are no-copy slices. The returned slice borrows Termio storage and is invalid after reuse or teardown.

The PTY wrapper now gives Termio two plain operations:

pub fn read(self: *Pty, buffer: []u8) !usize
pub fn writeAll(self: *Pty, bytes: []const u8) !void

read treats Linux EIO after slave closure as end-of-stream. writeAll handles partial writes and interrupted syscalls. Neither method knows what bytes mean.

Open Termio.zig: who owns the live session?

Section titled “Open Termio.zig: who owns the live session?”
adaptedsrc/termio/Termio.zig

Synchronous deterministic owner for PTY, child, read phases, writes, exit, and teardown; production parser/state/mailbox/thread responsibilities are deferred.

const Termio = @This();

const std = @import("std");
const Command = @import("../Command.zig");
const ptypkg = @import("../pty.zig");
const Pty = ptypkg.Pty;

pty: Pty,
child: Command.Child,
phase_bytes: [4096]u8 = undefined,
phase_len: usize = 0,

pub fn init(command: Command, size: ptypkg.winsize) !Termio {
    var pty = try Pty.open(size);
    errdefer pty.deinit();
    const child = try command.startPty(&pty);
    std.debug.print("[termio] started\n", .{});
    return .{ .pty = pty, .child = child };
}

pub fn deinit(self: *Termio) void {
    self.pty.deinit();
    if (!self.child.waited) _ = self.child.wait() catch {};
    std.debug.print("[termio] stopped\n", .{});
}

pub fn writeAll(self: *Termio, bytes: []const u8) !void {
    try self.pty.writeAll(bytes);
    traceHex("write", bytes);
}

pub fn readUntil(self: *Termio, marker: []const u8) ![]const u8 {
    self.phase_len = 0;
    var chunk: [3]u8 = undefined;
    while (std.mem.indexOf(u8, self.phase_bytes[0..self.phase_len], marker) == null) {
        const count = try self.pty.read(&chunk);
        if (count == 0) return error.EndOfStream;
        try self.append(chunk[0..count]);
    }
    return self.phase_bytes[0..self.phase_len];
}

pub fn readToEnd(self: *Termio) ![]const u8 {
    self.phase_len = 0;
    var chunk: [3]u8 = undefined;
    while (true) {
        const count = try self.pty.read(&chunk);
        if (count == 0) break;
        try self.append(chunk[0..count]);
    }
    return self.phase_bytes[0..self.phase_len];
}

pub fn wait(self: *Termio) !u8 {
    return self.child.wait();
}

fn append(self: *Termio, bytes: []const u8) !void {
    if (self.phase_len + bytes.len > self.phase_bytes.len) return error.OutputTooLong;
    @memcpy(self.phase_bytes[self.phase_len..][0..bytes.len], bytes);
    self.phase_len += bytes.len;
}

pub fn traceHex(label: []const u8, bytes: []const u8) void {
    std.debug.print("[termio {s}]", .{label});
    for (bytes) |byte| std.debug.print(" {X:0>2}", .{byte});
    std.debug.print("\n", .{});
}

test "Termio owns interactive PTY reads and writes" {
    const script: []const u8 = "printf 'ready>'; IFS= read -r line; printf 'reply:%s\\n' \"$line\"";
    const command: Command = .{ .argv = &.{ "/bin/sh", "-c", script } };
    var termio = try Termio.init(command, .{});
    defer termio.deinit();
    _ = try termio.readUntil("ready>");
    try termio.writeAll("test\n");
    _ = try termio.readToEnd();
    try std.testing.expectEqual(@as(u8, 0), try termio.wait());
}

init opens the PTY and starts the child. deinit closes the PTY and makes sure we do not leave a zombie child behind. This is the same create/use/cleanup story from App, just for a terminal session.

The child program deliberately pauses:

Terminal window
printf 'ready>'
IFS= read -r line
printf '\033[32mreply:%s\033[0m\n' "$line"

Termio reads until ready>, writes hello\n, then reads until the slave closes.

Do not confuse one read call with one message

Section titled “Do not confuse one read call with one message”

Both read methods use a three-byte scratch buffer. ready> is six bytes; the SGR sequences and reply are longer. The API aggregates until a semantic condition or end-of-stream rather than treating one read call as one message.

This is the rule to remember:

read chunks are transport accidents
VT actions are protocol structure

The parser in Chapter 07 must preserve state between calls for exactly this reason.

Section titled “Print the bytes without pretending we understand them”

traceHex prints two-digit hexadecimal bytes:

1B 5B 33 32 6D = ESC [ 3 2 m
1B 5B 30 6D = ESC [ 0 m

The reply also begins with 68 65 6C 6C 6F 0D 0A: the PTY’s local echo of parent input followed by CRLF. Termio preserves it all.

adaptedsrc/Surface.zig

Surface delegates the interactive session to Termio and only orchestrates the deterministic probe.

const Surface = @This();

const std = @import("std");
const App = @import("App.zig");
const Command = @import("Command.zig");
const Termio = @import("termio.zig").Termio;

app: *App,
id: u64,

pub fn create(app: *App, id: u64) !*Surface {
    const surface = try app.alloc.create(Surface);
    surface.* = .{ .app = app, .id = id };
    app.surfaceCreated();
    std.debug.print("[surface {d}] created\n", .{id});
    return surface;
}

pub fn runPipeProbe(self: *Surface) !void {
    const shell_script =
        \\printf 'child: hello\n'
        \\if [ -t 0 ]; then echo 'stdin_tty=yes'; else echo 'stdin_tty=no'; fi
        \\if [ -t 1 ]; then echo 'stdout_tty=yes'; else echo 'stdout_tty=no'; fi
        \\if [ -t 2 ]; then echo 'stderr_tty=yes'; else echo 'stderr_tty=no'; fi
    ;
    const command: Command = .{ .argv = &.{ "/bin/sh", "-c", shell_script } };
    var result = try command.run(self.app.alloc, self.app.io);
    defer result.deinit(self.app.alloc);

    std.debug.print("[pipe child stdout]\n{s}", .{result.inner.stdout});
    if (result.inner.stderr.len > 0)
        std.debug.print("[pipe child stderr]\n{s}", .{result.inner.stderr});

    switch (result.inner.term) {
        .exited => |code| std.debug.print("[pipe child] exited {d}\n", .{code}),
        else => std.debug.print("[pipe child] abnormal exit\n", .{}),
    }
}

pub fn runTermioProbe(self: *Surface) !void {
    _ = self;
    const shell_script = "printf 'ready>'; IFS= read -r line; printf '\\033[32mreply:%s\\033[0m\\n' \"$line\"";
    const command: Command = .{ .argv = &.{ "/bin/sh", "-c", shell_script } };
    var termio = try Termio.init(command, .{ .ws_row = 24, .ws_col = 80 });
    defer termio.deinit();

    const ready = try termio.readUntil("ready>");
    Termio.traceHex("read ready", ready);
    try termio.writeAll("hello\n");
    const reply = try termio.readToEnd();
    Termio.traceHex("read reply", reply);
    std.debug.print("[termio] child exited {d}\n", .{try termio.wait()});
}

pub fn destroy(self: *Surface) void {
    const app = self.app;
    const id = self.id;
    std.debug.print("[surface {d}] destroyed\n", .{id});
    app.surfaceDestroyed();
    app.alloc.destroy(self);
}

test "create and destroy Surface" {
    const app = try App.create(std.testing.allocator, std.testing.io);
    defer app.destroy();
    const surface = try Surface.create(app, 1);
    surface.destroy();
}
Terminal window
zig fmt --check .
zig build
zig build test
zig build run

The test uses the testing allocator and testing I/O capability, starts a real PTY child, reads readiness, writes input, drains the reply, and waits for exit.

File Status Frontier-related difference
src/termio.zig adapted one implementation; no backend union or mailbox/thread exports
src/termio/Termio.zig adapted synchronous fixed session; no parser, Terminal, renderer state, mailboxes, config, or threads
src/Command.zig adapted startable/waitable Linux child subset
src/pty.zig adapted incremental master read/write subset
src/Surface.zig adapted orchestrates one Termio probe
</>
Current Termio ownership inventorysrc/termio/Termio.zig:1–75Read the fields and their owners. Ignore detailed configuration types and methods until those jobs enter the frontier.
Read excerptGitHub
</>
Current writer-thread contractsrc/termio/Thread.zig:1–35Read why writes and control messages move off the read/parser hot path. Do not inspect event-loop details yet.
Read excerptGitHub

Current Ghostty handles PTY reads continuously, parses on the hot path, and moves writes and control messages through a dedicated thread/mailbox. We have earned those pressures but not yet concurrent behavior.

commit 6b3b663fc7620877060684ebc7be26459edb0390
tag chapter-06-termio

We can move bytes—how do we understand them?

Section titled “We can move bytes—how do we understand them?”

Termio can identify a phase marker only by searching raw bytes. It cannot explain printable text, carriage return, line feed, or SGR.

Chapter 07 introduces a stateful parser whose output is invariant under every possible input chunk split.