Skip to content

Chapter 05: A real PTY

Let’s replace the pipes with a real terminal device

Section titled “Let’s replace the pipes with a real terminal device”

We will run the exact same shell probe twice. With pipes it says no. With a PTY it says yes. Keeping the child command the same lets us see that the setup—not the shell script—caused the change.

VISIBLE RESULTBefore and after terminal attachment
chapter-05-pty
$ 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
[pty child stdout]
child: hello
stdin_tty=yes
stdout_tty=yes
stderr_tty=yes
tty_path=devpts
rows=24 cols=80
pid_equals_sid=yes
pgrp_equals_foreground=yes
[pty child] exited 0
[surface 1] destroyed
[runtime] terminated
[app] destroyed
[main] process exiting

Captured on Linux with a 24×80 PTY. Dynamic PIDs and /dev/pts numbers are converted into relationship checks so the evidence stays reproducible.

The PTY result is not inferred from colored output or shell prompts. The child asks the kernel about its descriptors, session, process group, foreground group, and window size.

KERNEL RELATIONSHIPThe parent and child see opposite ends of one terminal device
PARENTSurfacereads/writes master
KERNELPTY masterterminal-emulator side
KERNELPTY slave/dev/pts/*
CHILDShellfd 0 · 1 · 2
session
child is session leader
controlling tty
slave belongs to child session
foreground group
child group receives terminal input/signals
window
24 rows × 80 columns

A PTY sounds mysterious, but start with two connected file descriptors:

  • the master is owned by the terminal emulator;
  • the slave behaves like the terminal device seen by the child;
  • terminal modes and window size live in the kernel PTY state;
  • the child session names the slave as its controlling terminal;
  • one foreground process group receives terminal input and generated signals.

DEPENDENCY FRONTIER

Build only what the current result needs

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

Ghostty first opened a PTY on April 15, 2022. At that point it only proved descriptor and window-size operations. On April 24, childPreExec added session creation and controlling-terminal attachment while Command gained the hook needed to call it between fork and exec.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2022-04-24Attach the child session

    Pty.childPreExec called setsid, assigned the controlling terminal with TIOCSCTTY, and closed inherited PTY descriptors.

    9cc19b0Open first attachment
  2. 02
    RECONSTRUCTION · CHAPTER 05Measure every relationship

    A finite Linux probe compares pipes and PTY streams, window size, session leadership, and foreground process-group ownership.

    chapter-05-pty
  3. 03
    NOW · PINNED MAINCarry platform constraints

    Current PTY supports POSIX, Windows pseudoconsoles, modes, resize, UTF-8 input, process queries, and careful descriptor flags.

    6ad1fe7Open current PTY

The historical sequence separates opening a device pair from correctly launching a process inside it. Our checkpoint keeps those responsibilities separate in pty.zig and Command.zig too.

Start in build.zig: we need a C library function

Section titled “Start in build.zig: we need a C library function”
ZIG SYNTAX BRIDGE

Cross the POSIX C ABI without hiding it

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

Open in the cumulative Zig/C reference
C MENTAL MODEL

C import

The included C header supplies declarations and platform constants through the target C ABI.

Zig difference: @cImport translates declarations at compile time; it does not make the OS call yet.

MEMORY / LIFETIME FLOW
  1. parent stackwinsize + fd out slots
  2. kernelPTY master/slave objects
  3. Pty valuetwo integer capabilities; must close

Why no higher-level tab: Raw file descriptors, fork/exec process state, pointer casts, and C ABI layout have no useful direct TypeScript or Python equivalent without dropping to a native extension.

Text version: @cImport translates POSIX header declarations. extern struct preserves C field layout. openpty writes kernel file descriptors through pointer arguments; @ptrCast makes an ABI pointer conversion explicit. A negative C return becomes a typed Zig error, and the resulting descriptors must be closed exactly once.

Linux gives us openpty through libc. So the build file must link libc for both the program and its tests. If you have used -l flags in C, this is the Zig build-graph version.

This is not a future dependency framework. It solves one concrete call used by the current chapter. Zig 0.16 moved libc linkage onto the root module; attempting the older exe.linkLibC() API failed at build time and was corrected rather than hidden.

adaptedbuild.zig

The existing graph now links libc solely for the Linux PTY API.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    root_module.link_libc = true;

    const exe = b.addExecutable(.{
        .name = "ghostty-from-scratch",
        .root_module = root_module,
    });
    b.installArtifact(exe);

    const run = b.addRunArtifact(exe);
    run.step.dependOn(b.getInstallStep());
    if (b.args) |args| run.addArgs(args);

    const run_step = b.step("run", "Run ghostty-from-scratch");
    run_step.dependOn(&run.step);

    const tests = b.addTest(.{ .root_module = root_module });
    const run_tests = b.addRunArtifact(tests);
    const test_step = b.step("test", "Run the Zig tests");
    test_step.dependOn(&run_tests.step);
}
adaptedsrc/pty.zig

Linux openpty and child-session subset of current Ghostty PTY; no Windows, mode API, resize messages, or process queries.

const std = @import("std");

pub const c = @cImport({
    @cInclude("errno.h");
    @cInclude("pty.h");
    @cInclude("sys/ioctl.h");
    @cInclude("sys/types.h");
    @cInclude("sys/wait.h");
    @cInclude("unistd.h");
});

pub const winsize = extern struct {
    ws_row: u16 = 24,
    ws_col: u16 = 80,
    ws_xpixel: u16 = 0,
    ws_ypixel: u16 = 0,
};

pub const Pty = struct {
    master: c_int,
    slave: c_int,

    pub fn open(size: winsize) !Pty {
        var mutable_size = size;
        var master: c_int = undefined;
        var slave: c_int = undefined;
        if (c.openpty(&master, &slave, null, null, @ptrCast(&mutable_size)) < 0)
            return error.OpenptyFailed;
        return .{ .master = master, .slave = slave };
    }

    pub fn closeSlave(self: *Pty) void {
        if (self.slave >= 0) {
            _ = c.close(self.slave);
            self.slave = -1;
        }
    }

    pub fn deinit(self: *Pty) void {
        self.closeSlave();
        if (self.master >= 0) _ = c.close(self.master);
        self.* = .{ .master = -1, .slave = -1 };
    }

    /// Establish the slave as stdin/out/err and as the controlling terminal.
    /// This runs in the forked child before exec.
    pub fn childPreExec(self: Pty) !void {
        _ = c.close(self.master);
        if (c.setsid() < 0) return error.SetSidFailed;
        if (c.ioctl(self.slave, c.TIOCSCTTY, @as(c_int, 0)) < 0)
            return error.SetControllingTerminalFailed;
        inline for (0..3) |fd| {
            if (c.dup2(self.slave, @intCast(fd)) < 0) return error.DupFailed;
        }
        if (self.slave > 2) _ = c.close(self.slave);
    }
};

test "open and close PTY" {
    var pty = try Pty.open(.{});
    pty.deinit();
    try std.testing.expectEqual(@as(c_int, -1), pty.master);
}

Pty.open asks the kernel for the master/slave pair and starts it with a simple 24×80 size:

if (c.openpty(&master, &slave, null, null, &size) < 0)
return error.OpenptyFailed;

The parent keeps the master and closes its copy of the slave after forking. The child closes the master and eventually duplicates the slave onto descriptors 0, 1, and 2.

The close rules prevent a subtle hang: an inherited open slave can keep the PTY relationship alive after the intended child exits.

Now set up the child between fork and exec

Section titled “Now set up the child between fork and exec”

This part should feel familiar if you have written Unix C. After fork but before exec, the child changes its own session and descriptors:

close master
→ setsid()
→ ioctl(slave, TIOCSCTTY)
→ dup2(slave, stdin/stdout/stderr)
→ close extra slave descriptor
→ exec /bin/sh

setsid creates a new session and makes the child its leader. TIOCSCTTY assigns the slave as that session’s controlling terminal. Connecting all three standard descriptors makes each isatty check succeed.

This setup must happen after fork because it changes only the child process, and before exec because the shell should begin life inside the completed terminal relationship.

Return to Command.zig: parent and child split jobs

Section titled “Return to Command.zig: parent and child split jobs”
adaptedsrc/Command.zig

Finite Linux fork/exec PTY collection added beside the Chapter 04 pipe runner; production process control remains much broader.

const Command = @This();

const std = @import("std");
const Allocator = std.mem.Allocator;
const ptypkg = @import("pty.zig");
const Pty = ptypkg.Pty;
const c = ptypkg.c;

argv: []const []const u8,

pub const Result = struct {
    inner: std.process.RunResult,

    pub fn deinit(self: *Result, alloc: Allocator) void {
        alloc.free(self.inner.stdout);
        alloc.free(self.inner.stderr);
        self.* = undefined;
    }
};

/// Run a finite child with ordinary stdin/stdout/stderr pipe semantics.
pub fn run(self: Command, alloc: Allocator, io: std.Io) !Result {
    return .{ .inner = try std.process.run(
        alloc,
        io,
        .{ .argv = self.argv },
    ) };
}

pub const PtyResult = struct {
    bytes: [4096]u8 = undefined,
    len: usize = 0,
    exit_code: u8,

    pub fn output(self: *const PtyResult) []const u8 {
        return self.bytes[0..self.len];
    }
};

/// Fork, attach the child to the PTY slave, exec the command, and collect the
/// finite probe output from the PTY master.
pub fn runPty(self: Command, pty: *Pty) !PtyResult {
    const pid = c.fork();
    if (pid < 0) return error.ForkFailed;
    if (pid == 0) {
        pty.childPreExec() catch c._exit(126);

        var argv: [16:null]?[*:0]const u8 = .{null} ** 16;
        if (self.argv.len >= argv.len) c._exit(125);
        for (self.argv, 0..) |arg, index| argv[index] = @ptrCast(arg.ptr);
        _ = c.execv(@ptrCast(self.argv[0].ptr), @ptrCast(&argv));
        c._exit(127);
    }

    pty.closeSlave();
    var result: PtyResult = .{ .exit_code = 255 };
    var raw: [512]u8 = undefined;
    while (true) {
        const count = c.read(pty.master, &raw, raw.len);
        if (count > 0) {
            for (raw[0..@intCast(count)]) |byte| {
                if (byte == '\r') continue;
                if (result.len == result.bytes.len) return error.OutputTooLong;
                result.bytes[result.len] = byte;
                result.len += 1;
            }
            continue;
        }
        if (count == 0) break;
        const errno = c.__errno_location().*;
        if (errno == c.EINTR) continue;
        if (errno == c.EIO) break;
        return error.ReadFailed;
    }

    var status: c_int = 0;
    if (c.waitpid(pid, &status, 0) < 0) return error.WaitFailed;
    if (c.WIFEXITED(status)) result.exit_code = @intCast(c.WEXITSTATUS(status));
    return result;
}

The parent reads from the master until EOF or Linux’s EIO end-of-slave signal, then waits for the child. Carriage returns generated by the default output mode are removed only when producing the evidence trace; the PTY itself still emitted terminal bytes.

The probe avoids unstable PID and /dev/pts/N values. Instead it records facts:

tty path belongs to /dev/pts
a configured 24×80 window is visible
PID equals session ID
process group equals foreground process group

Put both experiments next to each other in Surface

Section titled “Put both experiments next to each other in Surface”
adaptedsrc/Surface.zig

Runs finite pipe and PTY probes side by side; persistent read/write ownership belongs to the next Termio chapter.

const Surface = @This();

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

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 runPtyProbe(self: *Surface) !void {
    _ = self;
    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
        \\case "$(tty)" in /dev/pts/*) echo 'tty_path=devpts';; *) echo 'tty_path=unexpected';; esac
        \\set -- $(stty size); echo "rows=$1 cols=$2"
        \\pid=$$; sid=$(ps -o sid= -p $$ | tr -d ' '); pgrp=$(ps -o pgid= -p $$ | tr -d ' '); fg=$(ps -o tpgid= -p $$ | tr -d ' ')
        \\[ "$pid" = "$sid" ] && echo 'pid_equals_sid=yes' || echo 'pid_equals_sid=no'
        \\[ "$pgrp" = "$fg" ] && echo 'pgrp_equals_foreground=yes' || echo 'pgrp_equals_foreground=no'
    ;
    const command: Command = .{ .argv = &.{ "/bin/sh", "-c", shell_script } };
    var pty = try @import("pty.zig").Pty.open(.{ .ws_row = 24, .ws_col = 80 });
    defer pty.deinit();
    const result = try command.runPty(&pty);
    std.debug.print("[pty child stdout]\n{s}", .{result.output()});
    std.debug.print("[pty child] exited {d}\n", .{result.exit_code});
}

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();
}

This is just a controlled experiment: same shell, same checks, different descriptor setup. Now the yes answers actually mean something.

Terminal window
zig fmt --check .
zig build
zig build test
zig build run

The PTY acceptance lines are:

stdin_tty=yes
stdout_tty=yes
stderr_tty=yes
tty_path=devpts
rows=24 cols=80
pid_equals_sid=yes
pgrp_equals_foreground=yes

The test suite also opens and closes a PTY directly, catching descriptor and libc-link regressions.

File Status Frontier-related difference
src/pty.zig adapted Linux subset; fixed size; no modes, resizing, Windows, or process inspection
src/Command.zig adapted finite fork/exec probe; no persistent process object, env/cwd, signals, or runtime hooks
src/Surface.zig adapted synchronous before/after experiment rather than long-lived session
build.zig adapted links libc; production dependency graph omitted

The implementation is intentionally Linux-specific because only one platform relationship has been demonstrated. A cross-platform abstraction before a second implementation would be speculative.

</>
Current POSIX PTYsrc/pty.zig:116–260Read open, descriptor flags, UTF-8 mode, and childPreExec. Ignore Windows and process-info branches.
Read excerptGitHub

Current Ghostty additionally marks the master close-on-exec, enables IUTF8, exposes terminal modes and resizing, and queries foreground process information. Each is an accumulated correctness or product requirement.

</>
Current process pre-exec contractsrc/Command.zig:1–105Connect the PTY setup to Command's OS/runtime hooks and stream fields. Ignore platform branches beyond that ownership handoff.
Read excerptGitHub
commit 352764a6c5e3d85b5888aea7d4923950b3ceab24
tag chapter-05-pty

The parent currently blocks until the finite child exits and stores all output in one fixed buffer. A terminal emulator must instead:

  • read arbitrary chunks for the whole session;
  • accept writes while reads continue;
  • resize while the process runs;
  • notice asynchronous child exit;
  • forward bytes toward terminal parsing.

Chapter 06 introduces Termio as the owner of that persistent I/O boundary before parsing any escape sequence.