wat: internal-define scoping (R7RS letrec*) + c-wasm gc gap documented

WAT — leading (define ...) forms in a lambda body now bind LOCALLY
(letrec*-equivalent) instead of polluting the global env. Implementation:
apply for closures pre-processes the body in three passes:
  1. hoist_internal_defines walks leading defines, env_define each name
     to VOID in the new env, returns the extended env.
  2. strip_leading_defines returns the body with the defines removed.
  3. fill_internal_defines evaluates each define's value-expression in
     the new env (so mutual references work) and env_set the real value.

(define x 1)
(define (f) (define x 99) x)
(f)   ; → 99 (was 99, still 99)
x     ; → 1  (was 99 wrongly — fixed)

(define (h) (define helper (lambda (x) (* x 2))) (helper 5))
helper  ; → unbound (was a leaked global procedure — fixed)

C-WASM — added a thorough doc-block in lumbda_wasm_entry.c covering
the gc.c fallback malloc situation and three plausible real fixes
(Boehm-em build, custom mark-sweep over NaN-boxed heap, generational
reset). Repl tabbar already surfaces the pressure to the user.

Parity corpus locks the new scoping behavior:
  internal-define-local    — global x stays 1
  internal-define-returns  — f returns 99
  internal-define-mutual   — mutually-recursive internal defines

Tests: 20 unit, 8 integration, 11 functional, 249 parity all green.
This commit is contained in:
russell@unturf.com 2026-06-14 17:54:33 -04:00
parent 18cc44d2a2
commit ee5e997df7
No known key found for this signature in database
6 changed files with 121 additions and 3 deletions

View file

@ -2247,9 +2247,92 @@
(local.set $body (i32.load offset=8 (local.get $fn)))
(local.set $env (i32.load offset=12 (local.get $fn)))
(local.set $new_env (call $bind_params (local.get $params) (local.get $args) (local.get $env)))
;; R7RS internal definitions: leading (define ...) forms in a
;; lambda body bind locally (letrec*-style) instead of polluting
;; the global env. hoist_internal_defines returns the env extended
;; with placeholder VOID bindings and advances $body past them;
;; then we evaluate each define's value-expression in the new env
;; (so mutual references work) and env_set the real value.
(local.set $new_env (call $hoist_internal_defines (local.get $body) (local.get $new_env)))
(local.set $body (call $strip_leading_defines (local.get $body)))
(call $fill_internal_defines (i32.load offset=8 (local.get $fn)) (local.get $new_env))
(return_call $eval_begin (local.get $body) (local.get $new_env))))
(global.get $VOID))
;; Walk leading (define ...) forms in $body; for each, add a placeholder
;; VOID binding to $env. Returns the extended env. Body is unchanged
;; (we strip and fill separately to keep iteration simple).
(func $hoist_internal_defines (param $body i32) (param $env i32) (result i32)
(local $form i32)
(local $head i32)
(local $rest i32)
(local $name i32)
(block $done
(loop $l
(br_if $done (i32.eqz (call $is_pair (local.get $body))))
(local.set $form (call $car (local.get $body)))
(br_if $done (i32.eqz (call $is_pair (local.get $form))))
(br_if $done (i32.ne (call $car (local.get $form)) (global.get $sym_define)))
(local.set $rest (call $cdr (local.get $form)))
(local.set $head (call $car (local.get $rest)))
;; (define name expr) — head is a symbol
;; (define (f . args) body) — head is a pair, name = car
(if (call $is_pair (local.get $head))
(then (local.set $name (call $car (local.get $head))))
(else (local.set $name (local.get $head))))
(local.set $env (call $env_define (local.get $env) (local.get $name) (global.get $VOID)))
(local.set $body (call $cdr (local.get $body)))
(br $l)))
(local.get $env))
(func $strip_leading_defines (param $body i32) (result i32)
(local $form i32)
(block $done
(loop $l
(br_if $done (i32.eqz (call $is_pair (local.get $body))))
(local.set $form (call $car (local.get $body)))
(br_if $done (i32.eqz (call $is_pair (local.get $form))))
(br_if $done (i32.ne (call $car (local.get $form)) (global.get $sym_define)))
(local.set $body (call $cdr (local.get $body)))
(br $l)))
(local.get $body))
;; Second pass of letrec* expansion — evaluate each internal-define's
;; value-expression in the new env, env_set the real value. Operates
;; on the ORIGINAL body (with defines still in it) so we know what to
;; bind. Forms after the leading defines are skipped.
(func $fill_internal_defines (param $body i32) (param $env i32)
(local $form i32)
(local $rest i32)
(local $head i32)
(local $name i32)
(local $value_expr i32)
(local $params i32)
(local $closure_body i32)
(block $done
(loop $l
(br_if $done (i32.eqz (call $is_pair (local.get $body))))
(local.set $form (call $car (local.get $body)))
(br_if $done (i32.eqz (call $is_pair (local.get $form))))
(br_if $done (i32.ne (call $car (local.get $form)) (global.get $sym_define)))
(local.set $rest (call $cdr (local.get $form)))
(local.set $head (call $car (local.get $rest)))
(if (call $is_pair (local.get $head))
(then
;; (define (f . params) body...) → desugar to closure
(local.set $name (call $car (local.get $head)))
(local.set $params (call $cdr (local.get $head)))
(local.set $closure_body (call $cdr (local.get $rest)))
(drop (call $env_set (local.get $env) (local.get $name)
(call $make_closure (local.get $params) (local.get $closure_body) (local.get $env)))))
(else
(local.set $name (local.get $head))
(local.set $value_expr (call $car (call $cdr (local.get $rest))))
(drop (call $env_set (local.get $env) (local.get $name)
(call $eval (local.get $value_expr) (local.get $env))))))
(local.set $body (call $cdr (local.get $body)))
(br $l))))
;; Pairwise bind params (a list of symbols, or symbol for rest) to args.
(func $bind_params (param $params i32) (param $args i32) (param $env i32) (result i32)
(local $new i32)

View file

@ -1,12 +1,39 @@
/* lumbda_wasm_entry.c — Emscripten entry points for the C tier.
*
* MEMORY MODEL c-wasm tier
* ---------------------------
* The native C tier links libgc (Boehm conservative collector) and
* everything is GC-managed. The WASM build defines LUMBDA_NO_BOEHM (no
* libgc port wired up to Emscripten), so gc.c takes its plain-malloc
* fallback path. malloc returns memory; nothing ever returns it.
*
* For browser use this manifests as monotonic linear-memory growth in
* the worker. The REPL surfaces the pressure in its tabbar ("c 48M ↑")
* and the "reboot tier" button gives users a manual reclaim path
* terminate + respawn the worker, which destroys the heap entirely.
*
* Real fixes, in order of decreasing cost:
* 1. Build bdwgc (github.com/ivmai/bdwgc) with emcc and link the
* WASM build with USE_BOEHM_GC defined. Their build system has
* a single-threaded mode that would suit our worker context.
* 2. Write a small mark-sweep over the existing NaN-boxed heap.
* The asm WAT tier already does this with a Cheney copying GC
* (lumbda_gc, gc_collect in asm/lumbda.wat). Same algorithm
* ports here once we know each lumbda struct's pointer-field
* layout types.c is the source of truth there.
* 3. Generational reset: between top-level evals, snapshot global
* env + interns to s-expressions, tear down everything, replay.
* Crudest of the three.
*
* ENTRY POINTS
* ------------
* The JS loader provides Module.print / Module.printErr callbacks that
* Emscripten routes stdout/stderr through, so output capture happens on
* the JS side. We only expose:
*
* lumbda_wasm_init() set up symbols + env + stdlib
* lumbda_wasm_eval(src) eval src, last value printed if non-void
* lumbda_wasm_free_result(p) free a string returned to JS
* lumbda_wasm_init() - set up symbols + env + stdlib
* lumbda_wasm_eval(src) - eval src, last value printed if non-void
* lumbda_wasm_free_result(p) - free a string returned to JS
*
* The env is module-global so successive eval calls preserve defines.
*/

Binary file not shown.

View file

@ -50,6 +50,14 @@ export const CORPUS = [
{ tag: "gc-survives-eval", expected: "(1 4 9 16)",
src: "(let loop ((i 0)) (if (= i 200) 0 (begin (cons i i) (loop (+ i 1))))) (map (lambda (x) (* x x)) (list 1 2 3 4))" },
// ─── Internal-define scoping (R7RS letrec*) ──────────────────────
{ tag: "internal-define-local", expected: "1",
src: "(define x 1) (define (f) (define x 99) x) (f) x" },
{ tag: "internal-define-returns", expected: "99",
src: "(define (f) (define x 99) x) (f)" },
{ tag: "internal-define-mutual", expected: "#t",
src: "(define (g) (define (e? n) (if (= n 0) #t (o? (- n 1)))) (define (o? n) (if (= n 0) #f (e? (- n 1)))) (e? 6)) (g)" },
// ─── division semantics ──────────────────────────────────────────
{ tag: "quotient-pos", src: "(quotient 17 5)", expected: "3" },
{ tag: "quotient-neg", src: "(quotient -17 5)", expected: "-3" },

Binary file not shown.

Binary file not shown.