c: enable Boehm GC by default, GC_INIT, file ops, regression test

Three coupled changes that unblock the ecdsa quantum-circuit
simulator's run on the C tier from neoblanka.

1. c/Makefile autodetects libgc-dev — if /usr/include/gc.h is
   present, the build links Boehm and defines USE_BOEHM_GC. Without
   GC, ul_free is a no-op (lumbda.h:35) and every allocation leaks;
   small REPL snippets work but workloads with thousands of envs
   OOM the process. Override with USE_GC=0 to force the malloc-only
   path for diagnostics.

2. c/main.c calls GC_INIT before init_symbols, then GC_disable.
   GC_INIT registers the stack base for conservative scan — without
   it some Linux configs miss roots. GC_disable is a deliberate
   stopgap: lumbda Values are NaN-boxed pointers that conservative
   Boehm cannot recognize as pointers, so live targets get reclaimed
   (env binding symbol payloads, SymbolEntry strings) and lookups
   fail with "undefined: <sym>". Reproducing this without GC_disable
   on the GC build: any sim.lsp call chain triggers the corruption
   after ~100 named-let iterations. Until tracing is precise,
   growing the heap is safer than wrong results. Long-running
   workloads run under ulimit -v.

3. c/builtins.c gains rename-file and delete-file matching the
   Python tier (lumbda.py:3468). sim.lsp's write-portal! pattern
   (write to .tmp, rename) needs rename-file to land cross-tier
   identical results.

4. tests/regression-named-let-leak.lsp + .sh pin four shapes that
   blew up ecdsa: the c/TODO-named-let-bytecode.md repro, the F1
   shape from foxhop.net's lumbda-c-tier-leak-SP.md (12-line
   minimum), a 200-iter scaled variant, and a sim.lsp run-ops!
   mirror. Wired into root Makefile as regression-named-let-leak;
   added to test-all. Wrapper caps memory at 256 MB virt and 15s
   per tier so a leak regression fails the run instead of consuming
   host RAM.

Known limits:
- --fast JIT still has the named-let + inner user-fn call hang
  (separate TODO; tree-walker handles this fine).
- GC_disable means the heap grows; workloads must bound their work
  budget. ecdsa's sim runs comfortably in 5 MB.

Verified inside a 2G/2vCPU QEMU guest (foxhop.net ecdsa/vm-runner.sh):
- test-c (tree-walker) — 35/35 PASS
- bench-c (tree-walker) — score 18 matches Python tier byte-identical
- F1 probe (tree-walker) — all four steps PASS
This commit is contained in:
russell@unturf.com 2026-06-04 00:55:31 -04:00
parent b3b08e5924
commit 88c4b05032
No known key found for this signature in database
6 changed files with 176 additions and 5 deletions

View file

@ -105,6 +105,9 @@ portal-rng-cross-test: c-build asm-build
zoe-favorites-test: c-build
@bash tests/zoe-favorites-test.sh
regression-named-let-leak: c-build
@bash tests/regression-named-let-leak.sh
# Fetches Zoë's source live from wedgewack.org, verifies the edits in
# examples/ursa.lisp.txt reduce to the documented six annotations, runs
# the suite across tiers, and spot-checks her exact source on asm-full
@ -114,7 +117,7 @@ zoe-favorites-test: c-build
prove-ursa-runs: c-build
@bash tests/prove-ursa-runs.sh
test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test
test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test regression-named-let-leak
@echo "════════════════════════════════════════════════════"
@echo "All tests passed (Python + C + Assembly + functional + portal-rng-cross + zoe-favorites)"

View file

@ -5,10 +5,17 @@ CC = gcc
CFLAGS = -O2 -Wall -Wextra -Wno-unused-parameter -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE
LDFLAGS = -lm
# Optional: Boehm GC support
# Uncomment the next two lines if libgc-dev is installed
# CFLAGS += -DUSE_BOEHM_GC
# LDFLAGS += -lgc
# Boehm GC autodetect — enable when /usr/include/gc.h is present.
# Without GC, every allocation leaks (ul_free is a no-op in our header);
# small REPL snippets work but anything iterating past a few thousand
# allocations OOMs the process. The named-let + per-iteration user-fn
# call pattern in tests/regression/named-let-gc.lsp pins this down.
# Override with USE_GC=0 to force the malloc-only path for diagnostics.
USE_GC ?= $(shell test -f /usr/include/gc.h && echo 1 || echo 0)
ifeq ($(USE_GC),1)
CFLAGS += -DUSE_BOEHM_GC
LDFLAGS += -lgc
endif
SRCS = types.c reader.c printer.c eval.c builtins.c vm.c jit.c portal.c
OBJS = $(SRCS:.c=.o)

View file

@ -1310,6 +1310,22 @@ static Value bi_current_directory(Value *a, int n, Env *e) {
if (!getcwd(buf, sizeof(buf))) return make_string_from_cstr(".");
return make_string_from_cstr(buf);
}
static Value bi_rename_file(Value *a, int n, Env *e) {
(void)e; CHECK_ARITY("rename-file", 2);
check_string(a[0]); check_string(a[1]);
if (rename(AS_STRING(a[0])->data, AS_STRING(a[1])->data) != 0) {
lisp_error("rename-file: %s", strerror(errno));
}
return VAL_VOID;
}
static Value bi_delete_file(Value *a, int n, Env *e) {
(void)e; CHECK_ARITY("delete-file", 1);
check_string(a[0]);
if (unlink(AS_STRING(a[0])->data) != 0) {
lisp_error("delete-file: %s", strerror(errno));
}
return VAL_VOID;
}
/* ═══════════════════════════════════════════════════════════════════════════
* System
@ -1908,6 +1924,8 @@ Env *make_global_env(void) {
/* File system */
DEF("file-exists?", bi_file_exists);
DEF("rename-file", bi_rename_file);
DEF("delete-file", bi_delete_file);
DEF("current-directory", bi_current_directory);
/* System */

View file

@ -119,6 +119,20 @@ static void repl(Env *env) {
* */
int main(int argc, char **argv) {
#ifdef USE_BOEHM_GC
/* GC_INIT registers stack base for conservative scan. Without it,
* roots can be missed on some Linux configs.
*
* GC_disable is a deliberate stopgap: lumbda Values are NaN-boxed
* pointers that conservative Boehm cannot recognize as pointers,
* so live targets get reclaimed (env binding symbol payloads,
* SymbolEntry strings) and lookups fail with "undefined: <sym>".
* Until tracing is precise, growing the heap is safer than wrong
* results. Long-running workloads should run under ulimit -v.
*/
GC_INIT();
GC_disable();
#endif
init_symbols();
Env *g = make_global_env();

View file

@ -0,0 +1,83 @@
;;; regression-named-let-leak.lsp
;;;
;;; Pins two leak shapes that crashed neoblanka on 2026-06-03 when
;;; ecdsa/lumbda/main.lsp ran through ~/git/lumbda/c/lumbda --fast.
;;;
;;; Both shapes share the same root cause: lumbda C tier shipped
;;; without Boehm GC linked, so ul_free was a no-op and every env,
;;; vstack data buffer, and closure leaked. The named-let + inner
;;; user-fn call pattern allocates fast enough to hit kernel oom-killer
;;; in seconds.
;;;
;;; Wrapper at tests/regression-named-let-leak.sh runs this under
;;; ulimit -v + timeout so a memory regression fails the run instead
;;; of consuming all RAM.
(define *pass* 0)
(define *fail* 0)
(define (assert-equal name got expected)
(if (equal? got expected)
(begin (set! *pass* (+ *pass* 1))
(display "PASS: ") (display name) (newline))
(begin (set! *fail* (+ *fail* 1))
(display "FAIL: ") (display name)
(display " got=") (write got)
(display " expected=") (write expected) (newline))))
;;; ── Shape 1 — c/TODO-named-let-bytecode.md reproducer ─────────
(define (maybe x) (if (> x 3) #f (+ x 1)))
(define (g start)
(let loop ((t start))
(let ((next (maybe t)))
(if next (loop next) t))))
(assert-equal "named-let + inner let + if-tail-call" (g 0) 4)
;;; ── Shape 2 — F1 from ecdsa/docs/lumbda-c-tier-leak-SP.md ────
(define (always-true) #t)
(define (walk1 ops)
(let loop ((rest ops))
(if (null? rest)
#t
(if (always-true)
(loop (cdr rest))
#f))))
(assert-equal "named-let + inner user-fn call per iter (3-list)"
(walk1 (list 'a 'b 'c)) #t)
;;; ── Shape 3 — same shape, larger N, hits leak harder if present ──
(define (count-down n)
(let loop ((i n) (sum 0))
(if (= i 0)
sum
(if (always-true)
(loop (- i 1) (+ sum 1))
sum))))
(assert-equal "named-let + inner user-fn call, 200-iter" (count-down 200) 200)
;;; ── Shape 4 — emulates ecdsa run-ops! shape on a small list ──
(define state-vec (vector 0 0 0 0 0 0 'running))
(define (st-status v) (vector-ref v 6))
(define (st-ok? v) (eq? (st-status v) 'running))
(define (exec-noop v op) v)
(define (run-ops! v ops)
(let loop ((rest ops))
(cond
((null? rest) #t)
((not (st-ok? v)) #f)
(else
(exec-noop v (car rest))
(loop (cdr rest))))))
(assert-equal "named-let + cond + inner-fn calls (run-ops! shape)"
(run-ops! state-vec (list 'x 'cx 'ccx 'x 'cx)) #t)
;;; ── Summary ───────────────────────────────────────────────────
(newline)
(display "regression-named-let-leak: pass=") (display *pass*)
(display " fail=") (display *fail*) (newline)
(if (> *fail* 0) (exit 1) (exit 0))

View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# regression-named-let-leak.sh
#
# Wraps tests/regression-named-let-leak.lsp under a memory cap so
# a regression fails the run within seconds instead of consuming
# host RAM. Runs the .lsp on every available tier (Python, C, C
# --fast); skips a tier silently if its binary is missing.
#
# Memory cap: 256 MB virt per process. A clean run completes in
# ~30 MB; the historical leak ran past 1.8 GB before being killed,
# so 256 MB is a tight ceiling that catches regression fast.
#
# Wall-clock cap: 15s per tier.
set -e
LSP="tests/regression-named-let-leak.lsp"
[ -f "$LSP" ] || { echo "missing $LSP — run from lumbda repo root"; exit 2; }
MEMCAP_KB=262144 # 256 MB virtual memory ceiling
TIMEOUT_S=15
run_tier() {
local label="$1" cmd="$2"
if [ -z "$cmd" ] || ! command -v $(echo "$cmd" | awk '{print $1}') >/dev/null 2>&1; then
printf "[skip] %s — binary missing\n" "$label"
return 0
fi
printf "── %s ──\n" "$label"
if ( ulimit -v $MEMCAP_KB; timeout ${TIMEOUT_S}s $cmd "$LSP" ); then
printf "[pass] %s\n\n" "$label"
else
rc=$?
printf "[fail] %s exited rc=%d (oom-killer or timeout = leak)\n\n" "$label" "$rc"
return $rc
fi
}
cd "$(dirname "$0")/.."
run_tier "Python tier (--fast)" "python3 lumbda.py --fast"
run_tier "C tier (tree-walker)" "./c/lumbda"
run_tier "C tier (--fast JIT)" "./c/lumbda --fast"
echo "════════════════════════════════════════"
echo "All tiers passed regression-named-let-leak"