Commit graph

63 commits

Author SHA1 Message Date
Stephen Dennis
db51882c49 test: make test-poison — run the smoke suite against a hostile allocator (#2149)
Upstreams the LD_PRELOAD shim built during the #2146 review, which found
nothing but was the only thing capable of finding it.

#2145 made eleven list-builtin tables uninitialized by contract, and the
#2136 arc keeps editing those same functions.  The risk that introduces is
a read past the count something actually wrote — and that read is invisible
to every gate this tree has.  Large allocations normally come back as fresh
kernel-zeroed pages, so the stale slot reads as zero and behaves exactly
like the value-initialized code it replaced.

It is worse than merely invisible.  Measured, alternating a table-filling
call with a short-list call reading one slot past a count of three:

    200 rounds: past-count read was null 1 times, NON-NULL 199 times
      of the non-null reads, 199 dereferenced without faulting

So the recycled case is not a crash waiting to happen — it is a stale but
valid pointer that dereferences cleanly into whatever the previous caller
left there.  Silent wrong output.  Filling every large malloc with 0xAA
makes that slot non-canonical, so it faults instead.

The shim is verified before it is trusted.  A suite passing under a shim
that silently failed to load is indistinguishable from one passing under a
working shim, and worth exactly nothing (#1946's genre for build config,
#2133's for the benchmark instruments).  run.sh asserts, as hard gates,
that a large allocation really is poisoned and that an injected past-count
read is invisible unpoisoned and fatal poisoned — otherwise it fails rather
than reporting a green run it cannot stand behind.  Sabotage-verified: a
shim built not to poison, one filling with 0x00, and a stuck LD_PRELOAD all
fail the target loudly.

selftest --recycle ships the demonstration above, so the justification is
runnable rather than remembered.

Opt-in, NOT part of `make test`: Linux/glibc only (macOS interposes through
malloc zones, a different mechanism — loud SKIP there), and it pays a memset
on every large allocation.  Smoke under poison: 1660 passed, 1 skipped, 0
failed, 0 crashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:31:50 -06:00
Stephen Dennis
526e49c98d test(perf): assert complexity class, not speed; xfail iter() on the JIT (#2052)
Adds tests/growth — a battery that measures how evaluation cost GROWS with
input size and asserts the growth class, rather than comparing any absolute
number to a baseline.

tests/perf needs one baseline file per box, hand-calibrated tolerances, and
only one of its three legs is trustworthy enough to gate on (#2046). An
exponent needs none of that: doubling N costs 2.0x if an implementation is
linear and 4.0x if it is quadratic on every machine, because the hardware
cancels out of the ratio. The trade is explicit — this cannot see a 10%
regression, it sees O(n) become O(n^2), which is the failure that actually
ruins a live game and the one a threshold test reports late and without a
cause.

Runs under muxscript: no network, no port, no server lifecycle.

The first case it covers is #2052:

  iter(lnum(N),1)  interp   b=0.99  [2.06x 1.96x 1.96x]  linear
  iter(lnum(N),1)  jit      b=1.99  [3.96x 3.95x 4.01x]  quadratic  (xfail)

Same expression, same list, two routes — which makes the interp line the
tightest available control for the jit line. ladd(lnum(N)) is measured on
both routes and reads linear on both, so the jit column is known to be
capable of reading linear at all.

Three things this harness has to get right, each of which has already cost
us a measurement:

* Two benchmark fields lie, and both were used to produce the numbers now
  on #2052. astbench's `jit=` calls jit_eval unconditionally and times it
  even when it bails instantly for want of a lowering — citer() reads a flat
  2.7us at every N through that field, which is absence rather than speed —
  and astbench's `result=` comes from a third ast_eval_node call, so it can
  never notice. rvbench's `native=` calls mux_exec, which dispatches to the
  JIT for any JIT-eligible expression, so for iter() it is a second
  measurement of the JIT differing only by dispatch overhead. This uses
  astbench's `ast=` and rvbench's `cached=`.

* Input shape is proved before anything is timed. repeat(a ,N) trims the
  trailing space and yields ONE element, and an LBUF is 32768 bytes so
  lnum(8000) truncates silently. A growth measurement over input that is not
  the size it claims is worse than none, because it looks like a result.

* The verdict is a least-squares exponent across all points, not consecutive
  ratios. One cold-cache first measurement produced a 1.42x step on an
  otherwise clean 1.97x/2.03x line and failed the run; a perf test that fails
  on a busy laptop gets disabled, and a disabled test is worse than none.
  There is also a discarded warm-up pass so no case is penalised for its
  position in the list.

Both failure arms were validated by deliberately breaking them: dropping the
xfail reports FAIL, and claiming the defect is expected-quadratic reports
XPASS and also fails, so a landed fix cannot leave a stale xfail behind.

Opt-in via `make test-growth`; NOT part of `make test`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:37:39 -06:00
Stephen Dennis
736759e415 test(perf): record and compare rvbench timings against a baseline (#2046)
Step 2 of the performance arc's measurement side. `make test-perf` extracts
the rvbench timings that rvbench_fn.mux already emits on every smoke run,
writes them as JSON, and diffs them against a per-machine baseline.

Opt-in, NOT part of `make test`: it reads a previous smoke run's log and
compares against a baseline that only exists where someone recorded one.

THREE DESIGN CHOICES, EACH MADE BY MEASUREMENT AFTER THE FIRST GUESS FAILED.

1. Parse the smoke log; do not drive a live server.

   A socket-driven harness was written first and measured: 82% run-to-run
   spread on the native leg against 2-9% for the same benchmarks through
   smoke, and systematically slower (426-612ns vs ~320ns for add(1,2)).
   Driving rvbench() over a connection interleaves network events, command
   dispatch and timer work with the timed loop. Changing the estimator did
   not rescue it -- median and min were both tried and min was no better
   (82.6% either way). The noise was in the vehicle, not the statistics.

   Deleted, and the quiet path that already existed is used instead. It also
   covers 55 expressions rather than the 8 the driver hand-picked.

2. Baselines are per-machine, and ratios are NOT the portable currency.

   I expected cached/native to cancel machine speed and noise. Measured, it
   does the opposite: ratio spread 8.4% against 6.0% and 7.4% for the legs.
   native and cached are timed in SEPARATE loops, so their noise is
   independent and dividing compounds it -- sqrt(6^2+7.4^2) = 9.5%, which is
   what came out. Cancellation needs both legs sampled under one excursion.

   So the file is baseline.<uname -s>-<uname -m>.json and a missing one is a
   skip of the comparison, not a failure.

3. Tolerances are per-leg, and only one leg may fail a gate.

   Two smoke runs, same build, 55 benchmarks:

     leg       median   p90     max
     native      2.8%  15.7%   37.2%
     compile     0.7%   1.6%    4.6%
     cached      4.9%  24.9%  104.8%

   Stability tracks measurement duration: compile-each costs 3-22us/call so
   its loop dwarfs a scheduler excursion; cached runs at 13-160ns/call, where
   one preemption is the whole sample. A single global tolerance either
   misses real regressions on compile or cries wolf on cached -- at 25%
   global, comparing a build against ITSELF produced five false regressions.

   PERF_GATE=1 therefore fails only on `compile`. A later run showed a +44.1%
   native outlier against the same build, past the 40% two runs had
   suggested, which is the argument in one number: a gate that fires on noise
   is how a suite earns a reputation for lying. native and cached are
   reported as advisories until per-leg iteration counts inside rvbench()
   make them measurable.

Keyed on the EXPRESSION rather than BENCH<nnn>: the numbers are positional in
rvbench_fn.mux, so inserting a benchmark would renumber every one after it
and silently re-point the baseline at different code.

make test 35 passed / 1 skipped / 0 failed
(jit=yes stubslave=no nls=yes realitylvls=yes wodrealms=yes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:27:56 -06:00
Stephen Dennis
e32ca0356c Merge master into test/color-ops-blob-differential
Conflict was the .PHONY line only, and it is a clean union: this branch
added test-codiff/test-codiff-2019 after test-blob, master's #2003 merge
added test-nfc after test-netaddr. Neither touched the other's entry.
Took master's line and re-inserted the two codiff targets.

Verified after resolving: every TEST_TARGETS entry appears in .PHONY, and
every .PHONY entry has a rule. test-codiff is in TEST_TARGETS;
test-codiff-2019 is deliberately not, being the expected-to-fail #2019
reproducer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:35:52 -06:00
Stephen Dennis
a1b24a1d73 Merge master into feat/nfc-tests
Conflict was mechanical: both sides appended a target to .PHONY --
test-nfc here, test-jit-recursion on master.  Kept both.
2026-08-03 19:46:08 -06:00
Stephen Dennis
62c9c1cbe4 test(codiff): run color_ops on every route that executes it, with qemu as oracle
color_ops.c is compiled twice -- once into libmux for the host, once into
the freestanding rv64 blob -- and the blob is then executed by two engines
of our own.  That is four implementations of one source, and until now the
only differential we had compared the first against itself.

#2002 is where that bit.  A cursor rewrite of the word-list functions was
verified on the host across 560,000 cases with a negative control, and it
still broke in the blob; the attempt was reverted with no root cause,
because reproducing it needed the blob under an external oracle and no box
had run one.

This adds that.  One freestanding guest binary uses only Linux syscalls 64
and 93 -- exactly what dbt_test.cpp's ELF harness implements -- so
qemu-riscv64-static, rv64_interp_run and the DBT all execute the SAME
instruction stream rather than three builds of one source.  The host leg
links the already-built libmux, which makes it the pre-change
implementation and therefore the specification, not a hand-written table
of expectations.

The battery is 16 fixed cases, 5 at the max_words cap, and 200 seeded
random cases driven by the same LCG on every route, so inputs are
identical by construction.  1,221 transcript lines.  The cap cases earn
their place: a cap-check off-by-one moves 4 lines and the random leg
catches it never, while a common-path off-by-one moves 212.  Both controls
were run.

What it found on first use is #2019 -- the DBT returns a wrong answer for
correct RV64 code when block chaining is enabled, intermittently.  So the
#2002 rewrite was sound and the divergence was ours.  repro/ carries that
reproducer: the rewrite as a patch (color_ops.c is generated -- a real
change belongs in color_ops.rl) and a script that loops it and reports a
rate, because a single run passes about half the time.

test-codiff joins TEST_TARGETS and passes on master.  test-codiff-2019 is
deliberately NOT in `make test`: it is expected to fail.  Both skip loudly
without a RISC-V cross-compiler rather than reporting a pass for a run
that compiled nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:33:37 -06:00
Stephen Dennis
3a9ecd068f fix(jit): stop the shared heap re-entering its own DBT context (#1994)
Terminating a runaway self-recursive ufun on the compiled route cost time
exponential in function_recursion_limit.  Measured here, before:

  function_recursion_limit    eval_attempts    seconds
     8                                   97       0.02
    12                                1,537       0.01
    16                               24,577       0.14
    20                              393,217       0.57
    24                            6,291,457       7.76

eval_attempts is exactly 3*2^(d-3)+1 at every depth -- a full binary tree
with 2^(d-3) leaves and 2^(d-3)-1 internal nodes.  The AST route is 0.00s
at every depth, so the blowup is specific to the compiled route.

Root cause.  shared_heap_t holds one dbt_state_t: one guest register
context, one stack pointer, one heap arena.  jit_eval's s_jit_depth > 1
arm runs through it, and that program ECALLs into u(), whose body
evaluates another bracket, which re-enters shared_heap_t::eval.  The
nested run sets ctx.x[2] = STACK_TOP, resets s_heap_next and calls
tier2_reset_writable -- all underneath the suspended outer run.  The outer
run resumes at a program counter that is not its own, the backend refuses
to translate there (XLATE_REFUSE), dbt_resume returns -1, and
shared_heap_t::eval returns false with host ECALLs already run.  jit_eval
propagates that, and mux_exec redoes the whole bracket through the AST.
That bracket contains the next recursion level, so each level is evaluated
twice.

The type declares the hazard away in a comment -- "independent of the
outer expression's DBT, so this is safe to call from within an ECALL
handler" -- which is true of the outer expression's DBT and false of its
own.  #1309 identified the same hazard for the Lua path and guarded it
with s_run_cached_depth; the softcode path re-enters unguarded.

Instrumented over the runaway, the correlation is total: of 511 runs that
had a nested run inside them, 511 failed and 0 succeeded; of 512 runs with
no nested run, 512 succeeded and 0 failed.  Every re-entrant outer run
fails, so the nested run's result is always discarded by the parent's AST
re-run -- declining it up front forfeits no retained work.

After: eval_attempts is exactly limit+1 at every depth, and depth 100 --
which the issue reports as not finishing in any practical time -- answers
instantly.

  function_recursion_limit    eval_attempts    seconds
     8                                    9       0.00
    24                                   25       0.00
   100                                  101       0.00

Not a behaviour change for recursion that should complete: sum(1..n) via a
self-recursive ufun returns n(n+1)/2 on both routes at n = 3..50, checked
against the arithmetic rather than against the other route.

One difference is visible at the boundary.  When the limit is low enough
that the recursion cannot complete, the two routes reach it at different
points inside the expression, because the JIT flattens nesting and this
changes how many func_nest_lev levels one softcode level costs.  For
[switch(gt(%0,0),1,[u(me/CD,sub(%0,1))],done)] the limit can land in the
condition instead of the arm, and switch() then returns its default --
so the compiled route may answer "done" where the AST answers the limit
error.  Enforcement itself is unaffected: func_nest_lev peaked at exactly
limit-2 in every configuration measured, never above.  This is the same
class as the INVOCATION-vs-RECURSION difference already noted on #1994.

The counter is exposed as jitstats() bail_shared_busy so the decline is
visible rather than silent.

Adds testcases/tools/jit_recursion/oracle.sh (make test-jit-recursion),
which asserts eval_attempts stays linear.  It has to assert a counter
rather than a result: the answer is correct either way -- the AST re-run
recomputes the same string -- which is exactly why this went unnoticed,
and a result-equality check cannot see it.  Confirmed by breaking the
guard and re-running: the cost assertion fails at 393,217 while every
result assertion still passes.

A census over the full smoke suite recorded zero re-entrant shared-heap
runs (outer_ok=0 outer_fail=0, flat_ok=113), so smoke never exercised this
path at all -- which is why the regression test is a new oracle rather
than a smoke case.

make test 34/34 with jit=yes stubslave=yes nls=yes realitylvls=yes
wodrealms=yes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:11:28 -06:00
Stephen Dennis
3ad4b05d2c test(nfc): add unit tests for utf8_normalize_nfc
Nothing in tests/ covered Unicode normalization.  The combining-character
smoke cases (graphemes, comp, chr) reach this code, but only
incidentally: none of them asserts that a decomposed sequence composes,
that marks are reordered by canonical combining class, or that the result
is idempotent.  That was the only evidence available when reviewing
#1998, which moved this routine's 512 KB code-point table off the stack.

37 assertions, from two deliberately separate sources:

  - Unicode-mandated results -- base plus combining mark composing to the
    precomposed character, canonical ordering by CCC (dot-below ccc 220
    must precede acute ccc 230, and equal-CCC marks must NOT be
    reordered), algorithmic Hangul L+V composition, and idempotence of
    the normal form over the whole corpus.  These follow from the
    standard, so they are correct expectations independent of what this
    implementation does.

  - Characterization of choices the standard does not dictate: malformed
    UTF-8 is dropped, leading marks with no starter are preserved, and
    the two output-bound behaviours differ (the already-NFC fast path is
    a truncating memcpy; the slow path emits only whole characters that
    fit).  These lock current behaviour so a change is visible rather
    than asserting it is required.

Mutation-tested so the suite is known to have teeth: commenting out
CanonicalOrder fails 2 assertions and commenting out CanonicalCompose
fails 7.  Reverting #1998's own change (static back to automatic) fails
none, correctly -- it is a storage-class change with no behavioural
effect, which is why that PR needed review rather than tests alone.

Wired into make test (34 targets now, was 33) and into the sanitizer
island list, where the bounds handling on the code-point table and
nDstMax are worth instrumenting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:38:41 -06:00
Stephen Dennis
88febbb492 feat(passwords): portable sha-crypt ($5$/$6$) on both platforms, rounds policy, safe auto-upgrade (#1962)
One standard password-hash format everywhere: mux_sha_crypt implements
the sha-crypt construction (glibc/musl/openssl-compatible, including
rounds= presence, clamping-with-clamped-value-in-output, and 16-char
salt truncation) over OS crypto primitives -- OpenSSL EVP on Unix,
Windows CNG with a reusable hash handle.  mux_crypt routes $5$/$6$
through it on BOTH platforms, so a Unix-written password database
verifies on Windows and vice versa, and Unix stops depending on which
libc crypt(3) understands those formats (macOS's does not).  The libc
fall-through remains only for the legacy tail (DES, _-extended, $1$).

New hashes are $6$ with an explicit rounds= from the new conf directive
password_hash_rounds (default 220000, tracking current OWASP guidance
for the SHA-512 PRF; measured ~190 ms/hash on 2022-era server
hardware).  The check_pass auto-upgrade trigger becomes parameter-aware:
a $5$/$6$ hash whose stored rounds differ from policy re-encodes on the
next successful login (work-factor migration, automatic), P6H/legacy
conversion-on-login is preserved, and the implicit default
(password_methods unset) no longer rewrites SHA1-or-stronger hashes --
it used to re-hash every login and would silently downgrade $6$ to
$SHA1$ after a config reset.

tests/shacrypt (make test-shacrypt, also in test-asan) pins 14 KATs
with every golden value from `openssl passwd` as an external oracle,
including both published spec vectors.  Verified on Windows: 14/14 KATs,
digest KATs still 17/17, smoke ALL 1601 PASSED / 0 failed, and a live
netmux end-to-end: @pcreate under sha512 policy stores $6$, a $SHA1$
player auto-upgrades on login, a rounds=5000 hash refreshes to 220000 on
login, and every Windows/CNG-generated hash re-derives byte-identically
with `openssl passwd -6`.

Stacked on #1963's CNG backend (same OS-primitives posture and
bcrypt.lib link).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 15:08:58 -06:00
Stephen Dennis
957caeb1b0 harden(digest): back mux_sha1_digest with Windows CNG, retire homegrown SHA-1 (#1963)
The non-OpenSSL digest backend is now CNG (BCrypt) with cached
algorithm-provider handles; the FIPS-180 MUX_SHA1_* implementation is
deleted and the tree ships no cryptographic source, matching the
Schannel-for-TLS precedent.  A new generalized mux_digest(name, ...)
entry point serves sha1/sha256/sha384/sha512/md5 (case-insensitive,
hyphenated aliases), and fun_digest's non-OpenSSL branch dispatches
through it, so digest(sha256,...) et al. now work on Windows --
digest_fn.mux TC004/TC005 flip from Skipped to Succeeded there via
their existing behavior-probing guards.

Output is byte-identical across the swap: tests/digest (new, wired as
make test-digest and into test-asan) pins the surfaces whose bytes may
never change -- RFC 6455 Sec-WebSocket-Accept (single-part and the
two-part gather websocket.cpp performs), the $SHA1$ salt||password
gather and bare-password $P6H$ shapes from player.cpp, and the sha1()
softcode FIPS vectors -- with every golden value generated by the
openssl(1) CLI as an external oracle.  Verified on Windows: homegrown
== oracle == CNG on all six SHA-1 vectors, 17/17 KATs against the CNG
build, full solution build with zero new warnings, smoke ALL 1601
PASSED / 0 failed.

A non-Windows non-OpenSSL platform now hits #error by design:
configure.ac hard-errors without OpenSSL, so no shipped config lands
there, and the homegrown fallback must not silently return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:01:56 -06:00
Stephen Dennis
e464fc5659 test(build): run the SQLite storage-backend suite (#1953)
tests/db builds two binaries, passes 21 assertions against sqlitedb.cpp and
sqlite_backend.cpp, and nothing ran it: no test-db rule, absent from
TEST_TARGETS, unmentioned in CLAUDE.md.  It is the fourth orphan suite after
#1917's three, and it covers the persistence layer -- whether a game's
database survives a restart.

It stayed hidden longer than the others because `test-dbt` (the RV64 DBT
suite) contains `test-db` as a substring, so grepping for the shorter name
matches the longer target and appears to succeed.  Both the TEST_TARGETS
comment and CLAUDE.md now say so explicitly.

No .gitignore change: tests/db/.gitignore is tracked and already covers
*.o, both binaries and *.db*, and a run leaves the tree clean.

Verified the suite can actually fail, since wiring one that cannot would be
worse than leaving it out.  The first control was badly chosen -- regressing
the LBUF_SIZE clamp in sqlitedb.cpp left it green, because that clamp guards
a tampered blob the suite never writes.  Aiming at asserted behaviour
instead, making UpdateLocation write the owner column gives 9 passed /
1 failed, and through the runner:

    make test: 1 targets - 0 passed, 0 skipped, 1 failed
    FAILED:  test-db

Cold build ~23s (it compiles sqlite3.c itself), warm ~0.65s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:14:39 -06:00
Stephen Dennis
841389752c test(config): state the build configuration a green run was green under (#1946)
Most optional features default to NO and their tests skip cleanly when
absent, so "29 passed" means different things on different boxes and
nothing in the output said which.  #1944 and #1943 landed the same
evening from opposite directions: in both, the bug survived because the
configuration was optional, and the fix inherited the same invisibility.

tests/buildconfig/report.sh prints the resolved configuration before the
first target and again in the summary, and `make test-buildconfig`
prints it alone.  Values come from the files that gate compilation --
TINYMUX_JIT/REALITY_LVLS/WOD_REALMS in the generated Makefile, the
stubslave conditional, HAVE_NLS in autoconf.h -- not from parsing
--enable-X off the configure line, which would report intent rather than
result.  Deriving them from configure.ac defaults was tried first and
got four of nine wrong; a banner that lies is worse than none.

Two assertions give it teeth: the run aborts if config.status is newer
than the binaries (reconfigured without rebuilding, so the banner would
name a configuration the artifacts do not have, misattributing every
result below it), and EXPECT_CONFIG="jit=yes" lets a box assert the job
it exists to do instead of skipping politely.

CLAUDE.md now requires a release to pass under more than one
configuration, naming the sharpest edge: --enable-stubslave is a release
build flag whose path the test build never exercises.

Verified by breaking each assertion: staleness abort fires, EXPECT_CONFIG
fires on mismatch and on an unknown key, and -- the control that matters
-- all five probes flip against a throwaway worktree configured with the
opposite flag set, so none is stuck on a constant.

make test: 30 targets, 29 passed, 1 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:15:26 -06:00
Stephen Dennis
54fc09a091 test(stubslave): deterministic muxscript teardown-recursion regression (#1939)
The muxscript<->stubslave teardown path had no deterministic coverage: the
#1939 crash surfaced only probabilistically through the luajit harness, and
only on --enable-stubslave builds (the configure default omits it, so CI
never exercised the path -- a first-run crash sat there undiscovered).

This harness forces the failure deterministically: boot muxscript, kill the
stubslave child so the pipe is dead, then @shutdown. Pre-fix the pump's
failed ShutdownSlave write recurses into shutdown_stubslave_parent() to a
stack-overflow SIGSEGV (rc 139); post-fix it exits cleanly. A baseline case
(@shutdown with the slave alive) guards the happy path. Verified red on the
pre-fix binary, green on the fix.

Wired as `make test-stubslave-teardown` and into `make test`. Skips green
when muxscript or the stubslave binary is absent (no --enable-stubslave),
matching the other feature-gated harnesses -- a build that cannot boot a
stubslave cannot exhibit the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 21:29:27 -06:00
Stephen Dennis
4925f8d088 test(blob): fail when softlib.rv64 is stale or unbuildable (#1924)
softlib.rv64 is a checked-in binary the JIT loads at run time, and
nothing in the normal build regenerates it.  Two failure modes follow,
and both happened this week.

An edit to mux/rv64/src/ is inert until someone rebuilds by hand, with
the whole suite staying green while the server runs the old code -- the
first fix for #1915 shipped exactly that way, a source-only no-op
against the segfault it was meant to fix.

A broken blob build is likewise invisible.  #1402 put a strtoll() call
in color_ops' parse_i64 with no declaration in the freestanding
stdlib.h, and the blob build stayed broken for days: the one place that
rebuilds it, Build.sh, downgraded the failure to a warning and copied
the stale artifact, and Build.sh is not in the `make test` path at all.

s_blob_version does not help -- it hashes the blob image to invalidate
the SQLite code cache, tying the cache to the artifact but never the
artifact to its source.

The check is possible because the build is byte-reproducible: the
committed artifact and two independent clean rebuilds agree exactly.
So rebuild and compare, fail on mismatch, and skip cleanly where no
cross-toolchain exists (the normal case for end users).  Verified to
fail on a deliberately perturbed source and pass when restored; 2s.

Build.sh's warning becomes a hard error, since a box that can build the
blob should not silently ship a stale one, and learns the xPack
riscv-none-elf-gcc name it did not previously detect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:18:47 -06:00
Stephen Dennis
bb5f7f18a7 build(test): run every target and report, instead of aborting at the first (#1912)
Two defects, one theme: `make test` did not report what was true.

1. It aborted at the first failing target.  `test-slave` was 3rd of 25, so
   one fragile guard hid smoke and 21 other targets.  A suite that says
   nothing about 22 targets trains people to ignore red -- and this cluster
   has now produced three instances in three days (#1909, #1912, #1917).
   `test:` is now a runner over TEST_TARGETS: every target runs, each gets
   a line, failures print their log tail, and the run exits nonzero if any
   failed.  Per-target logs land in test-logs/.  `install` stays a real
   prerequisite -- a tree that did not build should stop everything.

2. SKIP was indistinguishable from PASS.  Several harnesses here exit 0
   when their binary is missing (tests/slave, tests/comsys_conformance,
   tests/scenario, tests/stress, tests/parity213, tests/nls/run_ko.sh),
   so green could mean "never ran".  That is not hypothetical: a "clean
   master passes 5/5" control against test-slave was really
   `SKIP: slave binary not found`, exit 0, and it was used to argue a
   failure was pre-existing.  Skips are now counted and their reasons
   printed; `make test STRICT=1` makes them failures.

Negative controls, since the subject is reporting that cannot fail:

  - target emitting SKIP, exit 0 -> classified SKIP, not PASS
  - target exiting 1             -> classified FAIL, run continues
  - skip + STRICT=1              -> nonzero exit
  - all-pass                     -> zero exit

The FAIL path also has a non-synthetic witness: tests/libmux (#1917) shows
up as a failing target with its assertion tail while the targets it would
previously have hidden all run.

Scope note: this is only the structural half of #1912.  The peak_children
assertion itself is fixed by #1922, which roots the failure deeper than
this branch did (parent exit reparents the children, and one /proc sweep
costs ~1.3s) and adds cap-overrun detection.  Suite wiring is #1919's.

Refs #1912, #1917, #1919, #1922.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:57:17 -06:00
Stephen Dennis
e5ae32fa58 fix(test): update the #787 colour expectation to #1649's contract, and wire
three orphan suites into make test (#1917)

Two halves, and the second is the one that matters.

1. tests/libmux's test_copy_columns_color_inside_cluster pinned pre-#1649
   behaviour.  At 1 column the 2-wide cluster does not fit, and the test
   expected the bare 3-byte C_GREEN to pass through -- which leaves the
   client green with nothing visible after it.  #1649 rewrote
   co_copy_columns over co_copy_field, whose stated design is "color
   close on truncate", so master now emits GREEN + RESET (6 bytes;
   measured EF 98 82 EF 94 80).

   The new behaviour is the correct one: a field that renders empty must
   not leak its colour past itself.  Expectation updated to pin GREEN
   followed by RESET, with the reason in the comment so the next reader
   does not "restore" the old one.

   The property this case exists to guard is untouched: the cluster is
   still dropped ATOMICALLY rather than split between emoji base and
   skin-tone modifier (#787).  Only the colour state around the drop
   moved.

2. tests/libmux was not in `make test` at all -- `make -C tests/libmux
   test` by hand was the only way to run it.  That is why a behaviour
   change merged Jul 28 sat red for four days.

   Swept for siblings and found two more orphans: tests/color_ops (392
   assertions) and tests/table (16).  Both green today -- but so was
   libmux, right up until something moved under it.  All three are now
   make-test targets.

   That is 460 assertions brought from "runs if someone remembers" to
   "runs on every make test".

make test exit 0 (all 28 targets now); smoke 1596/0; libmux 52/52.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 22:16:07 -06:00
Stephen Dennis
4b4456ee9d fix(jit/hir): reject invalid block IDs in add_edge (#1863)
new_block() returns -1 when HIR_MAX_BLOCKS is exhausted and sets
overflowed, but control-flow lowering still calls add_edge with those
sentinels before compile_expression checks overflowed — indexing
block_nsucc/block_succ with -1.  Validate src/dst and set overflowed
instead of writing OOB.  Unit test fills the block table and pokes
invalid edges under ASan.
2026-07-31 08:54:16 -06:00
Stephen Dennis
4ab12e3092 test(ganl): ConnectionBase harness and Windows MSBuild runner (#1857 #1858)
Add ganl_connection_tests with fakes for NetworkEngine/Protocol/Session/TLS:
close-with-drain then Error aborts teardown (#1855), and a StickyTls
socketpair flood trips the 256 KiB ingress high-water (#1856).

Wire the new binary into make -C mux/ganl/tests check.  On Windows,
run-msvc.bat builds and runs ganl_tests.vcxproj (wselect/iocp) plus
ganl_connection_tests.vcxproj and propagates nonzero status.
2026-07-31 08:26:14 -06:00
Stephen Dennis
2659b6b620 test(slave): hermetic #1827 child-cap burst with stalled children (#1853)
A fast-resolver burst never reaches MAX_CHILDREN, so opportunistic checks
pass vacuously.  When SLAVE_TEST_HARNESS is set, honour a leading MS@
delay on each request so a mixed 19-slow + 1-fast batch can fill the cap
and prove post-cap work starts after one exit, not after every child
drains.  Wire make test-slave into make test.
2026-07-31 08:11:54 -06:00
Stephen Dennis
a474dc0a88 feat(nls): language in netmux.conf, and one catalogue reader everywhere (#1702)
Selecting a language was the one server-wide setting that did not live in
netmux.conf, and the documented way to do it did not work on every platform.

    LANGUAGE=ko ./bin/netmux      # mux/po/README.md

libintl honours LANGUAGE.  The built-in reader -- the MSVC path, since the
Windows SDK ships no <libintl.h> -- reads only LC_ALL, LC_MESSAGES and LANG,
so on Windows that selected nothing and the server ran English with no
diagnostic.  Two readers, two answers, one documented recipe.

## `language <catalogue>`

    language ko

names game/locale/ko/LC_MESSAGES/tinymux.mo.  Empty (every existing config)
keeps the environment behaviour exactly.  It is passed to mux_nls_init() as a
parameter rather than exported into the environment, so selection cannot mean
different things on different platforms.

## One reader

libintl is no longer used for lookup: mux_nls.cpp does not include
<libintl.h> and calls neither gettext nor ngettext.  The built-in MO reader
serves every platform.

That removes gettext's rule that the C/POSIX locale suppresses translation
outright.  Via libintl, `language ko` in a bare service environment could
only print "cannot take effect" and continue in English; opening a catalogue
by path has no such rule, so it now simply works:

    LANG=C LC_ALL=C, language ko  ->  kowidget을(를) 사물 #12(으)로 만들었습니다

The format is unchanged -- .po/.mo, msgfmt, xgettext, msgmerge, Plural-Forms.
Only the runtime is ours now, so a translator's workflow is untouched.

## Plural-Forms, which is what libintl was really providing

The built-in reader returned English plural forms regardless of catalogue, so
Windows already got "1 vs many" for all 18 msgid_plural entries -- wrong for
ko, which declares nplurals=1.  Making it the only reader meant implementing
the rule properly: a recursive-descent evaluator over the grammar gettext
uses (?: || && == != < > <= >= + - * / % ! and parens, over n).

Total and bounded by construction: no allocation, division and modulo by zero
yield 0, an unparseable rule or an out-of-range form falls back to form 0.

tests/nls/test_plural.cpp covers it directly -- 43 cases over en, ko, fr, ru,
pl and ar rules plus div-by-zero, unbalanced parens, truncated ternaries,
garbage and out-of-range forms.  Reaching plural_eval() means including
mux_nls.cpp, the same way tests/dbt/test_interp.cpp reaches its file-static
mem_check.  Wired as `make test-nls-plural`.

Mutation-checked rather than assumed: flipping % to / in the evaluator fails
13 cases across ru/pl/ar while en and ko still pass, so the suite measures
the thing it is named after.

## A regression the matrix caught

Dropping libintl dropped its LANGUAGE support, and mo_language() had never
read that variable -- so LANGUAGE=ko, the recipe in mux/po/README.md, would
have silently stopped working on Unix.  mo_language() now reads gettext's
documented order (LANGUAGE, LC_ALL, LC_MESSAGES, LANG) and takes the first
entry of a colon list, which also gives Windows LANGUAGE support it never
had.

Verified, each row distinguishing a fix from a no-op:

    language ko, no LANGUAGE            Korean     <- the case that failed
    no directive, nothing set           English    <- no regression
    no directive, LANGUAGE=ko           Korean     <- env still works
    no directive, LANGUAGE=ko:fr        Korean     <- priority list
    language ko vs LANGUAGE=xx          Korean     <- directive outranks env
    language ko under LANG=C            Korean     <- impossible via libintl

tests/nls/run.sh gains the directive cases (7 total, from 5); confirmed both
fail when the directive is disabled while the pre-existing cases pass.

make test: Smoke 1561 x3, tests/nls 7 cases, ko 4 cases, plural 43 cases.
TESTEXIT=0.

Documented in wizhelp (& LANGUAGE) and mux/po/README.md.

Per-player locale is deliberately not addressed; the parameter form leaves
room for it without committing to a design.

Refs #1419, #1444, #1473, #1523, #1580, #1622, #1702.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:29:13 +00:00
Stephen Dennis
df628e51dc fix(lua): apply lua_instruction_limit/lua_memory_limit at runtime (#1613)
SetLimits was pushed once, during module discovery, and CLuaMod kept
m_nInsnLimit/m_nMemLimit thereafter.  A plain cf_int wrote mudconf, @admin
answered "Set.", config() read the new value back, and InsnCountHook went on
comparing against the boot-time number until a restart.  Nothing warned.

Same defect #1222 fixed for the driver basket, so the same shape: a
cf_live_lua_int handler writes mudconf and re-pushes through pILuaControl,
gated on !bReadingConfiguration because the boot parse runs before module
discovery and discovery does the initial push itself.  Both limits are sent
rather than only the one that changed -- SetLimits takes the pair, mudconf
holds the current value of each, and re-sending both cannot leave the module
holding a mix.

These are the containment knobs for a sandbox softcode can enter, so the
realistic moment to change one is mid-incident: somebody is hammering lua(),
staff tightens the limit, the server agrees, and nothing happens until the
restart the incident exists to avoid.

  raise to 100000000, 400k-instruction chunk   before: error   after: 400000
  lower to 1000, same chunk                    before: error   after: error

And a bug the fix nearly joined.  cf_display() renders config() by comparing
tp->interpreter against known handlers; an unrecognised one falls through to
safe_noperm() and answers "#-1 PERMISSION DENIED" -- a reader who passed
check_access() moments earlier.  cf_live_driver_int was never added, so
#1222 fixed the write path and broke the read path for all fourteen knobs it
converted:

  config(max_players)     #-1 PERMISSION DENIED
  config(idle_timeout)    #-1 PERMISSION DENIED
  config(retry_limit)     #-1 PERMISSION DENIED
  config(output_limit)    #-1 PERMISSION DENIED
  config(nospam_connect)  #-1 PERMISSION DENIED

Both live handlers store a plain int at tp->loc, so both render beside
cf_int.  Sixteen knobs read again.  Found only because config() answered
differently on this branch than on master and that made no sense for a
handler swap -- there is no test that would have said so.

So, two guards, each verified to fail without the fix rather than assumed to:

tests/config/check_display.py requires every conftable handler to be either
rendered by cf_display or listed NOT_RENDERABLE, so converting a knob to a
live-push handler forces the decision instead of silently unrendering it.
Reverting cf_display reports both handlers with their counts (14 and 2).  It
also rejects stale exemptions in the other direction.

tests/config/run.sh gains a runtime @admin case, because the write half is
what the static guard cannot see: reverting to cf_int gives "FAIL: raising
lua_instruction_limit at runtime had no effect".  The chunk costs ~400k VM
instructions so it straddles the two limits, which is what makes answering
versus erroring mean the module saw the change.

The cf_live_driver_int half is connection-defense config rather than JIT; it
is here because it is the same three-line change and leaving fourteen knobs
unreadable while fixing two would be strange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 07:31:28 -06:00
Stephen Dennis
890d7f302d fix(comsys): the module's command surface, compared against the engine (#1640)
Four divergences from the issue, plus three more the probe found once the
fixtures stopped being plain ASCII.

From #1640:

  * @clist/full ignored the switch and printed the default listing, losing
    Header, Access, Users and Msgs -- the four columns the switch exists for.
  * @cwho printed a bare name where the engine prints unparse_object(), so
    staff lost the dbref and flags that identify WHICH object is on a channel.
  * comtitles reached no message at all.  The module stored them and
    round-tripped them through SyncChannelUser, then never read them back when
    composing one: not speech, not poses, not join/leave, and not spoof
    channels, where the comtitle is supposed to REPLACE the speaker's name.
  * delcom emitted the channel broadcast instead of the leaver's own
    confirmation -- which the leaver cannot even see, since clearing
    bConnected first is what suppresses it, so delcom looked like it had done
    nothing and any trigger matching "^You have left channel" stopped firing.

Comtitles are one fix, not four: BuildSpeakerPrefix mirrors the engine's
BuildChannelMessage, including that the LISTENER decides whether a comtitle
is shown, so SendChannelMessage now takes both variants and picks per
recipient.

Found while verifying, none of which an ASCII fixture can see:

  * The ENGINE conflated byte offset with column offset in @clist/full
    (comsys.cpp:2877), resetting iPos.m_column to the byte offset after
    writing JXR.  Those are equal only while every preceding field is plain
    ASCII; a colored channel header makes the byte count larger, PadField
    believes it is already past column 56, and the Users column shifts left.
  * The module stored channel headers with ANSI uncollapsed -- a raw strncpy
    where do_cheader runs StripTabsAndTruncate -- so `[ansi(r,RED)][ansi(b,BLU)]`
    persisted an extra reset between the codes.  A divergence in SQLite, not
    on screen, so it survived a handoff.  The same strncpy truncated at
    MAX_HEADER_LEN *bytes*, which can split a codepoint or a color code.
  * @clist/headers was missing the engine's pad to column 79.

Column layout in the module now goes through StripTabsAndTruncate/PadField
rather than printf field widths.  A printf precision counts codepoints and
knows nothing about PUA color; StripTabsAndTruncate carries a byte limit and
a column limit as a pair and reserves budget for the closing color sequence.
My first version of this fix hand-rolled the column stops with %-14.13s and
verified byte-identical output against the engine -- for ASCII only, which is
exactly the blind spot that produced two of the three findings above.  #1649
covers the general problem; nothing here waits on it.

@cwho deliberately reproduces the engine's composition including
strip_color(): @cwho discards color so its width arithmetic is honest while
@clist two functions away preserves it.  Both are coping strategies for the
primitive #1649 proposes, and this file's job is parity, not unilaterally
improving one side into a fresh divergence.

mux_IObjectInfo gains UnparseObject: the visibility rule is Examinable() plus
the CHOWN_OK/JUMP_OK/LINK_OK/DESTROY_OK/ABODE exceptions, which a module
cannot compute from GetFlags/DecodeFlags.  Caller-supplied buffer, matching
AtrGet, so nothing crosses the DLL boundary needing to be freed on the far
side.  CID_ObjectInfo is UseSameProcess only, so there is no proxy/stub to
update.

New harness, tests/comsys_cmdparity: one command stream, two fresh databases,
output diffed.  The two existing comsys harnesses compare state handoff and
delivery hooks; neither compares plain command output, and both scored green
against all seven of these.  Fixtures are deliberately colored and CJK.
Verified it goes red: reinstating the engine's PadField conflation fails case
3 while cases 1 and 2 still pass, which is the ASCII blind spot reproduced on
demand.

Windows Server 2022, MSVC 14.51, Release x64: three parity streams identical;
comsys_handoff 13/13; comsys_mogrify 5/5; smoke 1555 succeeded / 5 failed /
0 crashes, unchanged, the five being the pre-existing no-OpenSSL gap (#1641).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:36:25 -06:00
Stephen Dennis
24fd0b7249 test(comsys,mail): whole-output conformance diff, and the six divergences it found (#1614)
#1614 step 4 asked for the conformance list -- "the artefact none of the
eight issues produced".  This is that list, plus the harness that keeps it
honest, plus one fix for a bug found writing it.

## Why a diff and not assertions

Two harnesses already existed and neither compares what a command PRINTS:

  comsys_handoff   state written under one implementation, read under the
                   other.  Catches disagreement about STORED state.
  comsys_mogrify   each side joins and speaks in one process.  Catches
                   disagreement during DELIVERY.

Assertions can only catch a divergence somebody already thought to write a
case for, and that is exactly how the ones below survived.  So the third
harness runs one command stream under both implementations against identical
fresh databases and diffs the entire output against a recorded baseline.

Both directions fail the run.  A NEW divergence means the two just drifted
apart somewhere nobody was looking.  A KNOWN divergence VANISHING means
something was fixed and the baseline is now lying, so it must be regenerated
as part of that fix -- otherwise it hides the next regression.  That property
paid for itself twice while this branch was being written.

The stream is organised by the ABI rather than by feature, because
mux_IComsysControl (20 methods) and mux_IMailControl (12) are the definitive
list of places the two can answer differently.

## What one 47-line command stream found

Six divergences, none previously reported, in commands nobody had compared:

  @clist/full     module ignores the switch; Header, Access, Users and Msgs
                  columns all lost                                    #1640
  @cwho           engine "Wizard(#1PcW)", module "Wizard"             #1640
  comtitle        dropped by the module from speech AND join/leave    #1640
  delcom          engine gives a personal confirmation, module emits
                  the channel broadcast instead                       #1640
  @mail/dstats    identical to /stats under the module -- three
  @mail/fstats    commands collapsed into one, widening #1631         #1631
  @mail/fstats    engine counts +1 byte per message, disagreeing with
                  its own @mail/list about the same message           #1639

Two of those are engine-side, and on @mail/stats and the From: field the
module is the MORE correct implementation.  That cuts against #1614's
"the module is strictly behind" reading and is worth having before the
decision is made.

## The fix: trimmed_name left the caller's buffer uninitialised (#1637)

Found because the harness was flaky before it was committed, which is the
only reason anyone would have looked.

conn_bridge.cpp's trimmed_name does nothing when g_pConnMgr is null.  Every
other bridge in that file returns void or a number, so that is a complete
answer for them; this one has an out parameter, and doing nothing leaves the
CALLER'S stack buffer untouched.  mail.cpp has eight sites passing a bare
UTF8 szFromName[MBUF_SIZE] straight to tprintf("%s").

muxscript has no connection manager, so what got printed was whatever was on
the stack.  Measured across three runs of the same command: "From: p", then
"From: ", then -- when the garbage terminated the format early -- the entire
message header vanished, taking At:, Fldr:, Status:, To: and Subject: with
it and leaving only the body.

  cbuff[0] = '\0';

Unconditional, so it also covers a TrimmedName that fails partway.  netmux
always has a connection manager, so no live game was affected; muxscript is
what the whole suite runs on.

Restoring the header immediately exposed a module gap the truncation had been
hiding: the module's @mail <n> omits the To: line.  Nobody could have seen
that while the side it is compared against printed nothing at all.

## -Wformat on the module build

#1634 added __attribute__((format(printf,...))) to the new
log_storage_failure helpers and reported the format strings had been
type-checked.  They had not been: -Wformat is off by default and these
modules compile with neither -Wall nor -W, so a clean build says nothing.
The strings are in fact correct -- I checked them with the flag on -- but the
guard was inert.

Adding -Wformat, and only -Wformat, to the modules' AM_CPPFLAGS.  Measured
first: all five modules built there are already clean, so it lands no new
warnings.  Not -Wall, which has not been audited here.  Negative control:
one "%s" changed to "%d" now produces, through the real build,

  comsys_mod.cpp:752: warning: format '%d' expects argument of type 'int',
  but argument 5 has type 'const char*' [-Wformat=]

Regenerating mux/modules/Makefile.in used automake 1.16.5, the version
docs/building.md pins; it also picks up one unrelated line, PCRE2_CFLAGS,
which configure.ac already substitutes and the checked-in Makefile.in had
drifted from.  configure is NOT regenerated -- that wants autoconf 2.73 and
this box has 2.71.

## Interaction with what landed today

#1644 turned muxscript logging on, so diagnostics now reach the output and
are part of the comparison -- the module announces its shutdown and startup
and the engine does not, which the baseline records rather than discards.
Only the leading timestamp is normalised.

#1643 is open against #1639.  When it merges the fstats row disappears and
this baseline must be re-blessed; that is the harness working, not breaking.

Verified: make test green -- 1561 smoke on the module implementation, 1561 on
the built-in, 1561 on the interpreted route, handoff 13/13, mogrify 5/5,
conformance passing against the recorded baseline, and the rest of the suite
unchanged.

Refs #1564, #1572, #1585, #1587, #1589, #1594, #1620, #1631, #1633, #1634,
#1637, #1639, #1640, #1643, #1644.
2026-07-28 01:47:47 +00:00
Stephen Dennis
687ca87309 fix(comsys): the module honours all five MOGRIFY hooks and CHATFORMAT (#1572)
The module implemented BLOCK and NOBUFFER.  MESSAGE, OVERRIDE and FORMAT
were silently ignored, and per-player CHATFORMAT was not present at all,
so a channel configured with any of them behaved differently depending on
which implementation happened to be live.  Silence rather than an error,
which is why 31 comsys cases in the corpus passed identically against
both.

Two of the issue's claims were stale and are corrected here: BLOCK already
evaluated through the evaluator interface rather than comparing raw
attribute text, and NOBUFFER had landed.  The gap was three hooks and
CHATFORMAT, not four hooks and a semantics bug.

Order and precedence follow comsys.cpp:1705-1822 exactly.  "Mostly the
same" is what produced the divergence in the first place, so MESSAGE
replaces the text, FORMAT sees the result of MESSAGE, OVERRIDE both
suppresses CHATFORMAT and lets FORMAT win outright, and history still
records the UN-mogrified message as the engine does.

## An engine bug fell out of it

s_chatformat_atr was a static, lazy-initialised ONCE PER PROCESS.
CHATFORMAT is a vattr, created the first time any player sets one -- so on
a game where nobody had one before the first channel message, the number
cached as 0 and CHATFORMAT was ignored for the entire life of the process,
including for every player who set one afterwards.  Restarting fixed it,
which is the shape that never gets reported as a bug.

It survived because nothing compared the implementations.  Now it caches
only a hit.

## tests/comsys_mogrify, and why it is not in comsys_handoff

Five cases, each driving both implementations and asserting they agree.
4 of 5 fail against the unfixed code, and case 3 fails on the ENGINE side,
which is what pins the cache fix.

It is a separate harness because comsys_handoff cannot test delivery.
That driver's shape is "establish state under one implementation, read it
under the other", and bConnected is runtime state set when a player joins
during that process -- it is not persisted.  A run inheriting membership
from an earlier run has user records with bConnected false and delivers to
nobody.  Each side must join and speak within one process.

That cost real time and briefly looked like "the module never delivers at
all".  It does; verified over a real socket against netmux before drawing
any conclusion.  A note in comsys_handoff records why the cases are not
there, so nobody adds them back.

Case 5 is weak by construction and kept deliberately: it asserts
CHATFORMAT is ABSENT under OVERRIDE, which is trivially true when
CHATFORMAT never works at all, so it passes in both columns.  It earns its
place only once the others pass, where it distinguishes OVERRIDE from
FORMAT alone.

make test green: 1561/1561 on all three smoke routes, handoff 13/13,
mogrify 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:53:36 -06:00
Stephen Dennis
20b4476df9 test(comsys): cross-implementation handoff driver (#1589 stage 0b)
test-smoke-builtin made the other implementation reachable.  It did not
make a divergence detectable: the corpus scores 1561/1561 against BOTH,
so it cannot tell them apart.  Every bug this area has produced --
the other wrote, and no single run can produce that.

This drives muxscript repeatedly against ONE persistent database,
changing only whether `module comsys_mod` is in the config, and asserts
across the handoff in both directions.

Every run asserts which implementation it actually got and bails out if
it is not the one intended.  That guard is the point rather than
housekeeping: #1585 was marked "cannot reproduce" from a box where the
module never loads, so both halves of that comparison were the built-in.
Without the assertion this whole file would quietly degrade into
built-in-versus-built-in and keep passing.

Six assertions pass today, including both read directions and a control
proving the module can still rewrite a slot it owns -- without which the
to write at all".

Two are marked TODO because they are open bugs:

  #1585  the module cannot clear a flag the engine set
  #1620  the module cannot overwrite an engine-written history slot

A TODO failure does not fail the run.  A TODO that PASSES does, loudly,
because the marker has become a lie and would hide the next regression.
That is what makes this useful for the stage 1 migration: when channel
history moves into SQL these two flip, and the harness says so instead
of staying silently green.

Stacked on #1624 -- assertion 5 checks that the refusal is reported
rather than reported as success, which is #1624's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:04:22 -06:00
Stephen Dennis
17e470ea7a test(smoke): allow running the corpus against the built-in comsys/mail (#1589)
Stage 0 of the comsys history work.  Both implementations ship and both
are reachable, but a run could only ever exercise whichever one the
platform resolved -- Windows the built-in, Unix the modules -- so half
the shipped code had no coverage on any given box.  The module
directives are written into Smoke's heredoc and SMOKE_EXTRA_CONF only
appends, so there was no way to ask for the other side at all.

SMOKE_OMIT_MODULES filters them back out.  Filtering rather than making
the heredoc conditional keeps the two downstream checks honest: the
module preflight and the #1581 implementation guard both read `module`
lines back out of smoke.conf, so removing a line makes them correctly
expect -- and require -- the built-in.  Verified: the guard reports
built-in and passes rather than firing.

Wired as test-smoke-builtin, in `make test`.  It reuses smoke.flat, so
it costs one run of the corpus and no rebuild.

  Running WITHOUT modules:comsys_mod mail_mod
  === Implementations in this run ===
  Comsys: using built-in engine implementation.
  Mail: using built-in engine implementation.
  === Smoke: ALL 1561 TESTS PASSED ===

Worth recording plainly: the corpus scores 1561/1561 against BOTH
implementations.  It cannot tell them apart, which is what #1581 said
and is now demonstrated rather than argued.  So this makes the other
side reachable; it does not yet make a divergence detectable.  The three
known divergences (#1585, #1587, #1620) all need cross-implementation
state -- one implementation writing and the other reading -- which a
single run cannot produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:04:03 -06:00
Stephen Dennis
e053db0dcd feat(nls): add Korean as the second locale, and a runtime oracle for it (#1419)
`xx` is English with a prefix.  It proves the gettext plumbing works and
cannot prove anything about translation: because it preserves argument
order, every message it renders would render identically under a broken
implementation of argument handling.  Adding a second locale is how that
blind spot gets closed, and which language is chosen decides how much of
it actually gets closed.

Korean rather than Spanish, deliberately.  Two inputs were measured first:

  - Rendering is not a risk.  co_visual_width segments grapheme clusters
    and sums cluster_console_width, so CJK and emoji already cost nothing
    extra: vwidth(한국어)=6, vwidth(中文字)=6, vwidth(abcd)=4.
  - Fixed-width layout is not a risk either.  Exactly 1 of 653 msgids
    pins a column width, so text expansion -- the usual reason a locale
    breaks a UI -- has almost no surface here.

What is left is argument order, and that is precisely what `xx` cannot
test.  Spanish is SVO and would have translated nearly all 65 multi-
conversion msgids in place: a lot of real translation work that still
would not have told us whether the contract holds.  Korean is SOV with
postpositions, so it reorders on the first such message.

Two defects fell out of doing it, neither previously visible:

1. Positional arguments do not work, and nothing catches it.
   mux_vsnprintf stops at the '$' in %1$s and echoes the remainder
   literally (the #1429 stop policy -- safe, but the message is
   destroyed).  msgfmt -c ACCEPTS %N$; it rejects only the naive reorder
   that omits it.  So the translator who reorders correctly gets a clean
   build and a broken game, and the one who does it wrong gets a build
   error.  That is backwards.  Measured end to end:

     "%s created as object #%d"
       source order  -> kowidget을(를) 사물 #2(으)로 만들었습니다
       positional    -> 사물 #%2$d(으)로 %1$s을(를) 만들었습니다

   The 7 msgids Korean genuinely reorders carry their correct translation
   with %N$ and are marked fuzzy, so msgfmt excludes them and they fall
   back to English until this is fixed.

2. Three msgids are untranslatable at any argument order.  The caller
   passes English plural morphology as an argument -- (count == 1 ? "" :
   "s") at 5 sites -- and there is no ngettext anywhere in the NLS layer.
   Korean has no plural inflection, so the argument can only be dropped
   (msgfmt -c forbids it) or emitted as a stray Latin "s".  Left
   untranslated with a TRANSLATORS note; filed separately.

tests/nls/run_ko.sh, wired as `make test-nls-ko`, carries four cases.
Case 2 is the anti-vacuity control from #1523 (catalogue absent must
restore English).  Case 4 deliberately asserts the DEFECT in 1 above and
is written to fail once positional arguments are supported -- that is the
signal to drop the fuzzy markers, not a reason to delete the case.

check_nls.py demanded every msgid be translated and non-fuzzy in every
catalogue.  That is correct for a mechanically generated pseudo-locale
and wrong for a human one, where partial coverage is the designed
behaviour and `fuzzy` is the standard workflow marker msgmerge sets by
itself; enforcing it would mean no human catalogue could ever be
committed except complete and perfect, so nobody would commit one.
Coverage is now a per-catalogue policy declared in the .po header,
defaulting to `partial`.  Stale msgids, missing msgids, and conversion-
sequence mismatches stay fatal under both -- verified by breaking each:
the swapped-%s/%d control still fires on the partial locale, which is the
SIGSEGV class from Phase 3.  Coverage is printed every run so a locale
cannot rot silently.

The catalogue is partial (106/653) and NOT native-reviewed; it is meant
to be structurally correct so it exercises the runtime honestly.

mux/po/Makefile now builds every *.po, so adding a locale needs no edit
there.  mo-<lang> is deliberately not .PHONY: GNU make skips pattern-rule
search for phony targets, which made `mo` a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:51:12 -06:00
Stephen Dennis
305e576b2d fix(conf): make an unreadable configuration file fatal (#1601)
cf_read() returns -1 when the top-level config cannot be read, and its only
call site -- CGameEngine::LoadGame -- discarded that. Both callers therefore
took the success branch and the game came up on compiled-in defaults.

On netmux a mistyped -c path produced a live server: it bound the default
port 2860 and served a two-object database while the real one sat untouched,
having logged one CNF/NFND line between two INI/LOAD lines that read like
success. On muxscript it exited 0 and printed "loaded game from ...", so a
harness could not distinguish a green run against the intended database from
one against an empty default.

LoadGame now returns MUX_E_NOTFOUND, and both callers name the config file
rather than reporting only "LoadGame failed (-9)" -- this is the startup
failure most likely to be a simple typo.

Two cases stay deliberately non-fatal. An unrecognized directive still only
logs: games carry config files forward across releases, and cf_include
discards cf_set()'s per-line return, which is load-bearing rather than a
matching oversight. An empty file still succeeds, and is the supported way
to ask for the compiled-in defaults on purpose.

Also in cf_include, where fgets returns nothing:

  - fopen() succeeds on a directory on Linux and macOS and only the read
    fails, so `-c somedir` was indistinguishable from an empty file and was
    silently accepted. Checking ferror() separates the two.
  - the early return skipped the fclose the normal path does, leaking the
    handle on every empty include.

tests/config/run.sh covers all six corners, wired in as `make test-config`.
The good-config case asserts mud_name actually took effect rather than just
that the process exited 0 -- exit status alone cannot tell "read the config"
from "silently used defaults", which is the bug itself. Verified to fail
against the unfixed engine (2 of 6, the two non-fatal cases still passing).

make test green: 1560/1560 on both smoke routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:20:21 -06:00
Stephen Dennis
985cebc50e test(luajit): EXEC cases require lua_run_ok; grow differential corpus (#1426)
Expand tests/luajit/run.sh into three contracts so a decline cannot
masquerade as a pass:

  SURVIVE — process must not die (table ops; result diverge is reported)
  AGREE   — must match the interpreter; decline/compile-fail OK (#1512)
  EXEC    — must match AND lua_run_ok must advance (#1426)

EXEC chunks stay off globals/stdlib so they compile without the Lua-VM
bridge.  Runtime-operand arithmetic, compares, and if/else are included.
Power and loops stay in AGREE until they actually run compiled.

run_one now reports jitstats counters.  Baseline fails if return 1 does
not advance lua_run_ok under lua_jit 1 + jit_eval_brackets 0.

Makefile: rename the test-lua-ecall blurb to match the broader harness.
2026-07-27 13:14:53 +00:00
Stephen Dennis
51c6ca9743 test(nls): a runtime oracle for the xx pseudo-locale (#1523)
make test-nls (#1520) is the static half -- markings, catalogue coverage,
.pot freshness -- and that is where the risk that has actually bitten
lives.  What it cannot do is show translation happens at all: nothing in
the suite observes notify() prose, so the entire translatable surface is
invisible to it.

The trap is that the obvious experiment is vacuous.  A smoke run with
LANGUAGE=xx is green whether the catalogue is correct, corrupt or absent
-- #1523 measured identical numbers with the catalogue in a directory the
server never opens.  So a green LANGUAGE=xx run is evidence of nothing
unless something asserts both that translation changed the output and that
removing the catalogue takes the change away.

tests/nls/run.sh checks all three:

  default locale, catalogue present   0 [xx]   verbatim prose
  LANGUAGE=xx,   catalogue present    3 [xx]   translation happens
  LANGUAGE=xx,   catalogue absent     0 [xx]   and the catalogue caused it

The third case is what makes the second mean anything; without it the
script would pass on a build where gettext was never wired up.  In all
three the softcode token #-1 FUNCTION (...) NOT FOUND must come back
byte-identical -- #-1 tokens are S_() and deliberately outside the
catalogue (#1480), so a translated one means the S_()/M_() split broke
upstream.

Messages chosen to need no fixture and to be reachable as God: @moniker
set/clear and an unknown command.  "Huh?  (Type "help" for help.)" carries
typographic quotes, so the UTF-8 catalogue path is exercised rather than a
pure-ASCII one.

Where the catalogue must live, since #1523 read this as a defect:
mux_nls_init() binds the domain against the relative path "locale" and
muxscript chdir()s to its -g directory, so "locale" resolves under the
game directory.  That is consistent rather than broken -- but it does mean
a harness has to put the catalogue in the game directory it runs from, and
testcases/ has no locale/, which is why the smoke route could not have
loaded one even if it had looked.

Not a smoke case: the assertion needs the same commands run twice under
different environments, and Makesmoke/Smoke build one database and one
config per invocation.  Nothing in tr.tc* can vary LANGUAGE.

Negative controls, each reverted afterwards:
  - dropped one msgstr in xx.po           -> case 2 reports 2, fails
  - changed M_("Moniker set.") to T(...)  -> case 2 reports 2, fails
The second is #1489's real failure mode, half-marking during a slice.

Skips cleanly (exit 0) without --enable-nls, without msgfmt, or without
xx.po; verified the HAVE_NLS guard against a non-NLS tree.

make test passes on an --enable-nls build, both smoke routes 1542/1542,
318/318 dispatched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:58:26 -06:00
Stephen Dennis
76d7cf6fa7 fix(engine): clamp @poor's limit and widen hasquota's stored quota (#1402)
Two more instances of #1402's narrowing class, both observable on LP64 --
unlike the rest of that issue.  `long` is 64-bit on Linux and macOS, so
#1404's cases cannot fail there; `int` is 32-bit everywhere, so these can.

@poor's limit is compared against Pennies(), which is int, and
`int amt = mux_atoi64(arg1)` wrapped.  A limit outside int range cannot
describe wealth any player can hold, so it should reduce nobody -- instead
the argument became its own opposite.  Measured on a throwaway game:

    @poor 2147483648    every player left holding -2147483648 pennies
    @poor 4294967296    every player's money set to 0
    @poor 4294967297    every player's money set to 1

is_rational() accepts all three and nothing downstream range-checks, so
the wrap was the whole of the arithmetic.  This is @poor's documented job
done in reverse: a wizard raising the cap destroys the economy instead.
Parse wide and clamp to int, which makes an over-large limit the no-op it
reads as.  CA_GOD, so not reachable by players, but silent and destructive.

fun_hasquota narrowed only one side of its comparison.  Both operands come
from mux_atoi64; storing the player's stored quota in an int while the
request stayed int64_t made the two disagree about their own range.  With
RQUOTA=4294967296 the value truncated to 0 and hasquota(player,1) answered
0, refusing a player holding four billion quota; RQUOTA=2147483648
truncated to INT_MIN and refused everything.

Same shape as fun_shl's defeated `0 <= b && b < 64` in #1404: the
truncation lands before anything can judge the value.  @poor is the
degenerate case -- no check to defeat, so it corrupted rather than errored.

tests/narrowing/run.sh, wired into make test, covers both with 12 cases.
Not smoke cases, for two different reasons: @poor is CA_GOD and walks the
whole database, so a tr.tc* case would be refused and would also rewrite
every other test's money; hasquota() needs `quotas yes`, which smoke
deliberately runs without -- powersee_fn.mux TC005 asserts the disabled
path, so enabling it globally would break an existing case.

Negative controls, reverting each fix alone:
  - @poor clamp only:  exactly the 3 out-of-range rows fail, with the
    values above; all 5 in-range rows and all 4 hasquota rows stay green
  - hasquota widen only: exactly the 2 large-RQUOTA rows fail; the
    ordinary-quota and no-quota rows stay green
The in-range rows are the controls that matter -- a clamp that swallowed
everything would pass the out-of-range rows and fail these.

make test passes, both smoke routes 1509/1509, 316/316 dispatched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:02:12 -06:00
Stephen Dennis
ebb9c33762 test(nls): guard the marking and catalogues statically (#1505)
Until now the only thing standing between a bad marking slice and master was
somebody reading the .pot diff by eye.  I did exactly that four times while
reviewing #1489, #1500, #1502 and #1515.  It is not a control that scales to a
fleet of contributors, and none of the failure modes below are ones a reviewer
reliably spots.

Adds tests/nls/check_nls.py and a `make test-nls` target, wired into
`make test`.  Static: no build, no server, no catalogue installed.  It runs
whether or not the tree was configured --enable-nls, because the marking is in
the sources either way and a slice that breaks it should not be able to hide
behind an English-only build.

Five checks:

  1. Softcode ABI tokens.  A "#-1 ..." string is what softcode compares
     against; translating one breaks every game that tests for it.  Those
     belong in S_(), which is never extracted.
  2. printf conversions in translatable strings.  A translation can reorder,
     drop or invent conversions and the source literal does not reveal what
     the .mo ships.
  3. Half-marked literals -- the same text M_() in one place and T() in
     another within one file, so a translated game renders both languages at
     once.  Invisible in a .pot diff, which only ever shows the marked half.
     This is the class that bit #1489.
  4. Catalogue integrity: every .pot msgid present, translated, and NOT fuzzy.
     Fuzzy matters more than it looks -- msgfmt drops fuzzy entries silently,
     so a catalogue regenerated with a plain `msgmerge -U` ships its new
     strings untranslated while every count still looks complete.  I did this
     to myself cross-merging #1500 with #1502 and manufactured 25 of them.
  5. .pot freshness.  A slice that marks prose and forgets to regenerate
     leaves those strings out of every catalogue, and nothing notices: the
     code compiles, the suite passes, the strings simply never translate.

Deliberately NOT checked: whether a translated format reaches a format
function.  That is already closed structurally and better than this file
could close it -- tests/format/check_formats.py rejects M_() at every format
call site because M_ is absent from its CONST_CAST_WRAPPERS, so a translated
format cannot reach mux_vsnprintf at all (#1492).  Check 2 flags a msgid that
merely contains a conversion; the load-bearing guard is the other file.

Verified in both directions, because a check that cannot fail reports success
indistinguishably from one that found nothing -- which caught me twice today
on #1458:

  ABI token       CAUGHT     fuzzy entry     CAUGHT
  format spec     CAUGHT     untranslated    CAUGHT
  half-marking    CAUGHT     stale .pot      CAUGHT

And it does not redden legitimate work: run against the three marking slices
currently in flight, in a worktree, all pass --

  notify-slice-set           113 msgids, all clean
  notify-slice-speech-look    95 msgids, all clean
  notify-slice-player-flags   93 msgids, all clean

Master is clean at 62 msgids across 201 source files.

The freshness check regenerates the .pot to compare and restores the original
byte-for-byte afterwards, so it never leaves the tree dirty.  It skips loudly
if xgettext is absent rather than passing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:15:40 -06:00
Stephen Dennis
d73b367eb7 test(smoke): guard against cases that cannot fail (#1434 family)
Eight findings in one day were the same shape: a case that reports a verdict it
can never contradict.  #1413 (a regression test that only failed on the host it
was written on), #1426 (Lua JIT coverage asserting the attempt, not the
execution), #1434 (protect_fn TC001 logging "Succeeded" with no command run),
#1438 (all six cron_fn cases unconditional), #1460/#1498 (route_fn's zone cases
passing with no zones), #1495.  Every one was found by a person noticing.

The mechanical half of that pattern is detectable: a tr.tc* label with a
Succeeded branch and no non-success branch reports the same verdict on every
run.  It counts toward the headline total and cannot go red.

check_vacuous.py reports those, and #1490's label parser is factored out as
classify_labels() so the expected-verdict count and this check cannot drift
apart in how they read the corpus.  count_expected_verdicts() is now three
lines over it and returns the same 1507.

The non-success test is deliberately loose and case-insensitive, because the
corpus does not use one spelling.  A case-sensitive test for "Failed" reports
four files as unable to fail that are not:

  nested_depth  "probes failed (pre=...)"        lowercase
  paginate_fn   "Third mail failed."             lowercase
  parser_fn     "Skipped (divergence observed)"  parity probe, Skipped is its
                                                 non-success outcome
  shl_fn        "Legacy behavior."               documents the old behaviour

Reading only the capitalised verdict word was my first version and it was
wrong; that is why the pattern covers fail/skipped/legacy/not-run.

Three findings remain and are allowlisted: cachestats_fn TC004/TC005/TC007
print attribute-cache timings and a hit/miss baseline for a human to compare
across runs.  There is no property to assert, so "cannot fail" is correct for
them.  #1434's manual sweep reached the same conclusion about that file.  With
the list that short the check fails rather than merely reports -- an allowlist
entry is the deliberate friction, same as the format guard's.

Validated against history rather than by construction.  The guard run against
the real pre-fix files:

  pre-#1434 protect_fn.mux   1 finding  (TC001), exit 1
  pre-#1451 cron_fn.mux      6 findings (TC001-TC006), exit 1

So it would have caught both automatically.

What it does not catch: a case with both branches whose assertion is trivially
true anyway.  route_fn's zone cases post-#1491 are exactly that -- they can
report Failed, they just never will -- and finding those needs a negative
control per case, not a parse.  Said so in the file so nobody reads a green
guard as "no vacuous tests remain".

Full `make test` green: vacuous guard 1507 cases, format guard 1170, smoke
1505 on both routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:00:15 -06:00
Stephen Dennis
7e555f1a18 build(asan): fix the two things that stop test-asan reaching its suites
#1467 made the target finish once it runs.  These are the two problems
that stop it running at all, both measured on this host.

LeakSanitizer is fatal to the harness rather than merely noisy.  A
long-lived server legitimately does not free everything at exit, and
LSan's nonzero exit status at shutdown makes Makesmoke report
"ERROR: muxscript failed" before a single test runs:

  default ASAN_OPTIONS           muxscript exit=1, "detected memory leaks"
  ASAN_OPTIONS=detect_leaks=0    exit=0

Leak hunting stays available and opt-in.

The tests/ islands build their own binaries from their own Makefiles, so
they are not instrumented even when libmux is, and ASan refuses to start
with "runtime does not come first in initial library list".  test-format
and test-dbt -- two of the suites the target lists -- died on this
immediately.  Preloading the runtime is the documented remedy; the path
comes from the compiler rather than being hardcoded, and is empty when
unavailable so an unusual toolchain degrades to the original error.

Verified by running the combined target end to end on a sanitizer build:

  test-asan rc=0
  format 31738 / dbt 960 / netaddr / alarm / ganl / jit-qreg /
  jit-ifelse / lua-ecall / scenario -- all pass instrumented
  smoke compiled     1497 succeeded, 0 failed, 0 crashes, 314/314
  smoke interpreted  1497 succeeded, 0 failed, 0 crashes, 314/314
  remaining: 3 x misaligned load in svdhash (#1454)

That is a full clean sanitizer gate.  Neither this nor #1467 produces one
alone: without #1467 the smoke legs idle-hang on rvbench_fn, and without
these two the target never reaches a suite.

Refs #1440.
2026-07-26 17:56:21 -06:00
Stephen Dennis
fad590a9e3
build(asan): make test-asan finish, and cover the suites it was missing (#1467)
test-asan shipped in #1449 covering four suites, and its smoke leg did not
finish: the harness reported an idle-hang at ~260 of 315 files, so the target
said FAILED for instrumentation cost rather than for a defect.  Both halves
fixed here.

WHY IT STALLED

rvbench_fn issues 55 rvbench() calls at 10000 iterations each.  Measured
under -fsanitize=address,undefined: ~25ms per iteration, so that one file
runs for hours and produces no log output while it does, which the activity
timeout correctly reads as a hang.

Excluded via a new SMOKE_EXCLUDE knob on the generator.  Worth noting the
exclusion costs nothing measurable: rvbench_fn contains ZERO Succeeded or
Failed lines across its 56 cases -- it reports benchmark numbers, not
verdicts.  The sanitized run logs 1497 succeeded either way.

The knob lives in the generator rather than in the harness on purpose.  Both
the suite manifest and the expected-verdict count (#1393, #1403) are derived
there, so an excluded file is excluded from what the run is MEASURED against
rather than silently missing from it -- 314 of 314, not 314 of 315.

WHAT IS NOW COVERED

Added test-netaddr, test-alarm, test-ganl, test-jit-qreg, test-jit-ifelse,
test-lua-ecall and test-scenario.  Every one was run under sanitizers before
being added, and every one is clean apart from the already-filed #1454 and
#1456.

test-ganl and test-scenario matter most: they are the only legs that exercise
the live network path.  muxscript has no network at all, so the smoke suite --
however broad -- cannot reach a line of it.  A sanitizer over a suite that
never runs the code is worth nothing there.

Timeouts raised to 300s activity / 3600s wall-clock, since an instrumented
run legitimately takes several times longer than a normal one.

Verified: smoke under sanitizers now completes at 1497 passed, 0 failed,
314/314, in 85 seconds.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:55:04 -06:00
Stephen Dennis
aadf5e3f28 build: add --enable-sanitizers, and regenerate configure with autoconf 2.73
Completes the tooling half of #1440.  The bug fix in this branch was found
with a sanitizer build assembled by hand; this makes that build a supported
option rather than an incantation.

    ./configure <usual flags> --enable-sanitizers
    ./configure <usual flags> --enable-sanitizers=address

Defaults to address,undefined.  The flags go on the compile AND the link
lines with -fno-omit-frame-pointer, and are appended late so the feature
checks above them ran uninstrumented -- a sanitizer build is for running the
suites, not for deciding what the compiler supports.

No AC_DEFINE: nothing in the tree needs to know at compile time, and leaving
it out avoids regenerating autoconf.h.in with autoheader.  The flags land in
config.status, which is what `make test-asan` checks, so a tree configured
with the flags by hand is detected exactly like one configured with the
option.

NOTE ON THE DIFF: this regenerates configure with autoconf 2.73, where it
was last generated with 2.71.  Most of the 4,700 lines are that version
drift rather than this option.  The consequence is that anyone regenerating
configure from now on wants 2.73 -- accepted deliberately rather than
stumbled into.  aclocal.m4 is left alone (still 2.71); autoconf warns about
the mismatch and works.

Verified both directions on macOS arm64:

  --enable-sanitizers   "checking whether to enable sanitizers... address,undefined"
                        CXXFLAGS carries -fsanitize=address,undefined
                        make test-asan: format 31738 passed, dbt all green,
                        smoke 1497 passed / 315 of 315 on both routes,
                        no sanitizer report
  without it            "checking whether to enable sanitizers... no"
                        zero fsanitize occurrences in config.status
                        make test-asan refuses and prints the recipe

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:12:03 -06:00
Stephen Dennis
4b56f75254 fix(jit): a refused lowering must not be used as an array index (#1440)
AddressSanitizer, first run of the smoke suite:

  ERROR: AddressSanitizer: stack-buffer-overflow
  READ of size 4 ... hir_lower_funccall hir_lower.cpp:1746
  Address ... is located in stack of thread T0 at offset 268 in frame
    [272, 336280) 'h' <== Memory access at offset 268 underflows this variable

hir_lower_node() returns -1 to REFUSE a compile -- unknown AST node type,
added for #1242 so an unknown node could not silently lower to an empty
string -- and sets h.overflowed.  Three call sites then used that -1 as an
instruction index.

kind[] is the first member of hir_program, so h.kind[-1] reads four bytes
before the struct.  That is the access ASan caught, and it took the suite
down at test 151 of 315.

The other two are the same defect with quieter symptoms, which is the part
worth noting: h.ty[-1] lands INSIDE the struct, on the last element of
kind[], so it reads a garbage value and uses it to decide a branch.  No
sanitizer will ever flag those; they were found by reading the callers of a
function known to return -1, after ASan pointed at the first one.

Fixed by propagating the refusal.  No rollback is needed: overflowed
abandons the compile before codegen and the AST evaluator takes the
expression, which is what the -1 already meant.

Also adds a test-asan target for the suites that execute the most engine
code.  It deliberately does not reconfigure -- replacing the tree's build
settings silently would be rude, and a sanitizer build is not what anyone
wants left behind -- so it checks config.status and prints the recipe if the
tree is not set up for it.

Verified on macOS arm64 with -fsanitize=address,undefined:

  before   smoke dies at 151/315, stack-buffer-overflow
  after    1497 passed, 0 failed, 315/315, both routes, no ASan report

  tests/format 31738 passed, dbt all green, tests/luajit clean, all under
  the same sanitizer build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:01:49 -06:00
Stephen Dennis
c35c90b1b3
fix(lua/jit): stop a Lua error in an ECALL from aborting the process (#1423) (#1428)
A Lua error raised inside an ECALL had no protected call frame anywhere above
it, so luaD_throw() reached the default panic handler and abort()ed the whole
process.  `local t={10,20,30} return t[2]` was enough to do it: the lowering
handed lua_geti() something that was not a table, Lua raised "attempt to index
a ? value", and muxscript died with SIGABRT and a core.

  #4  luaD_throw          ldo.c:132   -> abort()
  #6  luaG_runerror       "attempt to %s a %s value%s"
  #10 lua_geti            lapi.c:706
  #11 eval_ecall          jit_compiler.cpp:4608
  #13 run_cached_program  jit_compiler.cpp:2853

Thirteen call sites could raise -- lua_geti, lua_seti, lua_getfield and
lua_setfield across the dedicated table ECALLs, the string bridge, and the
array pin/unpin paths -- and none of their inputs are trustworthy: the stack
index arrives in a guest register, and on the string bridge it is atoi() of
guest-supplied text.

Each one now checks its target with ecall_lua_plain_table() and then uses the
raw accessor, which cannot raise.  A target carrying a metatable is declined
rather than guessed at, since raw access would silently skip __index and
__newindex; nothing in the engine module installs a metatable today, so this
declines no path that works now.

Declining is the safe direction.  eval_ecall returning a positive value stops
dbt_run, handle_dbt_run_status treats it as a plain failure, and
run_cached_program returns false without harvesting the guest output buffer --
so CLuaMod falls back to the Lua VM, which produces the right answer and the
right error message when the chunk really is at fault.

This is fault containment only.  It deliberately does not paper over the
lowering bug that produced the bad value (#1424) or any other wrong-answer bug
(#1421, #1422, #1425): a 38-chunk differential against the interpreter shows
agreements unchanged at 13 and divergences unchanged at 10, with the single
crash becoming a clean decline.

tests/luajit/run.sh pins the contract that a Lua chunk cannot kill the
process, and reports interpreter-vs-JIT divergence without failing on it, so
it does not go red for bugs it does not own.  It sets jit_eval_brackets 0 as
well as lua_jit 1 -- without that, fun_lua is ECALLed from inside a DBT
program, run_cached_program refuses the nested run (#1326), and the Lua JIT
executes nothing, so the test would pass no matter how broken the ECALLs were
(#1426).

Negative control: with the fix reverted the test reports 2 crashes and exits
1, one of them a shape not in the original report (`local t={} t[1]=5 return
t[1]`, the SETI path); with the fix, 0.

Full smoke 1489/1489, 314/314 dispatched, both default and lua_jit 1.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:08:02 -06:00
Stephen Dennis
5c06e4032b
feat(format): implement %i, %o and floating point in mux_vsnprintf (#1416)
mux_vsnprintf implements printf's conversions by hand, and anything it did
not implement fell through to mux_assert(0) -- so a caller reaching for a
standard C conversion took the server down.  That is #1382 in fun_astbench,
and independently the same shape in @list.  Both were fixed by rewriting the
call site to avoid %f, which leaves the trap in place for the next caller.

A sweep of every call site through the four wrappers (tprintf,
safe_tprintf_str, mux_sprintf, mux_fprintf) found no third instance today:
986 call sites, 909 with a format, 1749 conversion specs, all supported.  But
"no third instance today" is not a property anyone can maintain by reading,
the restriction is invisible at the call site, and the penalty is the whole
process.  Better to implement the conversions than to keep forbidding them.

Added: %i (alias of %d), %o, and %f %F %e %E %g %G.

Floating point takes its digits from mux_dtoa -- the same correctly rounded
generator mux_ftoa, fval and NearestPretty already use -- so float output does
not depend on the host libc.  mux_ftoa itself is not usable here: it is MUX's
own float rendering and switches to exponent form once the decimal point
passes 18, which %f never does.  dtoa suppresses trailing zeros and reports a
decimal-point position, so padding back out to the requested precision, the
%g e-vs-f selection, and the exponent form are assembled here.  Note dtoa owns
its buffer and reclaims it on the next call (MULTIPLE_THREADS is not defined),
so callers must not free -- matching fval.

Octal needed mux_utoo/mux_ui64too, and a wider scratch: 64-bit octal is 22
digits, one more than LONGEST_I64 allows for decimal.

Hand-assembled float formatting is exactly the code that looks right and is
wrong at the boundaries, so tests/format compares against the platform
snprintf over 6 conversions x 9 precisions x 5 widths x 3 flags x ~40 values,
plus infinities, NaN, negative zero, ties where round-half-even differs, the
integer/fraction boundary, %i, %o, 64-bit octal, and truncation.

That oracle immediately earned itself: the first run was 31396 passed / 332
failed, all of them %g of values rendering as the empty string.  The strip of
trailing zeros ran over the whole buffer instead of the fraction, so it ate
the integer digit -- "%.1g" of 0 produced "" rather than "0".  Bounded to the
fraction, 31728 pass and none fail.

Wired into `make test` as test-format.  Smoke unchanged at 1488 passed,
314/314 dispatched.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:45:04 -06:00
Stephen Dennis
c7850cadfc fix(parity213): a dropped probe row must not become a verdict (#1368)
probe.py writes <NO-OUTPUT> when a leg loses a row.  adjudicate() then
compared that sentinel as though it were an answer from the engine, so a
shape whose three legs actually agree was reported VIOLATED -- the
harness inventing a divergence that never happened, which is the one
failure an oracle must not have.

Treat it as a failed measurement instead:

  - Any leg reporting <NO-OUTPUT> makes the shape UNMEASURED.  Never
    satisfied, never violated.
  - adjudicate() exits 2 for that, distinct from 1 for a real violation,
    and run.sh says "MEASUREMENT INCOMPLETE -- re-run" rather than
    "VERDICT VIOLATED".  A violation still outranks it, so a real
    divergence is never masked by an unmeasured row in the same run.
  - probe_engine retries a leg once when its no-output count is nonzero,
    keeping whichever attempt measured more shapes.  The drop is rare
    enough that one retry should usually clear it, and a retry that does
    worse is discarded rather than trusted.

Adds selftest_adjudicate.sh, which drives adjudicate() with synthetic
probe output: the two verdict paths that must keep working, a dropped row
on the 2.13 leg and on a 2.14 leg, all-legs-dropped, and a violation
alongside an unmeasured row.  It lifts the functions out of run.sh rather
than copying them so it cannot drift, needs no netmux, python3 or 2.13
tree, and therefore runs on hosts that cannot run the jig at all.  Wired
into test-parity213 ahead of the jig.

Verified against the pre-fix code, which reproduces the exact report from
the issue:

  OLD:  VIOLATED: 1   SHAPE_C want=both 2.13='<NO-OUTPUT>' JIT='3 mul(2,3)'
  NEW:  UNMEASURED: 1  (exit 2)

The selftest earned its keep immediately: it caught an apostrophe I had
written into a comment inside adjudicate's single-quoted awk program,
which closed the quote early and broke run.sh outright.  sh -n now passes
and the selftest guards against a repeat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:25:04 -06:00
Stephen Dennis
6df286146a test: run smoke on both evaluation routes in make test (#1243)
`make test` could not fail on an AST-route-only defect.  With
jit_eval_brackets on (the default), bracketed expressions are compiled,
so a bug that exists only in the interpreter never executes.

That is not theoretical: three fixes in one day -- #1214, #1238 and
caught only by a manual `SMOKE_EXTRA_CONF="jit_eval_brackets 0"` run,
and testcases/disarm_fn.mux carries a ROUTE: header saying the file
cannot defend itself under the default configuration.

The interpreted route is not legacy.  It handles every expression the
JIT declines, so it runs in production on every server.

Split the smoke step into test-smoke (compiled) and test-smoke-ast
(interpreted), both in `make test`.  Each is runnable alone;
test-smoke-ast depends on test-smoke so smoke.flat is built once and the
two passes report in a fixed order.

Verified the second pass actually changes route rather than silently
repeating the first: config(jit_eval_brackets) reads 1 by default and 0
under the override.

Control -- reintroducing the #1214 defect (ast_eval_argument iterating
children with eval unchanged, bypassing the one-call-per-region rule):

    compiled route:     1427/1427 passed
    interpreted route:  2 failed
        TC001: mid-region call stays literal. Failed (mid=x 3 y end=head 3)
        TC003: brackets re-arm recognition. Failed (mixed=x 3 7)

Whole corpus rather than a route-sensitive subset: measured 30.2s per
pass against a 0.75s Makesmoke, so the second pass costs ~30s.  Deciding
which files are route-sensitive would cost more than it saves and loses
coverage silently when the guess is wrong.

Also removes the orphaned test-dbt-interp target: #1300 folded
tests/dbt_interp into tests/dbt, leaving a target that referenced a
directory with no Makefile and failed when invoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:41:52 -06:00
Stephen Dennis
7472999178 test(lua/jit): make test-lua-jit runs full smoke with lua_jit 1
Full smoke is green with mudconf.lua_jit forced on (1428/1428) and with
the default off.  Add an opt-in make target so CI/devs can exercise the
bytecode→HIR→DBT path without flipping production defaults.

Update plan-lua-jit-bringup and audit-coverage D4 for #1309 progress.
Engage assert (lua_run_ok) still waits on Hatsuhara #1317 jitstats keys.
2026-07-25 23:19:40 -06:00
Stephen Dennis
2b980576f9 Merge master into refactor/consolidate-dbt-tests; fold in tests/dbt_interp
#1299 landed tests/dbt_interp while this branch was consolidating the
other three islands, so the root Makefile conflicted on all three hunks
(.PHONY, clean, and the test: prerequisite list).

Resolved toward this branch's consolidated `test-dbt`, and folded the
fourth island in rather than leaving a directory this refactor exists to
remove:

  - test_dbt_interp.cpp -> tests/dbt/test_interp.cpp (git mv, history
    follows), matching the test_chain/test_cache naming
  - tests/dbt_interp/{Makefile,.gitignore} removed
  - `interp` target added beside chain/cache/exec, in `all:`, in `test:`,
    and in .gitignore

It needs its own binary for the same reason the other three do: the
driver #includes dbt_interp.cpp to reach file-static mem_check, so it
cannot share a link with `exec`, which compiles that TU normally.

All four run from the one target: chain 48, cache 14, interp 13,
hand-assembled 172, plus both ELF legs.  Full make test green,
smoke 1427/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:35:57 -06:00
Stephen Dennis
9623b8f115 fix(jit/dbt): mem_check must not wrap for a large guest address (#1292)
`addr + len <= mem->size` is unsigned 64-bit arithmetic.  For an addr
within len of UINT64_MAX the sum wraps to a small number, compares below
mem->size, and the check passes -- after which the caller indexes
mem->data with the unwrapped addr.  Every guest read and write in
dbt_interp.cpp routes through this one predicate, so all ten share it.

Testing the operands separately cannot wrap: the first term establishes
addr <= mem->size, so the subtraction in the second is always defined.

This is the interpreter-route counterpart to #1151 (DBT-side intrinsic
bounds, PR #1277).  Same footing: defense-in-depth on a path a correctly
generated blob does not reach, fixed for the same reason.

New tests/dbt_interp island, wired into `make test`.  mem_check and the
accessors are file-static, so the test #includes the translation unit
rather than linking it -- dbt_interp.cpp depends only on dbt_interp.h,
dbt_decoder.h and the standard library, so there are no stubs and no skip
path.

The WRAP cases are the only ones that discriminate; the other eight
behave identically under both forms.  Negative control, reverting
mem_check to `addr + len <= mem->size`:

    FAIL: WRAP addr=2^64-1 len=2 must be refused
    FAIL: WRAP addr=2^64-4 len=8 must be refused
    FAIL: WRAP addr near 2^64 with a large len must be refused
    FAIL: WRAP read64 returns 0 rather than indexing out of bounds
    FAIL: WRAP write64 must not modify guest memory
    === dbt interp: 8 passed, 5 failed ===

Full make test green: GANL 16, netaddr 61, dbt chain 48, dbt cache 14,
dbt interp 13, alarm 8, smoke 1426/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:21:01 -06:00
Stephen Dennis
f760f8b57a test(dbt): consolidate the DBT test islands into tests/dbt
Three directories accumulated in a day -- tests/dbt_chain (#1152),
tests/dbt_cache (#1153) and tests/dbt_exec (the RV64 execution harness) --
each with its own Makefile, .gitignore and root-level make target.  The
boilerplate was three copies of the same six variables, and `make test`
carried three lines where one will do.

Now one directory, one Makefile, one `make test-dbt`.

Still three binaries, because each genuinely needs a different link and
they cannot coexist in one image:

  chain  all three backends at once with their colliding strong symbols
         renamed via -D.  Inspects emitted bytes only, so it needs no host
         that can run them -- which is the point, since #1152 was a decode
         bug in a backend nobody compiled.  Links no dbt.cpp.
  cache  dbt.cpp against backend stubs that abort if called.  Cannot merge
         with chain, whose backends are renamed away, nor with exec, which
         links the real ones.
  exec   interpreter and DBT, host backend only, because this one runs
         what it translates.

Individually runnable as `make -C tests/dbt chain|cache|exec`, so
collapsing the root targets costs nothing.

Sources moved with git mv so history follows them.  The two driver
programs are renamed to test_chain/test_cache, matching their binaries;
their internal abort messages are updated to match.

No behaviour change: same three suites, same assertions, same results
(48 + 14 + 172 + 2 ELF legs).

tests/dbt_interp from PR #1299 is deliberately untouched -- that branch is
open and moving its files would only hand its author a conflict.  It
should fold into tests/dbt when it lands; the Makefile has room for a
fourth binary and adding one is a five-line change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:41:04 -06:00
Stephen Dennis
2198dad994 test(dbt): wire the RV64 execution harness into make test
mux/modules/engine/dbt_test.cpp has existed for a long time and has never
been in any build: no Makefile.am entry, no CI, no `make test`.  It ran
only if a developer typed the compile line out of its own header comment
by hand.  The project therefore had no automated RV64 *execution*
coverage at all.

That is the same gap behind three DBT defects fixed this cycle -- #1152
(an x86 rel32 decode applied to AArch64), #1153 (block cache double
insert) and #1151 (unchecked guest pointers into strlen/memcpy).  All
three were found by reading the code, because nothing ran it.

Nothing was wrong with the harness itself: 172 hand-assembled cases pass,
plus the cross-compiled ELF through both routes.  It was simply never
built.

New tests/dbt_exec follows tests/dbt_chain and tests/dbt_cache, with one
deliberate difference: dbt_chain compiles all three backends because it
only inspects emitted bytes, whereas this harness EXECUTES translated
code and so must build the backend matching the host.  The selection
mirrors configure.ac's @DBT_BACKEND@ mapping and skips loudly on any
other host rather than building a backend that cannot run.

Coverage, stated plainly rather than implied: only about 6 of the 39 test
functions drive the DBT directly -- the rest are interpreter-only.  Most
of the block-translation coverage comes from the ELF leg, which
translates ~54 blocks through the DBT and compares against the
interpreter.  So this is an interpreter suite with a real but partial DBT
differential, not a DBT suite.  Widening it is worthwhile follow-up.

Verified it can fail rather than assuming it: an off-by-one introduced
into the a64 ADDI translation produced failures across the DBT cases
("DBT matches interpreter: got 0x16, expected 0x15", and seven more) and
a nonzero exit, so a translation bug in this backend now breaks the
build.  Reverted before commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:21:07 -06:00
Stephen Dennis
ba50eebaaa fix(jit/dbt): dedupe block cache inserts by guest_pc (#1153)
Intrinsic blocks are inserted into the block cache twice: try_emit_intrinsic()
in the backend inserts before returning, and every caller of
dbt_backend_translate_block() (dbt_run, dbt_pretranslate) inserts the result
again.  dbt_cache_insert had no dedupe, so each intrinsic consumed a second
way in its set.

Measured over a real blob load with a temporary counter (reverted before
commit): 102 duplicate inserts, exactly matching intrinsic_hits=102, spread
over 55 sets.  17 of those sets took two or more duplicates and the worst
took nine — into a set that holds four ways.  This happens once at blob load
and is permanent for the process: dbt_reset preserves blob translations.

A single duplicate is harmless, which is worth stating because it is why a
naive test passes: the way-0 FIFO evicts the original and the copy at way 1
keeps that pc reachable.  Harm needs two intrinsics in one set — all four
ways then hold two distinct blocks, and the next two distinct blocks evict
each other.  A set asked to hold only four distinct blocks drops one.  The
measurement says that is 17 sets, not a hypothetical.

The fix is in dbt_cache_insert rather than in the three backends: it is
backend-neutral, needs no per-backend change, and closes the whole class
rather than the one known double-insert path.  The match condition mirrors
dbt_cache_lookup (guest_pc equal AND native_code non-null) so an empty way
is never mistaken for an entry for pc 0.  Re-inserting a known pc updates
the entry in place; callers only translate on a lookup miss, so this is
reachable today only via the intrinsic path, where the pointer is identical.

The redundant insert in try_emit_intrinsic is left in place — it is now
harmless, and removing it would touch three backends for no behavioural gain.

New tests/dbt_cache unit island, wired into `make test`.  It compiles dbt.cpp
against four backend stubs, so it needs neither `install` nor --enable-jit
and has no skip path.  Set membership is discovered by probing rather than by
recomputing cache_set(), so the tests survive a retuned hash instead of
drifting with it.

Verified by reverting the dedupe: 4 assertions fail, including the eviction
case, which reports the specific pc that was dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:14:10 -06:00
Stephen Dennis
0003eb433b test(dbt): lock the chain patch encode/decode inverse for all backends (#1152)
dbt_resolve_chains decides whether a recorded patch site is still pointing
at its slow-path stub by decoding the site and comparing against
stub_offset, so dbt_backend_decode_jmp_target has to be the exact inverse
of dbt_backend_backpatch_jmp.  It was not: the decode was an x86-64 rel32
computation with no architecture guard, so on AArch64 every site took the
already_ok branch and was never resolved (#1152, fixed in PR #1244).

Nothing exercised the affected backend, which is why the defect survived.
So this harness does not test only the host's backend: all three
(a64_sysv, x64_sysv, x64_win64) are compiled into one binary via -D
symbol renames on the five colliding strong symbols.  No shipping source
changes; x64_win64 gets coverage it has never had outside Windows.

The backend sources are compiled directly rather than linked from the
engine build, so the target needs neither `install` nor --enable-jit and
has no skip path that could degrade it into testing nothing.

Four groups of assertions:

  - Golden vectors.  Round-trip identity is too weak on its own: it still
    holds when encoder and decoder are wrong in the same way.  Each of the
    six expected byte patterns was taken from a real disassembler (aarch64
    as/objdump for B imm26, x86_64-linux-gnu-objdump for JMP rel32), not
    derived from the code under test.
  - Round trip, exhaustive at each backend's granularity over the 1 MB
    buffer: 1310720 sites for a64, 5242880 for each x86-64 backend.
  - The predicate resolve_chains actually depends on -- an unresolved site
    must decode as its stub, and a resolved one must not.
  - The #1147 out-of-range guard, including that the last legal site is
    still accepted.

Verified by breaking the code five ways.  Reintroducing the #1152 bug
fails 10 assertions.  Making the a64 encoder and decoder wrong with a
matching base passes all 1310720 round trips while every branch lands 4
bytes short of its target, and is caught by the golden vectors alone --
which is what they are there for.  Removing the #1147 guard, dropping the
backpatch write, and dropping the decode sign extension each fail as well.

Not demonstrated: the guard against a resolved site still decoding as its
stub.  It is cheap and two-sided, but no single-line break tripped it
rather than a neighbouring assertion.  Clang is untested here.

Cold build and run is ~5s; the round trips themselves are 0.08s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 18:22:45 -06:00
Stephen Dennis
e0a8f0032c test: add a 2.13/2.14 parser parity jig
MUSH function-call recognition is context sensitive in ways a tokenizer
cannot decide on its own: `add(` is a call and `foo(` is not, and only a
function-table lookup separates them, so the parser knows things the
lexer cannot.  The grammar is not well-formed and there is no tidy rule
to validate against — which means the specification is what 2.13 actually
does.  This measures that instead of theorising about it.

Three engines, driven identically through a live netmux over a socket so
no difference can be an artifact of the harness:

  2.14 JIT   this tree, default conf
  2.14 AST   this tree, jit_eval_brackets 0
  2.13       reference tree via MUX213_ROOT, when available

The 2.14-internal comparison always runs and needs no reference tree: the
two routes of one build disagreeing is a defect whatever 2.13 says.  On
this tree today that is 19 of 53 shapes.

Corpus probes position (where in the text a call sits), arming (what
precedes it) and nesting, rather than grammar productions.  It also
carries the player-facing prose shapes — say/pose/@pemit and attribute
round-trips — because the reason the rule exists is that `say I tried
add(1).` must stay literal while `[add(1)]` must evaluate.  Those
currently agree everywhere; keeping them in means a change that breaks
player-visible behaviour surfaces next to the softcode-visible ones
instead of being found by a player.

Reading is sentinel-driven rather than sleep-based.  A first cut using
fixed sleeps intermittently lost a row when several servers ran at once,
and an intermittently missing row reads as a divergence — a flaky parity
harness is worse than none.  Each case is followed by a `think`
sentinel and the reader waits for it; three consecutive runs now report
identical counts and zero missing rows.

Opt-in via `make test-parity213`, deliberately NOT in `make test`:
divergences exist today (#1214), so this is a measurement tool rather
than a pass/fail gate.

A divergence here is a finding, not automatically a bug.  Some 2.13
behaviour is organic — `[strcat(x (add(1,2) y)]` returns the whole
expression verbatim, `strcat(` included, because an unbalanced paren
stopped it recognising the outer call.  That shipped for years and
softcode may depend on it; a reimplementation treating the grammar as
well-formed would silently "correct" it.  The README says so, so the
output is read as evidence for a design conversation rather than a
defect list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 10:00:21 -06:00
Stephen Dennis
7964d7135a fix(jit): decide ifelse()/if() conditions with xlate(), not atol() (#1157)
The interpreter decides an ifelse()/if() condition with xlate() (see
fun_ifelse in funceval.cpp).  The JIT's condition handling agreed with it
only for plain integers, and disagreed in three separate ways — each of
which made a true condition come out false, so the else arm was taken:

  carg   A bare %0-%9 lowers via emit_sref(): an HIR_SCONST carrying an
         empty sval and a runtime_ref marking, whose value only exists
         once run_cached_program fills CARGS_BASE.  The fold tested
         h.kind[cond] == HIR_SCONST, read the empty sval, got
         mux_atol("") == 0, and folded the whole ifelse to its else arm
         at compile time.  `ifelse(%0,Y,N)` returned N for every input —
         no BRC was emitted at all, the construct collapsed to a literal.
         This is the reported defect, and it hits the standard idiom for
         branching on a ufun argument.

  const  A genuine literal was folded with mux_atol() rather than
         xlate(), so "abc", "#5" and "0abc" — all true — came out false.

  float  A float condition used FTOI, which truncates toward zero, so
         0.5 and -0.5 came out false.

emit_sref() documents itself as marking the value "non-constant to
prevent folding", and hir.h already defines is_const(), which is exactly
that check (kind is ICONST/SCONST *and* !runtime_ref).  It had no callers
anywhere in the engine — every fold site hand-rolled the kind test.  This
routes the ifelse/if condition through is_const() and folds genuine
literals with xlate() itself, so compile-time and run-time answers come
from one implementation.

Non-constant conditions that are neither known_int nor foldable now defer
to the ECALL, which runs the real xlate().  That includes floats: no HIR
shape reproduces xlate() for them, since it calls any nonzero float true
but NaN/+Inf/-Inf false, which neither FTOI nor a plain nonzero test
gets right.

Cost: a bare `%0` condition now deopts to the ECALL instead of staying
compiled.  Guarded forms are unaffected — `ifelse(gt(%0,0),...)` and
`ifelse(strlen(%0),...)` keep the known_int/ATOI path and stay inlined.
Restoring the compiled path for bare cargs wants an HIR op that performs
string truth at run time so the BRC/PHI structure survives; that is a
follow-up, not a bug fix.

Tests
-----
testcases/ifelse_fn.mux — new; ifelse() had no assertive coverage at all.
Of 256 *_fn.mux files there was switch_fn.mux and if_cmd.mux but no
ifelse_fn.mux, and the only occurrence of the string "ifelse" in any
source test file was a rand()-driven timing loop in rvbench_fn.mux that
asserts nothing.  That is how this survived.

testcases/tools/jit_ifelse/oracle.sh — JIT-vs-AST differential over all
three families plus four shapes that already worked, wired into `make
test` as test-jit-ifelse.  It carries the jit_qreg canary: if the J side
reports eval_handled=0 it exits 2 rather than comparing the AST against
itself, since a JIT-less build (--enable-jit defaults to NO) otherwise
reports a vacuous pass — the second reason this went unnoticed.

Verified to discriminate by reverting the fix and rebuilding: 10 of the
20 oracle checks fail, spanning all three families, while all four
regr-* checks still pass — the suite isolates the defect rather than
failing broadly.

make test: smoke 1330 succeeded / 0 failed, both JIT oracles OK, ganl,
netaddr and alarm green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 00:11:29 -06:00