erlang: exit-signal propagation + trap_exit (+11 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 02:51:32 +00:00
parent c363856df6
commit 1a5a2e8982
6 changed files with 193 additions and 8 deletions

View File

@@ -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)))

View File

@@ -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"},

View File

@@ -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 |

View File

@@ -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))

View File

@@ -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) "'")))))

View File

@@ -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<N>`. 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.