Commit graph

11270 commits

Author SHA1 Message Date
Stephen Dennis
823a9291ee
Merge pull request #2226 from brazilofmux/fix/2223-ship-windows-build-docs
docs(#2223): ship current Windows build instructions in the source distribution
2026-08-10 23:25:12 -06:00
Stephen Dennis
07586ea760
Merge pull request #2225 from brazilofmux/fix/2222-stale-companion-checksums
fix(#2222): clear companion checksums when their artifacts are cleaned
2026-08-10 23:24:57 -06:00
Stephen Dennis
66991dcbe5
Merge pull request #2224 from brazilofmux/fix/2221-stage-pcre2-runtime
fix(#2221): stage the vcpkg PCRE2 runtime beside the build output
2026-08-10 23:23:51 -06:00
Stephen Dennis
86306410c4 docs(#2223): ship current Windows build instructions in the source dist
docs/building.md sits outside mux/, so the Windows source archive
never carried it.  The Windows document that does ship, readme.txt,
was last touched in July 2012: it pointed builders at
mux2.14/src/netmux.dsw, described the binaries as Intel 9.1 builds
for Windows 98 through Vista with a separate win64 subdirectory, and
gave unpacking instructions for PKZip, JAR and tar/gzip.  The
archives are .zip, x64 only, and that .dsw has since been deleted
outright by win32/TOC.src.removed.

Rewrites the distribution and build sections against what the tree
actually ships: the vcpkg manifest install with the shallow-clone
and first-run caveats, the vcpkg_installed layout and $(VcpkgDir)
override, netmux.sln under Visual Studio or MSBuild, the file list
to stage into game/bin, and why the redistributable DLLs appear in
the binary distribution but not in a source build.

Two corrections found while checking each claim: cscript
startmux.wsf has been superseded by Startmux.bat, which says so in
its own header comment; and mux.config is on neither Windows TOC, so
step 4 was directing operators at a file the distribution does not
contain.

The database section is left alone.  It is older still, but nothing
in it was verified here and it is outside what this issue asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:06:51 -06:00
Stephen Dennis
dfe6c3d7d8 fix(#2221): stage the vcpkg PCRE2 runtime beside the build output
The x64-windows triplet links dynamically, so engine.dll imports
pcre2-8.dll (pcre2-8d.dll in Debug).  #2212 pointed the include and
library paths at vcpkg_installed but nothing copies the runtime next
to the output, and the documented setup does not turn on vcpkg's
MSBuild integration.  A clean checkout therefore linked cleanly and
then failed at LoadLibrary with ERROR_MOD_NOT_FOUND.

Verified both directions with a LoadLibraryEx probe against
bin_release and bin_debug: error 126 with the DLL absent, success
with it staged, in both configurations.

Missing runtime is an error rather than a skipped copy.  If the link
found the import library the DLL is there too, so a miss means the
vcpkg tree is wrong -- and staying quiet reproduces exactly the
silent-unloadable-output failure this fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:03:37 -06:00
Stephen Dennis
e4ff0b9994 fix(#2222): clear companion checksums when their artifacts are cleaned
The blobs archive and APPLY.txt are both conditional on there being
changed binary blobs, and dounix.sh removes them up front so a rerun
that finds none leaves neither behind.  It did not remove their
.sha256 files, so those survived from the previous run.

A release operator staging by glob then signs and uploads checksums
for artifacts that no longer exist.  #2214 added the APPLY.txt
checksum, which widened an existing gap on the blobs checksum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:01:22 -06:00
Stephen Dennis
a54d379f09
Merge pull request #2220 from brazilofmux/fix/2212-pcre2-vcpkg
fix(#2212): PCRE2 comes from vcpkg, like grpc
2026-08-10 20:41:42 -06:00
Stephen Dennis
b05539f86a fix(#2212): PCRE2 comes from vcpkg, like grpc
The Windows build took PCRE2 from a hand-built out-of-tree directory
reached through $(Pcre2Dir).  engine.vcxproj honoured that property for
its headers but hardcoded ..\..\src\pcre2\{Release,Debug} for its
libraries, so -p:Pcre2Dir= applied to one half and not the other and the
link failed with LNK1104 in any tree where PCRE2 was not at the default.

Declare pcre2 in mux/vcpkg.json instead, alongside grpc and
nlohmann-json, and point netmux, libmux and engine at $(VcpkgDir).  All
three now read include and library paths from the same place.  Note
vcpkg's layout is lib/ for release and debug/lib/ for debug, not
Release/ and Debug/.

PCRE2's JIT is required -- funceval2.cpp calls pcre2_jit_compile -- and
comes automatically: the port's platform-default-features pulls in jit
everywhere except emscripten and iOS.

Ship mux/vcpkg.json in both source TOCs.  It was in neither, so the
source distribution carried no statement of its own dependencies; with
PCRE2 moving to vcpkg, the file that declares it has to travel with the
source or an unpacking reader is worse off than before.

Unix is unaffected -- configure finds system PCRE2 via pkg-config.

Verified on hatsuhara: Release and Debug both build clean with no
hand-built PCRE2 present anywhere on the box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:39:31 -06:00
Stephen Dennis
ce21a572c3
Merge pull request #2219 from brazilofmux/fix/2214-dounix-apply-sha256
fix(release): generate .sha256 for APPLY.txt (#2214)
2026-08-10 20:05:28 -06:00
Stephen Dennis
73dc1749d0 fix(release): generate .sha256 for APPLY.txt (#2214)
dounix.sh already produced APPLY.txt when companion blobs ship, and
every release cut has been checksumming it by hand.  Add the missing
sha256sum step (same blob-gate as BLOBS_FILE) and reword the closing
reminder so checksums and signatures cover the same artifact set.
2026-08-10 19:54:50 -06:00
Stephen Dennis
e97992b1d7
Merge pull request #2218 from brazilofmux/fix/timefmt-invalid-secs
fix(softcode): timefmt rejects unparseable seconds argument
2026-08-10 19:18:23 -06:00
Stephen Dennis
e315994177 fix(softcode): timefmt rejects unparseable seconds argument
SetSecondsString was ignored on failure, so a default-constructed
CLinearTimeAbsolute (FILETIME zero / 1601-01-01 UTC) was formatted.
In western zones that looked like "Sunday, December 31, 1600, 05:00 PM"
— e.g. timefmt(..., #-1 INVALID DATE) after a failed convtime().

Return #-1 INVALID DATE like convsecs/convtime on bad input. Smoke
TC004 pins the case.
2026-08-10 19:18:00 -06:00
Stephen Dennis
204e503199
Merge pull request #2217 from brazilofmux/docs/2215-readme-direct-websocket
docs: browser client connects directly to netmux — correct #2215's README
2026-08-07 22:12:10 -06:00
Stephen Dennis
4e94bb75aa docs: the browser client connects DIRECTLY to netmux — correct #2215's README
The README merged in #2216 claimed browsers cannot reach netmux and
that both transports require Hydra.  Wrong on the central point:
netmux serves WebSocket natively on its ordinary game ports
(mux/src/websocket.cpp, RFC 6455) with first-byte protocol detection
sharing each port between telnet and WebSocket (#1074/#2193,
proto_detect_window), and the handshake accepts both /wsclient and /
— which is exactly the path js/connection.js dials.  The minimal
browser deployment is netmux plus static files, no extra process;
Hydra is the OPTIONAL layer for session resume, multi-game links,
stored credentials, and gRPC-Web.

The error came from concluding absence out of a truncated grep: the
file listing was piped through head and mux/proxy's matches filled the
window before mux/src/websocket.cpp appeared.  A truncated listing is
not a complete listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:12:06 -06:00
Stephen Dennis
02c4a6acca
Merge pull request #2216 from brazilofmux/docs/2215-web-client-readme
docs(#2215): README for the browser client
2026-08-07 22:06:04 -06:00
Stephen Dennis
6e6dc6d611 docs(#2215): README for the browser client
client/web was a complete HTML5 client with nothing self-describing in
it — and players have started asking publicly whether TinyMUX has
browser support, with no page to point at.  Covers what it is, why
both transports go through Hydra (browsers cannot open raw TCP), which
transport is proxy-agnostic, deployment (static hosting + hydra.conf,
the mixed-content and cors_origin rules), the test_web.js harness and
its keep-modules-dependency-free convention, and the 2.13-vs-2.14
status.

Facts checked against the sources: transport split per
connection.js/hydra_connection.js (including the /h-command list and
gRPC-Web fallback), listener types and CORS default from
hydra.conf.example, hydra's standalone make from mux/proxy/Makefile,
localStorage persistence from settings.js, and `node test_web.js`
run green before writing (all PASS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:05:50 -06:00
Stephen Dennis
ad4c8d829c docs: the cycle count drifted as the cycle ran on
"seventy-odd changes" was accurate when the UNRELEASED section was opened
and is not now: 91 distinct issues across 156 non-merge commits touching
212 files.

Only the number is touched.  The narrative is deliberately left as it is
-- it was written from the work as it happened and tells that story well;
rewriting it after the fact to be exhaustive would make it worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:34:43 -06:00
Stephen Dennis
44c374ee5a Prepare 2.14.0.11 ALPHA release
Version bump across the three files that carry it: mux/include/_build.h
(MUX_VERSION, MUX_RELEASE_DATE) and OLD_BUILD/NEW_BUILD in dounix.sh and
dowin32.sh, which move 9->10 and 10->11 respectively.

CHANGES.md: close the 2.14.0.11 section that has been maintained through
the cycle, and add the client TCP_NODELAY work (#2204, #2205, #2206,
#2207) to Clients, which had #2196 for tf but not the other four native
clients.  Recorded with the wire measurement rather than by inspection --
two segments before the fix, five after, and no observable difference on
loopback at all.

Build-only changes are left out of the changelog as they have been all
cycle (#2177, #2180, #2185, #2211).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:31:13 -06:00
Stephen Dennis
d15b1dc3f8
Merge pull request #2213 from brazilofmux/fix/2211-win32gui-setfilepath
fix(#2211): win32gui defines CredStore::SetFilePath so it links again
2026-08-07 14:50:49 -06:00
Stephen Dennis
df1d10d6e0 fix(#2211): win32gui defines CredStore::SetFilePath so it links again
#1891 added a CredStore::SetFilePath() call to the shared
client/console/src/world.cpp and implemented it in console's
credential_store.  win32gui compiles that same world.cpp but has its own
Credential Manager backend, which had no such function, so win32gui.exe
has failed to link with LNK2019 since 2026-07-31.

Define it here as a documented no-op.  Credential Manager is keyed by
target name, not backed by a file, so there is genuinely no path to set.

Deliberately NOT consolidating onto console's credential_store.cpp, which
is cross-platform and a strict superset on Windows: the two backends use
different Credential Manager target prefixes -- console "HydraConsole:"
versus win32gui "Titan:".  Switching would leave every password the GUI
client has already saved stranded under the old target name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:42:33 -06:00
Stephen Dennis
677276cf81
Merge pull request #2209 from brazilofmux/fix/2205-console-nodelay
console/win32gui set TCP_NODELAY on connect completion (#2205).
2026-08-07 13:36:20 -06:00
Stephen Dennis
79d9ccf5cf
Merge pull request #2210 from brazilofmux/fix/2206-android-nodelay
Titan Android sets tcpNoDelay on the raw socket (#2206).
2026-08-07 13:36:06 -06:00
Stephen Dennis
aa111a3591
Merge pull request #2208 from brazilofmux/fix/2207-ios-nodelay
Titan iOS sets TCP noDelay on both parameter arms (#2207).
2026-08-07 13:36:00 -06:00
Stephen Dennis
63ae742168 fix(#2206): Titan Android sets tcpNoDelay on the raw socket
Java's Socket defaults TCP_NODELAY to false and MudConnection never
touched it, so the platform enabled Nagle and nothing overrode it —
every send after the first in a burst (a trigger, a macro) waited on
the ACK of its predecessor, worst on exactly the high-RTT radio links
an Android client lives on.  Android half of #2204.

Set on the RAW socket before connect and before the TLS wrap:
sslContext.socketFactory.createSocket(raw, ...) layers over raw and
inherits its options.

NOT build-verified: no Android SDK/gradle/kotlinc on this box (that
tooling lives on Hatsuhara).  The edit is the one-property fix #2206
specifies at the site it names.  Wants a gradle build before the issue
closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:58:48 -06:00
Stephen Dennis
3b69fa7eeb fix(#2205): console/win32gui set TCP_NODELAY on connect completion
The IOCP session socket ran with Nagle enabled — a multi-write burst
(trigger, macro, keybinding) serialized at the peer's delayed-ACK
cadence, up to ~40ms per write after the first.  win32gui compiles the
same connection.cpp, so one fix covers both clients.  Windows half of
#2204.

Placed on the ConnectEx COMPLETION, immediately after
SO_UPDATE_CONNECT_CONTEXT — before that call the socket is not fully
associated and option calls do not behave (mirrors the server IOCP
engine's setSocketOptions ordering).  Non-fatal by design: a latency
hint failing is no reason to drop a working connection.  The server's
IOCP engine treats the same failure as fatal only because its call
site also configures the listener.

NOT build-verified: MSVC/IOCP code, no Windows toolchain on this box.
The edit is byte-for-byte the fix specified in #2205 at the site it
names, and TCP_NODELAY/IPPROTO_TCP come from the winsock2.h/ws2tcpip.h
pair connection.h already includes.  Wants a Windows build and ideally
the #2196-style option-readback confirmation before the issue closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:58:26 -06:00
Stephen Dennis
5ba023e419 fix(#2207): Titan iOS sets TCP noDelay on both parameter arms
NWProtocolTCP.Options defaults noDelay to false, and MudConnection
built its NWParameters without touching TCP options on either arm —
the plain path took bare NWParameters.tcp defaults and the TLS path
configured TLS options only.  So the platform enabled Nagle and
nothing overrode it, on exactly the high-RTT radio links where a
multi-write burst (trigger, macro, script) serializing at delayed-ACK
cadence is most visible.  iOS half of #2204.

One NWProtocolTCP.Options with noDelay = true, passed to both arms via
NWParameters(tls:tcp:).

Verified on macOS/Xcode 26.6: Titan app target builds; a scratch
Network.framework probe confirms defaultProtocolStack.transportProtocol
carries noDelay=true through BOTH constructions and that both OLD
constructions read back false (the defect, demonstrated); TitanCore's
45 SPM tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:58:04 -06:00
Stephen Dennis
f82edc55c4
Merge pull request #2203 from brazilofmux/fix/2199-restart-argv
Preserve -p and -e command-line values across @restart (#2199).
2026-08-07 11:41:57 -06:00
Stephen Dennis
c502af6192 fix(restart): preserve the -p and -e command-line values across @restart (#2199)
do_restart() rebuilds its exec argv from mudconf.pid_file and
mudconf.log_dir.  Both were read in three places and written in NONE:

    predicates.cpp   the execl                                  <- read
    engine_com.cpp   GetConfig -> DRIVER_CONFIG.pid_file         <- read
    engine_com.cpp   GetConfig -> DRIVER_CONFIG.log_dir          <- read

There is no `pid_file` or `log_dir` config directive either, so nothing in
netmux.conf could populate them.  Both are driver-owned CLI values --
driver.cpp says so outright ("pid_file is driver-owned -- set from CLI or
default") -- and the engine had no way to learn them.  So every @restart on
every site exec'd with an empty -p and -e.

Impact is quiet, which is why it lasted: a site running

    netmux -p /var/run/mux/netmux.pid

has its pidfile revert to the default netmux.pid in the working directory
after the first restart, and stops maintaining the configured path.  The
pid inside the stale file stays correct, because execve preserves it, so
nothing breaks until something else moves.  Same for -e.

The fingerprint is visible in ps on any restarted 2.14: `-p` with nothing
after it.

Fix follows #817's precedent exactly -- driver-owned strings the engine
needs, handed over at bridge init:

  - g_driver_pid_file / g_driver_log_dir replace two file-static CLI
    variables in driver.cpp (pErrorBasename was also a poor name for
    something that is a log directory).  Globals for the same reason
    g_version is one: the CDriverControl bridge is a different TU.
  - mux_IDriverControl gains GetInvocationPaths.  IID bumped D4D6 -> D4D7;
    the comment now records both bumps and why a vtable change needs one.
  - conn_bridge_init caches them into mudconf.  Safe there because
    Startup() runs after LoadGame(), so cf_init() cannot wipe them
    afterwards.  Pointers are stored, not copied: they are driver-owned
    storage that lives for the process, and nothing in the engine frees or
    reassigns either field.
  - CScriptDriverControl implements it too, returning empty strings.
    muxscript writes no pidfile and has no @restart; an invented default
    would only be wrong somewhere later.

The argv is now built dynamically and omits an option it has no value for.
-c, -p and -e are all CLI_REQUIRED, so a bare flag makes the successor log
"Option 'x' requires an argument, but none was found."  Populating the
fields alone would have left that warning for any site that supplies only
one of the two -- which is what test-scenario showed, since run.sh passes
-p and not -e.

Verified on a live restart, reading the successor's argv (execve replaces
argv and preserves the pid, so this is direct evidence):

    boot:     ./bin/netmux -c netmux.conf -p custom-name.pid -e logs
    restart:  netmux -c netmux.conf -p custom-name.pid -e logs
    ok - successor kept -p custom-name.pid
    ok - successor kept -e logs
    ok - no missing-argument warning in the log

Catch-verified by removing only the bridge caching:

    restart:  netmux -c netmux.conf -p
    not ok - successor lost the -p value
    not ok - successor lost the -e value
    not ok - successor logged a missing-argument warning:
          15:Warning: Option '-p' requires an argument, but none was found.

tests/scenario/restart_helpers.py gains the assertion; it already pays for
a restart, so this costs no extra cycle.  It skips if the server under test
was not started with -p.

Clean rebuild, since the IDriverControl vtable changed.

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>
2026-08-07 11:39:34 -06:00
Stephen Dennis
05412a6a13
Merge pull request #2202 from brazilofmux/fix/2195-2196-client-latency
tf: honour IAC GA for prompts, set TCP_NODELAY (#2195, #2196).
2026-08-07 11:12:17 -06:00
Stephen Dennis
104c6d48d1
Merge pull request #2201 from brazilofmux/chore/2192-followups
Delete dead GanlAdapter::boot_stubslave(); ignore root netmux.log.
2026-08-07 11:12:06 -06:00
Stephen Dennis
619f735cb2 fix(tf): honour IAC GA / IAC EOR so prompts display immediately (#2195)
client/tf's telnet FSM had no handling for IAC GA (249) or IAC EOR (239)
anywhere.  Both fell into the IAC state's `default` arm and were silently
discarded, so a prompt terminated by GA and no newline stayed in
line_buf_ until check_prompt()'s 250ms partial-line timer guessed at it.

TinyMUX's @program prompt is exactly that shape --
queue_write_LEN(d, T(">\377\371"), 3), i.e. ">" IAC GA -- so the
interactive flow where prompt responsiveness matters most was the one
paying the delay.  The timer is the right safety net for servers that send
bare unterminated prompts; GA exists so a client that gets one does not
have to guess.

Both commands now set a flag that check_prompt() consumes, so the prompt
still reaches its single consumer (Terminal::set_prompt + Hook::PROMPT) by
exactly the path it did before -- just without the quarter second.

Three details worth stating:

- The flag is consumed unconditionally at the top of check_prompt(), even
  when there is nothing to deliver.  Left set, it would make some later
  unrelated partial line fire instantly and attribute a prompt boundary to
  a server that never claimed one.

- The GA path deliberately skips the last_prompt_ dedup.  The timed path
  needs it, because it is re-evaluated every main-loop pass while the same
  partial line sits in the buffer.  GA arrives once per prompt, and a
  server that sends the same prompt text twice has genuinely prompted
  twice.

- line_buf_ is not cleared, matching what the timed path already does, so
  current_prompt() still works on a world switch.

EOR is handled alongside GA for correctness if it is ever negotiated;
today tf answers DONT to TELOPT_EOR, so servers use GA.

Measured with tf under a pty against a server that sends a prompt
terminated by IAC GA:

    before:  prompt displayed 251 ms after the server sent it
    after:   prompt displayed   0 ms after the server sent it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:04:52 -06:00
Stephen Dennis
2f1399c80d fix(tf): set TCP_NODELAY on the session socket (#2196)
client/tf never set TCP_NODELAY, so the client side of every session ran
with Nagle enabled.  Client companion to #2194, which was the same gap in
the server's POSIX engines.

A single typed command is immune either way: send_line() assembles command
+ CRLF into one buffer and issues one write().  But anything that sends
several lines in one event-loop pass -- a trigger firing off a match, a
macro or keybinding bound to multiple commands, a scripted burst, a
speedwalk -- produces back-to-back small writes, and Nagle makes each one
after the first wait for the ACK of its predecessor, which the peer's
delayed-ACK timer can hold for up to ~40ms.  Against a remote server the
burst then leaves the client at ACK cadence instead of departing together.

Invisible on loopback, which is why local testing never showed it.

Set after the connect loop rather than at either `break`, so both paths
(immediate connect and EINPROGRESS + poll) are covered by one call.
Non-fatal: a latency hint failing is no reason to refuse a connection that
otherwise works.

Verified by strace'ing tf under a pty against a scratch netmux:

    setsockopt(4, SOL_TCP, TCP_NODELAY, [1], 4) = 0

Catch-verified: with the change reverted, tf makes no setsockopt call at
all on the connect path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:04:12 -06:00
Stephen Dennis
3845135669 chore: delete dead GanlAdapter::boot_stubslave(), ignore root netmux.log
boot_stubslave() had no callers.  The live path is
CPlatform::BootHelperProcess() via driver.cpp; this was a second,
unreachable copy of the same fork/socketpair/exec sequence -- carrying the
identical missing-FD_CLOEXEC bug that #2192 just fixed in the live one.
Leaving it would have left the defect sitting there for whoever revived
the function.

muxscript keeps its own boot_stubslave_helper() in mux/script/mux_main.cpp;
that one is live and unrelated.  It has the same missing CLOEXEC, but
muxscript has no exec-in-place restart path, so nothing leaks there and it
is left alone.

Also ignore a repo-root netmux.log.  A scratch netmux run from the repo
root drops one there; mux/game/*.log and testcases/netmux.log were already
covered and the root was not, which is how one slipped into the #2192 PR
tree and needed a follow-up commit to remove.

No behaviour change.  Build is clean -- no newly-unused statics fell out
of the deletion.

make test: 36 targets, 34 passed, 2 skipped (NLS), 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:57:07 -06:00
Stephen Dennis
40cbfe33ac
Merge pull request #2200 from brazilofmux/fix/2192-restart-helper-leak
Stop leaking a helper generation per @restart (#2192).
2026-08-07 10:50:17 -06:00
Stephen Dennis
5aff7c329f chore: drop accidental netmux.log from the #2192 PR tree
Scratch repro log that snuck into the branch; not part of the fix.
2026-08-07 16:50:04 +00:00
Stephen Dennis
a3f37b8da0 fix(restart): stop leaking a helper generation per @restart (#2192)
On a --enable-stubslave build, every @restart leaked the previous helper
generation: the old stubslave survived ALIVE FOREVER, the old slave became
a permanent zombie, and the parent's end of the old socketpair was carried
into the new process image.  It accumulated -- three restarts meant three
live orphan stubslaves, three zombie slaves and three dead fds, forever.

Root cause: do_restart() runs inside engine.so, where final_stubslave() is
a deliberate no-op whose contract is "the driver handles stubslave cleanup
after engine shutdown".  do_restart() never returns to the driver; it
execl()s in place, so that teardown is unreachable on the restart path and
no shutdown request is ever sent.  (An strace of the stubslave across a
full @restart shows zero syscalls.)

Two independent defects, and fixing either one alone still leaks:

1. The parent's channel fd was not FD_CLOEXEC, so the exec carried it into
   the new image, which knows nothing about it and rebuilds
   stubslave_channel_ fresh.  The old stubslave's EOF backstop never fired
   and it parked in poll() forever.  Now CLOEXEC, so the exec itself closes
   the channel and the old stubslave exits through the clean EOF path it
   already had.  This covers every exec, not only do_restart's -- panic
   restart included.  It is also what GANL's own helper spawn has always
   done (slave_spawn_posix.cpp, "Parent endpoint should not leak into child
   exec"); this path was never brought under the same rule, nor under
   #1823's restart-fd regime.

2. Helpers that exit during teardown are inherited as zombies -- children
   survive execve and the new image is still their parent.  The steady-state
   reap is SIGCHLD-driven, and in steady state no child ever exits, so
   nothing triggers it and the zombie is permanent.  The driver now drains
   inherited zombies at startup, before booting any helper of its own, so
   anything collected is by construction inherited and never ours.

Measured with a scripted repro over three @restarts:

    unfixed          3 live orphan stubslaves, 3 zombie slaves, +3 fds
    CLOEXEC only     6 zombies, fd count flat
    both             1 stubslave, 1 slave, no zombies, fd count flat

I had assumed SIGCHLD would collect the post-exec stubslave exit; the
isolation run above shows it does not, which is what makes part 2 load
bearing rather than belt-and-braces.

tests/scenario/restart_helpers.py asserts it against a live server, and
runs last in run.sh because it restarts the shared instance.  It skips
loudly on a non-stubslave build rather than passing vacuously.

Catch-verified against BOTH failure modes, which have distinct signatures:

    no CLOEXEC:  not ok 3 - ... (after=3 (.../S/stubslave, ...))   <- live
    no drain:    not ok 3 - ... (after=4 (.../Z/stubslave, .../Z/slave, ...))
                 not ok 4 - ... (zombies=[...])                    <- zombies

Introduced by the 2026-03 component split that created engine.so; 2.13 is
unaffected.  #386 reported the identical symptom in 2007 and was fixed
then -- the split reintroduced it, hence the regression test.

Scope note: the "real teardown on the restart path" option from the issue
is deliberately NOT taken here.  It would add a ShutdownSlave COM round
trip on a new path, in the exact area where #1939 (pump/ShutdownSlave
mutual recursion -> stack overflow) lived, and CLOEXEC already ends the
leak while covering exec paths that a do_restart-only fix would miss.

Two adjacent findings, both filed rather than folded in:
 - #2199: @restart drops the -p and -e command-line values.
 - GanlAdapter::boot_stubslave() (ganl_adapter.cpp) is dead code carrying
   the identical missing-CLOEXEC bug.  Left alone; deleting it is a
   separate call.

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>
2026-08-07 10:47:48 -06:00
Stephen Dennis
dfc9449e32
Merge pull request #2198 from brazilofmux/fix/2193-proto-detect-window
proto_detect_window — make the 500ms connect wait configurable (#2193).
2026-08-07 10:07:00 -06:00
Stephen Dennis
9c631958eb Merge origin/master into fix/2193-proto-detect-window
Combine Networking CHANGES entries with #2197's TCP_NODELAY notes.
2026-08-07 16:06:53 +00:00
Stephen Dennis
ed7c92ce94
Merge pull request #2197 from brazilofmux/fix/2194-tcp-nodelay-posix-engines
Set TCP_NODELAY on accepted sockets in every engine (#2194).
2026-08-07 10:05:57 -06:00
Stephen Dennis
95977dc7d4 feat(net): proto_detect_window — make the 500ms connect wait configurable (#2193)
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>
2026-08-07 10:01:47 -06:00
Stephen Dennis
c424b2a1f4 fix(net): set TCP_NODELAY on accepted sockets in every engine (#2194)
The IOCP engine set TCP_NODELAY on accepted client sockets; epoll, kqueue,
select and wselect did not.  So the same server ran with Nagle enabled on
POSIX and disabled on Windows -- a platform difference nothing in the tree
asserted either way.

The epoll engine carried the option as a commented-out question:

    // Optional: Set TCP_NODELAY?
    // int opt = 1;
    // setsockopt(clientFd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

and kqueue included <netinet/tcp.h> "For TCP_NODELAY potentially" without
ever using it.  This answers the question, uniformly.

Why it matters: output is already coalesced per flush -- a single command's
reply is one write(), even the 1343-byte connect banner -- so for strict
request/response traffic Nagle adds nothing and this changes nothing.  But
separate flush events are separate writes, and on a real link Nagle makes
each small write after the first wait for the ACK of its predecessor, which
the peer's delayed-ACK timer can hold for up to ~40ms.  Trigger-heavy
clients, @dolist output and channel bursts produce exactly that pattern, so
a remote player saw a multi-line burst dribble in at ACK cadence.

Scope is wider than the issue asked for.  #2194 named epoll and kqueue;
select and wselect lack it too, and the point of the fix is that the
platforms stop differing, so all four are covered.

Non-fatal on every POSIX engine, and on wselect: a latency hint failing is
no reason to drop an accepted connection, and wselect additionally must not
disturb the caller's `error`.  IOCP is left as it is -- its setsockopt is
in setSocketOptions, which also configures the listener, and loosening it
there is a separate question from this one.

Asserted, not assumed: ganl_engine_tests gains accept-tcp-nodelay, run
per-engine on both platform tables.  It must go through the real accept
path -- adoptConnection skips it, which is why every existing POSIX
scenario was blind to this.  For IOCP the new case is a regression guard.

Catch-verified: with the epoll and select changes stashed, the harness
reports

    not ok 19 - epoll: accept-tcp-nodelay  # accepted socket has Nagle
                                             enabled (TCP_NODELAY unset)
    not ok 20 - select: accept-tcp-nodelay # (same)

and passes with them restored.  kqueue and wselect are unbuildable on this
box and are covered by inspection plus the same shared test case.

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>
2026-08-07 09:23:48 -06:00
Stephen Dennis
47187270b0
Merge pull request #2191 from brazilofmux/fix/2.14-toc-missing-headers
build: ship five headers the TOCs never listed — a 2.14 cut today would not build
2026-08-07 02:33:27 -06:00
Stephen Dennis
c2b9db3659 build: ship five headers the TOCs never listed — a 2.14 cut today would not build
The TOCs are allowlists.  dounix.sh/dowin32.sh start from the previous
release tree and overlay only the paths named by unix/TOC.patchable and
win32/TOC.src.patchable, so a file added since the last release that
nobody lists never reaches the distribution.  Five have accumulated
since Build-2.14.0.10:

  ganl/include/ganl_debug.h
  include/dbt_reloc.h
  modules/engine/jit_tier1_stamp.h
  modules/engine/list_scratch.h
  modules/engine/word_scratch.h

All five are #included by shipped sources -- list_scratch.h by
functions.cpp and funceval.cpp, jit_tier1_stamp.h by six files -- and
ganl_debug.h is additionally named in ganl/Makefile.am's noinst_HEADERS,
so make stops before compiling anything:

  make[1]: *** No rule to make target 'include/ganl_debug.h',
           needed by 'all-am'.  Stop.

This is the same defect that shipped in 2.13.0.16 and had to be re-cut
(#2190).  Same file, same cause, other line.

Verified by reproducing a cut rather than by inspection: the real
2.14.0.10 reference tarball plus dounix.sh's overlay logic, built with
--enable-jit --enable-realitylvls --enable-wodrealms.

  TOCs as on master:  422 files, 0 of 5 headers, build FAILS above
  with this change:   427 files, 5 of 5 headers, build CLEAN
                      (netmux, libmux.so, engine.so all produced)

Also checked and clean, both platforms: removed intersect (patchable
union unpatched) is empty, and every TOC entry resolves to a file in
the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 02:32:17 -06:00
Stephen Dennis
98ee2febbf
fix(build): relative IncludePath/LibraryPath in master vcxproj files (#2185) (#2188)
fix(build): relative IncludePath/LibraryPath in master vcxproj files (#2185)
2026-08-07 00:35:59 -06:00
Stephen Dennis
6f32bc7741 fix(build): relative IncludePath/LibraryPath in master vcxproj files (#2185)
Replace hardcoded C:\tinymux paths with MuxDir/Pcre2Dir derived from
MSBuildProjectDirectory so checkouts at tinymux-213 or elsewhere cannot
silently compile against another tree's PCRE2.  Covers netmux, libmux,
engine, comsys, mail, exp3, sqlproxy, sqlslave.  2.13 half was #2187.
2026-08-07 00:35:17 -06:00
Stephen Dennis
b6c3af61b1
Merge pull request #2186 from brazilofmux/fix/2183-bare-subst-ref
fix(#2183): bare-substitution scope bodies escape their restores as raw slot references
2026-08-06 23:56:19 -06:00
Stephen Dennis
3f735be40e
Merge pull request #2181 from brazilofmux/fix/2180-ganl-debug-windows
fix(#2180): make GANL's Windows debug output controllable instead of absent
2026-08-06 23:34:02 -06:00
Stephen Dennis
99dc0a492b fix(#2183): a bare-substitution scope body escapes its restores as a raw slot reference
The filed shape — an attribute body of exactly %! still resolving to
the caller inside an inlined u() after #2182 — is one instance of a
class.  A body of a single substitution lowers to emit_sref: a lazy
guest address whose bytes are only read when a consumer copies them.
For a scope's RESULT value that consumer is the merge PHI's edge copy
(emit_phi_copies does the byte copy at the END of the arm) or the
caller's STRCAT — both of which execute after the scope's restore
helpers rewrote the slot.  Any second token in the body forces a
STRCAT inside the scope and hides the bug, which is why every #2179
and #2182 probe missed it.

The class is wider than the filed %!:
  - %0 / %+ against _RESTORE_CARGS in the u()-inline — the callee's
    OWN ARGUMENT read back post-restore (u(#2/B,xx) with &B #2=%0
    returned the caller's empty %0).  This half PREDATES #2179's fix.
  - a cold-tracked %q0 against _RESTORE_QREGS in localize()/letq() —
    localize([u(#2/setter)]%q0) returned the pre-scope register, when
    fun_localize returns the body's value and only then restores.

Fix: hir_materialize_restorable_ref, applied to the scope-body result
at all three restore-emitting sites (u()-inline, letq, localize) before
any restore is emitted.  It copies the value into a program-owned slot
(one-arg STRCAT) only when the result is a runtime reference into the
restorable CARGS+SUBST region — references elsewhere (a loop's pinned
accumulator, the common list-formatter body) pass through untouched
and pay nothing.  MAP/FILTER/FOLD need no site: their per-element
results are materialized in-loop by the accumulator append.

Verified against separate-command interpreter oracles (the issue's
harness caveat: a same-command oracle declines the whole bracket and
false-clears): bare %!, %!%! control, %0, %+, and the localize
post-ECALL %q0 shape all byte-match, jit_handled=5/5.  TC012 grows
bare-%! and bare-%0 legs — the two probes the corpus lacked.  Full
suite 35/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:32:40 -06:00
Stephen Dennis
1179c2ba83
Merge pull request #2182 from brazilofmux/fix/2179-uinline-executor
fix(#2179): u()-inline runs the body in the target's executor context
2026-08-06 23:21:46 -06:00
Stephen Dennis
d433d5f5f0 fix(#2179): u()-inline runs the body in the target's executor context
fun_u evaluates the attribute body with executor = the object holding
the attribute and caller = the previous executor; the Tier 3 inline
compiled the body into the caller's program without that swap, so
everything executor-derived inside u(#obj/attr) resolved against the
CALLER: %! (SUBST slot), %va-%vz and %=<name> (xget against that slot),
and every ECALL's ec->executor — v(), bare-name u(), name(me), and
permission checks, in both directions.  The MAP/FILTER/FOLD inline arms
documented this exact hole and guard it with a runtime executor==thing
STRCMP fallback ("the u()-inline lives with this hole"); #2092's arm
un-reversal then made the u() hole reachable for the first time.

Fix: _PUSH_UEXEC/_POP_UEXEC helpers bracket the inlined body.  Push
saves ec->executor/ec->caller to a handle stack, sets caller = old
executor and executor = thing (mirroring fun_u's mux_exec call), and
rewrites the guest %! substitution slot; pop restores all three.  Both
executor-derived channels — the ECALL context and the SUBST slot — go
through exactly this pair, so v(), bare-name u() with parent
inheritance, name(me), %!, %va-%vz, %=<name> and See_attr checks all
compose, including across nested inlines (each pair strictly brackets
its body).  A failed push (stack exhausted) returns -1 and the lowering
BRANCHES it to the existing fun_u ECALL fallback, which establishes its
own context — exhaustion costs the inline, never correctness.

The helper save stacks (this one and the existing CARGS stack) leaked
their slot when a program was abandoned between save and restore (DBT
decline, error unwind); a top-level run_cached_program entry proves no
outer program holds a live handle, so both stacks reset there.  Nested
entries never reset: both entry points have claimed the depth counter
since #2106.

Verified against the interpreter oracle (the [num(#obj)] spelling that
never reaches the inline gate): the issue's repro plus nested inline
(#2 -> #3 -> back), executor restored after the call, ulocal, and the
real-world iter(u(bare-name)) shape all byte-match, jit_handled=5/5.
jit_route_parity_fn TC012 pins the shape in smoke — its trigger context
runs as the test object, so caller != target (#1) makes the
discrimination live on every run.  Full suite 35/0.

The farm's 2x +jobs regression (wrong-executor denials flooding the
ECALL error-format path) should collapse with this; if utf8_strlen
under mux_vsnprintf stays hot afterwards it deserves its own issue.
MAP/FILTER/FOLD can now adopt the same pair and drop their conservative
executor==thing gate to open inlining to executor != thing — filed as a
perf follow-up in the arm's comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:07:46 -06:00