@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.
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.
chapter-01-app-lifecycle$ zig build run
[main] process started
[app] created
[app] running
[app] destroyed
[main] process exitingCaptured 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?
main?Chapter 00 could fit every responsibility in five lines:
main → print → returnA 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
A boundary earns its place here because it answers two concrete questions:
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.
App.init created the window and renderer state, App.run owned the event loop, and App.deinit released the window.
5bbdd75Open first App.zigOur App has no window or renderer. It proves stable allocation, one owner, ordered use, and one destruction path.
chapter-01-app-lifecycleCurrent App owns shared state. A selected platform runtime owns the event loop and calls back into App.
6ad1fe7Open current lifecycleThe 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.
App.create(allocator)→alloc.create(App)→stable *App→return *App→app.run()→defer app.destroy()→deinit + destroy→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 orchestrationBoth files remain smaller than production because no resources beyond App’s own allocation exist.
App.zig: what kind of thing is App?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.
App.create: reserve memory, then initialize itThe complete checkpoint file is short enough to inspect:
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:
Start with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceInside 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.
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;trypropagates allocation failure.errdeferfrees that storage only if a later error unwinds the function.app.*initializes the pointee. The caller owns the returned pointer and must calldestroy.
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.
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 allocationNothing 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.run do right now?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.
main.zig and follow the callsIt 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.
cd ~/ghostty-from-scratchzig fmt --check .zig buildzig build testzig build runExpected run output:
[main] process started[app] created[app] running[app] destroyed[main] process exitingThe 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:
src/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.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:
src/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.Current startup is conceptually:
shared App state: create ───────────────────── destroyplatform runtime App: init ─ run ─ terminateThat split allows GTK and macOS to provide different native event loops while sharing terminal application state.
The reference checkpoint is:
commit 02c9bedd6c193105b50be2c3d074094c2a3a21f3tag chapter-01-app-lifecycleThe 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 lifecycleThe program will remain headless. A native runtime, Surface, PTY, parser, and renderer are still beyond the frontier.