tinymux/mux/modules/engine/lua_mod.cpp

1782 lines
52 KiB
C++
Raw Permalink Normal View History

/*! \file lua_mod.cpp
* \brief Lua 5.4 scripting embedded in engine.so.
*
* Embeds a sandboxed Lua 5.4 interpreter. Scripts live as LUA_* attributes
* on objects. The lua() softcode function dispatches through this module.
*
* Bridge functions use engine-internal APIs (externs.h) for permission
* checks and COM interfaces for cross-layer operations.
*/
#include "copyright.h"
#include "autoconf.h"
#include "config.h"
#include "externs.h"
#include "engine_api.h"
#include "libmux.h"
#include "modules.h"
#include "lua_mod.h"
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <vector>
// Global pointer for bridge functions to reach the module instance.
// Safe because there is exactly one CLuaMod instance per process.
//
static CLuaMod *g_pLuaMod = nullptr;
// =========================================================================
// Per-execution context stored in Lua registry.
// =========================================================================
struct lua_exec_ctx
{
dbref executor;
dbref caller;
dbref enactor;
const UTF8 *pArgs[10];
int nArgs;
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
// compiled_route distinguishes the JIT leg for diagnostics and any
// future route-specific policy. It no longer forbids world effects:
// #1751 Phase 4 deleted post-entry interpreter re-run, so the #1750
// "compiled path is effect-free" medicine is obsolete. Effects on the
// compiled path are delivered exactly once under the same permission
// checks as the interpreter (softcode contract).
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
bool compiled_route;
};
// effect_refused / lua_refuse_compiled_effect removed with the effect-free
// corridor. Bridge functions below run on both routes.
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
#define LUA_EXEC_CTX_KEY "mux_exec_ctx"
#define LUA_MOD_KEY "mux_lua_mod"
static lua_exec_ctx *get_exec_ctx(lua_State *L)
{
lua_getfield(L, LUA_REGISTRYINDEX, LUA_EXEC_CTX_KEY);
lua_exec_ctx *ctx = static_cast<lua_exec_ctx *>(lua_touserdata(L, -1));
lua_pop(L, 1);
return ctx;
}
static CLuaMod *get_lua_mod(lua_State *L)
{
(void)L;
return g_pLuaMod;
}
// =========================================================================
// mux.* bridge functions (Lua C functions)
// =========================================================================
// mux.notify(dbref, message) — send text to a player.
//
// Permission model matches @pemit: executor must be nearby, have
// Long_Fingers, or control the target. If pemit_players is on and
// the target is a connected player, page-lock is checked. If
// pemit_any is on, any @pemit to a player is allowed.
//
static int bridge_notify(lua_State *L)
{
CLuaMod *mod = get_lua_mod(L);
if (nullptr == mod) return 0;
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) return 0;
dbref target = static_cast<dbref>(luaL_checkinteger(L, 1));
const char *msg = luaL_checkstring(L, 2);
dbref executor = ctx->executor;
if (!Good_obj(target))
{
lua_pushboolean(L, 0);
return 1;
}
// Enforce locality constraints (matches do_pemit_single).
//
bool ok = nearby(executor, target)
|| Long_Fingers(executor)
|| Controls(executor, target);
if ( !ok
&& isPlayer(target)
&& mudconf.pemit_players)
{
// Check page-lock without side effects.
//
ok = Connected(target)
&& could_doit(executor, target, A_LPAGE)
&& could_doit(target, executor, A_LPAGE);
}
if (!ok && mudconf.pemit_any)
{
ok = true;
}
if (!ok)
{
lua_pushboolean(L, 0);
return 1;
}
mux_INotify *pNotify = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_Notify, nullptr, UseSameProcess,
IID_INotify, reinterpret_cast<void **>(&pNotify));
if (MUX_SUCCEEDED(mr) && nullptr != pNotify)
{
pNotify->Notify(target,
reinterpret_cast<const UTF8 *>(msg));
pNotify->Release();
}
lua_pushboolean(L, 1);
return 1;
}
// mux.name(dbref) — return object name.
//
// Permission model matches name(): if read_rem_name is off, requires
// nearby_or_control, isPlayer, or Long_Fingers.
//
static int bridge_name(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushnil(L); return 1; }
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if (!Good_obj(obj))
{
lua_pushnil(L);
return 1;
}
if (!mudconf.read_rem_name)
{
if ( !Controls(ctx->executor, obj)
&& !nearby(ctx->executor, obj)
&& !isPlayer(obj)
&& !Long_Fingers(ctx->executor))
{
lua_pushnil(L);
return 1;
}
}
const UTF8 *pName = Name(obj);
if (nullptr != pName)
{
// For exits, return only the name before the first semicolon.
//
if (isExit(obj))
{
const char *semi = strchr(reinterpret_cast<const char *>(pName), ';');
if (semi)
{
lua_pushlstring(L, reinterpret_cast<const char *>(pName),
semi - reinterpret_cast<const char *>(pName));
return 1;
}
}
lua_pushstring(L, reinterpret_cast<const char *>(pName));
return 1;
}
lua_pushnil(L);
return 1;
}
// mux.owner(dbref) — return owner dbref.
// Softcode owner() uses match_thing + Owner(); absolute #dbref is allowed
// there too. Require Good_obj only (same as softcode after a successful match).
//
static int bridge_owner(lua_State *L)
{
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if (!Good_obj(obj))
{
lua_pushnil(L);
return 1;
}
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
dbref owner;
mr = pOI->GetOwner(obj, &owner);
pOI->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushinteger(L, owner);
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.location(dbref) — return location dbref.
//
// Permission model matches loc(): requires locatable(executor, obj,
// enactor), which respects UNFINDABLE, nearby, see_all.
//
static int bridge_location(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushnil(L); return 1; }
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if (!Good_obj(obj))
{
lua_pushnil(L);
return 1;
}
if (!locatable(ctx->executor, obj, ctx->enactor))
{
lua_pushnil(L);
return 1;
}
lua_pushinteger(L, Location(obj));
return 1;
}
// mux.get(dbref, attrname) — read an attribute value.
//
static int bridge_get(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushnil(L); return 1; }
int obj = static_cast<int>(luaL_checkinteger(L, 1));
const char *attrname = luaL_checkstring(L, 2);
mux_IAttributeAccess *pAA = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_AttributeAccess, nullptr,
UseSameProcess, IID_IAttributeAccess,
reinterpret_cast<void **>(&pAA));
if (MUX_SUCCEEDED(mr) && nullptr != pAA)
{
UTF8 value[8000];
size_t nValue = 0;
mr = pAA->GetAttribute(ctx->executor, static_cast<dbref>(obj),
reinterpret_cast<const UTF8 *>(attrname),
value, sizeof(value), &nValue);
pAA->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushlstring(L, reinterpret_cast<const char *>(value), nValue);
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.set(dbref, attrname, value) — write an attribute value.
//
static int bridge_set(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) return luaL_error(L, "no execution context");
int obj = static_cast<int>(luaL_checkinteger(L, 1));
const char *attrname = luaL_checkstring(L, 2);
const char *value = luaL_checkstring(L, 3);
mux_IAttributeAccess *pAA = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_AttributeAccess, nullptr,
UseSameProcess, IID_IAttributeAccess,
reinterpret_cast<void **>(&pAA));
if (MUX_SUCCEEDED(mr) && nullptr != pAA)
{
mr = pAA->SetAttribute(ctx->executor, static_cast<dbref>(obj),
reinterpret_cast<const UTF8 *>(attrname),
reinterpret_cast<const UTF8 *>(value));
pAA->Release();
if (MUX_FAILED(mr))
{
return luaL_error(L, "permission denied");
}
}
return 0;
}
// mux.eval(expression) — evaluate softcode expression.
//
static int bridge_eval(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushnil(L); return 1; }
const char *expr = luaL_checkstring(L, 1);
mux_IEvaluator *pEval = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_Evaluator, nullptr,
UseSameProcess, IID_IEvaluator,
reinterpret_cast<void **>(&pEval));
if (MUX_SUCCEEDED(mr) && nullptr != pEval)
{
UTF8 result[8000];
size_t nResult = 0;
mr = pEval->Eval(ctx->executor, ctx->caller, ctx->enactor,
reinterpret_cast<const UTF8 *>(expr),
result, sizeof(result), &nResult);
pEval->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushlstring(L, reinterpret_cast<const char *>(result),
nResult);
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.type(dbref) — return object type string.
//
static int bridge_type(lua_State *L)
{
int obj = static_cast<int>(luaL_checkinteger(L, 1));
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
int type = -1;
mr = pOI->GetType(static_cast<dbref>(obj), &type);
pOI->Release();
if (MUX_SUCCEEDED(mr))
{
// Type constants: 0=ROOM, 1=THING, 2=EXIT, 3=PLAYER
static const char *types[] = {"ROOM", "THING", "EXIT", "PLAYER"};
if (type >= 0 && type <= 3)
{
lua_pushstring(L, types[type]);
}
else
{
lua_pushstring(L, "UNKNOWN");
}
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.flags(dbref) — return flag string.
//
static int bridge_flags(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
int obj = static_cast<int>(luaL_checkinteger(L, 1));
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
UTF8 *pFlags = nullptr;
dbref looker = (ctx != nullptr) ? ctx->executor : static_cast<dbref>(1);
mr = pOI->DecodeFlags(looker, static_cast<dbref>(obj), &pFlags);
pOI->Release();
if (MUX_SUCCEEDED(mr) && nullptr != pFlags)
{
lua_pushstring(L, reinterpret_cast<const char *>(pFlags));
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.isplayer(dbref) — check if object is a player.
//
static int bridge_isplayer(lua_State *L)
{
int obj = static_cast<int>(luaL_checkinteger(L, 1));
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
bool bPlayer = false;
mr = pOI->IsPlayer(static_cast<dbref>(obj), &bPlayer);
pOI->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushboolean(L, bPlayer ? 1 : 0);
return 1;
}
}
lua_pushboolean(L, 0);
return 1;
}
// mux.isconnected(dbref) — check if player is connected.
// Match softcode hasflag(obj,CONNECTED): pub_flags || Examinable || self (#1287).
//
static int bridge_isconnected(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushboolean(L, 0); return 1; }
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if ( !Good_obj(obj)
|| ( !mudconf.pub_flags
&& !Examinable(ctx->executor, obj)
&& obj != ctx->executor
&& obj != ctx->enactor))
{
lua_pushboolean(L, 0);
return 1;
}
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
bool bConn = false;
mr = pOI->IsConnected(obj, &bConn);
pOI->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushboolean(L, bConn ? 1 : 0);
return 1;
}
}
lua_pushboolean(L, 0);
return 1;
}
// mux.pennies(dbref) — return object pennies.
// Match softcode money(): Examinable required (#1287). Absolute dbref
// without Examinable used to return balance for any object.
//
static int bridge_pennies(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushnil(L); return 1; }
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if (!Good_obj(obj) || !Examinable(ctx->executor, obj))
{
lua_pushnil(L);
return 1;
}
mux_IObjectInfo *pOI = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_ObjectInfo, nullptr,
UseSameProcess, IID_IObjectInfo,
reinterpret_cast<void **>(&pOI));
if (MUX_SUCCEEDED(mr) && nullptr != pOI)
{
int pennies = 0;
mr = pOI->GetPennies(obj, &pennies);
pOI->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushinteger(L, pennies);
return 1;
}
}
lua_pushnil(L);
return 1;
}
// mux.iswizard(dbref) — check if object is a wizard.
// Match softcode hasflag() object form: pub_flags || Examinable || self (#1287).
//
static int bridge_iswizard(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushboolean(L, 0); return 1; }
dbref obj = static_cast<dbref>(luaL_checkinteger(L, 1));
if ( !Good_obj(obj)
|| ( !mudconf.pub_flags
&& !Examinable(ctx->executor, obj)
&& obj != ctx->executor
&& obj != ctx->enactor))
{
lua_pushboolean(L, 0);
return 1;
}
mux_IPermissions *pPerms = nullptr;
MUX_RESULT mr = mux_CreateInstance(CID_Permissions, nullptr,
UseSameProcess, IID_IPermissions,
reinterpret_cast<void **>(&pPerms));
if (MUX_SUCCEEDED(mr) && nullptr != pPerms)
{
bool bWizard = false;
mr = pPerms->IsWizard(obj, &bWizard);
pPerms->Release();
if (MUX_SUCCEEDED(mr))
{
lua_pushboolean(L, bWizard ? 1 : 0);
return 1;
}
}
lua_pushboolean(L, 0);
return 1;
}
// mux.controls(who, what) — check if who controls what.
//
// Permission model: executor can only query control relationships
// where 'who' is themselves or an object they control. This
// prevents scripts from probing wizard control relationships.
//
static int bridge_controls(lua_State *L)
{
lua_exec_ctx *ctx = get_exec_ctx(L);
if (nullptr == ctx) { lua_pushboolean(L, 0); return 1; }
dbref who = static_cast<dbref>(luaL_checkinteger(L, 1));
dbref what = static_cast<dbref>(luaL_checkinteger(L, 2));
if ( !Good_obj(who)
|| !Good_obj(what))
{
lua_pushboolean(L, 0);
return 1;
}
// Restrict: executor must be 'who' or must control 'who'.
//
if ( who != ctx->executor
&& !Controls(ctx->executor, who))
{
lua_pushboolean(L, 0);
return 1;
}
lua_pushboolean(L, Controls(who, what) ? 1 : 0);
return 1;
}
// Bridge function table.
//
static const luaL_Reg bridge_funcs[] = {
{"notify", bridge_notify},
{"pemit", bridge_notify},
{"name", bridge_name},
{"owner", bridge_owner},
{"location", bridge_location},
{"type", bridge_type},
{"flags", bridge_flags},
{"isplayer", bridge_isplayer},
{"isconnected", bridge_isconnected},
{"pennies", bridge_pennies},
{"get", bridge_get},
{"set", bridge_set},
{"eval", bridge_eval},
{"iswizard", bridge_iswizard},
{"controls", bridge_controls},
{nullptr, nullptr}
};
// =========================================================================
// Custom memory allocator with limit enforcement.
// =========================================================================
void *CLuaMod::LuaAlloc(void *ud, void *ptr, size_t osize, size_t nsize)
{
CLuaMod *self = static_cast<CLuaMod *>(ud);
if (nsize == 0)
{
// Free.
if (ptr != nullptr)
{
self->m_nMemUsed -= osize;
free(ptr);
}
return nullptr;
}
// Check memory limit.
//
size_t delta = nsize - (ptr ? osize : 0);
if (self->m_nMemUsed + delta > static_cast<size_t>(self->m_nMemLimit))
{
self->m_bMemExceeded = true;
return nullptr; // Allocation denied — triggers Lua OOM error.
}
void *newptr = realloc(ptr, nsize);
if (newptr != nullptr)
{
self->m_nMemUsed += delta;
if (self->m_nMemUsed > self->m_nMemPeak)
{
self->m_nMemPeak = self->m_nMemUsed;
}
}
return newptr;
}
// =========================================================================
// Instruction count hook — enforces execution limits.
// =========================================================================
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
// Fires every m_nInsnPoll VM instructions (#1591).
//
// The Lua module used to bound a chunk only by its own instruction and memory
// limits, neither of which is what the rest of the server bounds on: softcode
// bounds on wall time. alarm_clock is armed per command
// (alarm_clock.set(mudconf.max_cmdsecs) in cque.cpp), the AST evaluator polls
// it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as
// dbt->alarm_flag. Lua referenced it nowhere, so lua() ignored max_cmdsecs
// entirely.
//
// Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do
// the real check periodically. The hook fires on a poll interval rather than
// once at the limit, checks the alarm, and only then accounts instructions --
// so the instruction limit keeps its previous meaning while wall time becomes
// the bound that agrees with everything else.
//
// This does NOT bound time spent inside a C function; the hook cannot fire
// there. The pattern-matcher half of #1591 is MatchInterrupt below, installed
// into lstrlib.c for the same pcall window.
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
//
fix(lua): bound the pattern matcher on wall time (#1591) Part (1) of #1591 landed in ce603e68e: InsnCountHook now checks alarm_clock. It cannot cover this case. lua_sethook(LUA_MASKCOUNT) counts VM instructions and does not fire inside a C function, and a pathological pattern spends all of its time inside one call to str_find_aux. lstrlib.c's own MAXCCALLS guard bounds recursion DEPTH, which is what stops a C stack overflow. It does not bound running time: 'a-a-a-...-b' against a string of 'a's backtracks exponentially in BREADTH, so depth stays under 200 while the number of match() calls goes to 2^n. Measured on macOS arm64, max_cmdsecs 1, each probe on its own fresh server behind a trivial control that had to answer first: before nothing in 45s, server pinned at 100% CPU after #-1 CPU LIMITED at t=1.00s, CPU back to 2.6% Adds a step counter to MatchState alongside matchdepth, checked in the same l_unlikely branch in match(). Every 65536 steps it calls lua_match_interrupt if installed; the module installs it around the same pcall that arms the count hook, and returns non-zero once alarm_clock has fired. That routes through the existing m_bCpuLimited path, so the caller sees "#-1 CPU LIMITED" -- the same answer the AST evaluator and the JIT give. One budget, one message, all four routes. The counter resets in prepstate but deliberately NOT in reprepstate: a scan that retries from every position in a long subject is slow in aggregate even when each attempt is cheap, and the budget bounds the whole call. mux/lua54 is the only vendored file touched, and the pointer defaults to NULL, so that tree still builds standalone as stock Lua 5.4. Ordinary patterns are unaffected -- find, match, gsub, gmatch, anchors, a 20000-character scan and a self-terminating backtracker all return in 0.00s with the budget active. An ordinary pattern never reaches 65536 steps, so the common path costs one increment and one predictable branch. make test green: 1561/1561 on both smoke routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:57:22 -06:00
// Wall-clock escape for the Lua pattern matcher (#1591).
//
// InsnCountHook below cannot cover this: lua_sethook(LUA_MASKCOUNT) counts VM
// instructions and does not fire inside a C function, and a pathological
// pattern spends all its time inside one call to string.find. lstrlib.c's
// own MAXCCALLS bounds recursion depth, which stops a stack overflow but not
// exponential backtracking -- that grows in breadth, so depth stays under 200
// while the matcher runs unbounded.
//
// lstrlib.c calls this every MATCH_INTERRUPT_MASK+1 match() steps when the
// pointer is installed. Returning non-zero raises "cpu limited" there, which
// lands in the same m_bCpuLimited path as the instruction hook, so the caller
// still sees "#-1 CPU LIMITED" -- one budget, one message, all four routes.
//
extern "C" int (*lua_match_interrupt)(lua_State *L);
int CLuaMod::MatchInterrupt(lua_State *L)
{
if (!alarm_clock.alarmed)
{
return 0;
}
// Same instance recovery as InsnCountHook. Flagging the module is what
// turns the Lua error into the softcode answer; without it the player
// would get a raw "#-1 LUA ERROR: cpu limited" instead.
void *ud = nullptr;
lua_getallocf(L, &ud);
CLuaMod *self = static_cast<CLuaMod *>(ud);
if (nullptr != self)
{
self->m_bCpuLimited = true;
}
return 1;
}
void CLuaMod::InsnCountHook(lua_State *L, lua_Debug *ar)
{
(void)ar;
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
// The allocator ud is the module instance (lua_newstate(LuaAlloc, this)).
void *ud = nullptr;
lua_getallocf(L, &ud);
CLuaMod *self = static_cast<CLuaMod *>(ud);
if (nullptr != self)
{
if (alarm_clock.alarmed)
{
self->m_bCpuLimited = true;
luaL_error(L, "cpu limited");
}
self->m_nInsnUsed += self->m_nInsnPoll;
if (self->m_nInsnUsed < self->m_nInsnLimit)
{
return;
}
}
luaL_error(L, "instruction limit exceeded");
}
// =========================================================================
// Lua state creation and sandbox setup.
// =========================================================================
bool CLuaMod::CreateLuaState(void)
{
m_nMemUsed = 0;
m_nMemPeak = 0;
m_bMemExceeded = false;
m_L = lua_newstate(LuaAlloc, this);
if (nullptr == m_L)
{
return false;
}
// Open whitelisted libraries.
//
luaL_requiref(m_L, "_G", luaopen_base, 1);
lua_pop(m_L, 1);
luaL_requiref(m_L, "string", luaopen_string, 1);
// string.dump reifies bytecode and is an escape hatch for reconstructing
// blocked loaders; softcode has no equivalent. (#1287)
lua_getglobal(m_L, "string");
if (lua_istable(m_L, -1))
{
lua_pushnil(m_L);
lua_setfield(m_L, -2, "dump");
}
lua_pop(m_L, 1);
luaL_requiref(m_L, "table", luaopen_table, 1);
lua_pop(m_L, 1);
luaL_requiref(m_L, "math", luaopen_math, 1);
lua_pop(m_L, 1);
luaL_requiref(m_L, "utf8", luaopen_utf8, 1);
lua_pop(m_L, 1);
luaL_requiref(m_L, "coroutine", luaopen_coroutine, 1);
lua_pop(m_L, 1);
// Remove dangerous functions from the global table.
//
// string.dump is removed via the string library patch below if present;
// base-library escapes that rebuild loaders are nilled here.
static const char *blocked[] = {
"load", "loadfile", "dofile", "require",
"rawget", "rawset", "rawequal", "rawlen",
"collectgarbage", nullptr
};
for (int i = 0; blocked[i] != nullptr; i++)
{
lua_pushnil(m_L);
lua_setglobal(m_L, blocked[i]);
}
// Remap print to do nothing (scripts should use mux.notify).
//
lua_pushcfunction(m_L, [](lua_State *) -> int { return 0; });
lua_setglobal(m_L, "print");
// Register mux.* bridge table.
//
luaL_newlib(m_L, bridge_funcs);
lua_setglobal(m_L, "mux");
// Store module pointer in registry for bridge function access.
//
lua_pushlightuserdata(m_L, this);
lua_setfield(m_L, LUA_REGISTRYINDEX, LUA_MOD_KEY);
return true;
}
void CLuaMod::DestroyLuaState(void)
{
if (nullptr != m_L)
{
lua_close(m_L);
m_L = nullptr;
}
}
// =========================================================================
// Chunk execution with sandbox.
// =========================================================================
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
// Execution-context setup shared by the interpreter and compiled routes.
//
// The bridge C functions (mux.eval, mux.name, ...) read the executor and
// friends from the registry, and per-run values (mux.executor, mux.args)
// are injected into the global mux table. This used to live inline in
// ExecuteChunk only -- the interpreter leg -- so a bridge function invoked
// from a COMPILED run found a cleared registry and stale mux fields. Now
// that compiled chunks call the real bridge functions through the ordinary
// Lua call path (#1745 follow-up), both routes must stage the same context.
//
// ctx is caller-owned: the registry holds a lightuserdata pointing at it,
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
// so it must outlive the run. Returns the PREVIOUS registry value so the
// teardown can restore rather than clear: nested runs (softcode -> lua
// under brackets can re-enter) used to stomp the outer run's context to
// nil, which #1750's adversarial review measured as a route-dependent
// divergence. Restore with lua_restore_exec_context.
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
//
// Nesting also used to leave the OUTER mux.args / executor fields destroyed:
// setup always overwrote the global mux table, and restore only put back the
// registry pointer. An outer chunk that did mux.eval("lua(...)") then
// re-read mux.args[k] saw the inner args (or nil) — plan residual "anytime
// item 3", pinned by smoke TC071. When nesting, the previous mux table
// fields are stacked in the registry and restored with the ctx pointer.
//
#define LUA_MUX_FIELDS_STACK "mux_fields_stack"
// Save mux.executor/caller/enactor/args onto a registry stack (nesting).
//
static void lua_save_mux_fields(lua_State *L)
{
lua_getglobal(L, "mux");
if (!lua_istable(L, -1))
{
lua_pop(L, 1);
return;
}
lua_createtable(L, 4, 0);
lua_getfield(L, -2, "executor");
lua_rawseti(L, -2, 1);
lua_getfield(L, -2, "caller");
lua_rawseti(L, -2, 2);
lua_getfield(L, -2, "enactor");
lua_rawseti(L, -2, 3);
lua_getfield(L, -2, "args");
lua_rawseti(L, -2, 4);
// stack: mux, saved
lua_getfield(L, LUA_REGISTRYINDEX, LUA_MUX_FIELDS_STACK);
if (!lua_istable(L, -1))
{
lua_pop(L, 1);
lua_newtable(L);
lua_pushvalue(L, -1);
lua_setfield(L, LUA_REGISTRYINDEX, LUA_MUX_FIELDS_STACK);
}
// stack: mux, saved, stack
const int n = static_cast<int>(lua_rawlen(L, -1));
lua_pushvalue(L, -2);
lua_rawseti(L, -2, n + 1);
lua_pop(L, 3); // stack, saved, mux
}
// Pop the most recently saved mux fields back onto the global mux table.
//
static void lua_restore_mux_fields(lua_State *L)
{
lua_getfield(L, LUA_REGISTRYINDEX, LUA_MUX_FIELDS_STACK);
if (!lua_istable(L, -1))
{
lua_pop(L, 1);
return;
}
const int n = static_cast<int>(lua_rawlen(L, -1));
if (n < 1)
{
lua_pop(L, 1);
return;
}
lua_rawgeti(L, -1, n);
// stack: stack, saved
lua_pushnil(L);
lua_rawseti(L, -3, n);
lua_getglobal(L, "mux");
if (!lua_istable(L, -1))
{
lua_pop(L, 3);
return;
}
// stack: stack, saved, mux
lua_rawgeti(L, -2, 1);
lua_setfield(L, -2, "executor");
lua_rawgeti(L, -2, 2);
lua_setfield(L, -2, "caller");
lua_rawgeti(L, -2, 3);
lua_setfield(L, -2, "enactor");
lua_rawgeti(L, -2, 4);
lua_setfield(L, -2, "args");
lua_pop(L, 3); // mux, saved, stack
}
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
static lua_exec_ctx *lua_setup_exec_context(lua_State *L, lua_exec_ctx &ctx,
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
dbref executor, dbref caller, dbref enactor,
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
const UTF8 *pArgs[], int nArgs, bool compiled_route)
{
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_getfield(L, LUA_REGISTRYINDEX, LUA_EXEC_CTX_KEY);
lua_exec_ctx *prev =
static_cast<lua_exec_ctx *>(lua_touserdata(L, -1));
lua_pop(L, 1);
// Nesting: keep the outer mux.* per-run fields so the outer chunk still
// sees its own args after the inner run returns (#1773 TC071).
//
if (nullptr != prev)
{
lua_save_mux_fields(L);
}
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
ctx.compiled_route = compiled_route;
ctx.executor = executor;
ctx.caller = caller;
ctx.enactor = enactor;
ctx.nArgs = (nArgs > 10) ? 10 : nArgs;
for (int i = 0; i < ctx.nArgs; i++)
{
ctx.pArgs[i] = (pArgs != nullptr) ? pArgs[i] : nullptr;
}
lua_pushlightuserdata(L, &ctx);
lua_setfield(L, LUA_REGISTRYINDEX, LUA_EXEC_CTX_KEY);
// Inject mux.executor, mux.caller, mux.enactor, mux.args into
// the global mux table.
//
lua_getglobal(L, "mux");
lua_pushinteger(L, executor);
lua_setfield(L, -2, "executor");
lua_pushinteger(L, caller);
lua_setfield(L, -2, "caller");
lua_pushinteger(L, enactor);
lua_setfield(L, -2, "enactor");
// Build mux.args table.
//
int nSafe = (nArgs > 0 && pArgs != nullptr) ? nArgs : 0;
lua_createtable(L, nSafe, 0);
for (int i = 0; i < nSafe; i++)
{
if (pArgs[i] != nullptr)
{
lua_pushstring(L, reinterpret_cast<const char *>(pArgs[i]));
}
else
{
lua_pushstring(L, "");
}
lua_rawseti(L, -2, i + 1);
}
lua_setfield(L, -2, "args");
lua_pop(L, 1); // pop mux table
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
return prev;
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
}
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
static void lua_restore_exec_context(lua_State *L, lua_exec_ctx *prev)
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
{
// Restore mux table fields before swapping the registry pointer back,
// so any bridge that peeks at mux.args during teardown (none today)
// still sees a coherent pair.
//
if (nullptr != prev)
{
lua_restore_mux_fields(L);
}
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
if (prev != nullptr)
{
lua_pushlightuserdata(L, prev);
}
else
{
lua_pushnil(L);
}
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
lua_setfield(L, LUA_REGISTRYINDEX, LUA_EXEC_CTX_KEY);
}
bool CLuaMod::ExecuteChunk(lua_State *L, dbref executor, dbref caller,
dbref enactor, const UTF8 *pArgs[], int nArgs,
UTF8 *pResult, size_t nResultMax, size_t *pnResultLen)
{
// The compiled chunk is on top of the Lua stack. Set up the execution
// context and call it.
lua_exec_ctx ctx;
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_exec_ctx *prev_ctx =
lua_setup_exec_context(L, ctx, executor, caller, enactor,
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
pArgs, nArgs, false);
// Set instruction count hook.
//
m_bMemExceeded = false;
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
m_bCpuLimited = false;
// Poll often enough to notice the alarm, but never less often than the
// instruction limit itself -- a small configured limit must still fire
// where it always did.
m_nInsnPoll = (m_nInsnLimit < LUA_ALARM_POLL_INSNS)
? m_nInsnLimit : LUA_ALARM_POLL_INSNS;
if (m_nInsnPoll < 1)
{
m_nInsnPoll = 1;
}
m_nInsnUsed = 0;
lua_sethook(L, InsnCountHook, LUA_MASKCOUNT, m_nInsnPoll);
fix(lua): bound the pattern matcher on wall time (#1591) Part (1) of #1591 landed in ce603e68e: InsnCountHook now checks alarm_clock. It cannot cover this case. lua_sethook(LUA_MASKCOUNT) counts VM instructions and does not fire inside a C function, and a pathological pattern spends all of its time inside one call to str_find_aux. lstrlib.c's own MAXCCALLS guard bounds recursion DEPTH, which is what stops a C stack overflow. It does not bound running time: 'a-a-a-...-b' against a string of 'a's backtracks exponentially in BREADTH, so depth stays under 200 while the number of match() calls goes to 2^n. Measured on macOS arm64, max_cmdsecs 1, each probe on its own fresh server behind a trivial control that had to answer first: before nothing in 45s, server pinned at 100% CPU after #-1 CPU LIMITED at t=1.00s, CPU back to 2.6% Adds a step counter to MatchState alongside matchdepth, checked in the same l_unlikely branch in match(). Every 65536 steps it calls lua_match_interrupt if installed; the module installs it around the same pcall that arms the count hook, and returns non-zero once alarm_clock has fired. That routes through the existing m_bCpuLimited path, so the caller sees "#-1 CPU LIMITED" -- the same answer the AST evaluator and the JIT give. One budget, one message, all four routes. The counter resets in prepstate but deliberately NOT in reprepstate: a scan that retries from every position in a long subject is slow in aggregate even when each attempt is cheap, and the budget bounds the whole call. mux/lua54 is the only vendored file touched, and the pointer defaults to NULL, so that tree still builds standalone as stock Lua 5.4. Ordinary patterns are unaffected -- find, match, gsub, gmatch, anchors, a 20000-character scan and a self-terminating backtracker all return in 0.00s with the budget active. An ordinary pattern never reaches 65536 steps, so the common path costs one increment and one predictable branch. make test green: 1561/1561 on both smoke routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:57:22 -06:00
// Cover the C-function gap the count hook cannot reach (#1591). Set on
// every call rather than once at startup: the pointer lives in lstrlib.c
// and costs nothing to reassign, and this way it cannot be left dangling
// by a module unload.
lua_match_interrupt = CLuaMod::MatchInterrupt;
// Call the chunk (it's below the mux table stuff we just popped).
//
int status = lua_pcall(L, 0, 1, 0);
// Remove the hook.
//
lua_sethook(L, nullptr, 0, 0);
fix(lua): bound the pattern matcher on wall time (#1591) Part (1) of #1591 landed in ce603e68e: InsnCountHook now checks alarm_clock. It cannot cover this case. lua_sethook(LUA_MASKCOUNT) counts VM instructions and does not fire inside a C function, and a pathological pattern spends all of its time inside one call to str_find_aux. lstrlib.c's own MAXCCALLS guard bounds recursion DEPTH, which is what stops a C stack overflow. It does not bound running time: 'a-a-a-...-b' against a string of 'a's backtracks exponentially in BREADTH, so depth stays under 200 while the number of match() calls goes to 2^n. Measured on macOS arm64, max_cmdsecs 1, each probe on its own fresh server behind a trivial control that had to answer first: before nothing in 45s, server pinned at 100% CPU after #-1 CPU LIMITED at t=1.00s, CPU back to 2.6% Adds a step counter to MatchState alongside matchdepth, checked in the same l_unlikely branch in match(). Every 65536 steps it calls lua_match_interrupt if installed; the module installs it around the same pcall that arms the count hook, and returns non-zero once alarm_clock has fired. That routes through the existing m_bCpuLimited path, so the caller sees "#-1 CPU LIMITED" -- the same answer the AST evaluator and the JIT give. One budget, one message, all four routes. The counter resets in prepstate but deliberately NOT in reprepstate: a scan that retries from every position in a long subject is slow in aggregate even when each attempt is cheap, and the budget bounds the whole call. mux/lua54 is the only vendored file touched, and the pointer defaults to NULL, so that tree still builds standalone as stock Lua 5.4. Ordinary patterns are unaffected -- find, match, gsub, gmatch, anchors, a 20000-character scan and a self-terminating backtracker all return in 0.00s with the budget active. An ordinary pattern never reaches 65536 steps, so the common path costs one increment and one predictable branch. make test green: 1561/1561 on both smoke routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:57:22 -06:00
lua_match_interrupt = nullptr;
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_restore_exec_context(L, prev_ctx);
if (status != LUA_OK)
{
// Error.
const char *errmsg = lua_tostring(L, -1);
if (nullptr == errmsg) errmsg = "unknown error";
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
if (m_bCpuLimited)
{
// Answer exactly as the AST evaluator and the JIT do, rather than
// wrapping it as a Lua error: one budget, one message (#1591).
m_stats.cpu_limit_hits++;
m_stats.errors++;
const UTF8 *kMsg = S_("#-1 CPU LIMITED");
size_t n = strlen(reinterpret_cast<const char *>(kMsg));
if (n >= nResultMax)
{
n = nResultMax - 1;
}
memcpy(pResult, kMsg, n);
pResult[n] = '\0';
*pnResultLen = n;
lua_pop(L, 1);
return MUX_S_OK;
}
if (m_bMemExceeded)
{
m_stats.mem_limit_hits++;
errmsg = "memory limit exceeded";
}
else if (strstr(errmsg, "instruction limit") != nullptr)
{
m_stats.insn_limit_hits++;
}
m_stats.errors++;
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
size_t n = mux_snprintf(pResult, nResultMax,
T("#-1 LUA ERROR: %s"), errmsg);
*pnResultLen = n;
lua_pop(L, 1);
return false;
}
// Success — convert return value to string.
//
size_t len = 0;
const char *result = nullptr;
if (lua_isnil(L, -1) || lua_isnone(L, -1))
{
result = "";
len = 0;
}
else if (lua_isboolean(L, -1))
{
result = lua_toboolean(L, -1) ? "1" : "0";
len = 1;
}
else
{
result = lua_tolstring(L, -1, &len);
if (nullptr == result)
{
result = "";
len = 0;
}
}
if (len >= nResultMax)
{
len = nResultMax - 1;
}
memcpy(pResult, result, len);
pResult[len] = '\0';
*pnResultLen = len;
lua_pop(L, 1);
return true;
}
// =========================================================================
// CLuaMod — main module class.
// =========================================================================
CLuaMod::CLuaMod(void) : m_cRef(1),
m_pILog(nullptr),
m_pIServerEventsControl(nullptr),
m_pINotify(nullptr),
m_pIObjectInfo(nullptr),
m_pIAttributeAccess(nullptr),
m_pIEvaluator(nullptr),
m_pIPermissions(nullptr),
m_pIJITCompile(nullptr),
m_L(nullptr),
m_nInsnLimit(LUA_DEFAULT_INSN_LIMIT),
fix(lua): honour the wall-clock alarm, the bound the rest of the server uses (#1591) The Lua module bounded a chunk only by its own instruction and memory limits. Neither is what the rest of the server bounds on: softcode bounds on wall time. alarm_clock is armed per command (cque.cpp:303, alarm_clock.set(mudconf.max_cmdsecs)), the AST evaluator polls it and answers "#-1 CPU LIMITED", and the JIT is handed the same flag as dbt->alarm_flag. `grep -c alarm mux/modules/engine/lua_mod.cpp` was 0, so lua() ignored max_cmdsecs entirely. Joins the existing mechanism rather than adding another. Shape borrowed from the JIT's guest-loop budget (#1571): count cheaply, do the real check periodically. InsnCountHook now fires every LUA_ALARM_POLL_INSNS (2000) instructions instead of once at the limit, polls alarm_clock.alarmed first, and only then accounts instructions toward m_nInsnLimit -- so the instruction limit keeps its previous meaning while wall time becomes a bound Lua shares with everything else. On the alarm it answers "#-1 CPU LIMITED" exactly, not "#-1 LUA ERROR: ...". One budget, one message: the same text the AST evaluator and the JIT already produce, so a chunk that runs long reads the same whichever route evaluated it. jitstats-style accounting gains cpu_limit_hits alongside insn_limit_hits and mem_limit_hits. Half of #1591, deliberately. The hook cannot fire inside a C function, so this does not bound Lua's pattern matcher: 26 bytes of subject and a 51-byte pattern still run indefinitely, because min_expand iterates rather than recursing and nothing counts attempts. That wants the matcher itself to count, the way quick_wild already does with mudstate.wild_invk_ctr / mudconf.wild_invk_lim -- the same mechanism, which would also make a command's total match effort bounded once rather than per-engine. Left in the issue because it means patching mux/lua54, which carries no local modifications today. Not exercisable under muxscript, and worth recording why: alarm_clock.set appears only in the queued-command path, and mux/script/mux_main.cpp:1373 clears the alarm and never arms it. So the smoke suite cannot observe wall-clock bounding for Lua *or* softcode -- max_cmdsecs has no automated coverage on any route, which is plausibly why this gap survived. Filed separately. Verified here that the instruction limit still fires ("instruction limit exceeded" on a 3e8-iteration loop) and ordinary chunks are unaffected. make test green, both smoke routes 1560/1560, tests/luajit PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:33:58 -06:00
m_bCpuLimited(false),
m_nInsnPoll(LUA_ALARM_POLL_INSNS),
m_nInsnUsed(0),
m_nMemLimit(LUA_DEFAULT_MEM_LIMIT),
m_nMemUsed(0),
m_nMemPeak(0),
m_bMemExceeded(false),
m_nCacheMaxSize(LUA_DEFAULT_CACHE_SIZE)
{
memset(&m_stats, 0, sizeof(m_stats));
g_pLuaMod = this;
}
MUX_RESULT CLuaMod::FinalConstruct(void)
{
MUX_RESULT mr;
// Acquire logging interface.
//
mr = mux_CreateInstance(CID_Log, nullptr, UseSameProcess,
IID_ILog, reinterpret_cast<void **>(&m_pILog));
if (MUX_FAILED(mr))
{
return mr;
}
// Register for server events.
//
mux_IServerEventsSink *pSink = nullptr;
mr = QueryInterface(IID_IServerEventsSink,
reinterpret_cast<void **>(&pSink));
if (MUX_SUCCEEDED(mr))
{
mr = mux_CreateInstance(CID_ServerEventsSource, nullptr,
UseSameProcess, IID_IServerEventsControl,
reinterpret_cast<void **>(&m_pIServerEventsControl));
if (MUX_SUCCEEDED(mr))
{
m_pIServerEventsControl->Advise(pSink);
}
pSink->Release();
}
// Acquire core interfaces.
//
mux_CreateInstance(CID_Notify, nullptr, UseSameProcess,
IID_INotify, reinterpret_cast<void **>(&m_pINotify));
mux_CreateInstance(CID_ObjectInfo, nullptr, UseSameProcess,
IID_IObjectInfo, reinterpret_cast<void **>(&m_pIObjectInfo));
mux_CreateInstance(CID_AttributeAccess, nullptr, UseSameProcess,
IID_IAttributeAccess,
reinterpret_cast<void **>(&m_pIAttributeAccess));
mux_CreateInstance(CID_Evaluator, nullptr, UseSameProcess,
IID_IEvaluator, reinterpret_cast<void **>(&m_pIEvaluator));
mux_CreateInstance(CID_Permissions, nullptr, UseSameProcess,
IID_IPermissions, reinterpret_cast<void **>(&m_pIPermissions));
// Acquire JIT compile interface (optional — graceful degradation).
mux_CreateInstance(CID_JITCompile, nullptr, UseSameProcess,
IID_IJITCompile, reinterpret_cast<void **>(&m_pIJITCompile));
// Create the Lua state.
//
if (!CreateLuaState())
{
if (nullptr != m_pILog)
{
bool fStarted;
m_pILog->start_log(&fStarted, LOG_ALWAYS, T("INI"), T("ERR"));
if (fStarted)
{
m_pILog->log_text(T("Lua module: failed to create Lua state."));
m_pILog->end_log();
}
}
return MUX_E_FAIL;
}
// Log that we are alive.
//
if (nullptr != m_pILog)
{
bool fStarted;
m_pILog->start_log(&fStarted, LOG_ALWAYS, T("INI"), T("INFO"));
if (fStarted)
{
m_pILog->log_text(T("Lua module loaded (Lua 5.4)."));
m_pILog->end_log();
}
}
return MUX_S_OK;
}
CLuaMod::~CLuaMod()
{
CacheClear();
DestroyLuaState();
if (nullptr != m_pILog)
{
bool fStarted;
m_pILog->start_log(&fStarted, LOG_ALWAYS, T("INI"), T("INFO"));
if (fStarted)
{
m_pILog->log_text(T("Lua module unloading."));
m_pILog->end_log();
}
m_pILog->Release();
m_pILog = nullptr;
}
if (nullptr != m_pIServerEventsControl)
{
m_pIServerEventsControl->Release();
m_pIServerEventsControl = nullptr;
}
if (nullptr != m_pINotify)
{
m_pINotify->Release();
m_pINotify = nullptr;
}
if (nullptr != m_pIObjectInfo)
{
m_pIObjectInfo->Release();
m_pIObjectInfo = nullptr;
}
if (nullptr != m_pIAttributeAccess)
{
m_pIAttributeAccess->Release();
m_pIAttributeAccess = nullptr;
}
if (nullptr != m_pIEvaluator)
{
m_pIEvaluator->Release();
m_pIEvaluator = nullptr;
}
if (nullptr != m_pIPermissions)
{
m_pIPermissions->Release();
m_pIPermissions = nullptr;
}
if (nullptr != m_pIJITCompile)
{
m_pIJITCompile->Release();
m_pIJITCompile = nullptr;
}
if (g_pLuaMod == this)
{
g_pLuaMod = nullptr;
}
}
MUX_RESULT CLuaMod::QueryInterface(MUX_IID iid, void **ppv)
{
if (mux_IID_IUnknown == iid)
{
*ppv = static_cast<mux_ILuaControl *>(this);
}
else if (IID_ILuaControl == iid)
{
*ppv = static_cast<mux_ILuaControl *>(this);
}
else if (IID_IServerEventsSink == iid)
{
*ppv = static_cast<mux_IServerEventsSink *>(this);
}
else
{
*ppv = nullptr;
return MUX_E_NOINTERFACE;
}
reinterpret_cast<mux_IUnknown *>(*ppv)->AddRef();
return MUX_S_OK;
}
uint32_t CLuaMod::AddRef(void)
{
return m_cRef.fetch_add(1, std::memory_order_relaxed) + 1;
}
uint32_t CLuaMod::Release(void)
{
uint32_t prev = m_cRef.fetch_sub(1, std::memory_order_acq_rel);
if (1 == prev)
{
delete this;
return 0;
}
return prev - 1;
}
// =========================================================================
// Bytecode cache — LRU keyed by source text.
// =========================================================================
// LoadCached: try the cache first, compile on miss.
// On success, the compiled chunk is on top of the Lua stack.
// Returns true on success, false on compile error (error string on stack).
//
bool CLuaMod::LoadCached(const char *source, size_t nSource,
const char *chunkname)
{
std::string key(source, nSource);
auto it = m_cache.find(key);
if (it != m_cache.end())
{
// Cache hit — push the cached chunk.
//
m_stats.cache_hits++;
lua_rawgeti(m_L, LUA_REGISTRYINDEX, it->second.lua_ref);
// Move to front of LRU.
//
m_cache_lru.erase(it->second.lru_it);
m_cache_lru.push_front(key);
it->second.lru_it = m_cache_lru.begin();
return true;
}
// Cache miss — compile.
//
m_stats.cache_misses++;
int status = luaL_loadbufferx(m_L, source, nSource, chunkname, "t");
if (status != LUA_OK)
{
return false; // Error string is on top of stack.
}
// Store in cache: push a copy, get a registry reference.
//
lua_pushvalue(m_L, -1); // duplicate the chunk
int ref = luaL_ref(m_L, LUA_REGISTRYINDEX);
// Evict if full.
//
if (static_cast<int>(m_cache.size()) >= m_nCacheMaxSize)
{
CacheEvict();
}
// Insert.
//
m_cache_lru.push_front(key);
cache_entry entry;
entry.lua_ref = ref;
entry.lru_it = m_cache_lru.begin();
entry.jit_key = 0;
entry.jit_eligible = false;
m_cache[key] = entry;
return true;
}
void CLuaMod::CacheEvict(void)
{
if (m_cache_lru.empty())
{
return;
}
// Remove the least recently used entry (back of list).
//
const std::string &oldest = m_cache_lru.back();
auto it = m_cache.find(oldest);
if (it != m_cache.end())
{
luaL_unref(m_L, LUA_REGISTRYINDEX, it->second.lua_ref);
if (it->second.jit_key != 0 && nullptr != m_pIJITCompile)
{
m_pIJITCompile->Invalidate(it->second.jit_key);
}
m_cache.erase(it);
}
m_cache_lru.pop_back();
}
void CLuaMod::CacheClear(void)
{
for (auto &pair : m_cache)
{
if (nullptr != m_L)
{
luaL_unref(m_L, LUA_REGISTRYINDEX, pair.second.lua_ref);
}
if (pair.second.jit_key != 0 && nullptr != m_pIJITCompile)
{
m_pIJITCompile->Invalidate(pair.second.jit_key);
}
}
m_cache.clear();
m_cache_lru.clear();
}
// =========================================================================
// lua_dump writer callback — accumulates bytecode into a vector.
// =========================================================================
struct dump_buffer {
std::vector<uint8_t> data;
};
static int dump_writer(lua_State *L, const void *p, size_t sz, void *ud) {
(void)L;
dump_buffer *buf = static_cast<dump_buffer *>(ud);
const uint8_t *bytes = static_cast<const uint8_t *>(p);
buf->data.insert(buf->data.end(), bytes, bytes + sz);
return 0;
}
// =========================================================================
// TryJIT: attempt to JIT-compile a cached chunk.
// The chunk must be on top of the Lua stack.
// Returns true if JIT succeeded and result is in pResult.
// Returns false on JIT failure (caller should fall through to lua_pcall).
// Does NOT pop the chunk from the stack.
// =========================================================================
bool CLuaMod::TryJIT(cache_entry &entry, dbref executor, dbref caller,
dbref enactor, const UTF8 *pArgs[], int nArgs,
UTF8 *pResult, size_t nResultMax, size_t *pnResultLen)
{
if (nullptr == m_pIJITCompile) return false;
// Safety gate (#1309): Lua JIT is off by default until the never-run
// lowering/codegen path is green. When off, fall through to the Lua
// interpreter — same behavior as before the loader fix made the JIT
// reachable.
//
if (!mudconf.lua_jit) return false;
// Already tried and failed?
if (entry.jit_eligible) {
// Already have a compiled key? Run it.
if (entry.jit_key != 0) {
// Save Lua stack — ECALL handlers may push tables/functions.
int saved_top = lua_gettop(m_L);
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
// Stage the same execution context the interpreter leg gets:
// compiled chunks reach the bridge C functions and the per-run
// mux fields through ordinary Lua calls now, and without this
// they saw a cleared registry and the PREVIOUS run's mux table.
lua_exec_ctx jit_ctx;
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_exec_ctx *prev_ctx =
lua_setup_exec_context(m_L, jit_ctx, executor, caller,
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
enactor, pArgs, nArgs, true);
MUX_RESULT mr = m_pIJITCompile->RunCompiled(entry.jit_key,
executor, caller, enactor, pArgs, nArgs,
pResult, nResultMax, pnResultLen, m_L);
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_restore_exec_context(m_L, prev_ctx);
lua_settop(m_L, saved_top); // restore stack
if (MUX_E_NOTFOUND == mr) {
// Program gone from cache (e.g. jitstats flush). Clear the
// latch and compile again below — pre-entry: nothing ran.
//
entry.jit_key = 0;
entry.jit_eligible = false;
} else if (MUX_FAILED(mr)) {
// Pre-entry setup failure from RunCompiled (depth,
// watermarks, oversize carg, get_dbt). Nothing ran;
// interpreter fallback is correct (#1837). Post-entry
// failures return MUX_S_OK with pResult already committed.
//
return false;
} else {
// #1751 Phase 4: handled run — success or committed
// LUA ERROR / POST-ENTRY / CPU LIMITED. Do not re-run.
//
return true;
fix(lua/jit): recompile after a flush instead of dying silently (#1417) (#1418) jitstats(flush) empties the JIT-side Lua program cache (jit_flush_memory_caches -> jit_lua_clear_cache) but cannot reach the Lua-side latch in CLuaMod::TryJIT that decides whether to use it. entry.jit_eligible stayed true and entry.jit_key stayed non-zero, so every later call took the "already have a key, run it" branch, RunCompiled missed the cleared cache and returned MUX_E_NOTFOUND without touching a counter, and the chunk fell back to the Lua interpreter. The first-attempt compile block was then unreachable, so that state was permanent for the life of the process. The answers stay correct, which is why nothing caught it: a dead Lua JIT behind correct results is the exact failure mode #1309 describes and the one no result-equality test can see. It also inverts the purpose of flush, which exists to force a recompile against new codegen for the A/B testing in #1315/#1316 -- instead it silently guaranteed the old path. Treat MUX_E_NOTFOUND as "the program was flushed" rather than "the JIT failed": clear the latch and fall through to the existing compile path, which makes flush self-healing. The stack is safe to fall through on because lua_settop restores the chunk function to the top before the compile path pushes its copy. Verified with #1397's testcases/lua_jit_fn.mux, which is the regression test for this. Before, deterministically (3/3 isolated, 2/2 full suite): TC001: lua JIT engage under lua_jit 1. Failed (never attempted; attempts flat at 38). TC007: string-arith sequence engages Lua JIT. Failed (attempts flat at 43). After: TC001: ... Succeeded (reached, declined per #1326; attempts 39 to 40). TC007: ... Succeeded (attempts 49 to 51). Full suite SMOKE_EXTRA_CONF='lua_jit 1': 1496/1496, 315/315 dispatched, 3 consecutive runs. Default mode: 1495/1495, 2 consecutive runs. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:28:14 -06:00
}
} else {
return false; // Previously failed to compile (pre-entry).
}
}
// First attempt: dump the chunk to bytecode and try JIT compilation.
entry.jit_eligible = true;
// lua_dump expects the function on top of stack. We have it there
// from LoadCached. Push a copy so we don't consume it.
lua_pushvalue(m_L, -1);
dump_buffer buf;
int dump_status = lua_dump(m_L, dump_writer, &buf, 0);
lua_pop(m_L, 1); // pop the copy
if (dump_status != 0 || buf.data.empty()) {
return false;
}
// Try to compile.
uint64_t key = 0;
MUX_RESULT mr = m_pIJITCompile->CompileLuaBytecode(
buf.data.data(), buf.data.size(), &key);
if (MUX_FAILED(mr) || key == 0) {
return false; // JIT doesn't support this bytecode; fall through.
}
entry.jit_key = key;
// Run the compiled program.
// Save/restore Lua stack — ECALL handlers for table ops, getglobal,
// and generic calls push values onto the Lua stack that must be
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
// cleaned up after JIT execution completes. Execution context staged
// exactly as on the cached-key path above.
int saved_top = lua_gettop(m_L);
fix(lua/jit): compiled mux.* calls the real bridge functions (#1745) Default-on's second exposure (smoke TC013/TC014, the day it first ran them compiled): the compiled mux.* path mapped the member NAME onto the softcode function table -- mux.eval("add(10,20)") reached softcode eval(obj, attr) and echoed its argument; mux.name(1) reached name() wanting "#1" and answered #-1 NOT FOUND. Name coincidence, not semantics, and mux.set mapping to softcode set() was the same trap armed. The unified fix: only mux.args keeps the SCONST sentinel (the CARGS/ALOAD fast path). Every other mux member is a field on the REAL global table -- GETGLOBAL, GETFIELD_REF, and a pcall of the same bridge C function the interpreter calls, correct by construction. The name-mapped branch and its is_bridge plumbing are deleted. Two consequences handled: * Execution context. The bridge functions read executor/caller/ enactor from the registry, and per-run mux fields are injected into the table -- previously only in ExecuteChunk, the interpreter leg, so a compiled run offered the bridge a cleared registry and the PREVIOUS run's mux table. The setup/teardown is now one shared pair (lua_setup_exec_context / lua_clear_exec_context) staged by both ExecuteChunk and TryJIT's two RunCompiled sites. * Effectful members stay exactly-once. mux.notify/pemit/set in a result-consuming form would pcall -- effect delivered -- then decline on the result type, and the interpreter re-run would deliver the effect twice. The referent now carries an effectful claim: statement form compiles (CALL_VOID has no result check), result- consuming forms decline at COMPILE time and run once, interpreted. Smoke TC013/TC014 flip back to Succeeded and are the acceptance tests; harness pins mux.eval in both spellings (local-then-return and tail call), executing compiled. smoke 1561/0; luajit 142 chunks, 0 wrong; test-config green; make test clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:26:27 -06:00
lua_exec_ctx jit_ctx;
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_exec_ctx *prev_ctx =
lua_setup_exec_context(m_L, jit_ctx, executor, caller, enactor,
fix(lua/jit): the compiled route is effect-free by construction Round two of #1750's adversarial review broke the name-keyed effectful claim: the binding is mutable. mux.tell = mux.pemit, a wrapper function installed in the mux table, or a plain global wrapper all delivered effects from compiled runs -- doubled under any later decline -- and the lowering cannot see through any of them, because any pcall of user code can reach an effector. Static analysis cannot win that game, so the guarantee moves to run time and becomes an invariant: THE INTERPRETER IS THE ONLY EFFECTOR. The execution context now carries a compiled_route flag; the effector bridge functions (notify/pemit -- one function, so aliases are covered by pointer identity -- set, and eval, which is arbitrary softcode) refuse under it: they set effect_refused FIRST, then raise. TryJIT treats effect_refused as run failure REGARDLESS of the run's own result -- a chunk that pcall-swallowed the refusal error and "completed" is still discarded -- and falls back to the interpreter, which re-runs from scratch and delivers every effect exactly once. The lowering's compile-time decline of directly-spelled effectful members stays as a fast path (no wasted compile-refuse-rerun for the common spellings); the runtime refusal is the authoritative layer that wrappers and rebindings cannot route around. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:28:00 -06:00
pArgs, nArgs, true);
mr = m_pIJITCompile->RunCompiled(key, executor, caller, enactor,
pArgs, nArgs, pResult, nResultMax, pnResultLen, m_L);
fix(lua/jit): effectful bridge members never compile; ctx save/restore Addresses both blocking objections from #1750's adversarial review. Chunk-granularity exactly-once. The review's probe proved the first version's reasoning wrong at the chunk level: statement-form mux.pemit(1,"X") followed by a DECLINING read delivered the message twice -- the effect ran compiled, the later decline failed the run, and the interpreter re-ran the whole chunk. The chunk is the rerun unit, so effectful members (notify, pemit, set, and eval -- arbitrary softcode, doubled identically in the probe) now decline at COMPILE time in every form. Chunks containing them run interpreted, once. Pure members (name, get, type, owner, ...) still compile and pcall the real bridge functions. Cost is nothing real: ECALL-bound shapes bench at parity (#1741). Nested context stomp. Setup/teardown now SAVE/RESTORE the registry's exec-ctx instead of clearing to nil, at all three sites including the interpreter leg -- the review measured the pre-existing clear as a route-dependent divergence under the production default (inner run's teardown nil'd the outer run's context). Harness: the mux.eval pins move to AGREE, asserting the ANSWER while the decline is the required behavior -- with a comment that an eval case starting to execute means the exactly-once argument must be re-made, not waved through. A mux.name EXEC pin proves the pure-member compiled path is real (lua_run_ok advances; equality with the interpreter is the assertion). Decline budget 4 -> 6, both newcomers permanent by design. make test exit 0; smoke 1561/0 (TC013/TC014 Succeeded); luajit 143 chunks 0 wrong; test-config green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:58:05 -06:00
lua_restore_exec_context(m_L, prev_ctx);
lua_settop(m_L, saved_top); // restore stack
// Post-entry failures return MUX_S_OK with a committed error string.
// Pre-entry setup failure (MUX_E_FAIL, empty result) falls through to
// the interpreter — nothing ran (#1837). Keep the compiled key so a
// later invocation with short cargs can still take the compiled path.
//
if (MUX_FAILED(mr)) {
return false;
}
return true;
}
// =========================================================================
// mux_ILuaControl implementation.
// =========================================================================
MUX_RESULT CLuaMod::CallAttr(dbref executor, dbref caller, dbref enactor,
dbref obj, const UTF8 *pAttrName,
const UTF8 *pArgs[], int nArgs,
UTF8 *pResult, size_t nResultMax, size_t *pnResultLen)
{
if (nullptr == m_L)
{
return MUX_E_FAIL;
}
// Read the attribute via COM interface (permission-checked).
//
if (nullptr == m_pIAttributeAccess)
{
return MUX_E_FAIL;
}
UTF8 source[8000];
size_t nSource = 0;
MUX_RESULT mr = m_pIAttributeAccess->GetAttribute(executor, obj,
pAttrName, source, sizeof(source), &nSource);
if (MUX_FAILED(mr))
{
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
size_t n = mux_snprintf(pResult, nResultMax,
T("#-1 LUA ERROR: cannot read attribute"));
*pnResultLen = n;
return MUX_S_OK;
}
if (nSource == 0)
{
pResult[0] = '\0';
*pnResultLen = 0;
return MUX_S_OK;
}
m_stats.calls++;
// Compile (or load from cache).
//
char chunkname[128];
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
mux_snprintf(reinterpret_cast<UTF8 *>(chunkname), sizeof(chunkname),
T("@#%d/%s"), static_cast<int>(obj), pAttrName);
if (!LoadCached(reinterpret_cast<const char *>(source), nSource,
chunkname))
{
const char *errmsg = lua_tostring(m_L, -1);
if (nullptr == errmsg) errmsg = "compile error";
m_stats.errors++;
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
size_t n = mux_snprintf(pResult, nResultMax,
T("#-1 LUA ERROR: %s"), errmsg);
*pnResultLen = n;
lua_pop(m_L, 1);
return MUX_S_OK;
}
// Try JIT execution. The compiled chunk is on top of the Lua stack.
// If JIT succeeds, pop the chunk and return.
//
std::string cache_key(reinterpret_cast<const char *>(source), nSource);
auto cache_it = m_cache.find(cache_key);
if (cache_it != m_cache.end())
{
if (TryJIT(cache_it->second, executor, caller, enactor,
pArgs, nArgs, pResult, nResultMax, pnResultLen))
{
lua_pop(m_L, 1); // pop the chunk
return MUX_S_OK;
}
}
// Fall through to Lua VM execution.
//
ExecuteChunk(m_L, executor, caller, enactor, pArgs, nArgs,
pResult, nResultMax, pnResultLen);
return MUX_S_OK;
}
MUX_RESULT CLuaMod::Eval(dbref executor, dbref caller, dbref enactor,
const UTF8 *pSource, size_t nSource,
UTF8 *pResult, size_t nResultMax, size_t *pnResultLen)
{
if (nullptr == m_L)
{
return MUX_E_FAIL;
}
// Wizard-only check.
//
if (nullptr != m_pIPermissions)
{
bool bWizard = false;
m_pIPermissions->IsWizard(executor, &bWizard);
if (!bWizard)
{
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
size_t n = mux_snprintf(pResult, nResultMax,
T("#-1 LUA ERROR: wizard-only"));
*pnResultLen = n;
return MUX_S_OK;
}
}
m_stats.calls++;
if (!LoadCached(reinterpret_cast<const char *>(pSource), nSource,
"@inline"))
{
const char *errmsg = lua_tostring(m_L, -1);
if (nullptr == errmsg) errmsg = "compile error";
m_stats.errors++;
chore(jit): finish the JIT-owned raw printf sites (#1653) lua_mod 6, hir_lower 3, hir_lower_lua 2, hir_codegen 1 -> 0. With jit_compiler in the previous commit that is every JIT-owned site that CAN be converted; legacy total 79 -> 41. Two checks rather than assumptions, either of which would have been a silent behaviour change: %.14g in hir_lower_lua is a float rendered onto the compiled path, and mux_vsnprintf implements %g by hand -- if it disagreed with libc, JIT and interpreter would print different numbers for the same value. Compared across 19 values including 1e-300, 1e+300, the 1e15/1e16 exponent-form boundary, and 9007199254740993 (past exact integer representation): zero differences. Every converted site also loses its `if (n < 0)` / `if (n >= size)` clamp. Those are not redundant, they are dead: snprintf returns what it WOULD have written, mux_snprintf what it DID. lua_mod's five result writes lose the explicit pResult[n] = '\0' for the same reason. That difference is why each site was re-read rather than renamed. dbt_test.cpp (22) and dbt_x64_div_harness.c (1) cannot be converted: tests/dbt builds them with "$(COMPILE) -o $@ $(SRCS)" and links no libmux, so mux_snprintf is not reachable without changing that build. Frozen with the reason recorded rather than exempted -- an exemption would let new sites in, a frozen count still may not grow, and neither file writes player-facing text. The remaining 18 are outside the JIT split: attrcache 4, ast 3, mail 2, match 2, predicates 2, and six singles. make test green; test-lua-jit 1561/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 08:26:36 -06:00
size_t n = mux_snprintf(pResult, nResultMax,
T("#-1 LUA ERROR: %s"), errmsg);
*pnResultLen = n;
lua_pop(m_L, 1);
return MUX_S_OK;
}
// Try JIT execution.
//
std::string cache_key(reinterpret_cast<const char *>(pSource), nSource);
auto cache_it = m_cache.find(cache_key);
if (cache_it != m_cache.end())
{
if (TryJIT(cache_it->second, executor, caller, enactor,
nullptr, 0, pResult, nResultMax, pnResultLen))
{
lua_pop(m_L, 1); // pop the chunk
return MUX_S_OK;
}
}
// Fall through to Lua VM execution.
//
ExecuteChunk(m_L, executor, caller, enactor, nullptr, 0,
pResult, nResultMax, pnResultLen);
return MUX_S_OK;
}
MUX_RESULT CLuaMod::GetStats(size_t *pnCalls, size_t *pnErrors,
size_t *pnInsnLimitHits, size_t *pnMemLimitHits,
size_t *pnBytesUsed,
size_t *pnCacheHits, size_t *pnCacheMisses,
size_t *pnCacheEntries)
{
*pnCalls = m_stats.calls;
*pnErrors = m_stats.errors;
*pnInsnLimitHits = m_stats.insn_limit_hits;
*pnMemLimitHits = m_stats.mem_limit_hits;
*pnBytesUsed = m_nMemUsed;
*pnCacheHits = m_stats.cache_hits;
*pnCacheMisses = m_stats.cache_misses;
*pnCacheEntries = m_cache.size();
return MUX_S_OK;
}
MUX_RESULT CLuaMod::SetLimits(int nInsnLimit, int nMemLimit)
{
if (nInsnLimit > 0)
{
m_nInsnLimit = nInsnLimit;
}
if (nMemLimit > 0)
{
m_nMemLimit = nMemLimit;
}
return MUX_S_OK;
}
// =========================================================================
// mux_IServerEventsSink stubs.
// =========================================================================
void CLuaMod::startup(void) { }
void CLuaMod::presync_database(void) { }
void CLuaMod::presync_database_sigsegv(void) { }
void CLuaMod::dump_database(int dump_type) { (void)dump_type; }
void CLuaMod::dump_complete_signal(void) { }
void CLuaMod::shutdown(void) { CacheClear(); DestroyLuaState(); }
void CLuaMod::dbck(void) { }
void CLuaMod::connect(dbref player, int isnew, int num) { (void)player; (void)isnew; (void)num; }
void CLuaMod::disconnect(dbref player, int num) { (void)player; (void)num; }
void CLuaMod::data_create(dbref object) { (void)object; }
void CLuaMod::data_clone(dbref clone, dbref source) { (void)clone; (void)source; }
void CLuaMod::data_free(dbref object) { (void)object; }
// =========================================================================
// =========================================================================
// Factory function — called from engine_com.cpp's CLuaModFactory.
// =========================================================================
MUX_RESULT lua_mod_create_instance(MUX_IID iid, void **ppv) {
CLuaMod *pLuaMod = nullptr;
try { pLuaMod = new CLuaMod; } catch (...) { ; }
if (nullptr == pLuaMod) return MUX_E_OUTOFMEMORY;
MUX_RESULT mr = pLuaMod->FinalConstruct();
if (MUX_SUCCEEDED(mr)) {
mr = pLuaMod->QueryInterface(iid, ppv);
}
pLuaMod->Release();
return mr;
}