diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e883530..42a5162 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 7cd84b4..3370bd7 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -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(); @@ -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); diff --git a/src/session/state.zig b/src/session/state.zig index 331c8d6..eed28d1 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -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, @@ -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); @@ -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. @@ -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)));