a73x

835bd46d

feat(pty): spawn child shell via forkpty

a73x   2026-04-08 06:01

Commit message
feat(pty): spawn child shell via forkpty

Adds Pty.spawn using forkpty(3): forks a child process, execs the given
shell, sets the master fd non-blocking, and returns the fd + PID.
Wires pty module into build.zig with libc + libutil linkage and a
dedicated test step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

build.zig
Old New
@@ -40,8 +40,31 @@ pub fn build(b: *std.Build) void {
40 const run_step = b.step("run", "Run waystty"); 40 const run_step = b.step("run", "Run waystty");
41 run_step.dependOn(&run_cmd.step); 41 run_step.dependOn(&run_cmd.step);
42 42
43 // pty module — forkpty-based PTY spawn
44 const pty_mod = b.createModule(.{
45 .root_source_file = b.path("src/pty.zig"),
46 .target = target,
47 .optimize = optimize,
48 .link_libc = true,
49 });
50 pty_mod.linkSystemLibrary("util", .{});
51 exe_mod.addImport("pty", pty_mod);
52
43 const test_step = b.step("test", "Run unit tests"); 53 const test_step = b.step("test", "Run unit tests");
44 54
55 // Test pty.zig
56 const pty_test_mod = b.createModule(.{
57 .root_source_file = b.path("src/pty.zig"),
58 .target = target,
59 .optimize = optimize,
60 .link_libc = true,
61 });
62 pty_test_mod.linkSystemLibrary("util", .{});
63 const pty_tests = b.addTest(.{
64 .root_module = pty_test_mod,
65 });
66 test_step.dependOn(&b.addRunArtifact(pty_tests).step);
67
45 // Test main.zig (and transitively vt.zig via its import) 68 // Test main.zig (and transitively vt.zig via its import)
46 const main_test_mod = b.createModule(.{ 69 const main_test_mod = b.createModule(.{
47 .root_source_file = b.path("src/main.zig"), 70 .root_source_file = b.path("src/main.zig"),
src/pty.zig
Old New
@@ -0,0 +1,71 @@
1 const std = @import("std");
2
3 const c = @cImport({
4 @cInclude("pty.h");
5 @cInclude("termios.h");
6 @cInclude("unistd.h");
7 @cInclude("sys/ioctl.h");
8 @cInclude("fcntl.h");
9 @cInclude("sys/wait.h");
10 @cInclude("stdlib.h");
11 });
12
13 pub const Pty = struct {
14 master_fd: std.posix.fd_t,
15 child_pid: std.posix.pid_t,
16
17 pub const SpawnOptions = struct {
18 cols: u16,
19 rows: u16,
20 shell: [:0]const u8,
21 };
22
23 pub fn spawn(opts: SpawnOptions) !Pty {
24 var master: c_int = undefined;
25 var winsize = c.struct_winsize{
26 .ws_row = opts.rows,
27 .ws_col = opts.cols,
28 .ws_xpixel = 0,
29 .ws_ypixel = 0,
30 };
31
32 const pid = c.forkpty(&master, null, null, &winsize);
33 if (pid < 0) return error.ForkptyFailed;
34
35 if (pid == 0) {
36 // Child process
37 _ = c.setenv("TERM", "xterm-256color", 1);
38
39 var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null };
40 std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {};
41 std.process.exit(1);
42 }
43
44 // Parent: set master fd non-blocking
45 const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
46 const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true }));
47 _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit);
48
49 return .{
50 .master_fd = master,
51 .child_pid = pid,
52 };
53 }
54
55 pub fn deinit(self: *Pty) void {
56 std.posix.close(self.master_fd);
57 _ = std.c.kill(self.child_pid, std.c.SIG.TERM);
58 _ = std.c.waitpid(self.child_pid, null, 0);
59 }
60 };
61
62 test "Pty.spawn launches /bin/sh and returns valid fd" {
63 var pty = try Pty.spawn(.{
64 .cols = 80,
65 .rows = 24,
66 .shell = "/bin/sh",
67 });
68 defer pty.deinit();
69 try std.testing.expect(pty.master_fd >= 0);
70 try std.testing.expect(pty.child_pid > 0);
71 }