From 646db3b374020ff8ee90201d6b9740e66b750013 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sat, 28 Feb 2026 20:40:54 +0100 Subject: [PATCH 1/2] fix(terminal): build terminal on macos and freebsd --- build.zig.zon | 4 ++-- src/tui/terminal_view.zig | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 5789f967..7966bf31 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#db8eee5fc5e5d1b942e57b7645b2b1e39a3e37f1", - .hash = "vaxis-0.5.1-BWNV_LHUCQBX3VsvEzYYeHuShpG6o1br7Vc4h-wxQjJZ", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#31e54f7d16c16b3f1a4aaf99966dadb9e5ca7a0f", + .hash = "vaxis-0.5.1-BWNV_DzhCQDZI3Vt_DRwcmmEd_jrTyNLGPKpnOPoHQ3-", }, .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 c6a97747..460eafc8 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -582,7 +582,7 @@ const pty = struct { }, }; } else if (try m.match(.{ "fd", "pty", "read_error", tp.extract(&self.err_code), tp.more })) { - // thespian fires read_error with EPOLLHUP when the child exits cleanly. + // thespian fires read_error when the pty fd signals an error condition // Treat it the same as EIO: reap the child and signal exit. const code = self.vt.cmd.wait(); std.log.debug("terminal: read_error from fd (err={d}), process exited with code={d}", .{ self.err_code, code }); @@ -615,10 +615,11 @@ const pty = struct { while (true) { const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) { error.WouldBlock => { - // No more data right now. Check if the child already exited - - // on Linux a clean exit may not make the pty fd readable again - // (no EPOLLIN), it just starts returning EIO on the next read. - // Polling here catches that case before we arm wait_read again. + // No more data right now. On Linux, a clean child exit may not + // generate a readable event on the pty master - it just starts + // returning EIO. Poll for exit here before sleeping in wait_read. + // On macOS/FreeBSD the pty master raises EIO directly, so the + // try_wait check here is just an extra safety net. if (self.vt.cmd.try_wait()) |code| { std.log.debug("terminal: child exited (detected via try_wait) with code={d}", .{code}); self.vt.event_queue.push(.{ .exited = code }); From 97f8d024c630465ff4247cdb69de65ea971493ec Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sat, 28 Feb 2026 21:49:56 +0100 Subject: [PATCH 2/2] feat(terminal): initial version of conpty windows support --- build.zig.zon | 4 +- src/tui/terminal_view.zig | 117 +++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7966bf31..e68bf0d5 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#31e54f7d16c16b3f1a4aaf99966dadb9e5ca7a0f", - .hash = "vaxis-0.5.1-BWNV_DzhCQDZI3Vt_DRwcmmEd_jrTyNLGPKpnOPoHQ3-", + .url = "git+https://github.com/neurocyte/libvaxis?ref=main#398f890e9015576673a9767d6d44877c1da34cfc", + .hash = "vaxis-0.5.1-BWNV_PQdCgBo6zTDEPmwNeTk9TM_qY0FjHWOx60YRYUB", }, .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 460eafc8..026ee22e 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const tp = @import("thespian"); @@ -74,7 +75,11 @@ pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Contex try argv_list.append(allocator, arg); } } else { - try argv_list.append(allocator, env.get("SHELL") orelse "/bin/sh"); + const default_shell = if (builtin.os.tag == .windows) + env.get("COMSPEC") orelse "cmd.exe" + else + env.get("SHELL") orelse "/bin/sh"; + try argv_list.append(allocator, default_shell); } // Use the current plane dimensions for the initial pty size. The plane @@ -503,7 +508,11 @@ const Vt = struct { }; var global_vt: ?Vt = null; -const pty = struct { +// Platform-specific pty actor: POSIX uses tp.file_descriptor + SIGCHLD, +// Windows uses tp.file_stream with IOCP overlapped reads on the ConPTY output pipe. +const pty = if (builtin.os.tag == .windows) pty_windows else pty_posix; + +const pty_posix = struct { const Parser = Terminal.Parser; const Receiver = tp.Receiver(*@This()); @@ -698,3 +707,107 @@ const pty = 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. +const pty_windows = struct { + const Parser = Terminal.Parser; + const Receiver = tp.Receiver(*@This()); + + allocator: std.mem.Allocator, + vt: *Terminal, + stream: tp.file_stream, + parser: Parser, + receiver: Receiver, + parent: tp.pid, + + pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid { + const self = try allocator.create(@This()); + errdefer allocator.destroy(self); + // tp.file_stream.init takes a *anyopaque (Win32 HANDLE) + const stream = try tp.file_stream.init("pty_out", vt.ptyOutputHandle()); + self.* = .{ + .allocator = allocator, + .vt = vt, + .stream = stream, + .parser = .{ .buf = try .initCapacity(allocator, 128) }, + .receiver = Receiver.init(pty_receive, self), + .parent = tp.self_pid().clone(), + }; + return tp.spawn_link(allocator, self, start, "pty_actor"); + } + + fn deinit(self: *@This()) void { + std.log.debug("terminal: pty actor (windows) deinit", .{}); + self.stream.deinit(); + self.parser.buf.deinit(); + self.parent.deinit(); + self.allocator.destroy(self); + } + + fn start(self: *@This()) tp.result { + errdefer self.deinit(); + self.stream.start_read() catch |e| { + std.log.debug("terminal: pty stream start_read failed: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; + tp.receive(&self.receiver); + } + + fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + + var bytes: []const u8 = ""; + 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(); + } + defer self.parent.send(.{ "terminal_view", "output" }) catch {}; + switch (self.vt.processOutput(&self.parser, bytes) catch |e| { + std.log.debug("terminal: processOutput error: {}", .{e}); + return tp.exit_normal(); + }) { + .exited => { + std.log.debug("terminal: processOutput returned .exited", .{}); + return tp.exit_normal(); + }, + .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 }); + const code = self.vt.cmd.wait(); + self.vt.event_queue.push(.{ .exited = code }); + self.parent.send(.{ "terminal_view", "output" }) catch {}; + return tp.exit_normal(); + } else if (try m.match(.{"quit"})) { + std.log.debug("terminal: pty actor (windows) received quit", .{}); + return tp.exit_normal(); + } else { + std.log.debug("terminal: pty actor (windows) unexpected message", .{}); + return tp.unexpected(m); + } + } +};