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#{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.
Unikernel worktree’s site/node_modules symlink
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:
- 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. - 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:
/api/build_info.json— ELF size at boot, compiler version, baked-at timestamp.- 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:
- Build
--feature wasmif the stamp is stale (~60s total). - For each
.qzinsite/public/playground/demo/, compile to.wasminto the same dir. - 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 at1d90d51b). - 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)
-
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.
-
The parser bug was a one-line latent regression. Once
@cfg(feature:"wasm")onimport codegen_wasmwas the only failing line, the fix was obvious (TOK_IMPORT branch in the attribute dispatch). Future cfg-gated imports won’t hit this. -
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/compilePOST with a static-asset GET. Clean integration. -
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.