Files
aish/ffi/libc.lua
T
marfrit 1f1065157e review BLOCKER: PTY input forwarding + raw mode toggle
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>
2026-05-10 20:00:53 +00:00

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