@@ -144,6 +144,11 @@ pub const SessionState = struct {
144144 InvalidArgument ,
145145 };
146146
147+ const WaitContextCleanup = enum {
148+ destroy_immediately ,
149+ defer_if_active ,
150+ };
151+
147152 pub fn init (
148153 allocator : std.mem.Allocator ,
149154 slot_index : usize ,
@@ -264,6 +269,16 @@ pub const SessionState = struct {
264269 }
265270
266271 pub fn deinit (self : * SessionState , allocator : std.mem.Allocator ) void {
272+ self .teardown (allocator , .destroy_immediately );
273+ }
274+
275+ /// Runtime close path used while the event loop is still active.
276+ /// Keeps active process wait callbacks valid by deferring context destruction.
277+ pub fn despawn (self : * SessionState , allocator : std.mem.Allocator ) void {
278+ self .teardown (allocator , .defer_if_active );
279+ }
280+
281+ fn teardown (self : * SessionState , allocator : std.mem.Allocator , wait_ctx_cleanup : WaitContextCleanup ) void {
267282 self .pending_write .deinit (allocator );
268283 self .pending_write = .empty ;
269284 self .quit_capture .deinit (allocator );
@@ -287,9 +302,16 @@ pub const SessionState = struct {
287302 self .process_watcher = null ;
288303 }
289304 if (self .process_wait_ctx ) | ctx | {
290- // Free unconditionally: deinit runs after the event loop stops, so
291- // processExitCallback will never fire for pending completions.
292- allocator .destroy (ctx );
305+ switch (wait_ctx_cleanup ) {
306+ .destroy_immediately = > {
307+ allocator .destroy (ctx );
308+ },
309+ .defer_if_active = > {
310+ if (ctx .completion .state () == .dead ) {
311+ allocator .destroy (ctx );
312+ }
313+ },
314+ }
293315 }
294316 self .process_wait_ctx = null ;
295317 // Wrap intentionally: process_generation is a bounded counter and may overflow.
@@ -818,6 +840,39 @@ test "SessionState assigns incrementing ids" {
818840 try std .testing .expectEqualStrings ("1" , std .mem .sliceTo (second .session_id_z [0.. ], 0 ));
819841}
820842
843+ test "despawn keeps active wait context alive until callback reclaims it" {
844+ const allocator = std .testing .allocator ;
845+ const size = pty_mod.winsize {
846+ .ws_row = 24 ,
847+ .ws_col = 80 ,
848+ .ws_xpixel = 0 ,
849+ .ws_ypixel = 0 ,
850+ };
851+ const notify_sock : [:0 ]const u8 = "sock" ;
852+
853+ var session = try SessionState .init (allocator , 0 , "/bin/zsh" , size , notify_sock );
854+ defer session .deinit (allocator );
855+
856+ const wait_ctx = try allocator .create (SessionState .WaitContext );
857+ wait_ctx .* = .{
858+ .session = & session ,
859+ .generation = 0 ,
860+ .pid = 1 ,
861+ .completion = .{},
862+ };
863+ wait_ctx .completion .flags .state = @enumFromInt (1 );
864+
865+ session .process_wait_ctx = wait_ctx ;
866+ session .despawn (allocator );
867+ try std .testing .expect (session .process_wait_ctx == null );
868+
869+ var loop = try xev .Loop .init (.{});
870+ defer loop .deinit ();
871+ var completion : xev.Completion = .{};
872+ const action = SessionState .processExitCallback (wait_ctx , & loop , & completion , 0 );
873+ try std .testing .expectEqual (xev .CallbackAction .disarm , action );
874+ }
875+
821876fn makeNonBlocking (fd : posix.fd_t ) MakeNonBlockingError ! void {
822877 const flags = try posix .fcntl (fd , posix .F .GETFL , 0 );
823878 var o_flags : posix.O = @bitCast (@as (u32 , @intCast (flags )));
0 commit comments