tinymux/mux/modules/engine/jit_lua.cpp

444 lines
15 KiB
C++
Raw Permalink Normal View History

/*! \file jit_lua.cpp
* \brief CJITCompile COM class Lua bytecode native JIT compilation.
*
* Implements mux_IJITCompile. Deserializes Lua 5.4 bytecode,
* lowers through HIR/RV64/x86-64 pipeline, caches compiled programs.
*/
#include "copyright.h"
#include "autoconf.h"
#include "config.h"
#include "externs.h"
#include "dbt_compile.h"
#include "engine_api.h"
#include "lua_bytecode.h"
#include "hir_lower_lua.h"
#include <atomic>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <unordered_map>
#include <vector>
#include <string>
// ---------------------------------------------------------------
// Compile cache
// ---------------------------------------------------------------
static std::unordered_map<uint64_t, compiled_program> s_lua_cache;
static std::atomic<uint64_t> s_next_key{1};
// ---------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------
struct lua_jit_stats {
uint64_t compile_ok;
uint64_t compile_fail;
uint64_t run_ok;
uint64_t run_fail;
uint64_t cache_hits;
uint64_t invalidations;
// #1751 Phase 0: post-entry ECALL_DECLINE committed as loud error
// (no interpreter re-run). Correct long-term value is zero.
//
uint64_t post_entry_decline;
};
static lua_jit_stats s_lua_jit_stats = {};
// Published to jitstats() (#1316). These counters are the only way to tell
// a Lua JIT that runs from one that compiles and then silently falls back.
//
void jit_lua_get_stats(lua_jit_counters *out) {
if (nullptr == out) return;
out->compile_ok = s_lua_jit_stats.compile_ok;
out->compile_fail = s_lua_jit_stats.compile_fail;
out->run_ok = s_lua_jit_stats.run_ok;
out->run_fail = s_lua_jit_stats.run_fail;
out->cache_hits = s_lua_jit_stats.cache_hits;
out->invalidations = s_lua_jit_stats.invalidations;
out->post_entry_decline = s_lua_jit_stats.post_entry_decline;
}
void jit_lua_note_post_entry_decline(void) {
s_lua_jit_stats.post_entry_decline++;
s_lua_jit_stats.run_fail++;
// A successful RunCompiled may have already counted run_ok; reverse it
// when the outer path commits a post-entry fail instead.
//
if (s_lua_jit_stats.run_ok > 0) {
s_lua_jit_stats.run_ok--;
}
}
void jit_lua_reset_stats(void) {
s_lua_jit_stats = {};
}
void jit_lua_clear_cache(void) {
const size_t n = s_lua_cache.size();
s_lua_cache.clear();
// Count each dropped entry as an invalidation so jitstats() shows the
// flush happened; operators comparing before/after can see the drop.
//
s_lua_jit_stats.invalidations += n;
}
// ---------------------------------------------------------------
// Compile a Lua bytecode blob to a compiled_program.
// ---------------------------------------------------------------
static bool compile_lua_bytecode(const uint8_t *data, size_t len,
compiled_program *out) {
// TINYMUX_DUMP_HIR covered only the softcode JIT, so nothing on this path
// was visible: neither the block layout nor, more basically, whether a
// chunk compiled at all. A declined chunk runs on the interpreter and so
// agrees with it trivially, which reads as a pass when comparing the two
// — the reason the declines below are reported and not just the dumps.
//
const char *dump_env = getenv("TINYMUX_DUMP_HIR");
bool bDump = (dump_env && *dump_env != '0');
// Softcode entry points load the Tier 2 blob; the Lua path used not to.
// With jit_eval_brackets 0 (required for Lua JIT under #1326) softcode
// never hits jit_eval, so the blob stayed unloaded and every `^` that
// needs tier2_sym_addr("pow") declined at lowering (#1561). Ensure once
// here so native FP lowers can resolve symbols; missing softlib still
// leaves s_tier2.loaded false and those ops decline cleanly.
//
tier2_ensure();
// Deserialize.
lua_bc_chunk chunk;
if (!lua_bc_load(data, len, &chunk)) {
if (bDump) {
printf("\n--- Lua JIT: declined, bytecode failed to load ---\n");
}
return false;
}
// Fast eligibility check — reject before allocating HIR/RV64 state.
lua_bc_reject reason = lua_bc_eligible(&chunk.main);
if (reason != LUA_BC_ELIGIBLE) {
if (bDump) {
printf("\n--- Lua JIT: %s declined by eligibility: %s ---\n",
chunk.main.source.c_str(), lua_bc_reject_name(reason));
}
return false;
}
// Create HIR program and RV64 compiler state.
hir_program *h = new hir_program;
h->init();
rv_compiler rc_state;
// Lower Lua bytecode to HIR.
int result = hir_lower_lua_proto(*h, rc_state, &chunk.main);
if (result < 0) {
if (bDump) {
printf("\n--- Lua JIT: %s declined by lowering"
" (%d bytecode insns) ---\n",
chunk.main.source.c_str(),
static_cast<int>(chunk.main.code.size()));
}
delete h;
return false;
}
fix(jit): make the HIR -1 sentinel safe in the subscript, not per site (#1501) Three rounds of per-site guards have landed for the same defect class -- #1440, #1449, #1457/#1470 -- and each round found producers the previous one had missed. The guards are invisible at the point of danger: nothing about `h.ty[p]` says p might be -1, so a fast path added ahead of an existing guard is unprotected by construction. #1470 found exactly that, and near-identical loops 80 lines apart had also drifted (#1457). Move the check into the subscript. The 16 per-instruction arrays become hir_slot<T, N>, whose operator[] refuses an out-of-range index: the access lands on a scratch element instead of past the array, and is recorded so refused_index() makes the whole program unusable. 828 access sites across five files are unchanged -- there is no memset or array decay anywhere in the family, so the wrapper is a true drop-in. Both halves are load-bearing, and measured separately. Making the subscript merely *defined* without recording it would convert a sanitizer report into a silently wrong compile, which is worse than the bug; that is case C below. Recording without a scratch element would leave the out-of- bounds access in place. refused_index() is read wherever h.overflowed already is. The two mean the same thing to a caller -- this program is not safe to use -- and differ only in what noticed: overflowed is set by the producer of a -1, so it catches capacity limits and the #1242 unknown-node refusal, while refused_index() is set by the consumer, so it also catches a refusal no producer flagged. That is the half the per-site guards kept missing. Also closes a gap on the Lua entry point: compile_lua_bytecode never checked h->overflowed at all. It checked only the -1 returned by hir_lower_lua_proto, so a refusal a consumer swallowed left the flag set and unread while SSA, the optimizer and codegen ran over a program whose lowering had stopped partway. compile_expression has had that check since #859; the Lua path is the same pipeline. Negative controls, all with an unguarded consumer injected at the #1470 site (h.known_int[-1] = true; h.ty[-1] = TY_INT; then read it back): A wrapper checks, refusal wired 0 UBSan reports compile_fail 0->1 B subscript passes through 2 UBSan reports (see note) C wrapper checks, refusal unwired 0 UBSan reports compile_fail 0->0 B reports exactly the class #1501 names -- "index -1 out of bounds for type 'bool [4096]'" and "'hir_type [4096]'". Its compile_fail also moved, but by accident: the out-of-bounds write lands on the preceding slot's `refused` byte and sets it, which is its own argument for the change. C is the one that matters -- the compile completed and was used (compile_ok 1->3) over a program with a refused index, with no sanitizer signal to show it. In all three the answer stayed correct, because the AST evaluator is the fallback. Verification: - make test passes, both smoke routes 1509/1509, 316/316 dispatched - sanitizer tree (--enable-sanitizers, aarch64): test-jit-qreg, test-jit-ifelse, test-lua-ecall and the smoke leg (1509/1509, 315/315) all clean -- zero sanitizer findings, zero "index -1 out of bounds", which is the acceptance test #1501 asks for - make test-asan itself cannot reach those legs on any tree; it stops at leg 2 on a pre-existing link failure in tests/netaddr, filed as #1522 and reproduced on unmodified master - no measurable compile-path cost: smoke 16s vs 17-18s baseline, i.e. within run-to-run noise hir_slot's element parameter is ElemT rather than T because mux_nls.h defines a function-style macro T(x); a parameter named T turns `T()` into `(reinterpret_cast<const UTF8 *>())`. Two static_asserts pin HIR_NOP and TY_VOID as the zero values, since a refused read returning a live-looking kind or type could steer a consumer down a real branch before refused_index() is consulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:41:23 -06:00
// The Lua path never consulted h->overflowed (#1501). It checked only the
// -1 returned by hir_lower_lua_proto, which catches a refusal the lowering
// propagated all the way out and misses one a consumer swallowed -- and
// then ran SSA, the optimizer and codegen over a program whose lowering had
// stopped partway. compile_expression has had this check since #859; the
// Lua entry point is the same pipeline and needs it too, plus the
// consumer-side refused_index() half.
//
if (h->overflowed || h->refused_index()) {
if (bDump) {
printf("\n--- Lua JIT: %s declined by overflow/refused index"
" (%d bytecode insns) ---\n",
chunk.main.source.c_str(),
static_cast<int>(chunk.main.code.size()));
}
fix(jit): make the HIR -1 sentinel safe in the subscript, not per site (#1501) Three rounds of per-site guards have landed for the same defect class -- #1440, #1449, #1457/#1470 -- and each round found producers the previous one had missed. The guards are invisible at the point of danger: nothing about `h.ty[p]` says p might be -1, so a fast path added ahead of an existing guard is unprotected by construction. #1470 found exactly that, and near-identical loops 80 lines apart had also drifted (#1457). Move the check into the subscript. The 16 per-instruction arrays become hir_slot<T, N>, whose operator[] refuses an out-of-range index: the access lands on a scratch element instead of past the array, and is recorded so refused_index() makes the whole program unusable. 828 access sites across five files are unchanged -- there is no memset or array decay anywhere in the family, so the wrapper is a true drop-in. Both halves are load-bearing, and measured separately. Making the subscript merely *defined* without recording it would convert a sanitizer report into a silently wrong compile, which is worse than the bug; that is case C below. Recording without a scratch element would leave the out-of- bounds access in place. refused_index() is read wherever h.overflowed already is. The two mean the same thing to a caller -- this program is not safe to use -- and differ only in what noticed: overflowed is set by the producer of a -1, so it catches capacity limits and the #1242 unknown-node refusal, while refused_index() is set by the consumer, so it also catches a refusal no producer flagged. That is the half the per-site guards kept missing. Also closes a gap on the Lua entry point: compile_lua_bytecode never checked h->overflowed at all. It checked only the -1 returned by hir_lower_lua_proto, so a refusal a consumer swallowed left the flag set and unread while SSA, the optimizer and codegen ran over a program whose lowering had stopped partway. compile_expression has had that check since #859; the Lua path is the same pipeline. Negative controls, all with an unguarded consumer injected at the #1470 site (h.known_int[-1] = true; h.ty[-1] = TY_INT; then read it back): A wrapper checks, refusal wired 0 UBSan reports compile_fail 0->1 B subscript passes through 2 UBSan reports (see note) C wrapper checks, refusal unwired 0 UBSan reports compile_fail 0->0 B reports exactly the class #1501 names -- "index -1 out of bounds for type 'bool [4096]'" and "'hir_type [4096]'". Its compile_fail also moved, but by accident: the out-of-bounds write lands on the preceding slot's `refused` byte and sets it, which is its own argument for the change. C is the one that matters -- the compile completed and was used (compile_ok 1->3) over a program with a refused index, with no sanitizer signal to show it. In all three the answer stayed correct, because the AST evaluator is the fallback. Verification: - make test passes, both smoke routes 1509/1509, 316/316 dispatched - sanitizer tree (--enable-sanitizers, aarch64): test-jit-qreg, test-jit-ifelse, test-lua-ecall and the smoke leg (1509/1509, 315/315) all clean -- zero sanitizer findings, zero "index -1 out of bounds", which is the acceptance test #1501 asks for - make test-asan itself cannot reach those legs on any tree; it stops at leg 2 on a pre-existing link failure in tests/netaddr, filed as #1522 and reproduced on unmodified master - no measurable compile-path cost: smoke 16s vs 17-18s baseline, i.e. within run-to-run noise hir_slot's element parameter is ElemT rather than T because mux_nls.h defines a function-style macro T(x); a parameter named T turns `T()` into `(reinterpret_cast<const UTF8 *>())`. Two static_asserts pin HIR_NOP and TY_VOID as the zero values, since a refused read returning a live-looking kind or type could steer a consumer down a real branch before refused_index() is consulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:41:23 -06:00
delete h;
return false;
}
if (bDump) {
printf("\n--- Lua JIT Compilation: %s (%d bytecode insns) ---\n",
chunk.main.source.c_str(),
static_cast<int>(chunk.main.code.size()));
printf("Phase 1: HIR Lowering\n");
hir_dump(*h);
}
// Always build block ranges (block_last starts at -1 until CFG is
// computed). Without this, single-block Lua programs skip every HIR
// insn in hir_codegen and leave final_out=0 (#1309 empty folds).
//
hir_build_cfg(*h);
if (bDump) {
printf("Phase 2: CFG (%d blocks)\n", h->n_blocks);
hir_dump(*h);
}
if (h->n_blocks > 1) {
hir_ssa_construct(*h);
if (bDump) {
printf("Phase 2b: SSA Construction\n");
hir_dump(*h);
}
hir_optimize(*h);
} else {
// Single block: just constant folding.
hir_const_fold(*h);
}
if (bDump) {
printf("Phase 3: %s\n",
h->n_blocks > 1 ? "SSA Optimization" : "Constant Folding");
hir_dump(*h);
}
fix(jit): make the HIR -1 sentinel safe in the subscript, not per site (#1501) Three rounds of per-site guards have landed for the same defect class -- #1440, #1449, #1457/#1470 -- and each round found producers the previous one had missed. The guards are invisible at the point of danger: nothing about `h.ty[p]` says p might be -1, so a fast path added ahead of an existing guard is unprotected by construction. #1470 found exactly that, and near-identical loops 80 lines apart had also drifted (#1457). Move the check into the subscript. The 16 per-instruction arrays become hir_slot<T, N>, whose operator[] refuses an out-of-range index: the access lands on a scratch element instead of past the array, and is recorded so refused_index() makes the whole program unusable. 828 access sites across five files are unchanged -- there is no memset or array decay anywhere in the family, so the wrapper is a true drop-in. Both halves are load-bearing, and measured separately. Making the subscript merely *defined* without recording it would convert a sanitizer report into a silently wrong compile, which is worse than the bug; that is case C below. Recording without a scratch element would leave the out-of- bounds access in place. refused_index() is read wherever h.overflowed already is. The two mean the same thing to a caller -- this program is not safe to use -- and differ only in what noticed: overflowed is set by the producer of a -1, so it catches capacity limits and the #1242 unknown-node refusal, while refused_index() is set by the consumer, so it also catches a refusal no producer flagged. That is the half the per-site guards kept missing. Also closes a gap on the Lua entry point: compile_lua_bytecode never checked h->overflowed at all. It checked only the -1 returned by hir_lower_lua_proto, so a refusal a consumer swallowed left the flag set and unread while SSA, the optimizer and codegen ran over a program whose lowering had stopped partway. compile_expression has had that check since #859; the Lua path is the same pipeline. Negative controls, all with an unguarded consumer injected at the #1470 site (h.known_int[-1] = true; h.ty[-1] = TY_INT; then read it back): A wrapper checks, refusal wired 0 UBSan reports compile_fail 0->1 B subscript passes through 2 UBSan reports (see note) C wrapper checks, refusal unwired 0 UBSan reports compile_fail 0->0 B reports exactly the class #1501 names -- "index -1 out of bounds for type 'bool [4096]'" and "'hir_type [4096]'". Its compile_fail also moved, but by accident: the out-of-bounds write lands on the preceding slot's `refused` byte and sets it, which is its own argument for the change. C is the one that matters -- the compile completed and was used (compile_ok 1->3) over a program with a refused index, with no sanitizer signal to show it. In all three the answer stayed correct, because the AST evaluator is the fallback. Verification: - make test passes, both smoke routes 1509/1509, 316/316 dispatched - sanitizer tree (--enable-sanitizers, aarch64): test-jit-qreg, test-jit-ifelse, test-lua-ecall and the smoke leg (1509/1509, 315/315) all clean -- zero sanitizer findings, zero "index -1 out of bounds", which is the acceptance test #1501 asks for - make test-asan itself cannot reach those legs on any tree; it stops at leg 2 on a pre-existing link failure in tests/netaddr, filed as #1522 and reproduced on unmodified master - no measurable compile-path cost: smoke 16s vs 17-18s baseline, i.e. within run-to-run noise hir_slot's element parameter is ElemT rather than T because mux_nls.h defines a function-style macro T(x); a parameter named T turns `T()` into `(reinterpret_cast<const UTF8 *>())`. Two static_asserts pin HIR_NOP and TY_VOID as the zero values, since a refused read returning a live-looking kind or type could steer a consumer down a real branch before refused_index() is consulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:41:23 -06:00
// SSA construction and the optimizer can exhaust capacity themselves, after
// the check above has already run -- the same reason compile_expression
// re-checks between phases (#1149).
//
if (h->overflowed || h->refused_index()) {
delete h;
return false;
}
// Code generation: HIR → RV64.
hir_codegen(*h, rc_state);
// Copy code into guest memory at code_base — same as softcode
// compile_expression. Without this, code lives only in rc_state.code
// while memory's code region stays zero; compact then materializes
// zeros at entry_pc and the DBT spins forever at guest PC 0
// (unknown opcode → exit_with_pc(same) → #1309 hang).
//
if (rc_state.code.size() * 4 > rv_compiler::CODE_LIMIT) {
delete h;
return false;
}
for (size_t i = 0; i < rc_state.code.size(); i++) {
memcpy(rc_state.memory.data() + rc_state.code_base + i * 4,
&rc_state.code[i], 4);
}
if (rc_state.out_exhausted || rc_state.pool_exhausted) {
delete h;
return false;
}
// Build compiled_program output.
// Include rc_state.needs_jit: codegen may force runtime for results that
// live only in registers (ITOA path), even when lowering saw no ecalls.
//
out->memory = std::move(rc_state.memory);
out->memory_size = rv_compiler::MEM_SIZE;
out->out_addr = rc_state.final_out;
out->out_used = rc_state.out_pool;
out->entry_pc = rc_state.code_base;
out->code_size = rc_state.code.size() * 4;
out->str_pool_end = rc_state.str_pool;
out->fargs_pool_end = rc_state.fargs_pool;
out->out_pool_end = rc_state.out_pool;
out->ok = true;
out->folds = h->folds;
out->ecalls = h->ecalls;
out->tier2_calls = 0;
out->native_ops = h->native_ops;
out->needs_jit = h->needs_jit || rc_state.needs_jit;
// Classify CARGS/SUBST refs (mux.args[N] → emit_sref) so
// run_cached_program populates the guest slots this program reads.
// Same pass as softcode compile_expression.
//
out->subst_mask = 0;
out->cargs_used = 0;
for (uint64_t a : h->sref_addrs) {
if ( a >= rv_compiler::CARGS_BASE
&& a < rv_compiler::CARGS_BASE
+ static_cast<uint64_t>(rv_compiler::MAX_CARGS)
* rv_compiler::CARGS_SLOT) {
int idx = static_cast<int>(
(a - rv_compiler::CARGS_BASE) / rv_compiler::CARGS_SLOT);
if (idx + 1 > out->cargs_used) {
out->cargs_used = idx + 1;
}
} else if ( a >= rv_compiler::SUBST_BASE
&& a < rv_compiler::SUBST_BASE
+ static_cast<uint64_t>(rv_compiler::SUBST_COUNT)
* rv_compiler::SUBST_SLOT) {
int slot = static_cast<int>(
(a - rv_compiler::SUBST_BASE) / rv_compiler::SUBST_SLOT);
out->subst_mask |= (UINT64_C(1) << slot);
}
}
delete h;
return true;
}
// ---------------------------------------------------------------
// CJITCompile COM class
// ---------------------------------------------------------------
class CJITCompile : public mux_IJITCompile
{
public:
CJITCompile(void) : m_cRef(1) {}
virtual ~CJITCompile() {}
// mux_IUnknown
MUX_RESULT QueryInterface(MUX_IID iid, void **ppv) override {
if (mux_IID_IUnknown == iid) {
*ppv = static_cast<mux_IUnknown *>(static_cast<mux_IJITCompile *>(this));
} else if (IID_IJITCompile == iid) {
*ppv = static_cast<mux_IJITCompile *>(this);
} else {
*ppv = nullptr;
return MUX_E_NOINTERFACE;
}
AddRef();
return MUX_S_OK;
}
uint32_t AddRef(void) override { return ++m_cRef; }
uint32_t Release(void) override {
uint32_t n = --m_cRef;
if (0 == n) delete this;
return n;
}
// mux_IJITCompile
MUX_RESULT CompileLuaBytecode(const uint8_t *pData, size_t nData,
uint64_t *pKey) override
{
if (nullptr == pData || nullptr == pKey) return MUX_E_INVALIDARG;
// Persistent cache key: "lua:" + sha1(bytecodes).
std::string cache_key = "lua:" + jit_sha1_hex(pData, nData);
// Check SQLite cache first.
compiled_program prog;
bool from_cache = jit_load_from_sqlite(cache_key, prog);
if (!from_cache) {
// Cache miss — compile from bytecodes.
if (!compile_lua_bytecode(pData, nData, &prog)) {
s_lua_jit_stats.compile_fail++;
*pKey = 0;
return MUX_E_FAIL;
}
// Persist to SQLite while prog.memory still exists.
jit_store_to_sqlite(cache_key, prog);
// Compact: extract blobs, release 4MB memory.
jit_compact_program(prog);
}
fix(jit): defer the in-memory flush until no program is running (#1316) (#1409) jitstats(flush) returned an empty string instead of OK whenever the region containing the call was JIT-compiled -- which, with jit_eval_brackets on by default, is the documented usage from wizhelp. s_compile_cache holds compiled_program by value and compile_cached() hands back a pointer into it. run_cached_program() keeps that pointer across the whole execution and harvests through it afterwards: int rc = dbt_run(dbt, prog->entry_pc, ...); ... uint64_t out_addr = resolve_runtime_out_addr(prog->out_addr, ...); and jit_eval() reads prog->ecalls / prog->tier2_calls after that returns. dbt_run is what issues ECALLs, and ECALL callees run the interpreter -- that is how u() and ufuns work from compiled code -- so fun_jitstats could execute with a live program on the stack. s_compile_cache.clear() then freed the program mid-flight and the harvest read released memory. It surfaced as a lost result rather than a crash, which is allocator luck, not safety. s_run_cached_depth did not help: it refuses nested run_cached_program, but an ECALL into the interpreter is a plain call, so the guard never fired. The SQLite DELETE and the write-queue drop stay immediate -- neither frees anything a running program points at. Only the in-memory caches wait, drained at the top of compile_cached() and of the Lua RunCompiled/Compile entries: those are the points where the caller does not yet hold a pointer, and depth == 0 means no outer frame holds one either. Nothing can be served stale in between, because every lookup goes through one of those drains. jit_lua's s_lua_cache had the same shape -- RunCompiled passes &it->second to run_cached_program -- so it drains on the same flag. Verified on macOS arm64, both routes, same tree: before direct= nested= warm= (empty, on the compiled route) after direct=OK nested=OK tailmark warm=OK and the flush still flushes: across it, cache_hit_mem is unmoved while cache_miss rises by exactly the number of regions evaluated after, including one whose text had just been compiled. jitstats_fn.mux TC005 pins both halves. It fails on the unfixed engine with the exact signature above, and asserts the counters so a flush that returned OK without flushing would not pass. No tr.done handoff was added: the dolist over tr.tc* already dispatches it, and chaining from tc004 ran it twice. with fix 1487 passed, 0 failed, 314/314, both routes without fix 1486 passed, 1 failed (TC005), 314/314 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:49:27 -06:00
// Drain before inserting, not after: a flush deferred from earlier in
// this command would otherwise wipe the program we just compiled and
// hand back a key that no longer resolves.
//
jit_flush_pending_caches();
uint64_t key = s_next_key.fetch_add(1, std::memory_order_relaxed);
s_lua_cache[key] = std::move(prog);
*pKey = key;
s_lua_jit_stats.compile_ok++;
return MUX_S_OK;
}
MUX_RESULT RunCompiled(uint64_t key,
dbref executor, dbref caller, dbref enactor,
const UTF8 *pArgs[], int nArgs,
UTF8 *pResult, size_t nResultMax, size_t *pnResultLen,
void *pLuaState) override
{
fix(jit): defer the in-memory flush until no program is running (#1316) (#1409) jitstats(flush) returned an empty string instead of OK whenever the region containing the call was JIT-compiled -- which, with jit_eval_brackets on by default, is the documented usage from wizhelp. s_compile_cache holds compiled_program by value and compile_cached() hands back a pointer into it. run_cached_program() keeps that pointer across the whole execution and harvests through it afterwards: int rc = dbt_run(dbt, prog->entry_pc, ...); ... uint64_t out_addr = resolve_runtime_out_addr(prog->out_addr, ...); and jit_eval() reads prog->ecalls / prog->tier2_calls after that returns. dbt_run is what issues ECALLs, and ECALL callees run the interpreter -- that is how u() and ufuns work from compiled code -- so fun_jitstats could execute with a live program on the stack. s_compile_cache.clear() then freed the program mid-flight and the harvest read released memory. It surfaced as a lost result rather than a crash, which is allocator luck, not safety. s_run_cached_depth did not help: it refuses nested run_cached_program, but an ECALL into the interpreter is a plain call, so the guard never fired. The SQLite DELETE and the write-queue drop stay immediate -- neither frees anything a running program points at. Only the in-memory caches wait, drained at the top of compile_cached() and of the Lua RunCompiled/Compile entries: those are the points where the caller does not yet hold a pointer, and depth == 0 means no outer frame holds one either. Nothing can be served stale in between, because every lookup goes through one of those drains. jit_lua's s_lua_cache had the same shape -- RunCompiled passes &it->second to run_cached_program -- so it drains on the same flag. Verified on macOS arm64, both routes, same tree: before direct= nested= warm= (empty, on the compiled route) after direct=OK nested=OK tailmark warm=OK and the flush still flushes: across it, cache_hit_mem is unmoved while cache_miss rises by exactly the number of regions evaluated after, including one whose text had just been compiled. jitstats_fn.mux TC005 pins both halves. It fails on the unfixed engine with the exact signature above, and asserts the counters so a flush that returned OK without flushing would not pass. No tr.done handoff was added: the dolist over tr.tc* already dispatches it, and chaining from tc004 ran it twice. with fix 1487 passed, 0 failed, 314/314, both routes without fix 1486 passed, 1 failed (TC005), 314/314 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:49:27 -06:00
// Apply a deferred flush before taking a pointer into the cache:
// &it->second below is held by run_cached_program for the whole
// execution, and a flush arriving mid-run would free it (#1316).
// A drop here is a miss, and the caller falls back to the Lua VM.
//
jit_flush_pending_caches();
auto it = s_lua_cache.find(key);
if (it == s_lua_cache.end()) return MUX_E_NOTFOUND;
s_lua_jit_stats.cache_hits++;
// #1751 Phase 4: run_cached_program with a Lua state never returns
// false after entry (CPU LIMITED, LUA ERROR, residual POST-ENTRY,
// and generic RUN FAIL are all committed handled=true). A false
// here is only a pre-entry/setup failure — depth/watermarks,
// oversize carg (#1055), get_dbt. Nothing ran; the interpreter
// must answer (#1837). Do not commit #-1 LUA JIT RUN FAIL.
//
bool ok = run_cached_program(&it->second, executor, caller, enactor,
pResult, nResultMax, pArgs, nArgs,
EV_FCHECK | EV_EVAL, pLuaState);
if (ok && nullptr != pResult
&& 0 == strncmp(reinterpret_cast<const char *>(pResult),
"#-1 LUA JIT POST-ENTRY DECLINE", 30)) {
s_lua_jit_stats.post_entry_decline++;
s_lua_jit_stats.run_fail++;
if (pnResultLen) {
*pnResultLen = strlen(reinterpret_cast<const char *>(pResult));
}
return MUX_S_OK;
}
if (!ok) {
// Pre-entry only. Leave pResult empty; caller falls back.
//
return MUX_E_FAIL;
}
if (pnResultLen) {
*pnResultLen = strlen(reinterpret_cast<const char *>(pResult));
}
// Handled run (success or committed LUA ERROR / CPU LIMITED).
// POST-ENTRY arm above already counted run_fail.
//
s_lua_jit_stats.run_ok++;
return MUX_S_OK;
}
MUX_RESULT IsCompiled(uint64_t key, bool *pCompiled) override {
if (nullptr == pCompiled) return MUX_E_INVALIDARG;
*pCompiled = (s_lua_cache.find(key) != s_lua_cache.end());
return MUX_S_OK;
}
MUX_RESULT Invalidate(uint64_t key) override {
auto it = s_lua_cache.find(key);
if (it != s_lua_cache.end()) {
s_lua_cache.erase(it);
s_lua_jit_stats.invalidations++;
}
return MUX_S_OK;
}
private:
uint32_t m_cRef;
};
// ---------------------------------------------------------------
// Factory creation function — called from engine_com.cpp.
// ---------------------------------------------------------------
MUX_RESULT jit_compile_create_instance(MUX_IID iid, void **ppv) {
CJITCompile *pObj = nullptr;
try { pObj = new CJITCompile; } catch (...) { ; }
if (nullptr == pObj) return MUX_E_OUTOFMEMORY;
MUX_RESULT mr = pObj->QueryInterface(iid, ppv);
pObj->Release();
return mr;
}