Architecture

Hopf is a thread-per-core multi-protocol networking framework on mio. Listen and dial are equal bindings on one Runtime. Protocol code sees plaintext handlers and Streams; TLS/QUIC, buffers, affinity, and backpressure stay below that line. It reimplements Gumdrop’s architectural contracts in Rust.

Purpose

The default topology is a graph of endpoints on one Runtime: N listeners and M dialers in one process are normal (peer / P2P compositions), not a third stack. Clients routinely host multiple protocols together — at minimum DNS under dial.

Many Internet application protocols share the same transport model: TLS/QUIC where appropriate, transport-level backpressure, worker/storage pools independent of connection count, and plain buffers (&[u8] / pooled owned buffers) rather than a proprietary buffer world.

Hopf is a re-implementation of Gumdrop’s techniques, protocol work, and architectural contracts, with Rust ownership and TPC execution — not a line-by-line port of Java types.

Thread-per-core

Thread-per-core Runtime Runtime fans out to AcceptLoop, Workers, and StorageExecutor. Accept and dial land on a reactor that owns the connection for life, then Endpoint and ProtocolHandler. Runtime AcceptLoop add_tcp_listener ACL · rate limit · RR Workers reactors 0 … N−1 timers · TLS · UDP StorageExecutor submit(…) bounded blocking pool affinity → Reactor i owns Conn Endpoint ProtocolHandler HTTP Stream FTP SMTP DNS IMAP and more
One Runtime: accept, dial, and storage meet reactors that keep connection affinity for life.
Piece Role
Accept loop Binds sockets, applies ACL / rate limits, round-robins accepts onto workers
Worker reactor Owns connection affinity; timers, TLS I/O, UDP (DNS), execute hops
Dial RR Separate round-robin for outbound connects
StorageExecutor Bounded blocking pool; results hop back via Endpoint::execute / ConnHandle
QUIC driver Dedicated hopf-quic thread; still exposes Endpoint per bi-stream. TLS 1.3 only, PQC-first hybrid ML-KEM KX

Optional future: io_uring completion paths for Linux behind the same Endpoint / handler traits — evolution, not a day-one requirement.

I/O substrate (mio)

Build on mio (epoll / kqueue readiness) with an in-tree executor, timer, and registration model. Hopf uses mio only, not the Tokio runtime.

This preserves the readiness mental model (interest ops, fill buffer, parse, write queue, backpressure), portable development (including macOS), and a near-zero dependency surface for the reactor.

Accept loops, per-core reactors, timers, cross-thread queues, buffer pools, and listen/dial APIs are implemented in-tree. Reactor/callback pattern for all I/O.

Storage and DNS

mio covers sockets, not arbitrary filesystem I/O. Mail, FTP, WebDAV, and large body paths use an explicit storage executor: blocking or off-loop file work, never on the reactor thread.

Name resolution is Runtime substrate, not a bolt-on product:

See DNS, runtime options.

Layering

Crate layering Stack from hopf-core up through crypto transports, protocol services, HTTP Streams, and Stream consumers. hopf-auth sits beside core with no core dependency. hopf-webdav · hopf-websocket · hopf-grpc · hopf-otel Stream consumers apps hopf-http — ServerHandler / ClientHandler HTTP Streams · H1 / H2 / H3 adapters streams hopf-ftp · hopf-smtp · hopf-mqtt · hopf-amqp · … Protocol services wire hopf-core::tls/dtls TCP+UDP · in-tree, AWS-LC hopf-quic QUIC · in-tree, PQC-first TLS 1.3 crypto hopf-core Runtime · Endpoint · ProtocolHandler · Composition · Storage · ACL core hopf-auth · TrustPolicy / SASL · no core dep
Protocol crates sit on crypto transports and hopf-core; Stream consumers ride HTTP Streams.

Transport vs HTTP Stream

Layer Concepts
A — Transport (hopf-core / hopf-quic) Endpoint (byte stream); TCP; UDP datagrams; QUIC stream endpoint; multiplexed QUIC connection
B — HTTP Stream (hopf-http) One request/response exchange; HttpStream + ServerHandler / ServerWriter, ClientHandler / ClientWriter — version- and transport-agnostic

H1 adapts a TCP Endpoint into serialised Streams; H2 multiplexes Streams on one TCP Endpoint; H3 maps each request to a QUIC stream Endpoint. Bind vs dial only affects how the transport was born; server/client role is a separate axis. Terminology: HTTP Stream ≠ H2 stream id ≠ QUIC stream endpoint.

Endpoint vs ProtocolHandler

Type Role
Endpoint Transport face: send, close, start_tls, pause/resume read, on_write_ready, execute, schedule_timer, handle, peer addrs, security_info
ProtocolHandler Protocol FSM: connected, receive, disconnected, security_established, error

HTTP adapters (H1Endpoint, H2Endpoint, CleartextHttpEndpoint, AlpnHttpEndpoint, H3 handlers) are ProtocolHandlers that drive Stream handlers. receive uses NIO compact semantics: advance the &mut &[u8] cursor; unconsumed suffix is preserved.

Listen and dial are peers

Listen Dial
TcpListenerConfig TcpConnectorConfig
Runtime::add_tcp_listener Runtime::connect
Composition::listen_tcp Composition::dial_tcp
listen_quic / listen_h3 connect_quic / connect_h3
ServerHandler ClientHandler

Both reduce to registering a stream on a reactor with lifetime affinity.

Module layout: server and client as peers

Gumdrop assumes server functionality by default, with client as a submodule. Hopf deliberately diverges: within a protocol crate, server (bind) and client (dial) are equal, not a default role plus an add-on. This is a module-layout axis distinct from the listen/dial API axis above — it governs where code lives, not how a socket was born. Three layouts are legitimate, chosen per crate by how tightly client and server share wire state:

Layout When to use it
Sibling modules
client/, server/, shared code at crate root
Client and server are separate objects with separate state machines. Reference implementations: hopf-dns (client/, server/, shared wire/, dnssec/, cache.rs), hopf-imap (client/, server/, shared capability.rs, idle.rs, …).
Role-parameterised file
one type/file, constructed as either role
Client and server share one wire FSM and splitting it would fragment that state machine. E.g. hopf-core::tls (acceptor_from_pem / connector_from_pem), hopf-quic (listen_quic / connect_quic), hopf-http's H1Endpoint::server() / ::client(), hopf-websocket's WsUpgradeHandler::server() / ::client(), and each hopf-auth SASL mechanism (PlainServer / PlainClient in one file — SASL client/server are wire roles, see auth).
Single role
no split, one role only
The protocol has no counterpart role in-tree: hopf-webdav (server-only filesystem handler), hopf-otel (client-only exporter; hopf does not run a collector), hopf-mailbox (storage backend, not a network protocol).

Anti-pattern: server code flat at crate root with no server/ name, plus a client/ submodule bolted on — server-as-unmarked-default is exactly the Gumdrop shape this axis exists to avoid. Crates in this shape get a server/ module carved out of their root files, mirroring their existing client/.

Dynamic bindings and composition

All bindings are dynamic. Listen and dial are added/removed through Runtime APIs while the process is alive. Configuration is a script over those APIs (Rust main / builder, or XML). Service orchestrates lifecycle; it does not own an immutable listener list as source of truth.

Composition startup flow main starts the Runtime, builds a Composition with TrustPolicy TLS and DNS, adds listen and dial bindings plus handler factories, then waits or shuts down. main entry Runtime::start reactors up Composition TrustPolicy · TLS · DNS add bindings (listen / dial) handler factories (closed registry) wait / shutdown
Configuration is a script over Runtime APIs — Rust builder or XML via tractrix, same destination.

Details: composition, services, clients.

Auth vocabulary

Prefer TrustPolicy + IdentityMaterial over Realm-as-root naming. Attach to both listen and dial. SASL client/server are wire roles, independent of listen/dial. See auth.

Codec style

Grammar-driven incremental codecs: each protocol owns a self-contained parser (H1Scanner, Pop3ServerLexer, SmtpServerLexer, FtpServerLexer, ImapServerLexer, …) with its own token alphabet and bounded in-progress-token scratch state; update the parse FSM as each production completes; CRLF is one production, not the only event boundary. A parser consumes every byte it's handed in a single call and never asks the caller to retain or re-supply anything. Early parse state ≠ early app callbacks. Sibling incremental parsers (tractrix, rjsonparser, rmimeparser, rprotobuf) follow the same push model for XML/JSON/MIME/protobuf.

Security substrate

Encrypted transport and shared authentication crypto are part of hopf-core's contract — not a bolt-on crate graph. Gumdrop treated TLS and DTLS as framework-transparent; Hopf follows the same idea with Rust ownership and TPC reactors.

Today

Direction

The AWS-LC consolidation and in-tree TCP TLS / QUIC work below are done. What remains:

Other transports (SSH/SFTP, DNSCrypt, CoAP/OSCORE) share the primitive layer but bring their own framing and key exchange — they are not TLS handshakes.

Status table: Conformance → Security substrate.

Dependencies

Allowed today (hard)

Dependency Role Planned fate
mio Readiness networking (TPC reactors) Keep
aws-lc-rs / aws-lc-sys Native crypto (AWS-LC / BoringSSL lineage) — TCP TLS, DTLS, and QUIC all use it directly Keep — centralised through hopf-core / hopf-quic
rustls-native-certs OS trust store loading for X.509 verification Keep
rustls Workspace dev-dependency only: socket-level TCP TLS interop tests against hopf-core::tls (independent peer implementation) Keep for test coverage

Sibling parsers (when features need them)

Crate Role
tractrix Incremental XML (WebDAV + composition)
rjsonparser Incremental JSON
rmimeparser Incremental MIME / RFC 5322
rprotobuf Incremental protobuf (e.g. OTLP)

Explicit non-dependencies

Async app runtimes (Tokio et al.), Hyper, Axum, Tower, serde as architecture, the quinn/quinn-proto crates (fully replaced by hopf-quic's in-tree RFC 9000 transport), servlet/JSP/Java EE APIs, reflective XML DI, TOML/YAML as primary composition formats.

Non-goals

Further reading