From 498d2533d8efcea66fa6f1ea12146fb6bb26ff18 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 19:34:30 +0000 Subject: [PATCH] erlang: Phase 8 BIF registry foundation (+18 runtime tests, 600/600) --- lib/erlang/runtime.sx | 33 ++++++++++++++++++++++++++ lib/erlang/scoreboard.json | 6 ++--- lib/erlang/scoreboard.md | 4 ++-- lib/erlang/tests/runtime.sx | 47 +++++++++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 +++- 5 files changed, 88 insertions(+), 6 deletions(-) diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index b6238869..e2829c8f 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -861,6 +861,39 @@ (define er-module-old-env (fn (slot) (get slot :old))) (define er-module-version (fn (slot) (get slot :version))) +;; ── FFI BIF registry (Phase 8) ─────────────────────────────────── +;; Global dict from "Module/Name/Arity" key to {:module :name :arity :fn :pure?}. +;; Replaces the giant cond chain in transpile.sx#er-apply-remote-bif over time — +;; Phase 8 BIFs (crypto / cid / file / httpc / sqlite) all register here. +(define er-bif-registry (list {})) +(define er-bif-registry-get (fn () (nth er-bif-registry 0))) +(define er-bif-registry-reset! (fn () (set-nth! er-bif-registry 0 {}))) + +(define er-bif-key + (fn (module name arity) + (str module "/" name "/" arity))) + +(define er-register-bif! + (fn (module name arity sx-fn) + (dict-set! (er-bif-registry-get) (er-bif-key module name arity) + {:module module :name name :arity arity :fn sx-fn :pure? false}) + (er-mk-atom "ok"))) + +(define er-register-pure-bif! + (fn (module name arity sx-fn) + (dict-set! (er-bif-registry-get) (er-bif-key module name arity) + {:module module :name name :arity arity :fn sx-fn :pure? true}) + (er-mk-atom "ok"))) + +(define er-lookup-bif + (fn (module name arity) + (let ((reg (er-bif-registry-get)) (k (er-bif-key module name arity))) + (if (dict-has? reg k) (get reg k) nil)))) + +(define er-list-bifs + (fn () (keys (er-bif-registry-get)))) + + ;; Load an Erlang module declaration. Source must start with ;; `-module(Name).` and contain function definitions. Functions diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index fb812d73..1462fc58 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,12 +1,12 @@ { "language": "erlang", - "total_pass": 582, - "total": 582, + "total_pass": 600, + "total": 600, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, {"name":"eval","pass":385,"total":385,"status":"ok"}, - {"name":"runtime","pass":52,"total":52,"status":"ok"}, + {"name":"runtime","pass":70,"total":70,"status":"ok"}, {"name":"ring","pass":4,"total":4,"status":"ok"}, {"name":"ping-pong","pass":4,"total":4,"status":"ok"}, {"name":"bank","pass":8,"total":8,"status":"ok"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 844d86b6..185cd415 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,13 +1,13 @@ # Erlang-on-SX Scoreboard -**Total: 582 / 582 tests passing** +**Total: 600 / 600 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | | ✅ | eval | 385 | 385 | -| ✅ | runtime | 52 | 52 | +| ✅ | runtime | 70 | 70 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | | ✅ | bank | 8 | 8 | diff --git a/lib/erlang/tests/runtime.sx b/lib/erlang/tests/runtime.sx index 6e1fbb5c..e9c18cff 100644 --- a/lib/erlang/tests/runtime.sx +++ b/lib/erlang/tests/runtime.sx @@ -165,6 +165,53 @@ (er-rt-test "registry-reset clears" (dict-has? (er-modules-get) "hr1") false) + + +;; ── Phase 8: FFI BIF registry ────────────────────────────────── +(er-bif-registry-reset!) + +(er-rt-test "empty registry" (len (er-list-bifs)) 0) +(er-rt-test "lookup miss" (er-lookup-bif "crypto" "hash" 2) nil) + +(er-register-bif! "fake" "echo" 1 (fn (vs) (nth vs 0))) +(er-rt-test "register grows registry" (len (er-list-bifs)) 1) + +(define er-rt-bif-hit (er-lookup-bif "fake" "echo" 1)) +(er-rt-test "lookup hit module" (get er-rt-bif-hit :module) "fake") +(er-rt-test "lookup hit name" (get er-rt-bif-hit :name) "echo") +(er-rt-test "lookup hit arity" (get er-rt-bif-hit :arity) 1) +(er-rt-test "lookup hit pure?" (get er-rt-bif-hit :pure?) false) + +(er-rt-test "fn invocable" ((get er-rt-bif-hit :fn) (list 42)) 42) + +;; Re-register replaces (same key) +(er-register-bif! "fake" "echo" 1 (fn (vs) "replaced")) +(er-rt-test "re-register same key, count unchanged" (len (er-list-bifs)) 1) +(er-rt-test "re-register replaces fn" + ((get (er-lookup-bif "fake" "echo" 1) :fn) (list 99)) "replaced") + +;; Pure variant +(er-register-pure-bif! "fake" "pure" 2 (fn (vs) (+ (nth vs 0) (nth vs 1)))) +(er-rt-test "pure registered separately, count 2" (len (er-list-bifs)) 2) +(er-rt-test "pure flag true" + (get (er-lookup-bif "fake" "pure" 2) :pure?) true) +(er-rt-test "pure fn invocable" + ((get (er-lookup-bif "fake" "pure" 2) :fn) (list 7 8)) 15) + +;; Arity disambiguation: same module+name, different arity = distinct entries +(er-register-bif! "fake" "echo" 2 (fn (vs) (list (nth vs 0) (nth vs 1)))) +(er-rt-test "arity disambiguation count" (len (er-list-bifs)) 3) +(er-rt-test "arity-1 lookup still works" + ((get (er-lookup-bif "fake" "echo" 1) :fn) (list 11)) "replaced") +(er-rt-test "arity-2 lookup independent" + (len ((get (er-lookup-bif "fake" "echo" 2) :fn) (list 1 2))) 2) + +;; Reset clears the registry +(er-bif-registry-reset!) +(er-rt-test "reset clears" (len (er-list-bifs)) 0) +(er-rt-test "reset lookup nil" (er-lookup-bif "fake" "echo" 1) nil) + + (define er-rt-test-summary (str "runtime " er-rt-test-pass "/" er-rt-test-count)) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index e11597e4..41dcc62a 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -112,7 +112,7 @@ Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules mu Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in `transpile.sx`) with a runtime-extensible **BIF registry**. Each registry entry is `{:module :name :arity :fn :pure?}`. Standard libs are then registered at boot, and fed-sx can register new BIFs from `.sx` files. Includes the marshalling layer (Erlang term ↔ SX value) so wrappers stay one-liners. -- [ ] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-lookup-bif`/`er-list-bifs` helpers +- [x] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-register-pure-bif!`/`er-lookup-bif`/`er-list-bifs`/`er-bif-registry-reset!` helpers — **+18 runtime tests** (600/600 total). Entries are `{:module :name :arity :fn :pure?}`. Arity is part of the key so `m:f/1` and `m:f/2` are independent. Re-registering the same key replaces the previous entry; reset clears. - [ ] Migrate existing local + remote BIFs (length/hd/tl/lists:*/io:format/ets:*/etc.) onto the registry; delete the giant `cond` dispatch in `er-apply-bif`/`er-apply-remote-bif`. Conformance must stay 530/530 after migration - [ ] Term-marshalling helpers: `er-of-sx` and `er-to-sx` (atom ↔ symbol, tuple ↔ list-of-elements, Erlang list ↔ SX list, binary ↔ string, dict ↔ map). Round-trip tests - [ ] `crypto:hash/2` — `sha256`, `sha512`, `blake3`; takes a binary, returns a binary. Uses the SX-host hash primitive @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **2026-05-14 Phase 8 BIF registry foundation** — `lib/erlang/runtime.sx` gains `er-bif-registry` (a `(list {})` mutable cell, same shape as `er-modules`) and five helpers: `er-bif-registry-get`/`er-bif-registry-reset!` (access + reset), `er-bif-key` (format `"Module/Name/Arity"`), `er-register-bif!` and `er-register-pure-bif!` (both upsert; differ only in the `:pure?` flag — pure ones are safe to inline, side-effecting ones go through normal IO), `er-lookup-bif` (returns the entry dict or nil), `er-list-bifs` (registered keys). Entries are `{:module :name :arity :fn :pure?}`. Lookup miss → nil; arity is part of the key so `m:f/1` and `m:f/2` are distinct; re-registering the same key replaces in-place (count stays the same); reset clears. Registry sits alongside `er-modules` in runtime.sx so any other piece of the system can register BIFs without touching the dispatcher — the migration onto this registry (the next checkbox) will rip out the giant cond chains in `er-apply-bif`/`er-apply-remote-bif`. 18 new runtime tests in `tests/runtime.sx`: empty-state, lookup-miss, register-grows-count, lookup-hit-fields (module/name/arity/pure?), fn-invocable, re-register-replaces, pure-flag-true, arity-disambiguation (3 entries for `fake:echo/1`, `fake:echo/2`, `fake:pure/2`), reset-clears, reset-lookup-nil. Total **600/600** (+18 runtime). + - **2026-05-14 Phase 7 capstone green — full hot-reload ladder works end-to-end** — Wires everything from the previous five iterations into one test program: load cap v1 with `start/0` (spawn-from-inside-module) + `loop/0` + `tag/0` → spawn Pid1 (running v1) → load cap v2 → assert `cap:tag()` returns v2 (cross-module dispatch hits `:current`) → spawn Pid2 (running v2) → `code:soft_purge(cap)` returns `false` (refuses while Pid1 is alive on v1's env) → `code:purge(cap)` returns `true` (kills Pid1, clears `:old`) → `code:soft_purge(cap)` returns `true` (clean — no `:old` left). To make this work, `er-procs-on-env` was extended with a new helper `er-env-derived-from?`: a process counts as "running on" mod-env if its `:initial-fun`'s `:env` IS mod-env directly OR contains at least one binding whose value is a fun closed over mod-env. Reason: `er-apply-fun-clauses` always `er-env-copy`s the closure-env before binding params, so a fun created inside a module body has a `:env` that's a *copy* of mod-env, not mod-env itself — the copy still contains the module's other functions as values, each pointing back to the canonical mod-env. The whole ladder runs as a single `erlang-eval-ast` invocation because each call to `ev` resets the scheduler via `er-sched-init!`, wiping any cross-call Pids. 5 capstone tests: v1 tag, v2 tag (cross-mod after reload), soft_purge-refuses, hard purge, soft_purge-clean-after-hard. Total **582/582** (+5 eval). Phase 7 fully ticked. - **2026-05-14 hot-reload call-dispatch semantics verified** — Tests-only iteration: no implementation change, just six new eval tests that nail down the Erlang semantics already implicit in the current code. (1) `M:F()` after reload returns v2's value (cross-module call hits `:current`). (2) Inside a freshly-loaded body, a bare local call resolves through the new mod-env so a chain `a() -> b()` reflects v2's `b/0`. (3) Calling a fun captured BEFORE reload, whose body uses a local call, returns the v1 value (closure pinned to old mod-env via `er-mk-fun`'s `:env` reference). (4) Calling a fun captured BEFORE reload, whose body uses a cross-module call `M:b()`, returns v2's value (cross-module always wins over closed-over env). (5) Two captured funs from two distinct vintages stay independent — F1() + F2() = 10 + 20 = 30. (6) The slot version counter still bumps even while old captured funs are alive, demonstrating the closure-pinning doesn't block reloads. The "running process finishes its current function with the version it started with" property falls out of fun-as-closure semantics for free — there's no special bookkeeping. Total **577/577** (+6 eval).