nacelle documentation
nacelle is an experimental Tokio-based Rust library for streaming application handlers across TCP and HTTP transports.
This book is the narrative documentation site. It follows the same broad delivery model as the Rust Book: chapter-oriented Markdown, search, keyboard navigation, and a local/offline build. Its content is organized like Django's documentation:
- Tutorials take you through a working path.
- Topic guides explain concepts and design choices.
- How-to guides solve specific operational tasks.
- Reference pages document exact behavior and APIs.
Rust API reference is still generated separately with cargo doc.
Start here
If you are new to nacelle, read:
If you are validating performance, read:
Internal readiness plans and assessments live under docs/internal and are not
part of this public book.
Getting started
This tutorial gets a minimal TCP service running with the repository's example length-delimited protocol.
Add nacelle
In this workspace, the umbrella crate is nacelle. The unpublished reference
protocol package is an example consumer of its TCP and codec APIs:
#![allow(unused)] fn main() { use nacelle::core::pipeline::handler_fn; use nacelle::core::{NacelleError, NacelleTelemetry}; use nacelle::tcp::{TcpRequestContext, TcpResponse, TcpServer}; use nacelle::NacelleApp; use nacelle_reference_protocol::LengthDelimitedProtocol; }
Build a handler
TCP handlers receive a protocol-specific TcpRequestContext and must complete
it through its typed responder.
#![allow(unused)] fn main() { let handler = handler_fn( |mut context: TcpRequestContext<LengthDelimitedProtocol>| async move { while let Some(chunk) = context.request_mut().body.next_chunk().await { let _ = chunk?; } context.respond(TcpResponse::bytes("ok")).await }); }
Start the app
#![allow(unused)] fn main() { let addr = "127.0.0.1:8080".parse().map_err(NacelleError::protocol)?; let server = TcpServer::<LengthDelimitedProtocol>::builder() .protocol(LengthDelimitedProtocol) .handler(handler) .build()?; NacelleApp::with_telemetry(NacelleTelemetry::default()) .with_ctrl_c_shutdown() .tcp("echo", addr, server) .run() .await?; Ok::<(), NacelleError>(()) }
For application dependencies, construct the app with
NacelleApp::with_state(...), annotate the handler context as
TcpRequestContext<LengthDelimitedProtocol, AppState>, and borrow the root with
context.app_state(). The same typed root is available to every TCP and HTTP
listener registered on that app.
Next steps
- Run
cargo run -p nacelle-examples --bin app_coreto see one app core served through multiple protocol adapters. - Read the architecture guide to understand the request path.
- Read runtime limits and backpressure before raising connection counts.
- Use Run the stress harness to validate a local build.
Run the stress harness
The stress harness has two binaries:
nacelle-stress-server, fromnacelle-stress-servernacelle-stress-test, fromnacelle-stress-test
Run the convenience script:
./examples/run-stress-test.sh
The script reads root config.toml by default. Pass --config to select a
repeatable benchmark profile. If the effective tls_self_signed value is true,
it passes --tls-insecure to the client so the server and client speak the
same transport.
For a plain TCP baseline, use
examples/nacelle-stress-server/configs/tcp.toml.
For full details, see the how-to guide:
Run the server:
cargo run --release --package nacelle-stress-server -- --config examples/nacelle-stress-server/configs/tcp.toml
Run a bounded client smoke test:
cargo run --release --package nacelle-stress-test -- --connections 32 --pipeline 16 --duration-secs 15
The examples/run-stress-test.sh and examples/run-stress-test.ps1 helpers
accept --config/-Config and pass --tls-insecure to the stress client only
when the effective tls_self_signed value is true.
The stress client enables its Rustls support by default so --tls-insecure
works with the local self-signed server. For a Rustls-free plain TCP build, run
both stress binaries with --no-default-features and use
examples/nacelle-stress-server/configs/tcp.toml.
Repeatable profiles:
examples/nacelle-stress-server/configs/tcp.toml: plain TCP baseline.examples/nacelle-stress-server/configs/tcp-low-memory.toml: plain TCP with mimalloc low-memory behavior and experimental runtime memory accounting.examples/nacelle-stress-server/configs/tcp-tls.toml: TCP wrapped in self-signed TLS.
Linux example:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml --server-threads 48 --connections 256 --pipeline 8 --duration-secs 30 --payload-bytes 256
PowerShell example:
.\examples\run-stress-test.ps1 -Config examples/nacelle-stress-server/configs/tcp.toml -ServerThreads 48 -Connections 256 -Pipeline 8 -DurationSecs 30 -PayloadBytes 256
The stress server installs a metrics-util recorder and prints a compact
metrics snapshot every 5 seconds. It enables request
started/completed counters plus request/response byte counters by default. The
generic telemetry API groups those switches under request_metrics; the stress
server exposes byte accounting as byte_metrics = true.
Use --no-byte-metrics for a lower-overhead recorder run. Use
--no-default-features with the plain TCP config for a Rustls-free,
system-allocator baseline; metrics collection remains active.
The launch helpers enable experimental-memory automatically when an effective
config contains max_memory_bytes. For a direct low-memory server run, pass
--features experimental-memory explicitly.
The Tokio stress server default build includes tls-self-signed support. The
checked-in root config.toml enables tls_self_signed = true, so the local
stress client should use --tls-insecure with that default config. Use
--no-default-features with examples/nacelle-stress-server/configs/tcp.toml when
you need a Rustls-free plain TCP baseline.
CI-friendly scenarios should stay short and deterministic:
- baseline echo throughput
- max connection cap
- max request cap
- slow reader
- slow writer
- graceful shutdown under load
Heavy RPS and soak tests should run manually or nightly on dedicated Linux hosts.
Serve TCP with self-signed TLS
Use tls-self-signed for local load tests and auto-deploy flows that need a
certificate immediately. It implies the rustls provider.
#![allow(unused)] fn main() { use nacelle::core::pipeline::handler_fn; use nacelle::core::NacelleError; use nacelle::rustls::NacelleTlsConfig; use nacelle::tcp::{TcpRequestContext, TcpResponse, TcpServer}; use nacelle_reference_protocol::LengthDelimitedProtocol; let generated = NacelleTlsConfig::self_signed(["localhost", "127.0.0.1"])?; let server = TcpServer::<LengthDelimitedProtocol>::builder() .protocol(LengthDelimitedProtocol) .handler(handler_fn( |mut context: TcpRequestContext<LengthDelimitedProtocol>| async move { let mut response = Vec::new(); while let Some(chunk) = context.request_mut().body.next_chunk().await { response.extend_from_slice(&chunk?); } context.respond(TcpResponse::bytes(response)).await })) .build()?; server .serve_tcp_tls("127.0.0.1:8443".parse()?, generated.tls_config) .await?; Ok::<(), NacelleError>(()) }
Self-signed certificates are for local and automated test flows. Public edge deployments should use managed certificate material and a documented rotation process.
For OpenSSL-backed TCP TLS, enable openssl and use
NacelleOpenSslConfig::from_pem_files(...) with serve_tcp_openssl(...). Use
openssl-vendored only when the build machine has the tooling needed to compile
OpenSSL from source. Select either rustls or openssl; the backends are
mutually exclusive compile-time choices and cannot be swapped at runtime.
How the documentation is organized
This book uses four kinds of documentation:
- Tutorials are guided paths. They assume little context and aim for a working result.
- Topic guides explain how nacelle works and why it is shaped the way it is.
- How-to guides are recipes for specific tasks.
- Reference pages are precise descriptions of behavior, APIs, and protocol contracts.
Use tutorials when you are new, topic guides when you need a model of the system, how-to guides when you have a concrete job, and reference pages when you need exact details.
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 negotiated TLS connection 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, AppState> and
complete requests with the response type associated with P. HTTP handlers
receive HttpRequestContext<ConnectionState, AppState> and complete through
HttpResponse. Both state parameters default to ().
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(). Applications that need shared
dependencies start with NacelleApp::with_state(...). The app owns one stable
Arc<AppState> shared by every registered listener, plus runtime state,
telemetry, shutdown, and supervision. Handlers receive only &AppState through
RequestContext::app_state(); Nacelle provides no mutable accessor or runtime
replacement for the whole dependency root. nacelle::runtime::NacelleHost
remains available for services that need manual listener control.
Reloadable configuration belongs behind the application root rather than in
Nacelle. A configuration service may use ArcSwap or another snapshot mechanism
internally. A handler should acquire one owned snapshot for a request and avoid
holding a reload guard across .await.
Per-connection TLS metadata lives 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 exposes mutually exclusive rustls and openssl
features plus the Rustls-based tls-self-signed helper. The backend is fixed at
compile time; there is no runtime provider abstraction or selection path. The
workspace defaults to its Rustls members, while validation runs OpenSSL through
explicit package and feature lanes.
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 requires experimental-thread-per-core 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.
Optional plaintext/OpenSSL detection requires
experimental-openssl-detection; required OpenSSL remains available with the
ordinary openssl feature.
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. LocalTcpRuntimeConfig::with_state(...) and
LocalHttpRuntimeConfig::with_state(...) share one Arc<AppState> across
workers while protocol, handler, and connection state remain worker-local.
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.
Runtime limits and backpressure
Runtime limits are enforced through NacelleRuntimeState. They are intended to
make overload predictable rather than perfectly invisible.
Key budgets include:
- active connections
- in-flight requests
- streaming body tasks
- optional per-peer connections
- experimental runtime memory budget allocations
- request and response body size
- core handler timeout
- TCP read, write, final shutdown, and idle timeouts through
NacelleTcpLimits - HTTP header, body, write, keep-alive, and connection-age limits through
NacelleHttpLimits - TLS handshake timeouts through the TLS config types
The important production habit is to size limits together. A high connection count with large read and response buffers is a memory budget decision, not just a concurrency decision.
For configuration details:
Start from NacelleLimits::default() and tune shared resource budgets for the
deployment. Use NacelleTcpLimits for TCP socket timeouts and
NacelleHttpLimits for HTTP edge timeouts and keep-alive behavior. Active
connections, in-flight requests, streaming tasks, body sizes, handler timeouts,
and transport timeouts are bounded by default. Runtime memory budgeting is
compiled only with the non-default experimental-memory feature. Without that
feature, memory fields, allocation APIs, transport accounting, ownership
tracking, waiters, and the memory gauge are absent.
The feature is use at your own risk and may change or be removed in a future
minor release.
Optional deadlines can be disabled without direct field mutation. Use
NacelleLimits::without_handler_timeout(), the TCP
without_read_timeout(), without_write_timeout(),
without_shutdown_timeout(), and without_idle_timeout() builders, and the
corresponding HTTP without_*_timeout() or
without_max_connection_age() builders. Keep bounded defaults for public-edge
listeners unless another layer enforces an equivalent deadline.
Recommended presets:
- Internal service: keep defaults, set body limits to the largest expected payload, and run behind process supervision.
- Internet-facing behind proxy: cap connections and requests to the container budget, keep 30 second transport timeouts, and let the proxy own coarse traffic filtering or certificate automation when desired.
- Proxy-aware HTTP: configure
NacelleHttpPolicy::with_trusted_proxy_ips(...)only with known proxy addresses before allowingForwardedorX-Forwarded-Forto affect per-peer request limits or request metadata. - Direct HTTPS listener: enable
http,rustls, load certificate/key material throughNacelleTlsConfig, configure an SNI allowlist withfrom_pem_with_allowed_server_namesorfrom_der_with_allowed_server_names, set a short TLS handshake timeout, configuremax_connections_per_peerandmax_connection_opens_per_peer_per_second, enable HTTP access logs, and attachNacelleHttpPolicywith Host, method, URI, header, security-header, and per-peer request-rate limits. - Direct TCP Rustls listener: enable
tcp,rustls, load certificate/key material throughNacelleTlsConfig, register it withNacelleApp::tcp_tls(...), and keep protocol-level authentication/authorization in the application protocol. - Direct TCP OpenSSL listener: enable
tcp,openssl, load certificate/key material throughNacelleOpenSslConfig, register it withNacelleApp::tcp_openssl(...), and configure theSslAcceptoryourself when you need OpenSSL-specific policy. - Local load-test/autodeploy HTTPS: enable
tls-self-signedand callNacelleTlsConfig::self_signed(...); do not treat generated certificates as a public trust or rotation strategy. - High concurrency: reduce TCP buffer capacities before raising
max_connections, and tuneNacelleTcpLimitsseparately from shared resource budgets.
Experimental memory budget:
connection_budget =
max_connections * (read_buffer_capacity + response_buffer_capacity)
body_budget =
concurrent_buffered_or_streaming_bodies * max_request_body_bytes
total_budget =
connection_budget + body_budget + handler/backend/runtime headroom
Enable experimental-memory, then set
NacelleLimits::with_max_memory_bytes(...) to activate enforcement. With the
feature enabled, its default max_memory_bytes is usize::MAX, so accounting
does not reject allocations until an explicit finite limit is configured.
Nacelle allocates from that budget for connection buffers and buffered or
streaming request bodies. The limiter accounts for Nacelle-managed allocations,
not total process RSS, so keep process or container memory limits in place.
Request body allocations wait in FIFO order when the budget is full. The default
wait limit is NacelleLimits::memory_allocation_timeout == Some(5s), and can
be tuned with with_memory_allocation_timeout(...) or disabled with
without_memory_allocation_timeout(). A timed-out waiter returns
NacelleError::Timeout(NacelleTimeoutReason::MemoryAllocation).
The memory budget is an accounting guard, not a buffer allocator: it grants a
NacelleMemoryAllocation that tracks bytes the transport or application intends to
hold elsewhere, and releases those bytes when the guard is dropped.
When Nacelle associates an allocation with NacelleBody, chunks extracted from
the body retain the allocation through their underlying Bytes ownership.
Dropping or consuming the body does not release the charge while an extracted
chunk or any clone of that chunk remains live.
Applications can allocate from the same budget through
NacelleRuntimeState::memory_budget(). Use try_allocate(...) for immediate
admission, allocate(...) for FIFO waiting, or
allocate_with_timeout_and_shutdown(...) when app work should stop waiting
during shutdown.
TCP processes requests sequentially per connection. request_body_channel_capacity controls the queued streaming chunks between the socket reader and handler. HTTP uses Hyper's internal buffers plus Nacelle's body queue, so leave extra headroom when enabling large request bodies.
TCP streaming bodies use TcpStreamingBodyMemoryPolicy::DeclaredLength by
default, preserving whole-body admission before handler dispatch. The opt-in
LiveChunks policy admits bodies larger than the currently available memory
budget when each chunk fits: it charges chunks being read, queued, or retained
by the handler and releases each charge after the final Bytes clone drops.
request_body_chunk_size sets the allocation granularity. A handler that keeps
earlier chunks while awaiting the rest of a body can exhaust its own memory
budget and reach memory_allocation_timeout; use declared-length accounting or
enough body headroom for handlers that aggregate complete payloads.
For TCP protocols, NacelleLimits::max_request_body_bytes is the default body
limit. Override
Protocol::max_request_body_bytes(request, connection, state, default_limit) to
choose a per-request limit from the decoded head, immutable connection metadata,
and concrete connection state before body-specific allocation or additional
body reads. There is no dynamically typed connection extension.
With experimental-thread-per-core, server factories execute once per
configured worker. Nacelle's global or partitioned runtime counters do not
partition external client pools or backend resources automatically; pass
explicitly shared resources into worker factories when process-wide budgets
must remain global.
Cap Nacelle-owned worker threads after any worker-selection strategy with
ThreadPerCoreConfig::with_max_threads(...). The effective capped worker count
must also be used when constructing ThreadPerCoreLimits::worker(...). Shared
runtime threads belong to the caller's Tokio runtime and are configured there.
Dangerous configurations:
- unbounded connections with large per-connection buffers
- large body limits without a process/container memory limit
- disabled timeouts on internet-facing listeners
- direct internet-facing HTTP without Host/header/method/URI policy
- direct internet-facing TLS without an SNI allowlist
- direct internet-facing listeners without per-peer connection caps
- direct internet-facing listeners without per-peer connection-open rate caps
- direct internet-facing HTTP without per-peer request caps and access logs
- trusting forwarded peer headers without an explicit trusted proxy list
- generated self-signed certificates used as a long-lived public-edge certificate strategy
- high keep-alive connection counts without proxy-level idle limits
TLS certificate rotation:
#![allow(unused)] fn main() { let tls = NacelleTlsConfig::from_pem_files("cert.pem", "key.pem")?; tls.reload_from_pem_files("next-cert.pem", "next-key.pem")?; }
Reloads affect new TLS handshakes. Existing connections continue with the configuration negotiated when they connected.
Operations model
Deployment Shape
Recommended internet-facing shape:
client -> proxy/load balancer/TLS -> Nacelle service
The proxy should own TLS, coarse connection filtering, and external idle timeouts. Nacelle owns application limits, protocol handling, body limits, and graceful shutdown.
Startup
Use explicit limits and print the effective config for stress or benchmark services. For production services, record:
- process version and git SHA
- configured limits
- listener addresses
- feature flags
- allocator settings
Thread-per-core mode requires experimental-thread-per-core, is experimental,
and is Linux-only. Select workers explicitly, record logical CPU ids and
affinity settings, and treat any bind, affinity, or worker initialization
failure as a whole-runtime startup failure. TCP, HTTP, Rustls TCP/HTTPS,
required OpenSSL TCP, and optional plaintext/OpenSSL TCP have worker-local
stacks; optional OpenSSL detection additionally requires
experimental-openssl-detection. Performance qualification remains under
implementation. Use ThreadPerCoreConfig::with_max_threads(...) to cap any
selected worker set; configure the caller-owned Tokio builder separately for
shared-runtime thread limits.
Shutdown
Use NacelleApp::with_ctrl_c_shutdown() for the standard signal path, or pass a
shared NacelleShutdown through NacelleApp::with_shutdown(...). Configure the
drain deadline with with_shutdown_drain_timeout(...). Advanced manual hosts
can use nacelle::runtime::NacelleHost::shutdown_and_wait_timeout(...). Short
deadlines protect deploy velocity but can abort in-flight work.
Expected shutdown telemetry:
- shutdown requested
- listener stopped accepting
- drain started
- drain completed or timed out
- active connections aborted
Metrics To Watch
server.connection.activeserver.request.activeserver.streaming_task.activeserver.memory.usageserver.connection.acceptedserver.connection.closedserver.request.startedserver.request.completedserver.connection.rejectedserver.request.rejectedserver.request.timed_outserver.timeoutsserver.request.failedserver.request.body.sizeserver.response.body.size
Alerts should focus on sustained saturation, rising rejections, timeout spikes, and memory approaching the configured budget.
Benchmarking
Nacelle emits metrics through the metrics facade according to
NacelleTelemetryConfig. Connection, runtime, and error domains are on by
default. Request metrics are grouped under request_metrics: started,
completed, and byte_counts are on by default, while in_flight and
duration_ms are opt-in. TCP phase histograms require the non-default
phase-timing Cargo feature and explicit runtime activation.
Use NacelleTelemetry::default().with_metrics(false) to suppress all Nacelle
metric domains while retaining any application recorder. This global gate does
not erase individual domain settings, and telemetry observers remain active.
Use with_connection_metrics, with_request_metrics, with_runtime_metrics,
with_error_metrics, and with_phase_duration_metrics for independent policy.
A shared NacelleRuntimeState has one runtime-metric policy; configure servers
sharing that state consistently before serving traffic.
The stress server installs a debugging recorder and prints a compact console snapshot every 5 seconds. Production applications should install their chosen recorder before constructing Nacelle runtime state, telemetry, or servers. If no recorder is installed, facade handles are no-ops.
Request duration metrics remain opt-in through NacelleTelemetryConfig. With
the default config, core/HTTP request paths avoid request timer work unless HTTP
access logging is enabled.
Compile and activate TCP phase timing only for a diagnostic build:
[dependencies]
nacelle = { version = "0.3.0", features = ["phase-timing"] }
#![allow(unused)] fn main() { let telemetry = NacelleTelemetry::default() .with_phase_duration_metrics(true); }
The server.phase.duration_ms histogram uses a low-cardinality phase label:
| Phase | Boundary |
|---|---|
socket_read | One completed transport read, including asynchronous wait but excluding decode. |
decode | One protocol decoder invocation; a request may require more than one invocation. |
request_body_read | Request-body assembly or remaining streaming-body drain. May include socket_read operations. |
handler | The awaited application handler, including application body consumption and response construction. |
response_encode | One synchronous protocol response-frame encoder invocation. |
socket_write | One response write batch or explicit transport flush, including asynchronous wait. |
These are operation histograms, not a per-request trace. Do not add their
percentiles to infer round-trip latency: pipelining can decode several requests
from one read, streaming overlaps body reads with the handler, and response
coalescing can write several completed requests in one batch. Use
server.request.duration for server request processing and client-side
latency for actual round-trip time.
The server cannot measure TCP handshake duration because the kernel completes
it before accept() returns. Connection accepted, active, and closed metrics
remain available; TLS handshake timing is not currently emitted as a phase.
Canonical metric names are resource-first. Instrument type is documented here rather than embedded in the metric name:
| Metric | Type | Notes |
|---|---|---|
server.connection.active | Gauge | Current active connections. Listener-labeled series provide transport-level detail; unlabeled series represent runtime permit usage. |
server.request.active | Gauge | Current active requests. Protocol-labeled series provide request-level detail; unlabeled series represent runtime permit usage. |
server.streaming_task.active | Gauge | Current runtime streaming body tasks. |
server.memory.usage | Gauge (By) | Current bytes allocated by runtime memory accounting; emitted only with experimental-memory. |
server.connection.accepted | Counter | Accepted connections, labeled by listener/transport/TLS where available. |
server.connection.closed | Counter | Closed connections, labeled with close reason where available. |
server.connection.rejected | Counter | Connections rejected before acceptance. |
server.request.started | Counter | Requests started. |
server.request.completed | Counter | Requests completed, labeled by status where available. |
server.request.rejected | Counter | Requests rejected before handler execution. |
server.request.timed_out | Counter | Request failures caused by a timeout, labeled by operation. |
server.request.failed | Counter | Requests failed before normal completion. |
server.request.body.size | Histogram (By) | Request body size per completed request. |
server.response.body.size | Histogram (By) | Response body size per completed response. |
server.request.duration | Histogram (s) | Request duration, opt-in. |
server.phase.duration_ms | Histogram | TCP operation duration; requires compile-time and runtime opt-in. |
Run microbenchmarks before and after hot-path changes:
cargo bench -p nacelle-examples --features "bench tcp experimental-memory"
Performance model
nacelle's high-throughput TCP path is sensitive to small per-request costs. When comparing runs, keep these variables fixed:
- commit
- Linux kernel and CPU governor
- allocator configuration
- server threads
- connection count
- pipeline depth
- payload size
- TLS versus plain TCP
- stress client version
Use the performance how-to for repeatable command lines.
Do not use a repository-wide RPS number as a baseline. Compare commits on the same host with the same kernel, governor, allocator, features, transport/TLS mode, configuration, workload, and client revision.
Suggested local benchmark:
cargo bench -p nacelle-examples --features "bench tcp experimental-memory"
The runtime_limits benchmark group covers connection/request permit
acquire/drop and memory allocation overhead. Watch it closely after changes to
NacelleRuntimeState.
Successful-path ownership and dispatch
The default successful TCP and HTTP pipelines use concrete protocol, handler, responder, body, and telemetry observer types. Nacelle does not box handler futures or dynamically dispatch those contracts.
Retained costs are scoped by ownership:
- TCP connection buffers and decoder state are created per connection and reused across requests. Oversized response frames and optional input-buffer rotation can replace those buffers deliberately.
- A non-empty HTTP request uses one bounded Tokio channel for the request-body bridge; an exact zero-length body skips the channel and producer work, while the small scoped future still completes immediately.
- HTTP response bodies remain concrete
StreamBodyvalues rather than boxed body trait objects. - Request/handler/read/write timeouts use concrete futures. The HTTP response
write deadline retains a boxed
Sleeponly after connection-level backpressure because Hyper requires its I/O wrapper to remainUnpin. - With
experimental-memory, memory-wait queue allocation and its boxed timeout occur only under contention; the available-capacity path is atomic and allocation-free. - Enabled per-peer request and connection-open rate limits use fixed-capacity, lock-free tables. Admission probes a bounded number of atomic slots and rejects a newly observed peer when the configured table is full; it does not take a per-request mutex or sweep every tracked peer.
- Type-erased protocol/handler errors are constructed only on error paths.
- TCP response coalescing is opt-in. It queues only complete bounded frames, retains overflow memory guards until flush, and restores socket backpressure at thresholds, streaming waits, and socket-read boundaries. Overflow grows geometrically through an old-plus-replacement memory-accounted transaction.
- With
experimental-memory, TCP streaming body accounting reserves the declared body length by default.TcpStreamingBodyMemoryPolicy::LiveChunksis opt-in and performs one shared memory-budget acquire/release per chunk so accounting follows queued chunks and application-ownedBytesclones. - App listener installers and worker thread closures erase startup-only closure types; they are not involved in request dispatch.
- Optional tracing, Hyper, Tokio, TLS providers, allocators, and metrics recorders retain their own external indirection.
TCP computes effective telemetry modes once per connection and constructs one
NacelleMetricsContext containing cached facade handles. Without an installed
recorder, metric writes through those handles are no-ops. Connection/request
permits update cached runtime gauges at their existing state transitions.
Memory accounting does the same only with experimental-memory. Diagnostic TCP phase timers are compiled only with
the non-default phase-timing feature and remain runtime-disabled until
explicitly enabled on NacelleTelemetry.
Enable the buffer-rotation feature for long-lived TCP connections that may
occasionally receive large requests. Once an oversized cumulative input buffer
is empty, Nacelle replaces it with a buffer sized to read_buffer_capacity.
Leave the feature disabled when retaining peak buffer capacity is preferable to
allocating again after traffic spikes.
Suggested RPS comparison:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml --server-threads 48 --connections 256 --pipeline 8 --duration-secs 30 --payload-bytes 256
The stress server installs a metrics-util debugging recorder and prints a
compact console snapshot every 5 seconds. Request metrics are grouped under the generic telemetry
request_metrics config; started/completed counters and byte counters are on by
default, while in-flight and duration metrics remain opt-in. Request duration
metrics are opt-in as well, which avoids request Instant work on core/HTTP
paths unless duration metrics or HTTP access logs are enabled. Use
--no-byte-metrics when comparing the cost of byte accounting. Use the
telemetry_paths benchmark for a no-recorder facade baseline; the stress server
intentionally installs its console recorder in every feature set.
The checked-in root config.toml enables self-signed TCP TLS for local
stress runs. For the plain TCP throughput baseline, use
examples/nacelle-stress-server/configs/tcp.toml. Compare TLS and non-TLS runs
separately:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp-low-memory.toml
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp-tls.toml
The examples/run-stress-test.sh and examples/run-stress-test.ps1 helpers
apply root config.toml first, then the selected profile, and choose the
matching client mode automatically. They also enable experimental-memory
when either effective config contains max_memory_bytes.
Guardrails:
- keep shutdown task tracking at the connection/listener boundary
- avoid per-request locks in the TCP hot path
- keep telemetry observers optional; default operation uses
NoopObserver - preserve single-chunk body fast paths
- tune TCP buffer sizes for the connection count instead of relying on large defaults
Migrate from 0.3.0-rc.1 to 0.3.0-rc.2
Pin the release candidate exactly while evaluating it:
nacelle = { version = "=0.3.0-rc.2" }
A broad 0.3 requirement does not select Cargo prereleases. Keep the same
feature set that was validated with RC.1.
RC.2 preserves the Rust API, limits, timeout defaults, and TLS feature
relationships from RC.1. It adds compiler-pressure and correctness regressions,
changes the emitted metrics schema, and rejects oversized declared HTTP request
bodies with 413 Payload Too Large before handler dispatch. Applications that
do not instantiate affected serial connection futures, consume Nacelle metrics,
or rely on handling those oversized requests need no source migration.
Raise the recursion limit for serial connection futures
RC.2 increases the type and layout depth of serial connection futures. A crate that instantiates one of these futures can exceed rustc's default query-depth limit during monomorphization, including under strict Clippy builds. This is a compile-time limit and does not indicate runtime recursion or a runtime failure.
Add the following inner attribute to the root of each affected binary or library
crate (main.rs or lib.rs):
#![allow(unused)] #![recursion_limit = "256"] fn main() { }
The attribute applies to the crate that compiles the concrete application future; setting it in a dependency does not propagate to consumers.
Update metric names
Metric names are now singular and resource-first:
| RC.1 | RC.2 |
|---|---|
nacelle.connections.opened | nacelle.connection.opened |
nacelle.connections.accepted | nacelle.connection.accepted |
nacelle.connections.active | nacelle.connection.active |
nacelle.connections.in_flight | nacelle.connection.active |
nacelle.connections.closed | nacelle.connection.closed |
nacelle.requests.started | nacelle.request.started |
nacelle.requests.in_flight | nacelle.request.active |
nacelle.requests.completed | nacelle.request.completed |
nacelle.requests.failed | nacelle.request.failed |
nacelle.streaming_tasks.active | nacelle.streaming_task.active |
nacelle.memory.used_bytes | nacelle.memory.usage |
nacelle.request.duration_ms | nacelle.request.duration |
RC.2 also separates rejected connections, rejected requests, and timed-out
requests into nacelle.connection.rejected, nacelle.request.rejected, and
nacelle.request.timed_out.
Update metric types and units
Request and response body measurements changed from cumulative byte counters to per-request histograms:
| RC.1 | RC.2 |
|---|---|
nacelle.request.bytes counter | nacelle.request.body.size histogram in bytes |
nacelle.response.bytes counter | nacelle.response.body.size histogram in bytes |
nacelle.request.duration_ms histogram in milliseconds | nacelle.request.duration histogram in seconds |
Update dashboards, recording rules, alerts, and exporters together. Do not sum the new body-size histograms as if they were the former cumulative counters. During a rolling deployment, query RC.1 and RC.2 series separately or use an explicit compatibility recording rule; the runtime does not emit both schemas.
The complete RC.2 schema and label guidance are in Operations model.
Migrate from 0.3.0-beta.5 to 0.3.0-rc.1
The 0.3.0-rc.1 stabilization track deliberately changes several pre-release
APIs. Wire behavior and bounded defaults remain unchanged. Update feature
selection, application state, error matching, and direct construction of
extensible types before upgrading.
To evaluate the exact freeze candidate, pin =0.3.0-rc.1. A broad 0.3
requirement does not opt into Cargo prereleases.
Select one TLS backend
TLS is now a graph-wide compile-time choice. Enable exactly one backend:
# HTTP or TCP with Rustls
nacelle = { version = "=0.3.0-rc.1", default-features = false, features = ["tcp", "http", "rustls"] }
# TCP with OpenSSL
nacelle = { version = "=0.3.0-rc.1", default-features = false, features = ["tcp", "openssl"] }
Remove the former tls umbrella feature, NacelleTlsProvider, and calls to
NacelleTlsConfig::provider() or NacelleOpenSslConfig::provider(). Backend
selection comes from Cargo features and the concrete configuration type. A
dependency graph that enables both rustls and openssl is rejected at compile
time. HTTP TLS requires Rustls; TCP TLS supports either backend.
Enable experimental APIs explicitly
Linux thread-per-core APIs now require experimental-thread-per-core:
nacelle = { version = "=0.3.0-rc.1", features = ["tcp", "experimental-thread-per-core"] }
Plaintext/OpenSSL detection and optional-OpenSSL listener APIs now require
experimental-openssl-detection, which enables TCP and OpenSSL:
nacelle = { version = "=0.3.0-rc.1", default-features = false, features = ["experimental-openssl-detection"] }
These gates make the experimental boundary explicit; the gated APIs remain available when their feature is enabled.
Move dependencies into typed application state
Code that captured shared dependencies in each handler can move them into one
application-owned root. Add the root as the final request-context type parameter
and borrow it with app_state():
#![allow(unused)] fn main() { use nacelle::NacelleApp; use nacelle::core::pipeline::handler_fn; use nacelle::tcp::{TcpRequestContext, TcpResponse, TcpServer}; struct AppState { response_prefix: &'static [u8], } let handler = handler_fn( |context: TcpRequestContext<MyProtocol, AppState>| async move { let prefix = context.app_state().response_prefix; context.respond(TcpResponse::bytes(prefix)).await }, ); let server = TcpServer::<MyProtocol>::builder() .protocol(MyProtocol) .handler(handler) .build()?; NacelleApp::with_state(AppState { response_prefix: b"service: ", }) .tcp("service", address, server) .run() .await?; Ok::<(), nacelle::core::NacelleError>(()) }
NacelleApp, NacelleHost, TCP handler/context types, and HTTP handler/context
types retain () as their default state, so applications without dependencies
need no state-related changes. NacelleApp shares one stable root through
Arc; there is no mutable accessor or runtime replacement of the root. Put
reloadable configuration behind an application-owned service in that root.
Low-level code that calls RequestContext::new(...) directly must now pass an
Arc<AppState> instead of an inline state value. Because access may dereference
an Arc, RequestContext::app_state() is no longer a const fn.
Match structured failure reasons
NacelleError::ResourceLimit and NacelleError::Timeout no longer contain raw
strings. Match the corresponding non-exhaustive reason enum:
#![allow(unused)] fn main() { use nacelle::core::{NacelleError, NacelleTimeoutReason}; match error { NacelleError::Timeout(NacelleTimeoutReason::Handler) => { // Apply handler-timeout policy. } NacelleError::Timeout(reason) => { tracing::warn!(reason = reason.as_str(), "operation timed out"); } _ => {} } }
Use NacelleResourceLimitReason::Other("application_reason") or
NacelleTimeoutReason::Other("application_reason") for application-defined
static reasons. Keep these values bounded and low-cardinality. Use as_str()
for stable telemetry or log labels; do not parse Display or hint() text.
Use builders for extensible values
Connection metadata, ConnectionInfo, telemetry events and event kinds, and
TCP/Unix listener option structs are now non-exhaustive. Replace external struct
literals with new, Default, conversions, and with_* builders. Add wildcard
arms when matching non-exhaustive enums:
#![allow(unused)] fn main() { match event.kind { KnownKind => handle_known(), _ => handle_other(), } }
Disable timeouts explicitly
Timeout defaults remain bounded. Applications that intentionally require no deadline can use the new consuming builders:
NacelleLimits::without_handler_timeout()NacelleTcpLimits::without_read_timeout()NacelleTcpLimits::without_write_timeout()NacelleTcpLimits::without_shutdown_timeout()NacelleTcpLimits::without_idle_timeout()NacelleHttpLimits::without_header_read_timeout()NacelleHttpLimits::without_request_body_read_timeout()NacelleHttpLimits::without_response_write_timeout()NacelleHttpLimits::without_max_connection_age()
Disabling an internet-facing deadline weakens resource protection. Keep an equivalent upstream or application deadline where appropriate.
Compatibility review
The stabilization review compared the public API of all seven published crates
against 0.3.0-beta.5 with cargo-public-api. Rustls and OpenSSL surfaces were
reviewed separately, and the newly gated experimental surfaces were snapshotted
with their features enabled.
nacelle-codechas no public API changes.nacelle-rustlsandnacelle-opensslonly remove runtime provider accessors.- Core and transport changes are limited to structured reasons, application state, non-exhaustive types, and additive timeout-disable builders.
- Facade removals in ordinary feature lanes are the newly gated experimental APIs; those APIs remain present in their explicit feature lanes.
No unclassified public API removal was found. Re-run the application test suite under its selected TLS backend and explicit experimental features before deploying the upgrade.
Configure production limits
Start from NacelleLimits::default() and tune shared resource budgets for the
deployment. Use NacelleTcpLimits for TCP socket timeouts and
NacelleHttpLimits for HTTP edge timeouts and keep-alive behavior. Active
connections, in-flight requests, streaming tasks, body sizes, handler timeouts,
and transport timeouts are bounded by default. Runtime memory accounting is
experimental and not compiled by default. Enable experimental-memory and set
max_memory_bytes only after measuring the limiter for your service. This
feature is use at your own risk and may change or be removed in a future minor
release.
Recommended presets:
- Internal service: keep defaults, set body limits to the largest expected payload, and run behind process supervision.
- Internet-facing behind proxy: cap connections and requests to the container budget, keep 30 second transport timeouts, and let the proxy own coarse traffic filtering or certificate automation when desired.
- Proxy-aware HTTP: configure
NacelleHttpPolicy::with_trusted_proxy_ips(...)only with known proxy addresses before allowingForwardedorX-Forwarded-Forto affect per-peer request limits or request metadata. - Direct HTTPS listener: enable
http,rustls, load certificate/key material throughNacelleTlsConfig, configure an SNI allowlist withfrom_pem_with_allowed_server_namesorfrom_der_with_allowed_server_names, set a short TLS handshake timeout, configuremax_connections_per_peerandmax_connection_opens_per_peer_per_second, enable HTTP access logs, and attachNacelleHttpPolicywith Host, method, URI, header, security-header, and per-peer request-rate limits. - Direct TCP Rustls listener: enable
tcp,rustls, load certificate/key material throughNacelleTlsConfig, register it withNacelleApp::tcp_tls(...), and keep protocol-level authentication/authorization in the application protocol. - Direct TCP OpenSSL listener: enable
tcp,openssl, load certificate/key material throughNacelleOpenSslConfig, register it withNacelleApp::tcp_openssl(...), and configure theSslAcceptoryourself when you need OpenSSL-specific policy. - Optional TCP OpenSSL listener: enable
experimental-openssl-detection(which impliestcp,openssl) and useserve_tcp_optional_openssl(...)or the matching host/app builder method when one listener must accept both plain and TLS clients; keepNacelleTlsDetectionOptions::timeoutshort enough to avoid tying up idle accepted connections. - IPv4 plus IPv6 TCP bind: use the
NacelleApp::*_dual_stack(...)helpers to register separate IPv4 and IPv6 listeners while forcing the IPv6 listener to v6-only mode. - Unix socket listener: enable
tcpon Unix and callNacelleApp::unix_socket(...); useNacelleUnixSocketOptionsonly when this process owns stale-path cleanup or socket-file permissions. - Local load-test/autodeploy HTTPS: enable
tls-self-signedand callNacelleTlsConfig::self_signed(...); do not treat generated certificates as a public trust or rotation strategy. - High concurrency: reduce TCP buffer capacities before raising
max_connections, and tuneNacelleTcpLimitsseparately from shared resource budgets.
Experimental memory budget:
connection_budget =
max_connections * (read_buffer_capacity + response_buffer_capacity)
body_budget =
concurrent_buffered_or_streaming_bodies * max_request_body_bytes
total_budget =
connection_budget + body_budget + handler/backend/runtime headroom
The APIs in this section require the non-default experimental-memory feature.
Set NacelleLimits::with_max_memory_bytes(...) when you want Nacelle to enforce
the calculated budget. Without the feature, Nacelle still enforces
connection/request/body limits and transport-owned timeouts but leaves memory
governance to the application, runtime, process supervisor, or container.
When the memory budget is full, request body allocations wait in FIFO order and
time out after NacelleLimits::memory_allocation_timeout (5s by default).
Tune this with with_memory_allocation_timeout(...), or call
NacelleRuntimeState::memory_budget() when application code needs to allocate from
the same budget as the transports.
TCP processes requests sequentially per connection. request_body_channel_capacity controls the queued streaming chunks between the socket reader and handler. HTTP uses Hyper's internal buffers plus Nacelle's body queue, so leave extra headroom when enabling large request bodies.
For TCP protocols, NacelleLimits::max_request_body_bytes is the default body
limit. Override
Protocol::max_request_body_bytes(request, connection, state, default_limit)
when the decoded request head, immutable connection metadata, or concrete
Protocol::ConnectionState should choose a stricter phase-specific cap. The
hook runs before body-specific allocation or additional body reads; normal
decoder read-ahead may already have placed bytes in the connection buffer.
Use NacelleTcpOptions for accepted TCP stream behavior. Defaults preserve the
existing behavior: TCP_NODELAY enabled and TCP keepalive disabled. Enable
keepalive deliberately per deployment target because OS defaults and supported
fields vary. NacelleTcpBindOptions adds listener bind controls such as
IPv6-only mode for APIs that need explicit family behavior.
NacelleTcpConfig::response_write_policy defaults to
ResponseWritePolicy::Immediate. Select CoalesceBuffered or
FlushAtBytes(n) only for measured workloads with already-buffered request
bursts. Coalescing preserves complete-frame order and flushes before waiting for
more socket input; streaming responses flush before awaiting the next body
chunk. Larger thresholds can delay earlier responses until a threshold or batch
boundary and apply the write timeout to the complete queued batch. Growth above
response_buffer_capacity is memory-accounted transactionally with
experimental-memory: the budget must temporarily cover both the current batch
allocation and its complete replacement. Size the base buffer near a measured
batch size when using larger thresholds.
Use NacelleTcpLimits for TCP socket read, socket write, final writer shutdown,
and idle timeouts. Set shutdown_timeout independently when finalization needs
a shorter deadline than ordinary response delivery. Disable these only through
the corresponding without_*_timeout() builders when an explicitly unbounded
policy is required.
Use NacelleHttpLimits on HyperServer for HTTP header read, request body
read, response write, keep-alive, and max connection age behavior. Use its
without_*_timeout() and without_max_connection_age() builders to disable
optional deadlines, and NacelleLimits::without_handler_timeout() for handler
execution.
Dangerous configurations:
- unbounded connections with large per-connection buffers
- large body limits without a process/container memory limit
- disabled timeouts on internet-facing listeners
- direct internet-facing HTTP without Host/header/method/URI policy
- direct internet-facing TLS without an SNI allowlist
- direct internet-facing listeners without per-peer connection caps
- direct internet-facing listeners without per-peer connection-open rate caps
- direct internet-facing HTTP without per-peer request caps and access logs
- trusting forwarded peer headers without an explicit trusted proxy list
- generated self-signed certificates used as a long-lived public-edge certificate strategy
- high keep-alive connection counts without proxy-level idle limits
- long TLS detection timeouts on optional TLS listeners
- Unix stale-path cleanup for a socket path not exclusively owned by this process
TLS certificate rotation:
#![allow(unused)] fn main() { let tls = NacelleTlsConfig::from_pem_files("cert.pem", "key.pem")?; tls.reload_from_pem_files("next-cert.pem", "next-key.pem")?; }
Reloads affect new TLS handshakes. Existing connections continue with the configuration negotiated when they connected.
Run stress tests
Run the server:
cargo run --release --package nacelle-stress-server -- --config examples/nacelle-stress-server/configs/tcp.toml
Run a bounded client smoke test:
cargo run --release --package nacelle-stress-test -- --connections 32 --pipeline 16 --duration-secs 15
The examples/run-stress-test.sh and examples/run-stress-test.ps1 helpers
accept --config/-Config and pass --tls-insecure to the stress client only
when the effective tls_self_signed value is true.
The stress client enables its Rustls support by default so --tls-insecure
works with the local self-signed server. For a Rustls-free plain TCP build, run
both stress binaries with --no-default-features and use
examples/nacelle-stress-server/configs/tcp.toml.
Repeatable profiles:
examples/nacelle-stress-server/configs/tcp.toml: plain TCP baseline.examples/nacelle-stress-server/configs/tcp-low-memory.toml: plain TCP with mimalloc low-memory behavior and experimental runtime memory accounting.examples/nacelle-stress-server/configs/tcp-tls.toml: TCP wrapped in self-signed TLS.
Linux example:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml --server-threads 48 --connections 256 --pipeline 8 --duration-secs 30 --payload-bytes 256
PowerShell example:
.\examples\run-stress-test.ps1 -Config examples/nacelle-stress-server/configs/tcp.toml -ServerThreads 48 -Connections 256 -Pipeline 8 -DurationSecs 30 -PayloadBytes 256
The stress server installs a metrics-util recorder and prints a compact
metrics snapshot every 5 seconds. It enables request
started/completed counters plus request/response byte counters by default. The
generic telemetry API groups those switches under request_metrics; the stress
server exposes byte accounting as byte_metrics = true.
Use --no-byte-metrics for a lower-overhead recorder run. Use
--no-default-features with the plain TCP config for a system-allocator,
Rustls-free diagnostic; metrics collection remains active. Add
--features mimalloc-allocator when the baseline must keep mimalloc while
disabling TLS.
The launch helpers enable experimental-memory automatically when an effective
config contains max_memory_bytes. For a direct low-memory server run, pass
--features experimental-memory explicitly.
TCP phase timing is excluded from the default build. Compile and activate it only for a diagnostic run:
cargo run --release -p nacelle-stress-server --features phase-timing -- \
--config examples/nacelle-stress-server/configs/tcp.toml \
--phase-duration-metrics
The five-second metrics snapshot then prints count, mean, minimum, and maximum for each observed phase. Use an external metrics backend for percentiles and longer retention. The phase timers and histogram writes affect the measured hot path, so do not compare this run directly with a phase-timing-free throughput baseline.
The Tokio stress server default build includes tls-self-signed support. The
checked-in root config.toml enables tls_self_signed = true, so the local
stress client should use --tls-insecure with that default config. Use
--no-default-features with examples/nacelle-stress-server/configs/tcp.toml when
you need a Rustls-free plain TCP baseline.
CI-friendly scenarios should stay short and deterministic:
- baseline echo throughput
- max connection cap
- max request cap
- slow reader
- slow writer
- graceful shutdown under load
For plain-TCP connection churn, cap each connection at one request and use pipeline depth 1:
cargo run --release --package nacelle-stress-test -- \
--connections 64 \
--pipeline 1 \
--requests-per-connection 1 \
--duration-secs 30
The summary reports completed_connections and connection_rate. Churn mode
does not currently support TLS; use the persistent TLS profile for transport
throughput and latency evidence.
Heavy RPS and soak tests should run manually or nightly on dedicated Linux hosts.
For response-delivery comparisons, the stress server accepts
--response-write-mode immediate (the default) or
--response-write-mode coalesce-buffered. The same setting can be placed in a
server config as response_write_mode. Coalescing drains complete requests
already present in the socket read buffer and flushes before awaiting more
input, so it does not leave a single response waiting for a later request. It
is intended for measured pipelined workloads; keep immediate delivery for
latency-first or unmeasured workloads.
The Linux profiling helper records this setting and also exposes shared versus serial handler dispatch for controlled diagnostics:
./scripts/profile-linux.sh \
--tool baseline \
--handler-mode shared \
--response-write-mode coalesce-buffered \
--pipeline 8 \
--runs 3
Add --feature-set default for mimalloc. To measure the
self-signed Rustls config, also pass
--config examples/nacelle-stress-server/configs/tcp-tls.toml and
--tls-insecure. The latter disables certificate verification and is only for
the local generated certificate. The stress client flushes each populated
request window before reading responses so buffered TLS records cannot strand
deeply pipelined workers.
Compare performance profiles
Use separate profiles for each transport mode:
- plain TCP
- TCP with low-memory allocator behavior
- TCP with TLS
- HTTP
Do not compare TLS and non-TLS runs as if they measure the same path. Likewise, do not compare two runs if the stress client version changed.
Recommended plain TCP baseline config:
examples/nacelle-stress-server/configs/tcp.toml
Then run:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml --connections 256 --pipeline 8 --duration-secs 30 --payload-bytes 256
Record the tested commit, kernel, CPU model/topology, CPU governor, Rust toolchain, allocator, features, configuration, connection count, pipeline depth, payload, TLS provider, and client revision with every result. A throughput number without that context is not a comparable baseline.
Suggested local benchmark:
cargo bench -p nacelle-examples --features "bench tcp experimental-memory"
For the codec/TCP integration specifically, run both Criterion targets:
cargo bench -p nacelle-codec --bench framed_comparison --all-features
cargo bench -p nacelle-examples --bench critical_paths --features "bench tcp experimental-memory"
The telemetry group can be run independently:
cargo bench -p nacelle-examples --bench critical_paths --features "bench tcp experimental-memory" -- telemetry --noplot
The TCP crate also measures one complete connection plus one request through the metrics facade, with independent phase-timing coverage:
cargo bench -p nacelle-tcp --bench telemetry_paths --features "buffer-rotation experimental-memory phase-timing rustls tls-self-signed" -- --noplot
Response delivery policies have a separate end-to-end benchmark:
cargo bench -p nacelle-tcp --bench response_delivery -- --noplot
Compare a tag or commit
The PowerShell comparison scripts keep data under
target/performance-comparisons, resolve tags to immutable commit hashes, and
use detached temporary worktrees so the current checkout is not modified. Each
revision receives an isolated Cargo target directory; only Criterion baseline
data is copied into the candidate target before comparison, preventing build
artifacts from being reused across worktrees.
Capture all Criterion suites for the current commit, a release tag, or another commit:
./scripts/capture-performance-baseline.ps1
./scripts/capture-performance-baseline.ps1 -Reference v0.3.0
./scripts/capture-performance-baseline.ps1 -Reference 00747f3
With no parameters, HEAD is captured in a detached worktree. Uncommitted
working-tree changes are intentionally excluded from that baseline.
Compare the current working tree with a captured baseline:
./scripts/compare-performance.ps1 -BaselineReference v0.3.0
Compare two committed revisions without checking either one out:
./scripts/compare-performance.ps1 `
-BaselineReference v0.3.0 `
-CandidateReference 0123456789abcdef
Use -Suite to limit both capture and comparison to one or more matching
suites: codec, critical-paths, telemetry, or response-delivery. For
example:
./scripts/capture-performance-baseline.ps1 `
-Reference v0.3.0 `
-Suite critical-paths,telemetry
./scripts/compare-performance.ps1 `
-BaselineReference v0.3.0 `
-Suite critical-paths,telemetry
The default capture runs every suite available at the selected commit. The
critical-paths suite follows its historical move from nacelle to
nacelle-examples. The telemetry and response-delivery suites are omitted for
commits that predate those benchmark targets. When comparison omits -Suite,
it uses exactly the suites recorded by the baseline, so newer-only targets do
not invalidate an older baseline. Within a shared suite, Criterion compares
matching benchmark IDs and measures newer IDs without a delta when no baseline
exists for them.
Each capture records the resolved commit, Rust toolchain, operating system,
architecture, CPU information, selected suites, and full benchmark log. Each
comparison records the same candidate metadata and Criterion's percentage and
confidence-interval output. Use -Force to replace an existing baseline for
the same resolved commit.
Run native host workloads
On a dedicated Linux host, the native workload harness detects physical cores, SMT siblings, sockets, NUMA nodes, memory, and CPU governors. It reserves one physical core per NUMA node for the operating system and uses one logical CPU per remaining physical core. Review the generated plan without building or running workloads:
./scripts/run-native-performance.ps1 -PlanOnly
Run the default capacity matrix with a warm-up before every measured sample:
./scripts/run-native-performance.ps1 `
-Runs 3 `
-WarmupSecs 15 `
-DurationSecs 30
The harness defaults to three 30-second measured runs per workload. The default capacity matrix uses 50 persistent connections, pipeline depth 1, worker counts of 1, 2, 4, 8, 16, 32, and 36, and response bodies of 0, 1, 10, and 100 KiB. Worker counts larger than the isolated server core set detected on the host are omitted. Requests use a zero-byte body while retaining the protocol's fixed frame overhead; sample banners report request and response body sizes separately.
Run plain TCP and TLS as separate result sets:
./scripts/run-native-performance.ps1 `
-Config examples/nacelle-stress-server/configs/tcp.toml `
-OutputDirectory target/native-performance/plain
./scripts/run-native-performance.ps1 `
-Config examples/nacelle-stress-server/configs/tcp-tls.toml `
-OutputDirectory target/native-performance/tls
The earlier diagnostic profiles remain available explicitly. For example:
./scripts/run-native-performance.ps1 -Profile min-rtt,pooled
Use -Profile all to run both the capacity matrix and the diagnostic
minimum-RTT, pooled, pool-saturation, and pipelined-throughput profiles. Override
the capacity dimensions with -CapacityWorkerCounts or
-CapacityResponseKiB.
The harness builds native release binaries once, starts a fresh server for each
sample, applies process and memory binding with numactl, and writes the host
snapshot, exact workload plan, raw server/client logs, per-run parsed results,
and median summary JSON under target/native-performance. It warns when it
detects virtualization or a non-performance CPU governor and refuses to reuse
an occupied bind address. Use -SkipBuild only when the release binaries
already match the current source.
The codec target measures length-delimited encoding, compares direct decoding
with MessageReader::decode_buffered, measures incomplete-header calls, and
separates the no-op buffer-rotation check from replacing an empty 256 KiB
buffer. The TCP target measures per-connection decoder construction and
compares direct reference-protocol decoding with the buffered 64-request
head/body drain used by the connection loop. Treat the rotation replacement
result as allocation cost, not per-request overhead.
The runtime_limits benchmark group covers connection/request permit
acquire/drop and memory allocation overhead. Watch it closely after changes to
NacelleRuntimeState.
Profile Linux CPU and allocations
Use the Linux profiling helper for repeatable plain-TCP diagnostics:
./scripts/profile-linux.sh --tool baseline
./scripts/profile-linux.sh --tool perf
./scripts/profile-linux.sh --tool heaptrack
The helper builds the profiling Cargo profile with optimization, debug
information, and frame pointers. Its default --feature-set minimal uses
--no-default-features, which disables TLS and mimalloc. The stress server's
downstream console metrics recorder remains active, but request and response
body-size histograms default off so profiling isolates the runtime from the
debug recorder's raw sample retention. Pass --byte-metrics when measuring
that recorder overhead deliberately. The system allocator is required because
Heaptrack cannot intercept calls made directly to mimalloc. Treat this as a
diagnostic profile, not as a matched comparison with the default mimalloc
build.
Use --feature-set default to profile the default mimalloc and Rustls-capable
binaries. Self-signed Rustls workloads also require a TLS config
and the explicit local-test trust flag:
./scripts/profile-linux.sh \
--tool perf \
--feature-set default \
--config examples/nacelle-stress-server/configs/tcp-tls.toml \
--tls-insecure
For a return-to-idle profile, keep the default mimalloc build and enable its immediate page-purge configuration:
./scripts/profile-linux.sh \
--tool baseline \
--feature-set default \
--low-memory
The profile metadata records low_memory=true. This mode is rejected with
--feature-set minimal because that diagnostic build uses the system allocator.
The helper rejects Heaptrack with the default feature set because direct
mimalloc calls are invisible to Heaptrack. It also rejects --tls-insecure
with the minimal feature set because that client omits Rustls.
Pass disjoint CPU lists after reviewing the native harness plan:
pwsh -NoProfile -File ./scripts/run-native-performance.ps1 -PlanOnly
./scripts/profile-linux.sh \
--tool perf \
--server-cpus 2,4,6,8,10,12,14,16 \
--client-cpus 3,5,7,9,11,13,15,17,19,21,23
For user-space profiling of processes owned by the current user, Linux must
permit performance counters. A value of 2 keeps kernel profiling disabled:
sudo sysctl -w kernel.perf_event_paranoid=2
Each run writes metadata, raw logs, and text reports under
target/linux-profiles. The perf mode warms the server before attaching and
records cycles:u at 999 Hz with frame-pointer call graphs. Heaptrack launches
the server directly so allocator interception survives process startup, then
applies any requested CPU affinity to all server threads.
The helper can compare handler ownership and response delivery without changing their safe defaults:
./scripts/profile-linux.sh --tool perf --handler-mode serial
./scripts/profile-linux.sh \
--tool baseline \
--response-write-mode coalesce-buffered \
--pipeline 8 \
--runs 3
July 2026 Linux response-delivery diagnostic
The following results are local confidence checks from an Intel Xeon Silver 4214 host with 24 physical cores, Linux 6.8, and Rust 1.95.0. The profiling build used the system allocator, no TLS, and the former metrics configuration, eight pinned server workers, 256 persistent connections, 256-byte request bodies, 64-byte response bodies, and all timeout defaults enabled. Server and client CPU sets were disjoint. Each matrix cell used a five-second warm-up and three measured ten-second runs; pipeline depths 1 and 8 also received ABBA interleaved checks.
| Pipeline | Immediate median | Coalesced median | Local delta |
|---|---|---|---|
| 1 | 614,661 req/s | 624,258 req/s | inconclusive; ABBA was -1.0% |
| 8 | 763,646 req/s | 1,081,277 req/s | +41.6% |
| 32 | 724,094 req/s | 1,256,126 req/s | +73.5% |
All clients completed without reported failures. The pipeline-8 ABBA check
measured 764,854-774,894 req/s for immediate delivery and
1,072,662-1,075,516 req/s for coalesced delivery. Matched perf captures lost no
samples; coalescing reduced write_all_tracked_with_timeout self share from
4.33% to 1.39%, ResponseDelivery::write_pending from 4.14% to 1.68%, and TCP
write polling from 1.59% to 0.46%. The connection loop always flushes after it
drains the requests already decoded from the current read buffer and before it
awaits another socket read.
These loopback saturation results support coalescing as an explicit option for highly pipelined workloads, not as a universal default. Pipeline-1 performance showed no repeatable benefit, and target-network latency, response sizes, backpressure, TLS, and telemetry require separate measurements.
Default-feature plain TCP and Rustls diagnostic
A follow-up on the same host used the then-default stress-server feature set: mimalloc, the former OpenTelemetry recorder with byte metrics, and self-signed Rustls support. The workload, CPU sets, timeout defaults, warm-up, sample duration, and three-run cell size matched the preceding matrix. Plain TCP and Rustls were measured as separate transport profiles.
| Transport | Pipeline | Immediate median | Coalesced median | Local delta |
|---|---|---|---|---|
| Plain TCP | 1 | 537,566 req/s | 536,274 req/s | -0.2% |
| Plain TCP | 8 | 619,749 req/s | 1,168,411 req/s | +88.5% |
| Plain TCP | 32 | 608,519 req/s | 1,329,735 req/s | +118.5% |
| Rustls | 1 | 478,062 req/s | 471,512 req/s | -1.4% |
| Rustls | 8 | 524,887 req/s | 529,998 req/s | +1.0% |
| Rustls | 32 | 547,103 req/s | 546,342 req/s | -0.1% |
All 36 measured client runs completed without reported failures; all cells had
at most 1.1% min-to-max spread. At pipeline depth 8, matched perf captures lost
no samples. Plain coalescing reduced tracked write-loop self share from 2.10%
to 0.47%, pending-write self share from 1.78% to 0.42%, send from 0.62% to
0.12%, and TCP write polling from 0.53% to 0.09%. The equivalent Rustls pair
was throughput-neutral: tracked write-loop self share remained approximately
1.01%, while encryption, TLS buffering, and the former metrics aggregation remained
material costs.
Deep-pipeline Rustls testing also exposed two stress-path correctness gaps.
The client now flushes buffered TLS requests before reading responses, and the
TCP connection driver flushes the underlying transport before another request
read and performs a shutdown-timeout-bounded shutdown. This delivers terminal TLS
records promptly and emits close_notify. Final pipeline-32 Rustls samples all
completed in approximately 10.02 seconds instead of waiting for the 30-second
read timeout.
These results support coalescing for measured plain-TCP pipelined workloads on this host. They do not support enabling it for Rustls or pipeline-1 workloads.
Historical disabled-policy specialization
A matched local comparison used the telemetry_paths benchmark at checkpoint
0bce7f0 and after caching the effective TCP telemetry plan once per connection.
Both builds used the same WSL2 host/toolchain and all TCP features:
| Path | Before | After | Local delta |
|---|---|---|---|
| Metrics disabled | 5.72-5.76 us | 5.03-5.07 us | approximately 12% lower |
| Metrics enabled | 7.00-7.04 us | 7.02-7.08 us | no material change |
The optimized path did not construct NacelleMetricsContext or metric attribute
arrays when metrics are disabled, and request/phase mode checks are cached in a
copyable per-connection plan. Connection/request permits remain active because
they enforce runtime limits and expose operational state. Memory accounting
also remains active when the benchmark includes experimental-memory;
disabling telemetry does not disable either safety policy.
On the same local WSL2 host, 64 already-buffered one-byte requests producing 32-byte responses measured. The first two rows use a 2 KiB base response buffer; the threshold rows use a 1 KiB base buffer:
| Policy | Time | Recorded writes |
|---|---|---|
| Immediate | 27.14-27.55 us | 64 |
| CoalesceBuffered | 20.18-20.40 us | 1 |
| FlushAtBytes(1024) | 20.22-20.45 us | 2 |
| FlushAtBytes(2048), grows from 1024 | 20.40-20.63 us | 1 |
This is a synthetic in-memory writer benchmark and demonstrates dispatch/write amortization plus one transactional buffer-growth case, not network throughput. Keep immediate delivery for latency-first workloads unless a matched workload shows a benefit.
Response coalescing depth and persistent-pool controls
The response_delivery benchmark also provides two policy-matched workload
families at pipeline depths 1, 8, and 32:
same_socket: one persistent in-memory connection and one request windowpool: eight persistent in-memory connections, each processing eight request windows; the next window becomes readable only after the previous response window is delivered
Both families compare Immediate with CoalesceBuffered on the same server,
decoder, handler, response shape, runtime, and transport. Every response is 32
bytes. The benchmark asserts total write count and largest write size on every
iteration. Response-buffer capacity is 2 KiB, so these cases remain inside the
base connection allocation and do not exercise overflow growth.
Run them with:
cargo bench -p nacelle-tcp --bench response_delivery --features "buffer-rotation experimental-memory phase-timing rustls tls-self-signed"
A local confidence run based on commit 9be59a0 with a modified worktree used
Rust 1.95.0 on Linux 6.6.87.2 WSL2 and an Intel Xeon Platinum 8370C virtualized
topology (one socket, eight visible cores, two threads per core). Times are
Criterion 95% confidence intervals from that one host.
| Same-socket depth | Policy | Time | Writes | Largest batch | Median delta |
|---|---|---|---|---|---|
| 1 | Immediate | 5.569-5.699 us | 1 | 32 B | baseline |
| 1 | CoalesceBuffered | 5.536-5.641 us | 1 | 32 B | 1.1% lower |
| 8 | Immediate | 8.497-8.577 us | 8 | 32 B | baseline |
| 8 | CoalesceBuffered | 7.593-7.758 us | 1 | 256 B | 10.2% lower |
| 32 | Immediate | 17.834-17.944 us | 32 | 32 B | baseline |
| 32 | CoalesceBuffered | 14.344-14.436 us | 1 | 1,024 B | 19.6% lower |
| Pool depth | Policy | Time | Total writes | Largest batch | Median delta |
|---|---|---|---|---|---|
| 1 | Immediate | 51.695-52.949 us | 64 | 32 B | baseline |
| 1 | CoalesceBuffered | 50.578-50.891 us | 64 | 32 B | 2.8% lower |
| 8 | Immediate | 229.82-230.90 us | 512 | 32 B | baseline |
| 8 | CoalesceBuffered | 176.78-177.72 us | 64 | 256 B | 23.1% lower |
| 32 | Immediate | 837.09-851.92 us | 2,048 | 32 B | baseline |
| 32 | CoalesceBuffered | 608.71-614.00 us | 64 | 1,024 B | 27.6% lower |
Depth 1 remains the latency control: both policies perform one write per window, and their intervals are close. At depths 8 and 32, coalescing reduces one response window to one write and lowers elapsed time in this synthetic adapter workload. These results demonstrate write-path amortization, not socket syscall cost, network throughput, allocator counts, or production latency.
Suggested RPS comparison:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml --server-threads 48 --connections 256 --pipeline 8 --duration-secs 30 --payload-bytes 256
The stress server installs a metrics-util recorder and prints a compact metrics
snapshot every 5 seconds. Request metrics are grouped under the generic telemetry
request_metrics config; started/completed counters and byte counters are on by
default, while in-flight and duration metrics remain opt-in. Request duration
metrics are opt-in as well, which avoids request Instant work on core/HTTP
paths unless duration metrics or HTTP access logs are enabled. Use
--no-byte-metrics when comparing the cost of byte accounting. Use the
telemetry_paths Criterion target for a no-recorder facade baseline; every
stress-server feature set installs the console recorder.
The checked-in root config.toml enables self-signed TCP TLS for local
stress runs. For the plain TCP throughput baseline, use
examples/nacelle-stress-server/configs/tcp.toml. Compare TLS and non-TLS runs
separately:
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp.toml
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp-low-memory.toml
./examples/run-stress-test.sh --config examples/nacelle-stress-server/configs/tcp-tls.toml
The examples/run-stress-test.sh and examples/run-stress-test.ps1 helpers
apply root config.toml first, then the selected profile, and choose the
matching client mode automatically. They also enable experimental-memory
when either effective config contains max_memory_bytes.
Guardrails:
- keep shutdown task tracking at the connection/listener boundary
- avoid per-request locks in the TCP hot path
- keep telemetry observers optional; the default
NoopObservermust remain allocation-free - preserve single-chunk body fast paths
- tune TCP buffer sizes for the connection count instead of relying on large defaults
Harden HTTP listeners
Nacelle's HTTP transport is Hyper HTTP/1. Configure HTTP timeout and keep-alive behavior through NacelleHttpLimits, shared body-size budgets through NacelleLimits, and request-shape policy through NacelleHttpPolicy.
Defaults:
NacelleHttpLimits::header_read_timeout: 30 seconds, enforced with Hyper's HTTP/1 header timeout andTokioTimer.NacelleHttpLimits::request_body_read_timeout: 30 seconds, enforced while reading body frames.NacelleHttpLimits::response_write_timeout: 30 seconds, enforced at Hyper's I/O write boundary.NacelleHttpLimits::keep_alive: enabled.NacelleHttpLimits::max_connection_age: disabled by default.- request and response body size limits: 16 MiB each.
NacelleHttpPolicy can reject requests before the handler runs:
- allowed Host headers
- allowed HTTP methods
- maximum URI length
- maximum header count
- maximum aggregate header bytes
- optional per-peer request rate limits through
with_max_requests_per_peer_per_second - bounded lock-free per-peer request-rate state through
with_peer_rate_limit_table_capacity(16,384 peers by default when enabled) - optional trusted proxy forwarded address handling through
with_trusted_proxy_ips - optional security headers through
with_security_header(...)orwith_default_security_headers() - optional per-peer connection caps through
NacelleLimits::with_max_connections_per_peer - optional per-peer connection-open rate caps through
NacelleLimits::with_max_connection_opens_per_peer_per_second
Rejected requests receive deterministic HTTP responses where the request parser has already accepted the request: 405, 414, 421, 429, or 431. Rejections emit low-cardinality telemetry reasons such as host, method_not_allowed, uri_too_long, header_count, header_bytes, peer_rate, and peer_rate_table_full.
Per-peer request and connection-open rate limiters use fixed-capacity,
lock-free tables. They retain active peer entries for 60 seconds and do not
allocate, lock, or scan every tracked peer during admission. Size the HTTP
table with NacelleHttpPolicy::with_peer_rate_limit_table_capacity(...) and
the TCP/connection table with
NacelleLimits::with_connection_rate_limit_table_capacity(...). If a table is
full or no inactive entry is found within its fixed probe budget, a newly
observed peer is rejected. Choose capacity from the deployment's expected
active-peer cardinality rather than silently accepting unbounded state.
Enable the rustls feature to terminate HTTP over TLS. NacelleTlsConfig loads PEM certificate/key pairs, accepts explicit Rustls ServerConfig values, supports reloads for future handshakes, and enforces a TLS handshake timeout. Enable tls-self-signed only when local load tests or auto-deploying applications need to generate a self-signed certificate immediately; it implies rustls.
For direct edge HTTPS, build TLS config with
NacelleTlsConfig::from_pem_with_allowed_server_names(...) or
NacelleTlsConfig::from_der_with_allowed_server_names(...). When an SNI
allowlist is configured, clients that omit SNI or send a name outside the list
fail during the TLS handshake. HTTP Host policy is enforced after the handshake,
so configure the SNI allowlist and NacelleHttpPolicy::with_allowed_hosts(...)
with the same service names unless you intentionally need a narrower Host
policy.
NacelleTlsConfig is the Rustls config shared with TCP TLS. HTTP TLS requires
the compile-time rustls feature. TCP can instead select openssl, but the two
backend features cannot be enabled together and cannot be swapped at runtime.
Enable HyperServer::with_access_log(true) when direct edge deployments need structured request logs. Access events are emitted with target nacelle::access and include transport, method, URI, status, request bytes, elapsed microseconds, and rejection reason.
Forwarded peer identity is disabled by default. Forwarded and
X-Forwarded-For are considered only when the immediate socket peer is listed
in NacelleHttpPolicy::with_trusted_proxy_ips(...); otherwise rate limits,
request metadata, and access logs use the socket peer address.
For internet-facing deployments, a reverse proxy or load balancer can still own coarse traffic filtering and certificate automation. Nacelle now also enforces application-level body, request, connection, per-peer connection/request/connection-open-rate, timeout, TLS handshake, security header, and optional Host/header/method/URI limits in-process.
Slowloris-style clients are closed by NacelleHttpLimits::header_read_timeout. Trickle request bodies are closed by NacelleHttpLimits::request_body_read_timeout. Slow response readers are closed by NacelleHttpLimits::response_write_timeout when socket writes stop making progress.
Run security scans
Run vulnerability and dependency checks before release:
cargo audit
cargo deny check
cargo tree -i serde_yaml
cargo tree -i unsafe-libyaml
deny.toml rejects known advisories, unapproved licenses and sources, wildcard
dependencies, and duplicate versions that do not have a dependency-owner
rationale. Review every exception when updating the lockfile; do not add a
blanket advisory, license, or duplicate-version exception.
serde_yaml and unsafe-libyaml should not appear in the dependency tree.
0.3.0-rc.2 release and rollback notes
RC.2 is the second 0.3.0 release candidate. It supersedes the published RC.1
candidate for stabilization evidence because post-RC.1 correctness coverage and
an observable metrics-schema correction required another prerelease.
Included changes
- Add external-consumer and internal regressions that keep representative
serving-future layouts below the 16 KiB compiler-pressure ceiling. Crates
that instantiate serial connection futures may also require
#![recursion_limit = "256"]for compiler query depth; see the RC.2 migration guide. - Add direct correctness evidence for concurrent per-peer admission, by-value cancellation, fragmented frame boundaries, unknown-length body limits, shutdown ordering, connection-task panic supervision, and repeated listener lifecycle cleanup.
- Align metrics to singular, resource-first names and base units. Request and response body sizes are histograms rather than cumulative counters.
- Add an enforced dependency policy for advisories, licenses, sources, wildcard requirements, and duplicate versions.
- Preserve RC.1 Rust APIs, resource-limit defaults, timeout defaults, and
graph-wide TLS backend exclusivity. Oversized declared HTTP request bodies are
now rejected with
413 Payload Too Largebefore handler dispatch.
Metric consumers must follow Migrate from 0.3.0-rc.1 to 0.3.0-rc.2. Applications upgrading from beta.5 must also follow Migrate from 0.3.0-beta.5 to 0.3.0-rc.1.
Rollback
RC.1 remains published and is the rollback target for an RC.2 deployment:
nacelle = { version = "=0.3.0-rc.1" }
Use the same feature set and TLS backend as the RC.2 deployment. Rebuild the consumer from a locked dependency graph, redeploy it through the normal service rollback mechanism, and restore RC.1 metric queries or recording rules at the same time. Do not leave RC.2 body-size histogram queries attached to RC.1 byte counters, or mix RC.1 millisecond duration samples with RC.2 second samples.
No data or wire-format migration is required. If rollback is caused by a generic runtime defect, reduce it to a transport-neutral regression before resuming the stable release. Publish RC.3 rather than replacing or retagging RC.2 when a candidate correction is required.
This rollback guidance does not claim production readiness for every workload. Validate RC.2 against the intended service limits, TLS mode, observability backend, and deployment rollback path before promotion.
Reference protocol
This document describes the LengthDelimitedProtocol fixture in the unpublished
examples/nacelle-reference-protocol workspace package. It is used by examples,
tests, benchmarks, and stress tools, but it is not part of Nacelle's published
library API. Applications can use it from a repository checkout or implement
Protocol with an associated Request type for TCP or Unix domain sockets.
Frame Layout
All integer fields are little-endian.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | frame_len |
| 4 | 8 | request_id |
| 12 | 8 | opcode |
| 20 | 4 | flags |
| 24 | frame_len - 20 | body bytes |
frame_len counts the fixed fields after itself plus the body. The minimum
valid value is 20.
Flags
| Flag | Value | Meaning |
|---|---|---|
FRAME_FLAG_START | 0b0001 | First response frame for a request |
FRAME_FLAG_END | 0b0010 | Last response frame for a request |
FRAME_FLAG_ERROR | 0b0100 | Response body contains an error message |
Request flags are decoded and preserved in FrameRequest, but the built-in
server does not currently interpret request flags.
Requests
Each request frame contains one complete request body. The server decodes only
the frame head before dispatch, then exposes the body to the handler as a
NacelleBody. Small bodies are served from the connection read buffer. Larger
bodies are streamed to the handler in configured chunks.
Custom protocols provide a per-connection MessageDecoder through
Protocol::decoder. Decoders follow the nacelle-codec progress contract:
returning a request consumes at least one head byte, while requesting more input
leaves the cumulative buffer unchanged.
A decoder may wait for a fixed header plus a small protocol prefix before
classifying a message as DecodedMessage::Request or
DecodedMessage::OneWay. If the prefix is incomplete, return Ok(None) and
leave every byte untouched. Once classification is possible, consume only the
header/prefix and report the unconsumed body length in DecodedRequest. This
preserves early body-limit checks and streaming while allowing one-way flags to
live immediately after the fixed header.
opcode is request metadata. The application handler decides whether to use it
for routing, reject it, or ignore it. If the handler rejects an opcode after
draining the body and returns an error, the server encodes that error as a
response frame.
Handlers receive TcpRequestContext<P, AppState>, where application state
defaults to (). context.app_state() borrows the dependency root shared by
the app across listeners. Its request contains the associated
protocol head under request().head and the bounded body under
request_mut().body. Its connection context contains a stable connection id,
peer/local addresses, listener label, TLS metadata, and
Arc<P::ConnectionState>. The runtime constructs that state once with
Protocol::connection_state and shares it across requests on the connection.
State confined to the serial connection loop can use SerialTcpServer or
LocalSerialTcpServer. Their handlers implement SerialTcpHandler or
LocalSerialTcpHandler and receive SerialTcpRequestContext<'_, P>, which
lends exclusive mutable access to directly owned connection state. The loop
awaits completion before decoding the next message, so one connection cannot
overlap serial handler calls. Serial one-way contexts expose the same mutable
state and still provide no response capability.
Responses
Handlers call context.respond(P::Response) and return the resulting typed
completion. A protocol can accept its own response type; the reference protocol
uses TcpResponse, whose body may be empty, one chunk, or streaming. Returning
an HTTP response from a TCP handler does not compile.
Protocols classify decoded messages as DecodedMessage::Request or
DecodedMessage::OneWay. Required requests use TcpRequestContext<P> and must
respond. One-way messages use TcpOneWayContext<P> with NoResponse, so no
respond method exists. Servers supporting one-way messages install a separate
concrete handler with TcpServer::<YourProtocol>::builder().one_way_handler(...). Request-only
protocols use Infallible as their one-way request type.
The TCP runtime encodes and writes each streaming response chunk before polling
the next one, so socket backpressure bounds response production. It stages only
one bounded frame at a time and writes an explicit end frame after a streaming
body reaches EOF. With experimental-memory, it also accounts staging growth
against the runtime memory budget.
ResponseWritePolicy::Immediate writes each completed frame immediately.
CoalesceBuffered and FlushAtBytes may queue multiple completed frames from
already-decoded requests, preserving order and rolling back only the current
frame on encoder failure. The queue drains before another socket read and before
awaiting another streaming response chunk. At that boundary, the runtime also
flushes the underlying AsyncWrite, which is required for buffered transports
such as TLS. When the connection ends cleanly, the runtime performs a
shutdown_timeout-bounded writer shutdown so TLS transports can emit
close_notify. Terminal error paths make the same bounded shutdown attempt;
an earlier connection failure remains the returned error if shutdown also fails.
Request telemetry records encoded response bytes when a request completes; a
later batch write, transport flush, or shutdown failure is reported as a
connection operation error.
The protocol guarantees:
- the first response frame has
FRAME_FLAG_START - the last response frame has
FRAME_FLAG_END - a handler that returns an empty body still emits a start/end response frame
- a handler error emits a start/end/error frame
Responses are written in request-processing order for a single connection. The prototype does not yet provide concurrent per-connection response interleaving.
Error Handling
Malformed frame heads, oversized frames, and EOF before a complete frame cause
the connection to fail. Decoder, framing-progress, and incomplete-head failures
are offered to Protocol::encode_error without an error context before the
connection closes. Once a request head has been decoded, buffered and streaming
body-read failures, handler errors, and handler timeouts are offered with that
request's error context. Streaming request read failure also cancels the handler
future. If encoding or writing an error frame fails, that delivery failure is
returned without another delivery attempt. The connection closes after a
terminal request failure so unread body bytes cannot be interpreted as another
frame. Unknown opcode handling is application policy.
Limits
The server enforces NacelleTcpConfig::max_frame_len against frame_len.
Buffer sizes and request-body chunking are configured through
NacelleTcpConfig.
Runtime budgets, timeouts, and active counters are configured through
NacelleLimits / NacelleRuntimeState.
TCP protocols can apply phase-aware request body limits by overriding
Protocol::max_request_body_bytes(request, connection, state, default_limit).
The TCP
runtime calls this after decoding the request head and before buffering or
streaming the body. Implementations can use concrete protocol configuration,
the decoded head, immutable ConnectionInfo, and the same concrete
Protocol::ConnectionState exposed to handlers. Rejection occurs before
body-specific allocation or additional body reads, although decoder read-ahead
may already have buffered bytes.
One-way messages use the equivalent
Protocol::max_one_way_body_bytes(request, connection, state, default_limit)
hook and the same early-rejection boundary.
TCP request handling is sequential per connection. Pipelined frames can sit
in the socket/read buffer, but Nacelle does not run multiple handlers
concurrently for one TCP connection. Streaming request bodies use
request_body_channel_capacity for backpressure between socket reads and the
handler, and declared streaming body bytes are allocated against the memory
budget until the streaming request finishes when experimental-memory is
enabled.
SharedProtocol marks protocols whose connection state is Send + Sync and is
required by the existing Arc-backed shared server. Shared serial servers
require state and handler futures to be Send, but not Sync, because each
connection owns its state. Worker-local serial servers may use !Send state and
futures.
Codec primitives
nacelle-codec provides ordered byte/message I/O over AsyncRead and
AsyncWrite transports.
Components
| Component | Purpose |
|---|---|
MessageDecoder | Decodes messages from cumulative input |
MessageReader | Reads, decodes, validates progress, and handles EOF |
MessageEncoder | Appends encoded messages to an output buffer |
MessageWriter | Queues encoded bytes and writes them to a transport |
LengthDelimitedDecoder | Decodes four-byte length-prefixed payloads |
LengthDelimitedEncoder | Encodes four-byte length-prefixed payloads |
RotatingMessageReader | Reclaims empty large input buffers when enabled |
Decoder contract
Implement MessageDecoder for protocol parsing. decode receives the
cumulative BytesMut directly. Return a message only after consuming input,
and return Ok(None) without consuming bytes when more input is required.
MessageReader reports either progress-contract violation explicitly.
The built-in length-delimited decoder returns a split BytesMut without
copying. Callers can retain it, freeze it, copy it, pool it, or parse it into a
protocol-specific value.
Writer contract
Implement MessageEncoder<M> by appending directly to BytesMut.
MessageWriter checkpoints the buffer before each encoder call and rolls back
newly appended bytes when encoding fails.
feed only encodes. flush writes all queued bytes and flushes the transport.
send performs both operations.
Buffer behavior
MessageReader and MessageWriter each hold one cumulative BytesMut, because
framing partial reads and writes requires storage across transport operations.
Use with_buffer to supply storage and into_parts to reclaim it.
A decoded message may share its backing allocation with the reader's input
buffer. freeze() converts it to Bytes without copying. Use
Bytes::copy_from_slice or BytesMut::from when the decoded message needs an
independent allocation.
Limits
The length-delimited codecs reject frames larger than their configured maximum.
Custom decoders are responsible for rejecting oversized input before the
cumulative MessageReader buffer grows indefinitely.
MessageWriter::feed queues encoded bytes. flush, send, and shutdown
write queued bytes to the transport.
Long-lived connections
The optional buffer-rotation feature provides
RotatingMessageReader. Configure a threshold and replacement capacity to
replace an empty input buffer after a large decoded message. The decoded
BytesMut remains zero-copy and retains its original allocation until it is
dropped. If coalesced input follows the large message, replacement waits until
that input has been consumed.
Stability
The 0.3 API is experimental.
HTTP hardening reference
Nacelle's HTTP transport is Hyper HTTP/1. Configure HTTP timeout and keep-alive behavior through NacelleHttpLimits, shared body-size budgets through NacelleLimits, and request-shape policy through NacelleHttpPolicy.
Defaults:
NacelleHttpLimits::header_read_timeout: 30 seconds, enforced with Hyper's HTTP/1 header timeout andTokioTimer.NacelleHttpLimits::request_body_read_timeout: 30 seconds, enforced while reading body frames.NacelleHttpLimits::response_write_timeout: 30 seconds, enforced at Hyper's I/O write boundary.NacelleHttpLimits::keep_alive: enabled.NacelleHttpLimits::max_connection_age: disabled by default.- request and response body size limits: 16 MiB each.
NacelleHttpPolicy can reject requests before the handler runs:
- allowed Host headers
- allowed HTTP methods
- maximum URI length
- maximum header count
- maximum aggregate header bytes
- optional per-peer request rate limits through
with_max_requests_per_peer_per_second - bounded lock-free per-peer request-rate state through
with_peer_rate_limit_table_capacity(16,384 peers by default when enabled) - optional trusted proxy forwarded address handling through
with_trusted_proxy_ips - optional security headers through
with_security_header(...)orwith_default_security_headers() - optional per-peer connection caps through
NacelleLimits::with_max_connections_per_peer - optional per-peer connection-open rate caps through
NacelleLimits::with_max_connection_opens_per_peer_per_second
Rejected requests receive deterministic HTTP responses where the request parser has already accepted the request: 405, 414, 421, 429, or 431. Rejections emit low-cardinality telemetry reasons such as host, method_not_allowed, uri_too_long, header_count, header_bytes, peer_rate, and peer_rate_table_full.
Per-peer request and connection-open rate limiters use fixed-capacity,
lock-free tables. They retain active peer entries for 60 seconds and do not
allocate, lock, or scan every tracked peer during admission. Size the HTTP
table with NacelleHttpPolicy::with_peer_rate_limit_table_capacity(...) and
the TCP/connection table with
NacelleLimits::with_connection_rate_limit_table_capacity(...). If a table is
full or no inactive entry is found within its fixed probe budget, a newly
observed peer is rejected. Choose capacity from the deployment's expected
active-peer cardinality rather than silently accepting unbounded state.
Enable the rustls feature to terminate HTTP over TLS. NacelleTlsConfig loads PEM certificate/key pairs, accepts explicit Rustls ServerConfig values, supports reloads for future handshakes, and enforces a TLS handshake timeout. Enable tls-self-signed only when local load tests or auto-deploying applications need to generate a self-signed certificate immediately; it implies rustls.
For direct edge HTTPS, build TLS config with
NacelleTlsConfig::from_pem_with_allowed_server_names(...) or
NacelleTlsConfig::from_der_with_allowed_server_names(...). When an SNI
allowlist is configured, clients that omit SNI or send a name outside the list
fail during the TLS handshake. HTTP Host policy is enforced after the handshake,
so configure the SNI allowlist and NacelleHttpPolicy::with_allowed_hosts(...)
with the same service names unless you intentionally need a narrower Host
policy.
NacelleTlsConfig is the Rustls config shared with TCP TLS. HTTP TLS requires
the compile-time rustls feature. TCP can instead select openssl, but the two
backend features cannot be enabled together and cannot be swapped at runtime.
Enable HyperServer::with_access_log(true) when direct edge deployments need structured request logs. Access events are emitted with target nacelle::access and include transport, method, URI, status, request bytes, elapsed microseconds, and rejection reason.
Forwarded peer identity is disabled by default. Forwarded and
X-Forwarded-For are considered only when the immediate socket peer is listed
in NacelleHttpPolicy::with_trusted_proxy_ips(...); otherwise rate limits,
request metadata, and access logs use the socket peer address.
For internet-facing deployments, a reverse proxy or load balancer can still own coarse traffic filtering and certificate automation. Nacelle now also enforces application-level body, request, connection, per-peer connection/request/connection-open-rate, timeout, TLS handshake, security header, and optional Host/header/method/URI limits in-process.
Slowloris-style clients are closed by NacelleHttpLimits::header_read_timeout. Trickle request bodies are closed by NacelleHttpLimits::request_body_read_timeout. Slow response readers are closed by NacelleHttpLimits::response_write_timeout when socket writes stop making progress.
Production configuration reference
Start from NacelleLimits::default() and tune shared resource budgets for the
deployment. Use NacelleTcpLimits for TCP socket timeouts and
NacelleHttpLimits for HTTP edge timeouts and keep-alive behavior. Active
connections, in-flight requests, streaming tasks, body sizes, handler timeouts,
and transport timeouts are bounded by default. Runtime memory accounting is
experimental and not compiled by default. Enable experimental-memory and set
max_memory_bytes only after measuring the limiter for your service. This
feature is use at your own risk and may change or be removed in a future minor
release.
Recommended presets:
- Internal service: keep defaults, set body limits to the largest expected payload, and run behind process supervision.
- Internet-facing behind proxy: cap connections and requests to the container budget, keep 30 second transport timeouts, and let the proxy own coarse traffic filtering or certificate automation when desired.
- Proxy-aware HTTP: configure
NacelleHttpPolicy::with_trusted_proxy_ips(...)only with known proxy addresses before allowingForwardedorX-Forwarded-Forto affect per-peer request limits or request metadata. - Direct HTTPS listener: enable
http,rustls, load certificate/key material throughNacelleTlsConfig, configure an SNI allowlist withfrom_pem_with_allowed_server_namesorfrom_der_with_allowed_server_names, set a short TLS handshake timeout, configuremax_connections_per_peerandmax_connection_opens_per_peer_per_second, enable HTTP access logs, and attachNacelleHttpPolicywith Host, method, URI, header, security-header, and per-peer request-rate limits. - Direct TCP Rustls listener: enable
tcp,rustls, load certificate/key material throughNacelleTlsConfig, register it withNacelleApp::tcp_tls(...), and keep protocol-level authentication/authorization in the application protocol. - Direct TCP OpenSSL listener: enable
tcp,openssl, load certificate/key material throughNacelleOpenSslConfig, register it withNacelleApp::tcp_openssl(...), and configure theSslAcceptoryourself when you need OpenSSL-specific policy. - Optional TCP OpenSSL listener: enable
experimental-openssl-detection(which impliestcp,openssl), useserve_tcp_optional_opensslor the matching host/app builder method, and keepNacelleTlsDetectionOptions::timeoutshort enough for your accepted-connection budget. - IPv4 plus IPv6 TCP bind: use the
NacelleApp::*_dual_stack(...)helpers to register separate IPv4 and IPv6 listeners while forcing the IPv6 listener to v6-only mode. - Unix socket listener: enable
tcpon Unix and useNacelleUnixSocketOptionsonly when this process owns stale-path cleanup or socket-file permissions. - Local load-test/autodeploy HTTPS: enable
tls-self-signedand callNacelleTlsConfig::self_signed(...); do not treat generated certificates as a public trust or rotation strategy. - High concurrency: reduce TCP buffer capacities before raising
max_connections, and tuneNacelleTcpLimitsseparately from shared resource budgets.
Experimental memory budget:
connection_budget =
max_connections * (read_buffer_capacity + response_buffer_capacity)
body_budget =
concurrent_buffered_or_streaming_bodies * max_request_body_bytes
total_budget =
connection_budget + body_budget + handler/backend/runtime headroom
The APIs in this section require the non-default experimental-memory feature.
Set NacelleLimits::with_max_memory_bytes(...) when you want Nacelle to enforce
the calculated budget. Without the feature, Nacelle still enforces
connection/request/body limits and transport-owned timeouts but leaves memory
governance to the application, runtime, process supervisor, or container.
When the memory budget is full, request body allocations wait in FIFO order and
time out after NacelleLimits::memory_allocation_timeout (5s by default).
Tune this with with_memory_allocation_timeout(...), or call
NacelleRuntimeState::memory_budget() when application code needs to allocate from
the same budget as the transports.
TCP processes requests sequentially per connection. request_body_channel_capacity controls the queued streaming chunks between the socket reader and handler. HTTP uses Hyper's internal buffers plus Nacelle's body queue, so leave extra headroom when enabling large request bodies.
NacelleTcpConfig::streaming_body_memory_policy defaults to
TcpStreamingBodyMemoryPolicy::DeclaredLength, which reserves the declared body
length before dispatch. LiveChunks instead charges each streaming chunk until
its final application-owned Bytes clone drops. This can admit a body larger
than currently available budget, but retained chunks apply backpressure to later
reads. Keep request_body_chunk_size within available budget and do not use
LiveChunks for full-body aggregation unless the budget can hold that body.
For TCP protocols, NacelleLimits::max_request_body_bytes is the default body
limit. Override
Protocol::max_request_body_bytes(request, connection, state, default_limit)
when the decoded request head, immutable connection metadata, or concrete
Protocol::ConnectionState should choose a stricter phase-specific cap. The
hook runs before body-specific allocation or additional body reads; normal
decoder read-ahead may already have placed bytes in the connection buffer.
NacelleTcpOptions controls accepted TCP stream behavior. Defaults preserve the
existing behavior: TCP_NODELAY enabled and TCP keepalive disabled.
NacelleTcpBindOptions adds listener bind controls such as IPv6-only mode for
APIs that need explicit family behavior.
TCP response delivery defaults to ResponseWritePolicy::Immediate.
CoalesceBuffered uses response_buffer_capacity as its threshold, while the
configuration builder normalizes FlushAtBytes(0) to FlushAtBytes(1). Only
complete frames enter the pending batch; the runtime flushes before another
socket read, before awaiting another streaming body chunk, and when the
threshold is reached or crossed. Read-boundary flushes include the underlying
AsyncWrite, and clean connection teardown performs a writer shutdown bounded
by NacelleTcpLimits::shutdown_timeout; terminal error paths make the same
bounded attempt. This delivers buffered TLS records and permits a TLS
close_notify. Pending capacity above the connection's base response buffer
remains charged to runtime memory until flush or failure cleanup when
experimental-memory is enabled. In that mode, geometric growth is
transactional and requires memory headroom for both the old buffer and its
complete replacement; a growth attempt is rejected before encoding when that
temporary allocation cannot be charged.
NacelleTcpLimits controls TCP socket read, socket write, final writer shutdown,
and idle timeouts. Shutdown uses its own deadline so finalization policy can be
tuned independently of ordinary response delivery. The corresponding
without_*_timeout() builders make an explicitly unbounded policy possible.
NacelleHttpLimits controls HTTP header read, request body read, response
write, keep-alive, and max connection age behavior on HyperServer. Its
without_*_timeout() and without_max_connection_age() builders disable those
optional deadlines; NacelleLimits::without_handler_timeout() disables the
core handler deadline.
Dangerous configurations:
- unbounded connections with large per-connection buffers
- large body limits without a process/container memory limit
- disabled timeouts on internet-facing listeners
- direct internet-facing HTTP without Host/header/method/URI policy
- direct internet-facing TLS without an SNI allowlist
- direct internet-facing listeners without per-peer connection caps
- direct internet-facing listeners without per-peer connection-open rate caps
- direct internet-facing HTTP without per-peer request caps and access logs
- trusting forwarded peer headers without an explicit trusted proxy list
- generated self-signed certificates used as a long-lived public-edge certificate strategy
- high keep-alive connection counts without proxy-level idle limits
- long TLS detection timeouts on optional TLS listeners
- Unix stale-path cleanup for a socket path not exclusively owned by this process
TLS certificate rotation:
#![allow(unused)] fn main() { let tls = NacelleTlsConfig::from_pem_files("cert.pem", "key.pem")?; tls.reload_from_pem_files("next-cert.pem", "next-key.pem")?; }
Reloads affect new TLS handshakes. Existing connections continue with the configuration negotiated when they connected.
OpenSSL builds need native OpenSSL development files. The openssl-vendored
feature can build OpenSSL from source, but that build requires Perl on Windows.
Versioning and support
Nacelle uses semantic versioning for published crates. This policy defines how new release lines are promoted, how patch releases are maintained, and which minor lines receive support.
Major and minor releases
Every new major or minor release line progresses through three stages:
- Publish one or more beta versions, such as
0.4.0-beta.1and0.4.0-beta.2, while the release scope and public contracts stabilize. - Promote the beta line to a release candidate, such as
0.4.0-rc.1, when it is ready for final compatibility and release validation. Publish another release candidate if the candidate requires changes. - Promote a validated release candidate to the release version, such as
0.4.0.
This promotion path applies to both major and minor releases. Beta and release candidate versions are prereleases for evaluation and stabilization; they do not add a supported minor line to the support window.
Patch releases
Patch releases do not repeat the beta and release-candidate progression. Apply
a compatible patch directly to the applicable supported release branch and to
main when the fix is still relevant there, then publish the next patch
version. For example, a fix for 0.4.0 is released directly as 0.4.1.
A patch may differ on the release branch and main when later development has
changed the affected code, but both changes must preserve the fix's behavior.
Supported versions
Nacelle supports the current released minor line and the immediately preceding
released minor line. This is the N-1 support window. Support begins with
0.3.0; every version before 0.3.0 is unsupported.
The support window therefore develops as follows:
- When
0.3is current, only the0.3line is supported. - When
0.4is current, the0.4and0.3lines are supported. - When
0.5is current, the0.5and0.4lines are supported, and0.3is unsupported.
Support applies to minor lines, not every patch within them. Consumers should run the latest available patch release in a supported minor line. Once a minor line falls outside the N-1 window, it no longer receives fixes or releases.
API stability
Nacelle is pre-1.0, but the 0.3 line distinguishes supported opt-in APIs
from explicitly experimental features. See Versioning and support
for the release promotion process and supported minor-version window.
Stable enough for prototype integrations:
nacelle::core::pipelinetyped context, responder, and handler contractsnacelle::tcpandnacelle::httptransport-owned request/response contractsnacelle::core::NacelleBodynacelle::core::{NacelleLimits, NacelleRuntimeState}nacelle::NacelleApplistener registration andNacelleApp::run(...)nacelle::prelude::*for common application importsnacelle::core::{NacelleTelemetry, NacelleTelemetryConfig}nacelle::core::NacelleTelemetryObserverfor statically dispatched application telemetry- the
phase-timingfeature and its documented low-cardinality phase schema - the
error-hintsfeature andNacelleError::hint()method
Experimental:
- runtime memory accounting behind
experimental-memory - Linux thread-per-core execution behind
experimental-thread-per-core - plaintext/OpenSSL detection behind
experimental-openssl-detection - stress tooling config
Features prefixed with experimental- are default-off and use at your own
risk. They are not part of the supported 0.3 contract and may change or be
removed in a future minor release. NacelleError::hint() is supported, but its
returned text is advisory operator guidance: do not parse it or treat it as a
stable error identifier. Match NacelleError::ResourceLimit with
NacelleResourceLimitReason, or NacelleError::Timeout with
NacelleTimeoutReason, instead. Both reason enums are non-exhaustive; include a
wildcard arm. Their as_str() methods return stable low-cardinality telemetry
labels. Applications may use Other(&'static str) for their own static, bounded
reason vocabulary.
Application code should use the app-first path:
NacelleApp::new().tcp(...).http(...).run().await. The app owns shared runtime
state, telemetry, shutdown, and listener supervision. Concrete transport
servers retain transport-specific limits and policy. nacelle::runtime::NacelleHost
and lower-level server APIs remain available for advanced manual supervision.
Public exports marked #[doc(hidden)] are internal composition plumbing. They
exist so the facade can coordinate one process-wide drain deadline, runtime
state, telemetry instance, and application-state binding across concrete
transport crates. They are not part of the supported 0.3 API contract, even
though Rust visibility permits direct use. Applications should use the visible
app, host, server, or listener methods instead. Removing or changing a hidden
export does not require a 0.3 compatibility shim.
Use NacelleApp::with_state(...) when handlers need application dependencies.
The app shares one typed root internally through Arc, while handlers borrow
&AppState from RequestContext::app_state(). Mutable access, dynamic type
maps, and runtime replacement of the whole root are outside the contract.
Growth-prone connection metadata, ConnectionInfo, telemetry events and event
kinds, and TCP/Unix listener option types are
non-exhaustive. Consumers must include wildcard enum match arms and construct
supported option values through new, Default, conversions, and with_* or
without_* builders. NacelleTcpConfig and transport limit types follow the
same builder-first rule so settings introduced by later releases retain their
defaults.
The former detached NacelleRequest/NacelleResponse handler and Tower adapter
were removed. Transport pipelines now remain strongly typed through completion;
there is no compatibility adapter.
Before 1.0, minor releases may change defaults or builder methods when production safety requires it. After 1.0, public API changes should follow semver, with migration notes for config/default changes.
Reference protocol migration
The former reference_protocol feature and its facade/prelude exports have
moved to the unpublished examples/nacelle-reference-protocol workspace
package. Repository examples depend on that package directly. Application code
should implement nacelle::tcp::Protocol or maintain its protocol in a separate
application crate rather than depending on a protocol implementation from the
Nacelle facade.
Rust API reference
Generate the Rust API reference with:
cargo doc -p nacelle --no-default-features --features buffer-rotation,error-hints,experimental-memory,experimental-thread-per-core,http,phase-timing,rustls,tcp,tls-self-signed --no-deps
cargo doc -p nacelle-openssl --all-features --no-deps
On Windows:
.\scripts\build-rustdoc.ps1
The facade documentation uses the Rustls backend. OpenSSL configuration is
documented separately from nacelle-openssl because both backend features
cannot be enabled in one build.
The generated index is:
target/doc/nacelle/index.html
Serving contract
The following contract applies to every supported app, host, direct TCP, TCP/Unix/TLS listener, and HTTP serving entry point. Overloads only select ownership of the listener, socket options, shutdown source, or drain timeout; they do not change request semantics.
- Purpose and ownership:
NacelleAppis the primary composition root and owns registered listener configurations untilrun.NacelleHoststarts manually registered listeners immediately and owns their tasks untilwaitorshutdown_and_wait. Lower-level listener functions consume anArc-backed server and own accepted connection tasks. Direct TCP methods borrow the server and own the supplied I/O value for the duration of the returned future. Dropping a serving future cancels that future; it does not provide a graceful drain guarantee. - Cancellation and shutdown: entry points without a shutdown argument run
until listener failure or external future cancellation. Token-aware entry
points stop accepting when the token changes, wait for active connections,
and abort tasks still active at the drain deadline.
NacelleApp::runalso requests process-wide shutdown when one listener fails. Configure Ctrl-C handling explicitly withwith_ctrl_c_shutdown(). - Errors: serving futures return
NacelleErrorfor bind, accept, socket, protocol, TLS, timeout, resource-limit, listener-task, and shutdown-drain failures. Match stable categories and reason enums rather than parsingDisplay. Connection-local failures, including HTTP connection-task panics, are observed through telemetry and do not normally stop the listener; listener setup/accept failure and top-level listener-task failure do. - Panics: shared-runtime serving methods do not intentionally panic for
runtime or peer input. They must be called while a Tokio runtime is entered;
Tokio may panic otherwise. Worker-local methods additionally require the
documented
LocalSet/thread-per-core context. Panics from application handlers are task failures and may trigger host/app supervision; panic-abort builds terminate instead of unwinding. - Limits:
NacelleRuntimeStatesupplies process-wide connection, per-peer, request, streaming-task, body-size, and optional memory limits. TCP and HTTP server configurations add transport timeouts, frame/header policy, and edge limits. Listener overloads do not bypass these limits. Functions whose names containwithout_connection_limitare advanced direct-I/O building blocks and require the caller to hold the connection permit. - Features: plain TCP and Unix serving require
tcp; HTTP/1 requireshttp; Rustls listeners requirerustls; required OpenSSL listeners requireopenssl.experimental-openssl-detection,experimental-memory, andexperimental-thread-per-coreremain outside the supported0.3contract. Unix-domain listeners are available only on Unix targets.
Runnable examples exercise the same contracts:
cargo run -p nacelle-examples --bin echo
cargo run -p nacelle-examples --bin http_echo --no-default-features --features http
cargo run -p nacelle-examples --bin tls_echo --features tls-self-signed
cargo run -p nacelle-examples --bin tls_http_echo --no-default-features --features http,tls-self-signed
cargo run -p nacelle-examples --bin listener_tcp
cargo run -p nacelle-examples --bin unix_echo
cargo run -p nacelle-examples --bin openssl_echo --no-default-features --features openssl -- cert.pem key.pem
See Runtime limits for default values and Operations model for the listener drain sequence.
Start with these public entry points:
nacelle::prelude::*for common application imports.nacelle::core,nacelle::codec,nacelle::tcp,nacelle::http,nacelle::openssl,nacelle::rustls, andnacelle::runtimefor capability-oriented imports.nacelle::openssl::NacelleOpenSslConfigandnacelle::rustls::NacelleTlsConfigfor concrete provider configuration.nacelle::advanced::runtimefor raw executor and transport listener helpers when app/host composition is not sufficient.nacelle::NacelleApplistener registration andNacelleApp::run(...)for the app-first serving path across TCP, Unix sockets, HTTP, and TLS.NacelleApp::with_state(...)orwith_state_and_telemetry(...)for one typed dependency root shared across listeners. Declare it inTcpRequestContext<P, AppState>orHttpRequestContext<ConnectionState, AppState>and borrow it throughRequestContext::app_state().nacelle::core::pipeline::Handlerfor typed shared-runtime handlers.nacelle::tcp::{NacelleTcpConfig, NacelleTcpLimits}for TCP buffering, framing, and timeout policy. These structs are non-exhaustive; construct them withDefaultand applywith_*builders so future fields retain their defaults.nacelle::runtime::{ThreadPerCoreConfig, WorkerSet}and therun_local_*_thread_per_core(...)functions for experimental Linux-only worker-local TCP, HTTP, Rustls, required OpenSSL, and optional OpenSSL execution. These APIs requireexperimental-thread-per-core; this mode does not silently fall back to the shared runtime.LocalTcpRuntimeConfig::with_state(...)andLocalHttpRuntimeConfig::with_state(...)to share the same typed dependency root across worker-local listeners.ThreadPerCoreConfig::with_max_threads(...)to cap the worker threads selected byWorkerSet::all(),WorkerSet::first(...), orWorkerSet::explicit(...)while preserving selection order. The shared runtime is caller-owned; configure its Tokio thread count on the runtime builder instead.nacelle::runtime::ThreadPerCoreLimits::Globalfor exact process-wide counters, orThreadPerCoreLimits::Workerfor partitioned worker-local counters. Worker mode enforces one shared hard memory ceiling across all workers whenexperimental-memoryis enabled.nacelle::runtime::WorkerContext::offload_blocking(...)for explicit blocking work whose completion is awaited back on the originating local worker.nacelle::tcp::Protocolfor TCP wire-format adapters.nacelle::tcp::{TcpServer, LocalTcpServer}forArc-backed connection state, orSerialTcpServer/LocalSerialTcpServerfor exclusive mutable state lent to one serial handler at a time.- With
experimental-memory,nacelle::tcp::TcpStreamingBodyMemoryPolicyto retain declared-length admission or account only live streaming chunks. NacelleAppandNacelleHostserial listener methods for plain TCP, required OpenSSL, optional OpenSSL, and Unix sockets. Optional plaintext/ OpenSSL methods requireexperimental-openssl-detection, which implies thetcpandopensslfeatures.nacelle::runtime::run_local_serial_tcp_thread_per_core(...)andrun_local_serial_tcp_openssl_thread_per_core(...)for worker-local serial plain TCP and required OpenSSL. Userun_local_serial_tcp_optional_openssl_thread_per_core(...)when plaintext and OpenSSL must share one worker-local listener; it requires both experimental features. Worker factories run once per worker, so externally bounded pools should be shared deliberately rather than constructed per worker.- Use
without_handler_timeout(), the fourNacelleTcpLimits::without_*_timeout()builders, and the HTTPwithout_*_timeout()/without_max_connection_age()builders when an explicitly unbounded policy is required. nacelle::core::{NacelleTelemetry, NacelleTelemetryConfig}for metrics and telemetry.nacelle::core::NacelleError::hint()with theerror-hintsfeature for optional operator guidance.NacelleError::Displayremains stable across feature combinations; applications append hints deliberately where suitable. Hint text is advisory and must not be parsed as a stable identifier.- Match
NacelleError::ResourceLimit(NacelleResourceLimitReason::...)andNacelleError::Timeout(NacelleTimeoutReason::...)for programmatic handling. The reason enums are non-exhaustive and theiras_str()methods expose stable low-cardinality labels. UseOther(&'static str)only for application-owned static reason vocabularies. - With
experimental-memory,nacelle::core::{NacelleMemoryBudget, NacelleMemoryAllocation}andNacelleRuntimeState::memory_budget()for shared application/transport memory budget allocations. Owned allocation guards can release retained capacity withNacelleMemoryAllocation::shrink_to(...). nacelle::tcp::TcpServer,nacelle::http::HyperServer,nacelle::runtime::NacelleHost, andnacelle::advanced::runtimewhen a service needs lower-level listener control.
Connection metadata, ConnectionInfo, telemetry event types, and TCP/Unix
listener options are non-exhaustive. Observe them with
wildcard enum matches and construct option values through their documented
constructors, defaults, conversions, and builders.