From 10d2fc5ac1e4741b1ebe60aef6a0874230148f03 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 10 May 2026 19:03:19 +0000 Subject: [PATCH] ffi/pty: forkpty-backed spawn + session handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 PTY substrate per PHASE1.md §5. Replaces Phase 0's io.popen sentinel-echo path with a real PTY so interactive cmds (vim, less, htop) work and exit-status comes from waitpid instead of parsing a sentinel out of stdout. API: pty.spawn(cmd) -> session | (nil, err) session:read(count) -> (data, n) ; n == 0 means EOF session:write(data) -> bytes session:close() ; closes master_fd; child gets SIGHUP session:wait(options) -> (kind, val) ; "exit"/"signal"/"other"/nil session:signal(sig) -> ok ; kill(pid, sig) Child branch execs `/bin/sh -c cmd`, preserving Phase 0's shell- interpretation semantics (quoting, redirection, pipes still work). The PTY makes vim/less/htop functional because the child gets a real tty for line discipline instead of a pipe. Loader uses the versioned-soname fallback idiom (util / util.so.1 / util.so.0) so a runtime-only host without libutil-dev works. Smoke covers: echo hello (exit 0), false (1), exit 7, bogus binary (sh's 127), multi-line printf, cat bidirectional (write ping -> read echo+cat output -> close master -> child exits via SIGHUP). Next: executor.lua swap from popen+sentinel to pty.spawn. That commit also retires the §7 amendment paragraph (no longer needed once popen is gone). Co-Authored-By: Claude Opus 4.7 (1M context) --- ffi/pty.lua | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) 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