From 97513e5b966fcce52af568f0a320c98449f302d6 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 24 Apr 2026 21:34:21 +0000 Subject: [PATCH] erlang: exit/1 + process termination (+9 tests) --- lib/erlang/runtime.sx | 48 ++++++++++++++++++++++++++++++++++++++++ lib/erlang/tests/eval.sx | 32 +++++++++++++++++++++++++++ lib/erlang/transpile.sx | 1 + plans/erlang-on-sx.md | 3 ++- 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index d91a5889..9d7a15f5 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -231,6 +231,22 @@ er-proc-mailbox-size (fn (pid) (er-q-len (er-proc-field pid :mailbox)))) +;; Main process is always pid 0 (scheduler starts with next-pid 0 and +;; erlang-eval-ast calls er-proc-new! first). Returns nil if no eval +;; has run. +(define + er-main-pid + (fn () (er-mk-pid 0))) + +(define + er-last-main-exit-reason + (fn + () + (if + (er-proc-exists? (er-main-pid)) + (er-proc-field (er-main-pid) :exit-reason) + nil))) + ;; ── process BIFs ──────────────────────────────────────────────── (define er-bif-is-pid @@ -272,6 +288,20 @@ (dict-set! proc :initial-fun fv) (get proc :pid))))) +(define + er-bif-exit + (fn + (vs) + (cond + (= (len vs) 1) + (let + ((reason (nth vs 0))) + (shift k (er-mk-exit-marker reason))) + (= (len vs) 2) + (error + "Erlang: exit/2 (signal another process) deferred to Phase 4 (links)") + :else (error "Erlang: exit: wrong arity")))) + ;; ── scheduler loop ────────────────────────────────────────────── ;; Each process's entry runs inside a `reset`; `receive` uses `shift` ;; to suspend (saving a continuation on the proc record). When a `!` @@ -288,6 +318,18 @@ (= (type-of v) "dict") (= (get v :tag) "er-suspend-marker")))) +(define + er-exited? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-exit-marker")))) + +(define + er-mk-exit-marker + (fn (reason) {:tag "er-exit-marker" :reason reason})) + (define er-sched-run-all! (fn @@ -351,6 +393,12 @@ ((r (nth result-ref 0))) (cond (er-suspended? r) nil + (er-exited? r) + (do + (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)) :else (do (er-proc-set! pid :state "dead") (er-proc-set! pid :exit-reason (er-mk-atom "normal")) diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index 371aeb1a..fd469d34 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -400,6 +400,38 @@ (ev "Me = self(), Me ! first, Me ! second, X = receive second -> got_second after 0 -> to end, Y = receive first -> got_first after 0 -> to end, {X, Y}") (er-mk-tuple (list (er-mk-atom "got_second") (er-mk-atom "got_first")))) +;; ── exit/1 + process termination ───────────────────────────────── +(er-eval-test "exit normal returns nil" (ev "exit(normal)") nil) +(er-eval-test "exit normal reason" + (do (ev "exit(normal)") (nm (er-last-main-exit-reason))) "normal") +(er-eval-test "exit bye reason" + (do (ev "exit(bye)") (nm (er-last-main-exit-reason))) "bye") +(er-eval-test "exit tuple reason" + (do (ev "exit({shutdown, crash})") + (get (er-last-main-exit-reason) :tag)) + "tuple") +(er-eval-test "normal completion reason" + (do (ev "42") (nm (er-last-main-exit-reason))) "normal") +(er-eval-test "exit aborts subsequent" + (do (er-io-flush!) (ev "io:format(\"a~n\"), exit(bye), io:format(\"b~n\")") (er-io-buffer-content)) + "a\n") +(er-eval-test "child exit doesn't kill parent" + (do + (er-io-flush!) + (ev "spawn(fun () -> io:format(\"before~n\"), exit(quit), io:format(\"after~n\") end), io:format(\"main~n\")") + (er-io-buffer-content)) + "main\nbefore\n") +(er-eval-test "child exit reason recorded on child" + (do + (er-io-flush!) + (ev "P = spawn(fun () -> exit(child_bye) end), io:format(\"~p\", [is_pid(P)])") + (er-io-buffer-content)) + "true") +(er-eval-test "exit inside fn chain" + (do (ev "F = fun () -> exit(from_fn) end, F()") + (nm (er-last-main-exit-reason))) + "from_fn") + (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 a8bcf2c5..30409984 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -564,6 +564,7 @@ (= name "is_pid") (er-bif-is-pid vs) (= name "self") (er-bif-self vs) (= name "spawn") (er-bif-spawn vs) + (= name "exit") (er-bif-exit 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 0339ae81..ad61dd4d 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -68,7 +68,7 @@ Core mapping: - [x] `spawn/1`, `spawn/3`, `self/0` — **13 new eval tests**; `spawn/3` stubbed with "deferred to Phase 5" until modules land; `is_pid/1` + pid equality also wired - [x] `!` (send), `receive ... end` with selective pattern matching — **13 new eval tests**; delimited continuations (`shift`/`reset`) power receive suspension; sync scheduler loop - [x] `receive ... after Ms -> ...` timeout clause (use SX timer primitive) — **9 new eval tests**; synchronous-scheduler semantics: `after 0` polls once; `after Ms` fires when runnable queue drains; `after infinity` = no timeout -- [ ] `exit/1`, basic process termination +- [x] `exit/1`, basic process termination — **9 new eval tests**; `exit/2` (signal another) deferred to Phase 4 with links - [ ] Classic programs in `lib/erlang/tests/programs/`: - [ ] `ring.erl` — N processes in a ring, pass a token around M times - [ ] `ping_pong.erl` — two processes exchanging messages @@ -99,6 +99,7 @@ Core mapping: _Newest first._ +- **2026-04-24 exit/1 + termination green** — `exit/1` BIF uses `(shift k ...)` inside the per-step `reset` to abort the current process's computation, returning `er-mk-exit-marker` up to `er-sched-step!`. Step handler records `:exit-reason`, clears `:exit-result`, marks dead. Normal fall-off-end still records reason `normal`. `exit/2` errors with "deferred to Phase 4 (links)". New helpers: `er-main-pid` (= pid 0 — main is always allocated first), `er-last-main-exit-reason` (test accessor). 9 new eval tests — `exit(normal)`, `exit(atom)`, `exit(tuple)`, normal-completion reason, exit-aborts-subsequent (via io-buffer), child exit doesn't kill parent, exit inside nested fn call. Total eval 174/174; suite 327/327. - **2026-04-24 receive...after Ms green** — Three-way dispatch in `er-eval-receive`: no `after` → original loop; `after 0` → poll-once; `after Ms` (or computed non-infinity) → `er-eval-receive-timed` which suspends via `shift` after marking `:has-timeout`; `after infinity` → treated as no-timeout. `er-sched-run-all!` now recurses into `er-sched-fire-one-timeout!` when the runnable queue drains — wakes one `waiting`-with-`:has-timeout` process at a time by setting `:timed-out` and re-enqueueing. On resume the receive-timed branch reads `:timed-out`: true → run `after-body`, false → retry match. "Time" in our sync model = "everyone else has finished"; `after infinity` with no sender correctly deadlocks. 9 new eval tests — all four branches + after-0 leaves non-match in mailbox + after-Ms with spawned sender beating the timeout + computed Ms + side effects in timeout body. Total eval 165/165; suite 318/318. - **2026-04-24 send + selective receive green — THE SHOWCASE** — `!` (send) in `lib/erlang/transpile.sx`: evaluates rhs/lhs, pushes msg to target's mailbox, flips target from `waiting`→`runnable` and re-enqueues if needed. `receive` uses delimited continuations: `er-eval-receive-loop` tries matching the mailbox with `er-try-receive` (arrival order; unmatched msgs stay in place; first clause to match any msg removes it and runs body). On no match, `(shift k ...)` saves the k on the proc record, marks `waiting`, returns `er-suspend-marker` to the scheduler — reset boundary established by `er-sched-step!`. Scheduler loop `er-sched-run-all!` pops runnable pids and calls either `(reset ...)` for first run or `(k nil)` to resume; suspension marker means "process isn't done, don't clear state". `erlang-eval-ast` wraps main's body as a process (instead of inline-eval) so main can suspend on receive too. Queue helpers added: `er-q-nth`, `er-q-delete-at!`. 13 new eval tests — self-send/receive, pattern-match receive, guarded receive, selective receive (skip non-match), spawn→send→receive, ping-pong, echo server, multi-clause receive, nested-tuple pattern. Total eval 156/156; suite 309/309. Deadlock detected if main never terminates. - **2026-04-24 spawn/1 + self/0 green** — `erlang-eval-ast` now spins up a "main" process for every top-level evaluation and runs `er-sched-drain!` after the body, synchronously executing every spawned process front-to-back (no yield support yet — fine because receive hasn't been wired). BIFs added in `lib/erlang/runtime.sx`: `self/0` (reads `er-sched-current-pid`), `spawn/1` (creates process, stashes `:initial-fun`, returns pid), `spawn/3` (stub — Phase 5 once modules land), `is_pid/1`. Pids added to `er-equal?` (id compare) and `er-type-order` (between strings and tuples); `er-format-value` renders as ``. 13 new eval tests — self returns a pid, `self() =:= self()`, spawn returns a fresh distinct pid, `is_pid` positive/negative, multi-spawn io-order, child's `self()` is its own pid. Total eval 143/143; runtime 39/39; suite 296/296. Next: `!` (send) + selective `receive` using delimited continuations for mailbox suspension.