Limits, timeouts and memory
Source files: 108 · checked against Etemenanki 596916d
Etemenanki/concepts/src/core.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/concepts/src/buffer.rsEtemenanki/concepts/src/relay.rsEtemenanki/concepts/src/wake.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/sniff/collector.rsEtemenanki/protocols/src/transports/accept.rsEtemenanki/protocols/src/transports/connect.rsEtemenanki/protocols/src/transports/keepalive.rsEtemenanki/protocols/src/transports/ws/endpoint.rsEtemenanki/protocols/src/transports/ws/stream.rsEtemenanki/protocols/src/transports/grpc/settings.rsEtemenanki/protocols/src/transports/grpc/liveness.rsEtemenanki/protocols/src/transports/grpc/framing.rsEtemenanki/environment/src/dial/tcp.rsEtemenanki/environment/src/dial/socket.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/protocols/src/mux/frame.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/http/protocol.rsEtemenanki/protocols/src/http/core.rsEtemenanki/protocols/src/http/codec.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/socks/protocol.rsEtemenanki/protocols/src/socks/codec.rsEtemenanki/protocols/src/socks/udp_link.rsEtemenanki/protocols/src/trojan/protocol.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/trojan/codec.rsEtemenanki/protocols/src/vless/protocol.rsEtemenanki/protocols/src/vless/core.rsEtemenanki/protocols/src/vless/codec.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/vmess/codec.rsEtemenanki/protocols/src/vmess/framing.rsEtemenanki/protocols/src/vmess/accounts.rsEtemenanki/protocols/src/ss_legacy/aead.rsEtemenanki/protocols/src/ss_legacy/core.rsEtemenanki/protocols/src/ss_legacy/codec.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_2022/protocol.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/ss_2022/codec.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/hysteria/protocol.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/protocols/src/hysteria/slot.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/endpoint.rsEtemenanki/protocols/src/hysteria/server/inbound.rsEtemenanki/protocols/src/hysteria/server/datagrams.rsEtemenanki/protocols/src/hysteria/server/shim.rsEtemenanki/protocols/src/hysteria/obfs.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/protocols/src/wireguard/slot.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/tun/inbound.rsEtemenanki/protocols/src/tun/udp.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/dns/message.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/proxy.rsEtemenanki/app/src/outbound/freedom.rsEtemenanki/app/src/outbound/udp_fanout.rsEtemenanki/app/src/balancer.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/config.rsEtemenanki/app/src/main.rsEtemenanki/concepts/tests/runtime.rsEtemenanki/concepts/tests/client.rsEtemenanki/environment/tests/integration/tcp.rsEtemenanki/protocols/tests/unit/core/mod.rsEtemenanki/protocols/tests/unit/sniff/collector.rsEtemenanki/protocols/tests/unit/transports/accept.rsEtemenanki/protocols/tests/unit/transports/grpc_framing.rsEtemenanki/protocols/tests/unit/transports/grpc_liveness.rsEtemenanki/protocols/tests/unit/transports/ws_endpoint.rsEtemenanki/protocols/tests/unit/mux/demux.rsEtemenanki/protocols/tests/unit/mux/frame.rsEtemenanki/protocols/tests/unit/http/core.rsEtemenanki/protocols/tests/unit/socks/server.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/unit/trojan/protocol.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/tests/unit/vmess/accounts.rsEtemenanki/protocols/tests/unit/hysteria/protocol.rsEtemenanki/protocols/tests/unit/hysteria/slot.rsEtemenanki/protocols/tests/unit/hysteria/server/datagrams.rsEtemenanki/protocols/tests/unit/wireguard/slot.rsEtemenanki/protocols/tests/unit/wireguard/device.rsEtemenanki/protocols/tests/pipeline/wireguard.rsEtemenanki/protocols/tests/unit/tun/udp.rsEtemenanki/protocols/tests/unit/dns/mod.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/unit/outbound.rsEtemenanki/app/tests/unit/serve.rsEtemenanki/app/tests/unit/balancer.rsEtemenanki/app/tests/integration/e2e_udp_route.rsEtemenanki/app/tests/integration/e2e_balancer.rs
This page lists every timeout, cap and fixed buffer size in the Etemenanki kernel crates and in etemenanki-app. For each one it gives the Rust name, the value, the file that defines it and the mechanism that enforces it. The second half turns the buffer constants into a per-connection memory budget: what one proxied connection costs for each server core and each client codec.
Read it before you change a limit, add a protocol, or try to explain why a connection ended. The runtime, the cores, the transports, Hysteria 2, WireGuard and TUN each have their own page. This page covers only the numbers and where they apply. The few limits an operator can set in the configuration file are described for operators in Limits.
Responsibilities
Section titled “Responsibilities”A limit in the code takes one of three forms:
| Form | Where it lives | Who can change it |
|---|---|---|
A const in a kernel or app module |
Next to the code that enforces it, usually pub or pub(crate) so tests can name it |
Only a code change and a release |
| A configuration default | A DEFAULT_* constant that a builder in app/src/ uses when a key is absent |
The operator, through the keys in Configurable limits |
| A derived bound | A const fn or a const expression over other constants, such as downlink_overhead(Self::BUF_SIZE) |
Only by changing its inputs |
Two rules keep the buffer numbers consistent. Both are enforced in code:
- The byte buffers of a runtime are fixed-size boxed arrays (
Box<[u8; N]>, fromboxed_arrayinconcepts/src/buffer.rs), allocated and zeroed once, when the runtime is built. They never grow. Backpressure comes from a full buffer, not from a queue. - A runtime refuses to be built when its buffer cannot hold the core’s or codec’s staging reserve.
ProxyServerRuntime::buildassertsBUF_SIZE > Core::STAGING_RESERVEandProxyClientRuntime::newassertsBUF_SIZE > Codec::STAGING_RESERVE. Both panic, withBUF_SIZE must exceed the core's STAGING_RESERVE or nothing is ever readandBUF_SIZE must exceed the codec's STAGING_RESERVE or nothing can be sealed.
Key types
Section titled “Key types”Buffer sizes and reserves
Section titled “Buffer sizes and reserves”BUF_SIZE is a const generic of both runtimes:
pub struct ProxyServerRuntime<const BUF_SIZE: usize, Core, Trans, Conn, Mode = ProxyRunsQuiet>where Core: ProxyCoreDecode, Conn: Connector<Core::Target>,pub struct ProxyClientRuntime<const BUF_SIZE: usize, Codec, Conn>where Codec: ProxyCoreEncodeHandshake, Conn: Connector<Codec::Target>,
impl<const BUF_SIZE: usize, Codec, Conn> ProxyClientRuntime<BUF_SIZE, Codec, Conn>where Codec: ProxyCoreEncodeHandshake, Codec::Error: Error + Send + Sync + 'static, Conn: Connector<Codec::Target>,{ pub fn new(codec: Codec, connector: &mut Conn, target: Codec::Target) -> Self}BUF_SIZE is not a trait item. Each server core exposes it as an inherent constant, for example pub const BUF_SIZE: usize = 16 * 1024 on impl<T> TrojanCore<T>. The caller instantiates the runtime with it, as in drive::<{ TrojanCore::<()>::BUF_SIZE }, _, _> in app/src/serve.rs. Client buffer sizes are chosen by the app, in app/src/outbound/mod.rs, as the first parameter of ProxyClient<const BUF: usize, S, D>.
The two reserve constants are trait items:
pub trait ProxyCoreDecode { type Key: Copy + Ord + Send + Sync + 'static; type Target; type Error; type TransportAddr: Clone + Send + Sync + 'static;
const STAGING_RESERVE: usize; const MAX_DATAGRAM: usize = 4096;
fn handle( &mut self, event: Event<'_, Self>, effects: &mut Effects<'_, Self>, ) -> Result<usize, Self::Error>;
fn held(&self) -> &[u8] { &[] }}
pub trait ProxyCoreEncodeHandshake { type Target; type Error;
const STAGING_RESERVE: usize;
fn start(&mut self, out: &mut Staging<'_>) -> Result<Handshake, Self::Error>; fn reply(&mut self, wire: &mut [u8], out: &mut Staging<'_>) -> Result<Reply, Self::Error>; fn finish(&mut self, out: &mut Staging<'_>) -> Result<(), Self::Error>;}STAGING_RESERVEon a server core is the most the core stages toward the transport while it handles one event, not counting the event’s own payload.STAGING_RESERVEon a client codec is the most onestart,reply,finishor seal call adds beyond the plaintext it takes: header, length prefix, tag and padding.MAX_DATAGRAMis the largest packet delivered whole on either side of a server runtime. A longer packet is truncated, the way a kernelrecvtruncates.
The runtime turns the last two into the largest datagram it reads from a datagram outbound:
impl<const BUF_SIZE: usize, Core, Trans, Conn, Mode> ProxyServerRuntime<BUF_SIZE, Core, Trans, Conn, Mode>where Core: ProxyCoreDecode, Trans: Transport<Addr = Core::TransportAddr>, Conn: Connector<Core::Target>, Conn::Datagram: DatagramLink<Addr = Destination>,{ pub const fn datagram_limit() -> usize}datagram_limit() returns min(Core::MAX_DATAGRAM, BUF_SIZE - Core::STAGING_RESERVE).
Mux carriers size their reserve with one shared helper, so the Keep headers that one outbound read splits into always fit:
pub const fn downlink_overhead(read_size: usize) -> usizeIt returns read_size.div_ceil(MAX_DATA_LEN).saturating_mul(FRAME_OVERHEAD_MAX).
Deadlines
Section titled “Deadlines”Every core keeps its one deadline in a Timing value from protocols/src/core/mod.rs:
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);pub const RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
impl Timing { pub fn new() -> Self pub fn phase(&self) -> Phase pub fn is_established(&self) -> bool pub fn touch<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) pub fn enter<C: ProxyCoreDecode>(&mut self, phase: Phase, fx: &mut Effects<'_, C>) pub fn expired<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) -> Expired}A runtime delivers no event while the client is silent, so the app also watches the handshake from outside the runtime:
async fn drive<const BUF: usize, Core, S>( stream: S, core: Core, connector: AppConnector, handshake: Arc<OwnedSemaphorePermit>,) -> io::Result<()>where S: AsyncRead + AsyncWrite + Unpin, Core: ProxyCoreDecode<Target = Flow, Error = io::Error, TransportAddr = ()> + Established,
pub trait Established { fn is_established(&self) -> bool;}Other limit-bearing entry points
Section titled “Other limit-bearing entry points”pub fn set_keepalive(stream: &TcpStream)
// environment/src/dial/tcp.rsimpl TcpDialer { pub fn with_connect_timeout(mut self, timeout: Duration) -> Self pub async fn connect(&self, addr: SocketAddr) -> io::Result<TcpStream> pub async fn connect_any(&self, addrs: &[SocketAddr]) -> io::Result<TcpStream>}
// environment/src/dial/socket.rsimpl SocketOptions { pub fn with_tcp_keepalive(mut self, idle: Duration) -> Self}
// protocols/src/transports/ws/endpoint.rspub(crate) fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig
// protocols/src/transports/grpc/liveness.rsimpl Liveness { pub(crate) fn new(ping_pong: Option<PingPong>) -> Self pub(crate) fn note_progress(&mut self) pub(crate) async fn watch(&mut self, idle: bool)}
// protocols/src/dns/mod.rsimpl Resolver { pub fn with_ttl_bounds(self, min: Duration, max: Duration) -> Self pub async fn resolve(&self, host: &str) -> io::Result<Vec<IpAddr>>}
// protocols/src/hysteria/slot.rs (ConnSlot) and protocols/src/wireguard/slot.rs (DeviceSlot)pub fn died_young(&self) -> boolpub fn note_failure(&mut self)
// app/src/balancer.rsimpl Balancer { pub fn spawn_probe( self: &Arc<Self>, token: CancellationToken, interval: Duration, timeout: Duration, resolver: Resolver, )}Data flow
Section titled “Data flow”The diagram follows one TCP connection through etemenanki-app and marks where each group of limits applies. Hysteria 2 and TUN inbounds own their sockets and run their own admission; their sections below cover them.
flowchart TB accept["accept loop: ACCEPT_ERROR_BACKOFF"] admit["live permit, then handshake permit"] transport["InboundTransport::accept: keepalive, TRANSPORT_HANDSHAKE_TIMEOUT"] carrier["WS and gRPC: message caps, idle and PING deadlines"] runtime["ProxyServerRuntime: 3 x BUF_SIZE, FrameTooLarge"] timing["core Timing: handshake, sniff, idle"] watchdog["drive: HANDSHAKE_TIMEOUT per step until established"] mux["mux.cool Demux: MAX_SESSIONS, MAX_DATA_LEN"] connector["AppConnector"] tcp["freedom: DEFAULT_CONNECT_TIMEOUT per address"] fanout["UDP flow: FanOutLink, MAX_SUBS"] client["proxy outbound: ProxyClientRuntime, 2 x BUF"] dns["Resolver: QUERY_TIMEOUT, CACHE_CAPACITY"] accept --> admit --> transport --> carrier --> runtime runtime --> timing watchdog --> runtime runtime --> mux runtime --> connector mux --> connector connector --> tcp connector --> fanout connector --> client tcp --> dns
Each core has exactly one deadline, and its meaning depends on the phase. Timing::touch runs at the top of every byte-carrying event, and Timing::enter moves to a new phase and arms that phase’s deadline:
stateDiagram-v2 [*] --> Handshake: runtime built, nothing armed Handshake --> Handshake: first byte event arms HANDSHAKE_TIMEOUT Handshake --> Sniff: request parsed, IP target, sniffing on Handshake --> Relay: request parsed, flow opened Sniff --> Relay: domain found, budget spent, or SNIFF_TIMEOUT Relay --> Relay: every byte event re-arms RELAY_IDLE_TIMEOUT Relay --> Closing: Expired Idle, Timing pushes Finish Relay --> [*]: both halves closed, core pushes Finish Closing --> [*]: runtime finishes Handshake --> [*]: Expired Handshake, core returns an error
No core enters Phase::Closing itself. Only Timing::expired moves a relaying connection there, in the same step that pushes Effect::Finish. When both halves of a single-flow relay close normally, Passthrough pushes Finish while Timing is still in Phase::Relay.
The two handshake guards complement each other:
| Client behaviour | What ends it | Error text |
|---|---|---|
| Sends nothing at all | drive wraps each runtime.next() in tokio::time::timeout(HANDSHAKE_TIMEOUT, …) until the core reports is_established() |
inbound handshake timed out after 10s |
| Sends a first byte, then trickles the rest | Timing arms HANDSHAKE_TIMEOUT once, on the first byte event, and never refreshes it in Phase::Handshake, so it limits the whole request rather than the gap between bytes |
client did not complete its request in time |
The idle deadline, by contrast, is re-armed on every byte event in Relay and Closing. That makes it an idle limit, not a lifetime limit.
Limits by layer
Section titled “Limits by layer”Values are exact. Sizes are given in KiB or MiB where the source writes n * 1024.
Runtime and core
Section titled “Runtime and core”| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
BUF_SIZE (const generic) |
Per core; see Server cores | ProxyServerRuntime in concepts/src/runtime.rs |
Sizes the boxed arrays up (a ReadBuffer), staging (a WriteBuffer) and scratch. |
ProxyCoreDecode::STAGING_RESERVE |
Per core | concepts/src/core.rs |
poll_transport_read reads the transport only when staging.room() is at least the reserve. A stream outbound is read only with STAGING_RESERVE + 1 bytes of room, and then into at most room - STAGING_RESERVE bytes of scratch. A datagram outbound is read only with STAGING_RESERVE + datagram_limit() of room. |
ProxyCoreDecode::MAX_DATAGRAM |
4096 by default; some cores raise it | concepts/src/core.rs |
A datagram outbound is read into scratch[..datagram_limit()]. A datagram transport is read into up.free()[..MAX_DATAGRAM.min(BUF_SIZE)]. |
RuntimeError::FrameTooLarge |
When the unparsed region fills BUF_SIZE |
concepts/src/runtime.rs, in poll_transport_read |
ReadBuffer::is_saturated() (start == 0 && end == N) while the core still wants more. Error text: protocol frame exceeds the read buffer. |
client down saturation |
When an unopened frame fills the client’s BUF_SIZE |
concepts/src/client.rs |
The same is_saturated() check on the client runtime’s down buffer. Error text: upstream frame larger than the client runtime's buffer. On the sending side, make_room fails with frame larger than the client runtime's buffer when a packet plus the reserve cannot fit even in an empty staging buffer. |
WORK_BUDGET |
64 units of work | concepts/src/runtime.rs |
Future::poll of a quiet runtime calls poll_once at most 64 times, then wakes itself and returns Pending, so a busy connection cannot monopolise a worker. The progress mode (showing_progress) is a Stream that yields after every unit instead. In the app, the Hysteria 2 stream and datagram runtimes and the TUN UDP associations are awaited as quiet futures; drive and the TUN inbound’s serve_stream use progress mode. |
INLINE_EFFECTS |
4 | concepts/src/core.rs |
Inline capacity of EffectList, a SmallVec. A longer effect list spills to the heap. Sized for Open + Forward + SetDeadline. |
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
Timing in every core. The app’s drive, the TUN inbound’s serve_stream and the SOCKS driver use the same constant for their own watchdogs. |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
Timing::touch re-arms it on every byte event in Relay and Closing. On expiry Timing::expired pushes Effect::Finish. The SOCKS driver reuses it; see Protocol frame caps and drivers. |
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
Timing::enter(Phase::Sniff, …). On expiry the core opens the flow with what it has collected. |
SNIFF_LIMIT |
4 KiB, across frames | protocols/src/sniff/mod.rs |
Collector::push returns Verdict::Exhausted once 4 KiB are held; Collector::remaining never offers more. |
MAX_DOMAIN_LEN |
253 | protocols/src/sniff/mod.rs |
plausible_domain rejects a longer sniffed host. |
HARNESS_STAGING |
64 KiB | protocols/src/core/harness.rs |
The staging room CoreHarness offers a core per call. The harness drives a core by hand for tests and does no I/O; no production path uses it. |
BidirectionalConnection BUF_SIZE |
8192 per direction by default | concepts/src/relay.rs |
One Box<[u8; BUF_SIZE]> per direction. The SOCKS driver instantiates it with RELAY_BUF. |
Each opened outbound also costs one Arc<KeyWaker> (concepts/src/wake.rs), the connector’s future while it dials, and one BTreeMap entry. The runtime itself puts no cap on the number of outbounds. A core that multiplexes bounds them; see Mux.
Transports and dialing
Section titled “Transports and dialing”| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
TRANSPORT_HANDSHAKE_TIMEOUT |
10 s | protocols/src/transports/accept.rs |
within() wraps one step of InboundTransport::accept: the TLS accept for tls, or, for ws and grpc, the optional TLS accept and the WebSocket upgrade or HTTP/2 handshake together, under one 10 s budget. Error text: tls handshake timed out, websocket handshake timed out, grpc handshake timed out; a WebSocket or gRPC transport over TLS reports its own name even when TLS was the slow part. serve_socket logs the failure at debug as inbound transport failed: <error>. Plain TCP and Unix sockets have no transport step. |
TCP_KEEPALIVE_IDLE |
120 s | protocols/src/transports/keepalive.rs |
set_keepalive, applied to every TCP socket InboundTransport::accept receives and every socket TransportConnector::dial opens. Best effort: when the platform rejects the option, it logs could not enable TCP keepalive at debug and keeps the connection. |
TCP_KEEPALIVE_INTERVAL |
30 s | same | same |
TCP_KEEPALIVE_RETRIES |
3 | same | same |
DEFAULT_CONNECT_TIMEOUT |
10 s per address | environment/src/dial/tcp.rs |
TcpDialer::connect wraps socket.connect in tokio::time::timeout: connect to <addr> timed out. connect_any tries addresses one after another (not Happy Eyeballs), so the worst case is 10 s times the number of addresses; when all fail it returns failed to connect to any address (…) with every per-address error. Used by the freedom outbound and by TransportConnector. with_connect_timeout overrides the value; nothing in the kernel or the app calls it. |
SocketOptions::tcp_keepalive |
None by default |
environment/src/dial/socket.rs |
When set with with_tcp_keepalive, TcpDialer::socket sets the keepalive idle time. Nothing in the kernel or the app sets it. The app’s freedom outbound builds its dialer from SocketOptions::default() and does not call set_keepalive, so its sockets keep the platform’s keepalive default. |
MAX_EARLY_DATA |
16 KiB | protocols/src/transports/ws/endpoint.rs |
Inbound: decode_early_data_header refuses a larger decoded Sec-WebSocket-Protocol payload, and the upgrade is answered 413 Payload Too Large. Outbound: an ?ed=N limit is clamped to N.min(MAX_EARLY_DATA). |
MAX_WS_MESSAGE_LEN |
1 MiB | protocols/src/transports/ws/endpoint.rs |
ws_config() sets both max_message_size and max_frame_size, replacing tungstenite’s defaults of 64 MiB and 16 MiB, on every session, accepted or dialed. |
WS_KEEPALIVE_INTERVAL |
60 s | protocols/src/transports/ws/stream.rs |
The read side of WsStream queues a Ping when this timer fires. touch() resets it on every received frame and every write. |
WS_IDLE_TIMEOUT |
300 s | protocols/src/transports/ws/stream.rs |
The read side reports end of stream when 300 s pass with no received frame and no write. Any frame counts, Pong included, which is what makes the Ping a liveness probe. |
H2_INITIAL_STREAM_WINDOW_SIZE |
4 MiB | protocols/src/transports/grpc/settings.rs |
configured_server_builder and configured_client_builder (HTTP/2 flow control). |
H2_INITIAL_CONNECTION_WINDOW_SIZE |
16 MiB | same | same |
H2_MAX_FRAME_SIZE |
256 KiB | same | same |
H2_MAX_CONCURRENT_STREAMS |
256 | same | Server builder only: at most 256 gRPC tunnel streams per HTTP/2 connection. |
MAX_GRPC_MESSAGE_LEN |
1 MiB | same | The Hunk decoder in grpc/framing.rs checks the declared length before it buffers the body: grpc message length exceeds the accepted maximum. |
H2_IDLE_TIMEOUT |
300 s | same | Liveness ends a served connection that carries no streams for 300 s. serve_h2 calls note_progress on every accepted stream and on every stream-count change while streams are open, which restarts the deadline. |
H2_KEEPALIVE_INTERVAL |
60 s | same | Liveness sends a PING every 60 s on a served connection. |
H2_KEEPALIVE_TIMEOUT |
20 s | same | A PING that is not answered within 20 s ends the served connection. |
Admission
Section titled “Admission”Admission limits decide whether a new connection, stream or flow is taken at all. Apart from the QUIC stream credit (DEFAULT_MAX_INCOMING_STREAMS), none of them queue: each uses try_acquire_owned or try_send, and a refusal is immediate.
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
MAX_LIVE_CONNECTIONS_PER_INBOUND |
65,536 | app/src/serve.rs |
A Semaphore per stream inbound. run_stream_inbound takes one permit per accepted socket, and serve_connection holds it (as _session) until the socket and every stream it yields are done. At the limit the socket is dropped and the loop logs dropping inbound connection; live connection limit reached at debug. |
MAX_HANDSHAKES_PER_INBOUND |
2048 | app/src/serve.rs |
A second Semaphore per stream inbound, taken after the live permit. drive drops its handshake permit (handshake.take()) once the core reports is_established(). At the limit: dropping inbound connection; handshake limit reached at debug. |
ACCEPT_ERROR_BACKOFF |
100 ms | app/src/serve.rs |
After an accept error other than ConnectionAborted or Interrupted (for example EMFILE), should_backoff_accept_error is true, the loop logs accept error, backing off 100ms at warn, and sleeps 100 ms or until cancelled. |
DEFAULT_MAX_CONNECTIONS |
4096 | protocols/src/hysteria/server/config.rs |
Hysteria 2 listener: one permit per QUIC connection, taken before the handshake completes and held for the connection’s life. At the limit incoming.refuse() tells the client at once. Config key max_connections. |
DEFAULT_MAX_CIRCUITS |
65,536 | protocols/src/hysteria/server/config.rs |
ServerConfig::circuit_permits, shared by every connection on the listener. A proxy stream takes a permit before its runtime is spawned and moves it into the task. A UDP session takes one when it opens and keeps it in its Session. At the limit a stream is reset with H3_REQUEST_REJECTED, and a datagram is dropped. Config key max_circuits. |
DEFAULT_MAX_INCOMING_STREAMS |
1024 | protocols/src/hysteria/server/endpoint.rs |
max_concurrent_bidi_streams in the QUIC transport config. QUIC withholds stream credit, so the client’s open_bi waits rather than being refused. |
MAX_CLASSIFYING_STREAMS |
64 | protocols/src/hysteria/server/inbound.rs |
Capacity of the two mpsc channels between the HTTP/3 layer and the stream classifier of one connection. When the channel toward the classifier is full, Hy2H3Conn::poll_accept_bidi (protocols/src/hysteria/server/shim.rs) resets the new stream with H3_EXCESSIVE_LOAD and logs hysteria2: resetting a stream; too many are awaiting classification at debug. |
DEFAULT_MAX_FLOWS |
65,536 | protocols/src/tun/config.rs |
TUN: one Semaphore across the device. Each TCP stream and each UDP association (one per client source address) takes a permit. At the limit the flow is dropped: tun: dropping a flow; the flow limit is reached at debug. Config key max_flows. |
FLOW_QUEUE |
16 | protocols/src/tun/udp.rs |
Capacity of the channel that hands new flows of one client source to its association. When it is full, try_send fails and the new flow is dropped; the client retransmits. |
The mux.cool demultiplexer, Demux, is shared by the Trojan, VLESS and VMess cores.
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
MAX_SESSIONS |
256 sub-flows per carrier | protocols/src/mux/demux.rs |
Demux answers a New frame with End and discards its payload when 256 sessions are open or the id is already live. The carrier stays up. |
MAX_META_LEN |
512 | protocols/src/mux/frame.rs |
The frame parsers reject a longer metadata block: mux: metadata length … exceeds 512. |
MAX_DATA_LEN |
8 KiB | protocols/src/mux/frame.rs |
The frame parsers reject a longer data block: mux: data length … exceeds 8192. On the downlink, Demux::on_outbound splits a read into Keep frames of at most 8 KiB, and Demux::on_datagram drops a packet longer than 8 KiB. |
FRAME_OVERHEAD_MAX |
268 | protocols/src/mux/frame.rs |
2 + MIN_META_LEN + 1 + AddressCodec::MAX_LEN + 2, with MIN_META_LEN = 4: the largest Keep header. |
downlink_overhead(16 * 1024) |
536 | protocols/src/mux/demux.rs |
Two Keep headers. Part of the staging reserve of TrojanCore and VlessCore. |
UDP fan-out
Section titled “UDP fan-out”The app does not dial a UDP flow as a unit. AppConnector::connect returns a FanOutLink, which routes each datagram by its own destination and keeps one sub-link per chosen outbound.
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
MAX_SUBS |
64 sub-links per association | app/src/outbound/udp_fanout.rs |
FanOutLink::poll_opening pushes the new sub-link and, when the table then holds more than 64, removes the front entry. A send moves its sub-link to the back, so the evicted one is the least recently sent to. Dropping it closes that outbound’s socket. |
A sub-link that fails to open is logged at debug (udp fan-out: opening an outbound failed), and the packet that triggered it is not delivered.
Hysteria 2
Section titled “Hysteria 2”Server side:
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
STREAM_RECEIVE_WINDOW |
8 MiB | protocols/src/hysteria/server/endpoint.rs |
QUIC per-stream receive window. |
CONNECTION_RECEIVE_WINDOW |
20 MiB (STREAM_RECEIVE_WINDOW / 2 * 5) |
same | QUIC per-connection receive window. |
MAX_IDLE_TIMEOUT |
30 s | same | QUIC max_idle_timeout: quinn closes a connection that is silent for 30 s. |
CLASSIFY_TIMEOUT |
10 s | protocols/src/hysteria/server/inbound.rs |
A stream must reveal its frame type within 10 s, or it is reset with H3_REQUEST_CANCELLED. |
DRAIN_TIMEOUT |
3 s | same | Hy2Inbound::shutdown closes the endpoint with CLOSE_CODE, then waits up to 3 s for endpoint.wait_idle(). |
RELEASE_TIMEOUT, RELEASE_POLL |
3 s, 20 ms | same | shutdown then tries to bind the port every 20 ms, for up to 3 s, so the next generation can bind it. A timeout logs hysteria2: <addr> did not come free within 3s at warn. |
CLOSE_CODE |
0x100 |
same | QUIC application error code sent to every client on teardown. |
MAX_SESSIONS |
256 UDP sessions per connection | protocols/src/hysteria/server/datagrams.rs |
While 256 sessions are open, Hy2UdpCore drops every packet that names a new session id (logged at trace). |
SWEEP_INTERVAL |
1 s | same | The core’s deadline. Each expiry advances its tick clock and closes sessions that have been quiet for udp_idle_timeout whole seconds (at least 1). |
udp_idle_timeout |
60 s default, 2 to 600 s accepted | app/src/inbound/mod.rs |
build_hysteria2_inbound refuses values outside the range, and refuses the key when udp is off. |
Protocol and client side:
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
MAX_ADDRESS_LENGTH |
2048 | protocols/src/hysteria/protocol.rs |
Encoding and parsing of TCPRequest and UDPMessage addresses. |
MAX_MESSAGE_LENGTH |
2048 | same | The longest TCPResponse message that is encoded or parsed. |
MAX_MESSAGE_KEPT |
128 | same | How many characters of a server-supplied message survive, sanitised, into an io::Error on the client. |
MAX_PADDING_LENGTH |
4096 | same | The longest padding accepted on any frame. |
MAX_UDP_SIZE |
4096 | same | The Defragger refuses to reassemble beyond 4096 bytes. Also Hy2UdpCore::MAX_DATAGRAM. |
MAX_DATAGRAM_FRAME_SIZE |
1200 | same | The server’s datagram task passes it to Hy2UdpCore as the peer’s datagram size when the connection reports no max_datagram_size(); longer replies are fragmented. |
MIN_PSK_LEN |
4 bytes | protocols/src/hysteria/obfs.rs |
Shortest Salamander key. The app refuses a shorter obfs_password on both sides: obfs_password must be at least 4 bytes for salamander. |
CONNECT_TIMEOUT |
10 s per address | protocols/src/hysteria/connection.rs |
Hy2Conn::connect bounds each address attempt: the socket bind, the QUIC handshake and the /auth round trip. The server name is resolved once, before the attempts, outside this timeout. A timed-out attempt is recorded as <addr>: timed out, and when every address fails the error is hysteria2: no address answered (…). |
OPEN_STREAM_TIMEOUT |
5 s | same | Bounds open_bi when a proxy stream is opened: hysteria2: timed out opening a proxy stream. |
DEFAULT_MAX_CONCURRENT_STREAMS |
102,400 | protocols/src/hysteria/config.rs |
The client’s streams semaphore. A permit is taken with try_acquire_owned before a stream is opened and held while it relays: hysteria2: connection is at its concurrent-stream limit. The default sits far above a default server’s 1024 streams, so in practice the server’s stream credit binds first and open_bi waits, up to OPEN_STREAM_TIMEOUT. Config key max_concurrent_streams; 0 is refused. |
MAX_UDP_SESSIONS |
256 | protocols/src/hysteria/connection.rs |
hysteria2: connection is at its UDP association limit. |
UDP_SESSION_BACKLOG |
256 datagrams | same | Capacity of each association’s receive channel. When it is full, try_send fails and the datagram is dropped, so a slow consumer never stalls the connection’s reader. |
KEEP_ALIVE |
10 s | same | QUIC keep_alive_interval on the client. The client’s windows and MAX_IDLE_TIMEOUT match the server’s. |
RECONNECT_BACKOFF_BASE, RECONNECT_BACKOFF_MAX |
1 s, 30 s | protocols/src/hysteria/slot.rs |
ConnSlot::note_failure sets retry_at to BASE * 2^min(failures, 5), capped at MAX: 2 s, 4 s, 8 s, 16 s, then 30 s. |
MIN_HEALTHY_LIFETIME |
10 s | same | A connection lost within 10 s of starting counts as a failure (died_young), so backoff accumulates against a server that authenticates and then closes. |
WireGuard
Section titled “WireGuard”| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
DEFAULT_MTU |
1420 | protocols/src/wireguard/config.rs |
InMemoryDevice reports it as smoltcp’s max_transmission_unit. Config key mtu. |
CHANNEL_CAP |
256 items | protocols/src/wireguard/device.rs |
Capacity of the device’s command channel and of the two mpsc channels between each tunnel flow and the application side. The channels count items, not bytes: one WgStream write is one item of the caller’s buffer size. The driver holds at most one uplink item per connection (Uplink) and reads that connection’s channel only while it holds none, so the most one connection can queue on its way up is its 64 KiB TCP_BUFFER send ring, plus 256 channel items, plus 1 held item; past that, the writer waits. CHANNEL_CAP is also the most items the driver hands one connection’s socket per pass, so no producer can keep the driver to itself. On the way down, the driver reads a socket only while that connection’s channel has room. |
TCP_BUFFER |
64 KiB | same | Size of each smoltcp TCP ring buffer (one per direction), and of the payload store of each UDP packet buffer (one per direction, with 64 metadata slots). A UDP datagram that finds the send ring full is held until the next poll empties it. A datagram larger than the whole 64 KiB send ring can never be sent, so the driver drops it (logged at trace) instead of retrying it, and later datagrams keep flowing. |
SCRATCH |
64 KiB | same | The driver’s encapsulation scratch buffer and its UDP receive buffer, once per device. |
TIMER_TICK |
250 ms | same | tokio::time::interval that drives Tunn::update_timers. |
EPHEMERAL_BASE |
49152 | same | First tunnel-side source port; ports wrap from 65535 back to 49152. |
| smoltcp poll delay | 2 ms when a repoll is pending, else smoltcp’s own delay, else 3600 s | compute_sleep in the same file |
Inline literals, not named constants. |
TCP_CONNECT_ATTEMPT_TIMEOUT |
10 s per address | protocols/src/wireguard/slot.rs |
connect_tcp_any bounds each in-tunnel TCP connect. |
REBUILD_BACKOFF_BASE, REBUILD_BACKOFF_MAX |
1 s, 30 s | same | DeviceSlot::note_failure, with the same arithmetic as the Hysteria slot. While the slot is backing off, acquire_device fails with wireguard: tunnel is down, waiting before the next attempt and builds no tunnel. |
MIN_HEALTHY_LIFETIME |
10 s | same | A device that dies within 10 s counts as a failed start, because WgDevice::start performs no handshake and succeeds even against an unreachable peer. |
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
DEFAULT_MTU |
1500 | protocols/src/tun/config.rs |
Passed to the device and to IpStackConfig::mtu. app/src/inbound/tun.rs refuses a configured mtu below 1280: tun mtu must be at least 1280. |
DEFAULT_UDP_IDLE_TIMEOUT |
60 s | same | IpStackConfig::udp_timeout: the stack ends a quiet (source, destination) UDP flow. Config key udp_idle_timeout. |
DEFAULT_MAX_FLOWS |
65,536 | same | See Admission. |
FLOW_QUEUE |
16 | protocols/src/tun/udp.rs |
See Admission. |
RELEASE_TIMEOUT, RELEASE_POLL |
3 s, 20 ms | protocols/src/tun/inbound.rs |
TunInbound::shutdown polls every 20 ms, for up to 3 s, until the device descriptor and every tracked TCP flow are released. A timeout logs a warn. |
Two timers apply to TUN UDP. The stack’s udp_timeout ends each (source, destination) flow, and the association’s TunUdpCore runs Timing, so a whole association that is quiet for RELAY_IDLE_TIMEOUT finishes.
A TCP flow in the TUN inbound dials its outbound only after the client’s first bytes. serve_stream therefore wraps each runtime step in HANDSHAKE_TIMEOUT until the PassthroughCore is established, and a server-speaks-first protocol ends with tun: the client never spoke after 10 s.
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
CACHE_CAPACITY |
8192 names | protocols/src/dns/mod.rs |
moka::future::Cache::max_capacity. The key is a name a client chooses, so the cache must be bounded. |
SYSTEM_TTL |
60 s | same | TTL for Backend::System answers, because getaddrinfo reports none. |
MIN_TTL, MAX_TTL |
5 s, 3600 s | same | resolve clamps every TTL into this range before it caches the answer. MAX_TTL is also moka’s cache-wide time_to_live. with_ttl_bounds overrides the range; the app does not call it. |
QUERY_TIMEOUT |
5 s | same | Wraps each query of the Udp, Tls and Https backends: dns: query timed out. The A and AAAA queries run concurrently, each with its own timeout. Backend::System goes through tokio::net::lookup_host and is not wrapped. |
UDP_BUF |
512 bytes | same | Receive buffer of the plain UDP backend. No EDNS is requested. |
MAX_POINTER_HOPS |
16 | protocols/src/dns/message.rs |
skip_name steps over at most 16 labels of a name before it gives up (Truncated("dns name pointer chain")). It never follows a compression pointer (a pointer ends the name in place), so a pointer cycle cannot loop. |
MAX_NAME_LEN |
255 | same | Longest name encode_query accepts: dns: name too long (<n> bytes). |
Balancer
Section titled “Balancer”| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
DEFAULT_PROBE_INTERVAL |
30 s | app/src/balancer.rs |
spawn_probe runs one task per member. It probes at once, then sleeps this long after each probe, so the period is the probe’s duration plus the interval. Config key probe_interval (seconds). |
DEFAULT_PROBE_TIMEOUT |
5 s | same | A probe resolves the member’s upstream and tries a TCP connect to each address in turn, all inside one tokio::time::timeout. Config key probe_timeout (seconds). |
Members start healthy. A change of state is logged at info as balancer member <tag> is now up or down. When every member is down, Balancer::select falls back to the first member instead of failing the flow. Both keys accept 0: probe_timeout = 0 leaves a probe no time to connect, so members are marked down and traffic falls back to the first member, and probe_interval = 0 probes back to back.
Protocol frame caps and drivers
Section titled “Protocol frame caps and drivers”These bound what a parser accepts, what an encoder produces, or what a protocol driver buffers. A parser cap fails the frame where it is parsed. A frame with no parser cap is bounded only by the buffer it has to fit in, and ends the connection with FrameTooLarge when it does not.
| Constant | Value | Defined in | Meaning |
|---|---|---|---|
MAX_HEAD |
64 KiB | protocols/src/http/protocol.rs |
Longest HTTP request head, and HttpCore::BUF_SIZE. HttpCore fails with http head exceeds maximum size once the unparsed bytes reach it without a complete head. HttpConnect::reply checks the same bound on the proxy’s response head (http: proxy response head exceeds maximum size), but in the app its client runtime of HTTP_BUF (16 KiB) saturates first. |
MAX_HEADERS |
128 | same | httparse header slots per head. |
HttpConnect::REQUEST_MAX |
1024 | protocols/src/http/codec.rs |
Upper bound on the CONNECT request the HTTP client stages, and its STAGING_RESERVE. |
AddressCodec::MAX_LEN |
259 | protocols/src/helpers/address.rs |
1 + 1 + 255 + 2: type, length, domain, port. Part of every reserve that includes an address. |
RELAY_BUF |
16 KiB | protocols/src/socks/server.rs |
Buffer per direction of a SOCKS CONNECT relay (BidirectionalConnection<A, B, RELAY_BUF>). Every RELAY_IDLE_TIMEOUT, relay_with_idle_guard compares the relayed byte counts with the previous check, and ends the relay with the error socks: relay idle when nothing moved. An idle relay therefore ends between 300 s and 600 s after its last byte. |
HUB_BUF |
64 KiB | same | Each of the two packet buffers of a SOCKS UDP ASSOCIATE relay. The association resets its RELAY_IDLE_TIMEOUT timer on every packet it forwards in either direction and ends when the timer fires. ExpectedSender::admits checks each datagram’s sender before the packet is parsed, and a datagram from an IP other than the client’s, or from another port once the client’s port is pinned, is discarded without resetting the timer, so traffic from another address cannot hold an association open. See The association’s client. |
RECV_BUF |
64 KiB | protocols/src/socks/udp_link.rs |
Receive buffer of SocksUdpLink, the SOCKS client’s UDP association. Every datagram is read into it, and one whose sender is not the relay, compared with endpoint (protocols/src/socks/protocol.rs), is discarded before it is parsed. |
MAX_LENGTH |
8192 | protocols/src/trojan/protocol.rs |
Largest Trojan UDP packet payload, and TrojanCore::MAX_DATAGRAM. |
MAX_PAYLOAD (VMess) |
1966 | protocols/src/vmess/framing.rs |
2048 - TAG_SIZE - 2 - 64: the most plaintext VMessCore and the VMess codecs put in one body chunk they seal, so a padded chunk never exceeds 2 KiB. |
MAX_PADDING, CHUNK_OVERHEAD_MAX |
64, 82 | same | Longest global-padding run on a chunk, and the most one sealed chunk adds beyond its plaintext (2 + TAG_SIZE + MAX_PADDING). |
AUTHID_WINDOW_SECS |
120 s | protocols/src/vmess/accounts.rs |
An auth id is accepted while its timestamp is within 120 s of the server clock, in either direction. |
REPLAY_TTL |
240 s | same | 2 * AUTHID_WINDOW_SECS: how long a seen auth id is remembered, across REPLAY_SHARDS (64) shards. |
MAX_PAYLOAD (Shadowsocks) |
0x3FFF |
protocols/src/ss_legacy/aead.rs |
Largest AEAD chunk payload this side seals. CHUNK_OVERHEAD is 34 (2 + 16 + 16). The chunk decoder does not check a received length against it; a received chunk only has to fit in the 20 KiB buffer (ShadowsocksCore::BUF_SIZE, SS_BUF), or the connection ends with FrameTooLarge. |
MAX_PADDING_LENGTH |
900 | protocols/src/ss_2022/protocol.rs |
The Shadowsocks 2022 client adds 1 to 900 random padding bytes to a request whose initial payload is shorter than 900 bytes. |
MAX_PACKET_SIZE |
0xFFFF |
protocols/src/ss_2022/crypto.rs |
Largest Shadowsocks 2022 record payload. The server core and the client codec split what they seal into records of at most this size. A received record is opened in place, so it has to fit in the 32 KiB buffer; see Server cores. |
| Constant | Value | Defined in | Enforced by |
|---|---|---|---|
| reload debounce | 200 ms | app/src/main.rs (inline literal in spawn_watcher) |
The config watcher drains queued file events, sleeps 200 ms and drains again before it calls reload() once. |
HTTP_BUF, SOCKS_BUF, TROJAN_BUF, VLESS_BUF |
16 KiB | app/src/outbound/mod.rs |
Client runtime buffer sizes; see Client codecs. |
VMESS_BUF, SS2022_BUF |
32 KiB | same | same |
SS_BUF |
20 KiB | same | same |
Configurable limits
Section titled “Configurable limits”Only these limits are read from the configuration file. Everything else on this page is compiled in.
| Config key | Struct in app/src/config.rs |
Default when absent | Validation in the app |
|---|---|---|---|
max_connections |
Hysteria2InboundSettings |
DEFAULT_MAX_CONNECTIONS (4096) |
0 is refused: max_connections must be at least 1 |
max_circuits |
Hysteria2InboundSettings |
DEFAULT_MAX_CIRCUITS (65,536) |
0 is refused: max_circuits must be at least 1 |
udp_idle_timeout |
Hysteria2InboundSettings |
60 s | 2 to 600 s: udp_idle_timeout must be between 2 and 600 seconds. Refused when udp is off: udp_idle_timeout is set but udp is not enabled |
max_concurrent_streams |
Hysteria2OutboundSettings |
DEFAULT_MAX_CONCURRENT_STREAMS (102,400) |
0 is refused: max_concurrent_streams must be at least 1. No upper bound. |
mtu |
TunInboundSettings |
DEFAULT_MTU (1500) |
At least 1280 |
udp_idle_timeout |
TunInboundSettings |
DEFAULT_UDP_IDLE_TIMEOUT (60 s) |
None |
max_flows |
TunInboundSettings |
DEFAULT_MAX_FLOWS (65,536) |
None. 0 is accepted and admits no flow. |
obfs_password |
Hysteria2InboundSettings, Hysteria2OutboundSettings |
None | At least MIN_PSK_LEN (4) bytes when obfs = "salamander": obfs_password must be at least 4 bytes for salamander |
mtu |
WireguardOutboundSettings |
DEFAULT_MTU (1420) |
None |
keepalive |
WireguardOutboundSettings |
None (no persistent keepalive) | A u16 number of seconds, passed to boringtun’s Tunn::new as the persistent keepalive interval |
ed=N in path |
WsStreamConfig, on an outbound |
No early data | Parsed from the path’s query by WsTarget::new; the limit is clamped to MAX_EARLY_DATA (16 KiB), and 0 disables early data |
probe_interval, probe_timeout |
BalancerConfig |
DEFAULT_PROBE_INTERVAL (30 s), DEFAULT_PROBE_TIMEOUT (5 s) |
None; 0 is accepted for both (see Balancer) |
The app prefixes each message with its context. For an inbound tagged h, etemenanki-app --test -c on a config with max_connections = 0 logs an error line ending in configuration invalid: inbound h: max_connections must be at least 1 and exits with a failure status.
Per-connection memory budget
Section titled “Per-connection memory budget”The runtimes allocate their byte buffers once, at construction, and never grow them. The fixed userspace cost of a connection is therefore a sum of constants:
- A server runtime (
ProxyServerRuntime) allocates three boxed arrays ofBUF_SIZE:up(transport reads),staging(bytes toward the transport) andscratch(outbound reads). - Every stream core also owns a
SniffPrefix, whoseCollector::newcallsVec::with_capacity(SNIFF_LIMIT). That is 4 KiB per connection, allocated when the core is built, whether or not sniffing is on.SniffPrefix::clearreplaces the collector with a freshCollector::new(), so the 4 KiB stays allocated for the life of the connection.TunUdpCoreandHy2UdpCorehave noSniffPrefix. - A client runtime (
ProxyClientRuntime) allocates two boxed arrays of itsBUF_SIZE:staging(sealed bytes toward the upstream) anddown(wire bytes from it). It also holds the boxed dial future until the dial completes.
A connection routed to a proxy outbound pays for both runtimes. A connection routed to freedom pays only for the server side, because the outbound is a kernel socket.
Server cores
Section titled “Server cores”| Core | Runs | BUF_SIZE |
Sized for | STAGING_RESERVE |
MAX_DATAGRAM |
datagram_limit() |
3 × BUF_SIZE |
With SniffPrefix |
|---|---|---|---|---|---|---|---|---|
HttpCore |
one HTTP inbound connection | 64 KiB (MAX_HEAD) |
a whole request head | 256 | 4096 (default) | stream only | 192 KiB | 196 KiB |
TrojanCore |
one Trojan inbound connection | 16 KiB | a UDP frame: header plus 8 KiB | 808 | 8192 (MAX_LENGTH) |
8192 | 48 KiB | 52 KiB |
VlessCore |
one VLESS inbound connection | 16 KiB | a UDP frame of MAX_DATAGRAM behind its length |
808 | 8192 | 8192 | 48 KiB | 52 KiB |
VMessCore |
one VMess inbound connection | 32 KiB | not stated in the source | 4096 | 8192 | 8192 | 96 KiB | 100 KiB |
ShadowsocksCore |
one Shadowsocks inbound connection | 20 KiB | one MAX_PAYLOAD chunk plus overhead |
128 | 4096 (default) | stream only | 60 KiB | 64 KiB |
Ss2022Core |
one Shadowsocks 2022 inbound connection | 32 KiB | a padded request chunk, or a record | 256 | 4096 (default) | stream only | 96 KiB | 100 KiB |
Hy2StreamCore |
one Hysteria 2 proxy stream | 8 KiB | a request with the longest address and padding | 2048 | 4096 (default) | stream only | 24 KiB | 28 KiB |
Hy2UdpCore |
the datagrams of one Hysteria 2 connection | 16 KiB | a reassembled packet plus fragment headers | 4096 | 4096 (MAX_UDP_SIZE) |
4096 | 48 KiB | 48 KiB (no prefix) |
PassthroughCore |
one TUN TCP flow | 8 KiB | “every buffer size this core needs” | 0 | 4096 (default) | stream only | 24 KiB | 28 KiB |
TunUdpCore |
one TUN UDP association (one client source) | 8 KiB | a datagram plus the reserve | 4096 | 4096 | 4096 | 24 KiB | 24 KiB (no prefix) |
How the non-literal reserves add up:
- Trojan:
PACKET_HEADER_MAX.next_multiple_of(16) + downlink_overhead(Self::BUF_SIZE).PACKET_HEADER_MAXis259 + 2 + 2 = 263, rounded up to 272, anddownlink_overhead(16 * 1024)is2 * 268 = 536, so the total is 808. - VLESS:
272 + downlink_overhead(Self::BUF_SIZE), also 808. - Shadowsocks:
32 + 2 * CHUNK_OVERHEAD + 28, withCHUNK_OVERHEAD = 34, is 128. - Shadowsocks 2022:
32 + 1 + 8 + 32 + 2 + 2 * TAG_SIZE + RECORD_OVERHEAD + 115, withTAG_SIZE = 16andRECORD_OVERHEAD = 34, is 256.
A core that opens frames in place needs each frame to fit in BUF_SIZE, and a larger frame ends the connection with FrameTooLarge. For Ss2022Core that means a record payload of no more than 32,734 bytes (32 KiB less the 34-byte RECORD_OVERHEAD) once the session is running, although the protocol allows up to 0xFFFF. For ShadowsocksCore it means a chunk payload of no more than 20,446 bytes (20 KiB less the 34-byte CHUNK_OVERHEAD).
The Hysteria 2 listener costs 28 KiB per proxy stream, plus 48 KiB per connection when udp is enabled; the datagram runtime starts only after the connection authenticates.
SOCKS inbound
Section titled “SOCKS inbound”The SOCKS inbound is the one stream protocol that does not run a core, so it has its own budget:
| SOCKS path | Buffers | Total |
|---|---|---|
CONNECT |
BidirectionalConnection<A, B, RELAY_BUF>: one 16 KiB buffer per direction, allocated when the relay starts. With sniffing on and an IP target, collect_prefix first holds a 4 KiB read chunk and a Collector with 4 KiB of capacity (8 KiB before the relay exists). The collected prefix stays in scope in connect for the rest of the relay. |
32 KiB, or 36 KiB after a sniff |
UDP ASSOCIATE |
up and down, each HUB_BUF (64 KiB), a 2 KiB packet BytesMut and a 256-byte sink array. associate allocates them only after ExpectedSender::new has accepted the request and the relay socket is bound, so an association refused with reply 0x02 costs none of them. |
about 130 KiB |
Client codecs
Section titled “Client codecs”| Outbound | Codec | Buffer constant | STAGING_RESERVE |
2 × BUF |
|---|---|---|---|---|
| HTTP | HttpConnect |
HTTP_BUF = 16 KiB |
1024 (REQUEST_MAX) |
32 KiB |
SOCKS (CONNECT) |
SocksConnect |
SOCKS_BUF = 16 KiB |
528 (a 513-byte credential message, or a request with a full address) | 32 KiB |
| Trojan | TrojanStream, TrojanDatagram |
TROJAN_BUF = 16 KiB |
320 (REQUEST_HEADER_MAX = 56 + 2 + 1 + 259 + 2) |
32 KiB |
| VLESS | VlessStream, VlessDatagram |
VLESS_BUF = 16 KiB |
278 (REQUEST_HEADER_MAX = 1 + 16 + 1 + 1 + 259) |
32 KiB |
| VMess | VMessStream, VMessDatagram |
VMESS_BUF = 32 KiB |
384 (HEADER_MAX = 358, rounded up to a multiple of 64) |
64 KiB |
| Shadowsocks | SsStream |
SS_BUF = 20 KiB |
386 (32 + CHUNK_OVERHEAD + AddressCodec::MAX_LEN + 61) |
40 KiB |
| Shadowsocks 2022 | Ss2022Stream |
SS2022_BUF = 32 KiB |
2048 | 64 KiB |
Outbounds that do not use a client runtime:
| Outbound | Per-flow userspace buffers |
|---|---|
freedom |
None; the outbound is a kernel socket. |
blackhole |
None. |
SOCKS UDP ASSOCIATE |
SocksUdpLink: a 64 KiB RECV_BUF array and a 2 KiB scratch BytesMut, about 66 KiB. |
| WireGuard, TCP flow | Two smoltcp ring buffers of TCP_BUFFER: 128 KiB. Channel items come on top of the two rings, and they are bounded: up to CHANNEL_CAP (256) plus one held item on the way up, and up to 256 on the way down, each as large as the write or socket read that produced it. The device adds 2 × SCRATCH (128 KiB) once, shared by all its flows. |
| WireGuard, UDP flow | Two smoltcp packet buffers with TCP_BUFFER of payload each: 128 KiB. Channel items come on top, bounded the same way: up to CHANNEL_CAP (256) datagrams plus one held on the way up, and up to 256 on the way down. |
| Hysteria 2 | No fixed buffer. QUIC flow control bounds data in flight at STREAM_RECEIVE_WINDOW per stream and CONNECTION_RECEIVE_WINDOW per connection. |
Worked totals
Section titled “Worked totals”| Inbound → outbound | Server side | Client side | Approximate total |
|---|---|---|---|
Trojan → freedom |
52 KiB | 0 | 52 KiB |
| VLESS → VLESS | 52 KiB | 32 KiB | 84 KiB |
| VMess → VMess | 100 KiB | 64 KiB | 164 KiB |
HTTP → freedom |
196 KiB | 0 | 196 KiB |
| Shadowsocks 2022 → WireGuard (TCP) | 100 KiB | 128 KiB | 228 KiB |
SOCKS CONNECT → Trojan |
32 KiB | 32 KiB | 64 KiB |
TUN TCP → freedom |
28 KiB | 0 | 28 KiB |
A mux.cool carrier pays for its server runtime once. Each sub-flow routed to a proxy outbound adds its own client runtime. A Trojan carrier with the maximum 256 sub-flows through a VLESS outbound therefore holds 52 KiB + 256 × 32 KiB = 8,244 KiB, about 8 MiB.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| A runtime can always stage its reserve | assert!(BUF_SIZE > Core::STAGING_RESERVE) in ProxyServerRuntime::build, assert!(BUF_SIZE > Codec::STAGING_RESERVE) in ProxyClientRuntime::new |
Every runtime built in the tests, at each core’s own BUF_SIZE; there is no dedicated should_panic test |
| A frame larger than the read buffer is an error, not a stall | poll_transport_read returns FrameTooLarge when up.is_saturated() |
frame_larger_than_the_buffer_is_an_error in concepts/tests/runtime.rs |
| Staging backpressure never truncates a datagram from an outbound | A datagram outbound is polled only with STAGING_RESERVE + datagram_limit() of room |
datagram_outbound_is_never_truncated_by_staging_backpressure in concepts/tests/runtime.rs |
| A stalled outbound stalls its own uplink, not other downlinks | Effects apply in order, and a blocked forward pins the transport read | stalled_outbound_holds_uplink_but_not_other_downlink in concepts/tests/runtime.rs |
| The client runtime never buffers beyond its two arrays | poll_write waits for STAGING_RESERVE + 1 bytes of room |
backpressure_from_the_wire_reaches_the_writer in concepts/tests/client.rs |
| The handshake deadline is armed once, the idle deadline on every byte event | Timing::touch |
timing_arms_handshake_once_then_idle_per_byte_event in protocols/tests/unit/core/mod.rs |
| A deadline fires against the tokio clock | The runtime’s timer is a tokio Sleep, so a paused test clock advances it |
deadline_event_lets_the_core_time_out, deadline_is_armed_against_the_tokio_clock in concepts/tests/runtime.rs |
Sniffing never takes more than SNIFF_LIMIT |
SniffPrefix::push takes at most Collector::remaining() |
sniff_prefix_takes_no_more_than_its_budget in protocols/tests/unit/core/mod.rs |
| A mux carrier never holds more than 256 sub-flows | Demux declines New with End |
unknown_or_excess_sessions_are_declined_with_end in protocols/tests/unit/mux/demux.rs |
| gRPC and WebSocket never buffer a message beyond 1 MiB | The MAX_GRPC_MESSAGE_LEN check runs before buffering; ws_config() |
decoder_rejects_an_oversized_length_before_buffering_the_body in grpc_framing.rs; the_configured_limits_replace_tungstenite_defaults in ws_endpoint.rs |
| A dial tries every address before failing | TcpDialer::connect_any |
connect_any_falls_through_a_dead_address in environment/tests/integration/tcp.rs |
| The DNS cache stays bounded under distinct names | moka max_capacity(CACHE_CAPACITY) |
the_cache_stays_bounded_under_distinct_names in protocols/tests/unit/dns/mod.rs |
| Reconnect backoff grows and is capped | note_failure shifts by at most 5, then takes min(MAX) |
backoff_grows_with_each_failure_and_is_capped (Hysteria), repeated_failures_escalate_the_backoff (WireGuard) |
| A SOCKS UDP association’s buffers and idle timer serve only its client | ExpectedSender::admits runs before parse_udp_packet and before the idle timer is reset: every datagram must come from the control connection’s IP, and the first one forwarded pins the port unless the request already named it with that IP. Over a Unix socket, the request must name the exact IP and port |
udp_association_ignores_another_ip (Linux only) and udp_association_ignores_another_port_once_pinned in protocols/tests/pipeline/socks.rs; only_the_control_peer_is_heard_and_its_first_datagram_pins_the_port in protocols/tests/unit/socks/server.rs |
| Hysteria UDP sessions count against the circuit budget | Hy2UdpCore takes a circuit_permits permit per session |
the_circuit_limit_refuses_new_sessions in protocols/tests/unit/hysteria/server/datagrams.rs |
| The WireGuard driver holds at most one uplink item per connection; a stalled tunnelled flow blocks its writer, not its siblings | Uplink holds at most one item that the socket has not accepted, and poll_refill reads the channel only while nothing is held, so a full socket fills the bounded channel and the application’s sender waits |
a_stalled_tcp_flow_blocks_its_writer in protocols/tests/pipeline/wireguard.rs (a writer of 1 KiB chunks must wait before 512 KiB are in, against an expected 385 KiB, while another flow on the same tunnel still echoes); a_held_item_keeps_the_channel_unread in protocols/tests/unit/wireguard/device.rs |
Failure paths and cancellation
Section titled “Failure paths and cancellation”When a limit trips, the result is one of four kinds. Knowing the kind tells you where to look in the logs.
| Kind | Limits | What the peer sees | Log |
|---|---|---|---|
| Silent drop at admission | MAX_LIVE_CONNECTIONS_PER_INBOUND, MAX_HANDSHAKES_PER_INBOUND, TUN max_flows and FLOW_QUEUE, Hysteria UDP MAX_SESSIONS, the circuit limit for Hysteria datagrams |
The socket closes, or the packet is lost | debug or trace |
| Explicit refusal | Hysteria max_connections (incoming.refuse()), the circuit limit on a Hysteria stream (H3_REQUEST_REJECTED), MAX_CLASSIFYING_STREAMS (H3_EXCESSIVE_LOAD), mux MAX_SESSIONS (an End frame), WebSocket early data (413) |
A protocol-level refusal; the carrier connection survives where there is one | debug; the mux decline logs nothing |
| Connection error | FrameTooLarge, HANDSHAKE_TIMEOUT, TRANSPORT_HANDSHAKE_TIMEOUT, MAX_GRPC_MESSAGE_LEN, MAX_WS_MESSAGE_LEN, the parser frame caps, the SOCKS relay idle check |
The connection closes | debug. For a stream inbound, a transport failure is inbound transport failed: <error> from serve_socket, and a runtime or driver error is <protocol> connection from <source> ended: <error> from serve_connection, with the source printed as an Option |
| Clean end | RELAY_IDLE_TIMEOUT in a core, WS_IDLE_TIMEOUT, H2_IDLE_TIMEOUT, the PING timeout, QUIC MAX_IDLE_TIMEOUT, the Hysteria UDP sweep, the SOCKS UDP ASSOCIATE idle timer |
The stream ends as if the peer had gone | Usually none |
Cancellation does not depend on any of these timers. Stream inbounds spawn every per-connection task with spawn_scoped under the generation’s CancellationToken. The Hysteria 2 and TUN inbounds own their per-connection work in a JoinSet inside their run future. Either way, ending a generation drops each runtime, and with it its buffers and permits. The Hysteria 2 and TUN inbounds then wait up to DRAIN_TIMEOUT and RELEASE_TIMEOUT for their socket or device to come free. See Generations and reload.
| Test | File | Pins |
|---|---|---|
frame_larger_than_the_buffer_is_an_error |
concepts/tests/runtime.rs |
FrameTooLarge |
datagram_outbound_is_never_truncated_by_staging_backpressure |
concepts/tests/runtime.rs |
datagram_limit() and the datagram read condition |
stalled_outbound_holds_uplink_but_not_other_downlink |
concepts/tests/runtime.rs |
Ordered effects as backpressure |
deadline_event_lets_the_core_time_out, deadline_is_armed_against_the_tokio_clock |
concepts/tests/runtime.rs |
The single core deadline |
backpressure_from_the_wire_reaches_the_writer |
concepts/tests/client.rs |
Client runtime staging bound |
connect_any_falls_through_a_dead_address |
environment/tests/integration/tcp.rs |
Sequential per-address dialing |
timing_arms_handshake_once_then_idle_per_byte_event, timing_reports_handshake_and_sniff_expiry_to_the_core |
protocols/tests/unit/core/mod.rs |
Timing |
sniff_prefix_takes_no_more_than_its_budget, a_sniffing_passthrough_core_opens_on_the_sniff_deadline, passthrough_core_finishes_when_the_outbound_fails_or_idles |
protocols/tests/unit/core/mod.rs |
SNIFF_LIMIT, SNIFF_TIMEOUT, RELAY_IDLE_TIMEOUT |
handshake_deadline_fails_the_connection, sniff_deadline_opens_with_what_was_collected |
protocols/tests/unit/trojan/core.rs |
Handshake and sniff deadlines in a real core |
the_budget_ends_the_search_without_a_match |
protocols/tests/unit/sniff/collector.rs |
SNIFF_LIMIT |
a_connection_that_opens_no_stream_is_given_up_on |
protocols/tests/unit/transports/accept.rs |
H2_IDLE_TIMEOUT, end to end |
liveness_gives_up_on_a_connection_with_no_streams, liveness_keeps_a_connection_carrying_streams, liveness_restarts_the_idle_deadline_on_progress |
protocols/tests/unit/transports/grpc_liveness.rs |
Liveness |
decoder_accepts_a_message_at_the_maximum, decoder_rejects_a_message_longer_than_the_maximum, decoder_rejects_an_oversized_length_before_buffering_the_body |
protocols/tests/unit/transports/grpc_framing.rs |
MAX_GRPC_MESSAGE_LEN |
rejects_oversized_early_data_header, callback_rejects_oversized_early_data, the_configured_limits_replace_tungstenite_defaults |
protocols/tests/unit/transports/ws_endpoint.rs |
MAX_EARLY_DATA, MAX_WS_MESSAGE_LEN |
unknown_or_excess_sessions_are_declined_with_end |
protocols/tests/unit/mux/demux.rs |
Mux MAX_SESSIONS |
read_meta_enforces_the_length_caps |
protocols/tests/unit/mux/frame.rs |
MAX_META_LEN, MAX_DATA_LEN |
an_oversized_head_is_refused |
protocols/tests/unit/http/core.rs |
MAX_HEAD |
udp_association_ignores_another_ip, udp_association_ignores_another_port_once_pinned, udp_association_refuses_a_relay_that_cannot_hear_the_client, udp_link_ignores_datagrams_not_from_the_relay |
protocols/tests/pipeline/socks.rs |
Which datagrams the SOCKS UDP ASSOCIATE relay (HUB_BUF) and SocksUdpLink (RECV_BUF) act on, and the 0x02 refusal of an association whose client the relay could never hear |
only_the_control_peer_is_heard_and_its_first_datagram_pins_the_port, a_relay_that_cannot_hear_the_client_is_refused |
protocols/tests/unit/socks/server.rs |
ExpectedSender and hears |
rejects_oversize_udp_packet |
protocols/tests/unit/trojan/protocol.rs |
Trojan MAX_LENGTH |
user_not_found_outside_time_window, replayed_authid_is_rejected, expired_ids_leave_the_set |
protocols/tests/unit/vmess/accounts.rs |
AUTHID_WINDOW_SECS, REPLAY_TTL |
an_idle_session_is_closed_by_the_sweep, the_circuit_limit_refuses_new_sessions |
protocols/tests/unit/hysteria/server/datagrams.rs |
SWEEP_INTERVAL, circuit permits |
reassembly_stops_at_the_maximum_datagram_size, a_payload_needing_more_than_255_fragments_is_refused, sanitise_message_caps_length_and_tolerates_bad_utf8 |
protocols/tests/unit/hysteria/protocol.rs |
MAX_UDP_SIZE, fragmentation, MAX_MESSAGE_KEPT |
backoff_grows_with_each_failure_and_is_capped, a_connection_that_never_started_counts_as_dying_young, a_backing_off_slot_refuses_without_dialling |
protocols/tests/unit/hysteria/slot.rs |
Reconnect backoff, MIN_HEALTHY_LIFETIME |
repeated_failures_escalate_the_backoff, a_short_lived_tunnel_counts_as_a_failure, the_backoff_gate_refuses_without_building_another_tunnel |
protocols/tests/unit/wireguard/slot.rs |
Rebuild backoff, MIN_HEALTHY_LIFETIME |
a_held_item_keeps_the_channel_unread, the_uplink_finishes_only_after_its_last_item |
protocols/tests/unit/wireguard/device.rs |
Uplink: one held item, the channel left unread behind it, and the end of the uplink seen only after its last item is handed on |
a_stalled_tcp_flow_blocks_its_writer, an_oversized_datagram_does_not_wedge_the_association |
protocols/tests/pipeline/wireguard.rs |
The CHANNEL_CAP bound on a stalled TCP flow; dropping a datagram larger than the TCP_BUFFER send ring |
the_association_finishes_when_the_outbound_fails_or_idles |
protocols/tests/unit/tun/udp.rs |
TUN UDP association idle |
the_cache_stays_bounded_under_distinct_names, an_expired_entry_is_looked_up_again |
protocols/tests/unit/dns/mod.rs |
CACHE_CAPACITY, TTL expiry |
an_out_of_range_udp_idle_timeout_is_refused, a_udp_idle_timeout_without_udp_is_refused, a_zero_limit_is_refused, tun_refuses_an_mtu_below_the_stack_floor |
app/tests/unit/inbound.rs |
Config validation of limits |
hysteria2_refuses_only_a_zero_stream_limit, the_default_stream_limit_is_configurable_as_well_as_defaulted |
app/tests/unit/outbound.rs |
max_concurrent_streams |
accept_error_backoff_classification |
app/tests/unit/serve.rs |
Which accept errors back off |
every_member_down_still_selects_rather_than_dropping |
app/tests/unit/balancer.rs |
Balancer fallback |
traffic_moves_off_a_member_that_stops_answering |
app/tests/integration/e2e_balancer.rs |
Probe interval and timeout, end to end |
one_association_routes_each_peer_separately, replies_from_several_peers_merge_back_correctly |
app/tests/integration/e2e_udp_route.rs |
The UDP fan-out |
At this revision, these limits have no test that names them: WORK_BUDGET, MAX_SUBS eviction, MAX_LIVE_CONNECTIONS_PER_INBOUND, MAX_HANDSHAKES_PER_INBOUND, TRANSPORT_HANDSHAKE_TIMEOUT, the TCP keepalive schedule, WS_KEEPALIVE_INTERVAL and WS_IDLE_TIMEOUT, downlink_overhead, and the client-side Hysteria limits (OPEN_STREAM_TIMEOUT, MAX_UDP_SESSIONS, UDP_SESSION_BACKLOG).
Changing a limit
Section titled “Changing a limit”-
Find every value derived from it. Search for the constant’s name. Reserves are often computed from other constants (
downlink_overhead(Self::BUF_SIZE),REQUEST_HEADER_MAX,AddressCodec::MAX_LEN), so raising a core’sBUF_SIZEcan raise its own reserve. -
Keep the reserve below the buffer.
BUF_SIZEmust stay larger thanSTAGING_RESERVE, or the runtime panics when it is built. For a datagram core, also check thatBUF_SIZE - STAGING_RESERVEis at leastMAX_DATAGRAM. Otherwisedatagram_limit()quietly shrinks and packets are truncated. -
Size a core’s
BUF_SIZEto its largest whole frame. A frame the core must see whole (an HTTP head, a Shadowsocks chunk or record, a UDP frame behind its length) has to fit inBUF_SIZE, or the connection ends withFrameTooLarge. Size a client buffer to the largest wire frame its codec opens, plus the codec’s reserve. -
Update the memory budget. A server buffer costs three times the change, a client buffer twice. Update the tables on this page and, for a default an operator can see, the user guide.
-
Pin it with a test. Refer to the constant by name in the test instead of repeating the literal, as
the_cache_stays_bounded_under_distinct_namesdoes withCACHE_CAPACITY. Then run the gates in Testing.