Skip to content

fix(build): pin let-go to v1.13.0, and fix the runner it silently zeroes - #195

Merged
mparrett merged 5 commits into
mainfrom
fix/repin-letgo-v1122
Sep 20, 2026
Merged

mparrett merged 5 commits into
mainfrom
fix/repin-letgo-v1122

Conversation

@mparrett

@mparrett mparrett commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

A bundle built with the pinned let-go does not start. It dies at namespace load:

error loading xsofy.input: ExecutionError: calling dev?
  caused by bit-shift-left expected Int

dev? runs when xsofy.input loads, so this is not a dev-mode-only path: the bundle never reaches the title screen.

Why the pin was a SHA, and why it no longer needs to be

.let-go-version held the bare commit cf195a67, which is nooga/let-go#378 itself, taken on 2026-07-16 because no release carried the #app host mount yet. The file said so, and said what to do about it:

Re-pin to a release tag once let-go ships #378.

v1.12.2 shipped #378 on 2026-07-20, so the SHA stopped being necessary four days after it was added. What this changes is not only that bookkeeping: the pin had also gone stale against the rest of the tree, and the failure above is the result.

This targets v1.13.0, released 2026-09-20, rather than v1.12.2. It carries #378 like v1.12.2 does, and also #859 and #862, which make vm.Int 64 bits on every host and lower a let-go :int to int64. Both are no-ops for this bundle — they widen 32-bit-int hosts (linux/386, TinyGo wasm), and stock Go wasm was already 64-bit — so the worlds existing seeds name are unchanged. What those seeds play is not — see “Worlds survive the bump; play does not” below.

The runner fix is not optional

v1.13.0 ports clojure.test for real (nooga/let-go#863), which gives run-tests Clojure's actual arities: no arguments means the current namespace. xsofy/test/run.lg calls a bare (run-tests) from xsofy.test.run, a namespace holding no deftests. On v1.13.0 that runs zero tests and exits 0, so this lane would have gone green having executed nothing.

Same tree, same command, three runs:

v1.12.2                    Tests: 328  Pass: 2857  Fail: 0  Error: 0
v1.13.0                    Ran 0 tests containing 0 assertions      exit 0
v1.13.0 + run-all-tests    Ran 328 tests containing 2857 assertions, 0 failures

Selecting the namespaces by regex keeps the :require list as the source of truth, since all-ns only sees what has been loaded.

The release notes call out the opcode set and bundle format as breaking, but not this. Anything downstream using a bare (run-tests) as an aggregate runner has the same silent hole.

Two warnings the bump surfaces

v1.13.0 warns when a namespace replaces a core referral, and the tree trips it twice:

WARNING: lazy-map already refers to: #'clojure.core/lazy-map in namespace: xsofy.check, being replaced by: #'xsofy.check/lazy-map
WARNING: peek already refers to: #'clojure.core/peek in namespace: xsofy.dag, being replaced by: #'xsofy.dag/peek

Both collisions predate this bump — core has carried (def lazy-map map) and peek since before the SHA being replaced — so what is new is the warning, not the shadowing.

They are fixed in opposite directions, because they are different situations.

xsofy.check/lazy-map is renamed to lazy-map*. It is private and file-local, and it is a different function from core's: core's is an alias for eager map, while check.lg's is a cons-based lazy version, one of the pair documented at check.lg:66 as existing because let-go's map and mapcat are eager and rose trees need laziness. Excluding the referral would assert a replacement this is not. Seven call sites, one file.

xsofy.dag/peek keeps its name, under (:refer-clojure :exclude [peek]). It is public, called from ten sites in dag_test.lg, and it is the domain term: reading the world at a commit-id in a namespace modelling a git-like DAG. Renaming it would churn a public API to quiet a warning, where excluding says what is true — in this namespace, peek means the DAG read.

The rule, for anyone hitting this on another bump: rename when the local is a different function that happens to share a name, exclude when the local is the intended meaning of that name. The assertion counts below are unchanged by either.

Worlds survive the bump; play does not

The worlds a seed names are unchanged. What a seed plays is not.

(world/make-world 40 25 12345) on the current pin cf195a67 and on v1.13.0 369e2a69 returns the same :seed, the same 25 entities with byte-identical generated ids, and the same player position [25 20]. Worldgen is untouched.

What changes is the order (keys (:entities w)) comes back in:

cf195a67 : :player :goblin-621174419 :rat-1400099564 :rat-39790946 …
v1.13.0  : :goblin-510791456 :rat-1355154828 :rat-1400099564 … :player 9th

process-creatures takes creature acting order straight from (vals (:entities world)) — #187 — so the same seed plays out differently either side of this bump. Measured on the balance harness over 40 runs with the tree held fixed and the lg version the only variable, mean survival moves from 132.2 turns to 163.4.

The assertion counts below do not speak to this. The replay and determinism tests replay within one lg version, comparing a run against itself, so they pass either way.

Nothing here blocks the bump: the bundle is broken on the current pin and the fix is to move off it. But a replay code recorded before this lands is not guaranteed to reproduce after it, and #187 is what would make that guarantee hold. The evidence is on #187.

Nothing shipped broken

deploy-pages.yml fires only on v* tags, and the last one, v0.0.2, predates the SHA pin. That release deployed with v1.11.1, which is why xsofy.quest currently serves a pre-#378 bundle mounting #terminal rather than #app. The live site is healthy.

The next tag would have been the first to carry the SHA pin, so this is a latent release blocker rather than an outage.

Why the floor lane is green

The pinned (deploy floor) lane compiles each test namespace, runs the suite, runs the Layer-1 runtime smoke, and — since #69 — builds the WASM bundle through the same build-wasm action the deploy uses. What it never does is boot the bundle it just built. This failure is at namespace load, so every one of those steps passes on the old pin and the lane has no way to see it.

An earlier revision of this body, and of the .let-go-version comment, said the lane never builds the bundle. That was wrong, and the correction sharpens the point: the gap is not a missing build, it is a missing load. Issue #196 carries the same error and needs the same correction.

Verification

On v1.13.0: 328 tests and 2857 assertions with the fixed runner, a full production-faithful bundle build, and the resulting bundle loaded headless under COI headers — crossOriginIsolated true, #app present, title screen, and the map screen after a keypress. The only console message is a 404 for build-info.json, which the local build path does not generate.

The same browser check against the old SHA pin fails, with the error above. CI installs the pin with go install …@<version>, which takes a tag and a SHA alike, so the resolution path is unchanged.

A bundle built with the pinned let-go does not start. It dies at namespace
load:

    error loading xsofy.input: ExecutionError: calling dev?
      caused by bit-shift-left expected Int

`dev?` runs when `xsofy.input` loads, so this is not a dev-mode-only path.

The pin was the bare commit cf195a67, which is nooga/let-go#378 itself, taken
on 2026-07-16 because no release carried the `#app` host mount yet. v1.12.2
shipped #378 four days later, so the SHA was already unnecessary; what this
fixes is that it had also gone stale against the rest of the tree.

Nothing shipped broken. `deploy-pages.yml` fires only on `v*` tags and the last
one, v0.0.2, predates the SHA pin — it deployed with v1.11.1, which is why the
live site serves a pre-#378 bundle mounting `#terminal`. The next tag would
have been the first to carry this.

The floor lane did not catch it because it compiles the test namespaces, runs
the suite, and runs the Layer-1 smoke, but never builds or boots the WASM
bundle — the one thing `deploy-pages.yml` does. All of those pass on the old
pin, which is why it looked healthy. Adding a boot check there is worth doing
separately.

Verified on v1.12.2: 328 tests / 2857 assertions, the Layer-1 smoke, a full
`make wasm`, and the resulting bundle loading in a browser under COI headers —
`#app` present, no console errors, title screen and a playable map. The same
browser check on the old pin fails 3 out of 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.13.0 ports clojure.test for real (nooga/let-go#863), which gives run-tests
Clojure's actual arities: no arguments now means the CURRENT namespace.
xsofy/test/run.lg calls a bare (run-tests) from xsofy.test.run, a namespace
holding no deftests, so on v1.13.0 the aggregate suite ran zero tests and
still exited 0 — CI would have gone green having executed nothing.

Measured, same tree, same command:
  v1.12.2   Tests: 328  Pass: 2857  Fail: 0
  v1.13.0   Ran 0 tests containing 0 assertions   (exit 0)
  v1.13.0 + run-all-tests   Ran 328 tests containing 2857 assertions, 0 failures

Selecting the namespaces by regex keeps the :require list as the source of
truth, since all-ns only sees what has been loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mparrett mparrett changed the title fix(build): pin let-go to v1.12.2 so the web bundle boots fix(build): pin let-go to v1.13.0, and fix the runner it silently zeroes Sep 20, 2026
v1.13.0 warns when a namespace replaces a core referral, and xsofy.check
trips it on every load:

  WARNING: lazy-map already refers to: #'clojure.core/lazy-map in
  namespace: xsofy.check, being replaced by: #'xsofy.check/lazy-map

The collision predates this pin bump — core has carried
`(def lazy-map map)` since before the SHA this PR replaces — but the
warning is new, so the bump is what surfaces it.

The two are not the same function. Core's is an alias for eager `map`;
xsofy.check's is a cons-based lazy version, one of the pair documented at
check.lg:66 as existing because let-go's `map` and `mapcat` are eager and
rose trees need laziness. Excluding the core referral would assert a
replacement this isn't, so rename instead: the helper keeps its
`lazy-concat` sibling's naming with a `*` marking it as ours.

Private and file-local, so the rename is confined to check.lg's seven
call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second core-referral warning the pin bump surfaces:

  WARNING: peek already refers to: #'clojure.core/peek in namespace:
  xsofy.dag, being replaced by: #'xsofy.dag/peek

Opposite fix to the one in check.lg, for the opposite reason. That
`lazy-map` was private, file-local, and a different function from core's
eager alias, so it was renamed. `xsofy.dag/peek` is public, called from
ten sites in dag_test.lg, and is the domain term: reading the world at a
commit-id in a namespace modelling a git-like DAG. Renaming it would
churn a public API to quiet a warning.

Excluding the referral says what is true here — in this namespace `peek`
means the DAG read — and leaves every call site alone.

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

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed via automated multi-angle analysis, including building lg from both the old pin (cf195a67) and v1.13.0 and running the actual WASM build pipeline against both.

Verdict: approve, with one open question for the author and a pre-existing gap worth a follow-up.

The re-pin itself is sound. v1.13.0 is a real, published tag (go list -m -versions confirms it against the module proxy) and is cf195a67's descendant 290 commits later, including the #859/#862/#906 PRs cited. Verified those two are genuinely no-ops for this bundle: stock GOOS=js GOARCH=wasm already has 64-bit int (PtrSize: 8 in Go's own arch.go), so they don't explain any crash even if the crash is real. grep -rn "cf195a6" across the repo hits only .let-go-version — nothing else needed updating.

The "silently zeroes" half is confirmed real and correctly fixed, independent of the crash claim. Not a .let-go-version-parsing issue (both CI extraction sites are format-agnostic between a SHA and a tag). The actual bug: at cf195a67, run-tests with zero args ran every already-loaded namespace with tests ((doseq [[s bs] *registered-tests*] ...)), which is what gave "328/2857." At v1.13.0, post the clojure.test port, zero-arg run-tests means only the calling namespace — xsofy.test.run defines no tests itself, so the old bare (run-tests) now silently reports 0 tests and exits 0. run-all-tests #"xsofy\.test\..*" (new in v1.13.0) is the correct semantic fix, not a workaround — restores coverage by construction rather than an explicit namespace list that could drift.

One thing I couldn't verify: I built the real WASM bundle at cf195a67 via the actual make wasm/build-wasm action pipeline and booted main.wasm under Node with wasm_exec.js, with and without ?mode=dev — no crash, no "error loading xsofy.input" in either case. Doesn't mean the claim is wrong (a JS-host or Worker-thread-specific path my single-threaded harness doesn't exercise could still trigger it), but it's currently unverified on my end. Would ask for the actual repro (browser + flags) so the root cause is nailed down, though it doesn't change whether re-pinning to a real tag is the right move regardless.

Pre-existing gap, not introduced by this PR: no automatic, non-label-gated, non-tag-gated CI lane actually boots the WASM bundle in a browser — test.yml's WASM step only builds it. The only lanes that boot it (tests/browser/smoke.mjs) are gated behind a v* tag push or an opt-in PR label. So the exact failure mode this PR's headline bug describes (builds clean, crashes at runtime) would go unnoticed by default PR CI again in the future. Worth a follow-up issue, not a blocker here. Also: nothing asserts a minimum test/assertion count post-fix, and the runner doesn't fail the CI step on individual is failures — same class of silent-pass risk in a different shape.

Minor: the new .let-go-version comment says the floor lane "never builds or boots" the bundle — it does build it, just doesn't boot it. Small wording nit, not consequential.

Diff mechanics check out: lazy-map → lazy-map* rename in xsofy/check.lg is complete (def + all 6 call sites, private, no external callers possible), and dag.lg's new (:refer-clojure :exclude [peek]) is safe (no internal use of core peek that the exclusion could break). CI green on both required lanes.

🤖 Generated with Claude Code

nooga's review of #195 caught the claim. test.yml's floor lane has run the
build-wasm action since #69, so it does build what the deploy builds; what it
never does is load the result. That distinction is the whole point — this
failure is at namespace load, and a build-only lane cannot see it.

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

Copy link
Copy Markdown
Collaborator Author

The repro

Chromium via Playwright, COI headers, no flags — plain /, no ?mode=dev. Bundle built from this branch's tree with lg at cf195a67, through all four steps of the build-wasm action.

=== TERMINAL TEXT ===
[program exited]
xsofy v0.0.2

=== CONSOLE ===
[log] error loading %s: %v
%!(EXTRA string=xsofy.input, *vm.ExecutionError=ExecutionError: calling dev?
	caused by bit-shift-left expected Int)

The boot probe times out after 60s waiting for the title screen; #app mounts and crossOriginIsolated is true, so the host comes up and the guest dies at namespace load.

Two details that make this easy to miss, and may be why your harness came back clean. It arrives as console.log, not console.error or stderr, so a check that watches the error channel sees nothing. And the format string is botched — error loading %s: %v is printed literally with the arguments trailing in %!(EXTRA ...) — so a grep for the assembled message finds nothing either.

dev? is (or (= (js/url-param "mode") "dev") (os/getenv "XSOFY_DEV")) (input.lg:5-12). Under Node with wasm_exec.js there is no location, so the js/url-param path is not the same one a browser takes. I have not pinned down which of the two calls shifts a non-Int at that pin, so treat the mechanism as unconfirmed — the failure and its trigger are not.

You were right about the floor lane

It does build the bundle. test.yml has run the build-wasm action since #69, so the lane builds exactly what the deploy builds; what it never does is load the result. Corrected in .let-go-version and in the PR body.

The correction sharpens the point rather than blunting it: the gap is not a missing build, it is a missing boot, and a build-only lane cannot catch a namespace-load failure by construction.

#196 carries the same wrong claim in its title and body and needs the same fix; it already tracks the CI gap you flagged, including the assertion-count floor.

@mparrett
mparrett merged commit 9e688e3 into main Sep 20, 2026
4 checks passed
@mparrett
mparrett deleted the fix/repin-letgo-v1122 branch September 20, 2026 22:57
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-20 22:57 UTC

mparrett added a commit that referenced this pull request Sep 21, 2026
The v1 salt format writes a term's byte length ahead of its bytes, which
reads as though the length has to be known first. It does not. The encoder
was running utf8-len over the string to get that byte, then conj-utf8 over
the same string to emit the bytes — decoding every codepoint twice, and
range-checking it twice along the way.

Writing the length as a placeholder and patching it once the real length is
the distance the accumulator moved removes the measuring pass outright. The
checked appender stays on the live path, so there is no second, unchecked
encoder to keep in step with the first, and codepoint->utf8-len, add-utf8-len
and utf8-len go with the pass they existed to serve.

Interleaved A/B, 20000 iterations of [:combat :hit "attacker-17" "target-93"],
all three arms alternating per round, five rounds, min to min:

  base       614 ms
  two-pass   529 ms   -13.8%
  one-pass   451 ms   -26.5%   (-14.7% against two-pass)

Output is byte-identical to base: encode-salt and combine agree over 25 cases
spanning the empty string, 1-4 byte codepoints, keywords, int64 extremes and
nested vectors; the 253/254/255/256/257-byte boundaries agree in both ASCII
and two-byte forms; and the over-255 string, keyword and vector cases throw
the same assertion text, as do the unsupported-type cases.

Suite is 330 tests and 2864 assertions, 0 failures — a real count, since this
is rebased past #195 and the runner no longer reports zero.

Follow-up to the non-blocking note on #194.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mparrett added a commit that referenced this pull request Sep 21, 2026
The v1 salt format writes a term's byte length ahead of its bytes, which
reads as though the length has to be known first. It does not. The encoder
was running utf8-len over the string to get that byte, then conj-utf8 over
the same string to emit the bytes — decoding every codepoint twice, and
range-checking it twice along the way.

Writing the length as a placeholder and patching it once the real length is
the distance the accumulator moved removes the measuring pass outright. The
checked appender stays on the live path, so there is no second, unchecked
encoder to keep in step with the first, and codepoint->utf8-len, add-utf8-len
and utf8-len go with the pass they existed to serve.

Taking the length from the accumulator also closes a seam. It used to come
from codepoint->utf8-len's cond ladder while the bytes came from
conj-codepoint->utf8's, so two ladders had to agree about the
0x80/0x800/0x10000 thresholds indefinitely, with nothing checking that they
did. One of them is now gone, and the other cannot disagree with itself.

Interleaved A/B, 20000 iterations of [:combat :hit "attacker-17" "target-93"],
all three arms alternating per round, five rounds, min to min:

  base       614 ms
  two-pass   529 ms   -13.8%
  one-pass   451 ms   -26.5%   (-14.7% against two-pass)

Output is byte-identical to base: encode-salt and combine agree over 25 cases
spanning the empty string, 1-4 byte codepoints, keywords, int64 extremes and
nested vectors; the 253/254/255/256/257-byte boundaries agree in both ASCII
and two-byte forms; and the over-255 string, keyword and vector cases throw
the same assertion text, as do the unsupported-type cases.

Suite is 330 tests and 2864 assertions, 0 failures — a real count, since this
is rebased past #195 and the runner no longer reports zero. Changing the patch
index from (inc start) to start fails 15 of them.

Follow-up to the non-blocking note on #194.

Co-Authored-By: Claude Opus 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.

2 participants