Skip to content

ci: build & test the bootstrap, then build & run every MVP example - #1

Merged
cmj0121 merged 5 commits into
mainfrom
feat/ci-github-actions
Jul 25, 2026
Merged

cmj0121 merged 5 commits into
mainfrom
feat/ci-github-actions

Conversation

@cmj0121

@cmj0121 cmj0121 commented Jul 25, 2026 •

Copy link
Copy Markdown
Owner

What

Adds .github/workflows/ci.yml — the first CI for the toolchain — plus the
cross-platform fixes it surfaced on its first Linux run.

The workflow mirrors the bootstrap → MVP flow end to end, run per PR (and on
main post-merge), on a ubuntu-latest (x86_64) + macos-latest (arm64) matrix:

  1. Build the bootstrap — make -C src/bootstrap build → bin/zerg
  2. Check the bootstrap works — make -C src/bootstrap test
  3. Build the MVP examples with that bootstrap — bin/zerg build on each
  4. Check every example is workable — link to a native binary and run it

Cross-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_uint leaked the dup_n intermediate
    (pad_field never frees its body).
  • fix(str) — str(list) now consumes its argument, so a temporary list
    never leaks — including on the EncodingError raise path an unwind would skip.
  • test(asm) — the AArch64 inline-asm run corpora are gated to arm64 (they SIGILL
    on 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 on
macOS, and the arm64/x86_64 asm gating confirmed on both platforms.

🤖 Generated with Claude Code

https://claude.ai/code/session_0179sqwtCFKVnaqVoKL59qQ5

cmj0121 added 5 commits July 25, 2026 11:54
    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
cmj0121 merged commit f641862 into main Jul 25, 2026
2 checks passed
@cmj0121
cmj0121 deleted the feat/ci-github-actions branch July 25, 2026 07:46
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant