Files
marfrit 10d2fc5ac1 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>
2026-05-10 19:03:19 +00:00

92 lines
2.6 KiB
Lua

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