Architecture
Nacelle is organized as a small core plus protocol-specific transport crates.
Crate Layout
nacelle-core: shared handler, request/response body, limits, lifecycle, telemetry, and provider-neutral TLS metadata.nacelle-openssl: OpenSSL configuration reload and negotiated metadata extraction.nacelle-rustls: Rustls configuration reload, certificate parsing, SNI policy, and negotiated metadata extraction.nacelle-tcp: TCP/Unix socket server, protocol trait, connection loop, and listener runtime.nacelle-http: Hyper HTTP/1 server, HTTP request policy, and HTTP TLS listener integration.nacelle: convenience crate withcore,codec,tcp,http, andruntimecapability namespaces.examples/nacelle-reference-protocol: unpublished length-delimited protocol fixture used by examples, tests, benchmarks, and stress tools.examples/nacelle-examples: unpublished runnable examples and benchmarks.
The reference protocol intentionally stays out of nacelle-core and
nacelle-tcp. It demonstrates the public protocol and codec contracts without
becoming part of the published library API.
App Core And Protocol Adapters
Nacelle is organized so application behavior lives behind statically dispatched
handler boundaries. TCP handlers receive TcpRequestContext<P> and complete
requests with the response type associated with P. HTTP handlers receive
HttpRequestContext<State> and complete through HttpResponse.
TCP Protocol implementations are adapters: they decode a wire format into
request metadata and encode responses back into frames. Swapping protocols
should not require rewriting the app core. The app-first serving path wires
concrete typed servers together with
NacelleApp::new().tcp(...).http(...).run(). The app owns shared runtime state,
telemetry, shutdown, and supervision. nacelle::runtime::NacelleHost remains
available for services that need manual listener control.
Provider-neutral TLS identity and per-connection metadata live in nacelle-core.
Concrete configuration, certificate handling, reload policy, and negotiated
metadata extraction live in nacelle-rustls and nacelle-openssl. Transport
crates retain listener lifecycle and async I/O adaptation so provider crates do
not depend back on TCP or HTTP. The nacelle facade preserves the rustls,
openssl, and tls-self-signed feature names and exposes provider namespaces.
Request Flow
listener
-> connection limit
-> connection task
-> protocol/HTTP decode
-> request limit
-> handler
-> response body encode/stream
TCP and Unix socket listeners use the nacelle-tcp Protocol trait and its
associated request, response, and connection-state types to decode request
heads and encode bounded response frames. HTTP uses nacelle-http with Hyper
HTTP/1 and a transport-owned typed request/response pipeline.
Connection metadata carries the transport, a stable connection id, listener
label, peer and local addresses, local Unix socket path, effective peer IP, and
TLS metadata. The typed TCP pipeline presents this immutable metadata to
handlers as ConnectionInfo through RequestContext.
The pipeline model for connection-local application state is
ConnectionContext<State>. TCP protocols construct Protocol::ConnectionState
once per accepted connection. TcpServer and LocalTcpServer expose shared
state as Arc<P::ConnectionState>. SerialTcpServer and
LocalSerialTcpServer instead lend &mut ConnectionContext<P::ConnectionState>
to exactly one awaited handler at a time, avoiding an async mutex for state
confined to one serial connection loop. No dynamic extension map participates
in either request path.
The shared multi-thread Tokio runtime remains the default. Experimental
thread-per-core execution is explicit and currently supports TCP, HTTP, Rustls
TCP/HTTPS, required OpenSSL TCP, and optional plaintext/OpenSSL TCP on Linux. Each selected worker owns a
current-thread Tokio runtime, LocalSet, reuse-port listener, protocol, and
LocalHandler pipeline. Accepted streams, handshakes, and connection tasks
remain on the accepting worker. Unsupported platforms fail configuration;
Nacelle does not silently switch runtime topology.
Serial mutable-state listeners support plain TCP, required OpenSSL, optional OpenSSL detection, and Unix sockets in the shared runtime. Worker-local serial listeners support plain TCP, required OpenSSL, and optional OpenSSL detection. Rustls serial and worker-local Unix socket serial variants are not exposed.
ThreadPerCoreConfig::with_max_threads(...) caps the selected worker set after
automatic or explicit selection and before any worker thread is created. It
does not configure the caller-owned shared Tokio runtime.
Thread-per-core resource accounting is selected statically at startup. Global
mode shares all existing counters. Worker mode partitions finite connection,
request, streaming, and per-peer capacities in configured worker order while
retaining a single shared FIFO hard memory ceiling when experimental-memory
is enabled.
Worker factories execute once per worker. Process-wide client pools, backend
limits, and other external resource budgets must be shared explicitly when they
must not scale with worker count.
HTTP-specific edge policy remains in nacelle-http: Host, method, URI/header
shape checks, per-peer request rate limits, access logging, and security header
injection. TCP keeps protocol semantics in the protocol implementation and
shared lifecycle/limit enforcement in core.
Runtime State
NacelleRuntimeState owns shared budgets and counters. Connection, request, and
streaming-task limits are non-blocking atomic bounded counters. With the
non-default experimental-memory feature, memory uses a checked allocation
guard that releases on drop.
This keeps the common request path allocation-light while still enforcing bounded defaults.
Bodies
NacelleBody has three internal shapes:
- empty/single chunk for fast small responses
- buffered chunks for decoded TCP bodies already in memory
- streaming channel for request/response bodies that move asynchronously
With experimental-memory, TCP streaming request bodies reserve their declared
length by default. Set
TcpStreamingBodyMemoryPolicy::LiveChunks to reserve each chunk before it is
detached from read-ahead or allocated for a socket read. The charge then follows
the chunk through the body channel and any application-owned Bytes clones.
HTTP request bodies reserve Content-Length when Hyper exposes a bounded size hint.
TCP protocols can override Protocol::max_request_body_bytes(...) to choose a
phase-aware body limit from the decoded head, immutable connection metadata,
and concrete connection state immediately after head decoding and before
body-specific allocation or additional body reads.
Shutdown
Listeners own a JoinSet of accepted connection tasks. Shutdown proceeds in
stages:
- signal shutdown
- stop accepting
- drain active connection tasks
- abort remaining tasks after the drain deadline
- emit shutdown telemetry
Task tracking is at the connection boundary, not the per-request hot path.
Observability
Telemetry is deliberately low-cardinality. Reasons are static strings such as
connections, request_body_bytes, or http_body_read.
Nacelle emits through the backend-neutral metrics facade and does not own an
exporter. With experimental-memory, runtime state and shared memory budgets
cache a memory gauge handle and update it at existing acquire/release
transitions. NacelleTelemetry owns lifecycle,
request, phase, error, and byte metrics for all transports. Transports that can
provide extra low-cardinality detail attach a NacelleMetricsContext with
listener, protocol, transport, and TLS labels. Applications must install their
recorder before constructing these values so cached handles bind to it; without
a recorder the handles are no-ops.
NacelleTelemetryConfig separates connection, request, runtime, error, and TCP
phase-duration metric domains. All domains except phase durations are enabled by
default for compatibility. Request sub-switches live under request_metrics:
started/completed counters and byte counters are on by default; in-flight
counters and duration histograms are disabled by default. with_metrics(false)
gates every domain without replacing those individual settings, so re-enabling
metrics restores the configured domain policy. Telemetry observers are
independent and continue receiving events while metrics are disabled.
TCP phase histograms additionally require the non-default phase-timing Cargo
feature. Enable them deliberately with NacelleTelemetry::default() builder
methods on the server or app when you need diagnostic detail and can afford the
extra timers and metric writes. Without phase-timing, TCP phase timer storage
and Instant calls are not compiled. Core/HTTP request paths do not start a
request timer unless duration metrics or HTTP access logging are enabled.