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.
|
-- ffi/pty.lua — forkpty-backed exec.
|
||||||
-- Phase 0: stub. Lands in Phase 1 to enable interactive programs (vim, htop).
|
-- 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
|
return M
|
||||||
|
|||||||
Reference in New Issue
Block a user