Repository navigation
ci: build & test the bootstrap, then build & run every MVP example - #1
Merged
Merged
Conversation
A GitHub Actions workflow mirroring the bootstrap -> MVP flow end to end:
1. build the Go-hosted bootstrap driver (make -C src/bootstrap build)
2. check it works (make -C src/bootstrap test — the Go suite)
3. build every examples/*.zg WITH that bootstrap (bin/zerg build)
4. check each is workable — linked to a native binary and run, failing the
job on any non-zero build or run
Runs on a ubuntu-latest (x86_64) + macos-latest (arm64) matrix, so the portable
POSIX-C runtime and both coroutine context-switch asm variants are exercised on
the same C codegen target Zerg cross-compiles through.
The test-data submodule (golden corpora) is declared with an SSH URL; CI fetches
it over HTTPS best-effort — the golden tests t.Skip when it is absent, so a
fetch failure narrows coverage without failing the build. Validated with
actionlint and by running the example build/run loop locally (14/14 clean).
pad_field always returns a fresh string cell (a dup_n copy when no padding is
needed, a padded buffer otherwise) and never frees its `body` argument, so a
caller that passes a HEAP body leaked it. zrt_fmt_int / zrt_fmt_float pass a
stack buffer (no leak), but zrt_fmt_str (dup_n) and zrt_fmt_uint's overflow path
(zrt_display_uint) pass a fresh cell — each now releases it after pad_field.
Surfaced by LeakSanitizer on Linux (TestFStrFormatSpec's `{s:>6}` hole); macOS
AddressSanitizer ships no LSan, so it was invisible there.
`str(bytes)` / `str(runes)` built a str from a list the runtime only read, leaving
the caller to drop it "as usual" — but a TEMPORARY (rvalue) list argument has no
owner to drop it, and on the EncodingError raise path an unwind would skip any
post-call drop anyway. Both leaked under LeakSanitizer (TestStrFromBytesValidates,
TestStrScanLexerRuns); macOS ASan has no LSan, so they were invisible.
Flip the ownership: zrt_str_from_bytes / zrt_str_from_runes now CONSUME (drop) the
input list on both the success and the raise path. The emit hands them an OWNED
list — a fresh rvalue goes straight over, a named list passes a deep copy so its
own scope-exit drop stays balanced. A raise can no longer skip a drop that no
longer exists.
TestAsmAddCompileAndRun and TestUnsafeConformanceCorpus compile-and-run AArch64
inline assembly (the `add %0, %1, %2` three-operand form, and testdata/1h_unsafe),
which assembles to an illegal instruction on x86_64 — a SIGILL on the ubuntu CI
runner. Skip these run-tests off arm64; the arch-independent lowering-SHAPE tests
still validate the codegen everywhere (the C path itself is portable — only the
inline-asm text is arch-specific).
A program whose closures capture ONLY POD (e.g. an int) leaks each closure's heap
env by design: the managed/refcounted-env path is a program-wide mode that turns
on only when some closure captures a NON-POD value, so an all-POD-capture program
plain zrt_alloc's its envs and never frees them — the staged closure "it.3 capture"
teardown. The three all-POD-capture run-tests pin closure BEHAVIOUR, so a shared
tolerateClosureEnvLeak helper sets detect_leaks=0 for their child under LSan while
the teardown stays deferred; LSan stays active for every other test. Remove once
POD closure envs are managed.
cmj0121
added a commit
that referenced
this pull request
Jul 31, 2026
…s not read a dead stack
CI's sanitizer job — running for the first time in this project's life, one run in
several hundred:
AddressSanitizer: SEGV on unknown address, READ
#0 wq_pop chan.c:91
#1 chan_close chan.c:207
#2 zrt_chan_sender_release chan.c:250
#3 zg_spawndrop_1 conc_actor.c:59
#4 zrt_unwind_to unwind.c:86
The crash is in the close path and the fault is a long way from it. A receiver parks
by pushing a `zrt_waiter` that lives ON ITS OWN STACK onto the channel's recvq. Three
things can wake it: a close, which POPS every waiter on its way past; a sender's
wq_take, which POPS the one it hands to; and a SPURIOUS wake, which pops nothing.
After the third, the loop went back to the top with the waiter still on the queue —
and two of the paths there RETURN, a buffered value and a parked sender to take one
from. So a receiver could leave cleanly while the channel still held a pointer into
its stack frame. Whenever the last sender then went away, chan_close walked recvq and
read a `zrt_waiter` out of a stack that no longer existed.
The send path has always had the unlink, with a comment saying why. This is its mirror,
and `wq_remove` is a no-op when the waiter is not on the queue — which is the ordinary
case — so it costs a walk of a queue that is almost always empty.
NOT REPRODUCED LOCALLY, and worth saying plainly: 400 runs on arm64, 80 under x86_64
emulation, and 1400 across the whole concurrency corpus after the fix, all clean. The
evidence is the stack trace, the asymmetry with the send path, and the two return
paths that are reachable with the waiter linked. What the runs prove is no regression.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
.github/workflows/ci.yml— the first CI for the toolchain — plus thecross-platform fixes it surfaced on its first Linux run.
The workflow mirrors the bootstrap → MVP flow end to end, run per PR (and on
mainpost-merge), on aubuntu-latest(x86_64) +macos-latest(arm64) matrix:make -C src/bootstrap build→bin/zergmake -C src/bootstrap testbin/zerg buildon eachCross-platform fixes it caught
macOS AddressSanitizer ships no LeakSanitizer, so leaks were invisible in local
dev; Linux CI ran LSan for the first time and found real ones, plus arch-specific asm:
fix(fmt)—zrt_fmt_str/zrt_fmt_uintleaked thedup_nintermediate(
pad_fieldnever frees itsbody).fix(str)—str(list)now consumes its argument, so a temporary listnever leaks — including on the
EncodingErrorraise path an unwind would skip.test(asm)— the AArch64 inline-asm run corpora are gated to arm64 (they SIGILLon x86_64); the arch-independent lowering-shape tests still run everywhere.
test(capture)— an all-POD-capture program leaks its closure env by design(the managed-env mode needs a non-POD capture; the teardown is the deferred "it.3
capture" work); the three affected run-tests tolerate that one leak under LSan while
LSan stays active everywhere else.
Verified green on both a Linux/LSan container run (
go test ./...) and locally onmacOS, and the arm64/x86_64 asm gating confirmed on both platforms.
🤖 Generated with Claude Code
https://claude.ai/code/session_0179sqwtCFKVnaqVoKL59qQ5