diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index cb028579..e7571c03 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -324,6 +324,29 @@ er-bif-is-reference (fn (vs) (er-bool (er-ref? (er-bif-arg1 vs "is_reference"))))) +(define + er-bif-process-flag + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: process_flag/2: arity") + (let + ((flag (nth vs 0)) + (val (nth vs 1)) + (me (er-sched-current-pid))) + (cond + (and (er-atom? flag) (= (get flag :name) "trap_exit")) + (let + ((old (er-proc-field me :trap-exit))) + (er-proc-set! me :trap-exit (er-truthy? val)) + (er-bool old)) + :else (error + (str + "Erlang: process_flag: unsupported flag '" + (er-format-value flag) + "'"))))))) + (define er-bif-make-ref (fn @@ -551,6 +574,14 @@ (define er-sched-step! + (fn + (pid) + (cond + (= (er-proc-field pid :state) "dead") nil + :else (er-sched-step-alive! pid)))) + +(define + er-sched-step-alive! (fn (pid) (er-sched-set-current! pid) @@ -578,10 +609,103 @@ (er-proc-set! pid :state "dead") (er-proc-set! pid :exit-reason (get r :reason)) (er-proc-set! pid :exit-result nil) - (er-proc-set! pid :continuation nil)) + (er-proc-set! pid :continuation nil) + (er-propagate-exit! pid (get r :reason))) :else (do (er-proc-set! pid :state "dead") (er-proc-set! pid :exit-reason (er-mk-atom "normal")) (er-proc-set! pid :exit-result r) - (er-proc-set! pid :continuation nil))))) + (er-proc-set! pid :continuation nil) + (er-propagate-exit! pid (er-mk-atom "normal")))))) (er-sched-set-current! nil))) + +;; ── exit-signal propagation ───────────────────────────────────── +;; Called when `pid` finishes (normally or via exit). Walks the +;; process's `:monitored-by` and `:links` lists to deliver `{'DOWN'}` +;; messages and exit signals respectively. Linked processes without +;; `trap_exit` cascade-die with the same reason; those with +;; `trap_exit` true receive an `{'EXIT', From, Reason}` message. +(define + er-propagate-exit! + (fn + (pid reason) + (er-fire-monitors! pid reason) + (er-fire-links! pid reason))) + +(define + er-fire-monitors! + (fn + (pid reason) + (let + ((mons (er-proc-field pid :monitored-by))) + (for-each + (fn + (i) + (let + ((m (nth mons i))) + (let + ((from (get m :from)) (ref (get m :ref))) + (when + (and (er-proc-exists? from) + (not (= (er-proc-field from :state) "dead"))) + (let + ((msg + (er-mk-tuple + (list + (er-mk-atom "DOWN") + ref + (er-mk-atom "process") + pid + reason)))) + (er-proc-mailbox-push! from msg) + (when + (= (er-proc-field from :state) "waiting") + (er-proc-set! from :state "runnable") + (er-sched-enqueue! from))))))) + (range 0 (len mons)))))) + +(define + er-fire-links! + (fn + (pid reason) + (let + ((links (er-proc-field pid :links)) + (is-normal (er-is-atom-named? reason "normal"))) + (for-each + (fn + (i) + (let + ((target (nth links i))) + (when + (and (er-proc-exists? target) + (not (= (er-proc-field target :state) "dead"))) + (let + ((trap (er-proc-field target :trap-exit))) + (cond + trap (er-deliver-exit-msg! target pid reason) + is-normal nil + :else (er-cascade-exit! target reason)))))) + (range 0 (len links)))))) + +(define + er-deliver-exit-msg! + (fn + (target from reason) + (let + ((msg + (er-mk-tuple (list (er-mk-atom "EXIT") from reason)))) + (er-proc-mailbox-push! target msg) + (when + (= (er-proc-field target :state) "waiting") + (er-proc-set! target :state "runnable") + (er-sched-enqueue! target))))) + +(define + er-cascade-exit! + (fn + (target reason) + (er-proc-set! target :state "dead") + (er-proc-set! target :exit-reason reason) + (er-proc-set! target :exit-result nil) + (er-proc-set! target :continuation nil) + (er-propagate-exit! target reason))) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 6cdffc67..2426a635 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 375, - "total": 375, + "total_pass": 386, + "total": 386, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":191,"total":191,"status":"ok"}, + {"name":"eval","pass":202,"total":202,"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 47c31770..d3146fa0 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 375 / 375 tests passing** +**Total: 386 / 386 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 191 | 191 | +| ✅ | eval | 202 | 202 | | ✅ | 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 1d531ef7..061b7794 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -502,6 +502,65 @@ (= (len (er-proc-field (er-mk-pid 1) :monitored-by)) 0))) true) +;; ── exit-signal propagation + trap_exit ──────────────────────── +(er-eval-test "process_flag default false" + (nm (ev "process_flag(trap_exit, true)")) "false") +(er-eval-test "process_flag returns prev" + (nm (ev "process_flag(trap_exit, true), process_flag(trap_exit, false)")) + "true") + +;; Monitor fires on normal exit. +(er-eval-test "monitor DOWN normal" + (nm (ev "P = spawn(fun () -> ok end), monitor(process, P), receive {'DOWN', _, process, _, R} -> R end")) + "normal") + +;; Monitor fires on abnormal exit. +(er-eval-test "monitor DOWN abnormal" + (nm (ev "P = spawn(fun () -> exit(boom) end), monitor(process, P), receive {'DOWN', _, process, _, R} -> R end")) + "boom") + +;; Monitor's ref appears in DOWN message. +(er-eval-test "monitor DOWN ref matches" + (nm (ev "P = spawn(fun () -> exit(bye) end), Ref = monitor(process, P), receive {'DOWN', Ref, process, _, _} -> ok_match end")) + "ok_match") + +;; Two monitors -> both fire. +(er-eval-test "two monitors both fire" + (ev "P = spawn(fun () -> exit(crash) end), monitor(process, P), monitor(process, P), receive {'DOWN', _, _, _, _} -> ok end, receive {'DOWN', _, _, _, _} -> 2 end") + 2) + +;; trap_exit + link + abnormal exit -> {'EXIT', From, Reason} message. +(er-eval-test "trap_exit catches abnormal" + (nm (ev "process_flag(trap_exit, true), P = spawn(fun () -> exit(boom) end), link(P), receive {'EXIT', _, R} -> R end")) + "boom") + +;; trap_exit + link + normal exit -> {'EXIT', From, normal}. +(er-eval-test "trap_exit catches normal" + (nm (ev "process_flag(trap_exit, true), P = spawn(fun () -> ok end), link(P), receive {'EXIT', _, R} -> R end")) + "normal") + +;; Cascade exit: A links B, B dies abnormally, A dies with same reason. +(er-eval-test "cascade reason" + (do + (ev "A = spawn(fun () -> B = spawn(fun () -> exit(crash) end), link(B), receive forever -> ok end end), receive after 0 -> ok end") + (nm (er-proc-field (er-mk-pid 1) :exit-reason))) + "crash") + +;; Normal exit doesn't cascade (without trap_exit) — A's body returns +;; "survived" via the `after` clause and A dies normally. +(er-eval-test "normal exit no cascade" + (do + (ev "A = spawn(fun () -> B = spawn(fun () -> ok end), link(B), receive {'EXIT', _, _} -> got_exit after 50 -> survived end end), receive after 0 -> ok end") + (list + (nm (er-proc-field (er-mk-pid 1) :exit-reason)) + (nm (er-proc-field (er-mk-pid 1) :exit-result)))) + (list "normal" "survived")) + +;; Monitor without trap_exit: monitored proc abnormal doesn't kill the monitor. +(er-eval-test "monitor doesn't cascade" + (nm (ev "P = spawn(fun () -> exit(boom) end), monitor(process, P), receive {'DOWN', _, _, _, _} -> alive end")) + "alive") + (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 d475481e..fad9b55b 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -572,6 +572,7 @@ (= name "unlink") (er-bif-unlink vs) (= name "monitor") (er-bif-monitor vs) (= name "demonitor") (er-bif-demonitor vs) + (= name "process_flag") (er-bif-process-flag vs) :else (error (str "Erlang: undefined function '" name "/" (len vs) "'"))))) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index f013f50f..84251271 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -80,7 +80,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 -- [ ] Exit-signal propagation; trap_exit flag +- [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` ### Phase 5 — modules + OTP-lite @@ -99,6 +99,7 @@ Core mapping: _Newest first._ +- **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. - **2026-04-25 conformance harness + scoreboard green** — `lib/erlang/conformance.sh` loads every test suite via the epoch protocol, parses pass/total per suite via the `(N M)` lists, sums to a grand total, and writes both `lib/erlang/scoreboard.json` (machine-readable) and `lib/erlang/scoreboard.md` (Markdown table with ✅/❌ markers). 9 suites × full pass = 358/358. Exits non-zero on any failure. `bash lib/erlang/conformance.sh -v` prints per-suite counts. Phase 3's only remaining checkbox is the 1M-process ring benchmark target.