v. vanilla 2.0
THE VANILLA HANDBOOK / 2.0

Nonblocking networking & UDP

Readiness, nonblocking streams, TLS listeners and datagrams.

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

This stage implements only the foundation milestone in NETWORK-SUITE-PLAN.md. Old TCP/DNS/TLS commands retain their syntax, budgets, and fatal-resource behavior. New commands use runtime-owned nonblocking CLOEXEC descriptors. No public network tests.

The finite design was written before implementation. The contract below includes the tested full-duplex refinement: read/WANT_READ preserves its retry arguments without preventing compatible writes. Specification and quality review are pending.

Implemented syntax and contract

socket wait $events $interests timeout_ms
socket tryaccept $result $connection $listener handshake_ms
socket tryrecv $result $data $connection maxbytes
socket trysend $result $connection data
socket handshake $result $connection
socket tlslisten $listener address port backlog
udp bind $handle address port
udp sendto $result $handle address port data
udp recvfrom $result $data $handle maxbytes
udp endpoint $address $port $handle
udp close $handle

Declare destinations first. Metadata destinations become JSON; raw data and handle outputs remain scalar (binary safe). Distinct output variables required. All inputs, output types/storage reservations, handles and limits are checked before I/O. Metadata destinations can already hold JSON; raw destinations cannot. Handles alias resources, not ownership; copied handles become stale together on close/failure.

wait takes a JSON array of 1–32 unique objects with exactly handle (string) and events (r, w, rw). UDP and stream handles share this wait. Listener events must be r. It returns an array in interest order, omitting idle entries, each with handle (string), and read, write, eof, error, handshake, expired booleans. Empty means timeout. HUP/error is readiness, not proof of EOF: only a read establishes EOF. Known EOF and shared plaintext or SSL_pending bytes are immediately readable. For pending TLS operations, logical requested direction is mapped to OpenSSL WANT (direction can be opposite); readiness means retry, not guaranteed progress. Wait never performs application I/O or handshake work. Timeout is 0–30000 ms (zero is a probe); positive reservations share the old cumulative 60000 ms budget.

tryaccept makes one accept attempt. result.status is wouldblock (connection output empty), progress (plain established), or handshake (TLS accepted but not verified/usable yet). TLS handshake deadline is absolute from acceptance, 1–30000 ms, reserved against the same cumulative time budget. No implicit handshake wait. handshake makes one SSL_accept attempt; result status wouldblock or progress, want is r, w, or empty. Wait with r on a handshaking handle maps to WANT. An expired handshake is invalidated on handshake/use or a wait selecting it; wait returns expired/error for that handle. Unselected handles cannot advance after deadline and retain at most one of the 32 bounded ownership slots until close/use or runtime destruction. A wait is capped by selected handshake deadlines.

tryrecv makes at most one transport read (up to 8192 of maxbytes 1–65536), first consuming the existing shared buffer. Metadata: status progress, wouldblock, or eof; count, eof, want. EOF is separate from no bytes yet. TLS read retry arguments are runtime-owned and frozen across WANT; repeat tryrecv with the same maxbytes. Legacy reads and changed read sizes are rejected while this retry is pending. A pending read/WANT_READ allows trysend while retaining the original owned read buffer and size: otherwise TLS tickets or fragmented records could deadlock full-duplex application messages. A read/WANT_WRITE is conservatively exclusive; only retry the read (or close). While a TLS write remains pending, reads are rejected, and only an empty-data write resume (or close) is permitted. Before an idle TLS read, a zero-time readiness probe avoids needlessly entering a pending SSL_read. TLS protocol processing can still yield WANT after readiness.

trysend owns one bounded (0–65536 bytes) payload per handle. Metadata: status progress or wouldblock, accepted (new bytes copied this call), count (bytes newly completed by send/SSL_write this call), pending, want. Initial acceptance is NOT completion or peer delivery. Resume a pending write with empty data only; new nonempty data, legacy sends/reads and TLS tryrecv while pending are rejected. Each invocation does at most one write. A partial success removes only its completed prefix; WANT preserves the exact pointer/length until retry. Empty data without pending work succeeds with zero counts. Account total accepted payload bytes once, not on retries. Close discards pending data; transport failure closes the handle (plain or TLS) and does not imply zero bytes reached the peer. No blind full-payload retries. Plaintext tryrecv is allowed while a plaintext write is pending; legacy I/O is not. When pending TLS write exists, wait interest must be w. A pending read/WANT_WRITE requires logical r; read/WANT_READ allows r, w, or rw.

TLS servers require both networking gates plus explicit operator --tls-cert-file PATH --tls-key-file PATH. Both options must appear together and require both gates. PEM certificate chain and unencrypted private key pair are loaded/checked before creating/binding listener FD; encrypted keys fail without prompting. TLS >=1.2, no renegotiation/compression/session cache. No client-cert authentication claim. Listener context is reused by accepted SSL objects, whose refcounts keep it alive if listener closes. Legacy accept on TLS listeners is rejected: use staged tryaccept/handshake. Existing tlsconnect handles support the new operations without changing original verification.

UDP uses numeric IPv4/IPv6, localhost/empty bind selecting 127.0.0.1; wildcard only when explicit. Bind requires both gates. No raw sockets, broadcast option, multicast joins, DNS, or hidden outbound auto-bind. sendto is one atomic datagram attempt (0–65507 bytes), status progress/wouldblock, count; retry wouldblock at application discretion. recvfrom consumes one packet (maxbytes 1–65507), raw data plus metadata status progress/wouldblock/truncated, count, size (original size), address and port. Truncation is explicit and the remainder is discarded, never reported as success. Zero-size datagrams are progress, not EOF. MSG_TRUNC obtains original size; reserve 65535 bytes before receive so even discarded bytes are accounted. Peer metadata is numeric. No delivery/order/reliability guarantee.

Shared limits / integration

32 live handles total (UDP included), 4096 socket+UDP calls, 16 MiB attempted sends/actual received bytes, 60000 ms timeout reservations. New pending buffers share 1 MiB aggregate conservative capacity reservation with old stream buffers; reserve copy headroom before mutation. All fatal limits remain uncatchable. Try calls do not poll-block/retry EINTR; EINTR is wouldblock. wait retries EINTR under one deadline. FD exhaustion releases owned descriptors before diagnostics. No per-handle background threads, implicit retries, or hidden blocking readline. Kernel/library memory, local cert reads and crypto are not hard-real-time bounded; use external process limits for untrusted workloads. New families remain modules.

Next stages must preserve accepted-versus-completed accounting, preserve unsent application buffers, use wait instead of blocking line reads, and close pending TLS operations rather than switching retry arguments. Protocol queues must have independent finite budgets. No WebSocket/MQTT/etc implementation is part of stage 1.

Primary references inspected

OpenSSL 3.0 SSL_write (identical retry arguments, partial write completion), SSL_read, SSL_accept, SSL_CTX_use_certificate, SSL_get_error; Linux man-pages poll(2), recv(2), udp(7), retrieved from docs.openssl.org and man7.org. Extraction backend and browser access failed; direct HTTPS retrieval succeeded. Raw selected excerpts and retrieved page lengths are recorded in build/foundations-primary-docs.json. OpenSSL SSL_get_error explicitly forbids other I/O during write/WANT_WRITE and describes opposite-direction retries. Full-duplex read retry ownership is exercised with real encrypted records rather than mocked SSL responses.

Reproduce and run actual examples

From /root/vanilla1:

python3 tests/verify_foundations.py
python3 tests/test_foundations.py ./build/vanilla1
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 python3 tests/test_foundations.py ./build/vanilla1-asan
python3 tests/run_foundations_demos.py ./build/vanilla1
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 python3 tests/run_foundations_demos.py ./build/vanilla1-asan

make test and make test-sanitize also include the new cases and demos. The full verifier additionally runs all six baseline cache differentials and old TCP/DNS/TLS suites on pre-network-suite. The wrapper prints JSON including exact application stdout. Actual checked stdout, grouped by application:

alice received: bob: hello
bob received: alice: hello
chat: slow reader blocked; incomplete line isolated; both healthy peers served
TLS: verified Python client exchanged exact messages
UDP: binary-NUL and zero-length datagrams roundtripped

The chat uses examples/chat_server.v and two concurrent actual examples/chat_client.v interpreters. Two further Python sockets are a deliberately non-reading peer and an incomplete-line sender. Before replying to either healthy client, the server verifies that its slow-reader send actually returns wouldblock. It does not call blocking accept/readline/sendall in its event loop. The finite fixture exchanges alice: hello\n / bob: hello\n, checks both replies, and reaps all three interpreter processes. This is a bounded demonstration, not a long-lived chat service or a general application queue library.

examples/tls_server.v is run with disposable operator-supplied CA-issued PEMs. Python verifies its chain and localhost identity, sends verified hello in two fragments, receives exactly verified reply, and observes close_notify. The main test suite separately checks invalid/mismatched/missing/encrypted credentials, pre-bind pair validation, wrong identity rejection and a healthy TLS handshake beside a stalled peer. No certificate or private key is added as a shipped example.

examples/udp_roundtrip.v sends two packets to a Python loopback socket: exactly 61 00 62 (hex) and an empty packet. The peer checks both original boundaries and echoes each separately. Vanilla checks peer address/port and exact bytes. Additional tests exercise IPv6, truncation, zero-probe readiness and mixed UDP/TCP wait lists.

Manual TLS launch syntax (replace paths with operator-owned unencrypted PEMs and a readiness filename that does not yet exist):

./build/vanilla1 --allow-network --allow-listen --allow-files --tls-cert-file /path/server-chain.pem --tls-key-file /path/server-key.pem run examples/tls_server.v -- build/new-tls-port.txt

The wrapper is the fully automated route; it generates temporary credentials, discovers ephemeral ports, bounds every wait, and removes the temporary files.

Verified evidence and limits of coverage

Final full verifier: 2764/2764 checks per artifact on release and ASan/UBSan; no compiler warnings or sanitizer diagnostics in passing runs. Breakdown:

Corpus Checks per artifact
Core 183
Utilities / permissions / worklog 173 / 16 / 130
Prior TCP / DNS-TLS 73 / 70
Three prior demos 3
Foundations / three new demos 79 / 3
Cache differential against six baselines 339 each, 2034 total

The cache corpus is repeated against pre-optimization, pre-utilities, pre-worklog, pre-sockets, pre-dns-tls and pre-network-suite; these totals are not counts of unique programs. Pre-network-suite additionally passes 73 old TCP and 70 old DNS/TLS cases outside per-candidate totals. All 17 original/baseline files have unchanged SHA-256 hashes, including the parent-preserved pre-network-suite artifacts.

Evidence: docs/foundations-verification.json (commands, counts, durations, binary hashes, log paths), docs/foundations-preservation.json, docs/foundations-tests*.json, docs/foundations-demos*.json, and docs/foundations-cleanup.json. Raw full-suite logs are build/foundations-*.log. build/foundations-red.log records the initial expected rejection of the then-unimplemented API. Early fixture failures are kept in build/foundations-initial-fixture-failures.log: a TLS 1.3 ticket legitimately made an idle read pending, and a fixed sleep failed to force write backpressure under the slower sanitizer build. The final retry test synchronizes draining on an observed WANT; it checks 6,553,600 exact nonuniform bytes with distinct block tags, accepted/completed totals and no duplication. No sanitizer was suppressed.

SPEC reset regression correction

A real loopback SO_LINGER reset reproduced a plaintext tryrecv transport error that left the copied handle and owned read buffer live. The read catch now erases the handle for both transports; local shared ownership releases the FD and buffers on unwinding. Preflight validation remains outside that catch. trysend and staged handshake transport catches already erase unconditionally; their behavior was not changed. Legacy blocking plaintext recv/send implementations were left untouched. The existing contract above has not been weakened.

Five added cases check real reset receive/send failures with exact unchanged JSON and raw output sentinels, invalidation of original and copied handles, 40 reset cycles under RLIMIT_NOFILE=20 without explicit successful closes, old blocking plaintext recv retaining its alias, and recv/send/handshake preflight preserving outputs and a usable handle. The repeated run exceeds the live handle/FD limits if ownership leaks; ASan leak detection is enabled. Fixtures join peers and use bounded waits, with no reset synchronization sleeps.

Before the fix, both artifacts passed 2/4 reset cases and failed the two receive cases; afterward both pass 4/4 reset cases and 1/1 preflight case. Logs are build/foundations-reset-red-{release,asan}.log, build/foundations-reset-green-{release,asan}.log, and build/foundations-reset-preflight-{release,asan}.log. The reviewer's unchanged reproducer prints alias invalidated afterward (its bug-expecting final assertion then intentionally exits 1); before/after output is preserved in build/foundations-reset-reviewer-{before,after}-{release,asan}.log.

The initial repeated fixture exceeded cumulative timeout reservations; its failed log is retained as build/foundations-reset-initial-budget-fixture-failure.log. Reservations were reduced to 300 ms per operation, not removed. The first full verification hit an existing HTTP self-signed fixture timeout (172/173 utilities); its log and partial JSON are preserved in build/foundations-reset-initial-{full,utilities}-failure.log and build/foundations-reset-initial-full-failure.json. Utilities passed on retry without source changes, then the complete verifier passed, including both sets of demos. Final transcript: build/foundations-reset-full-verifier.log.

Targeted reruns (from the repository root):

make release sanitize
python3 tests/test_foundations.py ./build/vanilla1 'TCP real reset'
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 python3 tests/test_foundations.py ./build/vanilla1-asan 'TCP real reset'
python3 tests/test_foundations.py ./build/vanilla1 'TCP preflight'
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1 python3 tests/test_foundations.py ./build/vanilla1-asan 'TCP preflight'
python3 tests/verify_foundations.py

The real MemoryBIO fixture sends only a TLS record header plus one byte, requires Vanilla to write a response while its read is pending, then releases the remaining record. Vanilla preserves the original read arguments, rejects a changed read limit and conflicting legacy I/O, and receives the exact application bytes. This guards against a full-duplex architecture that silently deadlocks on read/WANT_READ.

Reused installed dependencies: GCC 13.3.0, OpenSSL 3.0.13, c-ares 1.27.0; no packages were installed for this stage. No public endpoint, broadcast packet, raw socket, remote deployment or production change was used. No surviving fixture interpreter was found after the full run; fixture contexts also join threads and close sockets.

Limitations: no deterministic key-update-induced read/WANT_WRITE or write/WANT_READ fixture, forced allocator failure, OS EINTR injection, UDP send-queue wouldblock injection, large fuzz campaign, or whole-process RSS guarantee. Installed dependency libraries are not sanitizer-instrumented. Handshake crypto and filesystem certificate reads are cooperative, not preemptible deadlines. No mutual TLS/client identity verification, graceful drain guarantee, asynchronous DNS/connect API, or optional blocking TLS-server convenience accept is supplied. Existing bounded connect and legacy operations remain available, but must not be placed in a latency-sensitive event loop. Stages after these foundations remain pending parent dispatch/review.