diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index e7571c03..88c26f85 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -531,6 +531,30 @@ er-mk-exit-marker (fn (reason) {:tag "er-exit-marker" :reason reason})) +(define + er-mk-throw-marker + (fn (reason) {:tag "er-throw-marker" :reason reason})) + +(define + er-mk-error-marker + (fn (reason) {:tag "er-error-marker" :reason reason})) + +(define + er-thrown? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-throw-marker")))) + +(define + er-errored? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-error-marker")))) + (define er-sched-run-all! (fn @@ -592,7 +616,16 @@ (guard (c ((er-suspended? c) (set-nth! result-ref 0 c)) - ((er-exited? c) (set-nth! result-ref 0 c))) + ((er-exited? c) (set-nth! result-ref 0 c)) + ((er-thrown? c) + (set-nth! + result-ref + 0 + (er-mk-exit-marker + (er-mk-tuple + (list (er-mk-atom "nocatch") (get c :reason)))))) + ((er-errored? c) + (set-nth! result-ref 0 (er-mk-exit-marker (get c :reason))))) (set-nth! result-ref 0 diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 2426a635..bd549191 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 386, - "total": 386, + "total_pass": 405, + "total": 405, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":202,"total":202,"status":"ok"}, + {"name":"eval","pass":221,"total":221,"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 d3146fa0..6c9db0f7 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 386 / 386 tests passing** +**Total: 405 / 405 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 202 | 202 | +| ✅ | eval | 221 | 221 | | ✅ | 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 061b7794..ce4ca612 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -561,6 +561,79 @@ (nm (ev "P = spawn(fun () -> exit(boom) end), monitor(process, P), receive {'DOWN', _, _, _, _} -> alive end")) "alive") +;; ── try / catch / of / after ───────────────────────────────── +(er-eval-test "try plain" + (ev "try 1 + 2 catch _ -> oops end") 3) + +(er-eval-test "try throw caught" + (nm (ev "try throw(boom) catch throw:X -> X end")) "boom") +(er-eval-test "try error caught" + (nm (ev "try error(crash) catch error:X -> X end")) "crash") +(er-eval-test "try exit caught" + (nm (ev "try exit(quit) catch exit:X -> X end")) "quit") + +(er-eval-test "default class is throw" + (nm (ev "try throw(bye) catch X -> X end")) "bye") +(er-eval-test "default class doesn't catch error" + (do + (ev "P = spawn(fun () -> try error(crash) catch X -> X end end), receive after 0 -> ok end") + (nm (er-proc-field (er-mk-pid 1) :exit-reason))) + "crash") + +;; of clauses +(er-eval-test "try of single" + (ev "try 42 of N -> N * 2 catch _ -> 0 end") 84) +(er-eval-test "try of multi" + (nm (ev "try ok of ok -> matched; _ -> nope catch _ -> oops end")) + "matched") +(er-eval-test "try of fallthrough" + (nm (ev "try x of ok -> a; error -> b; _ -> default catch _ -> oops end")) + "default") +(er-eval-test "try of with guard" + (nm (ev "try 5 of N when N > 0 -> pos; _ -> nonneg catch _ -> oops end")) + "pos") + +;; after clause +(er-eval-test "after on success" + (do (er-io-flush!) + (ev "try 7 after io:format(\"a\") end") + (er-io-buffer-content)) + "a") +(er-eval-test "after on caught" + (do (er-io-flush!) + (ev "try throw(b) catch throw:_ -> caught after io:format(\"x\") end") + (er-io-buffer-content)) + "x") +(er-eval-test "after returns body value" + (ev "try 99 after 0 end") 99) +(er-eval-test "try preserves catch result" + (nm (ev "try throw(x) catch throw:_ -> recovered after 0 end")) + "recovered") + +;; nested try +(er-eval-test "try nested catch outer" + (nm (ev "try (try throw(inner) catch error:_ -> bad end) catch throw:X -> X end")) + "inner") +(er-eval-test "try nested catch inner" + (nm (ev "try (try throw(inner) catch throw:X -> X end) catch _ -> outer end")) + "inner") + +;; class re-raise on no-match +(er-eval-test "throw without catch-throw escapes" + (do + (ev "P = spawn(fun () -> try throw(bye) catch error:_ -> nope end end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (and (er-tuple? reason) (nm (nth (get reason :elements) 0))))) + "nocatch") + +;; multi-clause catch +(er-eval-test "multi-clause catch picks throw" + (nm (ev "try throw(a) catch error:X -> e; throw:X -> t; exit:X -> x end")) + "t") +(er-eval-test "multi-clause catch picks exit" + (nm (ev "try exit(a) catch error:X -> e; throw:X -> t; exit:X -> x end")) + "x") + (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 fad9b55b..48d1fb3b 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -122,6 +122,7 @@ (= ty "fun") (er-eval-fun node env) (= ty "send") (er-eval-send node env) (= ty "receive") (er-eval-receive node env) + (= ty "try") (er-eval-try node env) (= ty "match") (er-eval-match node env) :else (error (str "Erlang eval: unsupported node type '" ty "'")))))) @@ -573,6 +574,8 @@ (= name "monitor") (er-bif-monitor vs) (= name "demonitor") (er-bif-demonitor vs) (= name "process_flag") (er-bif-process-flag vs) + (= name "throw") (raise (er-mk-throw-marker (er-bif-arg1 vs "throw"))) + (= name "error") (raise (er-mk-error-marker (er-bif-arg1 vs "error"))) :else (error (str "Erlang: undefined function '" name "/" (len vs) "'"))))) @@ -1089,3 +1092,143 @@ (do (er-env-restore! env snap) (er-try-receive-clauses clauses msg env (+ i 1)))))))) + +;; ── try/of/catch/after ──────────────────────────────────────────── +;; The outer guard captures any exception so the `after` body is +;; guaranteed to run, then re-raises. The inner guard runs the +;; expression body, optional `of` clauses on success, and `catch` +;; clauses on a thrown/erred/exited condition. If no catch clause +;; matches the raised class+pattern, the inner guard's clause +;; re-raises by returning nothing (handled via re-raise marker). +(define + er-eval-try + (fn + (node env) + (let + ((after-body (get node :after)) + (saved-exc (list nil)) + (result-ref (list nil))) + (guard + (c (:else (do (set-nth! saved-exc 0 c) nil))) + (set-nth! result-ref 0 (er-eval-try-inner node env))) + (when + (> (len after-body) 0) + (er-eval-body after-body env)) + (if + (= (nth saved-exc 0) nil) + (nth result-ref 0) + (raise (nth saved-exc 0)))))) + +(define + er-eval-try-inner + (fn + (node env) + (let + ((catch-clauses (get node :catch-clauses)) + (of-clauses (get node :of-clauses)) + (caught-ref (list false)) + (result-ref (list nil)) + (re-raise-ref (list nil))) + (guard + (c + ((er-thrown? c) + (er-eval-try-catch + catch-clauses "throw" (get c :reason) env + caught-ref result-ref re-raise-ref)) + ((er-errored? c) + (er-eval-try-catch + catch-clauses "error" (get c :reason) env + caught-ref result-ref re-raise-ref)) + ((er-exited? c) + (er-eval-try-catch + catch-clauses "exit" (get c :reason) env + caught-ref result-ref re-raise-ref))) + (let + ((r (er-eval-body (get node :exprs) env))) + (if + (= (len of-clauses) 0) + (set-nth! result-ref 0 r) + (set-nth! + result-ref + 0 + (er-eval-of-clauses of-clauses r env 0))))) + (when (not (= (nth re-raise-ref 0) nil)) + (raise (nth re-raise-ref 0))) + (nth result-ref 0)))) + +;; Try catch-clauses against (Class, Reason). If a clause matches, +;; runs its body and writes to result-ref. If none match, queues a +;; re-raise marker. +(define + er-eval-try-catch + (fn + (clauses class-name reason env caught-ref result-ref re-raise-ref) + (er-eval-try-catch-iter + clauses class-name reason env 0 caught-ref result-ref re-raise-ref))) + +(define + er-eval-try-catch-iter + (fn + (clauses class-name reason env i caught-ref result-ref re-raise-ref) + (if + (>= i (len clauses)) + (set-nth! + re-raise-ref + 0 + (er-mk-class-marker class-name reason)) + (let + ((c (nth clauses i)) + (snap (er-env-copy env)) + (clause-class (get (get c :class) :value))) + (cond + (not (= clause-class class-name)) + (er-eval-try-catch-iter + clauses class-name reason env (+ i 1) + caught-ref result-ref re-raise-ref) + :else + (if + (and + (er-match! (get c :pattern) reason env) + (er-eval-guards (get c :guards) env)) + (do + (set-nth! caught-ref 0 true) + (set-nth! + result-ref + 0 + (er-eval-body (get c :body) env))) + (do + (er-env-restore! env snap) + (er-eval-try-catch-iter + clauses class-name reason env (+ i 1) + caught-ref result-ref re-raise-ref)))))))) + +(define + er-mk-class-marker + (fn + (class-name reason) + (cond + (= class-name "throw") (er-mk-throw-marker reason) + (= class-name "error") (er-mk-error-marker reason) + (= class-name "exit") (er-mk-exit-marker reason) + :else (er-mk-error-marker reason)))) + +(define + er-eval-of-clauses + (fn + (clauses subject env i) + (if + (>= i (len clauses)) + (raise + (er-mk-error-marker + (er-mk-tuple + (list (er-mk-atom "try_clause") subject)))) + (let + ((c (nth clauses i)) (snap (er-env-copy env))) + (if + (and + (er-match! (get c :pattern) subject env) + (er-eval-guards (get c :guards) env)) + (er-eval-body (get c :body) env) + (do + (er-env-restore! env snap) + (er-eval-of-clauses clauses subject env (+ i 1)))))))) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 84251271..2baff518 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -81,7 +81,7 @@ Core mapping: ### Phase 4 — links, monitors, exit signals - [x] `link/1`, `unlink/1`, `monitor/2`, `demonitor/1` — **17 new eval tests**; `make_ref/0`, `is_reference/1`, refs in `=:=`/format wired - [x] Exit-signal propagation; trap_exit flag — **11 new eval tests**; `process_flag/2`, monitor `{'DOWN', ...}`, `{'EXIT', From, Reason}` for trap-exit links, cascade death without trap_exit -- [ ] `try/catch/of/end` +- [x] `try/catch/of/end` — **19 new eval tests**; `throw/1`, `error/1` BIFs; `nocatch` re-raise wrapping for uncaught throws ### Phase 5 — modules + OTP-lite - [ ] `-module(M).` loading, `M:F(...)` calls across modules @@ -99,6 +99,7 @@ Core mapping: _Newest first._ +- **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. - **2026-04-25 link/unlink/monitor/demonitor + refs green** — Refs added to scheduler (`:next-ref`, `er-ref-new!`); `er-mk-ref`, `er-ref?`, `er-ref-equal?` in runtime. Process record gains `:monitored-by`. New BIFs in `lib/erlang/runtime.sx`: `make_ref/0`, `is_reference/1`, `link/1` (bidirectional, no-op for self, raises `noproc` for missing target), `unlink/1` (removes both sides; tolerates missing target), `monitor(process, Pid)` (returns fresh ref, adds entries to monitor's `:monitors` and target's `:monitored-by`), `demonitor(Ref)` (purges both sides). Refs participate in `er-equal?` (id compare) and render as `#Ref`. 17 new eval tests covering `make_ref` distinctness, link return values, bidirectional link recording, unlink clearing both sides, monitor recording both sides, demonitor purging. Total suite 375/375. Signal propagation (the next checkbox) will hook into these data structures. - **2026-04-25 ring benchmark recorded — Phase 3 closed** — `lib/erlang/bench_ring.sh` runs the ring at N ∈ {10, 50, 100, 500, 1000} and times each end-to-end via wall clock. `lib/erlang/bench_ring_results.md` captures the table. Throughput plateaus at ~30-34 hops/s. 1M-process target IS NOT MET in this architecture — extrapolation = ~9h. The sub-task is ticked as complete with that fact recorded inline because the perf gap is architectural (env-copy per call, call/cc per receive, mailbox rebuild on delete-at) and out of scope for this loop's iterations. Phase 3 done; Phase 4 (links, monitors, exit signals, try/catch) is next.