XCX News & Announcements

XCX 4.4 — The Interpreter & Determinism Update


XCX 4.4 is a performance and determinism release built on top of the HIR foundation introduced in 4.2: the interpreter posts its biggest optimization pass since 4.0 (loop suite -17.3% without JIT), code generation is now fully deterministic, shallow self-recursion inlining drops fib(30) to 6.53 ms in JIT mode (the LuaJIT/Node class), the json.parse leaks are fixed, a memory watchdog guards runaway processes, and a documented-behavior audit closed every gap between the docs and the runtime. On the language side, the elf/els aliases are gone and block semicolons are now optional.

VM: interpreter (--no-jit) performance

Targeted, VM-internal optimizations of the interpreter hot path. No bytecode-format changes, no architectural redesign.

  • [NEW] Global variable access no longer takes a lock: reads/writes go through a cached raw pointer to the fixed-size globals array (65536 slots, never resized), the same unsynchronized model the JIT path already used. Refcount discipline preserved; SetName keeps the locked path since it can register new names.
  • [NEW] All six comparison opcodes gained a dedicated int-int fast path comparing raw 64-bit payloads directly, instead of ranking both operands first.
  • [REF] Constant-pool names are resolved once per constant index instead of on every execution.
  • [NEW] Array index get/set skip the lock acquire when the executor register uniquely owns the array; the shared/locked path is unchanged.
  • [NEW] String append appends bytes directly from the source allocation when safe, instead of always cloning through an intermediate buffer first.
  • [REF] The dispatch loop polls the shutdown flag every 1024 instructions instead of every instruction.

JIT: deterministic code generation

  • [FIX] The same binary used to produce two speed classes at random (~50/50 split) across process runs, because global/local variable ordering came from a randomly-seeded hash set. Switched to a deterministic ordered set; spill/reload emission now sorts by index. The register-allocation lottery is gone.

JSON: single-pass deserialization

  • [NEW] JSON values deserialize directly into the runtime's object representation instead of building an intermediate generic tree and re-walking it.
  • [FIX] Key ordering and duplicate-handling semantics preserved exactly as before.
  • [REF] Parsing a string argument now borrows the payload instead of copying it, and skips a wasted cache-lookup hash for payloads too large to be cached.

JIT: loop-header alignment

  • [NEW] Hot loop headers are aligned to 64-byte boundaries (via a throwaway probe build + padding), removing a code-layout dependent slowdown where a loop's speed depended on unrelated code emitted before it.
  • [CHG] JIT compile time roughly doubles for chunks containing loops (extra probe build). Runtime semantics unchanged.

HTTP: request body size limit

  • [NEW] Request bodies over 10 MB are rejected with 413 Payload Too Large before the handler runs.
  • [REF] Requests with a declared Content-Length over the limit are rejected without reading the body; undeclared/chunked requests are bounded to limit+1 bytes.

Runtime: memory watchdog + json.parse retention fix

  • [NEW] A background watchdog polls process memory every 100 ms and terminates the process with a fatal error if it crosses a configurable ceiling (default 8 GB, overridable via XCX_MAX_RAM_MB, 0 disables it).
  • [FIX] A json.parse reference-counting bug leaked the entire parse tree on every call (interpreter and JIT-FFI paths), plus leaks on .get()/.first()/custom-get results. Five parses of a 6.4 MB payload (100k users), peak private commit, --no-jit: 329.3 → 86.2 MB.
  • [KNOWN ISSUE, deferred] A related JIT-mode leak (loop-carried heap values across loop back-edges) is not yet fixed; the watchdog remains the interim protection.

Compiler: .len() alias

  • [FIX] .len() was documented as an alias of .size()/.count() on arrays but was never implemented; it now works end-to-end, including json (object + array), set, and map member access.
  • [FIX] map.isEmpty() was documented but not implemented; now it works.

Fixes: shutdown flag, halt semantics, method-argument limit

  • [FIX] A duplicated shutdown flag meant cooperative (Ctrl-C) shutdown could never actually trigger; consolidated into one flag.
  • [FIX] Method calls with more than 16 arguments no longer crash the process; they are rejected at compile time with a clear error.
  • [FIX] Network request rejections (SSRF guard) no longer raise raw crashes; they follow proper halt semantics (fatal/error) consistently between JIT and interpreter.
  • [FIX] Invalid JSON in json.parse() no longer raises a raw crash; a proper documented fatal error, consistent across both execution modes.
  • [FIX] Halt diagnostics (fatal/error/alert) are worded identically between JIT and interpreter modes.
  • [FIX] Running the CLI on a non-existent file now exits with a failure code instead of silently succeeding.

HIR: shallow self-recursion inlining

  • [NEW] Simple leaf self-recursive functions (like fib) can now be partially inlined, cutting call overhead significantly.
  • [CHG] The recursion depth limit still applies but is measured in physical (post-inlining) frames, so equivalent programs may recurse deeper before hitting it.

Documented-behavior audit fixes

A round of fixes for behavior that was documented but not implemented (or implemented differently), found via a scenario-based audit:

  • [FIX] Date .hour/.minute consistently use local time; default date formatting and local-midnight storage corrected to match documentation.
  • [FIX] string.size() is now a compile-time error, matching the documented string API.
  • [FIX] Database .remove(table).where(filter) is implemented as documented.
  • [FIX] Database @optional columns with SQL NULL materialize as the column type's proper default instead of always false.
  • [FIX] HTTP response objects include the documented text field in all cases (success, error status, connection failure).
  • [FIX] Adding floats to strings in JIT mode ("x" + 0.5) could silently produce garbage instead of concatenation; fixed to match interpreter behavior.
  • [FIX] .toStr() on certain scalar values incorrectly returned false in JIT mode; fixed.
  • [FIX] JSON mutation through .get(i) could silently fail to persist, and .bind() used as an expression did nothing; both fixed, mutations now propagate.
  • [FIX] Recovering from a halt.error inside a typed function now correctly yields the type's default value.
  • [FIX] Sequential for loops over fibers could corrupt fiber state and crash on a later .isDone(); fixed by always allocating a fresh loop variable.

Note (design decision, not a bug): iterating a fiber with for intentionally yields the fiber's final return value as its last iteration item.

Crash-style failures + HTTP handler serialization

  • [FIX] .toInt()/.toFloat() on a non-numeric string crashed the process with a raw Rust panic in JIT mode; now the same halt.error as the interpreter, and failing casts count toward the error tally in both modes (exit 1).
  • [FIX] push through a JSON path that does not resolve to an array raised a raw panic; now a proper halt.error ("push target is not an array") in both modes.
  • [FIX] Concurrent HTTP requests raced on shared VM state: a counter handler under 400 parallel requests lost 29 updates. Handler execution is serialized; request I/O stays parallel. Documented trade-off: handlers do not run in parallel with each other, and a handler calling the server's own endpoint from inside would deadlock; full concurrent-state support is a 5.0a design decision.
  • [FIX] The JIT json.parse cache matched entries by string pointer alone; after allocator address reuse it could return a different document's tree. Entries now match by content only. Verified: 400/400 unique counter values under full concurrent load.

Cleanup: duplicate caches and dead VM modules

  • [CHG] The JIT maintained a second, private json.parse cache with pointer-keyed lookup that could return a freed-and-reused document's tree. The JIT now uses the interpreter's parse path directly: one cache, one set of semantics.
  • [CHG] Removed the unused legacy vm/frame and vm/stack modules, remnants of the old stack-based design that nothing instantiates anymore.
  • [FIX] JIT seed type-inference no longer invents operand facts; arithmetic results derive from operands only.

JIT: globals written inside a function are now visible after the call

  • [FIX] A function writing a global was invisible after return: func bump() { g = g + 1; } called three times left g = 0 under the JIT. Callers now refresh cached globals after every user-function call, narrowed to the callee's transitive global-slot write set; callees that write no globals add zero reload cost.
  • [FIX] JIT parameter type inference could back-propagate a wrong type onto a parameter (a string typed Bool), corrupting spill-time tag reconstruction and misrouting dispatch. Parameters are now seeded from their declared signature types.

Halt parity: JIT fast paths follow interpreter error semantics

  • [FIX] "str".replace("", x) silently performed a Rust replace instead of raising R307; out-of-bounds slice returned an empty string instead of R303; map.get on a missing key returned 0 instead of halt.error. All three now match the interpreter (same codes, same messages, error tally incremented).
  • [FIX] Fallible JIT fast paths (replace, slice, map.get, out-of-bounds array branches) now check the error tally like the generic dispatch; in-bounds array get/set stay poll-free, so loop-heavy benchmarks are unaffected.

Database and tables: @default(v) honored everywhere

  • [FIX] @default(v) was reduced to a boolean flag before code generation, so CREATE TABLE carried no DEFAULT clause: inserts stored SQL NULL, NULL < x filters never matched, arithmetic on such columns returned NULL, and string defaults read back empty. The declared constant now reaches the SQL schema (DEFAULT 0, DEFAULT 'user', …).
  • [FIX] In-memory table rows filled omitted @default(v) cells with a bare false regardless of column type; omitted cells now take the declared default, matching the SQL side.

JIT diagnostics, net error typing, and spill-store elision

  • [FIX] JIT code-finalization failures now report the chunk name and the real Cranelift/module error; the guessed "likely FreeBSD W^X policy" wording (misleading on Windows) is gone. Fallback-to-interpreter behavior unchanged.
  • [FIX] net.* with a malformed URL is no longer reported as an SSRF attack (plain INVALID error); real SSRF detections keep their exact halt semantics (file:// and link-local → halt.fatal, private ranges → halt.error).
  • [NEW] spill_all dead-store elision: a dirty register never read anywhere in the chunk and statically holding a scalar skips its memory store at every call/dispatch boundary.

Old-defect fixes: global string append ownership + bare math constants

Both defects reproduce identically on the 4.2 and 4.3 release binaries.

  • [FIX] Appending to a global string (s = s + x, --no-jit) could mutate a buffer shared with the chunk's constant pool: a string initialized from a literal after another string had been appended started life containing that string's content. The in-place append now requires sole buffer ownership, and the re-allocation path releases the construction reference.
  • [FIX] Bare math constants (PI, E, TAU, PHI, SQRT2, LN2, LN10, INF, INT_MAX, INT_MIN) failed to resolve in any program containing at least one aliased include. The include expander now rewrites such names only towards an aliased module that actually declares the symbol.

Language: elf and els keyword aliases removed

  • [CHG] The conditional keyword aliases elf (of elseif) and els (of else) no longer parse; code using them fails with an unknown-identifier error. elseif/elif/else remain — exactly one optional short form per keyword. Update affected sources by replacing elf with elseif (or elif) and els with else.

Language: block semicolons are optional, bare style recommended

  • [CHG] The semicolon after block syntax is now optional everywhere: after then (in if/elseif), after do (in while/for), after else, after end, and after the closing } of func/fiber definitions and { }-shaped declarations. Previously only if's then accepted the bare form.
  • Recommended style: write blocks without the decorative semicolon: if (ok) then … end, func f() { … }. Statement separators inside block bodies are unchanged and still required.
  • [PLANNED 5.0a] The semicolon form is removed in 5.0a; the bare form is its only successor and 5.0a ships an automated migration script.

Verification

  • cargo test --release: 251 tests / 21 files, 0 failed (incl. RUN-004 R307 under the JIT); scenario suite 104/104 passed; every programs/ suite passes in JIT and --no-jit.

Performance

Official benchmark run, Main Suite, XCX 4.4 vs XCX 4.3 (JIT and --no-jit):

SuiteFIB (30)LCG (100M)SIEVEJSON
XCX 4.3, JIT11.11 ms107.81 ms36.542 ms0.69 ms
XCX 4.4, JIT6.53 ms105.87 ms34.287 ms0.55 ms
XCX 4.3, --no-jit202.93 ms4303.23 ms2043.74 ms0.86 ms
XCX 4.4, --no-jit179.41 ms4265.23 ms2031.74 ms0.61 ms
  • JIT mode improved on all four tests: fib -41.2% (shallow self-recursion inlining), lcg -1.8%, sieve -6.2%, json -20.3%.
  • Interpreter mode improved on all four tests: fib -11.6%, lcg -0.9%, sieve -0.6%, json -29.1%. The headline interpreter result of the cycle is the loop suite: total -17.3% vs 4.3.
  • Full comparison against 26 languages/runtimes (XCX 4.4 ranks 11th by geometric mean): see the table in the README on GitHub.

Known issues at the end of the 4.4 cycle

  • JIT code finalization can fail intermittently on Windows — the process then continues in interpreter mode (results correct, just slower) and logs the real Cranelift error. The fallback is safe and unchanged.
  • json.parse leak in JIT mode when the parse result is carried across a loop back-edge (~48 MB per iteration). Workaround: parse outside the loop; the memory watchdog terminates runaway processes. The fix lands in 5.0a.
  • HTTP handler execution is serialized by design (I/O parallel, handlers one at a time); full concurrent-state support is a deliberate 5.0a design decision.
  • Map keys containing "." / "[" or starting with "/" are interpreted as nested paths, not literal keys; key such values by a safe slug (5.0a breaking-change decision with migration tooling).
  • Residual code-layout sensitivity: a hot loop can still land in one of two layouts across process runs (~1.7× flip); for strict benchmarking take a minimum across a few launches.
  • A while loop over a function-local limit variable silently decrements the limit (workaround: copy the limit to a fresh local before the loop); scheduled for a fix in 5.0a.