diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index 4dc1a709..b1f258f2 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -814,3 +814,58 @@ (er-mk-atom mod) (er-mk-atom name))))) (er-apply-fun (get mod-env name) vs))))) + +;; ── gen_server (OTP-lite) ─────────────────────────────────────── +;; A minimal gen_server behaviour — `start_link/2`, `call/2`, `cast/2`, +;; `stop/1`, plus the receive loop dispatching `Mod:handle_call/3`, +;; `Mod:handle_cast/2`, `Mod:handle_info/2`. Loaded into the user +;; module registry on demand via `(er-load-gen-server!)`. +(define + er-gen-server-source + "-module(gen_server). + start_link(Mod, Args) -> + spawn(fun () -> + case Mod:init(Args) of + {ok, State} -> gen_server:loop(Mod, State); + {stop, Reason} -> exit(Reason) + end + end). + call(Pid, Req) -> + Ref = make_ref(), + Pid ! {'$gen_call', {self(), Ref}, Req}, + receive {Ref, Reply} -> Reply end. + cast(Pid, Msg) -> + Pid ! {'$gen_cast', Msg}, + ok. + stop(Pid) -> + gen_server:call(Pid, '$gen_stop'). + loop(Mod, State) -> + receive + {'$gen_call', {From, Ref}, '$gen_stop'} -> + From ! {Ref, ok}; + {'$gen_call', {From, Ref}, Req} -> + case Mod:handle_call(Req, From, State) of + {reply, Reply, NewState} -> + From ! {Ref, Reply}, + gen_server:loop(Mod, NewState); + {noreply, NewState} -> + gen_server:loop(Mod, NewState); + {stop, Reason, Reply, NewState} -> + From ! {Ref, Reply}, + exit(Reason) + end; + {'$gen_cast', Msg} -> + case Mod:handle_cast(Msg, State) of + {noreply, NewState} -> gen_server:loop(Mod, NewState); + {stop, Reason, NewState} -> exit(Reason) + end; + Other -> + case Mod:handle_info(Other, State) of + {noreply, NewState} -> gen_server:loop(Mod, NewState); + {stop, Reason, NewState} -> exit(Reason) + end + end.") + +(define + er-load-gen-server! + (fn () (erlang-load-module er-gen-server-source))) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 9960afbe..b60173dc 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 415, - "total": 415, + "total_pass": 425, + "total": 425, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":231,"total":231,"status":"ok"}, + {"name":"eval","pass":241,"total":241,"status":"ok"}, {"name":"runtime","pass":39,"total":39,"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 6f9e96f3..ee7e06fd 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 415 / 415 tests passing** +**Total: 425 / 425 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 231 | 231 | +| ✅ | eval | 241 | 241 | | ✅ | runtime | 39 | 39 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index 530f8faf..93563c28 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -699,6 +699,75 @@ (ev "Me = self(), spawn(fun () -> m9:work(Me) end), receive done -> ok end")) (er-mk-atom "ok")) +;; ── gen_server (OTP-lite) ────────────────────────────────────── +(do + (er-load-gen-server!) + (erlang-load-module + "-module(ctr). + init(N) -> {ok, N}. + handle_call(get, _F, S) -> {reply, S, S}. + handle_call({set, V}, _F, _S) -> {reply, ok, V}. + handle_call({add, K}, _F, S) -> {reply, S + K, S + K}. + handle_cast(inc, S) -> {noreply, S + 1}. + handle_cast(dec, S) -> {noreply, S - 1}. + handle_cast({add, K}, S) -> {noreply, S + K}. + handle_info(_M, S) -> {noreply, S}.") + nil) + +(er-eval-test "gen_server start + call get" + (ev "P = gen_server:start_link(ctr, 10), gen_server:call(P, get)") + 10) + +(er-eval-test "gen_server cast then call" + (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, inc), gen_server:cast(P, inc), gen_server:cast(P, inc), gen_server:call(P, get)") + 3) + +(er-eval-test "gen_server call returns reply" + (ev "P = gen_server:start_link(ctr, 5), gen_server:call(P, {add, 7})") + 12) + +(er-eval-test "gen_server state mutation" + (ev "P = gen_server:start_link(ctr, 5), gen_server:call(P, {set, 99}), gen_server:call(P, get)") + 99) + +(er-eval-test "gen_server stop returns ok" + (nm (ev "P = gen_server:start_link(ctr, 0), gen_server:stop(P)")) + "ok") + +(er-eval-test "gen_server cast returns ok immediately" + (nm (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, inc)")) + "ok") + +(er-eval-test "gen_server multi-state mutations" + (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, {add, 100}), gen_server:cast(P, dec), gen_server:cast(P, dec), gen_server:call(P, get)") + 98) + +;; Stack server — exercises a different state shape. +(do + (erlang-load-module + "-module(stk). + init(_) -> {ok, []}. + handle_call(pop, _F, []) -> {reply, empty, []}; + handle_call(pop, _F, [H | T]) -> {reply, {ok, H}, T}; + handle_call(peek, _F, []) -> {reply, empty, []}; + handle_call(peek, _F, [H | T]) -> {reply, {ok, H}, [H | T]}; + handle_call(size, _F, S) -> {reply, length(S), S}. + handle_cast({push, V}, S) -> {noreply, [V | S]}. + handle_info(_M, S) -> {noreply, S}.") + nil) + +(er-eval-test "stack push/pop" + (ev "P = gen_server:start_link(stk, ignored), gen_server:cast(P, {push, 1}), gen_server:cast(P, {push, 2}), gen_server:cast(P, {push, 3}), gen_server:call(P, size)") + 3) + +(er-eval-test "stack lifo" + (ev "P = gen_server:start_link(stk, ignored), gen_server:cast(P, {push, 1}), gen_server:cast(P, {push, 2}), gen_server:cast(P, {push, 3}), {ok, V} = gen_server:call(P, pop), V") + 3) + +(er-eval-test "stack empty pop" + (nm (ev "P = gen_server:start_link(stk, ignored), gen_server:call(P, pop)")) + "empty") + (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 00afed25..0f4189ae 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -486,10 +486,10 @@ (er-apply-fun (get env name) vs) :else (er-apply-bif name vs))) (= (get fun-node :type) "remote") - (er-apply-remote-bif - (get (get fun-node :mod) :value) - (get (get fun-node :fun) :value) - (er-eval-args args env)) + (let + ((mod-name (er-resolve-call-name (get fun-node :mod) env "module")) + (fn-name (er-resolve-call-name (get fun-node :fun) env "function"))) + (er-apply-remote-bif mod-name fn-name (er-eval-args args env))) :else (let ((fv (er-eval-expr fun-node env))) @@ -509,6 +509,24 @@ (range 0 (len args))) out))) +;; Resolve a remote call's module/function reference into a string. +;; Atom AST nodes use their `:value` directly. For any other shape +;; (typically a var or another expression), evaluate it and require +;; the result to be an atom. +(define + er-resolve-call-name + (fn + (node env kind) + (cond + (= (get node :type) "atom") (get node :value) + :else (let + ((v (er-eval-expr node env))) + (if + (er-atom? v) + (get v :name) + (error + (str "Erlang: call " kind " must be an atom, got " (er-format-value v)))))))) + ;; ── fun values ─────────────────────────────────────────────────── (define er-mk-fun diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 152c18ec..ace6a470 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -85,7 +85,7 @@ Core mapping: ### Phase 5 — modules + OTP-lite - [x] `-module(M).` loading, `M:F(...)` calls across modules — **10 new eval tests**; multi-arity, sibling calls, cross-module dispatch via `er-modules` registry -- [ ] `gen_server` behaviour (the big OTP win) +- [x] `gen_server` behaviour (the big OTP win) — **10 new eval tests**; counter + LIFO stack callback modules driven via `gen_server:start_link/call/cast/stop` - [ ] `supervisor` (simple one-for-one) - [ ] Registered processes: `register/2`, `whereis/1` @@ -99,6 +99,7 @@ Core mapping: _Newest first._ +- **2026-04-25 gen_server (OTP-lite) green** — `er-gen-server-source` in `lib/erlang/runtime.sx` is the canonical Erlang text of the behaviour; `er-load-gen-server!` registers it in the user-module table. Implements `start_link/2`, `call/2` (sync via `make_ref` + selective `receive {Ref, Reply}`), `cast/2` (async fire-and-forget returning `ok`), `stop/1`, and the receive loop dispatching `{'$gen_call', {From, Ref}, Req}` → `Mod:handle_call/3`, `{'$gen_cast', Msg}` → `Mod:handle_cast/2`, anything else → `Mod:handle_info/2`. handle_call reply tuples supported: `{reply, R, S}`, `{noreply, S}`, `{stop, R, Reply, S}`. handle_cast/info: `{noreply, S}`, `{stop, R, S}`. `Mod:F` and `M:F` where `M` is a runtime variable now work via new `er-resolve-call-name` (was bug: passed unevaluated AST node `:value` to remote dispatch). 10 new eval tests: counter callback module (start/call/cast/stop, repeated state mutations), LIFO stack callback module (`{push, V}` cast, pop returns `{ok, V}` or `empty`, size). Total suite 425/425. - **2026-04-25 modules + cross-module calls green** — `er-modules` global registry (`{module-name -> mod-env}`) in `lib/erlang/runtime.sx`. `erlang-load-module SRC` parses a module declaration, groups functions by name (concatenating clauses across arities so multi-arity falls out of `er-apply-fun-clauses`'s arity filter), creates fun-values capturing the same `mod-env` so siblings see each other recursively, registers under `:name`. `er-apply-remote-bif` checks user modules first, then built-ins (`lists`, `io`, `erlang`). `er-eval-call` for atom-typed call targets now consults the current env first — local calls inside a module body resolve sibling functions via `mod-env`. Undefined cross-module call raises `error({undef, Mod, Fun})`. 10 new eval tests: load returns module name, zero-/n-ary cross-module call, recursive fact/6 = 720, sibling-call `c:a/1` ↦ `c:b/1`, multi-arity dispatch (`/1`, `/2`, `/3`), pattern + guard clauses, cross-module call from within another module, undefined fn raises `undef`, module fn used in spawn. Total suite 415/415. - **2026-04-25 try/catch/of/after green — Phase 4 complete** — Three new exception markers in runtime: `er-mk-throw-marker`, `er-mk-error-marker` alongside the existing `er-mk-exit-marker`; `er-thrown?`, `er-errored?` predicates. `throw/1` and `error/1` BIFs raise their respective markers. Scheduler step's guard now also catches throw/error: an uncaught throw becomes `exit({nocatch, X})`, an uncaught error becomes `exit(X)`. `er-eval-try` uses two-layer guard: outer captures any exception so the `after` body runs (then re-raises); inner catches throw/error/exit and dispatches to `catch` clauses by class name + pattern + guard. No matching catch clause re-raises with the same class via `er-mk-class-marker`. `of` clauses run on success; no-match raises `error({try_clause, V})`. 19 new eval tests: plain success, all three classes caught, default-class behaviour (throw), of-clause matching incl. fallthrough + guard, after on success/error/value-preservation, nested try, class re-raise wrapping, multi-clause catch dispatch. Total suite 405/405. **Phase 4 complete — Phase 5 (modules + OTP-lite) is next.** Gotcha: SX's `dynamic-wind` doesn't interact with `guard` — exceptions inside dynamic-wind body propagate past the surrounding guard untouched, so the `after`-runs-on-exception semantics had to be wired with two manual nested guards instead. - **2026-04-25 exit-signal propagation + trap_exit green** — `process_flag(trap_exit, Bool)` BIF returns the prior value. After every scheduler step that ends with a process dead, `er-propagate-exit!` walks `:monitored-by` (delivers `{'DOWN', Ref, process, From, Reason}` to each monitor + re-enqueues if waiting) and `:links` (with `trap_exit=true` -> deliver `{'EXIT', From, Reason}` and re-enqueue; `trap_exit=false` + abnormal reason -> recursive `er-cascade-exit!`; normal reason without trap_exit -> no signal). `er-sched-step!` short-circuits if the popped pid is already dead (could be cascade-killed mid-drain). 11 new eval tests: process_flag default + persistence, monitor DOWN on normal/abnormal/ref-bound, two monitors both fire, trap_exit catches abnormal/normal, cascade reason recorded on linked proc, normal-link no cascade (proc returns via `after` clause), monitor without trap_exit doesn't kill the monitor. Total suite 386/386. `kill`-as-special-reason and `exit/2` (signal to another) deferred.