diff --git a/lib/go/eval.sx b/lib/go/eval.sx index f5876627..b3e673ec 100644 --- a/lib/go/eval.sx +++ b/lib/go/eval.sx @@ -24,7 +24,9 @@ (list "print" (list :go-builtin "print")) (list "make" (list :go-builtin "make")) (list "close" (list :go-builtin "close")) - (list "after" (list :go-builtin "after")))) + (list "after" (list :go-builtin "after")) + (list "panic" (list :go-builtin "panic")) + (list "recover" (list :go-builtin "recover")))) (define go-env-lookup @@ -48,6 +50,27 @@ (not (= (len x) 0)) (= (first x) :eval-error)))) +(define + go-panic? + (fn (x) + (and (list? x) (not (= (len x) 0)) (= (first x) :go-panic)))) + +(define + go-find-raised-panic-cell + ;; Env is a list of (NAME VALUE) pairs. Find the first one whose + ;; name is "__go-panic-cell" AND whose state slot is :raised. + ;; Returns the cell (so recover() can mutate it) or nil. + (fn (env) + (cond + (or (= env nil) (= (len env) 0)) nil + :else + (let ((b (first env))) + (cond + (and (= (first b) "__go-panic-cell") + (= (nth (nth b 1) 0) :raised)) + (nth b 1) + :else (go-find-raised-panic-cell (rest env))))))) + ;; ── literal parsing ────────────────────────────────────────────── (define @@ -402,6 +425,27 @@ ;; with-timeout patterns express the intent even though we ;; don't model real time yet. (let ((ch (go-make-chan))) (go-chan-send! ch :tick) ch) + (= name "panic") + ;; Returns a panic sentinel — propagated like :return-value + ;; through statements/blocks; trapped by the enclosing frame + ;; to drain defers, then either consumed by recover() or + ;; re-raised. nil panic value is the implicit "nil panic". + (cond + (not (= (len vals) 1)) + (list :eval-error :builtin-arity name 1 (len vals)) + :else (list :go-panic (first vals))) + (= name "recover") + ;; Walks env chain for the *outermost* panic cell currently + ;; in :raised state — this is the panicking frame's cell, + ;; reached through the deferred-call invocation chain. + ;; Flips it to :recovered, returns V. Returns nil if no + ;; panic is in flight. + (let ((cell (go-find-raised-panic-cell caller-env))) + (cond + (= cell nil) nil + :else + (let ((v (nth cell 1))) + (do (set-nth! cell 0 :recovered) v)))) :else (list :eval-error :unknown-builtin name))))) (define @@ -562,22 +606,37 @@ :else (let ((call-env (go-bind-names caller-env param-names arg-vals))) - ;; Install a fresh defer stack for this call frame. - ;; Mutated by go-eval-defer-stmt via append!; drained - ;; LIFO before the call returns. Replaces any outer - ;; frame's stack (defers are frame-local). - (let ((defer-stack (list))) + ;; Install a fresh defer stack + panic cell for this + ;; frame. Panic cell is (list STATE VALUE): :none if + ;; nothing happened, :raised V if body panicked, + ;; :recovered if a defer called recover() to swallow. + (let ((defer-stack (list)) + (panic-cell (list :none nil))) (let ((frame-env (go-env-extend - call-env "__go-defer-stack" defer-stack))) + (go-env-extend + call-env "__go-defer-stack" defer-stack) + "__go-panic-cell" panic-cell))) (cond (= body nil) (do (go-run-defers! frame-env defer-stack) nil) (and (list? body) (= (first body) :block)) (let ((r (go-eval-block frame-env (nth body 1)))) (do + ;; If body panicked, stash value before + ;; defers run so recover() can see it. + (cond + (go-panic? r) + (do (set-nth! panic-cell 0 :raised) + (set-nth! panic-cell 1 (nth r 1))) + :else nil) (go-run-defers! frame-env defer-stack) (cond + ;; Recover called during defers — swallow. + (= (nth panic-cell 0) :recovered) nil + ;; Still raised after defers — propagate. + (= (nth panic-cell 0) :raised) + (list :go-panic (nth panic-cell 1)) (and (list? r) (= (first r) :return-value)) (nth r 1) (go-eval-error? r) r @@ -931,7 +990,7 @@ :else r)) (and (list? stmt) (= (first stmt) :range-for)) (go-eval-range-for env stmt) - :else (let ((v (go-eval env stmt))) (cond (go-eval-error? v) v :else env))))) + :else (let ((v (go-eval env stmt))) (cond (go-eval-error? v) v (go-panic? v) v :else env))))) (define go-select-try-case @@ -1313,21 +1372,36 @@ r (go-eval-error? r) r + (go-panic? r) + r :else (go-eval-block r (rest stmts))))))) (define go-eval-program - ;; Top-level driver. The "implicit main frame" gets its own defer - ;; stack so `defer` at top level (which is what most runtime tests - ;; use) behaves like deferring in main. The stack is drained after - ;; all forms run. + ;; Top-level driver = implicit main frame. Gets its own defer stack + ;; and panic cell so `defer` and `recover()` at top level behave + ;; like inside main(). Panic that escapes top-level surfaces as + ;; the program's return value (tests use this to assert uncaught + ;; panics). (fn (env forms) - (let ((defer-stack (list))) - (let ((env (go-env-extend env "__go-defer-stack" defer-stack))) + (let ((defer-stack (list)) + (panic-cell (list :none nil))) + (let ((env (go-env-extend + (go-env-extend env "__go-defer-stack" defer-stack) + "__go-panic-cell" panic-cell))) (let ((r (go-eval-program-loop env forms))) (do + (cond + (go-panic? r) + (do (set-nth! panic-cell 0 :raised) + (set-nth! panic-cell 1 (nth r 1))) + :else nil) (go-run-defers! env defer-stack) - r)))))) + (cond + (= (nth panic-cell 0) :recovered) env + (= (nth panic-cell 0) :raised) + (list :go-panic (nth panic-cell 1)) + :else r))))))) (define go-eval-program-loop @@ -1343,6 +1417,8 @@ r (go-eval-error? r) r + (go-panic? r) + r :else (go-eval-program-loop r (rest forms))))))) (define diff --git a/lib/go/scoreboard.json b/lib/go/scoreboard.json index 7fa980ef..f4fc9971 100644 --- a/lib/go/scoreboard.json +++ b/lib/go/scoreboard.json @@ -1,12 +1,12 @@ { "language": "go", - "total_pass": 503, - "total": 503, + "total_pass": 509, + "total": 509, "suites": [ {"name":"lex","pass":129,"total":129,"status":"ok"}, {"name":"parse","pass":176,"total":176,"status":"ok"}, {"name":"types","pass":72,"total":72,"status":"ok"}, - {"name":"eval","pass":86,"total":86,"status":"ok"}, + {"name":"eval","pass":92,"total":92,"status":"ok"}, {"name":"runtime","pass":40,"total":40,"status":"ok"}, {"name":"stdlib","pass":0,"total":0,"status":"pending"}, {"name":"e2e","pass":0,"total":0,"status":"pending"} diff --git a/lib/go/scoreboard.md b/lib/go/scoreboard.md index aaa9579f..067828cb 100644 --- a/lib/go/scoreboard.md +++ b/lib/go/scoreboard.md @@ -1,13 +1,13 @@ # Go-on-SX Scoreboard -**Total: 503 / 503 tests passing** +**Total: 509 / 509 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | lex | 129 | 129 | | ✅ | parse | 176 | 176 | | ✅ | types | 72 | 72 | -| ✅ | eval | 86 | 86 | +| ✅ | eval | 92 | 92 | | ✅ | runtime | 40 | 40 | | ⬜ | stdlib | 0 | 0 | | ⬜ | e2e | 0 | 0 | diff --git a/lib/go/tests/eval.sx b/lib/go/tests/eval.sx index b696ee6d..e3808920 100644 --- a/lib/go/tests/eval.sx +++ b/lib/go/tests/eval.sx @@ -516,6 +516,48 @@ (go-env-lookup env "e"))) (list 3 2 1 0)) +(go-eval-test + "panic: uncaught panic surfaces as (:go-panic V) from program" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "panic(\"boom\")"))))) + r) + (list :go-panic "boom")) + +(go-eval-test + "panic inside fn: surfaces from fn call too" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"oops\") }") (go-parse "boom()"))))) + r) + (list :go-panic "oops")) + +(go-eval-test + "recover: deferred recover swallows panic, fn returns normally" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "func safe() { defer recover() ; panic(\"x\") }") (go-parse "safe()") (go-parse "after := 42"))))) + (go-env-lookup env "after")) + 42) + +(go-eval-test + "recover: deferred recover captures the panic value" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func grab(c chan int) { r := recover() ; c <- r }") (go-parse "func safe(c chan int) { defer grab(c) ; panic(99) }") (go-parse "safe(ch)") (go-parse "got := <-ch"))))) + (go-env-lookup env "got")) + 99) + +(go-eval-test + "panic: propagates through intermediate frames without defers" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { middle() }") (go-parse "outer()"))))) + r) + (list :go-panic "deep")) + +(go-eval-test + "recover: middle-frame defer catches panic from deeper frame" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "func inner() { panic(\"deep\") }") (go-parse "func middle() { inner() }") (go-parse "func outer() { defer recover() ; middle() }") (go-parse "outer()") (go-parse "after := 7"))))) + (go-env-lookup env "after")) + 7) + (define go-eval-test-summary (str "eval " go-eval-test-pass "/" go-eval-test-count)) diff --git a/plans/go-on-sx.md b/plans/go-on-sx.md index 95b8d24a..2b82eff3 100644 --- a/plans/go-on-sx.md +++ b/plans/go-on-sx.md @@ -359,8 +359,12 @@ Progress-log line → push `origin/loops/go`. - [x] Defer stack per function frame; runs LIFO on normal return. Args eager at defer-time; frame-local (inner defers don't run outer ones); defer-in-loop pushes each iteration. 6 tests. -- [ ] `panic(v)` unwinds frames running deferreds; `recover()` inside a - deferred fn captures the panic value and stops unwinding. +- [x] `panic(v)` unwinds frames running deferreds; `recover()` inside a + deferred fn captures the panic value and stops unwinding. Panic + sentinel `(:go-panic V)` propagates through go-eval-block / + go-eval-stmt / go-eval-program-loop. Per-frame panic cell + `(STATE V)` flips :none → :raised → :recovered; recover walks + env chain finding the outermost :raised cell. 6 tests on eval/. - Goroutine panic propagation: a panicking goroutine that doesn't recover crashes the whole program (honour Go spec, or document divergence). - Tests: defer order (LIFO), defer + named-return mutation, panic/recover, @@ -611,6 +615,24 @@ Minimal repro: see `lib/go/lex.sx#gl-oct-digit?` and `#gl-match-op`. _Newest first. Append one dated entry per commit._ +- 2026-05-27 — **Phase 6: panic + recover.** `panic` and `recover` + builtins. Panic sentinel `(:go-panic V)` propagates like + `:return-value` through stmt/block/program-loop. Each call frame + gets its own `__go-panic-cell` of shape `(STATE V)`; on body panic + the cell flips to `:raised V` BEFORE defers drain so `recover()` + can find it. `recover` walks the env chain looking for the + outermost `:raised` cell (so deferred calls invoked from a + panicking frame still see that frame's cell despite their own + nested cell shadowing it). Flips to `:recovered` and returns V; + the panicking frame then returns normally instead of propagating. + 6 tests: uncaught surfaces from program, panic from fn surfaces, + defer-recover swallows, defer-recover captures value via channel, + propagation through 3-deep no-defer chain, middle frame catches + panic from deeper. 509/509 total. **Shape:** the panic cell is + a *frame-attached out-of-band channel* that survives across the + function boundary because env-chain lookup can walk past + shadowed bindings to find it. Same primitive will serve as the + scheduler kit's "cleanup-with-error-context" hook. [shapes-scheduler] - 2026-05-27 — **Phase 6 first slice: defer + LIFO.** Added `go-eval-defer-stmt`, `go-run-defers!`, `go-run-defers-prefix!`, plus new `:quoted-value` AST node so deferred calls can be diff --git a/plans/lib-guest-scheduler.md b/plans/lib-guest-scheduler.md index 44d89e02..b8f0dda8 100644 --- a/plans/lib-guest-scheduler.md +++ b/plans/lib-guest-scheduler.md @@ -231,6 +231,40 @@ real result. _Newest first. Append one dated entry per milestone landed._ +- 2026-05-27 — **Phase 6: panic/recover shape lands.** The panic + cell is the missing piece. It's a per-frame mutable record of + shape `(STATE VALUE)` carrying one of `:none` / `:raised` / + `:recovered`. Three properties matter for the scheduler kit: + + 1. **It survives the function boundary** via env-chain lookup — + when a deferred call's own frame creates a shadowing cell, + `recover()` walks past it to find the OUTER frame's cell (the + one that's `:raised`). This is the same mechanism the + scheduler will need when a panic-unwinding goroutine has + multiple frames each carrying their own state, and the + "current panic" must be locatable from any depth. + + 2. **It flips state in place** (`set-nth!`) so that the change + made by `recover()` deep in a defer chain is visible to the + enclosing frame's exit check. The scheduler kit needs the + same pattern: a goroutine's "termination reason" must be + writable by any frame in its stack. + + 3. **It's distinct from the return-value channel.** A frame can + carry both `(:go-panic V)` from its body AND a recovery + commitment in its panic cell; they're checked in sequence. + For the scheduler this maps to: a goroutine carries both its + running-state (channel-blocked, ready, sleeping) AND its + termination-record (panic V / clean exit / killed) — two + orthogonal slots, not one tag. + + Concrete kit hint: every frame record should expose + `frame-panic-cell` alongside `frame-defer-queue`. The scheduler's + exit-path becomes: drain defers (cell may flip :raised→:recovered) + → consult cell → either propagate or return clean. Erlang's + `try/catch/after` decomposes identically: `after` is the defer + queue, `catch` is the recover-via-cell mechanism. + - 2026-05-27 — **Phase 6 first slice: defer + LIFO observation.** Go's defer is a *frame-local cleanup queue* — a list of (callee, pre-evaluated-args) records appended on `defer`, drained LIFO at