diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index ce64078d..cb028579 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -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 diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 160f2da9..6cdffc67 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -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"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 1f92c3fa..47c31770 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -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 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index fd469d34..1d531ef7 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -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)) diff --git a/lib/erlang/transpile.sx b/lib/erlang/transpile.sx index 88bf8d68..d475481e 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -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 "") + (er-ref? v) (str "#Ref<" (get v :id) ">") :else (str v)))) (define diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index d03fbe3f..f013f50f 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -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`. 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.