199dd87eaa
Phase 4 commit #1 per docs/PHASE4.md §12. Two file changes bundled because R-B1 (flock for race-free single-writer enforcement) cannot be deferred — adding it retroactively means reopening the memory handle. ffi/libc.lua extensions: - cdef flock(int fd, int op), open(...), lseek(int, long, int) - constants LOCK_EX=2, LOCK_NB=4, LOCK_UN=8 - M.flock(fd, op) wrapper returning (true) on success or (false, errmsg) — errmsg is the strerror text so callers can surface "Resource temporarily unavailable" cleanly to the user. history.lua additions (Phase 4 section appended at end): - M.open_memory(path) -> handle | nil, err Opens the file via libc.open(2) (need integer fd for flock — io.open's FILE* doesn't expose it), takes flock(LOCK_EX | LOCK_NB). Returns "memory.jsonl held by another aish process" on lock-held. Scans existing content for max id; caches as handle.next_id. Writes meta header on first creation (no id, ignored at load). - handle:add(kind, content, tags?, source?) -> id Assigns next id; appends one JSONL item with auto-timestamp. kind ∈ {fact, pref, context} enforced via assert. - handle:forget(target_id) Appends a tombstone {id, ts, kind:"forget", target}. - handle:close() Releases fd (flock auto-released on close). - M.load_memory(path) -> items_table Reads all lines, builds forget-target set from kind=="forget" entries, returns active items as an array sorted by ts desc. Items without id (meta header) silently dropped. Tombstones with non-matching targets are no-ops (N3 invariant). Round-trip test passes: - open empty file → next_id=1 - add 3 items → ids 1, 2, 3 - forget id 2 (appends tombstone) - reopen → next_id correctly advances past the tombstone (=5) - load_memory → 2 active items (id 1 + id 3); tombstone resolved - lock-held detection: second open while first held → fails with "memory.jsonl held by another aish process" message - close releases the lock; reopen after release succeeds Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
6.1 KiB
Lua
178 lines
6.1 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);
|
|
|
|
/* Phase 4: advisory file locking on memory.jsonl. Single-writer
|
|
enforcement via LOCK_EX | LOCK_NB — fail-fast if another aish
|
|
process holds the lock. */
|
|
int flock(int fd, int operation);
|
|
]]
|
|
|
|
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
|
|
|
|
-- ---------------------------------------------------------------- flock
|
|
-- Advisory file locking. Phase 4 uses LOCK_EX | LOCK_NB so a second
|
|
-- aish process opening the same memory.jsonl fails fast rather than
|
|
-- blocking. Lock is released on fd close or process exit.
|
|
M.LOCK_EX = 2
|
|
M.LOCK_NB = 4
|
|
M.LOCK_UN = 8
|
|
|
|
-- Returns: true on success; false, errmsg on failure (e.g. EWOULDBLOCK
|
|
-- when LOCK_NB is set and another holder exists).
|
|
function M.flock(fd, op)
|
|
if C.flock(fd, op) == 0 then return true end
|
|
return false, ffi.string(C.strerror(C.__errno_location()[0]))
|
|
end
|
|
|
|
return M
|