Skip to content

internal/vm, ci: fix the js/wasm build, and make the guard that caught it reachable - #684

Merged
tannevaled merged 8 commits into
mainfrom
wt35/jswasm-errno-ci
Sep 26, 2026
Merged

tannevaled merged 8 commits into
mainfrom
wt35/jswasm-errno-ci

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #682.

Part 1 — the build

errno.go referred to syscall.ENOTRECOVERABLE, syscall.EOWNERDEAD and
syscall.ETXTBSY, which Go's js/wasm syscall package does not define. wasip1
defines all three, which is why the existing wasm lane never saw it.

The file already had the rule this needed. MRI's Init_syserr treats a
known_errors name the platform does not define as a constant naming
Errno::NOERROR (undefined_error), and errno.go reproduced that in three
buckets. The three names were simply in the wrong bucket on one target. They
now live in errnoPlatformNames, with numbers (or their absence) supplied per
target by errno_native.go / errno_wasm.go — the same !(js && wasm) /
(js && wasm) file pair this package already uses for jsbridge and
servergems. No number is hard-coded: the browser has no MRI to witness one
against, and the rule is that a name without a number is a NOERROR constant,
not a name with a number we invented.

The bucket rule is now stated once, at the top of errno.go, together with the
invariant it protects: the buckets partition known_errors on every target, so a
name that leaves one bucket must arrive in another or Errno.constants silently
shrinks.

The enumeration — per-target constant availability

I enumerated rather than fixing the three names I was handed. A probe generates a
package referencing every name in the table, compiles it with -gcflags=-e (report
all errors, not the first ten) for each target, and reads the
undefined: syscall.X lines back. The name list is parsed out of errno.go
itself, so the probe cannot drift from the table it judges.

Pass 1 — the 76 syscall-sourced names errnoNumbers claimed every target
defines
(its 77th entry, NOERROR, is the literal 0):

target missing
darwin/amd64, darwin/arm64 none
linux/amd64, linux/386, linux/arm64, linux/riscv64, linux/loong64, linux/ppc64le, linux/s390x none
windows/amd64, windows/arm64 none
wasip1/wasm none
js/wasm ENOTRECOVERABLE, EOWNERDEAD, ETXTBSY

So the answer to "are there others?" is no — js/wasm is the only target that
breaks the premise, and those are exactly the three. Twelve targets where nothing
was wrong is part of the result.

Pass 2 — the 81 names the table assumes are absent everywhere. This one
contradicts the file's own comment, which said Go "exposes the rest only under a
build tag, so from rbgo's point of view they are undefined names". They are not
behind a build tag; they are plainly available per-GOOS:

target of the 81 "undefined" names, actually DEFINED
linux/arm64, riscv64, loong64, ppc64le, s390x 58
linux/amd64, linux/386 57
windows/amd64, windows/arm64 56
js/wasm 45
darwin/arm64 32
darwin/amd64 31
wasip1/wasm 2 (EBADFD, ENOTCAPABLE)

rbgo reports 0 for all of them. That is a declared conformance gap, not a
bug introduced here — but it is much larger than the one macOS EAUTH at 80 the
comment named, and closing it needs a per-platform MRI witness per number. The
comment is corrected to say what is true; the gap is left to the open per-platform
errno issue. EWOULDBLOCK is the one genuinely per-platform alias, and the
probe shows why: wasip1 is the only target that lacks it, js/wasm included.

Two incidental findings from the same sweep:

  • linux/386 does not build, for a reason unrelated to errno:
    go-ruby-chronic/chronic@v0.0.0-20260916091614 has
    1_000_000_000_000 in a struct literal typed int, which overflows on a 32-bit
    target. Not in release.yml's target list, so not a supported target — flagged,
    not touched (it is a dependency, outside this cluster).
  • syscall.EWOULDBLOCK and syscall.EAGAIN are separately defined on every
    target but wasip1, so errnoAliases is load-bearing for exactly one target.

Part 2 — the guard that could not run

The js/wasm lane

GOOS=js and GOOS=wasip1 are different targets with different syscall
packages. The wasm (wasip1) lane stayed green for three days while the browser
target did not compile at all: one wasm lane is not wasm coverage. The new
wasm (js, browser) lane builds ./... — not just ./cmd/wasm, because the break
was in internal/vm, which the playground reaches only transitively — vets, builds
the playground module, and then runs it.

web/smoke.mjs loads the module into node with Go's own lib/wasm/wasm_exec.js,
the same glue web/index.html loads in a browser, waits for main() to publish
its entry points, and calls rbgoEval: arithmetic and $stdout capture, a rescued
Errno::ENOENT, a syntax error coming back as an error string rather than a crash,
and both halves of the bucket rule live in the browser runtime —
Errno::ENOENT::Errno is 2, Errno::ETXTBSY is Errno::NOERROR here, and
Errno.constants.size is 158. A compile is not a witness for a runtime target.

The lane passed on a GitHub ubuntu-latest runner, all nine smoke checks, with
the bucket rule witnessed inside a JS host on Linux:

  ok   stdout of `puts (1..10).sum`: "55\n"
  ok   Errno::ENOENT::Errno: "2"
  ok   Errno::ETXTBSY::Errno on js/wasm (no number here): "0"
  ok   Errno::ETXTBSY is Errno::NOERROR on js/wasm: "true"
  ok   Errno.constants.size (MRI known_errors): "158"
  ok   a rescued open of a missing path names its class: "Errno::ENOENT\n"
  ok   a syntax error is reported, not fatal: true
smoke: all checks passed

That is the half of #682 no compile-only check could have reached, and it is now
reached on the platform CI runs on rather than only on my laptop.

Proof the lane is a gate

CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./... run in a detached worktree at
origin/main (2aa1c94, the commit this branched from, unmodified) fails with:

internal/vm/errno.go:101:35: undefined: syscall.ENOTRECOVERABLE
internal/vm/errno.go:108:35: undefined: syscall.EOWNERDEAD
internal/vm/errno.go:120:35: undefined: syscall.ETXTBSY

A gate nobody has seen fail is not known to be a gate.

The RBGO_BUILD_IT decision: wire it in

Choice: wire it in, in its own lane. The three tests behind it cover what
nothing else does — the nested go build the rbgo command performs, that
--closed really drops the front-end, and that a baked program executes. Deleting
the claim would have thrown that away; leaving it as it was is documentation
pretending to be a check.

Measured cost (cold GOCACHE, M-series host; a GitHub runner is slower):

step wall artefact
go build ./... (js/wasm) 14s
go vet ./cmd/wasm ./cmd/rbgo ./internal/vm (js/wasm) 18s
./cmd/wasm module 96.9 MB
./cmd/rbgo module 98.4 MB
warm rebuild of the module 1s
all three RBGO_BUILD_IT tests 35s

And now measured on a runner, not extrapolated. The build-integration lane
ran on ubuntu-latest: 141s of test time, 5.3 min for the whole job. The
honest frame is not "cheap in the abstract" but where it lands among lanes this
file already pays for, on the same commit:

lane runner time
wasm (js, browser) 1.9 min new
wasm (wasip1) 2.0 min existing
rbgo build integration 5.3 min new
arch arm64 5.9 min existing
arch amd64 9.4 min existing
test (…) with -race + coverage 20–90 min existing

Both figures are from ubuntu-latest, not extrapolated. The js/wasm lane is the
cheapest lane in the file, and the integration lane costs less than arch arm64, which is already on every PR. The runner is 4.0× slower than my host
(141s vs 35s) — the number to reuse next time someone extrapolates CI cost from a
laptop. Keeping the integration tests in their own lane is about staying off the
coverage lane's critical path, not about price.

What turning it on revealed — and the fix for it

Turning the env var on exposed a pre-existing defect it had been hiding, on this
branch and identically on origin/main:

closed_build_test.go:56: closed binary still links the front-end (go-ruby-parser/parser)

rbgo build --closed printed "closed-world — front-end dropped (no
lexer/parser/compiler linked)"
and then linked the parser anyway. Cause, one edge:
internal/vm/rubocop.go and rubocop_bind.go carried no build tags, and
github.com/go-ruby-rubocop/rubocop imports the parser — a linter needs an AST to
inspect — so the front-end re-entered the closed dependency graph through the
RuboCop binding.

Fixed by dropping the binding from closed builds, on the reasoning that
--closed already promises no front-end and a lint needs a parser, so a linter has
no business in a binary that claims to have dropped one. There is no useful subset
of RuboCop to keep, so this takes the LoadError degradation of
backends_wasm.go, not the graceful-module one of bindings_wasm.go.

file
rubocop.go, rubocop_bind.go //go:build !rbgo_closed
rubocop_test.go, rubocop_internal_test.go the same — they assert the open build's behaviour
rubocop_closed.go (rbgo_closed) the registerRuboCop stub builtins.go calls, plus hookClosedUnavailable
rubocop_unavailable.go (no tag) the roster and the raise, tag-free so a native test asserts the LoadError without a closed-world build

builtins.go needs no tag: the stub supplies the symbol it calls, exactly as
backends_wasm.go does for registerGRPC and the rest. The four wrapper types are
redeclared as empty shells in the closed file — not vestigial: object_model.go's
classOf has a case arm per wrapper type in an untagged file, so the names must
resolve for the closed build to compile. No value of them can be constructed there,
so the arms are unreachable; same shape as arSQLiteAdapter in bindings_wasm.go.

Measured on a --closed binary built from the same one-line program:

before after
T …parser.(*Parser). symbols 98 0
any go-ruby-parser/parser symbol 255 0
any go-ruby-rubocop symbol — 0
internal/compiler symbol — 0
binary 172.9 MiB 172.2 MiB

The counts are over 173,293 nm lines, and the same grep finds 15,412
internal/vm symbols in that input — so the zeros are zeros, not a grep that could
not run. go list -deps -tags rbgo_closed ./cmd/rbgo now names no parser, no
rubocop and no compiler: rubocop was the only edge.

A correction, because the size barely moved

It moved 0.7 MiB, and not because another edge remains — the dep graph and the
symbol table both say zero. The front-end is simply not what makes this binary
large. An ordinary non-closed rbgo is 173.7 MiB, so dropping the entire
front-end saves 1.5 MiB, or 0.85%.

Earlier notes on this defect, mine included, cited "172.9 MiB carrying 255
parser symbols" as though the size were the symptom. It was not: the symbols were
the symptom, and the size is a separate and much larger question about the gem
bindings. The integration test's "closed should be smaller than open" assertion
holds, but it was never measuring much. Reported rather than acted on.

Both directions verified

The risk of a build tag is silently breaking the ordinary path, so both were run:

  • normal build — require "rubocop" succeeds, RuboCop::Runner#inspect returns
    an Array, RuboCop::Cop::Offense is a Class. Unchanged.
  • closed build — LoadError: cannot load such file -- rubocop (not available in the closed-world build), a retried require re-raises rather than returning
    false, and the program keeps running. Closed binaries still execute blocks,
    ranges and Errno::ENOENT correctly with no front-end linked.

All five RBGO_BUILD_IT tests now pass. No assertion was rewritten to accept
the 255 symbols — the product changed, which was the point.

The duplication this creates, and its guard

raiseClosedUnavailable is a second copy of raiseWasmUnavailable's rule, differing
only in the reason named, because merging them means editing
backends_unavailable.go, which this change was not scoped to touch.
TestClosedAndWasmUnavailableAgreeOnShape pins the two to each other — it takes the
wasm message as the reference, swaps only the parenthesised reason, and requires the
closed message to match, so changing either format string alone fails the test.
Verified by mutation: it does. A third condition should collapse all three.

Two skips that turned a build failure into a pass

From the same sweep, and the same defect class as #682:

if out, err := build.CombinedOutput(); err != nil {
    t.Skipf("cannot build cmd/rbgo (%v): %s", err, out)
}

TestNetHTTPThroughRbgoBinary and TestSocketThroughRbgoBinary each built
cmd/rbgo and reported skip — which reads as green — if it would not compile.
Both are now t.Fatalf. The socket one justified itself in its own comment by "the
unrelated pre-existing cmd/rbgo codegen issue", which no longer exists: the premise
had expired and left a skip that protected nothing and hid everything. The nethttp
one cited only "mirroring the socket suite", so its reasoning was never
independently held.

TestClosedWasmBuildIntegration now runs what it builds

Its docstring said the baked program's JS.log line "ran in a headless browser
when one is available". No version of that function ever ran anything — it read the
module's first four bytes and scanned it for symbols. It now runs the module under
node with the toolchain's wasm_exec_node.js and asserts both JS.log lines reach
the console, including that the absent document global comes back as a value
rather than trapping. node is a hard requirement of RBGO_BUILD_IT=1, not a
reason to skip: a check that steps aside when its host is absent is the failure
this lane exists to correct.

The module exits non-zero and that is by design — closed_main_wasm.go ends in
select{} to keep the runtime alive for JS callbacks, and this program registers
none, so Go's deadlock detector fires once every goroutine parks. The test asserts
that reason, so a trap, a link error or a Ruby exception still fails. Worth
knowing separately: a --closed wasm app that merely prints will show
fatal error: all goroutines are asleep - deadlock! in a browser console too,
after its output. Reported, not changed.

The rest of the suite: every env-var gate and skip, against what workflows set

The reusable half of the question. RBGO_BUILD_IT is the only environment
variable gating any test in this repo — there is no second one to find. But the
sweep turned up the same failure shape elsewhere, which is the part worth
keeping:

1. Env-var gates (1 variable, 3 tests) — RBGO_BUILD_IT:
TestBuildIntegration, TestClosedBuildIntegration,
TestClosedWasmBuildIntegration. Set by no workflow before this PR; set by the new
build-integration lane now.

2. A build failure that becomes a PASS (2 tests) — the most dangerous items
found, and the same shape as #682:

if out, err := build.CombinedOutput(); err != nil {
    t.Skipf("cannot build cmd/rbgo (%v): %s", err, out)
}

in internal/vm/nethttp_bind_test.go:387 (TestNetHTTPThroughRbgoBinary) and
internal/vm/socket_test.go:424 (TestSocketThroughRbgoBinary). If cmd/rbgo
stopped compiling, both would report skip, i.e. green. Not changed here —
internal/vm/*_test.go outside this cluster — but they should become t.Fatalf.

3. -tags rbgo_closed is compiled by no workflow at all. grep -rn -- -tags .github/workflows/ is empty, so nothing in CI ever builds the closed side of the
tree; internal/vm/prelude_test.go is //go:build !rbgo_closed, and its
rbgo_closed counterpart is exercised only through the RBGO_BUILD_IT tests. The
new lane is therefore the only CI path that compiles that configuration at all.

4. testing.Short() gates (3 tests) — TestNetHTTPThroughRbgoBinary,
TestNetHTTPOpenTimeout, TestSocketThroughRbgoBinary. No workflow passes
-short, so these do run in CI. The inverse case, listed so it is not
rediscovered.

5. Capability-probe skips (11), which pass silently when the capability is
absent
— symlink creation (dir_glob_wave16_internal_test.go:65,97,
filestat_internal_test.go:139, wave19_filedir_internal_test.go:115,292),
/dev/zero and mkfifo (io_nonregular_internal_test.go:31,51), user.Current()
(file_path_internal_test.go:195), and os.Geteuid() == 0
(wave28_open_paths_internal_test.go:137, wave19_filedir_internal_test.go:407).
All of them are satisfied on the GitHub POSIX runners today, so they run — but none
of them would say so if that changed.

6. Platform skips (~24) — runtime.GOOS-conditioned, all three OS lanes exist,
so each branch is reached by some lane. These are genuinely fine.

7. Two conformance "gap recorded" skips —
scripts/conformance/webapp/webapp_test.go:131,177 turn a missing Sinatra /
ActiveRecord binding into a skip. That package is in ./..., so the test
lane runs it; the skips are deliberate gap markers, but a regression that removed a
binding would present as a skip rather than a failure.

8. One skip that can never fire — grape_internal_test.go:171 tests
r.handler != nil two lines after setting handler: nil. Harmless, dead.

Verification

  • CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./... — green, and the module
    runs under node via web/smoke.mjs (9 checks).

  • CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build ./... — green, and the module
    still runs under wazero (the runtime the existing lane uses), which gives
    the bucket rule its two-sided witness — the same name, the same build, two
    targets:

    Errno::ETXTBSY::Errno Errno.constants.size
    wasip1/wasm under wazero 74 (the target's real number) 158
    js/wasm under node 0 (Errno::NOERROR, no number here) 158

    A single table would have had to be wrong on one of those two.

  • Cross-builds green for darwin/{amd64,arm64},
    linux/{amd64,arm64,riscv64,loong64,ppc64le,s390x}, windows/{amd64,arm64},
    wasip1/wasm, js/wasm.

  • gofmt -l internal/ cmd/ empty.

  • 100% coverage: the below-100 function LISTS are identical to origin/main's.
    Same -coverpkg=$(go list ./internal/... | paste -sd, -) profile, on a detached
    worktree at origin/main (2aa1c94) and on this branch, each profile resolved with
    go tool cover -func from its own worktree. Both totals read 99.9% on this
    host (the rounding the gate hides), so totals were not the comparison: 16
    functions below 100% on each side, the same 16, at the same percentages
    —
    arith.go:{binary,rubyEql,valueEqlRec}, bigdecimal_bind.go:Inspect,
    builtins.go:{bootstrap,cloneSingleton}, complex.go:registerNumericGeneric,
    converter_codec.go:{encodeCharTo,xtextRuneStep}, date_bind.go:Inspect,
    io.go:inspectStr, nethttp.go:registerNetHTTP, object.go:repr,
    object_model.go:{classOf,defaultNegation,objSingleton}.

  • No orphaning, checked against the FULL measured set and not just the below-100
    list.
    This matters because the below-100 diff cannot see orphaning at all: a
    function that leaves the profile entirely appears in neither list, so that diff
    stays clean while code silently stops being measured. The full set went 7293 →
    7295
    , and the diff is exactly two additions with zero removals:
    errno.go:mergeErrnoNumbers and rubocop_unavailable.go:raiseClosedUnavailable,
    both at 100%.

  • Two build tags were added in this PR and neither moved code out of the measured
    set
    , by design. !rbgo_closed is compiled on every ordinary lane, so all 19
    pre-existing rubocop functions stay measured (19 → 20 with the new raise).
    errno_wasm.go and rubocop_closed.go carry only the tagged-out halves; the
    former holds data and no functions at all, and the latter's stubs are unmeasured on
    POSIX lanes exactly as backends_wasm.go's have always been — stated plainly
    rather than glossed. It is also why mergeErrnoNumbers takes its tables as
    arguments: the js-shaped case (an empty platform table) is then covered by a POSIX
    lane instead of by a lane that cannot run this package's tests.

  • CGO_ENABLED=0 GOWORK=off go test ./... green (full suite, 8 packages, both trees).

  • A first attempt at this comparison was wrong and is worth recording: running
    go tool cover -func on the baseline profile from the branch worktree
    resolved its line numbers against the branch's sources and invented three
    errno.go functions at 0.0%. The judge and the subject have to read the same
    source.

  • The new guard was seen to fail: orphaning ETXTBSY from both per-target vars
    in a scratch mutation gave
    the buckets hold 157 distinct names, want MRI's known_errors count 158 — while
    TestErrnoClassRegistration stayed green, because it compares the registered
    constants against the tables' own lengths, an expectation that shrinks with its
    subject. That is why the new test pins a literal 158.

🤖 Generated with Claude Code

tannevaled and others added 6 commits September 26, 2026 16:46
…get bucket

GOOS=js GOARCH=wasm has not compiled since #629: errno.go referred to
syscall.ENOTRECOVERABLE, syscall.EOWNERDEAD and syscall.ETXTBSY, which Go's
js/wasm syscall package does not define. wasip1 defines all three, so the
existing wasm lane could not see it.

errno.go already had the rule this needs: a known_errors name with no number on
this platform is still a constant, bound to Errno::NOERROR, the way MRI's
undefined_error does it. The three names were simply in the wrong bucket on one
target. They now live in errnoPlatformNames, with the numbers (or their absence)
supplied per target by errno_native.go and errno_wasm.go -- the same
!(js && wasm) / (js && wasm) file pair this package already uses for
jsbridge and servergems. No number is hard-coded; the browser has no MRI to
witness one against.

The bucket rule is now stated once, at the top of errno.go, with the invariant it
exists to protect: the buckets partition known_errors on every target, so a name
that leaves one bucket must arrive in another or Errno.constants silently
shrinks.

TestErrnoBucketsPartitionKnownNames asserts that partition against a literal 158,
MRI's known_errors count. That matters: TestErrnoClassRegistration compares the
registered constants to len(errnoNumbers)+len(errnoUndefinedNames)+len(errnoAliases),
an expectation that shrinks with its subject -- orphaning ETXTBSY in a scratch
mutation left it green while the new test failed with "the buckets hold 157
distinct names, want 158".

Measured while enumerating rather than fixing the three names given: js/wasm is
the ONLY one of darwin/{amd64,arm64}, linux/{amd64,386,arm64,riscv64,loong64,
ppc64le,s390x}, windows/{amd64,arm64}, wasip1/wasm and js/wasm that lacks any
name errnoNumbers claimed was universal. The comment claiming Go exposes the
other known_errors names "only under a build tag" is wrong and is corrected: they
are plainly available per-GOOS (57 of 81 on linux/amd64, 56 on windows, 31 on
darwin/amd64, 2 on wasip1), so the 0 rbgo reports for them is a larger declared
conformance gap than the one macOS EAUTH the comment named. Closing it still
needs a per-platform MRI witness and is left to the open per-platform errno
issue.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a lane

Two screens failed open around #682 and both are closed here.

ci.yml gated wasip1 only. GOOS=js and GOOS=wasip1 are different targets with
different syscall packages, so the wasip1 lane stayed green for three days while
the browser target did not compile at all. The new `wasm (js, browser)` lane
builds ./... for js/wasm -- not just ./cmd/wasm, because the break was three names
in internal/vm, a package the playground reaches only transitively -- then vets,
builds the playground module and RUNS it.

Running it is the part that matters. web/smoke.mjs loads the module into node with
Go's own lib/wasm/wasm_exec.js, the same glue web/index.html loads in a browser,
waits for main() to publish its entry points and then calls rbgoEval: arithmetic
and $stdout capture, a rescued Errno::ENOENT, a syntax error coming back as an
error string rather than a crash, and both halves of errno.go's bucket rule live
in the browser runtime (Errno::ENOENT::Errno is 2, Errno::ETXTBSY is
Errno::NOERROR here, Errno.constants.size is 158). A compile is not a witness for
a runtime target.

The second screen: three integration tests in cmd/rbgo sat behind
`os.Getenv("RBGO_BUILD_IT")` and `grep -rn RBGO_BUILD_IT .github/workflows/`
returned nothing. TestClosedWasmBuildIntegration fails on the #629 regression with
exactly its three errors -- correct, complete and unreachable. The choice is to
wire it in, in its own lane, because those three cover what nothing else does: the
nested `go build` the rbgo command runs, that --closed really drops the front-end,
and that a baked program executes.

MEASURED COST, cold GOCACHE, M-series host (a GitHub runner is slower):

  build ./... (js/wasm)                                14s
  vet ./cmd/wasm ./cmd/rbgo ./internal/vm (js/wasm)    18s
  ./cmd/wasm module                                    96.9 MB
  ./cmd/rbgo module                                    98.4 MB
  warm rebuild of the module                            1s
  all three RBGO_BUILD_IT tests                        35s

Both lanes together are minutes, none of it -race-priced, and they run in parallel
with the existing ones -- next to the 20-90 min -race coverage lane the addition
is small. Being its own lane rather than a step in `test` keeps it off that lane's
critical path.

Proof the new lane is a gate: `CGO_ENABLED=0 GOOS=js GOARCH=wasm go build ./...`
run in a detached worktree at origin/main (2aa1c94, before this branch) fails with
the three undefined-symbol errors. A gate nobody has seen fail is not known to be
a gate.

TestClosedWasmBuildIntegration's docstring claimed the baked program's JS.log line
"ran in a headless browser when one is available". No version of that function ever
ran anything -- it read the module's first four bytes and scanned it for symbols.
It now really runs the module under node with the toolchain's wasm_exec_node.js and
asserts both JS.log lines reach the console, including that the absent `document`
global comes back as a value rather than trapping. node is a hard requirement of
RBGO_BUILD_IT=1 and a missing node fails the test: a check that steps aside when
its host is absent is the exact failure this lane exists to correct.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the leak

Two things came out of actually RUNNING what RBGO_BUILD_IT was hiding.

The closed wasm module aborts after its program finishes, and that is by design:
closed_main_wasm.go ends in select{} to keep the runtime alive for JS event and
animation callbacks, and the baked test program registers none, so Go's deadlock
detector fires once every goroutine is parked. The test now asserts that REASON
rather than tolerating any failure -- "all goroutines are asleep - deadlock!"
after both JS.log lines reached the console is the documented behaviour; a trap, a
link error or a Ruby exception still fails.

The second is the finding: with RBGO_BUILD_IT on, both closed-world tests fail
their "the front-end must not be linked" assertion, on this branch and identically
on the commit it branched from (verified in a detached worktree at origin/main,
unmodified). `rbgo build --closed` announces "front-end dropped (no
lexer/parser/compiler linked)" and produces a 172.9 MiB binary holding 255
github.com/go-ruby-parser/parser.(*Parser).* text symbols.

The cause is one edge: internal/vm/rubocop.go and rubocop_bind.go carry no build
tags, and github.com/go-ruby-rubocop/rubocop imports the parser, so the front-end
re-enters the closed dependency graph through the RuboCop binding --
`go list -deps -tags rbgo_closed ./cmd/rbgo` names it and nothing else.

That fix is not here. It is a scope decision about what --closed supports (does a
closed build still answer `require "rubocop"`, or degrade to a LoadError as the
network backends do on wasm?) and it touches internal/vm. The lane says so in
place, so the next reader meets the diagnosis rather than a red tick. No assertion
was weakened to make it green: a check nobody runs is documentation, and the point
of turning the var on was to stop that.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo pins checkout and setup-go at v7 under a "track the latest stable"
convention. actions/setup-node latest major is v7 too; v4 was three majors
stale on arrival. Caught by reading the action back with `gh api
repos/actions/setup-node/git/matching-refs/tags/v` rather than assuming the
major a different action happens to be on.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lane comments carried a laptop number and the word "budget". The
build-integration lane has now run on ubuntu-latest: 141s of test time, 5.3 min
for the job, against 35s cold on an M-series host -- the runner is 4.0x slower,
which is the figure to reuse rather than the guess.

The useful frame is not "cheap" but where it lands among lanes this file already
pays for on the same commit: wasm (wasip1) 2.0 min, rbgo build integration 5.3
min, arch arm64 5.9 min, arch amd64 9.4 min. The new lane is cheaper than a lane
already on every PR. wasm (js, browser) has not had a runner of its own yet, so
the comment says that instead of inventing a number for it.

Also records that the lane fails on Linux for EXACTLY the one predicted reason:
TestBuildIntegration, TestBuildEnv and TestRunSendsWarningsToTheRealStderr pass,
the node run of the closed wasm module passes, and the only failing lines are the
two "still links the front-end" assertions. The leak is platform-independent.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the file

The lane comment said it had not yet had a runner of its own to time. It has now:
1.9 min end to end on ubuntu-latest with all nine smoke checks green, which makes
it the cheapest lane here -- below the wasm (wasip1) lane beside it at 2.0 min.
No estimate is left in either new lane comment.

The runner log also carries the part that matters more than the timing: the bucket
rule witnessed inside a JS host ON LINUX, not just on a laptop --
Errno::ETXTBSY::Errno is 0 there, Errno::ETXTBSY.equal?(Errno::NOERROR) is true,
Errno.constants.size is 158. That is the half of #682 no compile-only check could
have reached.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled and others added 2 commits September 26, 2026 17:48
`rbgo build --closed` announced "front-end dropped (no lexer/parser/compiler
linked)" and then linked the parser anyway. internal/vm/rubocop.go and
rubocop_bind.go carried no build tags, and github.com/go-ruby-rubocop/rubocop
imports github.com/go-ruby-parser/parser -- a linter needs an AST to inspect --
so the front-end re-entered the closed dependency graph through the RuboCop
binding. Both closed-build integration tests asserted against it and both failed,
which is what wiring RBGO_BUILD_IT to a CI lane finally made visible (#682).

The reasoning for dropping rather than keeping: --closed already promises no
front-end, and a lint needs a parser, so a linter has no business in a binary that
claims to have dropped one. There is also no useful subset of RuboCop to keep, so
this takes the LoadError degradation of backends_wasm.go rather than the
graceful-module one of bindings_wasm.go: `require "rubocop"` fails exactly as a
missing gem would.

  rubocop.go, rubocop_bind.go            -> //go:build !rbgo_closed
  rubocop_test.go, rubocop_internal_test.go -> the same, since they assert the
                                            open build's behaviour
  rubocop_closed.go (rbgo_closed)        -> the registerRuboCop stub builtins.go
                                            calls, plus hookClosedUnavailable
  rubocop_unavailable.go (no tag)        -> the roster and the raise, tag-free so
                                            a NATIVE test can assert the LoadError
                                            without a closed-world build

builtins.go needs no tag: the stub supplies the symbol it calls, exactly as
backends_wasm.go does for registerGRPC and the rest.

The four wrapper types are redeclared as empty shells in the closed file. They are
not vestigial: object_model.go's classOf has a case arm per wrapper type in a file
with no build tag, so the names must resolve for the closed build to compile. No
value of them can be constructed there, so the arms are unreachable -- the same
shape as arSQLiteAdapter in bindings_wasm.go.

MEASURED, on a --closed binary built from the same one-line program:

                              before      after
  parser.(*Parser).* T syms       98          0
  any go-ruby-parser sym         255          0
  any go-ruby-rubocop sym        n/a          0
  internal/compiler sym          n/a          0
  binary                   172.9 MiB   172.2 MiB

The symbol counts are over 173,293 nm lines, and the same grep finds 15,412
internal/vm symbols in that input -- so the zeros are zeros, not a grep that could
not run. `go list -deps -tags rbgo_closed ./cmd/rbgo` now names no parser, no
rubocop and no compiler, so rubocop was the only edge.

AND A CORRECTION THAT MATTERS: the size barely moved, and not because another edge
remains. The front-end is simply not what makes this binary large. An ordinary
(non-closed) rbgo is 173.7 MiB, so dropping the whole front-end saves 1.5 MiB --
0.85%. Earlier notes on this, including mine, cited "172.9 MiB carrying 255 parser
symbols" as if the size were the symptom. It was not: the symbols were the symptom
and the size is a separate, much larger question about the gem bindings. The
integration test's "closed should be smaller than open" assertion holds, but it was
never measuring much.

Both directions verified, because the risk of a build tag is breaking the ordinary
path silently:

  normal build: require "rubocop" succeeds, RuboCop::Runner#inspect returns an
                Array, RuboCop::Cop::Offense is a Class -- unchanged
  closed build: LoadError "cannot load such file -- rubocop (not available in the
                closed-world build)", a retried require re-raises, and the program
                keeps running

All five RBGO_BUILD_IT tests now pass. No assertion was rewritten to accept the
255 symbols; the product changed, which is the point.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestNetHTTPThroughRbgoBinary and TestSocketThroughRbgoBinary each built cmd/rbgo
and, if the build failed, called t.Skipf("cannot build cmd/rbgo"). A compile break
in cmd/rbgo therefore reported SKIP, which reads as green. That is the defect class
of issue #682: the guard that caught a three-day js/wasm compile break was itself
unreachable, and these two would have swallowed the next one.

Both now t.Fatalf.

The socket test's skip was justified in its own comment by "the unrelated
pre-existing cmd/rbgo codegen issue". That issue no longer exists -- cmd/rbgo
builds -- so the premise had expired and left behind a skip that protected nothing
and hid everything. The nethttp test cited only "mirroring the socket suite", which
means the reasoning was never independently held there.

Refs #682

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 41fa005 into main Sep 26, 2026
16 of 17 checks passed
@tannevaled
tannevaled deleted the wt35/jswasm-errno-ci branch September 26, 2026 18:38
tannevaled added a commit that referenced this pull request Oct 9, 2026
…o it does not (#801)

* README: drop two limitations the repository already disproved, add two it does not

"What does not work yet" promised a dated re-check and got one. Two entries no
longer reproduce, and one of them had not for weeks:

  GOOS=js GOARCH=wasm go build ./cmd/rbgo   exit 0
  GOOS=js GOARCH=wasm go build ./cmd/wasm   exit 0
  rbgo -e '$stdout.print "O"; $stderr.print "E"'  ->  O on stdout, E on stderr

The browser one is the worse of the two. #684 fixed the three missing syscall
constants AND added the lane, so `wasm (js, browser)` has been building that
target green on every pull request while the README said it did not build and
had no lane -- in two separate places, one of them the Platforms table, which
omitted the lane entirely. The answer was in the tree the whole time.

readme_wasm_lane_test.go closes that class: if ci.yml builds the target and the
README says it cannot, the test fails and names both. It is narrow on purpose --
it does not try to validate the whole section against reality, only the claims
that files already in the repository can settle. Proved in both directions, and
it caught the first draft of this very commit, which quoted the old sentence
back verbatim.

Two entries added, both measured against MRI 4.0.7 on this host, and both
fail-open rather than merely missing:

  Timeout.timeout(0.3) { sleep 2 }   returns after 2s, no error (MRI raises at 0.3s)
  `sleep 1`                          freezes every other Ruby Thread (MRI: they run)

Timeout is stated as a paragraph rather than a table row because of the shape:
it is the ordinary Ruby way to bound work that might hang, and it returns
successfully, so code relying on it is unprotected and nothing says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* README: two more rows re-measured, and one of them was understating a crash

Both rows in the "Otherwise" table were re-checked against MRI 4.0.7 along with
the rest of that section. Every other row still reproduces; these two do not.

  # encoding: ascii-8bit   rbgo prints ASCII-8BIT, same as MRI -- row removed
  BEGIN { puts "B" }       rbgo prints B then main -- works

The BEGIN/END row was the one worth catching. It said both "do not parse",
which reads as an ordinary gap: the feature is unavailable, use something else.
What actually happens now is that BEGIN works and END parses, registers, lets
the program finish, and then kills the process with an uncontained Go panic:

  $ echo 'END { puts "E" }' > p.rb && rbgo p.rb
  panic: runtime error: slice bounds out of range [:1] with capacity 0
  $ echo $?
  2

For an embedder that is worse than a gap: ruby.Run does not return an error, it
takes the host process down. Filed as #805 with the isolation -- at_exit and
BEGIN are fine, the ISeq and its environment disagree about the block's locals,
and the repanic in Run is deliberate policy rather than the defect.

A stale note that promises LESS than the code delivers is a nuisance. One that
promises a missing feature where the code delivers a process death points the
reader the wrong way about the risk, which is why this row is now explicit
about what happens rather than about what is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tannevaled added a commit that referenced this pull request Oct 11, 2026
…813)

The go-ndarray playground shows each pipeline as Go | Ruby | NumPy and runs the
Ruby column in the browser through cmd/wasm. This implements the API its code
generator emits (ruby-ndarray-contract.md):

- creation: arange(stop | start, stop | start, stop, step), linspace, eye(n, m, k),
  zeros()/ones() (0-d with no shape, or a shape Array), full(value, *shape)
- views: reshape (with -1), T, squeeze(*axes), expand_dims, dup/copy (copy of
  the data), and NumPy basic indexing through a[...]: Integer, true for ':', a
  Range (exclusive, inclusive, endless, beginless) or an
  Enumerator::ArithmeticSequence ((0..) % 2, (-1..) % -1, (5...1) % -2)
- elementwise: square, ** (scalar, array with broadcasting, and 2 ** a through
  NDArray#coerce), clip, NDArray.maximum/minimum
- reductions: sum/mean/max/min/prod/argmax/argmin with (axis, keepdims:), cumsum
- products and joins: NDArray.outer, NDArray.concatenate/stack(axis:)
- p/inspect/to_s print go-ndarray's NumPy-style Repr

Every method now checks its arguments: a wrong count raises ArgumentError, a
wrong type TypeError, an index or axis out of range IndexError, other go-ndarray
errors ArgumentError with go-ndarray's message. NDArray.arange(5) and
NDArray.full() used to raise "ArgumentError: arange: runtime error: index out of
range [1] with length 1", a recovered Go panic. TestNativeFaultBecomesRubyError
relied on that bug; it now plants its own faulting native.

go-ndarray v0.3.0 -> v0.11.0 (and go-fft v0.1.14 -> v0.23.0 with it), outside
the grouped Renovate PR #792.

The README no longer says the browser target does not build (it has since
#684), in three places; readme_wasm_lane_test.go now catches that wording across
line breaks. web/smoke.mjs runs an NDArray program in the JS host.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

GOOS=js GOARCH=wasm has not compiled for 3 days, and the test that catches it is behind an env var no workflow sets

1 participant