ffi/pty: forkpty-backed spawn + session handle
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) <noreply@anthropic.com>
This commit is contained in:
+89
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user