Skip to content

Chapter 01: App owns the lifetime

Last time, main printed one line and quit. Now we want a place to keep application-wide state. Watch the output: an App is created, used, and destroyed while main is still alive.

VISIBLE RESULTApp is created, used, and destroyed before main exits
chapter-01-app-lifecycle
$ zig build run
[main] process started
[app] created
[app] running
[app] destroyed
[main] process exiting

Captured from the tagged reconstruction with Zig 0.16.0 on Linux. The ordered text is the visual proof: App cannot outlive the main scope that owns its pointer.

We still don’t have a window. That’s okay. This lesson is about answering a simpler question: who owns the application’s memory, and who cleans it up?

Chapter 00 could fit every responsibility in five lines:

main → print → return

A real terminal will eventually have configuration, windows, fonts, and callbacks. If we dump all of that into main, it will become one long function full of setup and cleanup.

So let’s introduce one owner called App. Its job is simple: hold application-wide state and provide one clear cleanup path.

DEPENDENCY FRONTIER

Build only what the current result needs

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

A boundary earns its place here because it answers two concrete questions:

  1. Who owns application state after initialization?
  2. Which one operation releases that state?

Ghostty reached this pressure quickly. Immediately before App.zig, main contained window creation, OpenGL setup, texture and shader resources, vertex buffers, and the event loop. Commit 5bbdd75 moved that responsibility into a file-as-struct App and reduced main to orchestration.

SOURCE ARCHAEOLOGYThen, reconstruction, and now solve different-sized problems
  1. 01
    THEN · 2022-04-03Extract ownership from main

    App.init created the window and renderer state, App.run owned the event loop, and App.deinit released the window.

    5bbdd75Open first App.zig
  2. 02
    RECONSTRUCTION · CHAPTER 01Expose only the lifetime

    Our App has no window or renderer. It proves stable allocation, one owner, ordered use, and one destruction path.

    chapter-01-app-lifecycle
  3. 03
    NOW · PINNED MAINSeparate state from runtime

    Current App owns shared state. A selected platform runtime owns the event loop and calls back into App.

    6ad1fe7Open current lifecycle

The first App boundary looked like this:

const App = @This();
window: glfw.Window,
glprog: gl.Program,
vao: gl.VertexArray,
pub fn init() !App { /* create window and renderer state */ }
pub fn deinit(self: *App) void { self.window.destroy(); }
pub fn run(self: App) !void { /* draw and wait for events */ }

And main became:

var app = try App.init();
defer app.deinit();
try app.run();

That historical App owned both shared state and the event loop. Current Ghostty eventually separated those jobs. Our reconstruction uses the current stable-pointer lifecycle while keeping a temporary run method until the runtime boundary exists.

OWNERSHIP TRACEOne stable pointer, one explicit owner
  1. Main
    App.create(allocator)
    App
  2. App
    alloc.create(App)
    Allocator
  3. Allocator
    stable *App
    App
  4. App
    return *App
    Main
  5. Main
    app.run()
    App
  6. Main
    defer app.destroy()
    App
  7. App
    deinit + destroy
    Allocator

Invariant: `main` owns the returned pointer. The allocator owns its memory. `destroy` is the single operation that closes both lifetimes.

Read the diagram as calls and returned ownership, not as threads. Every operation still happens synchronously on one thread.

The stable pointer matters later because native callbacks and surfaces need an address that does not change when a local variable moves or a container grows. We introduce the pointer now because it is part of the current App contract—not because callbacks already exist.

ghostty-from-scratch/
└── src/
├── App.zig new process-wide owner
└── main.zig reduced to orchestration

Both files remain smaller than production because no resources beyond App’s own allocation exist.

At the top of src/App.zig:

const App = @This();

In Zig, a source file is a struct namespace. @This() names that struct from inside the file. Importing the file gives callers the type directly:

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

This matches Ghostty’s established naming and path without creating a wrapper module.

Follow App.create: reserve memory, then initialize it

Section titled “Follow App.create: reserve memory, then initialize it”

The complete checkpoint file is short enough to inspect:

adaptedsrc/App.zig

The create/init/deinit/destroy shape follows current Ghostty. Trace output and App.run are temporary; surfaces, fonts, and mailboxes do not exist yet.

const App = @This();

const std = @import("std");
const Allocator = std.mem.Allocator;

/// General-purpose allocator used to own the stable App pointer.
alloc: Allocator,

pub const CreateError = Allocator.Error;

/// Allocate and initialize the process-wide application state.
pub fn create(alloc: Allocator) CreateError!*App {
    const app = try alloc.create(App);
    errdefer alloc.destroy(app);

    app.init(alloc);
    std.debug.print("[app] created\n", .{});
    return app;
}

pub fn init(self: *App, alloc: Allocator) void {
    self.* = .{ .alloc = alloc };
}

/// Release resources owned by App.
///
/// Chapter 01 has no resources beyond the allocation itself. Later chapters
/// will add cleanup here as App gains real state.
pub fn deinit(self: *App) void {
    _ = self;
}

/// Deinitialize App and release its stable allocation.
pub fn destroy(self: *App) void {
    const alloc = self.alloc;
    self.deinit();
    std.debug.print("[app] destroyed\n", .{});
    alloc.destroy(self);
}

/// Temporarily stand in for the platform runtime's event loop.
///
/// A later chapter moves this responsibility out of App.
pub fn run(self: *App) void {
    _ = self;
    std.debug.print("[app] running\n", .{});
}

test "create and destroy App" {
    const app = try App.create(std.testing.allocator);
    app.destroy();
}

Let’s start with create, because that is the first call main makes:

ZIG SYNTAX BRIDGE

Allocate and return one stable App

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

Open in the cumulative Zig/C reference
C MENTAL MODEL

@This

Inside the file-as-struct, this names the current struct type, similar to a typedef for the enclosing definition.

Zig difference: The source file itself is the namespace and struct type.

MEMORY / LIFETIME FLOW
  1. create stack framealloc + local pointer
  2. allocator heapstable App storage
  3. mainreturned *App; must destroy

Why no higher-level tab: TypeScript and Python garbage collection would hide the explicit allocation, pointer stability, errdefer cleanup edge, and required destroy operation.

Text version: @This() names the file-as-struct. Allocator.create(App) obtains aligned storage and returns *App; try propagates allocation failure. errdefer frees that storage only if a later error unwinds the function. app.* initializes the pointee. The caller owns the returned pointer and must call destroy.

pub fn create(alloc: Allocator) CreateError!*App {
const app = try alloc.create(App);
errdefer alloc.destroy(app);
app.init(alloc);
std.debug.print("[app] created\n", .{});
return app;
}

If you know C, alloc.create(App) is the typed version of asking for sizeof(App) bytes with the right alignment. It gives us a *App instead of a void *.

The production-shaped errdefer says: if a later initialization operation fails after allocation, release the allocation while unwinding the error. In this checkpoint init cannot fail yet, so the cleanup path is intentionally dormant. It becomes meaningful as soon as App initializes a fallible owned resource.

init writes a complete value into the uninitialized allocation:

pub fn init(self: *App, alloc: Allocator) void {
self.* = .{ .alloc = alloc };
}

After this assignment, the pointer refers to a valid App.

Follow destroy: why both deinit and destroy?

Section titled “Follow destroy: why both deinit and destroy?”
pub fn destroy(self: *App) void {
const alloc = self.alloc;
self.deinit();
std.debug.print("[app] destroyed\n", .{});
alloc.destroy(self);
}

The order is deliberate:

read allocator needed for final free
→ release resources owned by App
→ emit temporary trace
→ free the App allocation

Nothing may read self after alloc.destroy(self).

You might ask why we need two cleanup names. Today deinit is empty, but the split is useful:

  • deinit: release resources stored inside an initialized value;
  • destroy: deinitialize, then free the value’s own allocation.
pub fn run(self: *App) void {
_ = self;
std.debug.print("[app] running\n", .{});
}

This method does not pretend to be an event loop. It makes the middle of the lifecycle observable. Chapter 03 removes this responsibility from App when a runtime exists.

The _ = self line explicitly acknowledges that this checkpoint does not read App state yet.

temporarysrc/main.zig

It proves lifecycle ordering but still bypasses Ghostty’s generated entrypoint routing and process-wide global allocator.

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

pub fn main() !void {
    std.debug.print("[main] process started\n", .{});

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

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

Now main reads like a short checklist:

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

defer runs when control leaves the surrounding scope. The closing brace therefore forces app.destroy() before the final [main] process exiting trace.

std.heap.page_allocator is sufficient for one allocation. It is not Ghostty’s final allocator strategy; process-wide initialization belongs to a later boundary.

Terminal window
cd ~/ghostty-from-scratch
zig fmt --check .
zig build
zig build test
zig build run

Expected run output:

[main] process started
[app] created
[app] running
[app] destroyed
[main] process exiting

The test creates and destroys App with std.testing.allocator. Zig’s testing allocator reports leaked allocations, so forgetting destroy makes the narrow lifecycle test fail for the right reason.

File Status Why
src/App.zig adapted Current stable-pointer lifecycle and names; temporary trace and run; production resources omitted
src/main.zig temporary Direct import and page allocator before generated routing and global initialization
build.zig adapted Existing minimal executable and test graph remain sufficient

Nothing is exact yet. Copying production App.zig would pull in surfaces, fonts, mailboxes, configuration, renderer state, and runtime messages before any of them has a job.

Current App preserves the lifecycle shape reconstructed here:

</>
Current App allocation and cleanupsrc/App.zig:70–121Read create, init, deinit, and destroy as one ownership contract. Ignore surfaces, font caches, configuration, and mailboxes until they enter the frontier.
Read excerptGitHub

Production create needs errdefer because init creates a fallible shared font grid set. Production deinit closes surfaces and font caches before freeing App itself.

The larger architectural change is where run lives:

</>
Current shared App and platform runtimesrc/main_ghostty.zig:80–108Read the two adjacent lifetimes: App.create/destroy owns shared state; apprt.App.init/terminate/run owns platform integration and the event loop.
Read excerptGitHub

Current startup is conceptually:

shared App state: create ───────────────────── destroy
platform runtime App: init ─ run ─ terminate

That split allows GTK and macOS to provide different native event loops while sharing terminal application state.

The reference checkpoint is:

commit 02c9bedd6c193105b50be2c3d074094c2a3a21f3
tag chapter-01-app-lifecycle

The output fixture was generated from that implementation with Zig 0.16.0, not typed from memory.

main.zig now has a useful orchestration role, but it is still a hand-written direct entrypoint. Current Ghostty selects entry behavior through generated build configuration and routes the graphical executable into main_ghostty.zig.

Chapter 02 will reconstruct only that routing boundary:

executable main
→ generated build choice
→ Ghostty application entry
→ App lifecycle

The program will remain headless. A native runtime, Surface, PTY, parser, and renderer are still beyond the frontier.