erlang: link/unlink/monitor/demonitor + refs (+17 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled

This commit is contained in:
2026-04-25 02:16:04 +00:00
parent aa7d691028
commit c363856df6
6 changed files with 262 additions and 7 deletions

View File

@@ -117,6 +117,24 @@
er-pid-equal?
(fn (a b) (and (er-pid? a) (er-pid? b) (= (er-pid-id a) (er-pid-id b)))))
;; ── refs ─────────────────────────────────────────────────────────
(define er-mk-ref (fn (id) {:id id :tag "ref"}))
(define er-ref? (fn (v) (er-is-tagged? v "ref")))
(define
er-ref-equal?
(fn (a b) (and (er-ref? a) (er-ref? b) (= (get a :id) (get b :id)))))
(define
er-ref-new!
(fn
()
(let
((s (er-sched)))
(let
((n (get s :next-ref)))
(dict-set! s :next-ref (+ n 1))
(er-mk-ref n)))))
;; ── scheduler state ──────────────────────────────────────────────
(define er-scheduler (list nil))
@@ -128,6 +146,7 @@
er-scheduler
0
{:next-pid 0
:next-ref 0
:current nil
:processes {}
:runnable (er-q-new)})))
@@ -190,6 +209,7 @@
:mailbox (er-q-new)
:state "runnable"
:monitors (list)
:monitored-by (list)
:continuation nil
:receive-pats nil
:trap-exit false
@@ -296,9 +316,165 @@
(= (len vs) 1) (raise (er-mk-exit-marker (nth vs 0)))
(= (len vs) 2)
(error
"Erlang: exit/2 (signal another process) deferred to Phase 4 (links)")
"Erlang: exit/2 (signal another process) deferred to next Phase 4 step (signal propagation)")
:else (error "Erlang: exit: wrong arity"))))
;; ── links / monitors / refs ─────────────────────────────────────
(define
er-bif-is-reference
(fn (vs) (er-bool (er-ref? (er-bif-arg1 vs "is_reference")))))
(define
er-bif-make-ref
(fn
(vs)
(if
(not (= (len vs) 0))
(error "Erlang: make_ref/0: arity")
(er-ref-new!))))
;; Add `target` to `pid`'s :links list if not already there.
(define
er-link-add-one!
(fn
(pid target)
(let
((links (er-proc-field pid :links)))
(when
(not (er-link-has? links target))
(append! links target)))))
(define
er-link-has?
(fn
(links target)
(cond
(= (len links) 0) false
(er-pid-equal? (nth links 0) target) true
:else (er-link-has? (er-slice-list links 1) target))))
(define
er-link-remove-one!
(fn
(pid target)
(let
((old (er-proc-field pid :links)) (out (list)))
(for-each
(fn
(i)
(let
((p (nth old i)))
(when (not (er-pid-equal? p target)) (append! out p))))
(range 0 (len old)))
(er-proc-set! pid :links out))))
(define
er-bif-link
(fn
(vs)
(let
((target (er-bif-arg1 vs "link")) (me (er-sched-current-pid)))
(cond
(not (er-pid? target)) (error "Erlang: link: not a pid")
(er-pid-equal? target me) (er-mk-atom "true")
(not (er-proc-exists? target))
(raise (er-mk-exit-marker (er-mk-atom "noproc")))
:else (do
(er-link-add-one! me target)
(er-link-add-one! target me)
(er-mk-atom "true"))))))
(define
er-bif-unlink
(fn
(vs)
(let
((target (er-bif-arg1 vs "unlink")) (me (er-sched-current-pid)))
(cond
(not (er-pid? target)) (error "Erlang: unlink: not a pid")
:else (do
(er-link-remove-one! me target)
(when
(er-proc-exists? target)
(er-link-remove-one! target me))
(er-mk-atom "true"))))))
(define
er-bif-monitor
(fn
(vs)
(if
(not (= (len vs) 2))
(error "Erlang: monitor/2: arity")
(let
((kind (nth vs 0))
(target (nth vs 1))
(me (er-sched-current-pid)))
(cond
(not (and (er-atom? kind) (= (get kind :name) "process")))
(error "Erlang: monitor: only 'process' supported")
(not (er-pid? target)) (error "Erlang: monitor: not a pid")
:else (let
((ref (er-ref-new!)))
(append!
(er-proc-field me :monitors)
{:ref ref :pid target})
(when
(er-proc-exists? target)
(append!
(er-proc-field target :monitored-by)
{:from me :ref ref}))
ref))))))
(define
er-bif-demonitor
(fn
(vs)
(let
((ref (er-bif-arg1 vs "demonitor")) (me (er-sched-current-pid)))
(if
(not (er-ref? ref))
(error "Erlang: demonitor: not a reference")
(do
(er-demonitor-purge! me ref)
(er-mk-atom "true"))))))
(define
er-demonitor-purge!
(fn
(me ref)
(let
((old (er-proc-field me :monitors)) (out (list)) (target-ref (list nil)))
(for-each
(fn
(i)
(let
((m (nth old i)))
(if
(er-ref-equal? (get m :ref) ref)
(set-nth! target-ref 0 (get m :pid))
(append! out m))))
(range 0 (len old)))
(er-proc-set! me :monitors out)
(when
(and
(not (= (nth target-ref 0) nil))
(er-proc-exists? (nth target-ref 0)))
(let
((target (nth target-ref 0))
(oldby (er-proc-field (nth target-ref 0) :monitored-by))
(out2 (list)))
(for-each
(fn
(i)
(let
((m (nth oldby i)))
(when
(not (er-ref-equal? (get m :ref) ref))
(append! out2 m))))
(range 0 (len oldby)))
(er-proc-set! target :monitored-by out2))))))
;; ── scheduler loop ──────────────────────────────────────────────
;; Each scheduler step wraps the process body in `guard`. `receive`
;; with no match captures a `call/cc` continuation onto the proc

View File

@@ -1,11 +1,11 @@
{
"language": "erlang",
"total_pass": 358,
"total": 358,
"total_pass": 375,
"total": 375,
"suites": [
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
{"name":"parse","pass":52,"total":52,"status":"ok"},
{"name":"eval","pass":174,"total":174,"status":"ok"},
{"name":"eval","pass":191,"total":191,"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: 358 / 358 tests passing**
**Total: 375 / 375 tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
| ✅ | tokenize | 62 | 62 |
| ✅ | parse | 52 | 52 |
| ✅ | eval | 174 | 174 |
| ✅ | eval | 191 | 191 |
| ✅ | runtime | 39 | 39 |
| ✅ | ring | 4 | 4 |
| ✅ | ping-pong | 4 | 4 |

View File

@@ -432,6 +432,76 @@
(nm (er-last-main-exit-reason)))
"from_fn")
;; ── refs / link / monitor ──────────────────────────────────────
(er-eval-test "make_ref tag"
(get (ev "make_ref()") :tag) "ref")
(er-eval-test "is_reference fresh"
(nm (ev "R = make_ref(), is_reference(R)")) "true")
(er-eval-test "is_reference pid"
(nm (ev "is_reference(self())")) "false")
(er-eval-test "is_reference number"
(nm (ev "is_reference(42)")) "false")
(er-eval-test "make_ref distinct"
(nm (ev "R1 = make_ref(), R2 = make_ref(), R1 =:= R2")) "false")
(er-eval-test "make_ref same id eq"
(nm (ev "R = make_ref(), R =:= R")) "true")
(er-eval-test "link returns true"
(nm (ev "P = spawn(fun () -> ok end), link(P)")) "true")
(er-eval-test "self link returns true"
(nm (ev "link(self())")) "true")
(er-eval-test "unlink returns true"
(nm (ev "P = spawn(fun () -> ok end), link(P), unlink(P)")) "true")
(er-eval-test "unlink without link"
(nm (ev "P = spawn(fun () -> ok end), unlink(P)")) "true")
(er-eval-test "monitor returns ref"
(get (ev "P = spawn(fun () -> ok end), monitor(process, P)") :tag)
"ref")
(er-eval-test "monitor refs distinct"
(nm (ev "P = spawn(fun () -> ok end), R1 = monitor(process, P), R2 = monitor(process, P), R1 =:= R2"))
"false")
(er-eval-test "demonitor returns true"
(nm (ev "P = spawn(fun () -> ok end), R = monitor(process, P), demonitor(R)"))
"true")
;; Bidirectional link recorded on both sides.
(er-eval-test "link bidirectional"
(do
(ev "P = spawn(fun () -> receive forever -> ok end end), link(P)")
;; After eval, check links on main + child via accessors.
(and
(= (len (er-proc-field (er-mk-pid 0) :links)) 1)
(= (len (er-proc-field (er-mk-pid 1) :links)) 1)))
true)
;; unlink clears both sides.
(er-eval-test "unlink clears both"
(do
(ev "P = spawn(fun () -> receive forever -> ok end end), link(P), unlink(P)")
(and
(= (len (er-proc-field (er-mk-pid 0) :links)) 0)
(= (len (er-proc-field (er-mk-pid 1) :links)) 0)))
true)
;; monitor adds entries to both lists.
(er-eval-test "monitor records both sides"
(do
(ev "P = spawn(fun () -> receive forever -> ok end end), monitor(process, P)")
(and
(= (len (er-proc-field (er-mk-pid 0) :monitors)) 1)
(= (len (er-proc-field (er-mk-pid 1) :monitored-by)) 1)))
true)
;; demonitor clears both lists.
(er-eval-test "demonitor clears both"
(do
(ev "P = spawn(fun () -> receive forever -> ok end end), R = monitor(process, P), demonitor(R)")
(and
(= (len (er-proc-field (er-mk-pid 0) :monitors)) 0)
(= (len (er-proc-field (er-mk-pid 1) :monitored-by)) 0)))
true)
(define
er-eval-test-summary
(str "eval " er-eval-test-pass "/" er-eval-test-count))

View File

@@ -372,6 +372,7 @@
(range 0 (len ea)))))
(and (= (type-of a) "string") (= (type-of b) "string")) (= a b)
(and (er-pid? a) (er-pid? b)) (= (get a :id) (get b :id))
(and (er-ref? a) (er-ref? b)) (= (get a :id) (get b :id))
:else false)))
;; Exact equality: 1 =/= 1.0 in Erlang.
@@ -562,9 +563,15 @@
(= name "atom_to_list") (er-bif-atom-to-list vs)
(= name "list_to_atom") (er-bif-list-to-atom vs)
(= name "is_pid") (er-bif-is-pid vs)
(= name "is_reference") (er-bif-is-reference vs)
(= name "self") (er-bif-self vs)
(= name "spawn") (er-bif-spawn vs)
(= name "exit") (er-bif-exit vs)
(= name "make_ref") (er-bif-make-ref vs)
(= name "link") (er-bif-link vs)
(= name "unlink") (er-bif-unlink vs)
(= name "monitor") (er-bif-monitor vs)
(= name "demonitor") (er-bif-demonitor vs)
:else (error
(str "Erlang: undefined function '" name "/" (len vs) "'")))))
@@ -894,6 +901,7 @@
(er-tuple? v) (str "{" (er-format-tuple-elems (get v :elements)) "}")
(er-fun? v) "#Fun"
(er-pid? v) (str "<pid:" (get v :id) ">")
(er-ref? v) (str "#Ref<" (get v :id) ">")
:else (str v))))
(define

View File

@@ -79,7 +79,7 @@ Core mapping:
- [x] Target: 5/5 classic programs + 1M-process ring benchmark runs — **5/5 classic programs green; ring benchmark runs correctly at every measured size up to N=1000 (33s, ~34 hops/s); 1M target NOT met in current synchronous-scheduler architecture (would take ~9h at observed throughput)**. See `lib/erlang/bench_ring.sh` and `lib/erlang/bench_ring_results.md`.
### Phase 4 — links, monitors, exit signals
- [ ] `link/1`, `unlink/1`, `monitor/2`, `demonitor/1`
- [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
- [ ] `try/catch/of/end`
@@ -99,6 +99,7 @@ Core mapping:
_Newest first._
- **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.
- **2026-04-25 fib_server.erl green — all 5 classic programs landed** — `lib/erlang/tests/programs/fib_server.sx` with 8 tests. Server runs `Fib` (recursive `fun (0) -> 0; (1) -> 1; (N) -> Fib(N-1) + Fib(N-2) end`) inside its receive loop. Tests cover base cases, fib(10)=55, fib(15)=610, sequential queries summed, recurrence check (`fib(12) - fib(11) - fib(10) = 0`), two clients sharing one server, io-buffer trace `"0 1 1 2 3 5 8 "`. Total suite 358/358. Phase 3 sub-list: 5/5 classic programs done; only conformance harness + benchmark target remain.