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;
SetNamekeeps 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 Largebefore the handler runs. - [REF] Requests with a declared
Content-Lengthover 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.parsereference-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/.minuteconsistently 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
@optionalcolumns with SQL NULL materialize as the column type's proper default instead of alwaysfalse. - [FIX] HTTP response objects include the documented
textfield 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 returnedfalsein 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.errorinside a typed function now correctly yields the type's default value. - [FIX] Sequential
forloops 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 samehalt.erroras the interpreter, and failing casts count toward the error tally in both modes (exit 1). - [FIX]
pushthrough a JSON path that does not resolve to an array raised a raw panic; now a properhalt.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.parsecache 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.parsecache 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/frameandvm/stackmodules, 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 leftg = 0under 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
stringtypedBool), 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-boundsslicereturned an empty string instead of R303;map.geton a missing key returned0instead ofhalt.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, soCREATE TABLEcarried no DEFAULT clause: inserts stored SQL NULL,NULL < xfilters 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 barefalseregardless 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_alldead-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 aliasedinclude. 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(ofelseif) andels(ofelse) no longer parse; code using them fails with an unknown-identifier error.elseif/elif/elseremain — exactly one optional short form per keyword. Update affected sources by replacingelfwithelseif(orelif) andelswithelse.
Language: block semicolons are optional, bare style recommended
- [CHG] The semicolon after block syntax is now optional everywhere: after
then(inif/elseif), afterdo(inwhile/for), afterelse, afterend, and after the closing}offunc/fiberdefinitions and{ }-shaped declarations. Previously onlyif'sthenaccepted 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; everyprograms/suite passes in JIT and--no-jit.
Performance
Official benchmark run, Main Suite, XCX 4.4 vs XCX 4.3 (JIT and --no-jit):
| Suite | FIB (30) | LCG (100M) | SIEVE | JSON |
|---|---|---|---|---|
| XCX 4.3, JIT | 11.11 ms | 107.81 ms | 36.542 ms | 0.69 ms |
| XCX 4.4, JIT | 6.53 ms | 105.87 ms | 34.287 ms | 0.55 ms |
| XCX 4.3, --no-jit | 202.93 ms | 4303.23 ms | 2043.74 ms | 0.86 ms |
| XCX 4.4, --no-jit | 179.41 ms | 4265.23 ms | 2031.74 ms | 0.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.