muxscript idled ~15.15 seconds after EOF on every invocation, regardless
of workload — five thousand JIT compilations moved its wall clock by
22 ms while this held it for 15 s. strace showed the shape exactly:
fifteen one-second polls in script_loop's post-EOF arm, waiting for
CScheduler::HasPendingUserTasks to go false.
What kept it true: init_timer schedules dispatch_CanRestart — a one-shot
that arms the @restart throttle 15 seconds after startup — at
PRIORITY_OBJECT, inside the (PRIORITY_SYSTEM, PRIORITY_SUSPEND) band the
exit predicate counts as user work. It is maintenance wearing a user
priority: the predicate's own comment excludes "dumps, idle checks,
keepalives — which recur forever" but had no way to know about a
one-shot safety timer filed in the wrong band.
Moved to PRIORITY_SYSTEM. This also means the throttle arms even while
@disable'd dequeuing blocks the user band — the right behaviour for a
safety timer. (The comment above the call also said "10 seconds" while
the code said 15; now it says what the code does.)
Measured: `think hi` 15.153s -> 0.030s, faster than the @shutdown
workaround (0.134s). The completeness gate is intact: `@wait 3` holds
the process open exactly 3.0s and the deferred task runs before exit.
Every muxscript-driven suite (smoke, growth, codiff, jit parity) drops
the same 15 s per invocation.
Full `make test EXPECT_CONFIG="jit=yes"`: 35 passed, 1 skipped
(stubslave, not configured), 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to #2010. That change stopped a throwing task from killing the
game. It did not stop a throwing task from killing itself.
Each of the six recurring system dispatchers re-defers its next run on its
LAST line:
cache_tick(); // throws
// Schedule ourselves again.
scheduler.DeferTask(..., dispatch_CacheTick, ...); // never reached
#2010's barrier lives in CScheduler::RunTasks, above these frames, so it
catches the throw only after unwinding has already skipped the DeferTask.
The game survives and the task is gone permanently.
Measured on merged master with a one-shot throw on the third cache tick at
cache_tick_period 1:
before 3 ticks in 30 seconds, then never again
after 29 ticks
For dispatch_DatabaseDump the same shape means automatic dumps stop
permanently and silently -- a game that survives the exception and then
quietly stops saving is losing the database slowly rather than all at
once, which is not the trade #2009 was meant to buy.
dispatch_FreeListReconstruction, dispatch_IdleCheck, dispatch_KeepAlive
and dispatch_CheckEvents have the same structure.
run_task_body() wraps only each dispatcher's body, so the reschedule tail
stays unconditional. It does not replace #2010's barrier: that one still
covers queued command tasks (Task_ProcessCommand), which is the larger
surface and does not self-reschedule.
make test-smoke 1605/0/1. test-alarm, test-dbt, test-scenario,
test-comsys-handoff pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything softcode does runs under CScheduler::RunTasks -- command
parsing, function evaluation, mail, comsys, @dump -- and all of it
allocates. There was no catch between that frame and main(), so a
throw became std::terminate -> abort -> SIGABRT, and signals.cpp
handles SIGABRT by logging and exit(1): no dump_restart_db(), no
re-exec. A SIGSEGV on the same line forks, dumps and execl()s a fresh
netmux, so an exception cost the database where a null dereference
would have self-healed.
The issue treated 2.14 reachability as inferred, since its gdb trace
came from 2.13. It is now demonstrated here: with a one-shot
bad_alloc injected into ConnectionBase::close() and a login failed
past retry_limit, unpatched master dies with Abort trap: 6 and refuses
further connections.
Three barriers.
1. Per task, inside CScheduler::RunTasks. Chosen over per-tick
containment for two reasons. One bad command dies without dropping
the rest of the tick. And it keeps the delete reachable: pTask is
already off the priority heap when fpTask runs, so an escaping
throw leaked the record as well as losing the task -- per-tick
containment cannot fix that.
Abandoning one task is survivable. process_command() resets
func_nest_lev, func_invk_ctr, ntfy_nest_lev and lock_nest_lev at
the top of every command, so a half-finished command cannot poison
the next one's limits. It leaks that command's lbufs, which is
bounded and far cheaper than losing the database.
2. A netmux-side backstop around g_pIGameEngine->RunTasks. The
per-task barrier lives in engine.so while run_main_loop is in
netmux -- a module boundary 2.13 does not have, since it reaches
RunTasks directly. The backstop covers a throw from the
scheduler's own frame (the heap operations around the task record)
and anything crossing the boundary whose type the per-task arms did
not match: engine.so is built -fvisibility=hidden, so an
engine-local exception type has no exported typeinfo in netmux and
lands in catch (...).
3. close_contained() for the two conn->close() calls inside the
existing handleNetworkEvent catch arms. They were bare, and a
throw from close() is the demonstrated trigger, so they would have
escaped the handler and aborted anyway. A barrier that can abort
is not a barrier. Abandoning the close leaves the connection to be
reaped by the ordinary idle path, which is survivable.
Verified by injection on macOS arm64, baseline first so that a pass
could not be vacuous -- the close only fires once retries_left reaches
zero (retry_limit defaults to 3), so a single bad connect would have
proved nothing:
unpatched attempt 2: connection closed; Abort trap: 6
DEAD: cannot connect (Errno 61); pid gone
with fix attempt 3: connection closed cleanly
ALIVE: server answered 725 bytes
MUX BUG/TASK : Exception escaped a scheduled task
(std::bad_alloc); task abandoned.
The liveness check is a follow-up connection that must be answered,
not merely accepted; a listening socket outlives a dying process.
That also exercises cross-binary propagation: the throw originates in
libganl, is caught in engine.so, and netmux continues -- under
libc++abi on arm64, a different unwinder and architecture from the
Linux x86-64 run on the 2.13 side.
make test: 33 passed, 1 skipped (stubslave, not configured), 0 failed.
DeferTask/DeferImmediateTask return bool (nothrow OOM); wait_que and
sql_que free the BQUE and refund quota/waitcost when enqueue fails.
db_write_object reports stream status via ferror; db_write returns -1
on I/O failure after header/object/end/flush checks, and dump/dbconvert
callers refuse to publish a truncated flatfile.
Completes the sweep the issue called for. mux_atol returns long, which
is 32-bit on LLP64, so every caller silently truncated on Windows. Two
of those were real defects (the truthiness family and cf_size, fixed in
the preceding commits); the rest were latent, waiting for a value large
enough to matter.
Rather than audit 290 sites for whether each can reach 2^31 today, use
the 64-bit parser everywhere and remove the class. A dbref cannot
overflow now, but nothing stops a later caller passing that same site a
timestamp or a byte count.
Pure 1:1 substitution: 285 lines changed, and every removed line
contained mux_atol while every added line contains mux_atoi64. No
control flow, no types, no behaviour beyond the wider parse.
This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so
the generated code there is unchanged. It only widens the parse on
Windows. Narrowing destinations are unaffected either way: `int x =
mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both
models.
Left alone: mux_atol itself in mathutil, its declaration, and three
comments that name it. Callers that genuinely want 32-bit semantics can
still ask for them; none appear to.
Verified on Windows: full solution builds clean with no new warnings,
smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched --
identical to before the sweep, with the same 16 build-configuration
failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST).
Spot checks after the change: the boolean family returns 1 for multiples
of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited,
and arithmetic, string and list functions are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite runs under CLI muxscript via a serial semaphore chain, but
relied on a leftover @shutdown to stop the process. @shutdown raced the
command queue and silently dropped a nondeterministic, platform-sensitive
tail of tests behind an "ALL PASSED" banner.
- CGameEngine::WhenNext now returns MUX_E_NOTFOUND when the scheduler is
empty instead of always MUX_S_OK (also fixes an unset-timeout misread
in netmux's ganl idle loop, which had used a zero-initialized time).
- New CScheduler::HasPendingUserTasks(): muxscript exits on stdin EOF once
only recurring system maintenance (dump/idle/keepalive) and parked
semaphore tasks remain, while still honoring delayed @wait tasks.
- shutdown.mux no longer calls @shutdown; muxscript self-terminates.
- mux_main attempts the stdin read regardless of poll()'s verdict, since
macOS poll() never flags /dev/null readable and EOF went undetected.
- smoke.mux logs SUITE-EXPECTED/SUITE-DISPATCH and tools/Smoke asserts
every expected test dispatched, naming any that did not.
This surfaced ~140 tests that were being silently skipped, including the
strlen CJK grapheme cases fixed in the previous commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>