Every classic telnet connect waited a hardcoded 500ms in total silence
before the server sent its first byte. Measured over 30 connects to an
idle server: min 500.6ms, p50 501.0ms, max 504.5ms -- the distribution is
the grace window itself. Base command latency on the same setup is ~51us.
The window exists so telnet negotiation cannot corrupt a WebSocket
handshake (#1074), and the incentives are inverted: WebSocket and TLS
clients speak first and are served immediately, while the classic MUD
client -- which waits for the server to speak, and is the primary audience
-- is the only kind that always pays in full.
Now `proto_detect_window`, in milliseconds, default 500. Nothing changes
for a site that does not touch it. 0 disables detection: the banner goes
out at accept, as in 2.13. That is the correct setting for a port that
never serves WebSocket -- it has nothing to detect and no reason to wait.
On keeping 500 as the default rather than shaving it: the window is NOT
covering a round trip, which is what the original issue text assumed. A
WebSocket client's GET rides directly behind the handshake's final ACK, so
the healthy case needs ~0ms however distant the client -- the RTT is
already spent by the time the window opens. What the window must survive
is that first packet being LOST, where the retransmit arrives on an RTO
that Linux floors at 200ms. 500 covers one retransmit with headroom. 100
would sit in the dead zone -- past every healthy client, short of every
retransmit -- and would break real WebSocket handshakes intermittently on
a lossy link. A slow banner is a far better failure mode than that.
The value was a literal in two places (the age-out sweep and the main-loop
timeout clamp) with nothing tying them together, so they could drift and
the clamp would silently stop bounding the sweep. One accessor now.
0 is expressed as arithmetic rather than a second finalize call site: with
the window at 0, `age >= 0` is true on the first sweep and the clamp drives
processEvents to a 0ms timeout, so the connection finalizes in the same
main-loop iteration it was accepted in. A shortcut around the sweep would
have had to duplicate both the #2018 exception barrier and the #1800
partial-preface replay.
IID_IGameEngine bumped C9D2 -> C9D3. The vtable is unchanged, but
GetConfig() memsets and fills sizeof(DRIVER_CONFIG) as the ENGINE sees it
into storage the DRIVER sized, so a size disagreement is an out-of-bounds
write rather than a wrong answer.
tests/scenario/proto_detect.py asserts it against a live server:
ok 1 - silent client waits the configured proto-detect window # 500 ms
ok 2 - client that speaks first is served without waiting # 0 ms
ok 3 - engine accepted the runtime @admin to 0 # config='0'
ok 4 - #2193 window 0 serves a silent client at accept # 0 ms
ok 5 - restoring the window restores the wait (live push, both ways)
Catch-verified by reverting only the driver's USE of the knob, leaving the
config plumbing intact -- the #1222 shape, where the engine reports the
change and the driver ignores it until restart:
ok 3 - engine accepted the runtime @admin to 0 # config='0'
not ok 4 - #2193 window 0 serves a silent client at accept (500 ms)
Clean rebuild, since DRIVER_CONFIG's layout changed (#2107).
make test: 36 targets, 34 passed, 2 skipped (NLS), 0 failed
config: jit=yes stubslave=yes nls=no realitylvls=yes wodrealms=yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flip: FUNCTION/XFUNCTION/FUN::fun/delim_check and the module
interfaces take `const UTF8 * const fargs[]`. Double-const is
load-bearing: C++ qualification conversion needs const at both pointer
levels, so builder-side `UTF8 *[]` arrays convert implicitly — the
evaluator, the JIT marshaller, and every owner site need zero casts,
and slot reassignment inside bodies becomes a compile error for free.
The conversions: the flip landed first so the compiler enumerated every
violation; this commit is that inventory worked to zero — ~250 sites
across funceval, funceval2, functions, funmath, help, mail, session,
powers, levels, predicates, conf, walkdb, stringutil, timeutil/
date_scan (regenerated, one-line diff), exp3, and mux_main, each
classified per docs/campaign-2136-const-fargs.md's four recipes.
New idioms (functions.h): trim_space_sep_n() — non-destructive trim for
(pointer, length) consumers, so trim-then-scan sites need no copy at
all; FargVec — the argv counterpart of FargCopy for CS_ARGV handlers.
countwords() and DecodeListOfIntegers() rewritten non-destructive.
The flip deleted more than it added: #2157's fun_munge list1 copy, the
engine_com help-topic copy, fun_index's in-place NUL write, and five
const_casts (process_sex x4, sha1_helper). const_cast budget: zero
added.
Trap recorded in the brief: an old-signature definition doesn't fail
the build — it becomes a C++ overload, and the new-signature symbol
stays undefined until dlopen(RTLD_NOW). delim_check, the conn_bridge
bridges, the dbt_spike stub, and exp3::Call were all silently shadowed;
muxscript was the only host that noticed, because netmux's own net.cpp
resolved the flat-namespace lookup. After any signature flip, grep the
old spelling.
Verified: make test EXPECT_CONFIG="jit=yes" (35 passed / 0 failed) and
make test-scenario, including the new tests/scenario/sidefx_fargs.py
that live-probes the class-3 wrappers smoke never touches (pemit/
trigger/link/tel/wipe/destroy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three self-inflicted costs in how the JIT decides what is resident, all
invisible to single-expression benchmarks and all paid by the workload
shape live servers actually have (~1500 distinct programs at a ~100%
switch rate, per the #2129 profile measurement):
1. A declined shape never touches SQLite again. jit_eval fetched and
fully deserialized a compiled program in order to read four integers
and decline it (bail_noop) — ~19.4us per evaluation of pure waste for
get/v/u-shaped expressions once they fell out of the memory cache
(#2130). The verdict is a pure function of the code, so it is now
memoized by compile-cache key and refused before any cache machinery
runs. Dep-free programs only; cleared with the other caches on
jitstats(flush); noop_memo counts memo refusals (also counted in
bail_noop for dashboard continuity).
2. The in-memory compiled-program cache no longer cliffs at 256. One
expression past the old fixed capacity took the hit rate from 99.9%
to 0.16% and wall +56% (#2130) — round-robin is an LRU's worst case.
Capacity is now jit_compile_cache_max (default 2048, clamped [8,65536]
at use, read at insert time so runtime @admin applies immediately).
3. Slot admission resists cold storms (Kagura's #2139 review finding).
The strict-LRU slot claim helped working sets up to the slot count and
then cliffed: round-robin one past it evicted the entry needed next on
every evaluation — measured ~8% WORSE than the old reset. Slot 0 is
now a probation lane: first-touch and pinned programs run there and
cannot displace a hot resident; protected slots 1+ are evictable only
when cold (hot bit unset across a 64-miss sweep window). slot_evict
now counts protected displacements only — the raise-the-slot-count
signal — with probation churn split out as slot_churn0.
Measured (macOS arm64, 2000 commands round-robin, wall us/command):
distinct slots=1 slots=7 old policy @ K=8: 8% worse
1 19.9 17.4 1.14x new policy @ K=8: 1.60x BETTER
2 53.6 20.7 2.59x
4 49.6 20.7 2.39x skew 4 hot/32 cold (80/20):
6 49.1 20.8 2.36x 49.1 -> 24.6 us/cmd (2.0x)
8 49.2 30.8 1.60x
16 49.4 43.2 1.14x decline memo steady state:
32 49.4 45.5 1.09x memo+1500, sqlite+0
(fetch tier eliminated)
Never worse than the old reset at any size measured; converges to the
old cost from below instead of crossing it.
Also pays two documentation debts from the #2139 review: the read-only
string-pool claim now names its dependency on #2135's ECALL argument
copy (narrowing that predicate reintroduces #2128 silently through the
materialize-skip), and CHANGES states the slot-count regime instead of
an unqualified speedup.
tests/scenario/jit_alternation.py grows from 13 to 19 checks: a
jitstats(flush) clean-slate preamble (deterministic on aged servers), a
cold-storm phase asserting protected residents survive (slot_evict +0,
slot_miss +0 for the hot set afterward) while probation churns, and a
decline-memo phase asserting the second round of declined shapes reaches
neither SQLite nor the compiler. The driver also raises the command
quota itself: a burst phase behind default pacing left the closing
jitstats probe timing out, and empty stats read as +0 deltas — a false
PASS shape for check 18 and a false FAIL for check 17.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_cached_program kept translated blocks for exactly one program: a
single uint64_t remembered which program the DBT was last set up for, and
any other program forced dbt_reset + full re-translation. Two alternating
expressions re-translated on every evaluation — a fixed +22..76us per
command on the issue's box — and every JIT benchmark in the tree repeats a
single expression, so none of them could see it. Real command streams are
nothing but alternation; worse, the compiled unit is the WHOLE evaluated
argument (literal prefixes included), so even one command template with
varying literals is a stream of distinct programs.
The collision is at guest PCs: every cached program is compiled at the
same canonical base, so two programs occupy the same addresses with
different bytes. Rather than tagging the block cache (its 16-byte entry
and inline host-code lookup are load-bearing on three backends) or keeping
N whole DBT contexts (N copies of the blob translation), this makes the
guest PC itself the disambiguator — the same model the shared heap and
persistent_vm already run: each program materializes its CODE into its own
16 KB guest slot, and the PC-keyed cache holds every resident program at
once. Only code moves; str/fargs DATA keeps swapping at canonical
addresses, because data content never invalidates a translation.
Generated code turns out to be one relocation away from position-free:
internal control flow is PC-relative, data references are absolute into
regions that do not move, there is no JALR or AUIPC, and the only
position-dependent bytes are the blob-call JALs. Those are re-aimed at
materialize time by a linear decode of the pure 4-byte instruction stream
— no relocation records. A program that ever fails that scan is PINNED
to the canonical slot, which is exactly the old behaviour, scoped to
exactly those programs (today: none; slot_pinned going nonzero means a
new lowering quietly lost slotting).
Slot bases live in the two spans this arena leaves unallocated (0x40000-
0x50000, 0x60000-0x68000), all within JAL range of the blob. Slot reuse
does a range-scoped eviction (dbt_invalidate_guest_range) mirroring
dbt_reset's preserve-blob discipline; code-buffer exhaustion self-heals
through the existing reclaim, since residency means guest bytes, not
translations, and translations rebuild lazily on cache miss. rvbench's
foreign binding releases the slots it invalidates.
jit_code_slots (default 7, runtime @admin) clamps the working set;
1 restores the old single-program behaviour as the A/B lever. Measured
on macOS arm64, 2000 commands round-robin, wall us/command:
distinct slots=1 slots=7
1 25.2 21.5 1.17x
2 60.7 21.4 2.84x
4 57.3 24.7 2.32x
6 51.4 25.7 2.00x
slots=7 is flat across the sweep with slot_miss=0; slots=1 shows the old
per-switch tax with slot_miss == every evaluation.
Tests: tests/dbt/test_reloc checks the JAL codec against golden words
from riscv64-unknown-elf-as and verifies re-aimed targets at every slot
base; tests/scenario/jit_alternation.py drives a live server and asserts
the mechanism (exact results across alternation and eviction, slot_miss
+0 within the slot count, dbt_code_used flat, the knob restoring the old
behaviour). Also fixed in passing: dbt_configure_trace_from_env is no
longer re-read on every program switch, since the switch no longer resets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jit_can_handle() decided, on EVERY evaluation, whether the JIT could
compile an expression -- walking the whole AST with a heap-allocated
worklist and, per function-call node, building a std::string, uppercasing
it, copying it into a std::vector<UTF8> and doing two hash lookups. ASTs
are cached and re-evaluated many times, so this made a once-per-parse
question into a once-per-evaluation cost.
Measured on Linux/x86-64 as ~0.06-0.14us per evaluation, paid on
expressions the JIT declines, on plain text it will never touch, and on
expressions where it wins.
THE VERDICT IS NOT A PURE FUNCTION OF THE AST, which is the trap in "the
tree is cached, so cache the answer". It also depends on
mudconf.jit_eval_brackets (a runtime toggle) and on whether each call
node's name resolves in builtin_functions or ufunc_htab (@function,
module registration, the function_alias directive). So the memo carries
a stamp: the toggle in the low bit, an epoch bumped by
jit_gate_note_function_table_change() at the five sites that mutate
either table.
A missed invalidation would fail SILENTLY and in the dangerous direction
-- it picks the wrong evaluator, not a wrong answer, so both routes
return the same string and nothing reports it. That is why the hook is
declared with its reasoning in externs.h rather than an issue number, and
why TC008 asserts eval_attempts rather than values.
Measured, one binary two arms (MUX_NO_GATE_MEMO), min of 5, us/call:
add(add(add(1,2),add(3,4)),add(add(5,6),add(7,8))) 0.164 / 0.741 4.51x
bound(rand(100),10,90) 0.628 / 1.316 2.09x
strcat(abcdef,ghijkl) 0.166 / 0.329 1.98x
this is plain literal text with no function calls 0.388 / 0.596 1.54x
add(rand(100),1) 1.838 / 2.412 1.31x
iter(lnum(10),1) 4.039 / 4.439 1.10x
ladd(lnum(100)) 11.071 /11.066 1.00x
add(1,2) 0.906 / 0.906 1.00x
The two flat rows are the control, not a disappointment: at 15 and 8
bytes they are below AST_CACHE_MIN_LEN (16), so their trees are reparsed
every evaluation and the memo cannot hit. Predicted before the run; the
counters agree independently (20020 misses is 2 probes x 10000). That
also states the limitation -- the gate runs at >= 8 bytes but the AST
cache starts at 16, so 8-15 byte expressions still pay in full. Changing
that threshold is a separate question with its own cache-pressure
tradeoff.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
220000 rounds (~220ms/hash here) is a DoS surface in the single-threaded
server: verification recomputes at the stored rounds, so a 100-reconnect
burst serializes ~22s of hashing, stalling the whole game; even a threaded
server makes one connect a cheap asymmetric amplifier. Target a ~50ms
per-hash budget instead -- measured 50000 rounds = ~50ms on a 2.5GHz Xeon
Platinum 8259CL (AWS c5-class). Still 10x the sha-crypt spec default and
~5s per 100-reconnect burst. rounds ride in each hash and rehash-on-login
already propagates the change, so existing $6$rounds=220000$ hashes
downshift automatically as players log in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The gates in #1325 are met and the closing of #1433 was the last one:
- The differential harness executes every supported shape -- 140
chunks, EXEC requiring lua_run_ok to advance, declines all deliberate
(os.time, two pins, select arity).
- All three loop forms run under an aborting instruction budget whose
exhaustion surfaces the interpreter's own error, verbatim (#1732).
- The compiled path wins or ties every benched shape; loops beat the
VM's marginal per-iteration cost after #1743 (#1741).
- #1433 closed unreproduced: three machines, thousands of process
runs, including the original commit on the machine that first saw it.
- Windows gates closed by the maintainer.
A chunk the compiler cannot honestly run declines to the interpreter,
so OFF now buys nothing but the slower path; lua_jit=0 remains the
diagnostic switch. With the default on, ordinary smoke exercises the
engaged path -- TC006 asserts lua_run_ok advances under the default
config -- and the explicit lua_jit-0 cases keep the gate itself tested.
smoke 1561/0 with the JIT engaged; luajit harness 140 chunks, 0 wrong;
full make test clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
Replace ~900 typographic \xE2\x80\x.. escapes (curly quotes, en dashes)
and a few \xE2\x80\230 octal workarounds with real UTF-8 in message
strings under mux/modules and mux/src. Leave stringutil and convert
charset mapping tables as explicit byte sequences.
Completes the sweep the issue called for. mux_atol returns long, which
is 32-bit on LLP64, so every caller silently truncated on Windows. Two
of those were real defects (the truthiness family and cf_size, fixed in
the preceding commits); the rest were latent, waiting for a value large
enough to matter.
Rather than audit 290 sites for whether each can reach 2^31 today, use
the 64-bit parser everywhere and remove the class. A dbref cannot
overflow now, but nothing stops a later caller passing that same site a
timestamp or a byte count.
Pure 1:1 substitution: 285 lines changed, and every removed line
contained mux_atol while every added line contains mux_atoi64. No
control flow, no types, no behaviour beyond the wider parse.
This is a NO-OP on LP64 -- long is already 64-bit on Linux and macOS, so
the generated code there is unchanged. It only widens the parse on
Windows. Narrowing destinations are unaffected either way: `int x =
mux_atoi64(s)` truncates exactly as `int x = mux_atol(s)` did, on both
models.
Left alone: mux_atol itself in mathutil, its declaration, and three
comments that name it. Callers that genuinely want 32-bit semantics can
still ask for them; none appear to.
Verified on Windows: full solution builds clean with no new warnings,
smoke is 1418 passed / 16 failed / 0 crashes / 306 of 306 dispatched --
identical to before the sweep, with the same 16 build-configuration
failures (exp3 module not loaded, hmac/digest behind UNIX_DIGEST).
Spot checks after the change: the boolean family returns 1 for multiples
of 2^32, cf_size round-trips 3000000000 and still reads -1 as unlimited,
and arithmetic, string and list functions are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second instance of the same LLP64 defect, found by sweeping mux_atol
call sites for 64-bit destinations. cf_size parsed the numeric part
with mux_atol into an int64_t, so on Windows a raw byte count at or
above 2^31 was truncated before the k/m/g multiplier could apply:
max_cache_size 5000000000 -> 705032704 (0.7 GB, not 5 GB)
max_cache_size 3000000000 -> -1294967296 (NEGATIVE)
max_cache_size 4g -> 4294967296 (correct: small base)
The negative case is the dangerous one. Every consumer reads a negative
size as "unlimited" -- attrcache.cpp:506 and :1035, move.cpp:205 -- so an
operator capping the attribute cache at 3 GB silently got an UNCAPPED
cache instead. That is the opposite of the stated intent and an
unbounded-memory condition on a long-running server.
Suffix forms were unaffected because the base is small and the multiply
is already int64; only raw byte counts could reach the truncation.
Verified on Windows after the change: 5000000000 and 3000000000 both
round-trip exactly, 4g still gives 4294967296, and -1 still means
unlimited. Full smoke unchanged at 1418 passed / 16 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 of the Lua JIT bring-up: mudconf.lua_jit (GOD/WIZARD) defaults
to false so the interpreter path remains the only production path while
hir_lower_lua correctness work continues.
TryJIT no-ops when the gate is off; @list lua reports enabled/disabled.
Documents the full phase plan in docs/plan-lua-jit-bringup.md.
Paired with the #1278 loader cherry-pick on this branch: master can take
both and stay green; developers flip lua_jit 1 to exercise the JIT.
Pass 10, slice A1 -- the "config basket pull" item in the coverage map.
DRIVER_CONFIG knobs are read by the driver from g_dc, not mudconf, and the
engine only re-pulls that basket when a config handler calls
g_driver_config_sync_fn. #1085 added the sync to cf_bool/cf_string/
cf_option and introduced cf_live_driver_int, which covered the defense and
session knobs. cf_seconds and cf_dbref were not updated, so four
runtime-settable knobs behind them accepted an @admin -- config() reported
the new value and the change logged as Success -- while the driver kept
using the boot-time value until restart:
lag_limit cf_seconds g_dc.max_cmdsecs net.cpp:2629 (CPU watchdog)
lag_maximum cf_seconds g_dc.rpt_cmdsecs net.cpp:2661
timeslice cf_seconds g_dc.timeslice net.cpp:2938
player_starting_room cf_dbref g_dc.start_room net.cpp:2507
All four are CA_GOD. lag_limit is the operationally important one: it is
the alarm-clock CPU watchdog, exactly what an admin reaches for during an
incident -- the same rationale that made the #1085 set live.
Fix is the existing live_sync_driver_config() in both handlers. It already
guards on !bReadingConfiguration, so it is a no-op while parsing the config
file and costs one basket copy per runtime set. Forward-declared because
both handlers precede its definition.
Deliberately NOT changed: number_guests, guest_char_num, port, port_ssl and
ip_address are also in the basket behind non-syncing handlers, but all five
are CA_STATIC and check_access (command.cpp:1185) rejects CA_STATIC
outright, so they cannot be set at runtime -- no defect, and no reason to
add a sync they can never reach. port/port_ssl/ip_address should stay
unsynced regardless: re-pulling them would put new values in g_dc while the
listeners stay bound to the old ones, leaving g_dc.ports[0] -- which
telnet.cpp:310 reports to clients -- disagreeing with reality.
Adds tests/scenario/driver_config_sync.py, which digs a room, @admins
player_starting_room to it, and creates a player from the login screen to
see where the driver actually puts them. Verified against the unfixed
build:
ok 1 - engine accepted the runtime @admin of player_starting_room
not ok 2 - driver honours it (expected='#17' landed='#0' boot='#0')
Assertion 1 passing in both builds is the point of the test: config()
reports the new value and the set logs as a success, so nothing tells the
admin it did not take. The other three knobs share the defect and the fix
but are timing-dependent, so they are not pinned here.
Verified: build clean; smoke 1408/1408, 0 crashes; scenario 5/5 drivers
(wild_capture, site_threshold, jit_perms, telnet_negotiation, page_cost)
plus the new driver_config_sync 2/2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restart (#1039–#1043): zero process-local DESC fields after load,
restore peer via getpeername, strip non-survivable TLS/WS flags,
drop TLS and WebSocket before exec, tear down adopt failures with
shutdownsock, abort partial restart loads cleanly.
DB (#1044–#1047): cache_flush_writes returns success only after
Commit; leave dirty queue on failure; atr_head union SQLite with
pending cache/queue; set mudstate.bStandAlone in DbConvert; flush
attr queue before import Commit.
Queue (#1048–#1052): setup_que refunds on OOM; sql_que frees BQUE
on Query fail and inits waittime; do_wait rolls back semaphore if
wait_que fails; include nest depth limit 50; CPU halt_que uses
Owner+object for machines.
JIT (#1053–#1057): full 64-bit rv_load_i64; setq/r single-char
RegisterSet; decline long CARGS; accumulate inline u() watermarks;
bounded guest_strnlen on ECALL paths.
Conf/convert (#1058–#1061): @enable/@disable sync g_dc; live
driver knobs for output/retry/max_players/timeouts/quota; safe
heap encode for t5x/t6h/r7h attributes.
Pre-auth cap now keys on same_source_key (IPv4 host / IPv6 /64) so a
single /64 cannot fan out past max_preauth_sitecons. connect_rate_charge
runs only after pre-auth acceptance, matching the "charge accepted only"
invariant. Equal-subnet site inserts adopt ulThreshold so graduated
thresholds can be reconfigured without reset_site. Defense knobs use
cf_live_driver_int + a libmux driver-config sync callback so @admin
updates g_dc without a restart.
The Rhost item our own follow-up list skipped, and the vector was genuinely
uncovered. max_preauth_sitecons bounds how many pre-auth connections are held
AT ONCE; login_fail_limit bounds FAILED LOGINS. An attacker who connects and
immediately disconnects -- never logging in, never failing a login -- is
touched by neither, while every cycle still costs an accept, a DESC, the
welcome screen's file dump, the site checks and a log line.
max_lastsite_cnt default 40, 0 = off -- connections per source per window
min_con_attempt default 60 seconds -- window over which it refills
Both names are Rhost's. Checked in the GANL accept path before the pre-auth
cap (it is the cheaper test). Only ACCEPTED connections are charged: a refused
attempt costs the attacker nothing extra, but charging it would hold a shared
address at zero for as long as one attacker kept trying, starving the
legitimate users behind it of the refill.
Two deliberate differences from Rhost:
* The response is a transient refusal, not their lastsite_paranoia
auto-register/auto-forbid. A permanent sitelock earned by a burst is
precisely the wrong answer on a shared address: one abuser in a dorm would
lock out the whole building until an admin undid it by hand. A refusal
that heals as the bucket refills costs a legitimate player seconds. Admins
wanting the permanent form already have forbid_site, now with graduated
thresholds. We therefore ship no lastsite_paranoia knob at all rather than
one whose values mean something different from theirs.
* The counting is a true per-source bucket. Rhost keeps a single "last site"
slot, so only CONSECUTIVE connections from one address count and one
interleaved connection from anywhere else resets the counter -- trivially
walked around by alternating two addresses. Keyed per source (v6 by /64),
ours cannot be reset that way.
Default is 40/60 rather than Rhost's 20/60 because ours counts strictly harder
AND is on by default where theirs is gated behind lastsite_paranoia 0. 40/60
still cuts churn from unlimited to 40/minute -- three orders of magnitude --
while clearing the burst that matters: a whole dorm reconnecting after a
reboot. Verified 30 connect-and-login cycles from one address at the shipping
default: 30 accepted, 0 refused.
The failed-login bucket was generalized rather than copied: one source_bucket
mechanism (fixed 512-slot table, lazy refill, least-suspicious eviction, /64
v6 keying) now backs both g_login_fail and g_connect_rate.
Verified at max_lastsite_cnt 10 / min_con_attempt 60 with the pre-auth cap and
login-fail throttle both DISABLED -- proving this defense alone catches it --
pure connect/disconnect churn ran 11 cycles then was refused, NET/RATE damped
by nospam_connect to one line. At 5/10s, 5 of 12 rapid cycles refused and a
connection 8s later succeeded. Regression at the shipping default: smoke
1319/1319, stress 8/8, netaddr 57/57, ganl 14/14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surveyed RhostMUSH at ce5226ff for prior art on connection/login DoS defenses.
Two results change this work.
1. Refusal logging is itself a DoS vector, and we had missed it.
Refusing a connection still costs a log write, so a flood we successfully
refuse fills a disk instead. Rhost's nospam_connect exists solely for this and
their help text names it outright ("Real twinkish players may try multiple
connects to overload a log file"). Both of our new defenses -- the pre-auth
cap and the failed-login throttle -- logged every single refusal, converting a
connection flood into a disk flood.
Adopt their parameter by name and semantics:
nospam_connect 0 = log every refusal
1 = log the first of a consecutive run from one address,
then one summary line when the run ends (default)
2 = do not log refusals
Two deliberate differences from Rhost:
* Default 1, not their 0. The collapse loses no signal -- first line plus an
exact count -- so there is no reason to ship the hole open.
* Flushed on the periodic idle sweep, not only when a run ends. Rhost's
refusals happen at accept, so an accepted connection ends the run. Ours
also refuse at LOGIN, and every login attempt arrives on a freshly
accepted socket -- flushing on accept would end the run before every
single refusal and defeat the damping entirely. Flushing on the sweep also
bounds refusal logging by TIME rather than by the attacker's rate, which
is the property actually wanted: a sustained attacker produces neither a
different address nor a successful login, so the count would otherwise sit
unreported for the whole attack.
State is one address slot plus a counter, as Rhost does it -- the anti-flood
measure cannot itself be flooded.
2. Match Rhost's configuration vocabulary where the knob is the same thing.
nospam_connect is adopted verbatim. max_preauth_per_site is renamed to
max_preauth_sitecons to sit in Rhost's max_sitecons family -- but deliberately
NOT named max_sitecons, because theirs caps ALL connections from a site and
ours caps only unauthenticated ones. An identical name would be a false friend
that reads as configured while behaving differently.
The survey also validates two earlier decisions: Rhost has no per-account
lockout anywhere and no per-IP failed-login throttle either, and every Rhost
auto-generated ACL entry keys IPv6 on the full /128 address against a list with
no cap or expiry -- exactly the evade-and-flood hole our /64 source_key avoids.
Verified: 9 consecutive refusals collapse to 1 full line plus a periodic
summary ("[127.0.0.1] Connection refused [total 4 more times]."), and the
renamed knob still refuses correctly. Regression: smoke 1319/1319, stress 8/8,
netaddr 46/46, ganl 14/14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
retry_limit (3) is per-SOCKET: after three bad passwords the connection
closes and the attacker reconnects for three more. Nothing remembered
anything across connections, so brute-force-by-reconnect was unbounded.
Add that memory as a token bucket per source address, consumed only by
failed connect attempts:
login_fail_limit default 10, 0 = off -- burst of failed logins per source
login_fail_period default 60 seconds -- interval over which it refills
Sustained rate is limit/period, so the default is 10/minute against a
previously unlimited rate. Checked in check_connect BEFORE ConnectPlayer, so
a throttled source also stops costing a password hash per guess. Guests are
exempt (fixed password, separately bounded by the guest pool). On refusal the
socket is left open and retries_left untouched -- the attempt never reached a
password check, so it is not a failed login -- and conn_timeout still reaps an
idle one.
Two shapes of this defense are actively harmful in a MUSH and are not used:
* Per-ACCOUNT lockout. Player names are public (WHO, in-game, the
directory), so anyone could lock any player -- including a wizard -- out
of their own game by spamming failures at their name. That trades a
brute-force risk for a guaranteed griefing tool.
* A delay before answering a failed login. This server is single-threaded;
sleeping to slow one attacker stops the world for every other player. The
throttle must be non-blocking, so it refuses rather than stalls.
Keying: IPv6 by /64, not by address. One IPv6 customer normally holds a whole
/64, so a single host can source 2**64 addresses -- keying on the full address
would let one attacker both evade the throttle and flood the table with
single-use entries. mux_sockaddr::source_key() returns the 4-byte v4 address
or the 8-byte v6 /64 prefix; differing lengths keep the families from
colliding.
The table must not become the resource it protects: a fixed 512-slot array
scanned linearly, no allocation and no growth, consulted only on login
attempts (already bounded by max_preauth_per_site). When full, eviction takes
the LEAST suspicious entry (fullest bucket, oldest as tie-break) so table
pressure never costs us the record of an active attacker.
The dorm/NAT cost is real and deliberate: an exhausted bucket briefly refuses
legitimate players from a shared address, including ones typing the correct
password. It is bounded (continuous refill; seconds, not a lockout), the
default is generous relative to how often real players mistype, and admins can
widen or disable it. There is deliberately no "this source already has an
authenticated session" exemption -- it would read as dorm-friendly while
handing a full bypass to an existing player going after someone else's account.
Verified on a live netmux at limit 3 / period 600s: guesses 1-3 rejected
normally, 4-6 refused with the wait message, each from a FRESH connection --
reconnecting no longer buys a fresh batch. At limit 5 / period 20s the budget
demonstrably refills. Both the new CON/THR line and the earlier NET/SITE
pre-auth line confirmed to emit. Regression: smoke 1319/1319, stress 8/8,
netaddr 46/46 (+7 source_key tests), ganl 14/14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Multi-connection is normal in MUSH -- a dorm or NAT puts many unrelated
players behind one address, households share one, and a single player
commonly sits on five or more alts at once -- so a per-IP cap on *total*
connections would break real play. That is why 36 years of TinyMUX shipped
only allow/deny site ACLs and never a per-IP count.
What is not normal is many connections from one address sitting at the login
prompt. Legitimate multi-play authenticates promptly; a slowloris holding
half-open sockets to exhaust the descriptor table never authenticates at all.
So count only connections without DS_CONNECTED.
max_preauth_per_site (default 2, 0 = unlimited, CA_GOD / CA_WIZARD)
Checked in the GANL accept path just before the new DESC joins
g_descriptors_list, so a refused connection never occupies a descriptor slot.
Refusal writes a short "try again in a moment" line raw via SOCKET_WRITE --
the route fcache_rawdump uses -- because the DESC is torn down immediately
and the normal output queue would never flush; it logs under
LOG_NET|LOG_SECURITY.
mux_sockaddr::operator== includes the source port, which differs for every
connection from one peer, so add mux_sockaddr::same_address() for family +
address-bytes equality. Cross-family (v4 vs v4-mapped v6) deliberately does
not unify: it can at most double a hostile client's allowance, and both forms
of one peer cannot arrive on the same listener.
Self-healing by construction -- a slot frees the instant a peer authenticates
or conn_timeout (120s) reaps it -- so a legitimate collision costs one retry,
never a lockout.
Verified with the default of 2: a third pre-auth socket from 127.0.0.1 is
refused with the explanatory message; authenticating a pending connection
immediately frees a slot; and 12 authenticated sessions from that same single
address were all accepted, leaving the dorm / household / five-alts case
untouched. Regression: smoke 1319/1319, stress 8/8 (its 16 simultaneous
logins from one address still pass), netaddr 39/39 (+6 new same_address
tests), ganl 14/14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backstop for the pool_alloc -> fatal OutOfMemory cliff
(docs/survey-resource-defenses.md gap #3). pool_alloc/pool_alloc_lbuf
take memory from the system only on the slow path (freelist empty) and
never return it, so the process's pool footprint is its PEAK CONCURRENT
buffer count. New pool_memory_limit config (bytes; K/M/G suffixes;
0=unlimited) caps that footprint: on the slow path, crossing it trips
the same cooperative per-command abort the wall-clock alarm uses
(alarm_clock.alarmed, reachable from alloc.cpp — both are libmux) so a
runaway command unwinds and frees its buffers back to the freelist
instead of the server aborting on OutOfMemory. Composes with the JIT
wall-clock alarm (same flag). cf_pool_limit pushes the value to the
libmux global on load and @readcache reload.
Off by default, and honestly so: measurement showed the cliff is HARD
TO REACH, which is the point — the interpreter's alloc-and-free
discipline, bounded nesting (func_nest_lim/nStackLimit), and freelist
reuse keep peak pool footprint tiny (a short session didn't cross even
256KB). And no non-zero default is safe across deployments (a 256MB
VPS vs a 32GB host want different ceilings), so the admin sizes it.
Note: the nested-iter OS-OOM crash seen while building the JIT alarm is
a DIFFERENT path (JIT arena/guest growth, bounded by max_dispatch=10M
by default), not this one.
Verified default-off is a true no-op: smoke 1319/1319, oracle 9/9,
jit_diff 400/0, stress 8/8; the trip path is wired (server survives +
stays responsive when the budget is crossed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hardening: the per-connection pending-input backlog (docs/survey-
resource-defenses.md). Two parts, informed by measurement.
Correctness (the real fix): save_command now owns d->input_size,
incrementing it per enqueued line by the exact byte length it queues.
Previously telnet.cpp incremented it in a batch (nInputBytes) while
the dequeue decremented by cmd.size() — a latent drift — and the
websocket save_command path never touched it at all. Ownership in
the single choke point keeps the counter exactly in step with the
dequeue and covers telnet + websocket uniformly.
Anti-runaway backstop: new input_limit config (default 16*LBUF =
512KB, <=0 disables) caps per-connection pending-input bytes; excess
is dropped with input_lost accounting and hysteresis on
d->input_throttled to keep a flood from spamming the log (which would
just move the amplification into logging).
Deliberately a HIGH backstop, not a primary throttle: input is
drop-sensitive (a dropped line silently corrupts a user's paste — a
code attribute is one legit ~32KB line; @edit/multi-attribute uploads
are legit bursts), unlike output. Measurement shows the app queue
does not grow unbounded anyway — the single-threaded read/drain
cadence + TCP flow control bound it (input_size peaked at ~44 bytes
under a sustained flood with the drain throttled to command_quota 1).
The proper form is read-side backpressure (stop reading when
backlogged; TCP holds the excess with zero loss), deferred.
Verified: two back-to-back 30KB attribute pastes store intact with
zero drops; smoke 1319/1319; stress harness 8/8; server responsive
throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guard-lift campaign's final change (docs/plan-jit-evalbracket-lift
.md): eval brackets are JIT-compiled by default. Per the #1001 review
checklist, the same commit removes the netmux.conf soak opt-in (that
file ships) and retires jiteval() — with the default on, the
production route reaches everything the gate-bypass existed for.
Harnesses moved off default-off assumptions:
- jit_diff's I-side conf sets jit_eval_brackets 0 explicitly (relying
on the default would compare JIT against JIT).
- The q-register oracle now compares PRODUCTION evaluation across two
workspaces (default conf = JIT vs explicit 0 = AST) with u()
carriers and a jitstats canary; all nine shapes green.
The flip surfaced three latent items — Makesmoke bakes the smoke
database by EXECUTING setup commands under the conf default, a third
evaluation context never before run toggle-on:
1. #$ (switch token) was unimplemented in the lowerer and fell
through as literal text: @switch actions like [idiv(#$,2)]
computed idiv("#$",2) = 0. The lowerer now bails compilation on
#$, preserving AST semantics.
2. fdepth()/fcount() read func_nest_lev/func_invk_ctr, which
compiled code does not maintain (native lowering flattens the
nest): they read 0. The lowerer bails on both. The wider
function_recursion_limit design question (cost guard vs semantic
contract) is filed as #1002 — not a blocker, as the divergence
direction is fail-open into more capability.
3. The Smoke classifier greped case-sensitively for 'Failed';
nested_depth.mux's lowercase "failed" message slipped through and
the suite reported ALL PASSED around a real failure. Now -ci.
Final matrix: smoke 1318/1318 with the new default AND with the
toggle explicitly off; the smoke.flat re-bake is byte-identical to
the pre-flip bake; oracle 9/9 on the production route; sweeps
standard and brackets+utf8+longreg(SEED=7) both 400/0 LOGIC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guard-lift itself (docs/plan-jit-evalbracket-lift.md, Phase 4):
jit_can_handle() now admits terminated [...] eval brackets when the new
jit_eval_brackets config directive is on (cf_bool, CA_GOD, default
OFF — production behavior unchanged). Unterminated brackets always
bail; EV_NOFCHECK text is gated at the call site (literal passthrough
that the lowerer doesn't model).
Harness upgrades required to make the toggle-on green check
meaningful:
- jit_diff J/I sides split into separate processes: the I side always
runs with the toggle off, keeping the eval-bracket bail (the true
production interpreter route, production flags) as the oracle. An
asteval({...})-forced in-process I side was tried first and
manufactured ~113 false LOGIC divergences — fun_asteval trims a
trailing space after an empty-yielding bracket that production
preserves (filed as #987).
- JITDIFF_BRACKETS=1 mode: bracket-wrapped corpus (embedded/adjacent/
pure shapes), toggle in the J-side conf only, and a canary that
fails fast if the toggle didn't take.
- SMOKE_EXTRA_CONF passthrough in the smoke runner for whole-suite
toggle-on runs.
Results: with the toggle ON, letq_fn TC002 (the historical INNERINNER
failure) and the localize scoping cases route through the JIT and
pass; the bracket sweep is 400/0 LOGIC. The toggle-on smoke run also
witnessed two PRE-EXISTING production JIT bugs (bracket-independent,
reproduced bracket-free on today's default route): #988 maxArgsParsed
comma-catenation dropped (sha1(abc,def) -> sha1("abc")) and #989
tier2 wordpos() UTF-8 position miscount. Both block the Phase 5
default-on flip, not this landing.
Toggle OFF (default): oracle 7/7, smoke 1310/1310, standard sweep
400/0 LOGIC — byte-identical, cleanly gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second wave: convert manual alloc/free pairs in cque (1), db_rw (4),
conf (5), db (2), engine_com (6), help (7), levels (2), predicates (5),
player (7), funmath (8). Eliminates ~80 explicit free_lbuf calls
including multi-exit error paths in getboolexp1 (5 frees → 0),
get_list, AnnounceConnect/Disconnect, and the eight NOEVAL function
variants (cand/cor/firstof/allof and bool variants).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New wizard-only cachestats() function returns hits/misses/entries/size
as space-separated integers for programmatic cache measurement.
Fix cf_display to handle cf_size type so config(cache_max_size) works.
New cachestats_fn.mux smoke tests exercise cold reads, hot reads,
writes, and log baseline numbers for later stage comparison.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Story 1a — cache_max_size with K/M/G suffixes, -1 for unlimited, default
raised from 1 MB to 256 MB. @list cache shows human-readable sizes.
Story 1b — cache_preload_depth controls BFS depth for preloading adjacent
rooms. Unlimited mode: GetAll for player/location, GetBuiltin across BFS
neighbors. Bounded mode: GetBuiltin for player/location only, no BFS.
Move path preloads destination synchronously, defers BFS neighbors via
scheduler to avoid latency on look_in().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminate the ISOUTOFMEMORY macro that unconditionally aborted on allocation
failure. Each of the 23 call sites now handles OOM appropriately:
- Fatal sites (buffer pools, db array, anum table): mux_assert or OutOfMemory
- Recoverable sites (queue, mail, commands, guests, vattrs, config, restart,
forward lists): log the failure and return gracefully
Also fix g_dump_child_pid portability: volatile pid_t -> volatile sig_atomic_t
with explicit casts in ganl_adapter.cpp.
Close integer overflow issue as false alarm (getstring_noalloc uses a bounded
static buffer, so nBuffer+1 cannot wrap).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With LBUF_SIZE now at 32768, the old default of 16384 could cause
a single evaluation result to exceed the output limit and trigger
immediate drops. Double it to 65536 (2*LBUF_SIZE) to maintain
the same headroom ratio as before.
Update the comment in queue_write_LEN — with GANL, process_output
always fully drains the queue, so the call is never unproductive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Default 1000, 0 = unlimited. Wizard-owned objects are exempt.
Enforced in atr_add_raw_LEN with a fast cache_get existence check
(updates are free) and SQLite COUNT(*) for new attributes only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The English article rules were never changed by anyone, so hardcode
them into a Ragel-generated DFA (art_scan.rl) instead of parsing
PCRE regexps from netmux.conf at startup. Removes ArtRuleset,
cf_art_rule, and the article_rule config directive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The modular architecture (netmux.exe, libmux.dll, engine.dll, etc.)
with static CRT (/MT) gave each module its own CRT heap. FILE* handles
from mux_fopen (in libmux) crashed when used by stdio functions in
other modules — fclose in write_pidfile, fgets in cf_include, etc.
Switch all 11 vcxproj files to /MD (shared CRT DLL) so all modules
share one CRT instance. Ship msvcp140.dll, vcruntime140.dll, and
vcruntime140_1.dll in the binary distribution.
Also add mux_fclose to libmux as good hygiene (pairs with mux_fopen),
and replace all cross-module fclose calls. This change is safe on Unix
where everything links into one process.
Add Startmux.bat as a replacement for Startmux.wsf since Windows
Script Host is no longer associated by default on modern Windows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Config options in conftable:
- lua_instruction_limit (default 100000) — max VM instructions per call
- lua_memory_limit (default 1048576) — max bytes per execution
Wiring: mudconf fields → engine_com.cpp calls SetLimits() on module
discovery → CLuaMod::SetLimits() updates m_nInsnLimit/m_nMemLimit.
Also includes @lua command and @list lua from previous batch (were
in the same working tree).
525/525 smoke tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mudstate lives in engine.so (loaded via dlopen at runtime), so the
driver binary cannot reference it at link time — stricter linkers
reject the unresolved symbol. Move the StubSlave lifetime pointer
to a driver-side global in driverstate.h. Engine-side code that
needs mux_ISlaveControl now obtains its own proxy on demand through
mux_CreateInstance via libmux, matching the existing pattern used
for CID_QueryServer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Four new features identified from the TinyMUSH/PennMUSH/RhostMUSH survey:
MSSP (MUD Server Status Protocol, telnet option 70):
- Server sends structured key-value data (NAME, PLAYERS, UPTIME, PORT,
CODEBASE, FAMILY) to MU* directory crawlers on IAC DO MSSP
- Stateless response via send_mssp() in telnet.cpp using g_dc config basket
- start_time_utc added to DRIVER_CONFIG for uptime calculation
GMCP (Generic MUD Communication Protocol, telnet option 201):
- Protocol negotiation: server offers WILL GMCP, tracks gmcp_enabled per DESC
- Inbound: GMCP subneg queued as synthetic "\x01GMCP" command, dispatched
to handle_gmcp() which fires A_GMCP attribute with %0=package %1=json
- Outbound: gmcp(<player>, <package>, <json>) softcode function sends
GMCP frames to all GMCP-enabled descriptors via SendGmcp COM method
- Full COM architecture: mux_IConnectionManager::SendGmcp in driver,
send_gmcp() bridge in engine, CConnectionManager impl in modules.cpp
@protect (player name reservation):
- @protect[/add] <name>, @protect/del <name>, @protect/list [<player>]
- A_PROTECTNAME attribute (234) stores space-separated protected names
- protectname_check() hooked into create_player() and do_name()
- max_name_protect config param (default 5)
benchmark(<expression>, <iterations>):
- FN_NOEVAL, CA_PUBLIC, 10000 iteration cap
- Uses clock_gettime(CLOCK_MONOTONIC) / QueryPerformanceCounter
- Returns elapsed seconds as floating point
Also fixes: add unicode_tables.c to libmux.so LIBMUX_C_SRC (resolves
pre-existing tr_tolower_sbt etc. link errors).
505/505 smoke tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>