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>
This commit is contained in:
Stephen Dennis 2026-07-27 10:33:58 -06:00
parent bf215bfc09
commit ce603e68e0
2 changed files with 95 additions and 1 deletions

View file

@ -627,9 +627,50 @@ void *CLuaMod::LuaAlloc(void *ud, void *ptr, size_t osize, size_t nsize)
// Instruction count hook — enforces execution limits.
// =========================================================================
// 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. Lua's pattern matcher can run unboundedly on a small input, which is
// the remaining half of #1591 and needs the matcher itself to count, the way
// quick_wild already does with mudstate.wild_invk_ctr.
//
void CLuaMod::InsnCountHook(lua_State *L, lua_Debug *ar)
{
(void)ar;
// 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");
}
@ -772,7 +813,19 @@ bool CLuaMod::ExecuteChunk(lua_State *L, dbref executor, dbref caller,
// Set instruction count hook.
//
m_bMemExceeded = false;
lua_sethook(L, InsnCountHook, LUA_MASKCOUNT, m_nInsnLimit);
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);
// Call the chunk (it's below the mux table stuff we just popped).
//
@ -793,6 +846,25 @@ bool CLuaMod::ExecuteChunk(lua_State *L, dbref executor, dbref caller,
const char *errmsg = lua_tostring(L, -1);
if (nullptr == errmsg) errmsg = "unknown error";
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++;
@ -868,6 +940,9 @@ CLuaMod::CLuaMod(void) : m_cRef(1),
m_pIJITCompile(nullptr),
m_L(nullptr),
m_nInsnLimit(LUA_DEFAULT_INSN_LIMIT),
m_bCpuLimited(false),
m_nInsnPoll(LUA_ALARM_POLL_INSNS),
m_nInsnUsed(0),
m_nMemLimit(LUA_DEFAULT_MEM_LIMIT),
m_nMemUsed(0),
m_nMemPeak(0),

View file

@ -31,6 +31,12 @@ extern "C" {
// Default resource limits.
//
#define LUA_DEFAULT_INSN_LIMIT 100000
// How often the instruction hook fires so the wall-clock alarm is polled
// (#1591). Small enough that a chunk notices the alarm promptly, large enough
// that the hook is not the cost of running Lua. The instruction limit is
// accounted across polls, so this does not change what that limit means.
#define LUA_ALARM_POLL_INSNS 2000
#define LUA_DEFAULT_MEM_LIMIT 1048576 // 1 MB
#define LUA_DEFAULT_CACHE_SIZE 256
@ -42,6 +48,7 @@ struct lua_mod_stats
size_t errors;
size_t insn_limit_hits;
size_t mem_limit_hits;
size_t cpu_limit_hits; // wall-clock aborts (#1591)
size_t peak_mem_bytes;
size_t cache_hits;
size_t cache_misses;
@ -74,6 +81,18 @@ private:
size_t m_nMemPeak;
bool m_bMemExceeded;
// Wall-clock abort (#1591). Set by InsnCountHook when alarm_clock has
// fired, so Run() can answer "#-1 CPU LIMITED" -- the same text the AST
// evaluator and the JIT produce -- rather than wrapping it as a Lua error.
bool m_bCpuLimited;
// Instruction accounting for the hook. The hook now fires every
// m_nInsnPoll instructions rather than once at the limit, so that the
// alarm is polled periodically; m_nInsnUsed accumulates toward
// m_nInsnLimit so the instruction limit keeps its old meaning.
int m_nInsnPoll;
int m_nInsnUsed;
// Execution statistics.
//
lua_mod_stats m_stats;