Elvelt

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:

  1. Identify the failing operator from the stack trace.
  2. Check whether the inputs include a nullptr_t or mpl_undefined_t — those need a mpl_is_nullish short-circuit before the operator.
  3. If the inputs are std::string vs long 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_generated INTERFACE 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 DecoratorNode for supported decorators (@deprecated, @deprecated-decorator)
  • Better diagnostics for __host_log__ calls (include source location)
  • Inline mpl_call for monomorphic call sites to reduce overhead
  • Support eval() via a built-in implementation
  • Properly handle arguments outside class context
  • Switch from std::stringstream to fmt::format for ~3× faster generation

Cross-references