diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index d836a6c6..de791e67 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 571, - "total": 571, + "total_pass": 577, + "total": 577, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":374,"total":374,"status":"ok"}, + {"name":"eval","pass":380,"total":380,"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 43f8d776..d0b234f7 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 571 / 571 tests passing** +**Total: 577 / 577 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 374 | 374 | +| ✅ | eval | 380 | 380 | | ✅ | 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 59eb0ff2..40356cef 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1252,6 +1252,63 @@ (er-eval-test "code:is_loaded badarg" (nm (ev "try code:is_loaded(123) catch error:badarg -> ok end")) "ok") + +;; ── Phase 7: hot-reload call dispatch semantics ────────────────── +;; Cross-module M:F() calls always hit the CURRENT version; +;; local F() calls inside a module body resolve through the env +;; the function closed over (i.e. the version it was loaded with). + +(er-modules-reset!) + +;; M:F always hits current +(er-eval-test "cross-mod after reload v2" + (ev "code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 1.\"), + code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 2.\"), + hr1:f()") + 2) + +;; Local call inside reloaded module body resolves via fresh mod-env +;; (a() does a local b(); b() got upgraded too) +(er-eval-test "local call inside reloaded module body" + (ev "code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 1.\"), + code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 99.\"), + hr2:a()") + 99) + +;; Fun captured BEFORE reload, with local-call body, keeps v1 semantics +(er-eval-test "captured fun keeps closed-over env (local call)" + (ev "code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 1.\"), + Fn = hr3:get_fn(), + code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 99.\"), + Fn()") + 1) + +;; Fun captured BEFORE reload, with CROSS-mod body, sees v2's current +(er-eval-test "captured fun follows cross-mod to current" + (ev "code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 1.\"), + Fn = hr4:get_xref(), + code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 99.\"), + Fn()") + 99) + +;; Two captured funs from two different vintages +(er-eval-test "two funs from two vintages stay independent" + (ev "code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 10.\"), + F1 = hr5:gf(), + code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 20.\"), + F2 = hr5:gf(), + F1() + F2()") + 30) + +;; Version slot bumps correctly when a captured fun stays alive +(er-eval-test "version bumps despite captured funs" + (ev "code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 1.\"), + _Pinned = hr6:gf(), + code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 2.\"), + code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 3.\"), + hr6:v()") + 3) + (define er-eval-test-summary (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index e85febf1..7bc00270 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -105,7 +105,7 @@ Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules mu - [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). - [x] `code:purge/1` + `code:soft_purge/1` — purge clears `:old` slot and kills any process whose `:initial-fun` env identity matches the old env (returns `true` if there was old code, `false` if there wasn't). soft_purge: refuses (returns `false`, leaves `:old` intact) if any process is still pinned to the old env; otherwise clears and returns `true`. **+10 eval tests** (561/561 total). Caveat: a true "lingering on old code" test needs `spawn/3` (still stubbed) or `fun M:F/A` syntax (not parsed) — anonymous `fun () -> M:F() end` closures capture the caller's env, not the module's, and cross-module calls always resolve to `:current`. Current tests therefore exercise the return-value matrix but not the kill path. - [x] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection. **+10 eval tests** (571/571 total). Return-value contract: `which` → `loaded` / `non_existing` (since we have no filesystem path); `is_loaded` → `{file, loaded}` / `false`; `all_loaded` → list of `{Module, loaded}` tuples. Non-atom Mod raises `error:badarg`. -- [ ] 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 +- [x] 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 — **+6 eval tests** verifying the property end-to-end (577/577 total). No implementation change: `er-apply-user-module` already routes through `er-module-current-env`, and `er-mk-fun` captures its env by reference so closures created under v1 retain v1's `mod-env` even after the slot bumps to v2. - [ ] Tests: load v1 → spawn → load v2 → cross-module call hits v2 → local call inside v1 process keeps v1 semantics until function returns → purge kills v1 procs → soft_purge refuses while v1 procs alive ### Phase 8 — FFI BIF mechanism + standard libs @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **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). + - **2026-05-14 code introspection BIFs green** — `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` added to `er-apply-code-bif` dispatch with three small implementations in `transpile.sx`. `which` and `is_loaded` are dict-lookups on the module registry returning the loaded-marker (atom `loaded`) or the missing-marker (atom `non_existing` for which, atom `false` for is_loaded). Since we don't have a filesystem path representation, the standard `{file, Path}` shape for `is_loaded` becomes `{file, loaded}` — same tuple arity so destructuring code stays portable. `all_loaded` iterates `(keys (er-modules-get))` in reverse (so the result list preserves insertion order after the cons-prepend loop), wrapping each name in a `{Module, loaded}` tuple. **10 new eval tests**: non_existing for absent / loaded after load for which; missing / file-tag / loaded-value for is_loaded; empty / count-after-2-loads / first-entry-tag for all_loaded; badarg for both single-arg BIFs. Two of the all_loaded tests needed an explicit `(er-modules-reset!)` before the measurement because prior tests in the suite leave modules registered (the registry is process-global across the whole epoch session). Total **571/571** (+10 eval). - **2026-05-14 code:purge/1 + code:soft_purge/1 green** — Two new BIFs in `transpile.sx`: `er-bif-code-purge` and `er-bif-code-soft-purge`, both dispatched through the existing `er-apply-code-bif` cond chain. Shared helper `er-procs-on-env` walks `(er-sched-processes)` and collects pids whose `:initial-fun` is a fun whose `:env` is identical (dict-identity, not structural) to a given env, filtering out already-dead procs. `er-bif-code-purge` looks up the module slot, returns `false` if either the module isn't registered or `:old` is nil; otherwise calls `er-cascade-exit!` on every matching pid with reason `killed`, replaces the slot with a fresh `er-mk-module-slot` that has `:old nil` (current + version preserved), returns `true`. `er-bif-code-soft-purge` returns `true` (treating "no module" / "no old version" as already-purged), else checks for lingering procs and returns `false` (leaving the slot untouched) if any, else clears `:old` and returns `true`. Non-atom Mod raises `error:badarg` from both. **10 new eval tests**: unknown / no-old / after-reload / idempotent for purge; unknown / no-old / clean for soft_purge; badarg for both; one "purge after spawn" test verifying return value (does NOT exercise the kill path — see caveat in plan). Total **561/561** (+10 eval). Implementation cost: 1 dispatch entry, 3 small BIFs, no scheduler changes.