-- 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 return M