From 8027096f3e1b769c56e4cb5cd9db2045c939a381 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 1 Mar 2026 19:26:35 +0100 Subject: [PATCH] fix(vt): detect windows pty child exit via registerWaitForSingleObject --- build.zig.zon | 4 +- src/tui/terminal_view.zig | 102 ++++++++++++++++++++++++++++++-------- 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e68bf0d..7662f65 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,8 +30,8 @@ .hash = "fuzzig-0.1.1-Ji0xivxIAQBD0g8O_NV_0foqoPf3elsg9Sc3pNfdVH4D", }, .vaxis = .{ - .url = "git+https://github.com/neurocyte/libvaxis?ref=main#398f890e9015576673a9767d6d44877c1da34cfc", - .hash = "vaxis-0.5.1-BWNV_PQdCgBo6zTDEPmwNeTk9TM_qY0FjHWOx60YRYUB", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#e6801b9c81fab5313bc35b349b294ba4b0a060ad", + .hash = "vaxis-0.5.1-BWNV_L0eCgA4TNGahogJfmfebvJ-0sQXhOJAKn5WZmc6", }, .zeit = .{ .url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#ed2ca60db118414bda2b12df2039e33bad3b0b88", diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 0a38f59..42f543a 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -709,11 +709,23 @@ const pty_posix = struct { }; /// Windows pty actor: reads ConPTY output pipe via tp.file_stream (IOCP overlapped I/O). -/// Exit detection relies on error 109 (ERROR_BROKEN_PIPE) from the read stream, -/// which fires when the child process exits and the ConPTY closes the pipe. +/// +/// Exit detection: ConPTY does NOT close the output pipe when the child process exits - +/// it keeps it open until ClosePseudoConsole is called. So a pending async read would +/// block forever. Instead we use RegisterWaitForSingleObject on the process handle; +/// when it fires the threadpool callback posts "child_exited" to this actor, which +/// cancels the stream and tears down cleanly. const pty_windows = struct { const Parser = Terminal.Parser; const Receiver = tp.Receiver(*@This()); + const windows = std.os.windows; + + // Context struct allocated on the heap and passed to the wait callback. + // Heap-allocated so its lifetime is independent of the actor. + const WaitCtx = struct { + self_pid: tp.pid, + allocator: std.mem.Allocator, + }; allocator: std.mem.Allocator, vt: *Terminal, @@ -721,6 +733,7 @@ const pty_windows = struct { parser: Parser, receiver: Receiver, parent: tp.pid, + wait_handle: ?windows.HANDLE = null, pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { const self = try allocator.create(@This()); @@ -737,6 +750,10 @@ const pty_windows = struct { fn deinit(self: *@This()) void { std.log.debug("terminal: pty actor (windows) deinit", .{}); + if (self.wait_handle) |wh| { + _ = UnregisterWait(wh); + self.wait_handle = null; + } if (self.stream) |s| s.deinit(); self.parser.buf.deinit(); self.parent.deinit(); @@ -753,9 +770,46 @@ const pty_windows = struct { std.log.debug("terminal: pty stream start_read failed: {}", .{e}); return tp.exit_error(e, @errorReturnTrace()); }; + + // Register a one-shot wait on the process handle. When the child exits + // the threadpool fires on_child_exit, which sends "child_exited" to us. + // This is the only reliable way to detect ConPTY child exit without polling, + // since ConPTY keeps the output pipe open until ClosePseudoConsole. + const process_handle = self.vt.cmd.process_handle orelse { + std.log.debug("terminal: pty actor: no process handle to wait on", .{}); + return tp.exit_error(error.NoProcessHandle, @errorReturnTrace()); + }; + const ctx = self.allocator.create(WaitCtx) catch |e| + return tp.exit_error(e, @errorReturnTrace()); + ctx.* = .{ + .self_pid = tp.self_pid().clone(), + .allocator = self.allocator, + }; + var wh: windows.HANDLE = undefined; + // WT_EXECUTEONLYONCE: callback fires once then the wait is auto-unregistered. + const WT_EXECUTEONLYONCE: windows.ULONG = 0x00000008; + if (RegisterWaitForSingleObject(&wh, process_handle, on_child_exit, ctx, windows.INFINITE, WT_EXECUTEONLYONCE) == windows.FALSE) { + ctx.self_pid.deinit(); + self.allocator.destroy(ctx); + std.log.debug("terminal: RegisterWaitForSingleObject failed", .{}); + return tp.exit_error(error.RegisterWaitFailed, @errorReturnTrace()); + } + self.wait_handle = wh; + tp.receive(&self.receiver); } + /// Threadpool callback - called when the process handle becomes signaled. + /// Must be fast and non-blocking. Sends "child_exited" to the pty actor. + fn on_child_exit(ctx_ptr: ?*anyopaque, _: windows.BOOLEAN) callconv(.winapi) void { + const ctx: *WaitCtx = @ptrCast(@alignCast(ctx_ptr orelse return)); + defer { + ctx.self_pid.deinit(); + ctx.allocator.destroy(ctx); + } + ctx.self_pid.send(.{"child_exited"}) catch {}; + } + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { errdefer self.deinit(); @@ -763,16 +817,15 @@ const pty_windows = struct { var err_code: i64 = 0; var err_msg: []const u8 = ""; - if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) { - // Got output data from the child - process it, then arm next read. - if (bytes.len == 0) { - // Zero bytes = EOF on the pipe = child exited - const code = self.vt.cmd.wait(); - std.log.debug("terminal: ConPTY pipe EOF, process exited with code={d}", .{code}); - self.vt.event_queue.push(.{ .exited = code }); - self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); - } + if (try m.match(.{"child_exited"})) { + self.wait_handle = null; + if (self.stream) |s| s.cancel() catch {}; + const code = self.vt.cmd.wait(); + std.log.debug("terminal: child exited (process wait), code={d}", .{code}); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{ "stream", "pty_out", "read_complete", tp.extract(&bytes) })) { defer self.parent.send(.{ "terminal_view", "output" }) catch {}; switch (self.vt.processOutput(&self.parser, bytes) catch |e| { std.log.debug("terminal: processOutput error: {}", .{e}); @@ -784,21 +837,12 @@ const pty_windows = struct { }, .running => {}, } - // Re-arm the read for next chunk self.stream.?.start_read() catch |e| { std.log.debug("terminal: pty stream re-arm failed: {}", .{e}); return tp.exit_normal(); }; - } else if (try m.match(.{ "stream", "pty_out", "read_error", 109, tp.extract(&err_msg) })) { - // ERROR_BROKEN_PIPE (109) = child process has exited and ConPTY closed the pipe. - const code = self.vt.cmd.wait(); - std.log.debug("terminal: ConPTY pipe broken (child exited), code={d}", .{code}); - self.vt.event_queue.push(.{ .exited = code }); - self.parent.send(.{ "terminal_view", "output" }) catch {}; - return tp.exit_normal(); } else if (try m.match(.{ "stream", "pty_out", "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) { - // Other read error - treat as unexpected exit - std.log.debug("terminal: ConPTY read error: {d} {s}", .{ err_code, err_msg }); + std.log.debug("terminal: ConPTY stream error: {d} {s}", .{ err_code, err_msg }); const code = self.vt.cmd.wait(); self.vt.event_queue.push(.{ .exited = code }); self.parent.send(.{ "terminal_view", "output" }) catch {}; @@ -811,4 +855,18 @@ const pty_windows = struct { return tp.unexpected(m); } } + + // Win32 extern declarations + extern "kernel32" fn RegisterWaitForSingleObject( + phNewWaitObject: *windows.HANDLE, + hObject: windows.HANDLE, + Callback: *const fn (?*anyopaque, windows.BOOLEAN) callconv(.winapi) void, + Context: ?*anyopaque, + dwMilliseconds: windows.DWORD, + dwFlags: windows.ULONG, + ) callconv(.winapi) windows.BOOL; + + extern "kernel32" fn UnregisterWait( + WaitHandle: windows.HANDLE, + ) callconv(.winapi) windows.BOOL; };