Hysteria 2: protocol and client
Source files: 23 · checked against Etemenanki 596916d
Etemenanki/protocols/Cargo.tomlEtemenanki/protocols/src/hysteria/mod.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/hysteria/protocol.rsEtemenanki/protocols/src/hysteria/quic.rsEtemenanki/protocols/src/hysteria/auth.rsEtemenanki/protocols/src/hysteria/obfs.rsEtemenanki/protocols/src/hysteria/connection.rsEtemenanki/protocols/src/hysteria/slot.rsEtemenanki/protocols/src/hysteria/connector.rsEtemenanki/protocols/src/hysteria/server/datagrams.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/config.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/protocols/tests/unit/hysteria/protocol.rsEtemenanki/protocols/tests/unit/hysteria/auth.rsEtemenanki/protocols/tests/unit/hysteria/obfs.rsEtemenanki/protocols/tests/unit/hysteria/slot.rsEtemenanki/protocols/tests/pipeline/hysteria.rsEtemenanki/app/tests/integration/e2e_hysteria.rsEtemenanki/app/tests/support/mod.rs
Hysteria 2 is a proxy protocol that runs over QUIC. It is built so that it looks like an HTTP/3 web server to anyone who does not know the password. This page covers the parts of protocols/src/hysteria/ that both sides share: the wire primitives, the datagram fragmentation and the Salamander obfuscator. It then covers the client half, which dials a Hysteria server and carries flows routed to a hysteria2 outbound. The server half, Hy2Inbound, has its own page.
Hysteria does not fit the usual outbound shape, where each flow dials its own upstream connection. The client authenticates once per QUIC connection. After that, every proxied TCP connection is a bidirectional QUIC stream on that same connection, and UDP rides the connection’s datagram channel. So the client keeps one long-lived connection per outbound and rebuilds it when it dies, the same way the WireGuard client keeps its tunnel. Read this page before you change the codec, the connection slot or the connector. The upstream specification is the Hysteria 2 protocol document. This implementation is a port of the reference Go tree.
Responsibilities
Section titled “Responsibilities”| Module | Owns | Used by |
|---|---|---|
protocol.rs |
QUIC varints, padding, TCPRequest/TCPResponse, UDPMessage, Defragger |
client and server |
quic.rs |
send_udp_message (send whole, fragment on TooLarge) and datagram_error |
client; the server uses only datagram_error |
obfs.rs |
Salamander keystream, SalamanderSocket wrapper under quinn |
client and server |
auth.rs |
The single HTTP/3 /auth request and the reading of its response |
client |
config.rs |
Hy2Config, Obfs, DEFAULT_MAX_CONCURRENT_STREAMS |
client (server reuses Obfs) |
connection.rs |
Hy2Conn: socket, endpoint, QUIC and TLS setup, HTTP/3 setup, auth, proxy streams, UDP sessions |
client |
slot.rs |
ConnSlot: lazy single-flight connect, liveness check, reconnect backoff |
client |
connector.rs |
Hy2Connector, Hy2Stream, Hy2DatagramLink |
the app’s outbound table |
The whole module sits behind the hysteria Cargo feature of etemenanki-protocols. The feature pulls in a second TLS stack (quinn, h3, h3-quinn, rustls, rustls-native-certs, rustls-pemfile, rustls-pki-types) plus blake2. It is off by default, so a downstream crate gets that stack only when it asks for it. Both etemenanki-app and katana enable it. Every other TLS path in the workspace uses OpenSSL. rustls is used here only because quinn ships no other crypto backend.
Why this is not a dial-per-flow client
Section titled “Why this is not a dial-per-flow client”The module documentation of connection.rs explains this under the heading “Why this is not an OutboundTransport”. Every other client here dials a fresh upstream for each circuit, so it can be written as a codec over an injected dialer. In today’s code that shape is concepts/src/client.rs → ProxyClientConnector. Hysteria cannot work that way:
- The credential goes out once, in an HTTP/3 request, for the whole QUIC connection.
- A proxied TCP connection is a stream opened on that authenticated connection. It is not a byte stream that some dialer could hand over.
- UDP associations share the same connection’s datagram channel, and each one is told apart by a session ID.
So Hy2Conn owns its UDP socket, its quinn Endpoint and the h3 handle. Hy2Connector implements Connector<Flow<T>> directly, and every flow draws from the one shared Hy2Conn. On the server side, the same reasoning explains why Hy2Inbound owns its socket and does not sit under a server core.
Wire format
Section titled “Wire format”Everything in this section lives in protocols/src/hysteria/protocol.rs. Every length on the wire comes from the peer, so the code checks each one against its range before it uses it to allocate memory or to advance through the buffer.
QUIC variable-length integers
Section titled “QUIC variable-length integers”Every length and the frame type use the QUIC varint from RFC 9000 §16: two prefix bits give the width, and the rest of the value follows in big-endian order. This is not the protobuf base-128 varint that the gRPC transport uses, and the two cannot be swapped.
| Prefix bits | Bytes | Value bits | Largest value (Rust name) |
|---|---|---|---|
00 |
1 | 6 | 63 (MAX_VARINT_1) |
01 |
2 | 14 | 16,383 (MAX_VARINT_2) |
10 |
4 | 30 | 1,073,741,823 (MAX_VARINT_4) |
11 |
8 | 62 | 4,611,686,018,427,387,903 (MAX_VARINT_8) |
pub fn varint_len(value: u64) -> Result<usize, ProtocolError>pub fn put_varint(buf: &mut BytesMut, value: u64) -> Result<(), ProtocolError>pub async fn read_varint<R>(reader: &mut R) -> Result<u64, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn read_varint_slice(buf: &[u8]) -> Result<Option<(u64, usize)>, ProtocolError>- The encoder always picks the narrowest width. A value above 62 bits returns
ProtocolError::Overflow("hysteria2 varint exceeds 62 bits"). Upstream’svarintPutpanics in that case. - The readers accept non-minimal encodings, as RFC 9000 allows:
0x40 0x25reads as 37. read_varint_slicereturnsOk(None)while the buffer holds only part of the varint. The slice parsers (parse_tcp_request_body,parse_tcp_response,parse_udp_message_at) are built on it.
Padding
Section titled “Padding”Padding blurs the size of each message. Its bytes are drawn from the 62 ASCII alphanumerics (PADDING_CHARS). Encoders take the padding as an argument so that they stay deterministic in tests, and callers pass Padding::generate(). Padding is a half-open range [min, max), the same as upstream’s Go type.
| Constant | Range (bytes) | Where it goes |
|---|---|---|
AUTH_REQUEST_PADDING |
256 to 2047 | Hysteria-Padding header of the /auth request (client) |
AUTH_RESPONSE_PADDING |
256 to 2047 | Hysteria-Padding header of the 233 response (server) |
TCP_REQUEST_PADDING |
64 to 511 | TCPRequest (client) |
TCP_RESPONSE_PADDING |
128 to 1023 | TCPResponse (server) |
On read, any padding run up to MAX_PADDING_LENGTH (4096) is accepted. discard_padding bounds the length first and then drains the bytes through tokio::io::sink(), so a long run never becomes an allocation of that size.
TCPRequest
Section titled “TCPRequest”The client writes a TCPRequest as the first bytes of every proxy stream:
| Field | Size | Meaning |
|---|---|---|
| Frame type | varint | FRAME_TYPE_TCP_REQUEST = 0x401, encoded as 0x44 0x01 |
| Address length | varint | 1 to MAX_ADDRESS_LENGTH (2048) |
| Address | variable | host:port authority in UTF-8. IPv6 hosts are bracketed ([2001:db8::1]:443) |
| Padding length | varint | 0 to 4096 on read |
| Padding | variable | Discarded |
pub fn encode_tcp_request(address: &str, padding: &str) -> Result<Bytes, ProtocolError>pub async fn read_tcp_request<R>(reader: &mut R) -> Result<String, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub async fn read_tcp_request_body<R>(reader: &mut R) -> Result<String, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn parse_tcp_request_body(buf: &[u8]) -> Result<Option<(String, usize)>, ProtocolError>The encoder refuses an empty address, an address over 2048 bytes and padding over 4096 bytes. read_tcp_request consumes the frame type and checks it. read_tcp_request_body and parse_tcp_request_body start at the address length. The server needs these because it has already read the frame type to decide that the stream is a proxy stream.
TCPResponse
Section titled “TCPResponse”The server answers every TCPRequest with a TCPResponse:
| Field | Size | Meaning |
|---|---|---|
| Status | u8 |
STATUS_OK (0x00) or STATUS_ERROR (0x01) |
| Message length | varint | 0 to MAX_MESSAGE_LENGTH (2048) |
| Message | variable | The server’s explanation, often empty |
| Padding length | varint | 0 to 4096 |
| Padding | variable | Discarded |
pub struct TcpResponse { pub ok: bool, pub message: String,}
pub fn encode_tcp_response(ok: bool, message: &str, padding: &str) -> Result<Bytes, ProtocolError>pub async fn read_tcp_response<R>(reader: &mut R) -> Result<TcpResponse, ProtocolError>where R: AsyncRead + Unpin + ?Sizedpub fn parse_tcp_response(buf: &[u8]) -> Result<Option<(TcpResponse, usize)>, ProtocolError>pub fn sanitise_message(raw: &[u8]) -> StringHere the code is deliberately stricter than upstream. Upstream reads every non-zero status byte as an error. This code returns ProtocolError::Malformed("hysteria2 response status") for anything except 0x00 and 0x01, because a peer that sends another value is not a conformant server. The message passes through sanitise_message before it goes anywhere. That function decodes invalid UTF-8 lossily, drops every control character (including newlines, so the peer cannot forge log lines) and keeps at most MAX_MESSAGE_KEPT (128) characters.
UDPMessage
Section titled “UDPMessage”A UDPMessage travels in one QUIC datagram (RFC 9221), so it is unreliable and can arrive in any order.
| Field | Size | Meaning |
|---|---|---|
| Session ID | u32, big-endian |
The association. The server maps it to an outbound UDP port |
| Packet ID | u16, big-endian |
Ties the fragments of one datagram together. 0 when unfragmented |
| Fragment ID | u8 |
Index of this fragment, from 0 |
| Fragment count | u8 |
Number of fragments. 1 means not split |
| Address length | varint | 1 to 2048 |
| Address | variable | host:port of the remote peer, UTF-8 |
| Payload | rest of the datagram | Must not be empty |
The fixed part is 8 bytes (UDP_HEADER_FIXED). The payload has no length prefix: it is whatever follows the address. So a truncation can only be detected inside the header. QUIC delivers a datagram whole or not at all, which makes this harmless.
pub struct UdpMessage { pub session_id: u32, pub packet_id: u16, pub frag_id: u8, pub frag_count: u8, pub addr: String, pub payload: Bytes,}
impl UdpMessage { pub fn header_size(&self) -> Result<usize, ProtocolError> pub fn encoded_size(&self) -> Result<usize, ProtocolError> pub fn encode(&self) -> Result<Bytes, ProtocolError> pub fn fragment(&self, max_size: usize) -> Result<Option<Vec<UdpMessage>>, ProtocolError>}
pub fn parse_udp_message_at(datagram: &[u8]) -> Result<(UdpHeader, Range<usize>), ProtocolError>pub fn parse_udp_message(datagram: &[u8]) -> Result<UdpMessage, ProtocolError>An empty payload is refused in both directions: encode returns "hysteria2 empty udp payload", and so does the parser. The wire format has no way to express an empty payload, because it would look the same as a message cut off after the address. parse_udp_message_at returns the payload as a range, so the server can forward it without copying.
Fragmentation
Section titled “Fragmentation”quic.rs → send_udp_message is the client’s send path:
pub fn send_udp_message(conn: &Connection, message: UdpMessage) -> io::Result<()>pub fn datagram_error(e: quinn::SendDatagramError) -> io::Error- It first sends the message whole. Upstream does the same, and in the common case this adds no fragmentation header.
- Only if quinn returns
SendDatagramError::TooLargedoes it readconn.max_datagram_size(): the smaller of the peer’s advertised maximum datagram frame size and what the current path MTU allows. If that isNone, the send fails withUnsupported“hysteria2: the peer did not offer QUIC datagrams”. Otherwise it gives the message a random non-zero packet ID (rand::random_range(1..=u16::MAX)), because zero is reserved for the unfragmented case. UdpMessage::fragment(limit)cuts the payload into pieces oflimit - header_size()bytes. It returnsNoneif the header alone does not fit, or if more than 255 fragments would be needed (the fragment count is au8). The caller turnsNoneintoInvalidInput: “hysteria2: datagram cannot be split small enough for this connection”.
The server does not call send_udp_message. Its datagram core (server/datagrams.rs) reads the connection’s datagram size once, when the core starts (falling back to MAX_DATAGRAM_FRAME_SIZE). It compares each reply against that size and calls UdpMessage::fragment itself when the reply is too large, also with a random non-zero packet ID. Both ends share datagram_error, which maps quinn’s send errors to io::ErrorKinds a caller can act on: UnsupportedByPeer and Disabled become Unsupported, TooLarge becomes InvalidInput, and ConnectionLost becomes BrokenPipe.
Reassembly: Defragger
Section titled “Reassembly: Defragger”#[derive(Debug, Default)]pub struct Defragger { packet_id: u16, fragments: Vec<Option<Bytes>>, received: usize, size: usize,}
impl Defragger { pub fn feed(&mut self, message: UdpMessage) -> Option<UdpMessage>}Each association has one Defragger. On the client it lives in Hy2DatagramLink, and on the server in the datagram core. feed works like this:
- If
frag_count <= 1, the message is returned unchanged. - If
frag_id >= frag_count, the message is dropped. - If the fragment belongs to a different
packet_id, or has a different count,resetthrows away whatever was in progress. Like upstream, theDefraggerreassembles one packet at a time. This costs a packet when two large datagrams interleave, but it keeps the buffer bounded without any timer, which matters because the peer chooses both the fragment count and the arrival order. - A fragment that is already stored is ignored, so a duplicate cannot complete the packet early.
- If the reassembled size would exceed
MAX_UDP_SIZE(4096 bytes, upstream’sMaxUDPSize), the buffer is cleared and the fragment dropped. After that, the association keeps working normally. - When the last missing fragment arrives,
feedreturns one message withfrag_id = 0,frag_count = 1and the payload concatenated in index order.
HTTP/3 authentication
Section titled “HTTP/3 authentication”protocols/src/hysteria/auth.rs is a port of upstream’s protocol/http.go. On the client, it holds the /auth exchange. connection.rs also uses h3, to set up the HTTP/3 connection and to hold its SendRequest handle. The server has its own h3 code under server/. h3 is a 0.0.x crate, so an API change in it touches those places.
pub async fn authenticate( send_request: &mut SendRequest<h3_quinn::OpenStreams, bytes::Bytes>, password: &str, client_rx: u64,) -> io::Result<AuthOutcome>
pub struct AuthOutcome { pub udp_enabled: bool, pub server_rx: ServerRx,}
pub enum ServerRx { Unlimited, Auto, Bps(u64),}The client sends one request with no body and then finishes the request stream. The server waits for that before it answers.
| Pseudo-header or header | Rust name | Value |
|---|---|---|
:method |
POST |
|
:authority |
AUTH_HOST |
hysteria |
:path |
AUTH_PATH |
/auth |
hysteria-auth |
HEADER_AUTH |
The password, set as a sensitive HeaderValue |
hysteria-cc-rx |
HEADER_CC_RX |
The client’s receive rate in bytes per second. Hy2Conn always sends 0 |
hysteria-padding |
HEADER_PADDING |
AUTH_REQUEST_PADDING.generate() |
The request URI is https://hysteria/auth. Sending 0 in Hysteria-CC-RX means “I do not know my receive rate, so use congestion control”. This is correct because the client has no Brutal congestion controller, and it is what upstream sends when no bandwidth is configured.
The server accepts the client only by answering with status STATUS_AUTH_OK (233). Any other status means the server is showing its masquerade web site. authenticate then returns PermissionDenied with the text hysteria2 authentication rejected with status {status}, where the status prints as, for example, 404 Not Found. It includes nothing else, so neither the credential nor the body can leak into the error. Hy2Conn::connect folds this error, like every other per-address error, into the text of its final ConnectionRefused error (see below). When the status is 233, two response headers are read:
hysteria-udp(HEADER_UDP):udp_enabledistrueonly if the value equalstrue, compared without regard to case. A missing header meansfalse.hysteria-cc-rx:parse_server_rxtrims the value and mapsauto(any case) toServerRx::Auto,0toServerRx::Unlimitedand any otheru64toServerRx::Bps. A missing or unparseable value becomesAuto, so the client never invents a rate limit the server did not ask for. The client logsserver_rxat debug level and otherwise does not act on it.
If the password contains bytes that cannot appear in a header value, build_request fails with InvalidInput and the fixed message “hysteria2 password is not a valid header value”. The message never quotes the value.
Salamander obfuscation
Section titled “Salamander obfuscation”protocols/src/hysteria/obfs.rs ports upstream’s extras/obfs/salamander.go. It wraps the UDP socket underneath quinn. quinn hands the wrapper finished QUIC packets and never learns that obfuscation happens.
| Field | Size | Meaning |
|---|---|---|
| Salt | 8 bytes (SALT_LEN) |
Fresh random bytes for every packet |
| Body | variable | The QUIC packet XORed with BLAKE2b-256(psk ‖ salt), with the 32-byte key (KEY_LEN) repeated to the packet’s length |
Salamander is obfuscation, not encryption: QUIC’s own TLS protects the payload. The fresh salt ensures that the same plaintext never looks the same twice on the wire. The pre-shared key must be at least MIN_PSK_LEN (4) bytes, or Salamander::new refuses it, as upstream’s ErrPSKTooShort does.
impl Salamander { pub fn new(psk: &[u8]) -> Result<Self, ProtocolError> pub fn obfuscate(&self, payload: &[u8], out: &mut Vec<u8>) pub fn deobfuscate_in_place( &self, buf: &mut [u8], len: usize, stride: usize, ) -> Option<(usize, usize)>}
pub struct SalamanderSocket { inner: Arc<dyn AsyncUdpSocket>, obfs: Salamander, scratch: Mutex<Vec<u8>>,}
impl SalamanderSocket { pub fn new(inner: Arc<dyn AsyncUdpSocket>, psk: &[u8]) -> Result<Self, ProtocolError>}flowchart LR
quinn["quinn endpoint"]
subgraph sal["SalamanderSocket"]
tx["try_send: new salt, XOR into scratch"]
rx["poll_recv: split by stride, strip salt, XOR, pack"]
end
inner["inner AsyncUdpSocket"]
wire(("UDP on the wire"))
quinn -->|"one QUIC packet per Transmit"| tx
tx -->|"salt plus body, segment_size None"| inner
inner --> wire
wire --> inner
inner -->|"batch of RecvMeta"| rx
rx -->|"deobfuscated packets"| quinn
Offload
Section titled “Offload”- GSO is refused. Each datagram needs its own salt, so a single salt cannot cover a batch of datagrams packed into one buffer.
max_transmit_segmentsreturns1, which tells quinn not to batch.try_sendalso rejects anyTransmitwhosesegment_sizeis smaller than its contents, withInvalidInput“hysteria2 obfs: segmented transmit is not supported”. That check guards against future changes rather than a path that runs today. - GRO is split. Receive offload cannot be turned off on the inner socket, so
max_receive_segmentsreports whatever the inner socket reports.deobfuscate_in_placewalks a coalesced buffer atstridesteps (the last datagram may be shorter). It unwraps each datagram under its own salt and packs the results toward the front of the buffer. Each datagram shrinks by 8 bytes, so the write position never overtakes the read position. It returns the new(len, stride - SALT_LEN). If any segment is 8 bytes or shorter, the whole buffer is dropped, because removing one datagram from the middle would break the fixed-stride layout. - The wrapper cannot tell a Salamander packet from any other datagram of 9 bytes or more. It strips 8 bytes and XORs whatever arrives, and quinn discards the result when it does not decrypt.
deobfuscate_batchthen discards slots that failed, copies the surviving slots down, and rewrites theirRecvMeta. If a whole batch was junk,poll_recvloops and polls the inner socket again rather than returningReady(Ok(0)), which would make quinn’s endpoint driver spin. Once the socket is drained, the inner call returnsPendingwith the waker armed.may_fragmentpasses through the inner socket’s answer, because quinn derivesallow_mtudfrom it. An obfuscated packet is 8 bytes larger than the one quinn thinks it sent. This is consistent, because path-MTU discovery probes the obfuscated size. Upstream does not compensate for it either, and the code comment warns not to “fix” it.
The scratch send buffer sits behind a parking_lot::Mutex because try_send takes &self. A quinn endpoint drives its socket from one task, so nothing contends for the lock.
The client
Section titled “The client”Configuration
Section titled “Configuration”pub struct Hy2Config { pub server: Destination, pub server_name: String, pub password: String, pub verify: VerifyMode, pub obfs: Option<Obfs>, pub max_concurrent_streams: usize,}
pub enum Obfs { Salamander { psk: Vec<u8> },}
pub const DEFAULT_MAX_CONCURRENT_STREAMS: usize = 102_400;Hy2Config, Obfs, Salamander and SalamanderSocket all have hand-written Debug impls. Hy2Config prints the server, server name, verify mode, obfuscation and stream limit. The obfuscation types print only the key’s length. None of them prints the password or the key.
How the app builds it
Section titled “How the app builds it”app/src/outbound/mod.rs → build_hy2_config builds the config for an outbound whose protocol is hysteria2, hysteria or hy2. Every check fails closed:
| Setting | Becomes | Refused when |
|---|---|---|
server, port |
server as a Destination with DialNetwork::Udp |
either is missing |
settings.server_name |
server_name, which defaults to server |
|
settings.password |
password |
missing or empty |
settings.allow_insecure, settings.ca_file |
VerifyMode::Insecure, CustomCa(pem) (the file is read here) or System |
both are set, or the CA file cannot be read |
settings.obfs, settings.obfs_password |
Some(Obfs::Salamander { psk }), where the PSK is the password’s bytes as written |
obfs_password set without obfs, obfs other than "salamander" (compared exactly, case-sensitive), or an obfs_password that is missing or under 4 bytes |
settings.max_concurrent_streams |
max_concurrent_streams, default DEFAULT_MAX_CONCURRENT_STREAMS |
0 |
address_family |
AddressFamilyStrategy passed to Hy2Connector::with_address_family |
not a known strategy |
Hysteria2OutboundSettings uses deny_unknown_fields, so a misspelt key is an error. The outbound also rejects a stream network other than tcp and a stream security other than none (reject_stream), because it never reads [stream]. upstream_dest_opt returns None for it, so a Hysteria outbound cannot be a balancer member: the balancer’s health probe is a TCP connect, and a Hysteria server listens only on UDP. The connector uses the app’s Resolver through with_resolver.
Key types
Section titled “Key types”pub struct Hy2Conn { _endpoint: Endpoint, conn: quinn::Connection, _h3: h3::client::SendRequest<h3_quinn::OpenStreams, bytes::Bytes>, _h3_driver: AbortOnDropHandle<()>, streams: Arc<Semaphore>, sessions: Sessions, next_session: AtomicU32, _datagram_pump: Option<AbortOnDropHandle<()>>, pub outcome: AuthOutcome,}
type Sessions = Arc<Mutex<HashMap<u32, mpsc::Sender<UdpMessage>>>>;
impl Hy2Conn { pub async fn connect( config: &Hy2Config, address_family: AddressFamilyStrategy, resolver: &Resolver, ) -> io::Result<Self> pub fn is_alive(&self) -> bool pub fn remote_address(&self) -> SocketAddr pub fn max_datagram_size(&self) -> Option<usize> pub async fn open_tcp( &self, address: &str, ) -> io::Result<(OwnedSemaphorePermit, quinn::SendStream, quinn::RecvStream)> pub fn open_udp(&self) -> io::Result<UdpSession> pub fn send_udp(&self, session: u32, addr: &str, payload: Bytes) -> io::Result<()>}
pub fn client_config(config: &Hy2Config) -> io::Result<ClientConfig>The fields that start with an underscore are there only to be held:
_endpointkeeps the endpoint driver and the socket alive._h3_driverand_datagram_pumpareAbortOnDropHandles, so both tasks stop when theHy2Connis dropped._h3is the one that matters most.h3closes the QUIC connection withHTTP_NO_ERRORwhen the lastSendRequestis dropped, and that would take every proxy stream with it. So the handle is stored and never used again.
is_alive is conn.close_reason().is_none(): it reports on the QUIC connection that the proxy streams ride on, not on the HTTP/3 layer.
Connect, authenticate, first stream
Section titled “Connect, authenticate, first stream”Hy2Conn::connect resolves config.server with destination_to_socketaddrs, which applies the address-family strategy and the resolver. If that yields no address, it returns AddrNotAvailable. It then tries every candidate, in the order the address-family strategy produces, so a host with both A and AAAA records still connects when one family is unreachable. Each attempt (connect_to) runs under its own CONNECT_TIMEOUT (10 s). That budget covers binding the socket, the QUIC handshake, HTTP/3 setup and the /auth round trip. If every attempt fails, the error is ConnectionRefused with the text hysteria2: no address answered (…), which lists each address with its own error or timed out. The error kind of each attempt, such as PermissionDenied from a rejected /auth or NotFound from a missing trust store, survives only as text inside that message.
sequenceDiagram participant R as Runtime participant C as Hy2Connector participant S as ConnSlot participant T as connect task participant H as Hysteria server R->>C: connect(flow) C->>S: acquire() S->>T: spawn Hy2Conn::connect T->>H: QUIC Initial, TLS 1.3, ALPN h3, SNI H-->>T: handshake done T->>T: h3::client::new, spawn h3 driver T->>H: POST /auth with Hysteria-Auth, CC-RX 0, Padding H-->>T: 233 with Hysteria-UDP, Hysteria-CC-RX T->>T: spawn datagram pump if UDP is enabled T->>S: state = Ready(conn) T-->>C: shared result Ok(conn) C->>C: try_acquire_owned stream permit C->>H: open_bi, then TCPRequest 0x401 H-->>C: TCPResponse status 0x00 C-->>R: Outbound::Stream(Hy2Stream)
Each attempt against one address does the following:
bind_socketbinds astd::net::UdpSocketto the unspecified address of the target’s family (0.0.0.0:0or[::]:0) and wraps it withquinn::TokioRuntime. Ifobfsis set, it wraps the result again inSalamanderSocket.Endpoint::new_with_abstract_socketcreates a client-only endpoint for this one connection (EndpointConfig::default(), no server config).endpoint.connect_with(client_config(config)?, addr, &config.server_name)runs the QUIC handshake.server_nameis both the SNI and the name the certificate must match.h3::client::new(h3_quinn::Connection::new(conn.clone()))sets up HTTP/3 on a clone of the connection. A task spawned for the driver waits ondriver.poll_closeand logs how the connection ended at debug level, with the text passed throughauth::describe(that is,sanitise_message).auth::authenticate(&mut send_request, &config.password, 0)runs the/authexchange.- If
outcome.udp_enabledis true,pump_datagramsis spawned. It is not started otherwise, because it would wait forever on a channel that nothing writes to. - The stream semaphore is created with
config.max_concurrent_streamspermits, andnext_sessionstarts at1.
QUIC and TLS setup
Section titled “QUIC and TLS setup”client_config builds the quinn ClientConfig:
| Setting | Value | Rust name |
|---|---|---|
| ALPN | h3 |
ALPN_H3 |
| Stream receive window | 8 MiB | STREAM_RECEIVE_WINDOW |
| Connection receive window | 20 MiB (STREAM_RECEIVE_WINDOW / 2 * 5) |
CONNECTION_RECEIVE_WINDOW |
| Idle timeout | 30 s | MAX_IDLE_TIMEOUT |
| Keep-alive interval | 10 s | KEEP_ALIVE |
These values match upstream’s client defaults. client_config runs on every connect attempt, so a trust-store or PEM problem shows up when the slot connects, not when the config is built. tls_config builds a rustls ClientConfig with the ring provider, named explicitly through builder_with_provider. ClientConfig::builder() would panic if some downstream crate enabled a second crypto-provider feature in the same build. QUIC always carries TLS 1.3 (RFC 9001). Verification follows VerifyMode:
VerifyMode |
Trust | Notes |
|---|---|---|
System |
system_roots(): the platform store from rustls_native_certs::load_native_certs |
Certificates that fail to parse are skipped. If none load, the result is NotFound “hysteria2: no system root certificates could be loaded” |
CustomCa(pem) |
System roots plus every certificate in the PEM file | It loads the system roots first, so an empty platform store fails here too, with the same NotFound. A PEM entry that does not parse, or a certificate rustls will not add, is InvalidInput. A file that yields zero certificates is refused (InvalidInput “hysteria2: the CA file contains no certificates”) rather than falling back to the system roots alone |
Insecure |
NoVerification |
Accepts any chain and any name. It still checks the handshake signatures through rustls’ own verify_tls12_signature and verify_tls13_signature. Only an explicit allow_insecure reaches it |
Proxy streams: open_tcp
Section titled “Proxy streams: open_tcp”HTTP/3 and the proxy share one connection. The server tells a proxy stream apart by its first varint: 0x401 lies in a range where HTTP/3 has no frame type, so the server’s HTTP/3 layer hands those streams over to the proxy. The client therefore opens proxy streams directly on the QUIC connection with conn.open_bi(), never through h3, just as upstream’s conn.OpenStream() does.
- Permit.
streams.try_acquire_owned()runs first. If no permit is left, the call fails at once withWouldBlock“hysteria2: connection is at its concurrent-stream limit” instead of waiting. - Open.
open_bi()runs underOPEN_STREAM_TIMEOUT(5 s). If it times out, the error isTimedOut. If it fails, the error isBrokenPipe. - Request. The client writes
encode_tcp_request(address, &TCP_REQUEST_PADDING.generate())with quinn’s ownSendStream::write_all. - Response. The client reads the
TCPResponsebeforeopen_tcpreturns. If the server refuses, the error isConnectionRefused“hysteria2: server refused the target”, followed by: {message}when the sanitised message is not empty. This way an unreachable target shows up as a failed connect, not as a stream that opens and then ends at once. For the same reason, upstream’s “fast open” mode is not implemented. - Return. The permit comes back together with both halves of the stream.
UDP sessions: open_udp, send_udp and the pump
Section titled “UDP sessions: open_udp, send_udp and the pump”pub struct UdpSession { pub id: u32, pub inbound: mpsc::Receiver<UdpMessage>, pub guard: SessionGuard,}
pub struct SessionGuard { id: u32, sessions: Sessions,}open_udp refuses with Unsupported in two cases: the server did not advertise UDP (outcome.udp_enabled is false), or the peer did not offer QUIC datagrams (max_datagram_size() is None). If the client accepted datagrams it could not deliver, they would be dropped silently on arrival. open_udp also refuses with WouldBlock when MAX_UDP_SESSIONS (256) sessions are already open. Session IDs come from next_session.fetch_add(1), which wraps around. The allocator skips 0 and IDs still in use, and gives up after MAX_UDP_SESSIONS + 1 tries with “hysteria2: no free UDP session id”. Each session gets a bounded mpsc::channel(UDP_SESSION_BACKLOG) (256 messages).
When the SessionGuard is dropped, its entry is removed from the map. The protocol has no message that closes a session, so the server frees the port it bound after its own idle timeout. The guard is separate from the receiver so that a relay can move the two into different tasks.
send_udp builds an unfragmented UdpMessage (packet_id: 0, frag_count: 1) and calls quic::send_udp_message, which fragments only if needed.
flowchart LR
conn["quinn::Connection read_datagram"]
pump["pump_datagrams task"]
parse{"parse_udp_message"}
map{"session id in Sessions?"}
chan["session mpsc, 256"]
link["Hy2DatagramLink: Defragger then parse_authority"]
drop(("dropped"))
conn --> pump --> parse
parse -->|"Err"| drop
parse -->|"Ok"| map
map -->|"no"| drop
map -->|"yes: try_send"| chan
chan -->|"full"| drop
chan --> link
pump_datagrams is one task per connection, and it must never block:
- A datagram that does not parse is dropped with a trace log.
- The session’s sender is cloned out from under the lock, so the lock is never held during a send.
try_sendinto a full channel drops the arriving message, so one slow association cannot stall all the others.- The loop ends when
read_datagramfails, that is, when the connection is gone.
The pump hands on messages still fragmented: reassembly belongs to each association.
The connection slot
Section titled “The connection slot”protocols/src/hysteria/slot.rs shares one Hy2Conn among all the flows of an outbound, creates it lazily and rebuilds it after it dies.
pub enum SlotState { Idle, Connecting(PendingConnect), Ready(Arc<Hy2Conn>),}
pub struct ConnSlot { pub state: SlotState, pub failures: u32, pub retry_at: Option<Instant>, pub started_at: Option<Instant>,}
pub type ConnectResult = Result<Arc<Hy2Conn>, Arc<io::Error>>;pub type PendingConnect = Shared<BoxFuture<'static, ConnectResult>>;
pub enum Acquired { Ready(Arc<Hy2Conn>), Pending(PendingConnect),}
pub async fn acquire( slot: &Arc<Mutex<ConnSlot>>, config: &Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: &Resolver,) -> io::Result<Arc<Hy2Conn>>
pub fn inspect_slot( slot: &Arc<Mutex<ConnSlot>>, config: &Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: &Resolver,) -> io::Result<Acquired>
pub fn start_connect( slot: Arc<Mutex<ConnSlot>>, config: Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: Resolver,) -> PendingConnectThe lock is a parking_lot::Mutex. All of the slot’s decisions happen in inspect_slot, a synchronous function that holds the lock and awaits nothing. acquire awaits the returned PendingConnect only after the guard has been released, so a slow handshake blocks only the flows waiting for it. io::Error is not Clone, but Shared needs a Clone output, so the error travels as Arc<io::Error>. acquire rebuilds it into a fresh io::Error with the same kind and text.
stateDiagram-v2 [*] --> Idle Idle --> Connecting: inspect_slot, not backing off Idle --> Idle: backing off, returns connection_down Connecting --> Connecting: later callers share the same future Connecting --> Ready: task Ok, note_success, started_at set Connecting --> Idle: task Err, note_failure Ready --> Ready: is_alive, hand out Arc clone Ready --> Idle: found dead on next acquire
- Single flight. A caller that finds
Connecting(pending)clones the sameSharedfuture. Two flows arriving during one handshake never open two connections. - The task writes the result back, not the waiter.
start_connectrunsHy2Conn::connectin a detachedtokio::spawn, writes the result into the slot, and then sends it on aoneshot. The handshake therefore finishes even if every waiter goes away (a cancelled generation, or a client that hung up), and the result is there for the next flow. If the task disappears without sending, waiters get “hysteria2: the connect task disappeared”. - Lazy liveness. Nothing watches the connection in the background. A dead connection is noticed the next time a flow calls
acquire:inspect_slotseesReady(conn)with!conn.is_alive(), logshysteria2: connection closed, reconnectingat warn level, and moves toIdle. Flows that still hold anArc<Hy2Conn>keep thatHy2Connalive until they end. - Backoff.
note_failureincrementsfailuresand setsretry_at = now + min(RECONNECT_BACKOFF_BASE × 2^min(failures, 5), RECONNECT_BACKOFF_MAX). With a 1 s base and a 30 s cap, consecutive failures wait 2 s, 4 s, 8 s, 16 s, and then 30 s from the fifth failure on. The first wait is 2 s, not 1 s, becausefailuresis incremented before the delay is computed. Whilebacking_off()is true,inspect_slotreturnsconnection_down()(BrokenPipe“hysteria2: connection is down, waiting before the next attempt”) without dialling. - Connections that die young. When a dead connection is found,
died_young()checks whether it lived less thanMIN_HEALTHY_LIFETIME(10 s). A connection with nostarted_atcounts as young. A young death counts as a failure (note_failure), and an older one resets the penalty (note_success) so the slot reconnects at once. Without this rule, a server that accepts the connection and then closes it straight away (for example because it has reached its user limit) would be dialled again by every arriving flow. A successful connect callsnote_success, which setsfailuresback to 0. So in that pattern each young death costs one failure, and the slot waits 2 s before the next attempt; the wait does not grow.
The connector
Section titled “The connector”pub struct Hy2Connector { config: Arc<Hy2Config>, address_family: AddressFamilyStrategy, resolver: Resolver, slot: Arc<Mutex<ConnSlot>>, udp_warned: Arc<AtomicBool>,}
impl Hy2Connector { pub fn new(config: Hy2Config) -> Self pub fn with_address_family(config: Hy2Config, address_family: AddressFamilyStrategy) -> Self pub fn with_resolver(mut self, resolver: Resolver) -> Self pub fn slot(&self) -> &Arc<Mutex<ConnSlot>>}
type DialFuture = Pin<Box<dyn Future<Output = io::Result<Outbound<Hy2Stream, Hy2DatagramLink>>> + Send>>;
impl<T: Send + Sync + 'static> Connector<Flow<T>> for Hy2Connector { type Stream = Hy2Stream; type Datagram = Hy2DatagramLink; type Future = DialFuture;
fn connect(&mut self, flow: Flow<T>) -> DialFuture}Every clone of a Hy2Connector shares the same slot and udp_warned through their Arcs, so the clones share a single connection. connect clones self into a boxed dial(flow.destination). The dial acquires the connection and then branches on dest.network:
DialNetwork::Udpcallsopen_udp()and returnsOutbound::Datagram(Hy2DatagramLink { conn, session, defragger }). Ifopen_udpfails withUnsupported, the connector logs one warning, “hysteria2: …; datagrams routed to this outbound are dropped”, the first time only (udp_warned.swap(true)), so an operator who routes UDP to a server without UDP gets a sign that something is wrong.- Any other network calls
open_tcp(&format_authority(&dest))and returnsOutbound::Stream(Hy2Stream { .. }).
pub struct Hy2Stream { send: quinn::SendStream, recv: quinn::RecvStream, _permit: OwnedSemaphorePermit, _conn: Arc<Hy2Conn>,}
pub struct Hy2DatagramLink { conn: Arc<Hy2Conn>, session: UdpSession, defragger: Defragger,}Hy2Stream is a raw QUIC bidirectional stream. AsyncRead forwards to RecvStream, and AsyncWrite forwards to SendStream. poll_shutdown sends a QUIC FIN, which the server reads as a clean end. Dropping the stream also finishes the send side: quinn 0.11’s SendStream sends a FIN when it is dropped unfinished, and the dropped RecvStream asks the server to stop sending. (The comment on poll_shutdown says that dropping would reset the stream, but quinn does not do that.) The stream holds its permit and an Arc<Hy2Conn> for its whole life.
Hy2DatagramLink implements DatagramLink with Addr = Destination:
poll_send_toformats the target as an authority, copies the buffer intoBytes, callssend_udpand returnsReadyright away. It never returnsPending.poll_recv_frompulls from the session’s channel and feeds each message to theDefragger. It parses the address withparse_authority(&addr, 0)and skips messages whose address does not parse or whose port is0. It copies at mostbuf.remaining()bytes and returns the source address withnetwork: DialNetwork::Udp. If the session’s channel closes, it returnsBrokenPipe“hysteria2: the connection behind this association is gone”. The channel’s sender stays in theSessionsmap until the link’s ownSessionGuarddrops, and the link also holds theHy2Conn, so the loss of the connection does not close the channel. A dead connection shows up asBrokenPipefrompoll_send_to(quinn’sConnectionLost) and as silence on receive.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| One authenticated connection carries many proxy streams | Hy2Conn::_h3 holds the last SendRequest, so h3 never closes the connection |
one_connection_carries_several_proxy_streams_after_auth, proxy_streams_are_independent_of_each_other (e2e_hysteria.rs); new_server_vs_new_client_tcp (pipeline/hysteria.rs) |
| Concurrent flows share one connect | SlotState::Connecting(Shared) cloned under the lock |
concurrent_callers_share_one_connect, stream_and_datagram_circuits_share_one_connect (unit/hysteria/slot.rs) |
| A handshake survives its waiters | Detached tokio::spawn in start_connect writes the slot |
the_connect_finishes_even_when_every_waiter_leaves |
| A down server is not dialled by every flow | note_failure and backing_off checked in inspect_slot |
a_backing_off_slot_refuses_without_dialling, a_failed_connect_leaves_the_slot_retryable, backoff_grows_with_each_failure_and_is_capped, a_healthy_connection_clears_the_penalty, a_connection_that_never_started_counts_as_dying_young |
| The stream limit covers the whole relay and is released afterwards | OwnedSemaphorePermit stored in Hy2Stream::_permit |
stream_permits_are_released_when_a_circuit_ends (e2e_hysteria.rs, with a limit of 4 over 20 circuits) |
| A refused target is a failed connect, not an empty stream | open_tcp reads the TCPResponse before returning |
a_failed_connect_is_answered_with_a_refusal (pipeline/hysteria.rs) |
| A server that does not relay UDP is reported, not silently dropped | open_udp checks udp_enabled and max_datagram_size |
a_server_without_udp_refuses_associations (pipeline/hysteria.rs) |
| The credential never appears in errors | Status-only rejection text; HeaderValue::set_sensitive; a fixed message for an unencodable password |
the_credential_is_marked_sensitive_and_never_printed, an_unencodable_password_is_refused_without_quoting_it (unit/hysteria/auth.rs); a_wrong_credential_is_refused (pipeline/hysteria.rs); a_wrong_password_is_refused_without_echoing_it (e2e_hysteria.rs) |
| Server text cannot forge log lines | sanitise_message: no control characters, 128 characters at most |
sanitise_message_strips_control_characters, sanitise_message_caps_length_and_tolerates_bad_utf8, tcp_response_sanitises_the_message_it_returns |
| Varints are QUIC’s, and never panic | put_varint/read_varint with a width chosen from two prefix bits |
varint_matches_rfc9000_vectors, varint_accepts_non_minimal_encoding, varint_width_boundaries, varint_above_62_bits_is_refused_not_panicked, varint_truncated_at_every_boundary |
| Lengths are checked before allocation | read_length_prefixed, discard_padding, parse_length_prefixed |
tcp_request_rejects_out_of_range_address_lengths, tcp_response_rejects_out_of_range_lengths, tcp_response_truncated_at_every_boundary, tcp_response_padding_shorter_than_promised_is_truncation, udp_truncated_in_the_header_is_refused_and_never_panics |
Status bytes other than 0x00 and 0x01 are rejected |
match status in read_tcp_response and parse_tcp_response |
tcp_response_rejects_an_undefined_status_byte |
| Reassembly memory is bounded | Defragger: one packet at a time, MAX_UDP_SIZE, index and duplicate checks |
reassembly_stops_at_the_maximum_datagram_size, a_new_packet_id_discards_the_one_in_progress, a_repeated_fragment_does_not_complete_the_datagram, a_fragment_index_past_the_count_is_dropped, fragments_arriving_out_of_order_still_reassemble |
| Fragments fit the peer’s limit and interoperate with Go | send_udp_message fragments only on TooLarge, with a non-zero packet ID |
fragments_reassemble_into_the_original, a_limit_below_the_header_cannot_be_fragmented, a_payload_needing_more_than_255_fragments_is_refused; a_datagram_too_large_for_one_frame_is_fragmented (e2e_hysteria.rs); new_server_vs_new_client_udp |
| Salamander is BLAKE2b-256 with one salt per packet | Salamander::keystream, fresh salt in obfuscate |
keystream_matches_an_independent_blake2b256, keystream_repeats_every_32_bytes, each_packet_gets_a_fresh_salt, obfuscate_does_not_leak_the_previous_packet |
| No GSO; GRO is unwrapped per datagram | max_transmit_segments() == 1, deobfuscate_in_place stride walk |
segmented_transmit_is_refused_and_never_requested, a_coalesced_batch_is_unwrapped_per_datagram, a_coalesced_batch_with_a_short_tail_is_unwrapped, a_coalesced_batch_with_an_impossible_tail_is_discarded, receive_segments_and_fragmentation_follow_the_inner_socket |
| A junk batch never spins quinn | poll_recv loops until something survives or the inner socket is Pending |
a_batch_of_junk_yields_pending_not_zero, survivors_are_packed_to_the_front_of_the_batch |
| Obfuscation mismatch fails, never falls back to plaintext | With obfuscation configured, every packet in both directions goes through SalamanderSocket; a peer that does not use the same key receives and sends only packets that quinn cannot decrypt, so the handshake cannot complete |
salamander_against_a_plain_server_fails_rather_than_falling_back; wire compatibility with Go in app_socks_to_hysteria2_with_salamander (e2e_hysteria.rs) |
| No PSK in debug output | Hand-written Debug for Salamander, SalamanderSocket, Obfs, Hy2Config |
debug_output_never_contains_the_psk (unit/hysteria/obfs.rs) |
Failure paths and cancellation
Section titled “Failure paths and cancellation”| Where | Condition | Result |
|---|---|---|
build_hy2_config (app) |
CA file cannot be read | The std::fs::read error, at config build time |
Hy2Conn::connect |
Name resolves to nothing usable | AddrNotAvailable |
Hy2Conn::connect |
Every address failed or timed out | ConnectionRefused listing each address with its error text |
connect_to (one address) |
Invalid server name for QUIC | InvalidInput, reported inside the ConnectionRefused above |
connect_to (one address) |
QUIC handshake failed (including a TLS verification failure) | ConnectionRefused, reported inside the ConnectionRefused above |
tls_config (one address) |
No system roots, or a CA file that yields no usable certificate | NotFound or InvalidInput, reported inside the ConnectionRefused above |
authenticate (one address) |
Status other than 233 |
PermissionDenied with the status only, reported inside the ConnectionRefused above |
acquire |
Connect task ended without a result | Other “hysteria2: the connect task disappeared” |
inspect_slot |
Backing off | BrokenPipe from connection_down() |
open_tcp |
No stream permit left | WouldBlock |
open_tcp |
open_bi takes longer than 5 s |
TimedOut |
open_tcp |
Server answers STATUS_ERROR |
ConnectionRefused, with the sanitised message |
open_tcp |
Malformed or truncated TCPResponse |
InvalidData (malformed) or UnexpectedEof (truncated), converted from the ProtocolError |
open_udp |
No UDP from the server, or no QUIC datagrams | Unsupported (warned once per connector) |
open_udp |
256 sessions open, or no free ID | WouldBlock |
send_udp |
Datagrams disabled, too large to split, connection lost | Unsupported, InvalidInput, BrokenPipe |
send_udp |
Empty payload | InvalidData “hysteria2 empty udp payload” |
Hy2DatagramLink::poll_recv_from |
Session channel closed | BrokenPipe (the link keeps its own sender registered, so this does not happen while the link is alive) |
Cancellation works in these ways:
- If a waiter drops the dial future, only its own wait is cancelled. The connect task still runs to the end.
- If an
open_tcpis cancelled before it returns, the permit and the partly opened stream are dropped together, which releases the permit. - When a
Hy2StreamorHy2DatagramLinkis dropped, it gives back its permit, or its session throughSessionGuard, and its reference to the connection. - When the last
Arc<Hy2Conn>is dropped, theAbortOnDropHandles abort the HTTP/3 driver and the datagram pump, and the connection,h3and endpoint handles are dropped with it.
Limits
Section titled “Limits”| Constant | Value | File |
|---|---|---|
CONNECT_TIMEOUT |
10 s per resolved address | connection.rs |
OPEN_STREAM_TIMEOUT |
5 s for open_bi |
connection.rs |
DEFAULT_MAX_CONCURRENT_STREAMS |
102,400 streams per connection | config.rs |
MAX_UDP_SESSIONS |
256 associations per connection | connection.rs |
UDP_SESSION_BACKLOG |
256 queued messages per association | connection.rs |
STREAM_RECEIVE_WINDOW / CONNECTION_RECEIVE_WINDOW |
8 MiB / 20 MiB | connection.rs |
MAX_IDLE_TIMEOUT / KEEP_ALIVE |
30 s / 10 s | connection.rs |
RECONNECT_BACKOFF_BASE / RECONNECT_BACKOFF_MAX |
1 s / 30 s | slot.rs |
MIN_HEALTHY_LIFETIME |
10 s | slot.rs |
MAX_ADDRESS_LENGTH |
2048 bytes | protocol.rs |
MAX_MESSAGE_LENGTH / MAX_MESSAGE_KEPT |
2048 bytes on the wire / 128 characters kept | protocol.rs |
MAX_PADDING_LENGTH |
4096 bytes | protocol.rs |
MAX_UDP_SIZE |
4096 bytes reassembled | protocol.rs |
MAX_DATAGRAM_FRAME_SIZE |
1200 bytes, upstream’s largest emitted datagram. The server falls back to it when a connection reports no datagram size; the client does not use it | protocol.rs |
SALT_LEN / KEY_LEN / MIN_PSK_LEN |
8 / 32 / 4 bytes | obfs.rs |
DEFAULT_MAX_CONCURRENT_STREAMS is a budget for everything routed to the outbound, not a per-user limit. It is set so high that, against a server that keeps upstream’s default MaxIncomingStreams of 1024, the local semaphore never runs out first. In that case the server holds back stream credit and open_bi waits, bounded by OPEN_STREAM_TIMEOUT, instead of failing fast with WouldBlock. The trade-off is deliberate: the client does not have to keep this number in step with the server’s setting. HTTP/3’s control and QPACK streams are unidirectional, and QUIC counts the two directions separately, so they do not use up this budget.
Hy2DatagramLink::poll_recv_from truncates a reassembled payload that is larger than the caller’s buffer.
| File | What it covers |
|---|---|
protocols/tests/unit/hysteria/protocol.rs |
Varint vectors from RFC 9000 Appendix A.1, padding ranges, byte-exact TCPRequest, both response verdicts, truncation at every boundary, sanitising, UDPMessage framing, fragmentation and every Defragger rule |
protocols/tests/unit/hysteria/auth.rs |
The request’s headers, Hysteria-CC-RX parsing, credential redaction |
protocols/tests/unit/hysteria/obfs.rs |
An independent BLAKE2b-256 vector, round trips at every length, GRO batches, junk batches, GSO refusal, Debug redaction |
protocols/tests/unit/hysteria/slot.rs |
Single flight, backoff arithmetic, dying young, detached connect. It uses a config with an empty server_name, which fails before any packet is sent, so the tests are instant and deterministic |
protocols/tests/pipeline/hysteria.rs |
This client against this crate’s own Hy2Inbound on loopback: TCP, UDP with fragmentation, a bare Hy2Conn, Salamander, a server without UDP, a wrong credential, a refused target, a user table swapped under a live inbound, and a proxy stream sent before authentication that is never relayed |
app/tests/integration/e2e_hysteria.rs |
Interop with the upstream Go server: several streams after authentication, parallel streams, a private CA, a wrong password, half-close semantics, permit release, UDP, fragmentation against Go’s fragmenter, the full app over SOCKS (TCP, UDP and Salamander), and an obfuscation mismatch |
The interop tests build the vendored hysteria/ tree with go build once per test run (app/tests/support/mod.rs → HYSTERIA_BIN). They skip themselves, rather than fail, when go is missing or the build fails, so a green run on a machine without Go does not prove interop. The opposite direction, upstream’s client against this crate’s inbound, is in app/tests/integration/e2e_hysteria_inbound.rs, which the server page covers.
cd Etemenankicargo test -p etemenanki-protocols --features hysteria hysteriacd Etemenankigit submodule update --init hysteriacargo test -p etemenanki-app --test integration e2e_hysteria::Without --features hysteria, the unit modules and the pipeline::hysteria module are not compiled at all, so a plain cargo test -p etemenanki-protocols runs none of these tests. etemenanki-app enables the feature itself.