Commit graph

21 commits

Author SHA1 Message Date
Stephen Dennis
eb12a97f68 fix(lua/jit): pre-entry fallback, protect GETGLOBAL/EQ, CMP_RR types (#1835 #1836 #1837)
Phase 4 over-committed pre-entry setup failures (carg/depth/get_dbt) as
#-1 LUA JIT RUN FAIL; fall back to the interpreter when nothing ran.
Protect lua_getglobal and lua_compare via pcall so raising metamethods
cannot abort the server. Apply EQK-class type gates and NIL/BOOL kinds
to register-register CMP_RR equality, and decline mixed-type order.
2026-07-31 07:12:16 -06:00
Stephen Dennis
c891dea6ac fix(lua): restore outer mux.args after nested lua() runs
Nested ExecuteChunk/TryJIT always rewrote the global mux table's
executor/caller/enactor/args and only restored the registry pointer.
An outer chunk that mux.eval("lua(...)") then re-read mux.args saw
the inner args (or nil) — plan residual "anytime item 3", failing
smoke TC071 as outer-token:inner-token:nil after #1770 rebased.

Stack the previous mux fields in the registry on nest and restore them
with the outer ctx.  TC071–078 stay green; full smoke 1574/0.
2026-07-29 13:02:38 -06:00
Stephen Dennis
20fe115a32 fix(lua/jit): allow effects on compiled path; close post-entry loud
Phase 4 deleted silent re-run, so #1750's effect-free corridor is obsolete:
bridge notify/pemit/set/eval run on both routes under the same permissions.
Remove EFFECT_REFUSED and compile-time effectful ineligibility.

STATE e2 delivers PING once on both legs. POST_ENTRY_LOUD 1→0;
AGREE_DECLINE 10→8 (mux.eval executes).

Docs: rewrite plan-lua-post-entry-contract as campaign-complete + residual
design goals; product plan points residual quality at §3.
2026-07-29 11:08:22 -06:00
Stephen Dennis
950eacdacb fix(lua/jit): Phase 4 ratchet — no post-entry interpreter re-run (#1751)
After RunCompiled, TryJIT always commits the result (success, LUA ERROR,
POST-ENTRY residual, RUN FAIL, or CPU LIMITED) and never returns false to
re-run the chunk in the Lua VM.  Pre-entry fallback remains: compile refuse,
empty dump, prior ineligible, cache miss/recompile.

run_cached_program with a Lua state never returns false after dbt_run for
unhandled status — commits #-1 LUA JIT RUN FAIL instead.

UNHANDLED_LUA_BRIDGE becomes a committed LUA ERROR.  lua_ecall_decline
rewrites any residual soft decline into ECALL_LUA_ERROR so the poison
retry path cannot return by accident.

Harness still green (post_entry_loud=1: EFFECT_REFUSED only).
2026-07-29 06:52:02 -06:00
Stephen Dennis
6c9f250cb9 test(lua/jit): Phase 0.5 purity oracle for post-entry exhibits (#1751)
Add a STATE tier to tests/luajit that runs the #1751 exhibits as multi-step
setup/action/state probes:

  e1_double_mutation — N must match across legs (not 2 vs 1); JIT may loud-fail
  e2_coroutine_effect — effect count must match when both succeed; loud fail OK

Also count EFFECT_REFUSED via jit_lua_note_post_entry_decline so
lua_post_entry_decline advances when TryJIT commits the loud path outside
RunCompiled. Ratchet POST_ENTRY_LOUD_BUDGET to 7 (includes STATE loud).
2026-07-29 01:40:19 -06:00
Stephen Dennis
254ac63624 fix(lua/jit): post-entry decline is a loud fail, not a silent re-run (#1751 Phase 0)
A discarded compiled Lua run is not free once guest code may have run.
Wire every ECALL_DECLINE after entry to a committed error
(#-1 LUA JIT POST-ENTRY DECLINE (site)), a JIT/RETRY log line, and
jitstats lua_post_entry_decline. effect_refused follows the same rule.
Pre-entry fallback (compile refuse / cache miss) is unchanged.

Also fix lua_ecall_decline so it returns ECALL_DECLINE instead of
recursing (that hang turned budget/LIMITED cases into harness timeouts).

Harness: AGREE/NESTED_AGREE accept loud fails; ratchets POST_ENTRY_LOUD=5
and AGREE_DECLINE=2 from measured counts.
2026-07-29 01:34:45 -06:00
Stephen Dennis
df7e6d1d6f 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
Stephen Dennis
eabdd8e666 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
Stephen Dennis
6fb0860a99 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
Stephen Dennis
66de787605 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 14:35:25 +00:00
Stephen Dennis
641c886e90 fix(lua): note that MatchInterrupt covers the matcher half of #1591
The InsnCountHook comment still said the matcher was unbounded; that is
what this PR closes.
2026-07-27 18:21:05 +00:00
Stephen Dennis
e10f84a109 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
Stephen Dennis
ce603e68e0 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
Stephen Dennis
fa56d8031e
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
Stephen Dennis
8d6a7f29a4 fix(lua/jit): default-off lua_jit gate for safe loader land (#1309)
Phase 1 of the Lua JIT bring-up: mudconf.lua_jit (GOD/WIZARD) defaults
to false so the interpreter path remains the only production path while
hir_lower_lua correctness work continues.

TryJIT no-ops when the gate is off; @list lua reports enabled/disabled.
Documents the full phase plan in docs/plan-lua-jit-bringup.md.

Paired with the #1278 loader cherry-pick on this branch: master can take
both and stay green; developers flip lua_jit 1 to exercise the JIT.
2026-07-25 22:44:45 -06:00
Stephen Dennis
8e33b632f9 fix(lua): gate pennies/iswizard/isconnected like softcode (#1287)
Pass D4 residual on the Lua mux.* bridge.

mux.pennies/iswizard/isconnected took raw dbrefs with no Examinable /
pub_flags check, while softcode money() and hasflag() require those
gates. Match the softcode model. Also nil string.dump after opening
the string library (bytecode dump / loader rebuild).

Closes #1287.
2026-07-25 20:05:16 -06:00
Stephen Dennis
691a8aad76 engine/lua: make refcount and JIT cache key atomic (match comsys/mail)
The comsys and mail modules already use std::atomic<uint32_t> for their
COM-style refcounts; the Lua module did not, leaving CLuaMod::m_cRef and
the Lua JIT chunk-cache key counter as plain integers.

- #723: CLuaMod::m_cRef -> std::atomic<uint32_t>, with AddRef/Release using
  fetch_add(relaxed)/fetch_sub(acq_rel) exactly like CComsysMod. (CLuaMod
  has no separate factory class; the registration entry point is a plain
  function, so there is no second counter to convert.)
- #724: jit_lua.cpp s_next_key -> std::atomic<uint64_t> with
  fetch_add(relaxed); the pre-increment value is preserved (first key = 1).

The engine currently runs single-threaded, so neither was exploitable
today; this brings the Lua module in line with the established
correct-by-design refcounting convention so it stays sound if module calls
are ever made off the main thread. No behavioral change; smoke 1064/0.

Closes #723
Closes #724

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:40:57 -05:00
Stephen Dennis
2536921959 Close 4 Lua bridge permission holes (ISSUES #1 audit)
mux.notify/pemit: Add @pemit-equivalent permission checks —
nearby, Long_Fingers, Controls, page-lock, pemit_any/pemit_players
config flags. Returns false on denial instead of silently sending.

mux.location: Add locatable() check so UNFINDABLE objects return
nil instead of leaking their location.

mux.name: Add read_rem_name/nearby/control check matching the
name() softcode function. Strip exit aliases (semicolon handling).

mux.controls: Restrict 'who' parameter — executor must be 'who'
or must control 'who', preventing scripts from probing wizard
control relationships.

Also: include externs.h directly (Lua is in engine.so now, not a
separate module) and update stale file-header comment.

551/551 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 21:28:22 -06:00
Stephen Dennis
c9bea697df Enable generic Lua function calls (math.floor, string.upper, etc.)
The big unlock: GETTABUP now handles ALL _ENV globals (not just "mux"),
and CALL now handles both mux.* bridge calls and general Lua function
calls via __lua_call ECALL. Scripts using math.floor(), string.upper(),
tostring(), or any global function are now JIT-eligible.

Key fix: lua_settop() save/restore in TryJIT around RunCompiled.
ECALL handlers for __lua_getglobal push tables/functions onto the Lua
stack, and __lua_call pushes/pops during lua_pcall. Without cleanup,
the stack overflows on repeated calls. The save/restore ensures every
JIT execution leaves the Lua stack in its original state.

4 new smoke tests (TC043-TC046): math.floor, string.upper, tostring,
math.sqrt — verifying the generic global→field→call path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:35:29 -06:00
Stephen Dennis
b5ce590bb2 Thread lua_State through JIT ECALL path for VM callback
Added void *lua_state to eval_ctx, run_cached_program(), and
mux_IJITCompile::RunCompiled(). CLuaMod::TryJIT passes m_L through
the entire chain so ECALL handlers can call back into the Lua VM
for operations the JIT can't handle natively.

nullptr for softcode JIT (no change in behavior). Non-null for
Lua JIT — enables future ECALL handlers for table access, string
ops, generic calls, and upvalue reads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:19:28 -06:00
Stephen Dennis
22ee824c18 Fold lua_mod into engine.so — Lua is a first-class engine language
Moved lua_mod.cpp/h from mux/modules/lua/ into mux/modules/engine/.
engine.so now links liblua54.a directly (like libsqlite3.a) and
registers CLuaModFactory in its COM front-door.

This eliminates the .so boundary between the Lua VM and the JIT
compiler, enabling future ECALL-back-to-VM for unsupported opcodes
without cross-module callback plumbing.

- Removed standalone module entry points (mux_Register etc.)
- Added lua_mod_create_instance() factory function
- Registered CID_LuaMod in engine_classes[] and mux_GetClassObject
- Removed lua_mod.so build from modules/Makefile.am
- Removed "module lua_mod" from smoke.conf (no longer needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:11:05 -06:00