Skip to content

wasm: the boundary with no C in it, on top of the pinned tree - #4

Draft
alycda wants to merge 37 commits into
nix/flakefrom
wasm
Draft

alycda wants to merge 37 commits into
nix/flakefrom
wasm

Conversation

@alycda

@alycda alycda commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Stacked on #3. Merge that one first, with a merge commit — GitHub will retarget this PR to develop on its own, and its diff will shrink to the wasm work.

The wasm track, built first and pushed last: Exercise 4 and the 2015-12-01 variant, the C API carried into the one runtime that has no C ABI at all. 35 commits on the line, then the merge that joins it to the three later tracks and the flake pin, then one fix to the Verify workflow that the merge made visible.

What the track is

  • The raw route: cargo build --target wasm32-unknown-unknown exports the same two extern "C" functions as wasm exports, and Node's built-in WebAssembly calls them with nothing generated. The module lends the caller an allocator (alloc/free), because JavaScript cannot allocate in linear memory.
  • The generated lap: three #[wasm_bindgen] exports behind a wasm feature, with an Effect consumer that puts Err and panic back into separate channels.
  • Exercise 4 (exercises/ex4-wasm): your Ex 2 wrapper, unchanged, on a new target; TODOs for the string, the allocator and the hostile-input contract.
  • The banner by the other engine: figlet.js on the same .flf, tested row by row against libcaca's output.
  • A wasm devcontainer variant, just setup-wasm, a self-check probe, a wasm32 CI job on all three OSes, and a book chapter covering the five newer tracks with wasm first.

Fixes found on the way to origin

  • just setup-wasm died with exit 127 on a machine with no Node — the machine it exists for. set -e and a command substitution in an assignment.
  • The wasm devcontainer installed nixpkgs' default wasm-bindgen-cli, which is whatever the profile's channel calls current; the crate pin was checked against a different nixpkgs. home.nix now reads the pinned version from days/Cargo.lock and takes nixpkgs' versioned attribute, cached on every channel.
  • The banner fixture is bytes, not text: Git for Windows' autocrlf default put a \r on every row and the wasm32 job's first Windows run failed in that test. text eol=lf on the fixtures.
  • The wasm32 job captured the consumers' output into a variable that died with the step; it tees now. Every Verify track cell had the same shape; they print on failure now.

The merge
Thirteen shared files conflicted, all two lines appending at the same anchor, resolved as the union with the hand-picked lines named in the merge message. .gitignore did not conflict: the extracted node_modules rule under the plan commit is byte-identical to the branch's own.

Verified on the merged tree: self-check with all eight tracks; both justfiles; days/Cargo.lock under --locked; actionlint; default-feature test, clippy, fmt; the pinned full shell's cargo test --workspace --all-features --locked and clippy, lld now in the closure; just days wasm-demo 2015-12-01 end to end with the pinned CLI; mdbook build. CI on this head is running as this opens.

🤖 Generated with Claude Code

… target has none

First build for wasm32-unknown-unknown, straight from the tree:

    error: the wasm*-unknown-unknown targets are not supported by default,
    you may need to enable the "js" feature.

getrandom 0.2, reached through aoc-ornaments → rand 0.8 → rand_core, and
nothing in this day ever draws a random number. On every other target
getrandom has an OS to ask; wasm32-unknown-unknown is the target with no
OS by definition, so the crate refuses to compile rather than guess.

Two backends on offer. `js` asks the JavaScript host through
wasm-bindgen imports — which would hand the raw route a module with
imports to satisfy, and a dependency on the generator it exists to do
without. `custom` lets the crate register its own source: here, one
that returns an error, because this module has no entropy and should say
so if asked. The registration lives in a target-gated module rather than
behind a cargo feature, so a bare `cargo build --target
wasm32-unknown-unknown` builds the day with no flags — the shape the
wasm job asserts.

The dependency is target-scoped, so the host build, its lockfile
resolution on the 1.85 floor, and every other day are untouched.
…nker for it

Second finding from the first wasm build, after getrandom: the exercises
compiled for wasm32-unknown-unknown and then died at the link step with

    error: linker `lld` not found

rustup's toolchains ship rust-lld inside the sysroot and the target spec
reaches for it; nixpkgs' rustc is built with the wasm32 targets in its
list but without a bundled lld (there is no rust-lld under
lib/rustlib/<host>/bin, only rust-objcopy), so the spec's fallback is a
bare `lld` on PATH. nixpkgs' lld package provides it — 13.2 MiB download,
40.0 MiB unpacked, measured with nix-build --dry-run on 2026-09-15 — and
with it on PATH the same build links to a 20 KB module.

Default list, not `full`: the wasm track is the one boundary that needs no
C library, and a linker the compiler cannot link without is part of the
toolchain, not a track's extra. Node and the bindgen tools stay out of
this shell — they are the track's own install, per the devcontainer
variant and `just setup-wasm`.
…unknown-unknown

The first question the wasm work has to answer is whether the toolchains
attendees actually have carry the target: rustup's stable, and the
nixpkgs rustc the workshop shell hands out. nixpkgs' rustc.nix builds
every compiler with wasm32-unknown-unknown in its target list, so the
default shell should already have it — this job is the mechanical form
of that claim, on both OSes, before any code depends on it.

Two cells. `wasm` builds the day and the exercises for the target on
rustup stable (three OSes — the target itself is what is under test, and
Windows is where a missing std would surprise a manual-setup attendee).
The nix cell adds one line to the existing ffi job: the same build,
inside the DEFAULT shell, not the full one — the wasm track needs no C
library, which is the whole practical argument for it.
…rates them

The wasm track brings the first JavaScript into this tree: a package.json
per consumer directory and, on the generated lap, a pkg/ that wasm-bindgen
writes. In a jj repo an untracked file is committed by the next command,
so the rules land first and the installs come after — the same order the
Rust, Python, Swift, Kotlin and Dart sections above already keep.

node_modules/ is global, like target/. The pkg/ rule is scoped to the two
places wasm-bindgen is ever pointed at, for the reason the days' include/
rule is scoped: a directory called pkg anywhere else must stay tracked, and
jj never warns about an ignored file.
…asm` feature

Same two solvers the C API exports, this time through wasm-bindgen: the
generator writes the JavaScript glue that the raw route makes you write
by hand — the string copy into linear memory, the read-back, the error
conversion. That is the trade the workshop's UniFFI slot was cut before
it could show: generated bindings are the same C-shaped module plus a
toolchain, and here the toolchain is a crate and a CLI that must agree
to the patch version.

Three exports, one more than the C API, on purpose. part1 cannot fail.
part2 returns Result, and Santa never entering the basement crosses as a
thrown JS Error carrying the miette message. part2_unchecked is the
version a Rust-only codebase would have written — expect() on the Option
— and crosses as a trap: no Error, a WebAssembly.RuntimeError, and an
instance whose Rust state is now undefined. The consumer in wasm/ exists
to put those two failures back in different channels; this module exists
to produce them from one solver.

Pinned =0.2.121 because that is the wasm-bindgen-cli in the nixpkgs
channel shell.nix reads (<nixpkgs>, nixpkgs-unstable — checked
2026-09-15; the flake registry's nixpkgs is already at 0.2.127). The CLI
refuses a module built with any other crate version, so the pin follows
the channel and the recipe checks the two agree before running. Target-
scoped and optional: the host build, the 1.85 floor and --all-features
on the host never see the crate, and wasm.rs is the only file that does.
…in different channels

wasm-bindgen hands JavaScript one failure shape: something was thrown.
An Err from part2 and the trap from part2_unchecked both arrive that
way, and a try/catch cannot tell a puzzle input that never goes
underground from a broken invariant inside the module. Rust had two
channels for that — Result and panic — and the boundary flattened them.

Effect has exactly two as well, and they line up: a thrown Error becomes
a typed failure in the E channel, which the program can match and
recover from; a WebAssembly.RuntimeError becomes a defect, which catchAll
cannot see, the same way ? never catches a panic. callRust in
src/boundary.ts is the fifteen lines that route them. src/main.ts
mirrors main.rs — read inputs/2015-12-01.txt, print both parts — and
src/demo.ts runs the three outcomes on the statement examples so the
routing is something CI asserts, not something the README claims.

This is the first run of what the effect-playground prototype (07) only
described: it was never executed there. Verified here against the
channel's wasm-bindgen-cli 0.2.121 and Node 24: part2("(((") throws
Error with the miette message; part2_unchecked("(((") throws
RuntimeError: unreachable; and the instance goes on answering part1
afterwards — which is the honest version of 'its state is undefined'.

package-lock.json is committed, the rule days/*/dart/pubspec.lock set: a
worked example pins what it resolved. effect 3.22.2 is the version the
prototype typechecked against; typescript stays on the 5.9 line it was
verified with rather than the 7.x that npm now serves by default.
The C API takes a const char* and writes through an int*. Every caller
so far — the C harness, Python, and the tracks the golden days carry —
had an allocator on its own side of the boundary: it made the string,
it owned the four bytes, it handed over addresses. A JavaScript caller
of the raw wasm module has neither. The only memory the module can read
is its own linear memory, and nothing in JavaScript can allocate inside
it; there is no malloc on that side of the line.

So the module has to lend one out. alloc(size) returns an offset into
linear memory the caller may write; free(ptr, size) gives it back — with
the size, because Rust's allocator wants the layout it handed out and a
wasm module carries no malloc header to recover it from. That is the
callee-allocates ownership contract the reference card describes and no
exercise makes anyone write, arriving from the other direction: not a
string the library returns and the caller must free, but a buffer the
caller must borrow before the library can be called at all.

Target-gated, not feature-gated, next to the getrandom backend: a bare
build for wasm32-unknown-unknown is the raw route, and the raw route is
unusable without these. cbindgen never sees them — it is aimed at
c_api.rs — so the C header stays at two symbols, which is the decision
taken for this branch: the wasm-only surface lives in the wasm-only
module.
…ory, nothing generated

The generated lap's glue, done by hand, against the C API rather than the
wasm-bindgen exports: instantiate the .wasm cargo produced (one file, one
name — no .so/.dylib/.dll dance, and no dynamic linker, WebAssembly
.instantiate takes bytes), borrow len + 1 bytes of linear memory, write
the UTF-8 and the NUL yourself, borrow four more for the int*, call, read
the answer back through a DataView, give both buffers back. Two status
codes, -1 and -2, become two typed failures; a trap becomes a defect,
exactly as in boundary.ts. Nothing here is hidden, which is the point:
this is the file Exercise 4 asks an attendee to write, and the generated
lap next door is what a tool writes for them.

Two details the generated lap never shows. memory.buffer is re-read on
every access rather than cached, because a growing allocation detaches
the old ArrayBuffer and a view captured before an alloc is dead after
it. And alloc/free are paired through acquireUseRelease — Effect's
spelling of Drop — so the free runs whether the call succeeded, failed,
or trapped, which is the discipline the Dart track does with try/finally.

Also the hostile-input contract, from the outside: NULL is the integer 0
here, and invalid UTF-8 is two bytes written straight into the buffer.
Both come back as -1, the same treaty the C harness checks.
…rd.flf, against libcaca's output

libcaca cannot come to wasm32-unknown-unknown: it is a C library found
through pkg-config against a store path, and there is no libcaca for the
target and no dynamic linker that could find one. What can cross is the
font. fonts/standard.flf is data, and the same file libcaca's FIGfont
engine reads in caca.rs is readable by a second, unrelated engine on the
other side of the boundary — patorjk's figlet.js, whose parseFont takes
raw .flf contents. Same font, same text, two implementations of the
FIGfont spec written years and languages apart, byte-comparable output.

That gives this variant an assertion none of the other five has. The
fixture is what `cargo run --features caca` printed for the statement
example "()())" — the banners for -1 and 5 — captured once in a shell
with libcaca (nix-shell -p libcaca pkg-config, 2026-09-15) and committed
as test/fixtures/caca-banner.txt with its trailing spaces intact. The
test renders the same two answers through figlet.js and diffs.

Not @effect-ts/figlet: that package is four years unpublished and pinned
to the pre-v3 @effect-ts/core namespace, which does not interoperate
with the `effect` package this directory uses. Wrapping the untyped,
callback-era figlet.js in a typed Effect service is twenty lines and is
the more honest exhibit anyway.
…r, two modules

Found by running the recipes back to back. `cargo build --features wasm`
and a bare `cargo build` both write target/wasm32-unknown-unknown/debug/
aoc_2015_12_01.wasm, and the two files are not the same module: the
feature build carries wasm-bindgen's import section
(__wbindgen_placeholder__ and friends — the glue satisfies those), the
bare build has none. raw.ts loads whichever is there, and after a
feature build it got

    WebAssembly.instantiate(): Import #0 "__wbindgen_placeholder__":
    module is not an object or function

which is true and useless. The module's import list is readable before
instantiation — WebAssembly.Module.imports is the header, again — so
raw.ts now checks it and fails with the fact: this module asks the host
for things, it was built with the wasm feature, rebuild without it. The
raw route's whole premise is a module that asks for nothing; a module
that asks is the generated lap's, and it needs the generated lap's glue.

The recipe that follows rebuilds the bare module after generating the
glue for the same reason: the two laps share a target directory, and
the last build wins.
…policies

One verb per variant, like python-demo and dart-demo: build the day for
wasm32-unknown-unknown with the wasm feature, generate the glue, run
the Effect consumer against your input, then the raw route, then the
banner test. What the two recipes differ in is who fetches the
generator.

wasm-demo uses the wasm-bindgen on PATH — the nix channel's, or the one
`cargo install wasm-bindgen-cli --version <pin>` put there — and refuses
before building if its version is not the crate's: the CLI rejects a
module built with any other patch version, and the message it gives
names neither the lockfile nor the fix. The recipe asks cargo pkgid
for the pinned version and the CLI for its own, and prints both with
the install command when they disagree. Everything is local; nothing is
fetched.

wasm-pack-demo lets wasm-pack do it: it reads the same lockfile,
downloads a matching wasm-bindgen into ~/.cache/.wasm-pack on first use,
and runs the same two steps. No pin to keep in sync, one network fetch
inside a build. Run both once; the plan's expectation is that the first
stays as the workshop's path and the second becomes the README's 'on a
machine without a nix shell' paragraph, but that is a call to make after
watching them, not before.
…ned CLI, and the three consumers

The wasm job proved the target builds. Now it proves the track, and it
does so by running the recipe: `just` is one more prebuilt binary from
taiki-e/install-action (the Verify workflow already takes it that way),
so the job runs `just days wasm-demo 2015-12-01` rather than a copy of
its steps — the version guard, the feature build, the glue, the bare
rebuild, and the three consumers, tested as attendees run them. A step
added to the recipe is a step added here. `just` goes on every other
job's runner too, for the next job that wants a recipe; those jobs keep
their spelled-out commands because `just days verify` bundles three gates
the jobs deliberately keep separate.

What the job asserts: the Effect program's -1 and 5 through the
generated glue, the same two numbers through linear memory plus the
hostile-input contract from the outside, the banner test with its
one-column verdict against libcaca's fixture — all inside the recipe —
and then, outside it, the strict typecheck and the three-channel demo,
which exits nonzero if Ok, Err or a trap lands in the wrong channel.

Three OSes: node and npm ship on every stock image, install-action has
manifests for just and wasm-bindgen on all three, and the consumers
compute their own paths, so Windows costs nothing extra here — the
first place in this repo that is true of a language track. The pin
appears in one more place now (the install-action line); the recipe's
check is what catches the three copies drifting apart.
The variant table grows two rows for one boundary, because the boundary
has two laps and they teach different things. The raw route is the C
API called from JavaScript through linear memory — the first row in the
table whose direction is not the C ABI wearing a different hat — and the
generated lap is wasm-bindgen's glue plus an Effect consumer that puts
Err and panic back in the channels the boundary flattened.

The learnings are the ones the build produced, in the order it produced
them: the target with no OS refusing a dependency that wanted one; the
linker nixpkgs' rustc does not carry; the allocator a caller must borrow
because it has none; the header that is now inside the module; one
target directory holding two different modules; a panic that leaves the
instance answering; and the font that crossed as data to be rendered by
a second engine that agrees with the first to within one column — with
the reference figlet CLI as the tiebreaker that says which one is off.
…new target

Exercise 4 is the wasm track, and its first lesson is a diff: the crate
here is Ex 2's shape — a cdylib over your Ex 1 solver, the same
ex_part1(const char*) -> i64 — built for wasm32-unknown-unknown instead
of the host. The wrapper compiles for the new target with no edits,
which is the point of TODO 0: paste yours in and watch it build. Ex 2
itself stays frozen; this is a sibling crate, not a flag on that one, so
the morning's contract is byte-identical before and after.

Then the one thing the new runtime needs that C never asked for. A
JavaScript caller has no allocator inside the module's linear memory, so
TODO 1 is two exports — ex_alloc and ex_free — before any string can
cross at all. That is the callee-allocates ownership contract from the
reference card, met as a precondition rather than as a returned string,
and it is the reason this exercise exists as more than 'run cargo with a
--target'.

build.sh is Ex 2's four beats for this target: cargo build for
wasm32-unknown-unknown, cbindgen for a header nobody will read (the
module's export section is the header now — the script prints it), and
the consumer. Step 0 is the same as Ex 2's: run it before implementing
and watch the todo!() — here not an abort but a trap, RuntimeError:
unreachable, and an instance that is still there afterwards.
…Int, and two channels for what goes wrong

The caller for Exercise 4, as a TODO ladder in TypeScript with Effect,
the same shape as the four Ex 3 tracks: each TODO is one thing the
runtime needs that the C header could not say, and the last one is the
hostile-input contract proved from this side. Worked reference for every
step: days/2015-12-01/wasm/src/raw.ts.

  TODO 2  instantiate — one file, one name, no dynamic linker, and the
          export section printed by build.sh is the header now
  TODO 3  the string crosses — TextEncoder, ex_alloc(len + 1), a view
          created after the alloc, the NUL written by you
  TODO 4  call — and why the answer is a BigInt: i64 has no JavaScript
          number, so 5 comes back as 5n and -1 as -1n
  TODO 5  the contract — NULL is 0, garbage is two raw bytes, both -1n;
          and what a trap looks like from here, and what is still alive
          after it

The Effect part is not decoration. The sentinel is a typed failure and
the trap is a defect, so the compiler knows which of the two a caller may
recover from — which is the distinction the C ABI erased in Ex 2 and Ex
3 and this runtime hands back, if the caller is written to take it.
Each borrow of linear memory is an acquireUseRelease, so ex_free runs
however the call ends: Drop, spelled in TypeScript.

npm ci is step 0, against a committed lockfile; effect and the tooling
are pinned to the versions the day's consumer verified. The pkg/ rule in
the root .gitignore covers this directory too, for the bonus lap.
…ked for?

The exercise's front page, in the shape of Ex 2's and Ex 3's: goal,
route, tasks in order, the key concepts, the worked reference, and the
debrief question. The route is the raw one — nothing generated — and
the README says why, and where the generated lap lives for the bonus:
days/2015-12-01/wasm, where wasm-bindgen writes what TODO 3 and 4 make
you write, and the Effect consumer there shows what that glue still
cannot do.

Timing is a placeholder: this block has never been in front of a room,
and the plan's own rule is that the deck transcribes from validated
exercises. [??s] until it has been run.
…sysroot

A fifth optional row, in the shape of the other four: one probe, one
--track name for CI, one hint that names the recipe. Two things have to
be true for the track and neither is a tool's mere existence. Node,
because the caller is a script — 22 is the floor, being what CI and the
wasm devcontainer run rather than a feature anyone needs. And the
target's std, which is what `cargo build --target wasm32-unknown-unknown`
actually needs: rustup installs it on request (`rustup target add`), the
nix shell's rustc has it built in, and the probe asks the sysroot rather
than either toolchain — `rustc --print sysroot` then the target's lib
directory — so the answer is the same however rustc got there. The
linker is not probed: the nix shell now carries lld and rustup bundles
rust-lld, and a missing one fails at the first link with a message that
names itself.

just setup-wasm is the recipe the hint points at: `rustup target add` on
the manual path (a no-op under nix, and the recipe says so), and the Node
install pointer per OS, since Node is the track's own install and stays
out of the shell.
The attendee scaffold ships with todo!() and TODOs, so nothing on main
can run Ex 4 either; .github/ci/exercises gains the same two files
solved once — src/lib.rs with the Ex 2 wrapper pasted in and the
allocator pair filled, wasm/src/ex4.ts with the string written by hand
and the BigInt checked — on the overlay's own day (2025 day 3, whose
part-1 answer is 357 and whose part-2 answer is above 32 bits, which a
BigInt carries without comment). The Verify workflow copies them over
and runs `just exercises wasm`, the exact command the README gives.

A wasm column in the track matrix, on all three OSes: the self-check's
--track wasm probe (node, and the wasm32 std in the sysroot) is the
cell's verdict, provisioned with setup-node and `rustup target add` the
way the python cell provisions cffi. The end-to-end steps stay off
Windows with the other exercises' — Ex 2 must be green first and its own
script stops there — even though nothing in Ex 4 itself is Windows-shaped.
…iant

The wasm track's own install, in the shape of the kotlin and flutter
variants: a copy of git/devcontainer.json with a name and a
WORKSHOP_HOME_NIX pointing at a home.nix that imports the shared one
and adds what the track needs — nodejs_22 (the floor the self-check
probes and CI runs), wasm-bindgen-cli at the channel's version (which
is the version days/Cargo.toml pins, and the reason the pin is what it
is), and wasm-pack for the other recipe. The target's std and the
linker are not here: they come from shell.nix's rustc and lld, which
every variant already gets.

Node stays out of shell.nix on purpose — 45 MiB that no other track
needs — so this variant and `just setup-wasm` are the two ways to get
it, and the README row says which is which.
Where attendees meet it: the setup guide's track list gains
`just setup-wasm` and the sentence that says what it is — Exercise 4's
track, afternoon material, a runtime with no C in it — and the day menu's
2015-12-01 row says the day now carries the wasm variants, both laps.
Nothing about the required toolchain changes: the five tools are the
same five, the target's std comes with them, and the linker is in the
shell.
…e, and derives the pin

Found by review, reproduced with `just -n`: rust.yml's default
working-directory is days/, so `just days wasm-demo` ran from inside the
module directory, where the nearest justfile is days/justfile and there
is no `days` recipe. The step failed before any build on every trigger;
the version guard, the bare rebuild and the three consumers were never
reached. From days/ the recipe is `just wasm-demo`, and the input lines
above it were already written for that cwd.

Two more things in the same job, from the same review. `just` is
installed only where a job calls it — this one — rather than on every
runner; the other four jobs spell their commands out and a download they
never use is a step to keep in sync. And the wasm-bindgen pin is derived
from the lockfile with the same `cargo pkgid` line the recipe's guard
uses, rather than written into the workflow as a third copy; bumping the
crate no longer trips the guard in the job that exists to prove it.

Caching, while here, in both workflows: rust-cache covers the exercises
workspace the wasm job also builds, and setup-node caches npm against the
committed lockfiles — a fresh runner never has node_modules, so the
recipes' own guard never helps in CI without it.
…ys true

wasm-demo and wasm-pack-demo ended in the same six lines — the bare
rebuild, the cd, npm ci, main, raw, test — and only wasm-demo runs in
CI, so a consumer added to one would go missing from the other with
nothing to notice. The tail is now one recipe, _wasm-consumers, that
both name as a post-dependency (`&&`, so it runs after the generator,
which the bare rebuild depends on), the same shape the C tracks use
with `(bindgen day)`.

The guard's hint said the wasm devcontainer 'ships' the pinned version.
That is a snapshot: the container reads an unpinned channel, and the day
nixpkgs-unstable moves past 0.2.121 the hint is false in exactly the
environment that produced the error. It now reports what is on PATH and
names the two real fixes — install the pinned CLI, or move the pin to
what the channel ships — without claiming either environment agrees.
…one false claim

Three review findings on the exercise, one commit because they are all
'what the scaffold promises versus what runs'.

The typecheck script was never invoked: tsx strips types without checking
them, so the strict tsconfig the README leans on — 'the compiler keeps the
two channels apart' — gated nothing, and an attendee whose TODO 4 returned
a number against a bigint met a runtime 5 !== 5n instead of a type error.
`just exercises wasm` now typechecks before it runs, on the scaffold and
on the solved overlay alike.

build.sh wrote a cbindgen header into include/ for comparison only, and
supporting that file cost a .gitkeep, an ignore rule, and a hard cbindgen
dependency on a track whose pitch is 'no C in it'. cbindgen prints to
stdout without --output; the header now appears in the terminal next to
the export section it is there to be compared with, and the three files
that existed to hold it are gone.

ex4.ts's header said `just setup-wasm` covers npm ci. It does not — the
recipe does, once, and the README already said so correctly.
…t from the generated lap

Review caught what the file's own comment admitted: the raw route
imported callRust from boundary.ts and then carried a RustError case in
describe that it called unreachable, because the raw exports return
integers and never throw a JS Error. Two things wrong with that. The
route whose whole claim is 'nothing generated' depended on the generated
lap's file; and this is the worked reference for Exercise 4, whose
scaffold already has the eight-line die-only `call` — the reference and
the exercise disagreed on the shape of the one function that routes a
trap. raw.ts now has that same `call`: a trap is a defect, and there is
no other channel because the ABI has no other failure.
tsconfig included src/ only, so the CI typecheck never saw
test/banner.test.ts, and `npm test` runs it through tsx, which strips
types without checking them — a mistyped test was silent everywhere.
Measured by review with `tsc --listFilesOnly`: five src files, no test.
One line: test/**/*.ts joins the include.
banner() ran loadStandardFont on every call — the doc comment said
'idempotent', which was true and beside the point: idempotent here meant
the file was re-read and re-parsed into figlet.js's registry for every
banner, four times per test run. Effect.cached at module scope makes the
first run the only one; a consumer that banners N answers pays one read.
…er needs it

Review found a latent contract violation: alloc handed out
Layout::from_size_align(size, 1), and raw.ts passed one of those
pointers as the int* the C API writes through. Nothing failed — std's
dlmalloc returns eight-aligned blocks on wasm32 for every request — but
the export promised one-byte alignment and the caller relied on eight,
which is the kind of thing that stays true until the allocator changes.
alloc and free now use alignment 4: enough for the int32_t the C API
writes, harmless for the strings, and stated in the doc comment as part
of what the caller is being lent. Exercise 4's scaffold keeps alignment
1; its wrapper takes strings only and returns the i64 by value.
…ays how to link node@22

Three review findings on one recipe. The rustup half was six lines
duplicated verbatim across the [macos] and [linux] variants; it is one
private recipe now, _setup-wasm-target, and the OS split covers only
the Node line, which is the only part that differs — the shape the
other per-OS pairs already have.

On macOS, `command -v node && node --version || brew install` treated
any node as done, so a v18 machine passed the recipe and then failed
the self-check's floor with no path through; the recipe now reads the
major version and installs only below 22. And brew's node@22 is a
versioned formula Homebrew keeps unlinked (keg-only, as its versioned
formulae are by policy — check `brew info node@22` on a Mac if this
ever looks wrong), so after installing it the recipe prints the link
command, the way setup-kotlin does for its keg-only JDK.
…es Git Bash

The floor was a literal 22 in the probe, in both setup-wasm variants and
in two workflows, where the Dart probe reads its floor from the exercise
pubspec 'so the number lives in one place'. The wasm exercise's
package.json now declares engines.node, the probe and the setup recipe
read the major out of it, and CI's setup-node steps stay pinned to what
they run — a bump is the package.json and the two workflow lines, and the
probe cannot disagree with the package it probes for.

Second, from the same review: on the Windows cell `rustc --print sysroot`
returns a C:\\ path and the probe tests it with a POSIX [ -d ]. MSYS
usually converts drive-letter paths, but the cell is otherwise
unexercised and the probe's exit code is its whole verdict, so the path
goes through cygpath when cygpath exists — the same tell fetch-jna.sh
uses to notice Git Bash.
… lockfile

Two review findings on wording. The day README said 'wasm/ has its own
(npm test)', which reads as the wasm variants having tests; npm test is
the banner test only, and the boundary code is exercised by npm run raw
and demo, which are consumers. The line now says which is which.

exercises/.gitignore explains why the Dart scaffold does not commit its
pubspec.lock — the attendee resolves it, so the lock is their machine's
output — and Exercise 4 commits a package-lock.json without a word about
the opposite choice. The word: npm ci needs the lock to exist and refuses
to drift it, so an attendee never resolves anything; committing it is
what makes 'npm ci runs once' true on every machine the same way.
alycda and others added 7 commits September 15, 2026 19:09
… for that machine

`just setup-wasm` on a box without Node printed the rustup line and died:
`recipe setup-wasm failed with exit code 127`. Both recipes run under
`set -euo pipefail` and then do

    major="$(node --version 2>/dev/null | sed -nE '…')"

With no node the pipeline's status is 127, an assignment takes the status
of its command substitution, and `set -e` ends the script on that line —
before the `else` branch that says where to get Node 22, which is the only
reason the recipe runs on such a machine at all. Node present, it never
showed: the substitution succeeds and the floor check runs as written.

`|| true` inside the substitution, on both the linux and macOS recipes.
Not `command -v node` up front: the floor check must still run when node
is present, and this keeps one code path.

Reproduced and verified on a PATH holding only the tools the justfile
itself needs: the old recipe exits 127 after the rustup line; the patched
one prints the Node 22 pointer and exits 0; with node on PATH it prints
"node v24.19.0 meets the 22 floor" and exits 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…channel

Reopening the repo in the wasm variant and running `just days wasm-demo
2015-12-01` stopped at the recipe's own guard: wasm-bindgen-cli 0.2.127 on
PATH, days/Cargo.lock pinning the crate at 0.2.121, and the CLI refuses any
other version. home.nix installed the bare `wasm-bindgen-cli`, which is
whatever the profile's channel calls current — 0.2.121 on the channel the
track was written against, 0.2.127 on nixpkgs-unstable the same week,
which is what the devcontainer's nix feature leaves in place. The comment
above the pin said as much: "a channel bump here is a pin bump there". The
bump happened on its own.

nixpkgs keeps every recent CLI release as its own attribute
(wasm-bindgen-cli_0_2_93 through _0_2_127 today), hashes maintained
upstream and built by Hydra. So home.nix now reads the pinned version out
of days/Cargo.lock with builtins.fromTOML and takes the attribute that
names it — the same store path as the default package when the channel
agrees, the right older build from the binary cache when it does not, and
a message naming both the pin and the two ways out if nixpkgs has no
attribute for it at all. Three comments that described the old policy
(Cargo.toml's pin, the recipe's guard, the variant table) now describe
this one.

Not taken: pinning the crate to whatever the channel has today, which the
recipe's hint offers as an option — it moves the drift, it does not end
it. Also not taken: the flake pin on develop. It pins shell.nix's nixpkgs,
but the profile the devcontainer builds with home-manager reads the
container's channel, not shell.nix's — so the pin alone would not have
prevented this; the lockfile is the authority both sides can read.

Verified by evaluating the variant's package list against both channels:
26.05 and nixpkgs-unstable each resolve to nodejs 22, wasm-bindgen-cli
0.2.121 and wasm-pack; on unstable the 0.2.121 build comes from
cache.nixos.org and reports itself as 0.2.121; on 26.05 the versioned
attribute is byte-identical to the default. Not verified by rebuilding a
container — `just _rebuild` inside the variant is what applies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The boundary chapter is the record of one C API crossed from four
languages. Since then the same header has been carried into five more
runtimes, and each asked for something C never did. This chapter is those
five, one section each, answering the same three questions every time:
what crosses and in what shape, the one lesson that runtime teaches that
no other can, and the front door — what you install, what the recipe
runs, where the runtime's version gets to matter.

It is longest on wasm because wasm is Exercise 4 and the one boundary with
no C in it: the export section as the header, the caller with no
allocator, three failure channels where C had one, a trap that leaves the
instance answering, the raw route beside the generated lap, and the
toolchain clauses (std and linker as separate deliveries, a target with no
OS refusing `getrandom`, a generator version-coupled to its CLI). Fortran,
R and Godot each get the lesson their day README already earned:
by-reference for free and the hidden length from both sides; `.C()`
discarding the return value; a descriptor nothing validates and a panic
that leaves the return slot unwritten.

Written from the day READMEs and the exercise, not from memory; the
material for Fortran, R and Godot lives on the develop line, so on this
line the chapter is ahead of the tree it describes and reads correctly
once the lines meet. `mdbook build` is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a Windows checkout

The wasm32 job's first run on windows-latest failed in `_wasm-consumers`
(run 35016847250), and the step had captured the consumers' output into a
variable that died with it, so the log said only that a recipe failed. The
consumers themselves narrow it down: nothing pins line endings in this repo,
Git for Windows checks text files out with CRLF by default, and
banner.test.ts splits `test/fixtures/caca-banner.txt` on "\n" and compares
each row byte for byte with figlet.js's. Reproduced here by converting the
fixture to CRLF: the first assertion fails with `actual: '🦀:\r', expected:
'🦀:'`. The font beside it survives the same treatment — figlet.js
normalises while parsing — so it needs no rule.

`text eol=lf` on the fixtures directory keeps the recorded bytes on every
platform. Proved on a scratch repo cloned with core.autocrlf=true: a control
file comes out CRLF, the ruled fixture stays LF. Not run on the Windows
runner; the next push is what proves the job.

Not taken: normalising "\r" in the test. The fixture is a byte-exact record
of what libcaca printed, and the comparison is the point; teaching the test
to forgive a transport artefact would hide the next one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The step ran `out="$(just wasm-demo 2015-12-01)"` and echoed it afterwards.
With the shell's `set -e`, a failing recipe ends the step at the assignment,
and everything the consumers printed to stdout dies with the variable; only
stderr survives, and the consumers say nothing there. The first
windows-latest run (35016847250) therefore logged exactly one line, `recipe
_wasm-consumers failed with exit code 1`, for a fifteen-second failure.

`tee` to a file keeps every line in the log whether the recipe passes or
fails, `pipefail` keeps the recipe's exit status, and the assertions read
the file. The same capture shape is in every env-check track cell; those
have not failed yet, and this commit leaves them as they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shell, one lock

wasm on top of nix/flake: the track that was built first and pushed last
meets the three tracks, the octopus merge, and the flake pin that grew
underneath it. Thirteen files conflicted, and all of them were the same
shape again — two lines appending at the same anchor in the shared
plumbing — resolved as the union in branch order (the pinned tree first,
then wasm), with the lines a union cannot decide picked by hand: the
matrix track list and self-check's usage and case arms gain `wasm`, the
devcontainer sentence and the base variant's comment name it, the day
README's count is eleven solves, and the days index row names both wasm
routes. .gitignore did not conflict at all: the node_modules rule the
gitignore extract had rolled back under the plan commit is byte-identical
to the wasm branch's own, and diff3 knows an identical change when it
sees one.

The union collapsed two identical trailing lines again — probe_godot's
closing brace against the wasm probe's opening comment, and the blank
before the wasm paragraph in the day README — the same trap the tracks
merge recorded; restored.

Verified on the merged tree: self-check parses and lists all eight tracks;
both justfiles parse; days/Cargo.lock holds under --locked; actionlint has
nothing beyond its four standing shellcheck notes; default-feature test,
clippy and fmt clean; `nix-shell --arg full true --run 'cargo test
--workspace --all-features --locked'` and the clippy line after it green
at the pin, which now carries lld; `just days wasm-demo 2015-12-01` runs
the raw route, the generated lap and the banner tests to green with the
pinned CLI; `mdbook build` clean with the new chapter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every end-to-end cell captured its consumer's output with `out="$(…)"` and
echoed it on the next line. Under the runner's `bash -e`, a consumer that
exits non-zero ends the step at the assignment, and the captured stdout —
the answers it printed, or the message it failed with — dies with the
variable. Only stderr reaches the log. The wasm32 job in rust.yml hit this
on its first Windows run and logged one line for a fifteen-second failure;
these twenty-four cells have the same shape and have simply not failed yet.

One clause on each plain capture: `|| { rc=$?; echo "$out"; exit $rc; }`.
The assignment still happens on failure, so the echo prints what was
captured; the `||` list keeps `set -e` from firing first; the step exits
with the consumer's own status. Proved under the runner's exact shell flags
(`bash -e -o pipefail`): a command that prints then exits 3 leaves both
lines in the log and the step exits 3; a successful capture is unchanged.

Left alone on purpose: the r cell's negative test, which already branches on
the capture's status and echoes on both arms. The wasm32 job keeps its
`tee`, a different shape because there the output was also the diagnosis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alycda
alycda added this pull request to stack #5 September 15, 2026 20:49
# scratch the first time: the crate generates bindings for the whole
# engine API from a JSON file it ships, and that is minutes rather than
# seconds. An expression rather than raising the number for all nine,
# seconds. An expression rather than raising the number for all ten,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Suggested change
# seconds. An expression rather than raising the number for all ten,
# seconds. An expression rather than raising the number for all tracks,

"typecheck": "tsc --noEmit"
},
"dependencies": {
"effect": "^3.22.2",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Comment thread days/Cargo.toml

# wasm32 only, and one day so far (2015-12-01): aoc-ornaments pulls rand,
# rand pulls getrandom, and getrandom refuses to compile for
# wasm32-unknown-unknown until told where entropy comes from — the target

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🎲

Comment on lines +9 to +11
Afternoon / bonus material: this block has not been in front of a room
yet, so it carries no timing. It slots after Ex 3, and it is the answer
to "try another language" when the other language has no C ABI at all.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

TODO: other bindgen or wasi-environment

@alycda alycda self-assigned this Sep 15, 2026
@alycda alycda added enhancement New feature or request help wanted Extra attention is needed labels Sep 23, 2026
alycda pushed a commit that referenced this pull request Sep 23, 2026
In the room, one integer stopped being enough twice. Ex 2's -1 error
value collides with 2015-12-01's real answers, and 2022-12-01's menu row
already says its array is the thing worth exposing. This plans an
afternoon extension after Ex 4. It is a ladder of return shapes, each one
fixing the previous one's pain and bringing its own:

  A caller buffer   B callee allocates + free   C repr(C) structs
  D opaque handle   E bytes + schema (hand-rolled, then protobuf)

Stacked on #4. Rung B is Ex 4's alloc/free pair run the other way. It
also assumes #7's status table, because rung A extends -3 to report the
length needed. That extension changes #7's "out untouched on error" rule,
so the plan says the rule has to change explicitly.

What was checked, not assumed: 2022-12-01's parser, run from a scratch
crate against this tree. An empty input or a trailing blank line yields
a 0-calorie phantom elf. It is invisible to max and to the top-3 sum, and
becomes observable the moment the boundary returns the array. That is
Hyrum's Law, and it is Part 5's opening demo. CRLF input is a hard parse
error. Claims about other runtimes and libraries are marked [verify].

The plan also records a follow-up #4 needs regardless: raw.ts's MEANING
table lists only -1/-2, so after #7 a -3 or -4 prints "unknown status".

Nothing built. Five decisions are listed at the end for the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6DLQXw4LR4JkRTXNtbuaX

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant