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.
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.
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 exitingCaptured 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.
/dev/pts/*A PTY sounds mysterious, but start with two connected file descriptors:
DEPENDENCY FRONTIER
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.
Pty.childPreExec called setsid, assigned the controlling terminal with TIOCSCTTY, and closed inherited PTY descriptors.
9cc19b0Open first attachmentA finite Linux probe compares pipes and PTY streams, window size, session leadership, and foreground process-group ownership.
chapter-05-ptyCurrent PTY supports POSIX, Windows pseudoconsoles, modes, resize, UTF-8 input, process queries, and careful descriptor flags.
6ad1fe7Open current PTYThe 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.
build.zig: we need a C library functionStart with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceThe 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.
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:
@cImporttranslates POSIX header declarations.extern structpreserves C field layout.openptywrites kernel file descriptors through pointer arguments;@ptrCastmakes 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.
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);
}openpty into pty.zigLinux 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.
fork and execThis 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/shsetsid 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.
Command.zig: parent and child split jobsFinite 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/ptsa configured 24×80 window is visiblePID equals session IDprocess group equals foreground process groupRuns 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.
zig fmt --check .zig buildzig build testzig build runThe PTY acceptance lines are:
stdin_tty=yesstdout_tty=yesstderr_tty=yestty_path=devptsrows=24 cols=80pid_equals_sid=yespgrp_equals_foreground=yesThe 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.
src/pty.zig:116–260Read open, descriptor flags, UTF-8 mode, and childPreExec. Ignore Windows and process-info branches.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.
src/Command.zig:1–105Connect the PTY setup to Command's OS/runtime hooks and stream fields. Ignore platform branches beyond that ownership handoff.commit 352764a6c5e3d85b5888aea7d4923950b3ceab24tag chapter-05-ptyThe parent currently blocks until the finite child exits and stores all output in one fixed buffer. A terminal emulator must instead:
Chapter 06 introduces Termio as the owner of that persistent I/O boundary before parsing any escape sequence.