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:
parent
b3b08e5924
commit
88c4b05032
6 changed files with 176 additions and 5 deletions
83
tests/regression-named-let-leak.lsp
Normal file
83
tests/regression-named-let-leak.lsp
Normal 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))
|
||||
46
tests/regression-named-let-leak.sh
Executable file
46
tests/regression-named-let-leak.sh
Executable 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue