From cd45ebcc7a5cc1218d0b0be1aa2769a4469cc6eb Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 19:02:24 +0000 Subject: [PATCH] erlang: code:purge/1 + code:soft_purge/1 (+10 eval tests) --- lib/erlang/scoreboard.json | 6 ++-- lib/erlang/scoreboard.md | 4 +-- lib/erlang/tests/eval.sx | 52 ++++++++++++++++++++++++++++ lib/erlang/transpile.sx | 69 ++++++++++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 ++- 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 499a5c81..9ae4f811 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 551, - "total": 551, + "total_pass": 561, + "total": 561, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":354,"total":354,"status":"ok"}, + {"name":"eval","pass":364,"total":364,"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 ffac16ee..16b0c7d1 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 551 / 551 tests passing** +**Total: 561 / 561 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 354 | 354 | +| ✅ | eval | 364 | 364 | | ✅ | 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 72aa4da8..18d06fe3 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1157,6 +1157,58 @@ (nm (ev "element(2, code:load_binary(\"cl1\", \"x.erl\", \"-module(cl1). f() -> 0.\"))")) "badarg") + +;; ── Phase 7: code:purge/1 + code:soft_purge/1 ─────────────────── +(er-modules-reset!) + +;; purge unknown module → false +(er-eval-test "code:purge unknown" + (nm (ev "code:purge(nope)")) "false") + +;; load, then purge without old version → false (nothing to purge) +(er-eval-test "code:purge no old" + (nm (ev "code:load_binary(pg1, \"pg1\", \"-module(pg1). v() -> 1.\"), code:purge(pg1)")) + "false") + +;; load v1, load v2 (creates :old), purge with no live procs → true +(er-eval-test "code:purge after reload" + (nm (ev "code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 1.\"), code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 2.\"), code:purge(pg2)")) + "true") + +;; idempotent: purging again returns false (already purged) +(er-eval-test "code:purge twice" + (nm (ev "code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 1.\"), code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 2.\"), code:purge(pg3), code:purge(pg3)")) + "false") + +;; purge returns true whenever an :old slot exists, regardless of process tracking +;; (proper "kill lingering" semantics requires spawn/3 which is still stubbed) +(er-eval-test "code:purge with old slot present" + (nm (ev "code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> ok end.\"), + Pid = spawn(fun () -> pg4:loop() end), + code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> done end.\"), + code:purge(pg4)")) + "true") + +;; soft_purge unknown → true (nothing to purge) +(er-eval-test "code:soft_purge unknown" + (nm (ev "code:soft_purge(nope)")) "true") + +;; soft_purge with no old version → true +(er-eval-test "code:soft_purge no old" + (nm (ev "code:load_binary(sp1, \"sp1\", \"-module(sp1). v() -> 1.\"), code:soft_purge(sp1)")) + "true") + +;; soft_purge with old + no lingering procs → true (clears :old) +(er-eval-test "code:soft_purge clean" + (nm (ev "code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 1.\"), code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 2.\"), code:soft_purge(sp2)")) + "true") + +;; non-atom Mod is badarg (raise) +(er-eval-test "code:purge badarg" + (nm (ev "try code:purge(\"str\") catch error:badarg -> ok end")) "ok") +(er-eval-test "code:soft_purge badarg" + (nm (ev "try code:soft_purge(123) catch error:badarg -> ok end")) "ok") + (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 aa75cc99..04d95e56 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -1967,11 +1967,80 @@ :else (er-mk-tuple (list (er-mk-atom "module") mod-arg)))))))))) +(define er-procs-on-env + (fn (target-env) + (let ((all-keys (keys (er-sched-processes))) + (matches (list))) + (for-each + (fn (i) + (let ((proc (get (er-sched-processes) (nth all-keys i)))) + (let ((init-fun (get proc :initial-fun))) + (when (and (not (= init-fun nil)) + (er-fun? init-fun) + (= (get init-fun :env) target-env) + (not (= (get proc :state) "dead"))) + (append! matches (get proc :pid)))))) + (range 0 (len all-keys))) + matches))) + +(define er-bif-code-purge + (fn (vs) + (let ((mod-arg (nth vs 0))) + (cond + (not (er-atom? mod-arg)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else + (let ((registry (er-modules-get)) (mod-name (get mod-arg :name))) + (cond + (not (dict-has? registry mod-name)) (er-mk-atom "false") + :else + (let ((slot (get registry mod-name))) + (cond + (= (er-module-old-env slot) nil) (er-mk-atom "false") + :else + (let ((procs (er-procs-on-env (er-module-old-env slot)))) + (for-each + (fn (i) (er-cascade-exit! (nth procs i) (er-mk-atom "killed"))) + (range 0 (len procs))) + (dict-set! registry mod-name + (er-mk-module-slot (er-module-current-env slot) nil + (er-module-version slot))) + (er-mk-atom "true")))))))))) + +(define er-bif-code-soft-purge + (fn (vs) + (let ((mod-arg (nth vs 0))) + (cond + (not (er-atom? mod-arg)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else + (let ((registry (er-modules-get)) (mod-name (get mod-arg :name))) + (cond + (not (dict-has? registry mod-name)) (er-mk-atom "true") + :else + (let ((slot (get registry mod-name))) + (cond + (= (er-module-old-env slot) nil) (er-mk-atom "true") + :else + (let ((procs (er-procs-on-env (er-module-old-env slot)))) + (cond + (> (len procs) 0) (er-mk-atom "false") + :else + (do + (dict-set! registry mod-name + (er-mk-module-slot (er-module-current-env slot) nil + (er-module-version slot))) + (er-mk-atom "true")))))))))))) + (define er-apply-code-bif (fn (name vs) (cond (and (= name "load_binary") (= (len vs) 3)) (er-bif-code-load-binary vs) + (and (= name "purge") (= (len vs) 1)) + (er-bif-code-purge vs) + (and (= name "soft_purge") (= (len vs) 1)) + (er-bif-code-soft-purge 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 299b2bbf..50ec8236 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -103,7 +103,7 @@ Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules mu - [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) - [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 +- [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. - [ ] `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 - [ ] 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 @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **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. + - **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.