1f1065157e
Phase 1 review caught a structural gap: executor.exec only drained the
PTY master fd, never forwarded user keystrokes — vim/less/htop/nano
would render and hang on input. PHASE1.md §5 specified bidirectional
multiplex but only the read leg landed. tcgetattr/tcsetattr were also
missing, so even with input forwarding the parent's line discipline
would buffer until newline (breaking single-key UIs).
ffi/libc:
- struct termios opaque buffer + tcgetattr/tcsetattr + cfmakeraw
- M.set_raw(fd) saves termios + applies cfmakeraw; returns saved or
(nil, err) when fd isn't a tty (scripted / piped-stdin runs)
- M.restore_termios(fd, saved)
- struct pollfd + M.poll (POLLIN constant)
executor:
- multiplex(sess): poll(stdin, master); reads master on any revents
(POLLHUP fires when child closes its slave end, not POLLIN — the
revents != 0 check catches both); forwards stdin keystrokes to
master; loop exits when master read returns 0 (EOF / child gone)
- stdin polling is only enabled when stdin_is_tty (set_raw succeeded);
piped-stdin runs (tests / scripted) would otherwise drain queued
aish commands into the child of the *current* cmd, swallowing them
- raw mode is restored before returning so the user lands back at the
aish prompt in canonical mode
renderer + repl:
- exec_output(out, code) split into exec_begin() (top rule, before
spawn) + exec_end(code) (closing rule with exit, after wait). PTY
multiplex streams the body live to stdout in between; the renderer
never re-prints the body.
PHASE1.md §3:
- tcgetattr/tcsetattr changed from "optional" to "required for
single-key UIs to work — done-criteria #2"; poll added to the libc
row description.
Verified:
- non-interactive smoke (echo / false / exit 7 / ls /nonexistent /
printf multi-line) — all exit codes correct, output streamed live,
a\nb\nc\n preserved byte-for-byte
- scripted-stdin run reaches all expected lines (no stdin draining
into a non-interactive child)
- aish prompt + framed exec block + exit-code line all render in
correct order
Live interactive verification (vim / less / htop in a real terminal)
still needs a user-test pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
5.2 KiB
Lua
155 lines
5.2 KiB
Lua
-- ffi/libc.lua — shared libc bindings.
|
|
-- Phase 0: chdir, errno, strerror — enough for `cd` interception in executor.
|
|
-- Phase 1: waitpid + WEXITSTATUS, raw fd I/O (read/write/close), kill — the
|
|
-- syscalls ffi/pty needs to drive a forkpty'd child.
|
|
-- See docs/PHASE0.md §7 and docs/PHASE1.md §3.
|
|
|
|
local ffi = require("ffi")
|
|
local bit = require("bit")
|
|
|
|
ffi.cdef[[
|
|
int chdir(const char *path);
|
|
int *__errno_location(void);
|
|
char *strerror(int errnum);
|
|
|
|
typedef int pid_t;
|
|
typedef long ssize_t;
|
|
typedef unsigned long size_t;
|
|
|
|
pid_t waitpid(pid_t pid, int *wstatus, int options);
|
|
ssize_t read (int fd, void *buf, size_t count);
|
|
ssize_t write (int fd, const void *buf, size_t count);
|
|
int close (int fd);
|
|
int kill (pid_t pid, int sig);
|
|
|
|
/* termios for raw-mode toggle around interactive PTY children. The struct
|
|
is treated as opaque — cfmakeraw fills it; size 64 is comfortably larger
|
|
than glibc's struct termios (60 bytes) on aarch64/x86_64 Linux. */
|
|
struct termios { char _opaque[64]; };
|
|
int tcgetattr(int fd, struct termios *tio);
|
|
int tcsetattr(int fd, int actions, const struct termios *tio);
|
|
void cfmakeraw(struct termios *tio);
|
|
|
|
/* poll for stdin↔master multiplex in executor. */
|
|
struct pollfd { int fd; short events; short revents; };
|
|
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
|
|
]]
|
|
|
|
local C = ffi.C
|
|
|
|
local M = {}
|
|
|
|
-- ---------------------------------------------------------------- chdir / errno
|
|
-- Phase 0 invariants. Apply chdir per PHASE0.md §7.
|
|
-- Returns: true on success; false, errmsg on failure.
|
|
function M.chdir(path)
|
|
local rc = C.chdir(path)
|
|
if rc == 0 then return true end
|
|
return false, ffi.string(C.strerror(C.__errno_location()[0]))
|
|
end
|
|
|
|
function M.errno() return C.__errno_location()[0] end
|
|
function M.strerror(en) return ffi.string(C.strerror(en)) end
|
|
|
|
-- ---------------------------------------------------------------- waitpid
|
|
-- Mirrors glibc's WIFEXITED / WEXITSTATUS / WIFSIGNALED / WTERMSIG macros.
|
|
local function WIFEXITED (status) return bit.band(status, 0x7f) == 0 end
|
|
local function WEXITSTATUS(status) return bit.band(bit.rshift(status, 8), 0xff) end
|
|
local function WIFSIGNALED(status)
|
|
-- signal-killed iff low 7 bits in 1..126
|
|
local s = bit.band(status, 0x7f)
|
|
return s ~= 0 and s ~= 0x7f
|
|
end
|
|
local function WTERMSIG (status) return bit.band(status, 0x7f) end
|
|
|
|
M.WIFEXITED = WIFEXITED
|
|
M.WEXITSTATUS = WEXITSTATUS
|
|
M.WIFSIGNALED = WIFSIGNALED
|
|
M.WTERMSIG = WTERMSIG
|
|
|
|
-- waitpid wrapper. Returns (kind, value):
|
|
-- "exit", exit_code on normal exit (WIFEXITED -> WEXITSTATUS)
|
|
-- "signal", signum on signal kill (WIFSIGNALED -> WTERMSIG)
|
|
-- nil, errmsg on waitpid syscall failure
|
|
local status_buf = ffi.new("int[1]")
|
|
function M.waitpid(pid, options)
|
|
status_buf[0] = 0
|
|
local rc = C.waitpid(pid, status_buf, options or 0)
|
|
if rc < 0 then
|
|
return nil, ffi.string(C.strerror(C.__errno_location()[0]))
|
|
end
|
|
local status = status_buf[0]
|
|
if WIFEXITED(status) then return "exit", WEXITSTATUS(status) end
|
|
if WIFSIGNALED(status) then return "signal", WTERMSIG(status) end
|
|
return "other", status
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- raw fd I/O
|
|
-- Used by ffi/pty for master-fd transfer. Errors return nil + errmsg so
|
|
-- callers can decide between EAGAIN/EINTR retry and abort. EOF on read is
|
|
-- represented as ("", 0) — empty string, zero bytes.
|
|
local READ_BUF = ffi.new("char[?]", 4096)
|
|
|
|
function M.read(fd, count)
|
|
count = count or 4096
|
|
local buf = (count <= 4096) and READ_BUF or ffi.new("char[?]", count)
|
|
local n = C.read(fd, buf, count)
|
|
if n < 0 then
|
|
return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno()
|
|
end
|
|
return ffi.string(buf, n), tonumber(n)
|
|
end
|
|
|
|
function M.write(fd, data)
|
|
local n = C.write(fd, data, #data)
|
|
if n < 0 then
|
|
return nil, ffi.string(C.strerror(C.__errno_location()[0])), M.errno()
|
|
end
|
|
return tonumber(n)
|
|
end
|
|
|
|
function M.close(fd)
|
|
return C.close(fd) == 0
|
|
end
|
|
|
|
function M.kill(pid, sig)
|
|
local rc = C.kill(pid, sig)
|
|
if rc == 0 then return true end
|
|
return false, ffi.string(C.strerror(C.__errno_location()[0]))
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- termios
|
|
-- Save current tty mode and switch to raw via cfmakeraw. Returns the saved
|
|
-- termios pointer (to be passed back to M.restore_termios) or (nil, err) if
|
|
-- fd isn't a tty (e.g. stdin redirected from a file in CI / scripted runs).
|
|
local TCSANOW = 0
|
|
|
|
function M.set_raw(fd)
|
|
local saved = ffi.new("struct termios")
|
|
if C.tcgetattr(fd, saved) < 0 then
|
|
return nil, M.strerror(M.errno())
|
|
end
|
|
local raw = ffi.new("struct termios")
|
|
ffi.copy(raw, saved, ffi.sizeof("struct termios"))
|
|
C.cfmakeraw(raw)
|
|
if C.tcsetattr(fd, TCSANOW, raw) < 0 then
|
|
return nil, M.strerror(M.errno())
|
|
end
|
|
return saved
|
|
end
|
|
|
|
function M.restore_termios(fd, saved)
|
|
return C.tcsetattr(fd, TCSANOW, saved) == 0
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- poll
|
|
M.POLLIN = 0x0001
|
|
M.EINTR = 4
|
|
|
|
-- Returns: rc (>= 0 fds ready, 0 timeout, -1 error)
|
|
function M.poll(fds_arr, nfds, timeout_ms)
|
|
return C.poll(fds_arr, nfds, timeout_ms or -1)
|
|
end
|
|
|
|
return M
|