diff --git a/ffi/pty.lua b/ffi/pty.lua index 0cebc8b..6a4a83b 100644 --- a/ffi/pty.lua +++ b/ffi/pty.lua @@ -1,5 +1,91 @@ --- ffi/pty.lua — forkpty, openpty, waitpid bindings. --- Phase 0: stub. Lands in Phase 1 to enable interactive programs (vim, htop). +-- ffi/pty.lua — forkpty-backed exec. +-- Phase 1: replaces Phase 0's io.popen path so interactive cmds (vim, less, +-- htop) work and so executor's exit-code recovery can use waitpid instead +-- of the §7 sentinel hack. +-- See docs/PHASE1.md §5. + +local ffi = require("ffi") +local libc = require("ffi.libc") + +ffi.cdef[[ +typedef int pid_t; +pid_t forkpty(int *amaster, char *name, void *termp, void *winp); +int execvp (const char *file, char *const argv[]); +void _exit (int status); +]] + +-- libutil-dev's unversioned `libutil.so` symlink isn't assumed; fall back to +-- versioned sonames so a runtime-only host (no -dev installed) works. Same +-- idiom as ffi/readline + ffi/curl. +local function load_util() + local errs = {} + for _, name in ipairs({"util", "util.so.1", "util.so.0"}) do + local ok, lib = pcall(ffi.load, name) + if ok then return lib end + errs[#errs + 1] = name .. ": " .. tostring(lib) + end + error("libutil not loadable: " .. table.concat(errs, "; ")) +end + +local util = load_util() +local C = ffi.C + +local M = {} +local Session = {} +Session.__index = Session + +-- Spawn `cmd` (shell-interpreted via /bin/sh -c) under a fresh PTY. +-- Returns: +-- session table : { pid, master_fd, closed } with :read/:write/:close/:wait/:signal +-- nil, errmsg : on forkpty failure +function M.spawn(cmd) + local master = ffi.new("int[1]") + local pid = util.forkpty(master, nil, nil, nil) + if pid < 0 then + return nil, "forkpty: " .. libc.strerror(libc.errno()) + end + if pid == 0 then + -- child: exec /bin/sh -c cmd. argv must be NULL-terminated. + local argv = ffi.new("const char *[4]") + argv[0] = "/bin/sh" + argv[1] = "-c" + argv[2] = cmd + argv[3] = nil + C.execvp("/bin/sh", ffi.cast("char *const *", argv)) + -- execvp returned -> exec failed; abandon ship with conventional 127 + C._exit(127) + end + return setmetatable({ + pid = pid, + master_fd = master[0], + closed = false, + }, Session) +end + +-- Read up to `count` bytes from the master fd. Blocking. +-- Returns: +-- (string, n) on success; n == 0 means EOF (child closed its end) +-- (nil, errmsg) on syscall failure +function Session:read(count) + return libc.read(self.master_fd, count or 4096) +end + +function Session:write(data) + return libc.write(self.master_fd, data) +end + +function Session:close() + if self.closed then return end + libc.close(self.master_fd) + self.closed = true +end + +function Session:wait(options) + return libc.waitpid(self.pid, options) +end + +function Session:signal(sig) + return libc.kill(self.pid, sig) +end -local M = {} return M