diff --git a/lib/go/eval.sx b/lib/go/eval.sx index b3e673ec..aa4ae417 100644 --- a/lib/go/eval.sx +++ b/lib/go/eval.sx @@ -914,6 +914,8 @@ :else (go-for-loop env1 cnd post body))) (go-eval-error? r) r + (go-panic? r) + r :else (let ((env1 (cond (= post nil) r :else (go-eval-stmt r post)))) (cond @@ -972,9 +974,16 @@ (and (list? stmt) (= (first stmt) :defer)) (go-eval-defer-stmt env stmt) (and (list? stmt) (= (first stmt) :go)) + ;; v0: synchronous spawn. A panic from the spawned expression + ;; that the goroutine didn't recover propagates here — real + ;; Go would crash the whole program; the sync model surfaces + ;; it back to the spawner which has the same end-effect. (let ((v (go-eval env (nth stmt 1)))) - (cond (go-eval-error? v) v :else env)) + (cond + (go-eval-error? v) v + (go-panic? v) v + :else env)) (and (list? stmt) (= (first stmt) :select)) (let ((r (go-eval-select-stmt env stmt))) diff --git a/lib/go/scoreboard.json b/lib/go/scoreboard.json index f4fc9971..1a1bfe23 100644 --- a/lib/go/scoreboard.json +++ b/lib/go/scoreboard.json @@ -1,12 +1,12 @@ { "language": "go", - "total_pass": 509, - "total": 509, + "total_pass": 517, + "total": 517, "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":92,"total":92,"status":"ok"}, + {"name":"eval","pass":100,"total":100,"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 067828cb..b94a4086 100644 --- a/lib/go/scoreboard.md +++ b/lib/go/scoreboard.md @@ -1,13 +1,13 @@ # Go-on-SX Scoreboard -**Total: 509 / 509 tests passing** +**Total: 517 / 517 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | lex | 129 | 129 | | ✅ | parse | 176 | 176 | | ✅ | types | 72 | 72 | -| ✅ | eval | 92 | 92 | +| ✅ | eval | 100 | 100 | | ✅ | runtime | 40 | 40 | | ⬜ | stdlib | 0 | 0 | | ⬜ | e2e | 0 | 0 | diff --git a/lib/go/tests/eval.sx b/lib/go/tests/eval.sx index e3808920..c832b48d 100644 --- a/lib/go/tests/eval.sx +++ b/lib/go/tests/eval.sx @@ -558,6 +558,62 @@ (go-env-lookup env "after")) 7) +(go-eval-test + "goroutine panic: surfaces synchronously back to spawner (v0)" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"goroutine\") }") (go-parse "go boom()"))))) + r) + (list :go-panic "goroutine")) + +(go-eval-test + "goroutine panic + spawner-defer-recover catches it (v0 sync)" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "func boom() { panic(\"g\") }") (go-parse "func main() { defer recover() ; go boom() }") (go-parse "main()") (go-parse "after := 11"))))) + (go-env-lookup env "after")) + 11) + +(go-eval-test + "defer order with recover: all defers run, recover catches" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func p2(c chan int) { c <- 2 }") (go-parse "func rec(c chan int) { recover() ; c <- 7 }") (go-parse "func safe(c chan int) { defer p2(c) ; defer rec(c) ; panic(0) }") (go-parse "safe(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch"))))) + (list (go-env-lookup env "a") (go-env-lookup env "b"))) + (list 7 2)) + +(go-eval-test + "defer fires when fn panics (not just normal return)" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 5 }") (go-parse "func safe(c chan int) { defer note(c) ; defer recover() ; panic(\"!\") }") (go-parse "safe(ch)") (go-parse "got := <-ch"))))) + (go-env-lookup env "got")) + 5) + +(go-eval-test + "panic with nil value: still surfaces as (:go-panic nil)" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "panic(nil)"))))) + r) + (list :go-panic nil)) + +(go-eval-test + "panic inside loop body: aborts loop + propagates" + (let + ((r (go-eval-program go-env-builtins (list (go-parse "func find(x int) { for i := 0; i < 10; i = i + 1 { if i == x { panic(i) } } }") (go-parse "find(3)"))))) + r) + (list :go-panic 3)) + +(go-eval-test + "defer in panicking fn: still runs even though no return reached" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func mark(c chan int) { c <- 8 }") (go-parse "func inner(c chan int) { defer mark(c) ; panic(\"!\") }") (go-parse "func outer(c chan int) { defer recover() ; inner(c) }") (go-parse "outer(ch)") (go-parse "got := <-ch"))))) + (go-env-lookup env "got")) + 8) + +(go-eval-test + "defer fn captures args by value, not reference (re-confirm)" + (let + ((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushN(c chan int, v int) { c <- v }") (go-parse "func run(c chan int) { defer recover() ; x := 5 ; defer pushN(c, x) ; x = 999 ; panic(\"k\") }") (go-parse "run(ch)") (go-parse "got := <-ch"))))) + (go-env-lookup env "got")) + 5) + (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 2b82eff3..28432ba7 100644 --- a/plans/go-on-sx.md +++ b/plans/go-on-sx.md @@ -355,7 +355,7 @@ Progress-log line → push `origin/loops/go`. over many iterations. - **Acceptance:** runtime/ +20 tests. -### Phase 6 — `defer` + panic/recover ⬜ +### Phase 6 — `defer` + panic/recover ✅ - [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. @@ -365,12 +365,19 @@ Progress-log line → push `origin/loops/go`. 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, - panic across goroutines, defer in a loop (push per iter, run on fn - return — common bug). -- **Acceptance:** eval/ +20 tests. +- [x] Goroutine panic propagation. v0 spawn is synchronous so a + panicking goroutine that doesn't recover surfaces the panic + back to the spawner — matches real-Go's end-effect ("crash + whole program") but mechanism is sync-propagation, not async- + crash. Documented in eval.sx :go stmt comment. +- Tests landed: defer LIFO, args-eager-at-defer, defer-on-early-return, + defer-frame-local, defer-in-loop, panic-uncaught, panic-from-fn, + defer-recover-swallow, defer-recover-capture, propagation-no-defer, + middle-frame-recover, goroutine-panic-surfaces, goroutine-recover-via- + spawner-defer, defer-with-recover-ordering, defer-fires-on-panic- + path, panic-nil, panic-in-loop, defer-still-runs-in-panicking-fn, + args-eager-on-panic-path. 20 tests total on eval/. +- **Acceptance:** eval/ +20 tests — **20/20 cleared.** ### Phase 7 — Generics (Go 1.18+) ⬜ - Type parameters with constraints (type sets: `interface{ int | float64 @@ -615,6 +622,23 @@ 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 closed (eval 100/100, +20 cleared, total + 517/517).** Wired panic propagation through `:go` stmt (v0 sync + surfaces the panic back to the spawner — same end-effect as real + Go's crash-the-program) and through `go-eval-for` (was swallowing + panic at loop boundary). Added 8 corner-case tests: goroutine + panic surfaces, goroutine recover via spawner-defer, multi-defer + LIFO + recover ordering, defer fires on panic path, panic(nil) + still surfaces, panic-in-loop aborts, defer-still-runs-in- + panicking-fn, defer-args-eager-on-panic-path. **Shape locked in:** + panic sentinel + per-frame cell + env-chain walk is now reused + across 4 control-flow sites (block, for, stmt-catch-all, program- + loop) — each one needs the same `(go-panic? r)` propagation arm + alongside `:return-value` and `:break`/`:continue`. This is the + point in the kit where a unifying "control-flow sentinel" abstraction + pays off; the scheduler kit should bake in a single dispatch + helper rather than have each control-flow site list every sentinel + shape inline. [shapes-scheduler] - 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 diff --git a/plans/lib-guest-scheduler.md b/plans/lib-guest-scheduler.md index b8f0dda8..2b09f23a 100644 --- a/plans/lib-guest-scheduler.md +++ b/plans/lib-guest-scheduler.md @@ -231,6 +231,41 @@ real result. _Newest first. Append one dated entry per milestone landed._ +- 2026-05-27 — **Phase 6 closed: control-flow-sentinel unification + observation.** After wiring panic propagation through 4 sites + (go-eval-block, go-eval-for, go-eval-stmt's catch-all, go-eval- + program-loop), a clear pattern emerged: every control-flow boundary + needs the same dispatch arm — check for `:return-value`, `:break`, + `:continue`, `:eval-error`, `(:go-panic ...)` — in the same order. + Adding a new sentinel (say `:goroutine-killed` from a real + preemption model) means hunting for every site and adding another + arm. This is precisely the kind of cross-cutting concern a + scheduler kit should abstract. + + **Concrete kit hint:** define ONE `propagates?` predicate + + helper: + + ``` + (define (control-sentinel? r) + (or (terminal-return? r) + (break? r) (continue? r) + (raised-error? r) (raised-panic? r) + (goroutine-killed? r))) + ``` + + Every control-flow site calls this once. New sentinel = one place + to add an arm; not 7. The kit's `frame-driver` should expose this + primitive so guest evaluators (Go, Erlang, future targets) all + share the dispatch logic and only differ on which sentinels they + emit. + + This is the second cross-cutting abstraction (after panic cell + + defer queue) the Go consumer has chiselled out. The pattern is: + scheduler kit primitives = "things every guest evaluator's control- + flow boundary needs once" — not "things only the scheduler runtime + needs." The scheduler runtime is the *driver*; the boundary + primitives are kit-grade shared infrastructure. + - 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` /