mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
63 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|