Skip to content

Chapter 04: Pipes are not a terminal

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?”

VISIBLE RESULTA child runs, but none of its streams is 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 exiting

The 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.

MEASURED LIMITATIONPipes move bytes; they do not become a terminal
Core Surface/bin/sh
stdin
ignored / pipe semantics
isatty = no
stdout
captured byte pipe
isatty = no
stderr
captured byte pipe
isatty = no

A 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

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

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.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2022-04-16Control fork and exec

    The first Command redirected standard descriptors and provided a pre-exec hook for setsid, terminal attachment, signals, and related setup.

    992d52fOpen first Command
  2. 02
    RECONSTRUCTION · CHAPTER 04Prove pipes are insufficient

    std.process.run is enough for a finite child and makes the missing isatty behavior explicit before custom process control exists.

    chapter-04-child-pipes
  3. 03
    NOW · PINNED MAINOwn every launch constraint

    Current Command handles environment, cwd, stream descriptors, platform pre-exec, runtime hooks, process IDs, and Windows pseudoconsoles.

    6ad1fe7Open current Command

Our 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 at startup: pass the tools process code needs

Section titled “Start at startup: pass the tools process code needs”
ZIG SYNTAX BRIDGE

Borrow argv, then own child output

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

Open in the cumulative Zig/C reference
C MENTAL MODEL

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.

MEMORY / LIFETIME FLOW
  1. Command borrowerargv slice views
  2. process runnerallocates stdout + stderr
  3. Result callerdeinit frees both buffers

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 u8 is a borrowed slice of borrowed immutable byte slices. RunResult returns allocator-owned stdout and stderr buffers. deinit frees those buffers with the allocator and poisons the result with undefined; 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)
adaptedsrc/Command.zig

Finite 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:

  1. spawns the command;
  2. ignores stdin;
  3. creates pipes for stdout and stderr;
  4. drains both pipes;
  5. waits for exit;
  6. returns owned output buffers.

The wrapper’s Result.deinit makes buffer ownership explicit.

Return to Surface.zig: run one controlled experiment

Section titled “Return to Surface.zig: run one controlled experiment”
adaptedsrc/Surface.zig

Runs 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:

Terminal window
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

-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.

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

The critical lines are:

stdin_tty=no
stdout_tty=no
stderr_tty=no
[child] exited 0

If 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

What does today’s Ghostty need beyond this?

Section titled “What does today’s Ghostty need beyond this?”
</>
Current Command launch contractsrc/Command.zig:1–105Read the opening rationale and fields for streams, environment, cwd, and pre-exec hooks. Ignore platform implementation details.
Read excerptGitHub

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.

</>
Current PTY subprocess backendsrc/termio/Exec.zig:1–42Preview the ownership nouns only: Command, Pty, Termio, subprocess. Do not read the thread machinery yet.
Read excerptGitHub
commit 04e405b359961783f19875babf3f3ac05c12814a
tag chapter-04-child-pipes

Pipes cannot make the shell interactive. Chapter 05 must provide:

PTY master ↔ parent terminal emulator
PTY slave ↔ child stdin/stdout/stderr
child session + controlling terminal + foreground process group
window size + terminal modes

The acceptance result is concrete: the child must report terminal identity, and the trace must expose the PTY and process-session relationships.