Skip to content

Chapter 03: Runtime and Surface

Run it and watch the order. We now have one App for shared state, plus a runtime that creates one Surface. When the program stops, those objects disappear in reverse order:

VISIBLE RESULTRuntime and Surface lifetimes close in reverse order
chapter-03-runtime-surface
$ zig build run
[entry] ghostty
[main] process started
[app] created
[runtime] initialized
[surface 1] created
[runtime] tick
[surface 1] destroyed
[runtime] terminated
[app] destroyed
[main] process exiting

The headless runtime creates one Surface during its tick. Runtime termination destroys the Surface before shared App teardown.

You won’t see a window yet. Our headless runtime is just a practice version that lets us understand ownership before GTK enters the picture.

Chapter 02 routes into main_ghostty, but Chapter 01’s temporary App.run() gives shared state ownership of the event loop. That does not scale to native platforms:

  • GTK owns a GLib event loop;
  • macOS owns Cocoa’s application lifecycle;
  • embedding hosts may own the loop themselves;
  • browser builds receive callbacks from JavaScript.

So shared App code should not pretend it owns every platform’s event loop. We need a separate runtime owner.

DEPENDENCY FRONTIER

Build only what the current result needs

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

Real Ghostty eventually made the same split

Section titled “Real Ghostty eventually made the same split”

Ghostty’s February 2023 runtime refactor made platform selection compile-time and exposed the chosen runtime’s App. The next commit introduced Surface: a terminal session drawable inside a window, tab, split, preview, or host-owned container.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2023-02-22Select an application runtime

    apprt.App owned the GUI loop while core App stopped depending directly on GLFW or GTK.

    3d8c62cOpen early runtime
  2. 02
    RECONSTRUCTION · CHAPTER 03Prove ownership headlessly

    A headless runtime App creates one runtime Surface around one core Surface and closes both in order.

    chapter-03-runtime-surface
  3. 03
    NOW · PINNED MAINCompile platform contracts away

    Current GTK, embedded, browser, and no-runtime implementations share core App and Surface behavior through compile-time interfaces.

    6ad1fe7Open current apprt

The sequence matters: runtime extraction came before the mature Surface accumulated PTY, parser, renderer, input, and thread ownership.

Let’s name the four owners before reading code

Section titled “Let’s name the four owners before reading code”
LIFETIME NESTINGThe runtime closes platform objects before shared App state
  1. Core Appshared state
  2. Runtime Appevent-loop owner
  3. Runtime Surfaceplatform container
  4. Core Surfaceterminal-session owner

The headless runtime creates one pair of Surface objects during run. terminate destroys them before App.destroy checks that no core surfaces remain.

The names look similar, so pause here. There are two Apps and two Surfaces, but each has a different job:

ZIG SYNTAX BRIDGE

Keep a stable optional Surface pointer

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

Open in the cumulative Zig/C reference
C MENTAL MODEL

optional pointer

This is a pointer plus an explicit empty state, similar to a nullable pointer but exhaustively handled by the language.

Zig difference: ?*Surface can contain null or a valid typed pointer; the default starts with no owned Surface.

MEMORY / LIFETIME FLOW
  1. core App heapshared process state
  2. runtime App stackoptional *Surface
  3. Surface heapstable session state

Why no higher-level tab: GC languages can express optional references, but they do not accurately model the required destroy-before-App teardown contract or allocator-owned stable storage.

Text version: ?*Surface is either null or a typed stable pointer. A successful create stores ownership; payload capture unwraps the non-null pointer for destroy; assigning null records that teardown completed. The Surface must die before the runtime App and core App.

Type Owns
core App state shared across all terminal sessions
runtime App platform application lifecycle and event loop
core Surface one terminal session’s platform-neutral state
runtime Surface the platform container that hosts a core Surface
adaptedsrc/apprt.zig

Current compile-time selector reduced to one teaching-only headless backend.

const build_config = @import("build_config.zig");

pub const headless = @import("apprt/headless.zig");

/// The build-selected application runtime.
pub const runtime = switch (build_config.app_runtime) {
    .headless => headless,
};

pub const App = runtime.App;
pub const Surface = runtime.Surface;

test {
    _ = runtime;
}

This is the same compile-time switch idea from Chapter 02:

pub const runtime = switch (build_config.app_runtime) {
.headless => headless,
};

pub const App = runtime.App gives callers one stable type name even though the implementation changes at compile time.

adaptedsrc/Surface.zig

Stable App-owned Surface allocation with no terminal I/O, renderer, input, or configuration yet.

const Surface = @This();

const std = @import("std");
const App = @import("App.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 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);
    defer app.destroy();
    const surface = try Surface.create(app, 1);
    surface.destroy();
}

The core Surface stores only:

app: *App,
id: u64,

Creation increments App’s live-surface count. Destruction decrements it before freeing the stable allocation. App.deinit asserts that the count is zero, making teardown order executable rather than documentary.

Why call it Surface instead of Window? Because one terminal session might later live in a tab, split, dropdown, or embedded view. A window is only one possible container.

temporarysrc/apprt/headless.zig

Observable platform lifecycle scaffold removed when the first native runtime exists.

const std = @import("std");
const CoreApp = @import("../App.zig");
const CoreSurface = @import("../Surface.zig");

pub const App = struct {
    core_app: *CoreApp,
    surface: ?Surface,

    pub fn init(self: *App, core_app: *CoreApp) void {
        self.* = .{ .core_app = core_app, .surface = null };
        std.debug.print("[runtime] initialized\n", .{});
    }

    pub fn terminate(self: *App) void {
        if (self.surface) |*surface| surface.terminate();
        self.surface = null;
        std.debug.print("[runtime] terminated\n", .{});
    }

    pub fn run(self: *App) !void {
        self.surface = .{};
        try self.surface.?.init(self.core_app, 1);
        std.debug.print("[runtime] tick\n", .{});
    }
};

pub const Surface = struct {
    core_surface: ?*CoreSurface = null,

    pub fn init(self: *Surface, app: *CoreApp, id: u64) !void {
        self.core_surface = try CoreSurface.create(app, id);
    }

    pub fn terminate(self: *Surface) void {
        if (self.core_surface) |surface| surface.destroy();
        self.core_surface = null;
    }
};

test "headless runtime owns a Surface" {
    const core_app = try CoreApp.create(std.testing.allocator);
    defer core_app.destroy();

    var app: App = undefined;
    app.init(core_app);
    defer app.terminate();
    try app.run();
}

This runtime stores a pointer to the shared App and an optional Surface. Optional just means “we may not have created one yet.” Its one pretend event-loop tick does this:

self.surface = .{};
try self.surface.?.init(self.core_app, 1);
std.debug.print("[runtime] tick\n", .{});

terminate closes the runtime Surface, which closes the core Surface. Only then may core App be destroyed.

Return to main_ghostty.zig and check the order

Section titled “Return to main_ghostty.zig and check the order”
adaptedsrc/main_ghostty.zig

Matches current shared-App/runtime-App ordering with a headless runtime and temporary traces.

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

/// The application entrypoint selected by src/main.zig.
pub fn main() !void {
    std.debug.print("[entry] ghostty\n", .{});
    std.debug.print("[main] process started\n", .{});

    {
        const app = try App.create(std.heap.page_allocator);
        defer app.destroy();

        var app_runtime: apprt.App = undefined;
        app_runtime.init(app);
        defer app_runtime.terminate();

        try app_runtime.run();
    }

    std.debug.print("[main] process exiting\n", .{});
}

Here is the payoff: App.run() is gone. Shared state is created first, then the runtime is initialized and asked to run:

const app = try App.create(allocator);
defer app.destroy();
var app_runtime: apprt.App = undefined;
app_runtime.init(app);
defer app_runtime.terminate();
try app_runtime.run();

The nested scope makes the reverse cleanup order visible before [main] process exiting.

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

The tests independently prove core App, core Surface, and headless runtime lifetimes with std.testing.allocator.

File Status Frontier-related difference
src/App.zig adapted live count instead of production Surface list, mailbox, fonts, and config
src/Surface.zig adapted identity and App ownership only
src/apprt.zig adapted one runtime choice
src/apprt/headless.zig temporary no production equivalent; exists to prove lifecycle without GUI dependencies
src/main_ghostty.zig adapted production ordering without globals, CLI actions, or native runtime

How does today’s Ghostty grow this idea?

Section titled “How does today’s Ghostty grow this idea?”
</>
Current runtime selectorsrc/apprt.zig:1–48Read the contract, compile-time switch, and exported App/Surface types. Ignore backend internals.
Read excerptGitHub
</>
Current App/runtime startupsrc/main_ghostty.zig:80–108Read the nested lifetimes and event-loop handoff. Ignore quit timers until multiple surfaces exist.
Read excerptGitHub
</>
Current Surface contractsrc/Surface.zig:1–20Read why Surface is a widget-like terminal session rather than necessarily a window.
Read excerptGitHub
commit 40752eab8f9c1a05eda14d60cfe15d2c7773372c
tag chapter-03-runtime-surface

The Surface has identity and ownership but no terminal program. Chapter 04 launches a real child through ordinary pipes and measures the missing terminal semantics before introducing a PTY.