Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ Renderer draws attention border / story overlay
| `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` |
| `input/mapper.zig` | SDL keycodes to VT escape sequences, shortcut detection | `encodeKey()`, modifier helpers | `c` |
| `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) |
| `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher, foreground agent detection, graceful agent teardown at quit | `SessionState`, `AgentKind`, `init()`, `deinit()`, `ensureSpawnedWithDir()`, `render_epoch`, `pending_write`, `detectForegroundAgent()`, `sendTermToForegroundPgrp()`, `drainOutputForMs()` | `shell`, `pty`, `vt_stream`, `cwd`, `font`, xev |
| `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher, foreground agent detection, graceful agent teardown at quit | `SessionState`, `AgentKind`, `init()`, `despawn()`, `deinit()`, `ensureSpawnedWithDir()`, `render_epoch`, `pending_write`, `detectForegroundAgent()`, `sendTermToForegroundPgrp()`, `drainOutputForMs()` | `shell`, `pty`, `vt_stream`, `cwd`, `font`, xev |
| `session/notify.zig` | Background notification socket thread and queue; handles status and story notifications | `NotificationQueue`, `Notification` (union: status/story), `startThread()`, `push()`, `drain()` | std (socket, thread) |
| `session/*` (shell, pty, vt_stream, cwd) | Shell spawning, PTY abstraction, VT parsing, working directory detection | `spawn()`, `Pty`, `VtStream.processBytes()`, `getCwd()` | std (posix), ghostty-vt |
| `render/renderer.zig` | Scene rendering: terminals, borders, animations, terminal scrollbar painting | `render()`, `RenderCache`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `ui/components/scrollbar`, `c` |
Expand Down
4 changes: 2 additions & 2 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@ pub fn run() !void {
}

// Close the terminal
session.deinit(allocator);
session.despawn(allocator);
session_interaction_component.resetView(session_idx);
session.markDirty();

Expand Down Expand Up @@ -1967,7 +1967,7 @@ pub fn run() !void {
break :blk null;
};
}
sessions[idx].deinit(allocator);
sessions[idx].despawn(allocator);
session_interaction_component.resetView(idx);
sessions[idx].markDirty();
compactSessions(sessions, session_interaction_component.viewSlice(), &render_cache, &anim_state);
Expand Down
61 changes: 58 additions & 3 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ pub const SessionState = struct {
InvalidArgument,
};

const WaitContextCleanup = enum {
destroy_immediately,
defer_if_active,
};

pub fn init(
allocator: std.mem.Allocator,
slot_index: usize,
Expand Down Expand Up @@ -264,6 +269,16 @@ pub const SessionState = struct {
}

pub fn deinit(self: *SessionState, allocator: std.mem.Allocator) void {
self.teardown(allocator, .destroy_immediately);
}

/// Runtime close path used while the event loop is still active.
/// Keeps active process wait callbacks valid by deferring context destruction.
pub fn despawn(self: *SessionState, allocator: std.mem.Allocator) void {
self.teardown(allocator, .defer_if_active);
}

fn teardown(self: *SessionState, allocator: std.mem.Allocator, wait_ctx_cleanup: WaitContextCleanup) void {
self.pending_write.deinit(allocator);
self.pending_write = .empty;
self.quit_capture.deinit(allocator);
Expand All @@ -287,9 +302,16 @@ pub const SessionState = struct {
self.process_watcher = null;
}
if (self.process_wait_ctx) |ctx| {
// Free unconditionally: deinit runs after the event loop stops, so
// processExitCallback will never fire for pending completions.
allocator.destroy(ctx);
switch (wait_ctx_cleanup) {
.destroy_immediately => {
allocator.destroy(ctx);
},
.defer_if_active => {
if (ctx.completion.state() == .dead) {
allocator.destroy(ctx);
}
},
}
}
self.process_wait_ctx = null;
// Wrap intentionally: process_generation is a bounded counter and may overflow.
Expand Down Expand Up @@ -818,6 +840,39 @@ test "SessionState assigns incrementing ids" {
try std.testing.expectEqualStrings("1", std.mem.sliceTo(second.session_id_z[0..], 0));
}

test "despawn keeps active wait context alive until callback reclaims it" {
const allocator = std.testing.allocator;
const size = pty_mod.winsize{
.ws_row = 24,
.ws_col = 80,
.ws_xpixel = 0,
.ws_ypixel = 0,
};
const notify_sock: [:0]const u8 = "sock";

var session = try SessionState.init(allocator, 0, "/bin/zsh", size, notify_sock);
defer session.deinit(allocator);

const wait_ctx = try allocator.create(SessionState.WaitContext);
wait_ctx.* = .{
.session = &session,
.generation = 0,
.pid = 1,
.completion = .{},
};
wait_ctx.completion.flags.state = @enumFromInt(1);

session.process_wait_ctx = wait_ctx;
session.despawn(allocator);
try std.testing.expect(session.process_wait_ctx == null);

var loop = try xev.Loop.init(.{});
defer loop.deinit();
var completion: xev.Completion = .{};
const action = SessionState.processExitCallback(wait_ctx, &loop, &completion, 0);
try std.testing.expectEqual(xev.CallbackAction.disarm, action);
}

fn makeNonBlocking(fd: posix.fd_t) MakeNonBlockingError!void {
const flags = try posix.fcntl(fd, posix.F.GETFL, 0);
var o_flags: posix.O = @bitCast(@as(u32, @intCast(flags)));
Expand Down