go: defer + LIFO drain → eval 86/86, total 503/503 [shapes-scheduler]
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s

Phase 6 first slice. New :defer stmt dispatch, go-eval-defer-stmt
captures (callee, eagerly-evaluated args) onto a frame-local
__go-defer-stack mutable list. go-eval-call installs the stack and
drains LIFO before returning; go-eval-program does the same for
the implicit main frame. New :quoted-value AST node lets defer
re-invoke calls with the frozen arg values.

6 eval tests: single defer, multi-LIFO, args-eager-at-defer-time,
fires-on-early-return, frame-local (no bleed to outer), defer-in-loop.

Shape: defer is a per-frame cleanup queue (LIFO on frame exit) that
the scheduler kit will reuse for panic-unwind + clean-exit + select-
case-rollback paths. Distinct from the scheduler's ready-queue —
diary updated to keep that distinction explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:00:37 +00:00
parent 1d3021d206
commit 219e2fcfe7
6 changed files with 701 additions and 394 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{ {
"language": "go", "language": "go",
"total_pass": 497, "total_pass": 503,
"total": 497, "total": 503,
"suites": [ "suites": [
{"name":"lex","pass":129,"total":129,"status":"ok"}, {"name":"lex","pass":129,"total":129,"status":"ok"},
{"name":"parse","pass":176,"total":176,"status":"ok"}, {"name":"parse","pass":176,"total":176,"status":"ok"},
{"name":"types","pass":72,"total":72,"status":"ok"}, {"name":"types","pass":72,"total":72,"status":"ok"},
{"name":"eval","pass":80,"total":80,"status":"ok"}, {"name":"eval","pass":86,"total":86,"status":"ok"},
{"name":"runtime","pass":40,"total":40,"status":"ok"}, {"name":"runtime","pass":40,"total":40,"status":"ok"},
{"name":"stdlib","pass":0,"total":0,"status":"pending"}, {"name":"stdlib","pass":0,"total":0,"status":"pending"},
{"name":"e2e","pass":0,"total":0,"status":"pending"} {"name":"e2e","pass":0,"total":0,"status":"pending"}

View File

@@ -1,13 +1,13 @@
# Go-on-SX Scoreboard # Go-on-SX Scoreboard
**Total: 497 / 497 tests passing** **Total: 503 / 503 tests passing**
| | Suite | Pass | Total | | | Suite | Pass | Total |
|---|---|---|---| |---|---|---|---|
| ✅ | lex | 129 | 129 | | ✅ | lex | 129 | 129 |
| ✅ | parse | 176 | 176 | | ✅ | parse | 176 | 176 |
| ✅ | types | 72 | 72 | | ✅ | types | 72 | 72 |
| ✅ | eval | 80 | 80 | | ✅ | eval | 86 | 86 |
| ✅ | runtime | 40 | 40 | | ✅ | runtime | 40 | 40 |
| ⬜ | stdlib | 0 | 0 | | ⬜ | stdlib | 0 | 0 |
| ⬜ | e2e | 0 | 0 | | ⬜ | e2e | 0 | 0 |

View File

@@ -467,6 +467,55 @@
(go-eval env (go-parse "find(nums, 99)"))) (go-eval env (go-parse "find(nums, 99)")))
-1) -1)
(go-eval-test
"defer: single defer runs after surrounding fn body returns"
(let
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func run(c chan int) { defer push2(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "first := <-ch") (go-parse "second := <-ch")))))
(list (go-env-lookup env "first") (go-env-lookup env "second")))
(list 1 2))
(go-eval-test
"defer: multiple defers run LIFO"
(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 p3(c chan int) { c <- 3 }") (go-parse "func run(c chan int) { defer p2(c) ; defer p3(c) ; c <- 1 }") (go-parse "run(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch")))))
(list
(go-env-lookup env "a")
(go-env-lookup env "b")
(go-env-lookup env "d")))
(list 1 3 2))
(go-eval-test
"defer: arguments are evaluated at defer-time (not call-time)"
(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) { x := 7 ; defer pushN(c, x) ; x = 99 }") (go-parse "run(ch)") (go-parse "got := <-ch")))))
(go-env-lookup env "got"))
7)
(go-eval-test
"defer: runs even when fn returns early via return"
(let
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func note(c chan int) { c <- 42 }") (go-parse "func run(c chan int) int { defer note(c) ; return 1 }") (go-parse "r := run(ch)") (go-parse "n := <-ch")))))
(list (go-env-lookup env "r") (go-env-lookup env "n")))
(list 1 42))
(go-eval-test
"defer: stack is frame-local — outer defers don't run on inner return"
(let
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func push1(c chan int) { c <- 1 }") (go-parse "func push2(c chan int) { c <- 2 }") (go-parse "func inner(c chan int) { defer push2(c) }") (go-parse "func outer(c chan int) { defer push1(c) ; inner(c) }") (go-parse "outer(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch")))))
(list (go-env-lookup env "a") (go-env-lookup env "b")))
(list 2 1))
(go-eval-test
"defer: in a loop, all defers fire on fn return (not loop iter)"
(let
((env (go-eval-program go-env-builtins (list (go-parse "ch := make()") (go-parse "func pushI(c chan int, v int) { c <- v }") (go-parse "func loop(c chan int) { for i := 0; i < 4; i = i + 1 { defer pushI(c, i) } }") (go-parse "loop(ch)") (go-parse "a := <-ch") (go-parse "b := <-ch") (go-parse "d := <-ch") (go-parse "e := <-ch")))))
(list
(go-env-lookup env "a")
(go-env-lookup env "b")
(go-env-lookup env "d")
(go-env-lookup env "e")))
(list 3 2 1 0))
(define (define
go-eval-test-summary go-eval-test-summary
(str "eval " go-eval-test-pass "/" go-eval-test-count)) (str "eval " go-eval-test-pass "/" go-eval-test-count))

View File

@@ -356,9 +356,11 @@ Progress-log line → push `origin/loops/go`.
- **Acceptance:** runtime/ +20 tests. - **Acceptance:** runtime/ +20 tests.
### Phase 6 — `defer` + panic/recover ⬜ ### Phase 6 — `defer` + panic/recover ⬜
- Defer stack per function frame; runs LIFO on return (normal or panic). - [x] Defer stack per function frame; runs LIFO on normal return.
- `panic(v)` unwinds frames running deferreds; `recover()` inside a Args eager at defer-time; frame-local (inner defers don't run
deferred fn captures the panic value and stops unwinding. 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.
- Goroutine panic propagation: a panicking goroutine that doesn't recover - Goroutine panic propagation: a panicking goroutine that doesn't recover
crashes the whole program (honour Go spec, or document divergence). crashes the whole program (honour Go spec, or document divergence).
- Tests: defer order (LIFO), defer + named-return mutation, panic/recover, - Tests: defer order (LIFO), defer + named-return mutation, panic/recover,
@@ -609,6 +611,21 @@ Minimal repro: see `lib/go/lex.sx#gl-oct-digit?` and `#gl-match-op`.
_Newest first. Append one dated entry per commit._ _Newest first. Append one dated entry per commit._
- 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
re-invoked with values captured at defer-time. Frame: `go-eval-call`
installs a fresh `__go-defer-stack` (mutable list) in the call env,
drains LIFO before returning. `go-eval-program` does the same for
the implicit main frame. 6 tests on eval/: single defer,
multi-defer LIFO, args eager at defer-time, defer fires on early
return, frame-local stack (inner defers don't bleed to outer),
defer-in-loop (all iterations defer to fn return). 503/503 total.
**Shape:** SX assignment shadows rather than mutates, so the
natural defer side-effect channel is the *channel buffer* — shared
via closure identity. Drove the test design and matches the eventual
panic/recover shape (errors will need to escape through a similar
out-of-band mechanism, not through env mutation). [shapes-scheduler]
- 2026-05-27 — **Phase 5 acceptance bar hit (40/40 runtime, 497/497 - 2026-05-27 — **Phase 5 acceptance bar hit (40/40 runtime, 497/497
total).** Added `after(d)` builtin (v0 timer stub: returns a channel total).** Added `after(d)` builtin (v0 timer stub: returns a channel
already buffered with `:tick`) and 13 canonical-pattern tests: already buffered with `:tick`) and 13 canonical-pattern tests:

View File

@@ -231,6 +231,42 @@ real result.
_Newest first. Append one dated entry per milestone landed._ _Newest first. Append one dated entry per milestone landed._
- 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
frame exit. The scheduler kit needs the same shape because: (a) a
panicking goroutine must run its frame's defers before unwinding to
the next frame; (b) a goroutine that exits cleanly still runs them;
(c) `select` cases that own resources (an acquired send slot, a
buffer reservation) need a cleanup hook on the case-not-taken path.
All three reduce to the same primitive: **"hand the frame a list
of thunks; call them LIFO before the frame is gone."**
Concretely the kit should expose `frame-defer!` (push) and an
internal `frame-teardown!` (drained by the scheduler on exit / by
the panic unwinder on abort). The scheduler's exit-path becomes:
1. Mark frame done.
2. Call `frame-teardown!` — run defers LIFO. A defer that itself
panics: capture the new panic, continue running the rest
(matches Go spec).
3. Release frame slot.
Crucially the defer queue is *not* the same as the scheduler's
ready-queue — confusing the two was an early temptation. The defer
queue is per-frame and synchronous-on-exit; the ready-queue is
global and async. Phase 5b will need to keep these distinct when
real preemption lands.
Test signal that drove the shape: SX assignment shadows rather than
mutates, so the only observable side-effect channel for deferred
calls is `(append! buf ...)` on a value with stable identity (e.g.
a channel). That maps cleanly to "deferred work emits its effects
through capabilities the frame held, not through enclosing-env
mutation" — which is also how the scheduler kit's deferred work
should communicate with the rest of the system. No magic; just
capabilities the frame can hand to its defers.
- 2026-05-27 — **Phase 5 acceptance crossed (40 runtime tests).** - 2026-05-27 — **Phase 5 acceptance crossed (40 runtime tests).**
Final shape observation: *time-as-readiness-flip*. The Go side Final shape observation: *time-as-readiness-flip*. The Go side
added an `after(d)` builtin that returns a channel **already added an `after(d)` builtin that returns a channel **already