Quartz v5.25

Handoff — WASM playground live on the unikernel (Apr 19 2026, afternoon)

Session summary. Picked up from the post-Phase-2 handoff with “polish the joy-of-quartz-site + get WASM actually working” as the mandate. Both happened. Three commits on unikernel-site (the worktree at .claude/worktrees/unikernel-site/):

a700b98c [kern]   Wire WASM playground end-to-end + styled 404 + live request feed
f911ddd6 [parser] Fix @cfg(feature:...) on import — blocked --feature wasm builds
39aa7db5 [kern]   Handoff: unikernel-site Phase 1+2 done (previous session)

What landed

1. WASM playground, actually running in the browser (a700b98c)

Seven Quartz programs (hello, pipeline, closures, structs, fib, compose, bits) are now pre-compiled to .wasm blobs, baked into the unikernel ELF as static assets, and served at /playground/demo/<name>.wasm with the right Content-Type (application/wasm). The matching source is served beside each as /playground/demo/<name>.qz (text/plain).

playground.astro got a pre-baked fast path: if the editor source matches an unedited EXAMPLES[key] and we have a pre-baked blob for that key, fetch .wasm straight from the unikernel and run it via the existing WASI-shim + WebAssembly.instantiate() pipeline. No backend compiler needed, no /api/compile round trip. Edited source still falls through to the compile-server path with an improved “you need a live server” error message that now tells the reader the pre-baked demos work without one.

End-to-end verification locally (QEMU -M microvm, hostfwd :18085-:80):

curl .../playground/demo/hello.wasm → 1667 bytes, application/wasm
wasmtime on downloaded blob → "Hello, Quartz! ..." + exit 0

All seven demos compile and run correctly.

2. @cfg(feature: ...) on imports, unblocked (f911ddd6)

Discovered while trying to build --feature wasm that the parser has had a latent bug forever: the attribute-decl dispatch at frontend/parser.qz:~8065 accepts TOK_DEF, TOK_STRUCT, TOK_ENUM, TOK_TYPE, TOK_NEWTYPE, TOK_VAR+@thread_local, TOK_EXTERN — but not TOK_IMPORT. So when @cfg(feature:"X") evaluated to true, the import line that followed it errored out with QZ0101: Attribute must precede a definition. ps_skip_cfg_definition has already handled TOK_IMPORT since forever, so the two paths were asymmetric — the negative (cfg-false) case worked, the positive (cfg-true) case didn’t. Nobody had built --feature wasm recently enough to notice.

Added the TOK_IMPORT branch alongside the TOK_EXTERN one. Rejects any attribute other than @cfg on imports (category error) but accepts @cfg which is the whole point. Fixpoint held at 2144 functions (+3 for the new branch). Guard passed, style_demo + brainfuck smoke-test green.

3. /api/recent.json + live request feed on the landing (a700b98c)

64-slot ring buffer of recent served paths + tick + HTTP status, written to from tcp_handle_frame after the response is formed. Exposed via a new dynamic route and polled by the landing page’s existing 500 ms fetch loop. The landing now has a “Live request feed” card that fills with rows as requests stream in — gives the visitor a visceral “other people are here right now, and that previous curl you did is showing up” feel.

Memory cost: 96 bytes × 64 slots = 6 KiB = 2 PMM pages. Paths longer than 64 bytes get suffix-trimmed (preserving the distinguishing tail like a/b/c/d.html over the common /docs/ prefix).

4. Styled 404 (a700b98c)

Replaced the plain-text 404 not found\n with a dark-themed HTML page matching the landing, including a mini stack diagram showing the path the missed request took through the kernel. Purely browser-side polish; API clients still see the 404 on the response line.

5. Asset table bump (a700b98c)

MAX_ASSETS went from 128 → 512 and asset_table_init() now allocates 8 PMM pages (was 2) so the bigger table fits. Without this, the playground-demo bake tipped total asset count from 88 to 228 and half the demos silently got dropped at g_asset_count >= MAX_ASSETS. The comment at the constant site now spells out the PMM math so the next bump isn’t a guess.

What’s NOT fixed, with details

WASM_STRING_INTERP_PTR.md (new)

The WASM backend prints the pointer address of a String value when it’s interpolated via #{s} — LLVM and C backends format the text correctly; only WASM is broken. Filed at docs/bugs/WASM_STRING_INTERP_PTR.md with repro, likely cause (to_s overload missing in codegen_wasm), and the workaround used for the demos (avoid #{string_var}, use + concat or literal strings). P2 — not blocking the demos but should land before advertising the WASM backend as feature-complete.

Production deploy still manual

The new ELF is at tmp/baremetal/quartz-unikernel.elf (5.9 MB, up from 2.4 MB — mostly the baked .wasm + additional assets). To ship:

# From the worktree:
scp tmp/baremetal/quartz-unikernel.elf mattkelly.io:/opt/quartz/quartz-unikernel.elf
ssh mattkelly.io systemctl restart quartz-unikernel
# Verify:
curl -I http://195.35.36.247:8080/playground/demo/hello.wasm
curl    http://195.35.36.247:8080/api/recent.json

Not pushed from this session because deploy = production change = wants user authorization. No hidden deltas: what runs locally matches what’d run there.

RX ring stall and 209 KB TX stall still open

Both carry-overs from the previous session. docs/bugs/UNIKERNEL_RX_RING_STALL.md needs DEF-B (IOAPIC + IRQ-driven RX, HIGH brick risk) and docs/bugs/UNIKERNEL_TX_STALL_209KB.md needs real TCP window tracking + retransmits. Neither is on the WASM path — the demos are ≤ 2.3 KiB each, well under either ceiling.

I created site/node_modules -> /Users/mathisto/projects/quartz/site/node_modules as a local-only convenience so Astro builds work from inside the worktree without redundant npm install. Already in the site/ .gitignore as node_modules/, so git ignores it. Mention in case anyone wonders why the worktree has one.

What’s next, ordered by punch-through

A. Deploy + verify on the VPS

Cheapest win. scp + systemctl restart and confirm each demo runs end-to-end in a real browser at http://195.35.36.247:8080/playground. Look at /api/recent.json fill with your own paths. User call on timing.

B. Fix WASM_STRING_INTERP_PTR.md

The doc has a best-guess fix location (interpolation lowering in codegen_wasm.qz, cross-check against codegen.qz). Likely a half-session compiler fix. Unlocks string-heavy demos (strings.qz, collections.qz — both currently in EXAMPLES but not in PREBAKED).

C. Live compile server

The /api/compile POST endpoint in playground.astro is unimplemented. Hooking it up lets users edit the demos and run the result. Two shapes:

  1. Caddy-proxied host service (simplest): a tiny Node/Go/Rust HTTP wrapper on the VPS that spawns quartz-wasm --backend wasm - on stdin. Needs sandboxing (firejail / container / seccomp) to prevent arbitrary compilation from burning VPS CPU.
  2. Compiler-in-WASM (farthest): compile the Quartz compiler itself to WASM so the whole toolchain runs in the browser. This is TGT.3 Phase 9+ territory — months of work.

B is much higher priority than C.

D. HTTPS via unikernel.mattkelly.io

Still blocked on the user adding an A record. Caddy config snippet in the previous handoff.

E. Smaller polish still worth doing

From the prior handoff, still applicable:

  1. /api/build_info.json — ELF size at boot, compiler version, baked-at timestamp.
  2. Bake oversized docs in chunks with server-side concat as a stop-gap before TCP window (B from the prior handoff).

F. Automate the .wasm bake

Right now the pre-baked .wasm files are checked-in binaries. A new quake task baremetal:bake_wasm_demos could:

  1. Build --feature wasm if the stamp is stale (~60s total).
  2. For each .qz in site/public/playground/demo/, compile to .wasm into the same dir.
  3. Be a prerequisite of baremetal:bake_assets.

Then changing a demo .qz doesn’t require manual recompilation. Half-session job — straightforward if slightly repetitive.

Repo state

  • Branch: unikernel-site, 3 commits ahead of the previous handoff (7 ahead of trunk at 1d90d51b).
  • Worktree dir: .claude/worktrees/unikernel-site/.
  • Production ELF: mattkelly.io:/opt/quartz/quartz-unikernel.elf, still at 2.42 MB (has not been updated this session — pre-WASM demos). Regenerate + deploy per section A above.

What worked (notes for future-me)

  1. The “15 min / 30 GB” WASM build cost was stale — actually ~60s total (compile IR 33s + llc 15s + link 2s) on this Mac. The fear-of-cost in the prior handoff had us prepared for a much worse worst case. Don’t let stale benchmarks gate the design.

  2. The parser bug was a one-line latent regression. Once @cfg(feature:"wasm") on import codegen_wasm was the only failing line, the fix was obvious (TOK_IMPORT branch in the attribute dispatch). Future cfg-gated imports won’t hit this.

  3. Pre-baking .wasm + fetching matches the existing playground architecture. The page already had a full WASI polyfill, exit-code handling, and Monaco editor wiring. All I needed was to intercept before the /api/compile POST with a static-asset GET. Clean integration.

  4. The prior handoff’s “use ONE of pre-bake or live-compile” framing was wrong — both work naturally, pre-bake for unedited demos is free win, live-compile remains the upgrade path for edits. Not mutually exclusive.