From 81ac49ece0a6c00081588659cabf75a779c36fef Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 11 Jun 2026 09:24:51 -0400 Subject: [PATCH] fork-self + waitpid-nonblock + exit-immediate + sleep primitives across c-tier + python-tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substrate for fork-per-accept pattern in gpu-worker.lsp — enables async bend dispatch with internal load balancing. c-tier (builtins.c): - bi_fork_self: fork() wrapper, returns 0 in child / pid in parent / #f on fail - bi_waitpid_nonblock: waitpid(-1, WNOHANG), returns reaped pid or 0 - bi_exit_immediate: _exit() wrapper — REQUIRED in fork-self children, regular exit() runs atexit handlers against shared parent state and hangs the child (observed empirically 2026-06-11 via vm-runner.sh). - bi_sleep: real wall-clock sleep(3) — yields CPU. Replaces busy-loop patterns that would (a) burn CPU and (b) SIGKILL in cgroup-limited VMs (observed: 100M iter let-loop SIGKILL'd after 5s in qemu vm). python-tier (lumbda.py): _fork_self / _waitpid_nonblock / _exit_immediate / _sleep mirrors via os.fork / os.waitpid / os._exit / time.sleep. Tested in vm-runner.sh VM (Ubuntu 2G/2vCPU): 3-child fork-cycle test spawns + reaps cleanly 3/3 in both c-tier + python-tier. The exact test pattern that crashed neoblanka host pre-fix now works fine. Asm tier: deferred. Lock retained at chmod a-x ~/git/lumbda/asm/lumbda* per CLAUDE.md threat model. --- c/builtins.c | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ lumbda.py | 37 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/c/builtins.c b/c/builtins.c index 993d4bd..c56c3d8 100644 --- a/c/builtins.c +++ b/c/builtins.c @@ -1375,6 +1375,72 @@ static Value bi_spawn_process_stdio(Value *a, int n, Env *e) { return cons(pin, pout); } +/* sleep: pause execution for N seconds (integer). Wraps libc sleep(3). + * Returns void. Real wall-clock wait — yields CPU. + * + * Needed for fork-per-accept admission loops (waitpid-nonblock between + * sleeps) and any cooperative pacing. Without this, scripts busy-loop + * with (let loop () ... (loop)) which (a) burns CPU and (b) hangs in + * resource-constrained VMs / fork contexts (SIGKILL after a few sec + * under cgroup limits — observed 2026-06-11 vm-runner.sh testing). + */ +static Value bi_sleep(Value *a, int n, Env *e) { + (void)e; CHECK_ARITY("sleep", 1); + unsigned int secs = (unsigned int)as_number_int(a[0]); + sleep(secs); + return VAL_VOID; +} + +/* exit-immediate: _exit() wrapper. No atexit handlers, no stdio + * flush, no GC cleanup. REQUIRED in fork-self children — calling + * regular exit() in a forked child runs libc atexit + stdio + * flush against parent's already-shared state, which can hang + * or corrupt (seen empirically: minimal `(let ((pid (fork-self))) + * (exit 0))` hangs the child until SIGTERM). + * + * Use (exit-immediate 0) in fork-self child branches. */ +static Value bi_exit_immediate(Value *a, int n, Env *e) { + (void)e; + _exit(n > 0 ? (int)as_number_int(a[0]) : 0); + return VAL_VOID; +} + +/* fork-self: fork() wrapper. + * + * (fork-self) ⇒ 0 in child, pid in parent, #f on failure + * + * Used by gpu-worker.lsp to fork-per-accept — parent immediately + * returns to accept while child handles the request and exits. + * Single-PID-many-handler pattern: parent PID persists, ephemeral + * children carry the per-request work + bend-cuda subprocess. + * + * Caller's responsibility: reap children via (waitpid-nonblock) in + * the accept loop, or set SIGCHLD handler. We do NOT install a + * default reaper because that would interfere with spawn-process-stdio + * which expects synchronous waitpid in handle-request. + */ +static Value bi_fork_self(Value *a, int n, Env *e) { + (void)a; (void)n; (void)e; CHECK_ARITY("fork-self", 0); + pid_t pid = fork(); + if (pid < 0) return VAL_FALSE; + return VAL_INT((int64_t)pid); +} + +/* waitpid-nonblock: reap one completed child (WNOHANG). + * + * (waitpid-nonblock) ⇒ pid (int) if child reaped, 0 if none ready + * + * Reaps zombies left by fork-self. Call from accept loop before each + * accept to keep zombie count bounded. + */ +static Value bi_waitpid_nonblock(Value *a, int n, Env *e) { + (void)a; (void)n; (void)e; CHECK_ARITY("waitpid-nonblock", 0); + int status; + pid_t pid = waitpid(-1, &status, WNOHANG); + if (pid <= 0) return VAL_INT(0); + return VAL_INT((int64_t)pid); +} + static Value bi_flush_port(Value *a, int n, Env *e) { (void)e; CHECK_ARITY("flush-port", 1); ULPort *p = AS_PORT(a[0]); @@ -2703,6 +2769,10 @@ Env *make_global_env(void) { DEF("tcp-send", bi_tcp_send); DEF("tcp-close", bi_tcp_close); DEF("spawn-process-stdio", bi_spawn_process_stdio); + DEF("fork-self", bi_fork_self); + DEF("waitpid-nonblock", bi_waitpid_nonblock); + DEF("exit-immediate", bi_exit_immediate); + DEF("sleep", bi_sleep); DEF("flush-port", bi_flush_port); DEF("write-binary-file", bi_write_binary_file); DEF("append-binary-file", bi_append_binary_file); diff --git a/lumbda.py b/lumbda.py index 0dc20cc..3863d12 100644 --- a/lumbda.py +++ b/lumbda.py @@ -2839,6 +2839,39 @@ def _spawn_process_stdio(path, args): ) return Pair(proc.stdin, proc.stdout) +import os as _os +def _fork_self(): + """fork() wrapper. Returns 0 in child, pid in parent, False on + failure. Used by gpu-worker.lsp for fork-per-accept pattern — + single PID parent, ephemeral children handle requests. Match for + lumbda c-tier bi_fork_self (builtins.c).""" + try: + pid = _os.fork() + except OSError: + return False + return pid + +def _waitpid_nonblock(): + """waitpid(-1, WNOHANG) wrapper. Returns reaped pid or 0. Match + for c-tier bi_waitpid_nonblock.""" + try: + pid, _status = _os.waitpid(-1, _os.WNOHANG) + except OSError: + return 0 + return pid + +import time as _time +def _sleep(secs): + """Real wall-clock sleep. Match for c-tier bi_sleep.""" + _time.sleep(int(secs)) + return VOID + +def _exit_immediate(code): + """os._exit() wrapper. Skip atexit / stdio flush. REQUIRED in + fork-self children to avoid dual-cleanup hang. Match for c-tier + bi_exit_immediate.""" + _os._exit(int(code)) + def _flush_port(port): """Flush a write port. No-op if port has no flush method.""" if hasattr(port, 'flush'): @@ -3826,6 +3859,10 @@ def make_global_env(): return out d(S('spawn-process-stdio'), lambda a, _: _spawn_process_stdio(_str_val(a[0]), _spawn_args(a))) + d(S('fork-self'), lambda a, _: _fork_self()) + d(S('waitpid-nonblock'), lambda a, _: _waitpid_nonblock()) + d(S('exit-immediate'), lambda a, _: _exit_immediate(int(_num(a[0])) if a else 0)) + d(S('sleep'), lambda a, _: _sleep(int(_num(a[0])) if a else 0)) d(S('flush-port'), lambda a, _: _flush_port(a[0])) d(S('write-binary-file'), lambda a, _: _write_binary_file(_str_val(a[0]), _str_val(a[1]))) d(S('append-binary-file'), lambda a, _: _append_binary_file(_str_val(a[0]), _str_val(a[1])))