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.
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.
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 exitingEvery 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.
ready>blocks waiting for input3 bytes · 3 bytesread boundaries are arbitraryreadUntil("ready>")owns PTY + child + phase bufferwriteAll("hello\n")writes while session remains aliveecho + ESC [ 32 m …opaque bytes, not styled text yetwait → 0Termio closes the sessionLet’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 orderDEPENDENCY FRONTIER
This chapter stops before the parser half of that frontier.
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.
Exec owned PTY, Command, terminal state, and process teardown while a new thread boundary prepared continuous reads and writes.
35c1decdOpen first backendTermio owns a finite interactive exchange and proves protocol phases do not align with three-byte read buffers.
chapter-06-termioCurrent Termio owns backend, terminal, parser stream, renderer state, Surface mailbox, writer mailbox, config, and thread-enter state.
6ad1fe7Open current TermioThe 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.
start and wait as one operationCommand.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 → ...Pty small read and write operationsStart with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceThis 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.
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]andphase_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) !usizepub fn writeAll(self: *Pty, bytes: []const u8) !voidread treats Linux EIO after slave closure as end-of-stream. writeAll handles partial writes and interrupted syscalls. Neither method knows what bytes mean.
Termio.zig: who owns the live session?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:
printf 'ready>'IFS= read -r lineprintf '\033[32mreply:%s\033[0m\n' "$line"Termio reads until ready>, writes hello\n, then reads until the slave closes.
read call with one messageBoth 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 accidentsVT actions are protocol structureThe parser in Chapter 07 must preserve state between calls for exactly this reason.
traceHex prints two-digit hexadecimal bytes:
1B 5B 33 32 6D = ESC [ 3 2 m1B 5B 30 6D = ESC [ 0 mThe 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.
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();
}zig fmt --check .zig buildzig build testzig build runThe 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 |
src/termio/Termio.zig:1–75Read the fields and their owners. Ignore detailed configuration types and methods until those jobs enter the frontier.src/termio/Thread.zig:1–35Read why writes and control messages move off the read/parser hot path. Do not inspect event-loop details yet.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 6b3b663fc7620877060684ebc7be26459edb0390tag chapter-06-termioTermio 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.