Skip to content

Hysteria 2: protocol and client

Source files: 23 · checked against Etemenanki 596916d
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/protocols/src/hysteria/mod.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/hysteria/protocol.rs
  • Etemenanki/protocols/src/hysteria/quic.rs
  • Etemenanki/protocols/src/hysteria/auth.rs
  • Etemenanki/protocols/src/hysteria/obfs.rs
  • Etemenanki/protocols/src/hysteria/connection.rs
  • Etemenanki/protocols/src/hysteria/slot.rs
  • Etemenanki/protocols/src/hysteria/connector.rs
  • Etemenanki/protocols/src/hysteria/server/datagrams.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/protocols/tests/unit/hysteria/protocol.rs
  • Etemenanki/protocols/tests/unit/hysteria/auth.rs
  • Etemenanki/protocols/tests/unit/hysteria/obfs.rs
  • Etemenanki/protocols/tests/unit/hysteria/slot.rs
  • Etemenanki/protocols/tests/pipeline/hysteria.rs
  • Etemenanki/app/tests/integration/e2e_hysteria.rs
  • Etemenanki/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.

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.

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.

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.

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 + ?Sized
pub 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’s varintPut panics in that case.
  • The readers accept non-minimal encodings, as RFC 9000 allows: 0x40 0x25 reads as 37.
  • read_varint_slice returns Ok(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 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.

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 + ?Sized
pub async fn read_tcp_request_body<R>(reader: &mut R) -> Result<String, ProtocolError>
where
R: AsyncRead + Unpin + ?Sized
pub 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.

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 + ?Sized
pub fn parse_tcp_response(buf: &[u8]) -> Result<Option<(TcpResponse, usize)>, ProtocolError>
pub fn sanitise_message(raw: &[u8]) -> String

Here 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.

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.

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
  1. It first sends the message whole. Upstream does the same, and in the common case this adds no fragmentation header.
  2. Only if quinn returns SendDatagramError::TooLarge does it read conn.max_datagram_size(): the smaller of the peer’s advertised maximum datagram frame size and what the current path MTU allows. If that is None, the send fails with Unsupported “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.
  3. UdpMessage::fragment(limit) cuts the payload into pieces of limit - header_size() bytes. It returns None if the header alone does not fit, or if more than 255 fragments would be needed (the fragment count is a u8). The caller turns None into InvalidInput: “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.

#[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, reset throws away whatever was in progress. Like upstream, the Defragger reassembles 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’s MaxUDPSize), the buffer is cleared and the fragment dropped. After that, the association keeps working normally.
  • When the last missing fragment arrives, feed returns one message with frag_id = 0, frag_count = 1 and the payload concatenated in index order.

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_enabled is true only if the value equals true, compared without regard to case. A missing header means false.
  • hysteria-cc-rx: parse_server_rx trims the value and maps auto (any case) to ServerRx::Auto, 0 to ServerRx::Unlimited and any other u64 to ServerRx::Bps. A missing or unparseable value becomes Auto, so the client never invents a rate limit the server did not ask for. The client logs server_rx at 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.

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
  • 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_segments returns 1, which tells quinn not to batch. try_send also rejects any Transmit whose segment_size is smaller than its contents, with InvalidInput “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_segments reports whatever the inner socket reports. deobfuscate_in_place walks a coalesced buffer at stride steps (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_batch then discards slots that failed, copies the surviving slots down, and rewrites their RecvMeta. If a whole batch was junk, poll_recv loops and polls the inner socket again rather than returning Ready(Ok(0)), which would make quinn’s endpoint driver spin. Once the socket is drained, the inner call returns Pending with the waker armed.
  • may_fragment passes through the inner socket’s answer, because quinn derives allow_mtud from 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.

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.

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.

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:

  • _endpoint keeps the endpoint driver and the socket alive.
  • _h3_driver and _datagram_pump are AbortOnDropHandles, so both tasks stop when the Hy2Conn is dropped.
  • _h3 is the one that matters most. h3 closes the QUIC connection with HTTP_NO_ERROR when the last SendRequest is 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.

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:

  1. bind_socket binds a std::net::UdpSocket to the unspecified address of the target’s family (0.0.0.0:0 or [::]:0) and wraps it with quinn::TokioRuntime. If obfs is set, it wraps the result again in SalamanderSocket.
  2. Endpoint::new_with_abstract_socket creates a client-only endpoint for this one connection (EndpointConfig::default(), no server config).
  3. endpoint.connect_with(client_config(config)?, addr, &config.server_name) runs the QUIC handshake. server_name is both the SNI and the name the certificate must match.
  4. 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 on driver.poll_close and logs how the connection ended at debug level, with the text passed through auth::describe (that is, sanitise_message).
  5. auth::authenticate(&mut send_request, &config.password, 0) runs the /auth exchange.
  6. If outcome.udp_enabled is true, pump_datagrams is spawned. It is not started otherwise, because it would wait forever on a channel that nothing writes to.
  7. The stream semaphore is created with config.max_concurrent_streams permits, and next_session starts at 1.

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

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.

  1. Permit. streams.try_acquire_owned() runs first. If no permit is left, the call fails at once with WouldBlock “hysteria2: connection is at its concurrent-stream limit” instead of waiting.
  2. Open. open_bi() runs under OPEN_STREAM_TIMEOUT (5 s). If it times out, the error is TimedOut. If it fails, the error is BrokenPipe.
  3. Request. The client writes encode_tcp_request(address, &TCP_REQUEST_PADDING.generate()) with quinn’s own SendStream::write_all.
  4. Response. The client reads the TCPResponse before open_tcp returns. If the server refuses, the error is ConnectionRefused “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.
  5. 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_send into a full channel drops the arriving message, so one slow association cannot stall all the others.
  • The loop ends when read_datagram fails, that is, when the connection is gone.

The pump hands on messages still fragmented: reassembly belongs to each association.

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,
) -> PendingConnect

The 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 same Shared future. Two flows arriving during one handshake never open two connections.
  • The task writes the result back, not the waiter. start_connect runs Hy2Conn::connect in a detached tokio::spawn, writes the result into the slot, and then sends it on a oneshot. 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_slot sees Ready(conn) with !conn.is_alive(), logs hysteria2: connection closed, reconnecting at warn level, and moves to Idle. Flows that still hold an Arc<Hy2Conn> keep that Hy2Conn alive until they end.
  • Backoff. note_failure increments failures and sets retry_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, because failures is incremented before the delay is computed. While backing_off() is true, inspect_slot returns connection_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 than MIN_HEALTHY_LIFETIME (10 s). A connection with no started_at counts 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 calls note_success, which sets failures back 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.
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::Udp calls open_udp() and returns Outbound::Datagram(Hy2DatagramLink { conn, session, defragger }). If open_udp fails with Unsupported, 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 returns Outbound::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_to formats the target as an authority, copies the buffer into Bytes, calls send_udp and returns Ready right away. It never returns Pending.
  • poll_recv_from pulls from the session’s channel and feeds each message to the Defragger. It parses the address with parse_authority(&addr, 0) and skips messages whose address does not parse or whose port is 0. It copies at most buf.remaining() bytes and returns the source address with network: DialNetwork::Udp. If the session’s channel closes, it returns BrokenPipe “hysteria2: the connection behind this association is gone”. The channel’s sender stays in the Sessions map until the link’s own SessionGuard drops, and the link also holds the Hy2Conn, so the loss of the connection does not close the channel. A dead connection shows up as BrokenPipe from poll_send_to (quinn’s ConnectionLost) and as silence on receive.
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)
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_tcp is cancelled before it returns, the permit and the partly opened stream are dropped together, which releases the permit.
  • When a Hy2Stream or Hy2DatagramLink is dropped, it gives back its permit, or its session through SessionGuard, and its reference to the connection.
  • When the last Arc<Hy2Conn> is dropped, the AbortOnDropHandles abort the HTTP/3 driver and the datagram pump, and the connection, h3 and endpoint handles are dropped with it.
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.

Terminal window
cd Etemenanki
cargo test -p etemenanki-protocols --features hysteria 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.