fork-self + waitpid-nonblock + exit-immediate + sleep primitives across c-tier + python-tier

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.
This commit is contained in:
russell@unturf.com 2026-06-11 09:24:51 -04:00
parent e57c4948ab
commit 81ac49ece0
No known key found for this signature in database
2 changed files with 107 additions and 0 deletions

View file

@ -1375,6 +1375,72 @@ static Value bi_spawn_process_stdio(Value *a, int n, Env *e) {
return cons(pin, pout); 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) { static Value bi_flush_port(Value *a, int n, Env *e) {
(void)e; CHECK_ARITY("flush-port", 1); (void)e; CHECK_ARITY("flush-port", 1);
ULPort *p = AS_PORT(a[0]); ULPort *p = AS_PORT(a[0]);
@ -2703,6 +2769,10 @@ Env *make_global_env(void) {
DEF("tcp-send", bi_tcp_send); DEF("tcp-send", bi_tcp_send);
DEF("tcp-close", bi_tcp_close); DEF("tcp-close", bi_tcp_close);
DEF("spawn-process-stdio", bi_spawn_process_stdio); 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("flush-port", bi_flush_port);
DEF("write-binary-file", bi_write_binary_file); DEF("write-binary-file", bi_write_binary_file);
DEF("append-binary-file", bi_append_binary_file); DEF("append-binary-file", bi_append_binary_file);

View file

@ -2839,6 +2839,39 @@ def _spawn_process_stdio(path, args):
) )
return Pair(proc.stdin, proc.stdout) 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): def _flush_port(port):
"""Flush a write port. No-op if port has no flush method.""" """Flush a write port. No-op if port has no flush method."""
if hasattr(port, 'flush'): if hasattr(port, 'flush'):
@ -3826,6 +3859,10 @@ def make_global_env():
return out return out
d(S('spawn-process-stdio'), d(S('spawn-process-stdio'),
lambda a, _: _spawn_process_stdio(_str_val(a[0]), _spawn_args(a))) 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('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('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]))) d(S('append-binary-file'), lambda a, _: _append_binary_file(_str_val(a[0]), _str_val(a[1])))