Bytecode serialization: compile Wren source ahead of time, load and run it without the compiler (#535) - #1238
Bytecode serialization: compile Wren source ahead of time, load and run it without the compiler (#535)#1238joshgoebel wants to merge 19 commits into
Conversation
- Payload is root ObjFn tree plus user-declared module variable names only. - Module name and core-module variables (System, Object, Fn, etc.) are excluded from the artifact; the loader reconstructs core variables from its own live VM state before wiring up the deserialized ObjFn tree, mirroring compileInModule's core-import step. - Note that LOAD_MODULE_VAR/STORE_MODULE_VAR slot indices are positional with no core/user tag, so the serializer must record the boundary and the loader must reproduce the same leading core slots. - Add missing boolean constant tag to constant-table value set (attribute values can be booleans, not just null/number/string/fn). - Check off ticket 002's remaining decision checklist item. - plan/starters/ left untouched (legacy background docs, not part of the current work product).
- Add src/vm/wren_serialize.c: exports a compiled single-file module to a version-locked .wrenc-style byte artifact, reusing the normal compiler/VM path (no separate compiler pipeline). - Add wrenSerializeModule/wrenFreeSerializeResult to the public API in src/include/wren.h. - Wire the new source file into all six GNU make variants (projects/make, projects/make.bsd, projects/make.mac). - Add work/003-serializer.md implementation notes covering the format decisions, build changes, and validation performed.
The previous draft mirrored wrenSerializeModule's throwaway-VM pattern (create a VM, do the work, tear it down). That's wrong for the loader: it needs to load into the host's real, long-lived VM (the one the host already created with wrenNewVM and its own bindForeignClassFn/writeFn/ etc.), the same way wrenInterpret compiles into an existing VM. The loader is symmetric with wrenInterpret, not with wrenSerializeModule. This cascades into several corrections: - Drop the NULL-name / placeholder-module-name workaround entirely. The caller now supplies a real, required module name (same as wrenInterpret's module argument), so bindForeignClass/bindMethod get a real name to dereference. - Register the loaded module in vm->modules like any normally-compiled module, instead of leaving it unregistered. - New decision surfaced by the above: reject the load if the requested module name is already loaded, rather than reusing/overwriting an existing module (mirrors compileInModule's own import-dedup behavior not being a fit here). - Update the breakdown steps, decision checklist, and acceptance criteria to match.
…_LOAD - Fix a real use-after-collect bug in the loader's own module-creation step: wrenNewModule(vm, AS_STRING(wrenNewString(vm, module))) leaves the intermediate name string unrooted across an allocation that can trigger GC. Root the name string before creating the module, matching the existing pattern in defineClass/wrenHasModule. - wrenDefineVariable can return -1 (duplicate name) as well as -2 (too many variables); the ticket only checked -2. A hostile/corrupt artifact listing the same own-variable name twice would have been silently accepted. Now checks both. - Add a dedicated WREN_ERROR_LOAD / WREN_RESULT_LOAD_ERROR pair instead of overloading WREN_ERROR_COMPILE's documented "line where the error occurs" field with a fake -1 for artifact-structure failures that have no source line at all. This is a purely additive wren.h change; ticket 001 already treats this format as version-locked, not a stable ABI, and no existing switch over WrenInterpretResult has a default case.
- Add wrenInterpretBytecode loader and WREN_ERROR_LOAD/WREN_RESULT_LOAD_ERROR - Correct per-function metadata layout in serializer before loader reads it - Add bytecode_loader API test and wire it into test harness - Update AGENTS.md and ticket status; all 867 tests pass
- Read own-variable names from the artifact buffer instead of allocating an unrooted ObjString before wrenDefineVariable. - Cap maxSlots at 1 << 16 to avoid negative/large slot allocations. - Cast lineCount to size_t to prevent uint32 overflow in the debug-line precheck. - Rewrite deepNesting test with literally nested Fn.new literals. - Mark ticket 004 complete in plan/tickets.md.
Move nextToken calls after initCompiler/vm->compiler is registered so a GC during scanning can mark token values. See BUGS.md.
- Add source-vs-bytecode equivalence, malformed-artifact rejection, API/host, and format tests. - Split monolithic bytecode_loader.c into bytecode_test.c, bytecode_equivalence.c, bytecode_rejection.c, bytecode_api.c, and bytecode_format.c. - Update makefiles for BSD, macOS, and GNU make to include new objects. - Update plan/tickets.md and tickets/005-tests.md. - Full suite passes: 867 tests. ASan debug build also passes.
- Serialize the serializer VM's full method-names table into the artifact. - Loader builds an old-index-to-new-index relocation map and patches CALL_*, SUPER_*, METHOD_INSTANCE, and METHOD_STATIC operands before execution, including nested functions reached through CODE_CLOSURE. - Add host wrenCall tests for loaded static/instance methods and a preexisting-method-symbol shift test. - Update malformed-artifact helpers for the new section and add coverage for truncated/excessive method-name tables, empty names, and out-of-range method operands. - Keep the real error message in btLastError by ignoring stack-trace callbacks. - Update AGENTS.md with build/test command warnings and ASan clean rebuild recipe. - Mark ticket 006 implemented in plan/tickets.md.
- BUGS.md: mark the wrenCompile GC-safety fix as landed (d742934) - format/serialize/deserialize docs: document the method-symbol table added by ticket 006 - ticket 003: mark superseded function-metadata layout, point to 004 - ticket 002: note the ticket-006 payload amendment - tickets.md/roadmap.md: mark Phase 5 tooling as deliberately deferred - wren.h: document that wrenSerializeModule's module arg is unused
|
Yes commit history is terrible, but I can clean that up if there was interest in merging. |
|
Tests failing on debian trixie, built using the makefile: $ python3 util/test.py
FAIL: test/api/bytecode_loader.wren
Unexpected output on stderr:
realloc(): invalid pointer
Expected return code 0 and got -6. Stderr:
realloc(): invalid pointer
Missing expected output "true" on line 6.
866 tests passed. 1 tests failed. |
Architecture? Could you reproduce this inside Docker? If so I could take a look. All of my builds were on Mac OS Darwin. |
|
I have an x86_64 cpu, default compiler (gcc), just normal desktop linux |
|
I'll see if I can fire up an x86 container and reproduce there... |
|
I wonder: normal programs work just fine, only the tests fail |
|
were you able to reproduce the issue? |
|
No, a lot going on right now, might not be able to get back to this for a bit. |
|
Fixed the Debian/glibc
Validation: |
|
@tccountus Give that a shot! |
|
Thanks, can confirm it's fixed. Before merging, I would recommend (re-)moving the ai generated files |
I can absolutely clean it up for sure IF merging were desirable... I posted it largely for learning and discussion. I'm not sure if this is even desired in core (though it may ultimately end up in my own wren-console project). Also might be that we'd want to improve it a bit further before merging - such as adding the ability to handle modules, import other files, etc... this was just a very tight MVP to prove the encoding/decoding all can be done properly. Ultimately all up to @ruby0x1 |
TL;DR
This adds a minimal, version-locked bytecode serialization path to
Wren. A new
wrenSerializeModule()compiles a single Wren source file into aself-contained byte artifact, and
wrenInterpretBytecode()loads and executesthat artifact in a VM — no source text, and no compiler pass, at load time.
The artifact is deliberately not a stable ABI: it embeds the exact Wren
version and the loader rejects anything that doesn't match. Single module
only; imports, bundles, and obfuscation are explicitly out of scope for this
first pass. All 867 tests pass, plain and under AddressSanitizer.
All feedback is welcome!
Dev Notes
I'm not sure if we are still interested in merging something like this, but I thought there are likely those of you here who might want to use or expand on this for your own embedded Wren purposes. Also unsure what upstreams policy is on AI assisted contributions [know that's a hot topic in some circles]... this would not have been possible without AI - I simply don't know C that well nor have the months it would have taken to pour into this to have done it on my own. I did babysit the work and read and commented on a lot of things, but still C is not my forte...
This was a cross-collab of Sonnet 5/GPT 5.5 (planning), Fable 5 (oversight/review), Kimi K2.7 Code (all the coding). Plus an early adversarial critique of the loader ticket (004) [it seemed to be the most sticky bit] by 6 different AIs (for fun and learning):
Details
This is a first-pass implementation of the long-standing request in #535
("Save (and load) compiled bytecode?"), scoped as narrowly as possible.
New public API (
src/include/wren.h)Plus
WREN_RESULT_LOAD_ERRORandWREN_ERROR_LOADso hosts can distinguish"this artifact is bad/incompatible" from compile and runtime errors.
The artifact
WRENmagic, one byte each of major/minor/patch version,one flags byte (currently just a debug-info bit).
encode method names as indices into a VM-global table, so the loader
interns each name in the loading VM and relocates every
CALL_*/SUPER_*/METHOD_*operand. This is what makes an artifactloadable into a VM whose symbol table evolved differently (e.g. one that
already ran other code).
values are produced by executing the module, as with a normal compile).
ObjFntree: code, constants (null/bool/num/string/nestedfn), and optional debug info (function names + line tables).
All multi-byte values are big-endian; doubles are serialized as IEEE-754
bits. Core-module variables (
Object,System,Fn, ...) are neverstored — the loader copies them from the loading VM's own live core module,
the same way
compileInModuledoes, so the slot layout lines up byconstruction. The module name is likewise not embedded; the caller supplies
it at load time, mirroring
wrenInterpret(vm, module, source).What you can do today
.wrenfile to bytes on a desktop host and run those bytes onanother VM instance built from the same Wren version — including a
freshly-created VM, or one that has already interpreted other code.
attributes, control flow,
System.print(), etc.wrenGetVariable(),wrenCall()on handles into loaded classes,writeFn/
errorFnall behave identically to source interpretation (there is atest suite asserting source-vs-bytecode behavioral equivalence).
debugInfo = false) to shrink the artifact; runtimeerrors then report without function names / line numbers.
What you can't do (v1 non-goals)
Wren versions.
serialized-module imports, no multi-module bundles, no external module
loading.
structure (magic/version/flags, truncation at every read, arity/upvalue/
slot limits, constant tags, symbol-table bounds, duplicate/empty variable
names, trailing bytes) and fails cleanly with
WREN_RESULT_LOAD_ERROR, butit does not semantically verify opcodes. Treat artifacts with the same
trust you'd give source files you ship yourself.
wrenc/wrenvmbinaries yet — this lands as library API first; tooling can be layered on
later.
Implementation footprint
Nearly everything lives in one new file,
src/vm/wren_serialize.c(serializer + loader, ~1100 lines). Changes to the existing VM are tiny:
wren_vm.c: extractwrenRunClosure()(shared bywrenInterpretand theloader) and add a bounds guard in
methodNotFound()for symbols a loadedartifact may reference.
wren_compiler.c:MAX_UPVALUES/MAX_CONSTANTSmoved towren_common.hso the loader can validate against them, plus one bug fix (below).
Included bug fix: while validating the loader's GC rooting under
WREN_DEBUG_GC_STRESS+ ASan, we found a pre-existing upstreamuse-after-free:
wrenCompile()scans its first two tokens beforeinitCompiler()registers the compiler with the GC, so a collectiontriggered mid-scan can sweep a token's
ObjStringwhile the parser stillreferences it. Fixed by registering the compiler before priming the token
stream (see
BUGS.mdfor the full investigation). This window exists onevery
wrenCompilecall upstream, independent of this feature.Tests
malformed-artifact rejection (including an every-byte truncation
sweep), host API interaction, and format sanity (round-trips,
fresh-VM loads, debug flag).
debug + AddressSanitizer build.
plan/forreviewers who want the rationale behind each decision.
FAQ
What about ABI / bytecode format compatibility?
The format is intentionally version-locked and makes no stability promise —
the header carries the exact Wren version and the loader rejects mismatches
outright. We think that's the right trade, and for most real uses of Wren it
costs nothing: Wren is an embedded language that you compile into your
application, and your scripts ship with your application. When you release
a new build of your project against a newer Wren, you simply recompile your
.wrensources to fresh artifacts in the same build step — exactly like.ofiles, which nobody expects to survive a compiler upgrade. The artifact is a
build product, not an archival format. This also answers the original concern
in #535 that a serialization feature "will put pressure on us to keep that
format stable": Lua's
luachas made zero cross-version promises for decadesand that has worked fine, and here the version stamp makes misuse fail loudly
and immediately rather than mysteriously.
Doesn't the compiler's coupling to the live runtime make this impossible?
That coupling (constants stored as live heap values, method names resolved
against a VM-global symbol table, core variables injected by slot position)
was the main technical objection in #535. This PR solves it narrowly rather
than generally: constants are serialized by tagged value (only null, bools,
numbers, strings, and nested functions can appear in constant tables), the
method-name table is carried in the artifact and relocated at load time, and
core-module slots are rebuilt from the loading VM's own live core module. No
new IR, no compiler rewrite — the serializer walks the same
ObjFntree thecompiler already produces.
Is this a security boundary? Can I load bytecode from untrusted sources?
No. The loader validates structure exhaustively and fails cleanly on
malformed input, but it is not a verifying loader — hostile-but-well-formed
bytecode is out of scope (a future bytecode verifier is noted in the planning
docs). Load artifacts you built yourself.
Does this protect my source code?
It raises the bar — you don't ship source text, and you can strip names and
line info — but it is not DRM. As noted in #535, bytecode can be reverse
engineered; if you need real protection, that's a legal/contractual matter.
Obfuscation/encryption layers are explicitly out of scope for v1.
What about imports and multi-module projects?
Out of scope for v1, deliberately. One file, one module. Whole-module
granularity is also what @munificent suggested as the workable compromise in
the original thread. External module loading and bundle formats are on the
backlog and nothing in the format precludes them.
Does this break the REPL or incremental compilation?
No. The normal
wrenInterpret()path is untouched; the serializer runs inits own temporary VM and the loader is purely additive.
Does this actually help embedded/memory-constrained targets?
It removes the two costs raised in #535: the compiler pass at startup, and
holding source text (plus
wrenInterpret's copy of it) in RAM. Artifacts canadditionally drop debug info. We have not yet published benchmark numbers on
a microcontroller target — that measurement work is a natural follow-up.
Is the artifact portable across platforms?
The format is fully byte-defined (big-endian integers, IEEE-754 bit-pattern
doubles, length-prefixed strings) and contains no pointers or
platform-dependent layouts, so artifacts should be portable across
architectures running the same Wren version (including NaN-tagging vs.
non-NaN-tagging builds, since values are reconstructed at load). Cross-
platform round-trips are not yet exercised in CI, so treat this as designed-
for rather than guaranteed.
Why no
wrenccommand-line tool?Library API first. The compile-to-artifact and load-from-artifact halves are
each one function call, so a standalone tool (or a
wren-clisubcommand) isa thin follow-up once the API shape has soaked.