v. vanilla 2.0
THE VANILLA HANDBOOK / 2.0

WebSocket reference

Client and server upgrades, messages and graceful closing.

This reference describes the implemented native runtime. Historical paths and internal version labels are retained where relevant. Later extension chapters supersede older limitations.

Stage 2 adds an original RFC6455 codec on the existing TCP/verified-TLS transport. It does not implement stage 3 (webhook/MQTT/IRC/framing), compression, HTTP/2, proxying, redirects, or a production long-lived service. The pre-code finite design is WEBSOCKETS-DESIGN.md. Parent specification and quality reviews remain required. Originals and every baseline, including baselines/pre-websockets, are immutable.

API

websocket client $result $connection $config timeout_ms
websocket server $result $connection $config timeout_ms
websocket step $result $data $connection
websocket send $result $connection text|binary|ping data
websocket close $result $connection code reason timeout_ms
websocket abort $connection

All destinations must be declared. Metadata becomes typed JSON. $data is a binary-safe scalar, including embedded NUL. Step destinations must differ. Metadata may already hold JSON but not a legacy array; data may not hold either. Handle, argument, destination and conservative result-storage checks precede I/O. A preflight error preserves output sentinels and a usable handle. Protocol or transport failure invalidates all copied aliases; socket wait also invalidates expired selected handles. As in Stage 1, fatal resource failures cannot be caught.

client / server claim an idle established stream in place, retaining its handle and aliases. No unread legacy buffer, pending TLS read/write, transport EOF, or incomplete TLS handshake is permitted. socket connect / tlsconnect still have their existing bounded but blocking setup semantics; do not use those setup commands inside a latency-sensitive event loop. The HTTP upgrade itself is fully staged. TLS servers use tlslisten, tryaccept, and handshake first. There is no new insecure TLS option, and client chain/hostname verification is unchanged.

$config is a JSON object with exactly four string fields:

{"host":"127.0.0.1:12345","path":"/ws","origin":"http://127.0.0.1:23456","protocol":"vanilla.dashboard"}

host is the exact HTTP Host value, including any explicit port; it is not a DNS lookup override. path is an absolute request target beginning / with no fragment. Whitespace/control characters are rejected in all four fields, and each is limited to 1024 bytes. Host cannot contain /, backslash, @, ?, or #. Protocol is empty or one HTTP token. This is intentionally not a URL parser or multi-protocol chooser. Server path/Host/Origin must match exactly. Empty configured Origin requires an absent Origin header, not a wildcard. Browser deployments must configure their specific HTTP page origin. Non-browser clients can forge Origin; it is not identity or authentication. A configured subprotocol must be offered/selected exactly; empty means no selected protocol. Before sending 101, the server validates extension offers using RFC6455 §9.1: extension and parameter names are tokens; parameter values are tokens or quoted strings whose unescaped value must itself be a nonempty token. Quoted-pair escapes are processed, not blindly accepted. Valid unsupported offers (including browser permessage-deflate defaults) are declined without any extension selection; malformed offers fail the connection. Client rejects any extension selection. This is syntax validation, not extension-specific semantics.

Both roles require --allow-network; server upgrade additionally requires --allow-listen. TLS server credentials remain explicit operator CLI --tls-cert-file PATH --tls-key-file PATH, validated before binding. A private test CA can be supplied to a client with --tls-ca-file PATH.

Progress and accounting

Every result has status, want, accepted, count, completed, pending, type, and code fields:

  • client/server: handshake; no network I/O. Client queues its HTTP request.
  • send: progress, payload accepted into owned bounded storage; no network I/O. One outstanding text/binary message per handle; another is rejected, never implicitly retried. Ping is limited to 125 bytes and shares the bounded queue.
  • step: handshake, open, progress, wouldblock, message, ping, pong, closing, or closed. At most one transport read or write per call, and at most one complete HTTP header/frame processed. Incomplete parse work is bounded. Step can complete output before reading; continue the event loop.
  • accepted: new payload bytes copied by this call. For close this includes the two-byte code and reason; step is zero. Zero-length messages still enqueue a frame.
  • count: wire bytes newly written by this step, including masks/headers/control bytes. It is not application data received or proof of peer delivery.
  • completed: application payload bytes whose entire outgoing text/binary frame drained in this step. Zero for controls/HTTP and for partial writes. pending tells whether any queued/current output remains; zero-byte-message completion is observable via pending becoming false, not a positive completed count.
  • message: $data is a complete message; type is text or binary. Receive size is the byte length of $data, not count. ping/pong return their payload. Ping automatically queues an identical pong. closing carries the received close reason (without code); code is received code, 1005 for an empty close, or zero before a close is received. 1005 is never transmitted.
  • want exposes current physical retry direction (r or w); closed uses empty. Do not translate it yourself into a raw transport operation.

Use logical r for WebSocket handles in socket wait, even while writing:

set $interests "[{\"handle\":\"" $h "\",\"events\":\"r\"}]"
json parse $interests $interests
socket wait $events $interests 20
websocket step $r $data $h

Wait maps that interest to queued output, transport/TLS WANT, buffered parse work, or the next deadline. It performs no protocol I/O. For a WebSocket, a true read readiness flag means retry step, even if the physical operation is a write. The legacy wait handshake boolean still describes TLS transport handshaking; WebSocket upgrade phase is reported by the WebSocket result's status. Existing plain/UDP/TLS interests retain their Stage 1 meanings and can share the same interest list. Expiry is an error/expired wait event, not clean EOF; read/write readiness never guarantees progress. Raw socket recv/readline/sendall/tryrecv/trysend/handshake cannot bypass a claimed WebSocket. socket endpoint, socket close and wait remain permitted. abort / raw close discard protocol output; graceful shutdown requires close/step.

close validates a code/reason and queues the close behind already accepted output. Reason is UTF-8, at most 123 bytes. Legal transmitted codes are 1000–1014 except 1004/1005/1006, and application/private 3000–4999. It starts its absolute deadline, 1–30000 ms. Valid already-in-flight incoming messages during local closing are validated then discarded, never delivered as new application actions. The peer's close is echoed if necessary; closed means both frames were exchanged and the handle was released. EOF without that exchange is a truncation error, including EOF at a frame boundary. TLS truncation remains independently detected by OpenSSL.

Protocol and resource boundaries

  • HTTP/1.1 GET upgrade only, 8192 header bytes / 64 fields. Strict CRLF, no folding, ambiguous singleton duplicates, body lengths or transfer encoding. Upgrade and Connection are case-insensitive comma-token lists; version is exactly 13. Keys must be canonical base64 of exactly 16 bytes. Client verifies SHA1/base64 accept. Trailing bytes after the header delimiter remain available for frame processing. Extension-offer scanning is linear and constant-storage within that same header byte bound. RFC2616 1# empty comma-list elements are allowed only if at least one actual extension is present. Existing stricter no-folding and duplicate-field rejection remains unchanged, including duplicate extension header fields.
  • Client masks use OpenSSL RAND_bytes; server sends unmasked. Wrong masking, any RSV bit, unknown opcode, fragmented/oversized controls, noncanonical lengths, the 64-bit high bit, frame overflow and invalid fragmentation sequencing fail.
  • Frame and reassembled message payload maximum: 65536 bytes. Text UTF-8 is validated incrementally as each complete frame is processed, retaining constant continuation state across fragments and interleaved controls. Impossible prefixes fail immediately (including overlong, surrogate and above-Unicode prefixes); valid incomplete codepoints may cross fragment boundaries but must be complete at message FIN. The same validation runs before local-close discard. A peer Close may interrupt a still-valid incomplete message; it cannot hide an invalid prefix already processed. Partial wire frames remain buffered until complete. No compression or extension semantics. Outgoing messages use one FIN frame.
  • One application output plus a bounded queue of at most eight frame/HTTP entries; the currently written frame is separate. The eight-entry limit includes queued application data, ping/pong and close. A new application message is rejected until its predecessor drains. Queue overflow while handling a peer is a protocol error closing that peer, not unbounded memory growth. Input is capped at 73742 bytes (one maximum masked frame plus one transport read); reassembly is separately capped.
  • Existing 32 handles, 4096 network calls, 16 MiB wire send/receive, 1 MiB aggregate socket/protocol retained storage, 60000 ms cumulative timeout reservations remain shared with TCP/UDP. Capacity and temporary-copy headroom are charged conservatively. Creation reserves its upgrade timeout plus 1000 ms for a potential peer-initiated close. Explicit local close reserves its own timeout. Fatal resource limits do not promise completing a close handshake.
  • Stable owned TLS retry buffers survive WANT. Write retries cannot move storage or change arguments. Read/WANT_READ allows compatible writes while preserving its 8192-byte read buffer; read/WANT_WRITE remains exclusive. No internal retry loop, sleeping helper thread, or hidden blocking readline is used by this protocol. DNS/connect, local certificate reads, crypto/library allocation and OS scheduling retain Stage 1 cooperative rather than hard-real-time limitations.

Real examples and browser verification

examples/websocket_dashboard.v is the actual multi-peer backend. It owns the counter, echo and revisions, validates incoming JSON operations server-side, and broadcasts snapshots. Its bounded application policy coalesces latest snapshots while a slow peer has an accepted output pending: it never blindly resends an accepted action or grows an unlimited per-peer queue. It accepts at most eight peers, binds loopback, expires incomplete peers, and runs at most 20 seconds / 1000 loop turns under unchanged runtime budgets. It is a finite demonstration, not a service daemon. Invalid application controls disconnect only their sender.

examples/dashboard/{index.html,dashboard.css,dashboard.js} are local static assets, with increment/reset/echo and an explicit reconnect button. There are no CDNs, fake state mutations, optimistic counter increments, or action retries. Page reload and reconnect read the current Vanilla state. The wrapper's Python HTTP server serves these files only; it does not handle WebSockets or own dashboard state.

Run the complete two-browser fixture (plain plus TLS):

cd /root/vanilla1
build/ws-venv/bin/python tests/run_websocket_dashboard.py ./build/vanilla1
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 build/ws-venv/bin/python tests/run_websocket_dashboard.py ./build/vanilla1-asan

It chooses ephemeral loopback ports, passes the exact HTTP Origin to Vanilla, records real backend state logs, checks both browsers and JavaScript errors, performs reload/reconnect without replaying actions, isolates a still-live stalled peer during a healthy action, exercises invalid controls, and observes zero clients after disconnect. Every subprocess/socket/thread is bounded and torn down. After asserting zero open clients, the browser fixture terminates/reaps the remaining finite backend instead of waiting for its 20-second idle lifetime; ASan/UBSan run throughout the exercise, but exit-time LeakSanitizer does not run for that SIGTERM teardown. The normal-exit protocol/client fixtures do run with leak detection. TLS browser testing uses a freshly generated local CA/leaf plus only that leaf's SPKI exception in isolated Chromium. This is a browser fixture exception, not an interpreter verification bypass. Trusted Python TLS peers verify the CA normally.

Screenshots: docs/websocket-dashboard-{release,asan}-{plain,tls}.png. Machine-readable browser results and actual Vanilla snapshots use the corresponding JSON names. examples/websocket_client.v runs against the trusted Python WebSocket server in tests/test_websocket_limits.py, plain and verified TLS, and prints exactly:

echo: hello from Vanilla
WebSocket close exchange complete

Dependencies and rerun commands

The interpreter adds no new library: original codec plus already linked OpenSSL. Primary references are RFC6455 sections 4–9 and OpenSSL 3 RAND_bytes, SSL_read, SSL_write, SSL_get_error / existing Stage 1 ownership documentation. Retrieved RFC and RAND_bytes source pages are build/rfc6455.txt and build/RAND_bytes.html. The extraction tool lacked a fetch backend; direct HTTPS retrieval succeeded.

Test-only local venv: websockets==16.0 (BSD-3-Clause) and playwright==1.58.0 (Apache-2.0), with existing cached Chromium 1223. Installed distribution version, license expression and license paths are in docs/websockets-dependencies.json. No third-party WebSocket source was copied into the interpreter. For a fresh setup:

python3 -m venv build/ws-venv
build/ws-venv/bin/pip install websockets==16.0 playwright==1.58.0
# If no Chromium is already installed, install this test-only browser:
build/ws-venv/bin/playwright install chromium
make release sanitize
build/ws-venv/bin/python tests/test_websockets.py ./build/vanilla1
build/ws-venv/bin/python tests/test_websocket_limits.py ./build/vanilla1
python3 tests/verify_websockets.py

make test and make test-sanitize include the new protocol/limits/browser suites and require that venv. The complete verifier additionally repeats all seven cache baseline differentials and records exact counts, commands, logs and binary hashes.

Final verified evidence / limitations

3304/3304 checks per artifact passed on the final warning-clean release and ASan/UBSan builds, with leak detection and halt-on-error. This is the prior 2764, plus another 339-case pre-websockets differential, 182 WebSocket protocol cases, 17 ownership/limits/client-demo cases and two browser scenarios. The 339-case corpus is repeated against seven baselines; these are checks, not 3304 unique programs. The protocol count preserves all prior 59 cases and adds 122 SPEC regressions plus one permanent TLS MemoryBIO retry/readiness case. Before source edits, the 122 new SPEC cases passed 30 and failed 92 on each original artifact; after correction, 122/122 passed on each. Red/green logs and JSON are in build/ws-spec-fix/. Outside those totals, the old pre-network-suite binary passed 73 TCP and 70 DNS/TLS checks, and pre-websockets passed all 79 foundation checks. All 19 original and baseline files retain their captured SHA-256 hashes. No fixture interpreter or cached Chromium process remained after verification (docs/websockets-cleanup.json). All 65 captured original/baseline/independent-review artifact hashes also match; build/ws-spec-fix/final-audit.json checks exact final binary hashes against the verifier and unchanged transport includes. Prior final reports/logs and the old binaries were preserved under build/ws-spec-fix/before/ before replacements.

Full command: python3 tests/verify_websockets.py; complete transcript: build/websockets-full-verifier.log. Exact per-command counts/durations, binary hashes and log paths: docs/websockets-verification.json. Preservation: docs/websockets-preservation.json. Protocol/limits results: docs/websockets-tests*.json and docs/websockets-extra*.json. Browser output is PASS dashboard TLS=False, PASS dashboard TLS=True, with 2/2 scenarios per artifact, zero JavaScript errors and zero final connected clients. The synchronized plain and TLS pressure fixtures each verify 4,192,182 application bytes in 64 individually tagged messages, only begin draining after observed wouldblock, and match whole-message completion accounting exactly.

Earlier failures remain available, not suppressed: build/websockets-build-initial.log (two misleading-indentation warnings subsequently corrected), build/websockets-browser-initial.log (container renderer crash and fixture certificate directory setup failure), build/websockets-extra-initial.log (a peer incorrectly expected request bytes after a rejected preflight), and build/websockets-close-race-red-asan.log. The latter exposed the original rejection of valid data already in flight during local closing. It is now validated/discarded while the close exchange completes; the final clean build passes that regression on both artifacts. The incomplete first verifier transcript and JSON are retained as build/websockets-full-verifier-first.log and build/websockets-initial-verification.json; they are not the final evidence.

The SPEC remediation's first two full-verifier attempts encountered intermittent TLS dashboard upgrade expiry on a Python malformed-control peer. They are preserved under build/ws-spec-fix/full-first/ and full-second/; the third complete run passed unchanged suites on both builds. Diagnostic evidence shows expiry rather than extension-syntax rejection, but the timing failure's root cause remains unresolved. No tests, deadlines or TLS checks were relaxed. Full details, including the unsuccessful optional strace diagnostic, are in docs/websockets-spec-fixes.md. This passing implementation run is not independent SPEC or quality approval.

The suite contains raw-frame HTTP/protocol adversaries, plain/TLS boundary sizes, trusted-library interop in both directions, fragmentation/coalescing/UTF-8/control/close cases, wrong masks/lengths, slow handshake and closing deadlines, injection rejection, output sentinel and raw-bypass guards, alias invalidation, shared fatal budgets, and synchronized backpressure with exact per-block-tagged bytes and completion accounting. All prior core/utilities/worklog/TCP/DNS/TLS/foundation tests remain.

Not claimed: Autobahn certification, broad fuzzing, deterministic allocator/OS EINTR injection, a WebSocket-specific forced TLS key-update read/WANT_WRITE or write/WANT_READ fixture, whole-process RSS limits, mutual TLS, authentication, production persistence/rate policy, hard-real-time crypto, compression, outbound fragmentation, HTTP fallback/error pages, or unlimited server lifetime. Existing Stage 1 MemoryBIO coverage remains in the full prior suite. Closing/protocol errors may discard unsent data; applications must not interpret acceptance as delivery. Both confirmed SPEC fixes are implemented; independent SPEC re-review remains pending, followed by quality review. Stage 3 has not been started.