diff --git a/Makefile b/Makefile index 9f100f1..9ad485f 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/c/Makefile b/c/Makefile index 751dd40..fa21583 100644 --- a/c/Makefile +++ b/c/Makefile @@ -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) diff --git a/c/builtins.c b/c/builtins.c index 49f5872..8176c1c 100644 --- a/c/builtins.c +++ b/c/builtins.c @@ -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 */ diff --git a/c/main.c b/c/main.c index 45fab55..5c17000 100644 --- a/c/main.c +++ b/c/main.c @@ -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: ". + * 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(); diff --git a/tests/regression-named-let-leak.lsp b/tests/regression-named-let-leak.lsp new file mode 100644 index 0000000..98c064b --- /dev/null +++ b/tests/regression-named-let-leak.lsp @@ -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)) diff --git a/tests/regression-named-let-leak.sh b/tests/regression-named-let-leak.sh new file mode 100755 index 0000000..a5dd660 --- /dev/null +++ b/tests/regression-named-let-leak.sh @@ -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"