Imported from fluffos/fluffos (
src/www/AGENTS.md). Install upstream withnpx skills add fluffos/fluffos --skill www. Copyright stays with the author.
src/www agent guide
Read README.md here first — it is the architecture doc (the two pages,
the xterm.js/telnet.js/transport layering, line-vs-char mode, vendor
policy, packaging, testing). This file is the agent-facing checklist of
rules and once-bitten lessons.
Hard rules
- Never hand-roll terminal emulation. xterm.js owns escape parsing,
key/mouse/paste encoding, screen state, wide chars. A bespoke VT
emulator existed here once and was replaced; don't reintroduce one,
and don't "quickly strip ANSI" for display — the old
parseANSI()deleted cursor addressing and silently broke every full-screen TUI. - One telnet implementation. Both pages share
telnet.js. Fix bugs there, not with per-page workarounds; page-specific options (GMCP, MSP) go through its hooks (onRemoteOption/onLocalOption/onSubneg), never as forks of the parser. - Self-contained pages. Plain
<script>tags, UMD globals, no bundler, no CDN. Everything a page loads lives next to it when deployed: keeptools/wasm/pack-mudlib.shand the release-zip step in.github/workflows/release.ymlin sync with any new file. - Vendor byte-exact from the official npm tarball (
npm pack, sha256-compare, keep licenses). Never reconstruct vendor code by any lossy channel (root AGENTS.md §14 — observed to fabricate plausible code that passes tests). - wasm bridge reentrancy (
wasm/index.html): the bridge is synchronous both ways — never callfluffos_inputfrom insideTelnetClient.receive()processing, and never process inbound bytes reentrantly. The outbox (0-timeout flush) / inbox (non-reentrant drain) queues exist because negotiation replies sent mid-parse re-entered the parser and blew the stack at boot. - Browser compat: no regex lookbehind (
(?<!...)broke Safari <16.4), setws.binaryType = 'arraybuffer'(Blob handlers reorder async), and expect telnet sequences AND multi-byte UTF-8 to split across ws frames (streaming decode + stateful parsing already handle this — keep it true).
Driver-side facts that bite web-client work
- Char mode is
WILL SGA, sent byset_charmode()when the mudlib armsget_char()(src/net/telnet.cc);WONT SGA= back to line mode. ECHO masks passwords. NAWS answers must use real terminal geometry (fit addon), re-sent on resize → drives thewindow_sizeapply → TUIs relayout live. - The lws output wedge (
src/net/ws_telnet.cc/ws_ascii.cc): the handlers drain multiple write windows per callback in a loop gated onlws_send_pipe_choked(). That gate is true in TWO cases, and they differ in who re-arms the next writeable callback: (a) lws holds a truncated send from a genuine partial write — lws re-arms itself; (b) a zero-timeoutpoll(POLLOUT)says the socket is full RIGHT NOW — every write so far fully succeeded, lws has nothing pending, and nobody re-arms anything. The drain loop therefore requests the next writeable callback itself whenever it exits with data still queued — otherwise output freezes permanently (megabytes stuck inpss->buffer).lws_callback_on_writable()is the upstream-documented way to ask (lws README.coding.md: "if you want to send something, do not just send it but request a callback when the socket is writeable"), and calling it from inside the writeable handler is safe — traced end to end through_lws_change_pollfd()→ the evlib glue'sevent_add(); an earlier theory that in-handler requests are "lossy with the libevent event lib" did not survive tracing and is dead. The same doc's "do not rely on only your own WRITEABLE requests appearing" is why the handler drains frompss->bufferand treats a nothing-to-do callback as a no-op. If ws output ever "stops after a burst", start at that data-remaining re-arm. The bug only reproduces under real backpressure (a slow or paused reader) — a fast local reader never fills the kernel send buffer, so a burst test that doesn't force backpressure passes on broken code. It's also invisible to every non-ws test. - Session teardown must free the pss resources UNCONDITIONALLY in
LWS_CALLBACK_CLOSED. On driver-initiated closes (mudlib destructs the interactive),close_user_websocket()nullspss->userbefore CLOSED fires — an earlyreturnon!pss->userthere leaked the evbuffer on every such close, and when an interim revision parked a libevent timer in the session it became a deterministic ASan use-after-free (the pending timer fired on the freed wsi/pss). Any new per-session resource must be released on BOTH sides of thatpss->usercheck. - Driver-initiated close is close-AFTER-FLUSH, with four invariants
(
close_user_websocket()setspss->close_after_flush+ a 5sPENDING_FLUSH_STORED_SEND_BEFORE_CLOSEdrain deadline; the writable handlers close with status 1000 once drained). Breaking any of these reproduced real truncation:- Close only when the evbuffer is empty AND
!lws_send_pipe_choked(). Choked-with-empty-buffer means permessage-deflate still holds the compressed tail of the final message (tx_draining_ext) — entering the lws close states DISCARDS that drain ("defeat tx draining" inops-ws.c), truncating the final message for every pmd client, i.e. every real browser. Re-arm the writable callback instead; lws services the extension drain itself and calls back when done. - The close is ONE-SHOT: clear
close_after_flushbefore thereturn -1. A second already-queued WRITEABLE callback can land before lws processes the first close; returning -1 again re-enters the close path inLRS_RETURNED_CLOSEand degrades the clean close handshake into an immediateclose(fd). On a socket whose autotuned kernel buffers still hold undelivered output (easy on a connection that already moved megabytes), any late client byte hitting the closed fd triggers an RST that purges ALL in-flight data — a flaky ~30% truncation that only reproduced on a REUSED high-throughput connection, never a fresh one. LWS_CALLBACK_RECEIVEwith a nulledpss->usermust DISCARD (break), neverreturn -1. During the drain window a real player keeps typing; hard-closing on that input truncates the very flush the close is waiting for.- Do not use
lws_rx_flow_control()to "stop reading" during the drain. With the libevent event lib, flipping POLLIN on a live wsi stalls POLLOUT servicing too — the drain freezes and the deadline kills the session (observed directly; both subprotocols).
- Close only when the evbuffer is empty AND
- Multiple stale ws connections skew debugging: each test run that
doesn't quit cleanly leaves the driver processing net-dead teardown;
restart
driver etc/config.testbetween debugging sessions and watch for]-prefixed escape spam in its log (writes to dead connections).
Testing expectations
sys_reload_tls()on a websocket TLS port is gated bynode tools/ws-tls-reload.js(ctestws-tls-reload, CI unit-test step): overwrite the on-disk cert, reload, require openssl s_client and a Node TLS client to see the new fingerprint. Reloading the same files is not enough. Keep it green when touchingsrc/net/websocket.ccorf_sys_reload_tls().- Any change to these pages,
telnet.js, orsrc/net/ws_*.ccmust keepnode tools/ws-smoke.jsgreen (CI runs it: boots the real driver, telnet + ascii subprotocols, SGA switch, multi-window bursts, paused-socket backpressure bursts on plain AND TLS ports, destruct-while-choked close-flush checks with mid-drain client input, a permessage-deflate close-vs-extension-drain sweep (the only pmd coverage anywhere — the hand-rolled client must opt in), a bounded close-deadline check, TUI frames, clean quit). It exists because GTest + the LPC suite exercise zero websocket client traffic. The backpressure checks are what actually regression- guard the lws output wedge above — verified to fail (all three) on the pre-fix driver; don't remove the socket pause/resume from them to "speed it up", they are no-ops without it (a fast reader never blocks a write). The teardown check mostly bites on the ASan CI job, where a teardown memory bug aborts the driver and the follow-up connection check catches it. - For rendering/keyboard/resize changes, drive a real browser through
the
tuidemoshowcases (see README.md "Testing") — the smoke test can't see what xterm.js draws.