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.
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:
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 exitingThe 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.
App.run() stay where it is?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:
So shared App code should not pretend it owns every platform’s event loop. We need a separate runtime owner.
DEPENDENCY FRONTIER
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.
apprt.App owned the GUI loop while core App stopped depending directly on GLFW or GTK.
3d8c62cOpen early runtimeA headless runtime App creates one runtime Surface around one core Surface and closes both in order.
chapter-03-runtime-surfaceCurrent GTK, embedded, browser, and no-runtime implementations share core App and Surface behavior through compile-time interfaces.
6ad1fe7Open current apprtThe sequence matters: runtime extraction came before the mature Surface accumulated PTY, parser, renderer, input, and thread ownership.
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:
Start with the C model, then inspect each highlighted Zig line.
Open in the cumulative Zig/C referenceThis 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.
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:
?*Surfaceis either null or a typed stable pointer. A successfulcreatestores ownership; payload capture unwraps the non-null pointer fordestroy; 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 |
apprt.zig: choose a runtimeCurrent 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.
Surface.zigStable 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.
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.
main_ghostty.zig and check the orderMatches 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.
zig fmt --check .zig buildzig build testzig build runThe 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 |
src/apprt.zig:1–48Read the contract, compile-time switch, and exported App/Surface types. Ignore backend internals.src/main_ghostty.zig:80–108Read the nested lifetimes and event-loop handoff. Ignore quit timers until multiple surfaces exist.src/Surface.zig:1–20Read why Surface is a widget-like terminal session rather than necessarily a window.commit 40752eab8f9c1a05eda14d60cfe15d2c7773372ctag chapter-03-runtime-surfaceThe 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.