From 89a6b3050120081e7f393e380e513f4f0b1f6d1c Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 18:52:45 +0000 Subject: [PATCH] erlang: code:load_binary/3 hot-reload BIF (+8 eval tests) --- lib/erlang/scoreboard.json | 6 ++-- lib/erlang/scoreboard.md | 4 +-- lib/erlang/tests/eval.sx | 32 +++++++++++++++++++ lib/erlang/transpile.sx | 64 ++++++++++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 ++- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index cd5a1e20..499a5c81 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 543, - "total": 543, + "total_pass": 551, + "total": 551, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":346,"total":346,"status":"ok"}, + {"name":"eval","pass":354,"total":354,"status":"ok"}, {"name":"runtime","pass":52,"total":52,"status":"ok"}, {"name":"ring","pass":4,"total":4,"status":"ok"}, {"name":"ping-pong","pass":4,"total":4,"status":"ok"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index dea0473e..ffac16ee 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 543 / 543 tests passing** +**Total: 551 / 551 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 346 | 346 | +| ✅ | eval | 354 | 354 | | ✅ | runtime | 52 | 52 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index a3056000..72aa4da8 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1125,6 +1125,38 @@ (er-eval-test "lists:duplicate val" (nm (ev "hd(lists:duplicate(3, marker))")) "marker") + +;; ── Phase 7: code:load_binary/3 ─────────────────────────────── +(er-modules-reset!) + +(er-eval-test "code:load_binary ok tag" + (nm (ev "element(1, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))")) + "module") +(er-eval-test "code:load_binary ok name" + (nm (ev "element(2, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))")) + "cl1") +(er-eval-test "code:load_binary then call" + (ev "cl1:foo()") 1) + +(er-eval-test "code:load_binary reload v2" + (ev "code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 99.\"), cl1:foo()") + 99) + +(er-eval-test "code:load_binary name mismatch tag" + (nm (ev "element(1, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))")) + "error") +(er-eval-test "code:load_binary name mismatch reason" + (nm (ev "element(2, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))")) + "module_name_mismatch") + +(er-eval-test "code:load_binary badfile on garbage" + (nm (ev "element(2, code:load_binary(cl3, \"x.erl\", \"this is not erlang\"))")) + "badfile") + +(er-eval-test "code:load_binary non-atom mod is badarg" + (nm (ev "element(2, code:load_binary(\"cl1\", \"x.erl\", \"-module(cl1). f() -> 0.\"))")) + "badarg") + (define er-eval-test-summary (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/lib/erlang/transpile.sx b/lib/erlang/transpile.sx index ac2bf562..aa75cc99 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -727,6 +727,7 @@ (= mod "io") (er-apply-io-bif name vs) (= mod "erlang") (er-apply-bif name vs) (= mod "ets") (er-apply-ets-bif name vs) + (= mod "code") (er-apply-code-bif name vs) :else (error (str "Erlang: undefined module '" mod "'"))))) @@ -1911,3 +1912,66 @@ (fn (_) (set! out (er-mk-cons v out))) (range 0 n)) out)))) + + +;; ── code module (Phase 7 hot-reload) ───────────────────────────── +(define er-source-walk-bytes! + (fn (n bytes-box) + (cond + (er-nil? n) true + (er-cons? n) + (let ((h (get n :head))) + (cond + (= (type-of h) "number") + (do (append! (nth bytes-box 0) h) + (er-source-walk-bytes! (get n :tail) bytes-box)) + :else (do (set-nth! bytes-box 0 nil) false))) + :else (do (set-nth! bytes-box 0 nil) false)))) + +(define er-source-to-string + (fn (v) + (cond + (= (type-of v) "string") v + (er-binary? v) (list->string (map integer->char (get v :bytes))) + (or (er-nil? v) (er-cons? v)) + (let ((box (list (list)))) + (er-source-walk-bytes! v box) + (cond + (= (nth box 0) nil) nil + :else (list->string (map integer->char (nth box 0))))) + :else nil))) + +(define er-bif-code-load-binary + (fn (vs) + (let ((mod-arg (nth vs 0)) (src-arg (nth vs 2))) + (cond + (not (er-atom? mod-arg)) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((src-str (er-source-to-string src-arg))) + (cond + (= src-str nil) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((result-box (list nil)) (failed-box (list false))) + (guard + (c (:else (set-nth! failed-box 0 true))) + (set-nth! result-box 0 (erlang-load-module src-str))) + (cond + (nth failed-box 0) + (er-mk-tuple + (list (er-mk-atom "error") (er-mk-atom "badfile"))) + (not (= (get (nth result-box 0) :name) (get mod-arg :name))) + (er-mk-tuple + (list (er-mk-atom "error") (er-mk-atom "module_name_mismatch"))) + :else + (er-mk-tuple (list (er-mk-atom "module") mod-arg)))))))))) + +(define er-apply-code-bif + (fn (name vs) + (cond + (and (= name "load_binary") (= (len vs) 3)) + (er-bif-code-load-binary vs) + :else (error + (str "Erlang: undefined function 'code:" name "/" (len vs) "'"))))) + diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 69c07a30..299b2bbf 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -102,7 +102,7 @@ Core mapping: Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules must be replaceable at runtime without bouncing the scheduler. Classic OTP behaviour: two versions per module ("current" and "old"), local calls stick to the version the process started with, cross-module (`M:F(...)`) calls always resolve to the current version, and `purge` kills any process still running old code. - [x] Module version slot: `er-modules` entry becomes `{:current MOD-ENV :old MOD-ENV-or-nil :version INT}`; bump version on each load — **13 new runtime tests** (543/543 total) -- [ ] `code:load_file/1` — re-parses module source, swaps `:current` → `:old`, installs new env as `:current`; returns `{module, Name}` or `{error, Reason}` +- [x] `code:load_binary/3` (the canonical reload BIF) — re-parses module source, swaps `:current` → `:old`, installs new env as `:current`; returns `{module, Name}` or `{error, Reason}` (badarg / badfile / module_name_mismatch). **+8 eval tests** (551/551 total). `code:load_file/1` is a thin filesystem wrapper around this and lands once `file:read_file/1` is in (Phase 8). - [ ] `code:purge/1` — kills any process whose `:module-version` slot points at `:old`; clears `:old` slot; returns `true` (some procs killed) or `false`. `code:soft_purge/1` returns `false` without killing if any process is still on old code - [ ] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection - [ ] Cross-module call `M:F(...)` dispatches to `:current`; local calls inside a module body keep using the env they closed over so a running process finishes its current function with the version it started with @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **2026-05-14 code:load_binary/3 green** — Canonical hot-reload entry point. Adds a `"code"` module branch to `er-apply-remote-bif`'s dispatch; new helpers `er-source-walk-bytes!` and `er-source-to-string` coerce any of {SX string, Erlang binary `<<...>>`, Erlang char-code cons list} to an SX source string before parsing. `er-bif-code-load-binary` is the BIF itself: validates `Mod` is an atom (`{error, badarg}` else), coerces source (`{error, badarg}` on unrecognised shape), wraps `erlang-load-module` in `guard` to convert parse failures into `{error, badfile}`, checks the parsed `-module(Name).` matches the BIF's first arg (`{error, module_name_mismatch}` else), returns `{module, Mod}`. Reload reuses the Phase-7 slot logic from the previous iteration so calling `code:load_binary(m, _, v2_source)` after `code:load_binary(m, _, v1_source)` bumps the slot to version 2 with v1 sitting in `:old`. 8 new eval tests: ok-tag/ok-name on first load, immediate cross-module call hits new env, reload-and-call returns v2 result, name-mismatch errors with both tag and reason, garbage source yields badfile, non-atom Mod is badarg. Total **551/551** (+8 eval). `code:load_file/1` deferred until `file:read_file/1` lands in Phase 8 (it's just a wrapper that reads bytes from disk then calls `load_binary`). + - **2026-05-14 Phase 7 module-version slot landed** — `er-modules` entries are now `{:current MOD-ENV :old MOD-ENV-or-nil :version INT :tag "module"}` instead of bare mod-env dicts. New helpers in `runtime.sx`: `er-mk-module-slot`, `er-module-current-env`, `er-module-old-env`, `er-module-version`. `erlang-load-module` updated: first load creates a slot with `:version 1` and `:old nil`; subsequent loads of the same module name copy `:current` into `:old` and increment `:version` (bump-and-shift, single-old-version retention as per OTP semantics). `er-apply-user-module` now reads via `er-module-current-env` so cross-module calls always hit the latest version. 13 new runtime tests (mostly in `tests/runtime.sx`): slot constructor + accessors, registry-after-first-load (v1, old nil), registry-after-second-load (v2, old = previous current env identity, current = new env), v3 on triple-load, registry-reset clears. Total **543/543** (was 530/530). Note: sx-tree path-based MCP tools (`sx_replace_node`, `sx_read_subtree`) are broken in this worktree's `mcp_tree.exe` (every path returns/replaces form 0); edits applied via a Python script then `sx_validate`d. Pattern-based tools (`sx_find_all`, `sx_rename_symbol`) still work fine. - **2026-05-14 Phase 7 + Phase 8 scoped** — Plan extended with two new phases driven by fed-sx (see `plans/fed-sx-design.md` §17.5). Phase 7 brings hot code reload back in scope (was previously listed as out-of-scope): module versioning slot, `code:load_file/1`/`purge/1`/`soft_purge/1`/`which/1`/`is_loaded/1`, cross-module calls hitting current, local calls keeping start-time semantics until function returns. Phase 8 introduces a runtime-extensible **FFI BIF registry** that replaces today's hardcoded `er-apply-bif`/`er-apply-remote-bif` cond chains, plus a term-marshalling layer and concrete BIFs for `crypto:hash`, `cid:from_bytes`/`to_string`, `file:read_file`/`write_file`/`list_dir`/`delete`, `httpc:request`, `sqlite:open`/`exec`/`query`. Scope decisions header updated accordingly. Baseline 530/530 unchanged; no code touched this iteration.