Skip to content

Editor preview runs the real C — eliminate the Python twin - #2

Open
silviot wants to merge 8 commits into
mainfrom
editor-c-preview
Open

Editor preview runs the real C — eliminate the Python twin#2
silviot wants to merge 8 commits into
mainfrom
editor-c-preview

Conversation

@silviot

@silviot silviot commented Jul 24, 2026

Copy link
Copy Markdown

The badge editor's live preview now runs the user's real C — the same native/<mod>.c that ships to the badge. There is no longer a hand-written Python "twin" the user had to keep behaviour-identical. One implementation, written once.

Live: https://protogon.codemyriad.io/editor-c/#fastcode — open it (or + → C module) and the C compiles in your browser and runs; add(20, 22) shows 42, and add(2_000_000_000, 2_000_000_000) shows −294967296 — the 32-bit wrap the badge does, which a Python stand-in would have gotten wrong (4000000000).

Why this is faithful, not a mock

A native module can only ever touch MicroPython's small py/dynruntime.h API, routed through mp_fun_table. The editor compiles the module to a WebAssembly CPython extension against a shim that reimplements that one API on Pyodide's CPython C API, and import <mod> in the sim loads it. Because the module can't observe anything outside dynruntime.h, and the shim implements it — including 32-bit mp_int_t on wasm32, identical to the ESP32 — integer overflow, floats, str/bytes, buffers and exceptions all behave exactly as on hardware. The only things a shim can't reproduce (defining brand-new MicroPython object types, poking raw tagged-pointer / mp_obj_base_t memory) are outside the supported API; the editor rejects modules that use them.

Two independent host spikes proved this end to end before any editor change:

  • Shim → Pyodide (shipped here): unchanged fastcode/features0/features1 compile to a side module, 22/22 proofs incl. the int-overflow fidelity, buffers, exceptions.
  • MicroPython-wasm emulator (the fuller "switch to an emulator" option, documented not taken): real MicroPython in wasm loading a recompiled natmod against the genuine mp_fun_table — perfect fidelity, but a weeks-long re-host of the whole simulator (scheduler, eventbus, ctx, boot.py) with an open ASYNCIFY question. The shim slots into the existing, mature Pyodide sim with small blast radius, so it ships now; the emulator is a proven future upgrade.

In-browser compile pipeline

clang.wasm (wasm32, reused from the badge toolchain so the ~14 MB download is shared) compiles the module + shim runtime + a generated PyInit glue; wasm-ld.wasm (lld, built for this) links a Pyodide side module (~7 KB, imports CPython symbols dynamically, exports only PyInit_<mod>). No emscripten driver, no server — entirely client-side, offline after the one-time toolchain fetch. A fresh clang instance per translation unit is required (clang.wasm tears down LLVM's option registry after each run). Bundle at the immutable, same-origin /toolchains/mpy-preview-v1/, behind a runtimeValidated kill switch (flipped after the live QA above).

Editor changes

  • Creating a C module makes only native/<mod>.c (plus the badge sys.path shim in app.py). No .py twin; a .py may not share a C module's name (files.moduleNameConflicts).
  • The swap gates on the preview compile: while compiling, the preview is "pending"; a compile error marks the offending C line and the last working app stays on screen (exactly like a Python syntax error); compiled modules are re-injected on every worker respawn. preview-build.js content-addresses per module (cache → prebuilt → in-browser compiler); boot.py loads the .so via install_native_module into /live-native and purges it on each edit so changes re-import.
  • Removed: pyFallbackTemplate, the fallback ImportError stub, and the API-surface parity warning — all obsolete once the preview runs the real C.
  • Docs (c-modules.md, NATIVE-MODULES.md) rewritten to the new model; the fastcode sample lost its twin.

Tests

  • test-native-preview.mjs — boots boot.py and loads a real compiled .so (the shim spike's fastcode), proving add(20,22)==42 from C, rebuild re-imports, an un-built module rejects the swap keeping the last good app, and removal drops the stale .so.
  • test-files.mjs — name-conflict validation; test-runtime.mjs multi-file tests moved to Python sibling modules. All suites green; verified live on the deployed site.

Instant demos (done)

The demo projects' C is compiled to its preview side module at site-build time and shipped as a content-addressed previews/<key>.json, so opening a C demo runs the real C instantly — verified live: a clean load fetches only the ~16 KB artifact, zero toolchain requests, renders 42. The prebuild runs the exact browser pipeline (clang.wasm + wasm-ld.wasm) under Node via a shared compile core, keyed identically to the client, and the artifact is picked up by the offline precache. (Making a new C module still fetches the ~15 MB toolchain once, then caches it — that's the general path; only the bundled demos are prebuilt.)

Also fixed a real correctness bug found while wiring this up: boot.py now calls importlib.invalidate_caches() after writing/removing live module files, so adding → removing → re-adding a module (or a rejected swap restoring the project) can't leave a just-written module unimportable via a stale directory cache.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh

silviot and others added 8 commits July 24, 2026 09:29
A C module is no longer shadowed by a hand-written Python stand-in that
the user had to keep behaviour-identical. The live preview now runs the
SAME native/<mod>.c the badge does: the editor compiles it to a
WebAssembly CPython extension (against a shim that reimplements
MicroPython's py/dynruntime.h on the CPython C API) and imports it into
the simulator's Pyodide. On wasm32 mp_int_t is 32-bit — identical to the
ESP32 — so integer overflow and the whole dynruntime surface behave
exactly as on hardware; a spec-scope module cannot observe it isn't on a
badge. Two host feasibility spikes proved this end to end (the shim
path, 22/22; and — as the documented future option — real MicroPython in
wasm loading a recompiled natmod against the genuine runtime).

- boot.py: C modules load as compiled .so injected via
  install_native_module into /live-native (on sys.path); the per-swap
  module purge covers them so edits re-import. No twin, no ImportError
  stub, no parity check — all removed.
- sim-worker.js: `native` message writes the compiled .so before the swap
  that imports it (queued pre-ready like swap/reset).
- preview-build.js + preview-worker.js: the in-browser preview compiler —
  clang.wasm (wasm32) + wasm-ld.wasm link a Pyodide side module against
  the shim; content-addressed per module, cache -> prebuilt -> browser
  compiler. Behind a runtimeValidated manifest flag until QA.
- app.js: the swap gates on the preview compile — while compiling the
  preview is "pending"; a compile error pins to the C line and keeps the
  last good app; compiled modules are re-injected on every worker
  respawn. Creating a C module makes ONLY native/<mod>.c (+ the sys.path
  shim); a .py may not share a C module's name (files.moduleNameConflicts).
- files.js: no pyFallbackTemplate; badgePyFiles is just app.py + real
  Python modules. Template + fastcode demo reworded (demo twin deleted).
- tests: test-native-preview.mjs loads a REAL compiled .so (the shim
  spike's fastcode) and proves add(20,22)==42 from C, rebuild re-imports,
  un-built module rejects the swap keeping last good, removal drops the
  stale .so. test-runtime multi-file tests moved to Python modules.

The preview toolchain bundle (wasm-ld + shim + headers) is building; the
sim-side is proven now with real compiled fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
The mpy-preview-v1 bundle froze its arg vectors as top-level cc1/ld arrays
with baked-in /sysroot paths ({IN}/{OUT}/{QSTR} the only placeholders), and
clang.wasm tears down LLVM's option registry after each callMain — so:
- preview-worker.js uses args.cc1/args.ld, mounts the sysroot tar at
  /sysroot, works in /work, and creates a FRESH clang instance per
  translation unit (the -mllvm wasm-EH/SjLj flags are rejected on a reused
  instance); clang is fetched from the badge bundle (clang-xtensa-v1) so the
  14 MB download is shared, wasm-ld + sysroot + args from mpy-preview-v1.
- pwa.js: ?nosw disables + tears down the service worker (iterative dev
  against a live server); preview-build.js: ?previewforce accepts the
  toolchain before its runtimeValidated flag is flipped (QA).

Verified live in a browser (fresh origin): add a C module → only
native/fastcode.c → the preview compiles it client-side (clang.wasm +
wasm-ld.wasm, ~0.2 s warm) and runs the real C: "C says 42", and
add(2e9, 2e9) shows -294967296 — the 32-bit wrap the badge does, which a
Python twin would have gotten wrong (4000000000). A compile error marks
the C line and keeps the last good app running.

native-build/browser-preview/: the agent's bundle reference (cc1 args,
wasm-ld build script, README with the reproducible pipeline + shim role).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
The line under the tabs read "C module: this same C runs in the preview
(compiled to WebAssembly) and on the badge…" — a description of what a C
module is, which is exactly what you'd expect ("all our food is edible").
Replaced with just the actionable status for the file you're editing:
✓ live in the preview · ✓ built for badge, or "compiling…", or
"✗ won't compile — line N" (badge-build chip suppressed while the code
doesn't compile). Shown only on a C file; app.py keeps the run-dot. The
what-is-a-C-module explanation lives in the ? panel / c-modules.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
… download

Opening a C demo (or making a C module) used to download the ~15 MB preview
toolchain once before it could run the C. Now the demo's C is compiled to
its preview side module at SITE-BUILD time and shipped as a
content-addressed previews/<key>.json, which the client resolves before it
ever touches the compiler — verified live: a clean demo load fetches only
the ~16 KB artifact, zero toolchain requests, and renders 42 from the real
compiled C.

- preview-compile-core.js: extracted the environment-agnostic compile+link
  core (qstr/glue generation, per-TU fresh-clang, wasm-ld) shared by the
  browser worker and the build-time prebuild, so their output can't drift.
  preview-worker.js now just boots (fetch) + base64s; preview-build.js
  single-sources PREVIEW_TOOLCHAIN_ID from the core and exports
  previewModuleKeys (the client key logic the prebuild reuses).
- native-build/browser-preview/prebuild_previews.mjs: runs the exact browser
  pipeline (clang.wasm + wasm-ld.wasm from the mpy-preview-v1 bundle) under
  Node, keyed identically to the client. Network-tolerant: warns + skips if
  the toolchain can't be fetched (demo then compiles in-browser at runtime).
  build.sh stage 3d wires it; the artifact is picked up by the SW precache
  so it also works offline. Toolchain is cached in web/.cache so repeat
  builds don't re-download.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
_sync_live_files and install_native_module write module files that the next
swap imports, but Python's import machinery caches each directory's listing —
so a module written after being removed (add → remove → re-add, or a rejected
swap restoring the accepted project) could fail with ModuleNotFoundError
because the finder still saw the stale contents. Made a test flaky (helper
module intermittently unimportable); would hit real users editing project
files. importlib.invalidate_caches() after touching /live and /live-native
fixes it deterministically (5/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
The starter native/<mod>.c led with mechanism the C author doesn't act on
(compiles to .mpy, loads at import, no reflashing, how the preview executes
it) and handed over mpy_init as mysterious boilerplate — MP_DYNRUNTIME_INIT_
ENTRY / mp_store_global(...) with no hint which line is theirs.

Rewritten around the task: it opens with `import <mod>; <mod>.add(20,22) ->
42`, states the two-step pattern (write the function, register it in
mpy_init), comments the mp_obj_t <-> C conversions and the _2 arg-count
macro, and — the part that was opaque — labels ENTRY/EXIT as the required
frame to leave alone and shows the exact mp_store_global line to copy per
exported function. Same treatment for the fastcode demo's C so the two
match. (Types/limits and the how-it-runs details live in the ? panel.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLAG6JndPQewe7w9gVRKgh
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.

1 participant