Commit graph

7 commits

Author SHA1 Message Date
Stephen Dennis
c023eadc85 fix(engine): the restart-arm timer is system work, not user work (#2131)
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>
2026-08-06 07:12:57 -06:00
Stephen Dennis
5ccf4e4a03 fix(engine): keep a recurring system task alive after it throws (#2011)
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>
2026-08-03 16:47:06 -06:00
Stephen Dennis
4b8b4c6970 fix(engine,net): contain exceptions on the command/task path (#2009)
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.
2026-08-03 16:13:54 -06:00
Stephen Dennis
9a4de32dcb fix(queue,db): scheduler enqueue success and flatfile write failures (#1871 #1869)
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.
2026-07-31 09:09:18 -06:00
Stephen Dennis
2f106f200f fix(win32): migrate the remaining mux_atol callers to mux_atoi64 (#1373)
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>
2026-07-26 10:12:35 -06:00
Stephen Dennis
9aa4566647 Make smoke harness self-terminate and detect dropped tests
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>
2026-06-04 12:11:13 -05:00
Stephen Dennis
4ff1398de1 Restructure mux/ directory: component-based layout with proper build root
Move from flat mux/src/ layout to clean component hierarchy:
- mux/ is now the autoconf/automake build root (configure.ac lives here)
- mux/include/ — shared headers used by multiple components
- mux/lib/ — libmux.so (core utilities, no game state)
- mux/src/ — netmux driver only (thin networking shell)
- mux/modules/engine/ — engine.so (game logic)
- mux/modules/{comsys,mail,exp3,sqlproxy,sqlslave}/ — external modules
- mux/ganl/ — GANL networking library
- mux/sqlite/ — SQLite amalgamation (builds libsqlite3.a)
- mux/announce/ — announce tool (was mux/src/tools/)

Build changes:
- SUBDIRS ordering: ganl sqlite lib src modules announce
- libmux.so gets -Wl,-soname,libmux.so; netmux links via -L -lmux
- engine.so links libsqlite3.a and libmux.so with -Wl,--no-undefined
- RPATH uses $ORIGIN for portable .so resolution
- Install hooks use absolute paths for game/bin symlinks

Bug fixes:
- engine.so mux_Register() now passes nullptr to mux_RegisterClassObjects
  (matches all other modules; libmux already has the factory via dlsym)
- DbConvert() now calls pcache_init() before db_write, fixing a latent
  crash (free(): invalid pointer) when exporting from SQLite databases

411/411 smoke tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:38:37 -06:00
Renamed from mux/src/timer.cpp (Browse further)