Backends
Known Issues & Limitations
Known Issues & Limitations
This document tracks issues, edge cases, and patterns that don't yet lower perfectly or that produce debug-trace noise. The source code preserves a number of __host_log__ calls and debug file dumps that surface during development — they are deliberate hooks for diagnosis, not bugs.
Variant access errors
The mpl_value std::variant can throw std::bad_variant_access if the wrong alternative is read. The backend avoids this via the helpers in ./variant-handling.md but a few patterns still slip through:
Error pattern: std::get<long double>(string value)
Symptom: terminate called after throwing an instance of 'std::bad_variant_access'.
Cause: An operator helper (mpl_add, mpl_sub, mpl_lt, etc.) was called with two mpl_values whose underlying types can't be combined directly. The fix path is:
- Identify the failing operator from the stack trace.
- Check whether the inputs include a
nullptr_tormpl_undefined_t— those need ampl_is_nullishshort-circuit before the operator. - If the inputs are
std::stringvslong double, the operator helper should handle the coercion internally; if it doesn't, that's a helper bug.
Error pattern: mpl_get_value returns __mpl_undefined__ for a known key
Symptom: A property read returns undefined when the value is present.
Cause: The object has been frozen, sealed, or had its prototype chain broken. Check mpl_object::properties for the key, then prototype for inheritance.
Error pattern: std::get_if returns nullptr for an obviously-object alternative
Symptom: Code that branches on if (auto* p = std::get_if<shared_ptr<mpl_object>>(&v)) always takes the nullptr branch.
Cause: The mpl_value actually holds a bool or long double. Look at the std::visit history — somewhere upstream, the value was stored in a different alternative than expected.
Hoisting edge cases
Mutual recursion
function a() { return b(); }
function b() { return a(); }
Expected: works, both forward-declared.
Status: works for direct mutual recursion. The hoisting pass at CppBackend.h:2094-2097 collects all moduleFunctionVars as hoisted names; the forward-declaration pass at line 2105-2119 emits static mpl_value a = __mpl_undefined__; for each.
Self-reference in a function declaration
function fact(n) { return n <= 1 ? 1 : n * fact(n - 1); }
Expected: works.
Status: works. The self-reference detection at CppBackend.h:2064-2087 adds fact to legacyForwardDecls, which then triggers forward-declaration emission.
Closure over a let in a for loop
const fns = [];
for (let i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map(f => f())); // expect [0, 1, 2]
Expected: works after canonicalization cell-wraps i per iteration.
Status: works in current canonicalizer + backend. The cell-wrap metadata is emitted correctly; the lambda body emits auto& i = *__cell_i_<n>; and increments the cell-wrapped alternative.
Reassignment of hoisted callable
let f = () => 1;
function reset() { f = 42; }
f(); // expects to call the lambda
reset();
try { f(); } catch (e) { console.log("err:", e.message); }
Expected: f is callable, then becomes a number, then throws on call.
Status: works because of the 32f5694 fix (Pass 2 collectNestedNonCallableAssignments downgrades f from functionVars to valueVars).
var redeclaration in same scope
var x = 1;
var x = 2; // legal in JS, illegal in strict mode
Expected: the second var x = 2 is a re-assignment.
Status: works in non-strict mode. In strict mode, the frontend emits a validation error before the backend sees the IR.
Variant unwrap gotchas
Numeric precision loss
const big = 9007199254740993; // Number.MAX_SAFE_INTEGER + 2
console.log(big === big + 1); // true (IEEE 754)
The backend uses long double (80-bit on x86, 64-bit on ARM64), which can hold ~19 decimal digits precisely. For arbitrary-precision integers, use BigInt:
const big = 9007199254740993n; // BigInt
mpl_to_string(mpl_object) order
mpl_to_string on an mpl_object walks obj.order (insertion order). This matches JS JSON.stringify for string-keyed properties but may differ from Python repr() or other languages.
Symbol comparison
mpl_strict_eq(mpl_symbol("a"), mpl_symbol("a")) returns true. mpl_loose_eq is the same for symbols. Cross-type comparisons (mpl_symbol == mpl_string) return false.
Debug tracing noise
Several static std::ofstream instances write to /tmp/mpl_debug*.log during normal operation:
| File | Where | Purpose |
|---|---|---|
/tmp/mpl_debug2.log |
CppBackend.h:1936-1946 |
Every MemberAccess node visited during collectRefs |
/tmp/mpl_debug3.log |
CppBackend.h:7853-7857 |
Every Identifier whose name contains "message", "author", or "username" |
/tmp/mpl_debug4.log |
CppBackend.h:8098-8111 |
Every BinaryOp with right-hand MemberAccess |
These are intentional development hooks — they help diagnose hoisting and identifier-resolution bugs by leaving a paper trail. They are not bugs.
Similarly, __host_log__ calls (line 5418-5420, 5531-5533) emit to stderr when the runtime-required path is hit:
__host_log__("MPL runtime-required statement encountered: <reason>");
When you see these in stderr, the backend encountered an IR node it cannot lower — the <reason> identifies the node kind.
Extract debug dumps
File: CppBackend.h:9817-9827, 9863-9872
When a generated module exceeds 500 KB, two additional dumps are written:
/tmp/mpl_extract_full_<n>_<name>.cpp— the full generated code before body extraction/tmp/mpl_extract_body_<n>_<name>.cpp— the extracted body that gets embedded in__mpl_module_init()
The naming uses a process-global counter and the source-language module name. These are also development hooks.
Patterns that the backend does not yet handle
Pattern: DecoratorNode
The IR has a DecoratorNode for class/method decorators (Stage 3 ECMAScript proposal). The backend's nodeNeedsRuntime check returns true for it (line 4394-4406) and emits __host_log__("MPL runtime-required statement encountered: decorator"); instead of generating C++.
Workaround: apply the decorator's effect at the frontend (e.g. manually inline a wrapper class).
Pattern: MacroNode
Hygienic macros (proposed for several languages). Same as DecoratorNode — flagged as runtime-required.
Pattern: PatternMatchNode
match/case patterns (Rust, modern JS proposal). Same as above.
Pattern: with statement (JS legacy)
The with statement (with (obj) { x; }) is parsed but not lowered — nodeNeedsRuntime triggers, the generated code emits a __host_log__ call, and the program continues without executing the with body correctly.
Workaround: refactor to explicit obj.x access.
Pattern: eval
eval("…") is a FunctionCallNode { name: "eval" }. The backend looks it up in jsGlobals (line 7963) and emits mpl_get_value(__mpl_js_globalThis(), "eval"). The runtime helper must be provided by the embedder — there is no built-in mpl_eval in the shipped runtime.
Pattern: arguments outside a class context
Inside a class method or constructor, arguments resolves to the C++ arguments slot (line 7879-7882). Outside that context, the backend does not emit arguments-bearing callables.
Pattern: Async generators (combined async function*)
Async generators are partially supported — await and yield are handled individually, but the combined semantics (an async-iterable that can also be awaited) require a dedicated mpl_async_generator<T> runtime type that is not yet in the preamble.
Edge cases in closureCaptureModeFor
File: CppBackend.h:3822-3884
The function consults three parallel stacks (localBindingNamesStack_, localHoistedValueVarsStack_, localHoistedFunctionVarsStack_) and one binding-scope id array (closureCaptureScopeBindingIds_). If the stacks are out of sync (e.g. a push without a corresponding push on closureCaptureScopeBindingIds_), the capture mode resolves to a default that may be wrong.
Symptom: a lambda captures by [&x] when it should capture by [=] (or vice versa), producing a dangling reference.
Workaround: re-run canonicalization (EMPLCanonicalizer::canonicalize) to rebuild the ClosureBindings analysis.
Known stack-sync issue: deeply nested closures
For closures more than ~16 levels deep, the localBindingScopeIds_ and closureCaptureScopeBindingIds_ arrays may not align — this is an area under active repair.
CMake scaffold gaps
The writeCppModularBuildScaffold function in MPL_Compiler.cpp writes a minimal CMakeLists.txt that:
- Adds an
entry_generatedINTERFACE library - Adds an executable target
- Links the runtime as an INTERFACE include directory
It does not currently:
- Set MSVC-specific defines (e.g.
/bigobj) - Configure per-source-file precompiled headers
- Set optimization levels
- Detect and link Boost / OpenSSL / ZLIB dependencies for advanced modules
For production builds, users must extend the generated CMakeLists.txt.
Performance characteristics
Per-module generation time
A 1 MB source module generates in ~5–10 seconds on a modern machine. The bulk of the time is in std::stringstream operations and the per-statement generateStatement recursive calls.
Generated code size
A 100 KB JavaScript module produces ~150 KB of C++ (the extra comes from the preamble and hoisting forward declarations). Compilation of the generated C++ takes 30–60 seconds with -O2.
Memory
Each CppBackend instance retains its state between generate() calls. A typical state size is < 10 MB for a moderately complex module. The std::stringstream buffer is freed when generate() returns.
Future work
Items tracked but not yet implemented:
- Add
mpl_async_generator<T>to the runtime preamble - Lower
DecoratorNodefor supported decorators (@deprecated,@deprecated-decorator) - Better diagnostics for
__host_log__calls (include source location) - Inline
mpl_callfor monomorphic call sites to reduce overhead - Support
eval()via a built-in implementation - Properly handle
argumentsoutside class context - Switch from
std::stringstreamtofmt::formatfor ~3× faster generation
Cross-references
- The runtime needs guard:
./emitter.md§ Runtime needs guard - The closure cell-wrap machinery:
./closures.md - The
mpl_valuevariant:./variant-handling.md - IR nodes that the canonicalizer produces but the backend cannot lower:
../../empl/node-kind.md