7d62eb5659
CONCERNs from the Phase 1 review pass:
ffi/curl.lua:
- SSE write_cb body is now pcall-wrapped. A Lua error in on_event (or
in the parse loop itself) is captured into cb_error and surfaced
after curl_easy_perform rather than propagating across the FFI
callback boundary (which LuaJIT documents as process-fatal). The
EOS flush path gets the same shield. Errors return
(nil, "callback: <msg>") from post_sse.
history.lua:
- sh_singlequote() escapes shell metacharacters; the mkdir -p and
ls -1 shell-outs no longer double-quote (where $(...) and $VAR
still expand) — single-quote with embedded-' escaping is the
safe form.
- M.load now returns (turns, meta) instead of (meta, turns). turns
is ALWAYS a table on success, never nil-when-no-header; failure
path is the unambiguous (nil, err). Callers can `if not turns
then` without the previous ambiguity. repl.lua :resume updated
to the new shape.
repl.lua :resume:
- Refuse to resume into a non-empty ctx — silent overwrite was the
Q15 default, but the review surfaced the no-undo / no-warning
failure mode. User must :reset (or :save then re-launch) to
express intent. The current session's on-disk log is unaffected
either way.
NITs:
- ffi/libc.lua READ_BUF: comment noting it's module-shared and
Phase 1 has no reentrant readers; revisit when that changes.
- PHASE1.md §7: \C-x\C-c reservation pinned to Phase 3 ("deferred
from Phase 1 — no consumer here") rather than the previous
dangling "(or here)".
Regression suite verifies:
- history.load new signature on success + failure paths
- shell-quoted history.dir with $ doesn't trip
- aish scripted run: ctx with 2 turns refuses :resume anchor with
a clear status; user must :reset first
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.4 KiB
Lua
158 lines
5.4 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.
|
|
-- Note: READ_BUF is module-shared. Phase 1 has no reentrant M.read callers
|
|
-- (no coroutines, no concurrent FFI callbacks performing reads); revisit if
|
|
-- that ever changes.
|
|
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
|