IO channel layer (emp-tool/runtime/io/)
July 19, 2026 · View on GitHub
Conventions for NetIO and any code that uses it. Read this when
writing protocol code that does sends / recvs / flushes across multiple
threads, or when investigating a NetIO deadlock.
Flush contract
Callers MUST call flush() at the end of any protocol step that ends
in sends, before returning to the caller or blocking on anything other
than a recv on the same NetIO. The auto-flush only fires when the same
NetIO does a recv (recv_data_internal drains pending sends via
flush_unlocked() before blocking on the read); a
NetIO whose step is purely sends — e.g. the IKNP receiver-role channel
during setup_recv — strands its tail bytes in the user-space
send_buf until something else moves them.
~NetIO flushes, so pure send-then-destruct works. Long-lived NetIOs
with a mid-life send-only step do not: the destructor only runs at
end-of-life, which doesn't happen until the protocol completes, which
can't complete because the peer is blocked on bytes stuck in send_buf.
This is a circular-wait deadlock, not "data eventually arrives slowly".
Rule of thumb: if the next thing you do on this NetIO isn't a recv, flush first. Applies to function boundaries, phase boundaries within a function, and any blocking wait (thread join, barrier, recv on a different NetIO).
test/runtime/test_netio.cpp runs the correctness + send-only-regression coverage.
Throughput lives separately in bench/bench_netio.cpp.
Thread-safety
NetIO is NOT thread-safe. The user-space send_buf coalescing has no
per-call lock; concurrent send_data / recv_data / flush from two
threads on the same instance corrupts the buffer. Each instance must be
owned by one thread at a time — flush() counts as a "touch" and is
unsafe to call from a thread other than the one currently sending.
Thread-safety contract
A channel is not thread-safe: send-buffer coalescing is unlocked, and
TLSIO's SSL* mutates internal state on every read and write. Only one
thread may touch a given channel at a time; sequential hand-off between
threads is fine. Threaded consumers take a channel per thread —
make_sibling() on NetIO, or a per-lane channel at the protocol layer.
Races on a shared channel are not detected at runtime; when debugging a
threaded consumer, build with -fsanitize=thread.
Fiat–Shamir transcript
The IOChannel base can keep two running hash transcripts — one over
every byte sent, one over every byte received — for deriving
Fiat–Shamir challenges from the wire. Off by default;
enable_fs<opt>(send_first) turns it on.
Cross-party contract: both parties MUST select the same backend opt
(default EMP_FS_HASH_DEFAULT, set stack-wide via CMake EMP_FS_HASH)
or every derived challenge diverges. Exactly one party passes
send_first=true — it fixes the concatenation order so both ends
compute the same digest. enable_fs is single-shot: calling it twice
asserts.
get_digest() returns the first 16 B of H(d_AB ‖ d_BA) over both
directions and is non-destructive (call it repeatedly to derive
sub-challenges across stages). get_send_digest() / get_recv_digest()
are per-direction snapshots for diagnostics. All three assert that
enable_fs ran first.
Other base surface
sync(): optional 1-byte ping/pong handshake to confirm both directions are alive. NetIO implements it; the base default is a no-op.- Telemetry: the base tracks
send_counter/recv_counter/rounds/flushes_count;get_statistics_string()renders them for logging (~NetIOprints it unless constructedquiet). - NetIO factories:
NetIO::listen(port)/NetIO::connect(addr, port)are named, ownership-returning replacements for the nullptr-means-server constructor; repeatedmake_sibling()calls on the primary or any related sibling open additional duplex channels to the same peer on the same port. Related server channels share listener ownership; the listener closes with the last one. This avoids closing, rebinding, or sleeping between accepts. - Multiple channels — recommended pattern: to run several channels to
the same peer, keep one anchor NetIO alive and take the others via
make_sibling(), calling it serially and in the same order on both parties (its accept/connect pairing is FIFO on the shared listener — concurrentmake_sibling()from multiple threads is not deterministic). Do not rely on closing every channel and reopening a new one on the same port as the coordination mechanism. That reopen path is supported and race-free — each connection is only considered established once the peer has actuallyaccept()ed it (a one-byte accept acknowledgement intcp_socket.hprotects against aconnect()landing on the previous, now-stale listener) — but the anchor +make_siblingpattern is simpler and avoids the reconnect entirely. TraceIO(trace_io.h): anIOChannelthat tees every wire byte to<prefix>.send/<prefix>.recvfiles for diff-based wire-equivalence checks; seetest_mode.md.
TLS variant
TLSIO (in emp-tool/runtime/io/tls_io_channel.h) is another IOChannel
implementation for deployments where the wire crosses an untrusted network. Same flush
contract, same single-thread-owned discipline, same telemetry counters
as NetIO; the only difference is the wire — TLS 1.3 over OpenSSL
instead of raw TCP. Pin both ends of the protocol-version range to
TLS 1.3 (no negotiation surface), use the default socket BIO (we
already coalesce above SSL_write so BIO_f_buffer would just double-
buffer), and do a two-phase SSL_shutdown from the destructor so
buffered records actually leave the box before FIN.
Cert / key / CA material is caller-supplied via TLSConfig (PEM file
paths). mTLS is on by default — each side verifies the peer's
certificate chain against the configured CA; clear require_peer_cert
on the server to make client-cert presentation optional, or set
insecure_skip_verify in dev only. Verification is chain-only: no
expected-hostname / SAN / fingerprint binding, so any certificate
issued by the configured CA authenticates — the CA must be
deployment-private (see the README's Security section). One SSL_CTX
per channel; if a profile shows the per-channel cert-parse cost
mattering, share a CTX via a thin factory.