nested slices
This is a borrowed span of borrowed byte spans: pointer-plus-length pairs, not NUL-terminated char** strings.
Zig difference: Both const qualifiers prevent mutation through these views; neither slice owns its bytes.
So far our program has only managed its own objects. Now let’s launch /bin/sh and ask the child a very simple question: “Do your stdin, stdout, and stderr look like a terminal?”
chapter-04-child-pipes$ zig build run
[entry] ghostty
[main] process started
[app] created
[runtime] initialized
[surface 1] created
[runtime] tick
[child stdout]
child: hello
stdin_tty=no
stdout_tty=no
stderr_tty=no
[child] exited 0
[surface 1] destroyed
[runtime] terminated
[app] destroyed
[main] process exitingThe shell itself evaluates -t for file descriptors 0, 1, and 2. All three results are measured behavior from the tagged checkpoint.
The child launches successfully, but every answer is no. That failure is useful—it shows us exactly what ordinary pipes cannot provide.
That is the obvious C-programmer idea: one pipe for input and two for output. Let’s try it before adding a more complicated PTY.
/bin/shA pipe is excellent byte transport. A terminal device additionally participates in kernel-managed terminal behavior: terminal modes, window size, sessions, controlling-terminal ownership, and foreground process groups.
A pipe moves bytes perfectly well. But a terminal also has modes, a window size, a controlling session, and a foreground process group. We should see the missing behavior before reaching for PTY APIs.
DEPENDENCY FRONTIER
The frontier marker names the problem area. This checkpoint stops on the pipe side; Chapter 05 crosses into the PTY side.
Ghostty opened a basic PTY on April 15, 2022. The next day it introduced a custom Command because Zig’s standard child abstraction did not expose the post-fork/pre-exec control required to attach a shell correctly.
The first Command redirected standard descriptors and provided a pre-exec hook for setsid, terminal attachment, signals, and related setup.
992d52fOpen first Commandstd.process.run is enough for a finite child and makes the missing isatty behavior explicit before custom process control exists.
chapter-04-child-pipesCurrent Command handles environment, cwd, stream descriptors, platform pre-exec, runtime hooks, process IDs, and Windows pseudoconsoles.
6ad1fe7Open current CommandOur use of std.process.run is not an attempted replacement for production Command. It is the smallest experiment that reaches the limitation production Command exists to solve.
Start with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceThis is a borrowed span of borrowed byte spans: pointer-plus-length pairs, not NUL-terminated char** strings.
Zig difference: Both const qualifiers prevent mutation through these views; neither slice owns its bytes.
Why no higher-level tab: TypeScript and Python arrays do not expose this borrowed-slice versus allocator-owned-buffer contract; garbage collection would erase the reason deinit exists.
Text version:
[]const []const u8is a borrowed slice of borrowed immutable byte slices.RunResultreturns allocator-owned stdout and stderr buffers.deinitfrees those buffers with the allocator and poisons the result withundefined; it does not free the caller-owned Result value.
Process spawning needs memory and I/O services. Zig 0.16 hands them to main_ghostty through std.process.Init, and we pass them down instead of hiding them in globals.
This changed because process spawning needs an initialized I/O implementation. Calling an uninitialized global I/O singleton failed with OutOfMemory during the experiment; passing the process-owned capability is both smaller and correct.
The ownership path is:
Zig process startup→ main_ghostty(init)→ App { alloc, io }→ Surface→ Command.run(alloc, io)Command.zigFinite standard-library child collection through ordinary pipes; no fork/pre-exec, PTY, environment, cwd, or long-lived process state.
const Command = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
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 },
) };
}For this first experiment, std.process.run does the boring work for us:
The wrapper’s Result.deinit makes buffer ownership explicit.
Surface.zig: run one controlled experimentRuns one deterministic shell probe; it does not yet own a persistent child or PTY.
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 runChild(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("[child stdout]\n{s}", .{result.inner.stdout});
if (result.inner.stderr.len > 0)
std.debug.print("[child stderr]\n{s}", .{result.inner.stderr});
switch (result.inner.term) {
.exited => |code| std.debug.print("[child] exited {d}\n", .{code}),
else => std.debug.print("[child] abnormal exit\n", .{}),
}
}
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();
}We give the shell the same tiny test for all three descriptors:
if [ -t 0 ]; then echo 'stdin_tty=yes'; else echo 'stdin_tty=no'; fiif [ -t 1 ]; then echo 'stdout_tty=yes'; else echo 'stdout_tty=no'; fiif [ -t 2 ]; then echo 'stderr_tty=yes'; else echo 'stderr_tty=no'; fi-t is not an environment-variable guess. The shell checks whether the descriptor refers to a terminal device.
The parent prints collected stdout and the child’s exit status. Exit code zero proves launch succeeded; the three no results prove terminal semantics are absent.
The teaching-only headless runtime calls runChild after creating the core Surface. That keeps session ownership where production will eventually put it without inventing Termio yet.
The child is finite and fully collected. A real terminal child is long-lived, receives input incrementally, changes terminal modes, resizes, and exits asynchronously. Those responsibilities are intentionally absent.
zig fmt --check .zig buildzig build testzig build runThe critical lines are:
stdin_tty=nostdout_tty=nostderr_tty=no[child] exited 0If the child failed to launch, this would not be a PTY lesson. Here launch succeeds and terminal identity alone is missing.
| File | Status | Frontier-related difference |
|---|---|---|
src/Command.zig |
adapted | finite std.process.run; no production fork/pre-exec or platform handling |
src/Surface.zig |
adapted | owns one synchronous experiment rather than persistent Termio |
src/App.zig |
adapted | carries process allocator and I/O capability |
src/apprt/headless.zig |
temporary | invokes the probe from one synthetic tick |
src/Command.zig:1–105Read the opening rationale and fields for streams, environment, cwd, and pre-exec hooks. Ignore platform implementation details.The current file still documents why generic child spawning may not provide enough control for PTY attachment. It also notes Zig 0.16 may justify reevaluation—an example of production source recording evolving constraints rather than eternal rules.
src/termio/Exec.zig:1–42Preview the ownership nouns only: Command, Pty, Termio, subprocess. Do not read the thread machinery yet.commit 04e405b359961783f19875babf3f3ac05c12814atag chapter-04-child-pipesPipes cannot make the shell interactive. Chapter 05 must provide:
PTY master ↔ parent terminal emulatorPTY slave ↔ child stdin/stdout/stderrchild session + controlling terminal + foreground process groupwindow size + terminal modesThe acceptance result is concrete: the child must report terminal identity, and the trace must expose the PTY and process-session relationships.