a73x

835bd46d

feat(pty): spawn child shell via forkpty

a73x   2026-04-08 06:01

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>

diff --git a/build.zig b/build.zig
index 10e595b..0f315c7 100644
--- a/build.zig
+++ b/build.zig
@@ -40,8 +40,31 @@ pub fn build(b: *std.Build) void {
    const run_step = b.step("run", "Run waystty");
    run_step.dependOn(&run_cmd.step);

    // pty module — forkpty-based PTY spawn
    const pty_mod = b.createModule(.{
        .root_source_file = b.path("src/pty.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    pty_mod.linkSystemLibrary("util", .{});
    exe_mod.addImport("pty", pty_mod);

    const test_step = b.step("test", "Run unit tests");

    // Test pty.zig
    const pty_test_mod = b.createModule(.{
        .root_source_file = b.path("src/pty.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    pty_test_mod.linkSystemLibrary("util", .{});
    const pty_tests = b.addTest(.{
        .root_module = pty_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(pty_tests).step);

    // Test main.zig (and transitively vt.zig via its import)
    const main_test_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
diff --git a/src/pty.zig b/src/pty.zig
new file mode 100644
index 0000000..01e6e9b
--- /dev/null
+++ b/src/pty.zig
@@ -0,0 +1,71 @@
const std = @import("std");

const c = @cImport({
    @cInclude("pty.h");
    @cInclude("termios.h");
    @cInclude("unistd.h");
    @cInclude("sys/ioctl.h");
    @cInclude("fcntl.h");
    @cInclude("sys/wait.h");
    @cInclude("stdlib.h");
});

pub const Pty = struct {
    master_fd: std.posix.fd_t,
    child_pid: std.posix.pid_t,

    pub const SpawnOptions = struct {
        cols: u16,
        rows: u16,
        shell: [:0]const u8,
    };

    pub fn spawn(opts: SpawnOptions) !Pty {
        var master: c_int = undefined;
        var winsize = c.struct_winsize{
            .ws_row = opts.rows,
            .ws_col = opts.cols,
            .ws_xpixel = 0,
            .ws_ypixel = 0,
        };

        const pid = c.forkpty(&master, null, null, &winsize);
        if (pid < 0) return error.ForkptyFailed;

        if (pid == 0) {
            // Child process
            _ = c.setenv("TERM", "xterm-256color", 1);

            var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null };
            std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {};
            std.process.exit(1);
        }

        // Parent: set master fd non-blocking
        const flags = try std.posix.fcntl(master, std.posix.F.GETFL, 0);
        const nonblock_bit: usize = @as(u32, @bitCast(std.posix.O{ .NONBLOCK = true }));
        _ = try std.posix.fcntl(master, std.posix.F.SETFL, flags | nonblock_bit);

        return .{
            .master_fd = master,
            .child_pid = pid,
        };
    }

    pub fn deinit(self: *Pty) void {
        std.posix.close(self.master_fd);
        _ = std.c.kill(self.child_pid, std.c.SIG.TERM);
        _ = std.c.waitpid(self.child_pid, null, 0);
    }
};

test "Pty.spawn launches /bin/sh and returns valid fd" {
    var pty = try Pty.spawn(.{
        .cols = 80,
        .rows = 24,
        .shell = "/bin/sh",
    });
    defer pty.deinit();
    try std.testing.expect(pty.master_fd >= 0);
    try std.testing.expect(pty.child_pid > 0);
}