Skip to content

Testing

Source files: 65 · checked against Etemenanki 596916d · katana v3.0.1
  • Etemenanki/Cargo.toml
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/app/Cargo.toml
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/concepts/tests/client.rs
  • Etemenanki/environment/src/lib.rs
  • Etemenanki/environment/tests/integration.rs
  • Etemenanki/environment/tests/integration/udp.rs
  • Etemenanki/environment/tests/unit/dial/socket.rs
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/core/harness.rs
  • Etemenanki/protocols/tests/pipeline.rs
  • Etemenanki/protocols/tests/pipeline/core.rs
  • Etemenanki/protocols/tests/pipeline/trojan.rs
  • Etemenanki/protocols/tests/pipeline/vless.rs
  • Etemenanki/protocols/tests/pipeline/vmess.rs
  • Etemenanki/protocols/tests/pipeline/shadowsocks.rs
  • Etemenanki/protocols/tests/pipeline/transports.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/pipeline/hysteria.rs
  • Etemenanki/protocols/tests/pipeline/wireguard.rs
  • Etemenanki/protocols/tests/pipeline/tun.rs
  • Etemenanki/protocols/tests/support/mod.rs
  • Etemenanki/protocols/tests/support/pipeline.rs
  • Etemenanki/protocols/tests/support/wireguard.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/protocols/tests/unit/trojan/core.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/protocols/tests/unit/wireguard/device.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/tests/integration.rs
  • Etemenanki/app/tests/support/mod.rs
  • Etemenanki/app/tests/integration/e2e_xray.rs
  • Etemenanki/app/tests/integration/e2e_xray_vmess.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • Etemenanki/app/tests/integration/e2e_unix.rs
  • Etemenanki/app/tests/integration/e2e_hysteria.rs
  • Etemenanki/app/tests/integration/e2e_hysteria_inbound.rs
  • Etemenanki/app/tests/integration/e2e_wg.rs
  • Etemenanki/app/tests/integration/e2e_tun.rs
  • katana/Cargo.toml
  • katana/src/main.rs
  • katana/src/runtime.rs
  • katana/src/serve.rs
  • katana/src/traffic.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/tests/integration.rs
  • katana/tests/support/mod.rs
  • katana/tests/integration/xray_interop.rs
  • katana/tests/integration/sniff.rs
  • katana/tests/integration/hysteria_interop.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/api/newv2board.rs

Both repositories test in layers. The lowest layer calls a sans-I/O core or codec by hand and inspects the effects and bytes it produces. The next drives the per-connection runtimes with toy protocols over in-memory pipes. Above that, each protocol’s server core runs against its own client codec over loopback sockets. At the top, the real etemenanki-app and katana binaries run as child processes, and the interop tests among them run against independent implementations (Xray and the official Hysteria 2 client and server) that the tests build from the reference trees with Go.

This page is for contributors who add or change code in either repository. It describes where each kind of test lives, the harness each layer offers, how to run a subset, the gates a change must pass, and the tests a new protocol or a pipeline change is expected to bring. The per-component pages list the individual tests that pin their behaviour; this page covers how the tests are built.

Unit tests live in tests/unit/ and compile into their module

Section titled “Unit tests live in tests/unit/ and compile into their module”

Unit tests do not live in the file under test. The test body lives under <crate>/tests/unit/, mirroring the module path, and the module under test mounts it with a #[path] attribute:

protocols/src/trojan/core.rs
#[cfg(test)]
#[path = "../../tests/unit/trojan/core.rs"]
mod tests;

The file brings the parent module into scope with use super::*;, usually on its first line. Because it compiles as a child module of the code under test, it reaches everything the module can see, including private items and the module’s private use imports (the Trojan test takes Validator, Arc and io from super::*), without any of them becoming pub. Cargo auto-discovers only tests/*.rs and tests/<dir>/main.rs as integration targets, and there is no main.rs under tests/unit/, so these files are compiled only through their #[path] mount.

Rule Example
The path usually mirrors the module protocols/src/vless/codec.rs mounts tests/unit/vless/codec.rs; protocols/src/hysteria/server/inbound.rs mounts tests/unit/hysteria/server/inbound.rs
The transports flatten their third level into one file name protocols/src/transports/tls/config.rs mounts tests/unit/transports/tls_config.rs; transports/grpc/framing.rs mounts tests/unit/transports/grpc_framing.rs
A mod.rs in protocols mounts a mod.rs protocols/src/core/mod.rs mounts tests/unit/core/mod.rs
The binary crates keep tests/unit/ flat app/src/inbound/mod.rs mounts tests/unit/inbound.rs; katana’s src/outbound/mod.rs mounts tests/unit/outbound.rs
A binary crate can mount a suite from main.rs katana’s src/main.rs mounts tests/unit/e2e.rs as mod e2e

The only exceptions are three files in etemenanki-concepts: concepts/src/buffer.rs, concepts/src/wake.rs and concepts/src/relay.rs keep a small inline mod tests. New tests go in tests/unit/.

environment/src/lib.rs and protocols/src/lib.rs deny clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing and clippy::arithmetic_side_effects for the crate, then allow all four under cfg_attr(test, …). Mounted unit tests are part of the crate under cfg(test), so the allowance is what lets them unwrap() and index freely. Integration targets are separate crates and never see the deny. Test support that ships in the library, such as CoreHarness, is not under cfg(test), so it obeys the lints: that is why it uses get(..).unwrap_or_default() and saturating_add rather than indexing and +.

katana has no crate-wide deny. It gates a few test-only helpers inside the source with #[cfg(test)]: AuthKey::from_auth, TokenBucket::consume, NodeTraffic::set_users and NodeTraffic::get in src/traffic.rs. Production code calls NodeTraffic::prepare and NodeTraffic::commit as two separate steps, and TokenBucket::charge directly.

Each package has at most two integration entry points. An entry point is a single file under tests/ that declares its submodules, so all scenarios of one kind link into one test binary instead of one binary per file.

Package Entry point Modules What it proves
etemenanki-concepts tests/runtime.rs none ProxyServerRuntime driven by toy cores: relaying, backpressure, deadlines, datagram transports, held bytes
etemenanki-concepts tests/client.rs server_side ProxyClientRuntime driven by toy codecs, alone and as the outbound of a server runtime
etemenanki-environment tests/integration.rs tcp, udp, quic (feature quic) The dialers against real loopback peers
etemenanki-protocols tests/pipeline.rs pipeline::{core, dns_secure, http, hysteria, shadowsocks, socks, transports, trojan, tun, vless, vmess, wireguard} Every server core against its client codec over loopback; every transport; DNS over TLS and HTTPS; the WireGuard connector against an in-process peer
etemenanki-app tests/integration.rs 13 e2e_* modules The real binary: interop with Xray and Hysteria, routing, sniffing, DNS, balancers, Unix sockets, WireGuard, TUN
katana tests/integration.rs xray_interop, sniff, hysteria_interop The real binary against a fake panel, driven by real Xray and Hysteria clients

In tests/pipeline.rs, mod hysteria sits behind #[cfg(feature = "hysteria")] and mod tun behind #[cfg(feature = "tun")]. In environment/tests/integration.rs, mod quic sits behind #[cfg(feature = "quic")]. The shared helpers of etemenanki-protocols, etemenanki-app and katana live in tests/support/, declared by the entry point as mod support;. protocols/tests/support/mod.rs and support/wireguard.rs carry #![allow(dead_code)], because no single scenario uses every helper. The two concepts targets and the environment target have no shared support module.

katana’s in-process end-to-end suite is not an integration target. tests/unit/e2e.rs is mounted into the binary crate from src/main.rs, so it runs with the unit tests and has crate-private access to NodeManager, PanelClient and the config structs. Its fixtures are pub(crate), and tests/unit/runtime.rs, mounted from src/runtime.rs, imports them from crate::e2e to drive reloads against running nodes.

Layer Where I/O What a failure there usually means
Codec and parser unit tests protocols/tests/unit/<proto>/{protocol,codec}.rs None Wrong bytes on the wire, or a malformed field accepted
Core unit tests protocols/tests/unit/<proto>/core.rs through CoreHarness None Wrong effects: an early Open, a missing deadline, a lost half-close
Runtime tests concepts/tests/{runtime,client}.rs tokio::io::duplex, some UDP on loopback The driver breaks the event contract, backpressure or deadlines
Pipeline tests protocols/tests/pipeline/*.rs Loopback TCP and UDP Server core and client codec disagree, or a transport breaks
App unit tests app/tests/unit/*.rs Mostly none Config accepted that should fail closed, or a build step mis-wires
App end-to-end app/tests/integration/e2e_*.rs Child processes on loopback The binary as an operator runs it does not work, or disagrees with Xray or Hysteria
katana unit tests katana/tests/unit/*.rs duplex, paused clock Admission, metering, rate limiting or accounting logic
katana in-process e2e katana/tests/unit/e2e.rs, katana/tests/unit/runtime.rs Loopback, in-process fake panels The node manager tree: sync, metering, reporting, user refresh, bootstrap retries, reload decisions
katana integration katana/tests/integration/*.rs Child processes on loopback The binary against an independent client and a panel

concepts/tests/runtime.rs and concepts/tests/client.rs never use a real protocol. They define small protocols whose whole wire format fits in a comment, so a failure points at the runtime rather than at a codec. etemenanki-concepts enables tokio’s test-util in its dev-dependencies for the paused-clock tests.

Core Transport Wire format Used for
TinyMux Byte stream [kind][key][len:u16][payload], payload XOR 0x55; kinds OPEN 1, DATA 2, CLOSE 3, FIN 4 from the client, CONNECTED 5, CONNECT_FAILED 6, EOF 7 to it Several keys over one connection: relaying, stalls, half-close, deadlines, FrameTooLarge
TinyUdp Byte stream [len:u16][port:u16][payload]; len == 0 finishes; len == 0xFFFE sends to an IPv6 peer through an IPv4 socket to provoke SendFailed Key = Single with a datagram outbound
TinyHub UDP socket (over_datagrams) [port:u16][payload] per packet; port 0 finishes; port 0xFFFF provokes TransportSendFailed Key = SocketAddr: one outbound per peer, replies through stage_to
TinySessions over TinyQuic Single-peer DatagramLink (Addr = ()) over two mpsc channels, refusing packets above max [session:u32][port:u16][payload] The QUIC-datagram shape: one peer, many sessions, a refused packet reported as NOTICE
Sniffing Byte stream The first 8 bytes are the target name Effect::ForwardHeld and the held-buffer pin rule

The core’s constants are chosen for the test: TinyMux has STAGING_RESERVE = HDR + 64 (with HDR = 4), TinyUdp 4, TinyHub 2, TinySessions 6, Sniffing 0.

The dialers are plain closures, because Connector has a blanket implementation for FnMut(Target) -> Future:

  • dialer(dials) pops a pre-made DuplexStream for every Open and refuses the target "fail" with ConnectionRefused ("refused by test").
  • mux_harness(far_caps) builds the client pipe (4096 bytes) and one far-end duplex per entry of far_caps. A small capacity is how a test makes one outbound stall: stalled_outbound_holds_uplink_but_not_other_downlink gives key 1 an 8-byte pipe.
  • gated_dialer(targets, links) records each target and completes the dial only when the test fires a oneshot, so a test can hold a dial open while more bytes arrive.
  • udp_connector and udp_echo bind real UDP sockets on 127.0.0.1.

core_is_driven_without_any_io is the one test with no runtime at all: it calls TinyMux::handle with an EffectList and a WriteBuffer::<256> and asserts on the effects. It is the pattern CoreHarness generalises.

Codec Traits Wire format Used for
TinyCodec ProxyCoreEncodeHandshake, ProxyCoreEncode Header [0xC0][len:u8][target], then [kind][len:u16][payload ^ 0x55] with kind 1 data and 2 close; seal takes at most MAX_FRAME = 32 bytes Framing across calls, EOF, dial failure, backpressure
TwoRoundCodec same SOCKS-shaped: [0x05] then [0x05, 0x00], [0x01][target] then [0x00] Handshake::AwaitReply and a refused method
Scripted all three, including ProxyCoreEncodeDatagram Returns whatever Handshake, Reply and Opened the test set Contract violations: every such test wraps the read in a 1 s timeout and expects an error, never a spin
TinyUdpCodec ProxyCoreEncodeHandshake, ProxyCoreEncodeDatagram TinyCodec framing with [port:u16] before each payload Datagrams over a stream upstream

The server_side module runs a server runtime whose connector is a ProxyClientConnector wrapping TinyCodec, with a Passthrough core that answers + on Connected and - on ConnectFailed. It pins that Connected is reported only after the upstream accepted the header (server_runtime_relays_through_a_client_runtime_in_one_task uses a 4-byte upstream pipe to make the header wait), and that a dial failure or a refused upstream handshake becomes ConnectFailed (upstream_dial_failure_is_connect_failed_not_connected, refused_upstream_handshake_is_connect_failed_not_connected).

Both runtimes assert their buffer against the core or codec at construction:

concepts/src/runtime.rs
assert!(
BUF_SIZE > Core::STAGING_RESERVE,
"BUF_SIZE must exceed the core's STAGING_RESERVE or nothing is ever read"
);

ProxyClientRuntime::new has the same check against Codec::STAGING_RESERVE. A test that picks a tiny buffer to force a condition therefore panics at construction if it goes too far. frame_larger_than_the_buffer_is_an_error uses BUF_SIZE = 128 with a 504-byte frame to reach RuntimeError::FrameTooLarge legitimately.

CoreHarness drives a server core by hand: it delivers events, collects the effects the core pushes and the bytes it stages, and does no I/O. It lives in the library, not under cfg(test), so both the crate’s own unit tests and downstream crates can use it.

protocols/src/core/harness.rs
pub const HARNESS_STAGING: usize = 64 * 1024;
pub struct CoreHarness<C: ProxyCoreDecode> {
pub core: C,
/* private: effects, staging, packets */
}
impl<C: ProxyCoreDecode> CoreHarness<C> {
pub fn new(core: C) -> Self;
pub fn over_datagrams(core: C) -> Self;
pub fn event(&mut self, event: Event<'_, C>) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn transport(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn feed(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn outbound(
&mut self,
key: C::Key,
data: &mut [u8],
) -> Result<(usize, Vec<Effect<C>>), C::Error>;
pub fn staged(&mut self) -> Vec<u8>;
pub fn staged_packets(&mut self) -> Vec<(Vec<u8>, C::TransportAddr)>;
pub fn held(&self, range: std::ops::Range<usize>) -> Vec<u8>;
}
Method Behaviour
new Stands in for a stream transport. The staging area is a WriteBuffer<HARNESS_STAGING>, 64 KiB.
over_datagrams Also keeps a PacketList, so the core’s stage_to and put_to work. Use it for cores whose TransportAddr is a peer address.
event Calls core.handle once with a fresh Effects sink, returns the consumed count and every effect pushed, in order.
transport event(Event::Transport(data)): one call, exactly as written.
feed What the runtime does: calls again on the unconsumed tail while the core makes progress, stops when a call consumes 0. Effect::Forward and Effect::SendTo ranges are rebased so they are absolute in data.
outbound event(Event::Outbound { key, data }).
staged Takes everything staged toward the transport so far and clears it.
staged_packets Takes the staged packets, each with its peer, in order. Empty under new.
held Resolves a ForwardHeld range against core.held(), as the runtime would.

The choice between transport and feed is deliberate. transport tests what a core does with exactly the bytes it was given, which is how header_split_across_events_opens_once_complete (protocols/tests/unit/trojan/core.rs) checks that 30 bytes of a Trojan header consume 0 and arm only the handshake deadline. feed tests the loop the runtime runs, which is what a multi-frame input needs.

Most core test files define two helpers on top of the harness: a constructor such as core(sniff: bool) -> CoreHarness<TrojanCore<()>>, and moves(fx), which drops Effect::SetDeadline so the assertions stay about behaviour. When the deadline is the point, assert on it directly against the named constants: HANDSHAKE_TIMEOUT (10 s), RELAY_IDLE_TIMEOUT (300 s) from protocols/src/core/mod.rs, and SNIFF_TIMEOUT (300 ms) from protocols/src/sniff/mod.rs. A deadline test delivers Event::Deadline through event; no clock is involved.

protocols/tests/unit/core/mod.rs tests the shared pieces the cores compose (Timing, SniffPrefix, Passthrough, PassthroughCore). Most of its tests skip the harness and use a with_sink helper that builds an Effects over a WriteBuffer::<256> and returns the drained effects and staged bytes; the three tests of a sniffing PassthroughCore use CoreHarness.

Pipeline tests: server core against client codec

Section titled “Pipeline tests: server core against client codec”

protocols/tests/pipeline.rs puts each protocol’s two halves on opposite ends of a loopback TCP connection: the server core inside a ProxyServerRuntime, the client codec inside a ProxyClientRuntime, and an echo server as the destination. Neither side knows it is talking to its own sibling, so a divergence between them shows as corrupted or missing bytes.

sequenceDiagram
  participant T as Test
  participant C as ProxyClientRuntime with codec
  participant L as serve_runtime listener
  participant S as ProxyServerRuntime with core
  participant E as tcp_echo
  T->>C: client(codec, server addr)
  C->>L: TCP connect to 127.0.0.1
  L->>S: spawn one runtime per accept, make_core()
  T->>C: write_all(payload)
  C->>S: handshake header, then sealed frames
  S->>E: Effect::Open, FreedomConnector dials
  S->>E: Effect::Forward of decoded bytes
  E-->>S: echoed bytes
  S-->>C: frames staged by the core
  C-->>T: read_exact returns the payload
  T->>C: shutdown()
  C-->>T: read_to_end is empty
protocols/tests/support/pipeline.rs
pub struct FreedomConnector;
impl Connector<Flow<()>> for FreedomConnector {
type Stream = TcpStream;
type Datagram = UdpOutbound;
type Future = DialFuture<TcpStream, UdpOutbound>;
fn connect(&mut self, flow: Flow<()>) -> Self::Future;
}
pub struct TcpConnector;
impl Connector<Destination> for TcpConnector {
type Stream = TcpStream;
type Datagram = NoDatagram;
type Future = DialFuture<TcpStream, NoDatagram>;
fn connect(&mut self, dest: Destination) -> Self::Future;
}
pub async fn serve_runtime<const BUF: usize, Core, F>(make_core: F) -> SocketAddr
where
Core: ProxyCoreDecode<Target = Flow<()>, TransportAddr = ()> + Send + 'static,
Core::Error: fmt::Display,
F: Fn() -> Core + Send + Sync + 'static;
pub fn client<const BUF: usize, Codec>(
codec: Codec,
addr: SocketAddr,
) -> ProxyClientRuntime<BUF, Codec, TcpConnector>
where
Codec: ProxyCoreEncodeHandshake<Target = Destination>,
Codec::Error: std::error::Error + Send + Sync + 'static;
Helper Behaviour
FreedomConnector Dials a flow’s destination directly. A UDP flow binds a fresh socket on 127.0.0.1:0; a TCP flow connects, and a TCP flow to a domain fails with Unsupported, so pipeline tests address echo servers by IP.
TcpConnector Dials the proxy under test by address over plain TCP. A domain address fails with Unsupported.
serve_runtime Binds 127.0.0.1:0, then for every accepted connection spawns ProxyServerRuntime::<BUF, Core, _, _>::new(stream, make_core(), FreedomConnector). A runtime that ends with an error prints server runtime ended with … to stderr; the test itself sees the error as bytes that never arrive.
client ProxyClientRuntime::new(codec, &mut TcpConnector, tcp_dest(addr)). The result is AsyncRead + AsyncWrite for a stream codec and a DatagramLink<Addr = Destination> for a datagram codec.

tests/support/mod.rs adds tcp_echo() and udp_echo() (loopback echo servers on port 0), tcp_dest(addr) and udp_dest(addr) to build a Destination, and self_signed_pem(), a 2048-bit RSA certificate for CN=localhost with a localhost SubjectAltName, valid for 365 days, for TLS transports and DoT/DoH.

The BUF parameter is the core’s own constant, not a guess: TrojanCore::<()>::BUF_SIZE (16 KiB), VlessCore (16 KiB), VMessCore (32 KiB), ShadowsocksCore (20 KiB), Ss2022Core (32 KiB), HttpCore::<()>::BUF_SIZE (MAX_HEAD, 64 KiB), PassthroughCore (8 KiB). Using the production size keeps the test on the same buffer arithmetic the app runs. SOCKS has no core constant, and its pipeline tests give the client runtime a fixed 16 KiB.

  • SOCKS has its own driver rather than a core. pipeline/socks.rs builds each server with serve_inbound(config, sniff), which takes a whole SocksServerConfig<()>, binds 127.0.0.1:0 and runs SocksInbound::serve(stream, local_ip, Some(peer.ip()), FreedomConnector) per accepted connection. new_server(auth) and new_server_with(auth, sniff) pass it config(auth): UDP enabled, udp_bind unset, and with auth one account, alice/secret. A TCP test that needs another udp_bind passes its own config to serve_inbound; the Unix-socket test builds its SocksInbound directly. The UDP tests use the kernel’s client, SocksUdpLink::associate; the tests of the association’s source check add a second sender beside it or drive the relay by hand, as described below.
  • Hysteria 2 runs over QUIC, so pipeline/hysteria.rs binds a UDP socket, runs Hy2Inbound::run(socket, |_| FreedomConnector, token), and stops it by cancelling the token when the NewServer guard drops. It is compiled only with the hysteria feature.
  • TUN (pipeline/tun.rs, feature tun) replaces the device with a socketpair: the test injects IP packets it builds with etherparse, reads what the stack answers, and captures the flows the inbound opens with a recording connector. It needs no privilege, and runs on flavor = "multi_thread".
  • WireGuard is an outbound, so pipeline/wireguard.rs points a WgConnector at the in-process peer described below and drives a TCP and a UDP flow through it. Two more tests pin what the tunnel does with traffic it cannot deliver: a_stalled_tcp_flow_blocks_its_writer (a flow to a remote that stops reading holds up its writer, a sibling flow still echoes, and the writer resumes once the remote reads again) and an_oversized_datagram_does_not_wedge_the_association (a datagram too big to ever send is dropped, and the next one still echoes).
  • Transports (pipeline/transports.rs) serve an echo behind every InboundTransport kind and dial it with the matching TransportConnector, including a pinned CA, WebSocket early data and many gRPC streams on one connection.

Over TCP, a SOCKS5 UDP association hears one client: the IP its control connection came from, compared in canonical form, pinned to one port by the first datagram it forwards, or up front by a request that names that IP with a port (see SOCKS). SocksUdpLink always names an all-zero source, so the tests that need a chosen source, or a second sender, work below it with helpers from pipeline/socks.rs:

Helper Behaviour
associate_raw(control, source) Sends a no-authentication greeting and a UDP ASSOCIATE naming source over any AsyncRead + AsyncWrite stream, and returns the server’s Reply. It waits at most 5 s for each read of the reply.
send_via(socket, relay, target, payload) Sends one relay packet for target from a plain UdpSocket.
recv_via(socket, relay) Returns the payload of the next relay packet within 5 s, and asserts it came from relay.
recording_echo() A UDP echo on 127.0.0.1 that reports every payload on an unbounded channel before echoing it; forwarded(&mut seen) drains the channel, so a test asserts exactly what the relay passed on.
assert_closed(control) Expects the server to close the control connection within 5 s, as it does after a refusal.

A test that sends an unwanted datagram queues it ahead of the client’s, so the relay, which drains its socket in order, meets it first. NOT_ALLOWED is 0x02, the reply to an association the relay cannot hold to its client.

Test Pins
udp_association_ignores_another_ip A stranger on 127.0.0.2 that sends before the client’s first datagram, and again after it, is never forwarded, and hears no answer in the 200 ms the test waits
udp_association_ignores_another_port_once_pinned After the first datagram, a second socket on 127.0.0.1 is not forwarded
udp_association_holds_to_the_port_the_request_names A request naming the client socket’s own address and port holds the association to it before any datagram: a neighbour that sends first is not forwarded
udp_association_sets_aside_a_source_it_cannot_hold_to A request naming [::1]:0 for an IPv4 client is granted, and the client relays
udp_association_is_not_widened_by_the_request A request naming a socket on 127.0.0.2 does not admit it; the control connection’s peer still relays
udp_association_over_a_unix_socket_needs_its_exact_source Over a Unix socket, a request naming 0.0.0.0:0 gets 0x02 and a closed connection; one naming the client’s exact address and port relays and shuts out a neighbour
udp_association_refuses_a_relay_that_cannot_hear_the_client With udp_bind = ::1, an IPv4 client’s association gets 0x02 and a closed connection
udp_link_ignores_datagrams_not_from_the_relay The client side: a hand-written server grants the association, then an intruder answers the client’s first datagram ahead of the relay; the link returns only the relay’s reply
udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay SocksUdpLink on a socket bound to [::]:0 accepts the IPv4 relay’s replies, which arrive from its IPv4-mapped address

The Unix test creates a UnixStream::pair() per association, keeps one end as the control connection and serves the other with serve(stream, None, None, FreedomConnector): no local IP and no peer, under a config with udp_bind set to 127.0.0.1. The tests that bind 127.0.0.2 or a dual-stack socket (udp_association_ignores_another_ip, udp_association_is_not_widened_by_the_request, udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay) are #[cfg(target_os = "linux")], and the Unix test is #[cfg(unix)]. All of 127.0.0.0/8 is loopback on Linux, which is what lets a second sender take 127.0.0.2 without any setup.

The rule itself is also tested without sockets. protocols/tests/unit/socks/server.rs, mounted from protocols/src/socks/server.rs, calls ExpectedSender::new(peer, declared, hub), admits and pin directly: the first pin holds, an IPv4-mapped peer is the IPv4 one, the declared sources a client may send (a loopback of the other family, a LAN address behind NAT, a port with no address, a domain, another host) are set aside, a Unix-socket request must name a non-zero IP address and port, and a relay bound in a family the client cannot reach is an error, while an unspecified IPv6 hub hears both. endpoint_sees_through_ipv4_mapping_and_ignores_flow_info in protocols/tests/unit/socks/protocol.rs pins the comparison both ends use.

The stream-core TCP pipeline tests push more than one buffer’s worth through the tunnel, so the relay loops, the staging buffer wraps and backpressure engages: 70 000 bytes for Trojan, VLESS, Shadowsocks and Shadowsocks 2022, 100 000 for VMess, HTTP, SOCKS and PassthroughCore. The transport tests echo 200 000 bytes. The Hysteria 2 TCP tests use short messages and check stream sharing instead.

The UDP tests send a short datagram, then a larger one: 1500 bytes for Trojan and VLESS, 1400 for SOCKS, 4000 for VMess, and 4096 for Hysteria 2, which is above any QUIC datagram size on loopback so both directions fragment. The SOCKS, Trojan, VLESS, VMess and Hysteria 2 UDP tests also assert that the first reply is attributed to the echo server’s address, not only that the bytes match.

protocols/tests/support/wireguard.rs runs a complete WireGuard peer inside the test process: a boringtun Tunn bridged to a smoltcp interface that echoes TCP and UDP, plus a TCP sink whose reads the test switches on. No kernel interface, no privilege and no network access are needed.

protocols/tests/support/wireguard.rs
pub const SERVER_TUN_IP: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
pub const CLIENT_TUN_IP: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2);
pub const ECHO_PORT: u16 = 5555;
pub const SINK_PORT: u16 = 5556;
pub async fn spawn_echo_peer(
server_priv: [u8; 32],
client_pub: [u8; 32],
sink_reads: Arc<AtomicBool>,
) -> SocketAddr;
pub struct Peer {
pub endpoint: SocketAddr,
pub client_private: [u8; 32],
pub server_public: [u8; 32],
pub sink_reads: Arc<AtomicBool>,
}
pub async fn spawn_peer() -> Peer;
sequenceDiagram
  participant W as WgConnector under test
  participant U as peer UDP socket
  participant N as boringtun Tunn
  participant D as TestDevice rx and tx queues
  participant S as smoltcp sockets on 10.0.0.1
  W->>U: encrypted packet
  U->>N: decapsulate after zeroing bytes 1 to 3
  N->>D: WriteToTunnelV4 pushes to rx
  D->>S: iface.poll delivers the IP packet
  S->>S: TCP or UDP echo copies data back, the sink answers nothing
  S->>D: emitted packets queue on tx
  D->>N: encapsulate each packet
  N->>U: WriteToNetwork
  U->>W: encrypted reply to the last peer address

The loop in spawn_echo_peer does, in order: iface.poll, service the TCP echo listeners, the TCP sink and the UDP echo, encrypt everything smoltcp emitted, then wait on whichever comes first of a received packet, a 200 ms update_timers tick, or smoltcp’s poll_delay. Details a test author runs into:

Detail Value or behaviour
Keys spawn_peer uses the fixed private keys [0x11; 32] (client) and [0x22; 32] (peer)
Tunnel address The peer is SERVER_TUN_IP/24; the client config uses CLIENT_TUN_IP
Device MTU MTU = 1420
Scratch buffers SCRATCH = 64 KiB for encapsulation and receive
Concurrent TCP connections LISTENERS = 4 echo sockets on ECHO_PORT, each re-armed once its connection closes
TCP sink One socket on SINK_PORT with 64 KiB receive and 64 KiB send buffers. It reads nothing until sink_reads is set, so its receive window closes and the tunnel stops draining a writer to it; once set, it discards whatever arrives. It is re-armed once its connection closes
Sink switch spawn_peer creates sink_reads unset and hands it out as Peer::sink_reads; the peer notices a change within one timer tick (200 ms)
Socket buffers 64 KiB per TCP direction; UDP 16 packets and 64 KiB per direction
Reserved bytes Bytes 1 to 3 of every received packet longer than 3 bytes are zeroed before decapsulation, so a client configured with reserved bytes still completes the handshake
Reply address The peer answers the address of the last packet it received; before the first packet arrives it sends nothing

The pipeline’s WireGuard tests dial this peer. The protocols crate’s test support is not part of its library, so app/tests/integration/e2e_wg.rs carries its own copy of the peer: one TCP listener, no UDP echo, no sink, the same keys, MTU and reserved-byte handling. app_socks_to_wireguard_outbound_tcp spawns the real binary with a SOCKS inbound routed to a WireGuard outbound and echoes through that peer.

a_stalled_tcp_flow_blocks_its_writer uses the sink to pin backpressure through the tunnel. It opens a flow to SINK_PORT and writes 1 KiB chunks, each with a 1 s timeout, until one write has to wait. The sink’s 64 KiB window, the client socket’s 64 KiB, the driver’s 256-chunk channel and the one chunk the driver holds come to 385 KiB, and the test fails if the flow accepts more than 512 KiB before its writer waits. A second flow through the same tunnel must then echo within 10 s. After the test sets sink_reads, the write that was waiting must complete within 10 s, and 1024 more chunks within 20 s. an_oversized_datagram_does_not_wedge_the_association sends a 70 000-byte datagram, larger than the 64 KiB send ring the driver gives an association, then a short one, and expects the short one back within 10 s.

The driver’s uplink queue is also tested without a tunnel, in protocols/tests/unit/wireguard/device.rs (mounted from protocols/src/wireguard/device.rs): a_held_item_keeps_the_channel_unread checks that while the driver holds a chunk the socket has no room for, it does not read the channel behind it, so the producer’s try_send fails with Full; the_uplink_finishes_only_after_its_last_item checks that the uplink reports finished only once everything the application sent has been handed on.

app/tests/integration.rs spawns the real etemenanki-app binary. Cargo builds the binary before an integration target of the same package and exposes its path as CARGO_BIN_EXE_etemenanki-app; the tests never look in target/ themselves.

app/tests/support/mod.rs
pub struct Proc(Child);
impl Drop for Proc { fn drop(&mut self); }
impl Proc {
#[cfg(unix)]
pub fn terminate(mut self);
}
pub fn spawn_app(dir: &Path, config: &str) -> Proc;
pub fn spawn_xray(bin: &Path, dir: &Path, config: &str) -> Proc;
pub fn spawn_hysteria(bin: &Path, dir: &Path, config: &str) -> Proc;
pub fn spawn_hysteria_client(bin: &Path, dir: &Path, config: &str) -> Proc;
pub static XRAY_BIN: LazyLock<Option<PathBuf>>;
pub static HYSTERIA_BIN: LazyLock<Option<PathBuf>>;
pub fn free_port() -> u16;
pub fn free_udp_port() -> u16;
pub async fn wait_port(port: u16) -> io::Result<()>;
pub async fn wait_udp_port(port: u16) -> io::Result<()>;
pub fn test_dir(name: &str) -> PathBuf;
Helper Behaviour
Proc Owns a child process. Drop kills it and waits, so a failed assertion never leaves a listener running into the next test. terminate sends SIGTERM through kill -TERM, waits up to 10 s for exit, and leaves the rest to Drop; use it when what the process does on the way out is under test (e2e_unix checks the socket file is removed, e2e_tun that the routed address stops answering).
spawn_app Runs the binary with -c <config> in dir, stdout and stderr discarded.
test_dir CARGO_TARGET_TMPDIR/<name>, created if missing. Configs, certificates and the upstream binaries go here. Directories are not removed after the run, which leaves the last failing config available for inspection.
free_port Binds TCP port 0 on loopback and returns the port after closing the socket.
free_udp_port The same for UDP. TCP and UDP port spaces are independent, so a QUIC listener must use this one.
wait_port Connects to 127.0.0.1:<port> every 50 ms for up to 20 s.
wait_udp_port Binds the port every 50 ms for up to 20 s and returns once the bind fails with AddrInUse, because a UDP listener cannot be probed by connecting. Any other bind error is returned at once.
socks5_roundtrip, socks5_roundtrip_over, socks5_roundtrip_domain, Socks5Udp A hand-written SOCKS5 client (no authentication, IPv4 or domain targets), independent of the kernel’s own codec. socks5_roundtrip_over runs over an already-open stream such as a Unix socket. Socks5Udp keeps the control connection open for the association’s lifetime and returns each reply with the address the SOCKS header attributes it to.
udp_echo, tagged A UDP echo whose reply is prefixed with the echo server’s own port, so a test can tell which peer received a packet apart from which peer the reply was attributed to.
self_signed_pem, self_signed_pem_san, write_certs, write_certs_san, pinned_hash Certificates. The Xray tests pin the CN-only certificate by SHA-256 (pinnedPeerCertSha256); the Hysteria tests need a SubjectAltName because rustls rejects a CN-only leaf.
real_client_hello A ClientHello produced by OpenSSL for a given SNI, for sniffing tests.
fake_dns A UDP DNS server that answers an A query for one name with 127.0.0.1 and everything else with NXDOMAIN.

The interop tests run the reference implementations, built from the Xray-core and hysteria submodules of the Etemenanki repository. Each binary is built once per test binary, on first use, through a LazyLock:

Static Command Working directory Output
XRAY_BIN go build -o <out> ./main Xray-core CARGO_TARGET_TMPDIR/xray
HYSTERIA_BIN go build -o <out> . hysteria/app (the repository is a Go workspace; main sits at the root of the app module) CARGO_TARGET_TMPDIR/hysteria
flowchart TD
  A["first use of XRAY_BIN or HYSTERIA_BIN"] --> K{"katana only: sibling source dir exists?"}
  K -- no --> S["print SKIP line, value is None"]
  K -- yes --> G{"go version succeeds?"}
  G -- no --> S
  G -- yes --> B["go build -o CARGO_TARGET_TMPDIR/name"]
  B -- fails --> S
  B -- succeeds --> R["Some(path): the test runs"]
  S --> P["each test returns early and passes"]

A test, or the shared helper it calls, starts with let Some(xray) = XRAY_BIN.as_ref() else { return; }; (run_app_client and run_app_server in e2e_xray.rs), or uses ? inside a helper that returns Option (start in e2e_xray_mux.rs). A machine without Go, or with the submodules not checked out, therefore runs the suite green without exercising any interop. In the app’s support, a submodule directory that does not exist at all makes the build return None without printing a SKIP line. go build also needs the Go modules the reference trees depend on, from the module cache or the network, and a failed build is a skip too. Before trusting a green run, install Go and run git submodule update --init in the Etemenanki repository.

Module Upstream Proves
e2e_xray Xray VLESS over WebSocket and gRPC, each plain and with TLS, and over TCP with TLS, in both roles (app as client, app as server)
e2e_xray_vmess Xray VMess over gRPC with TLS (app as server), and WebSocket early data without TLS in both roles
e2e_xray_mux Xray mux.cool against VLESS, VMess and Trojan servers, concurrent sub-streams, XUDP, reply attribution
e2e_hysteria Hysteria server This kernel’s Hysteria 2 client: several proxy streams after one authentication, a private CA, a refused password, half-close, Salamander, UDP fragmentation, and the app’s SOCKS entry into a hysteria2 outbound
e2e_hysteria_inbound Hysteria client This kernel’s Hysteria 2 inbound: TCP and UDP, large datagrams fragmented both ways, obfuscation, username and password credentials, a wrong credential, a reload that rebinds the UDP port
e2e_sniff Xray client A sniffed HTTP Host or TLS SNI routes an IP-addressed flow by a domain rule, and turning sniffing off stops it
e2e_udp_route, e2e_route_context, e2e_dns, e2e_balancer, e2e_unix none Routing per UDP packet, inbound_tag, source_cidr and network matchers, the DNS backend, balancer failover, Unix-socket listeners; the app’s own SOCKS inbound produces the traffic, so these run without Go
e2e_wg in-process peer The WireGuard outbound through the real binary
e2e_tun kernel TUN device A tun inbound answers a routed connect, and the route disappears with the process
sequenceDiagram
  participant T as Test
  participant X as xray server
  participant A as etemenanki-app
  participant E as tcp_echo
  T->>T: XRAY_BIN built on first use
  T->>X: spawn_xray with xray.json in test_dir
  T->>A: spawn_app with app.toml
  T->>X: wait_port(xray_port)
  T->>A: wait_port(socks_port)
  T->>A: SOCKS5 CONNECT to echo, then payload
  A->>X: VLESS over ws, gRPC or TLS
  X->>E: freedom
  E-->>T: echo returns along the same path
  T->>T: assert exact bytes within 15 s

e2e_tun compiles only on Linux (#![cfg(all(unix, target_os = "linux"))]), and its one test, a_routed_connect_is_answered_while_the_app_runs, needs CAP_NET_ADMIN. Its can_create_a_device opens a TUN device itself first; on PermissionDenied it prints SKIP: cannot create a tun device: … and returns, and on any other error it panics. The module comment gives the command to run it with privilege: sudo -E cargo test -p etemenanki-app --test integration e2e_tun.

e2e_wg::wireguard_outbound_live_env_tcp is #[ignore] and talks to a real WireGuard peer over the network. It reads ETEMENANKI_WG_PRIVATE_KEY, ETEMENANKI_WG_PEER_PUBLIC_KEY, ETEMENANKI_WG_ENDPOINT and ETEMENANKI_WG_ADDRESS (required), and ETEMENANKI_WG_MTU, ETEMENANKI_WG_KEEPALIVE, ETEMENANKI_WG_RESERVED (exactly three comma-separated bytes), ETEMENANKI_WG_TEST_TARGET (an IPv4 socket address) and ETEMENANKI_WG_TEST_HOST (optional). A missing required variable panics with missing <name>; run this ignored test with real WG settings. It passes when an HTTP request through the tunnel gets a response starting with HTTP/ within 20 s.

katana is a binary crate. All of its unit tests, including the in-process end-to-end suite, compile into the katana binary’s test harness; the integration target spawns the built binary as CARGO_BIN_EXE_katana. katana’s dev-dependencies enable tokio’s test-util and a vendored openssl for certificate generation.

File Mounted in Covers
tests/unit/api/newv2board.rs, tests/unit/api/sspanel.rs src/api/… Panel response parsing and push body shape; for newV2board, a rebuilt client keeping the block routes of the client it replaces until it reads the node config itself (inherit_routes)
tests/unit/connector.rs src/connector.rs Admission by user and uid, audit blocks, billing of streams and UDP, the lease reaching the connection
tests/unit/inbound.rs, tests/unit/outbound.rs src/inbound.rs, src/outbound/mod.rs Building nodes and outbounds from panel data and config, and the refusals
tests/unit/meter.rs src/meter.rs Metered billing and rate limiting per direction, retirement
tests/unit/rule.rs src/rule.rs Audit rule updates and records
tests/unit/runtime.rs src/runtime.rs Reload identity and apply_reload decisions against running nodes (see Reload tests)
tests/unit/serve.rs src/serve.rs The per-connection driver’s deadlines and retirement
tests/unit/traffic.rs src/traffic.rs Counters, residuals, rate changes, the token bucket

The rate-limit and deadline tests run on #[tokio::test(start_paused = true)]; the token-bucket tests call TokenBucket directly, and the meter and serve tests run over tokio::io::duplex. With the clock paused, tokio advances time to the next timer whenever the runtime has nothing else to do, so a test asserts durations without waiting for them:

Test File Pins
a_chunk_larger_than_the_burst_is_still_limited tests/unit/traffic.rs 30 000 bytes against a 10 000 B/s bucket take exactly 2 s: the part above the burst becomes debt
debt_holds_back_the_next_charge_too tests/unit/traffic.rs charge(25_000) repays in exactly 1500 ms, and the next caller waits it out
an_idle_bucket_banks_one_second_and_no_more tests/unit/traffic.rs After 60 s idle, the burst is still one second of rate
the_limit_holds_however_the_writes_are_sized tests/unit/meter.rs A 30 000-byte write through Metered delays the next write by at least 2 s
the_limit_is_shared_by_both_directions tests/unit/meter.rs A 20 000-byte upload against a 10 000 B/s bucket delays the next download by at least 1 s
a_silent_client_is_dropped_at_the_handshake_deadline tests/unit/serve.rs drive ends with Ended::Handshake after exactly HANDSHAKE_TIMEOUT (10 s)
a_connection_that_stops_moving_is_dropped tests/unit/serve.rs A client that stops reading is dropped with Ended::Relay (“nothing moved”) no earlier than PROGRESS_WATCHDOG, which is RELAY_IDLE_TIMEOUT plus 60 s (360 s)

The same technique pins the kernel’s deadlines: deadline_event_lets_the_core_time_out and deadline_is_armed_against_the_tokio_clock in concepts/tests/runtime.rs, and the gRPC liveness and accept tests in protocols/tests/unit/transports/. The second of those exists because a deadline armed from std::time::Instant ignores the paused clock; the test advances tokio’s clock by an hour first and checks that a 5 s idle deadline neither fires early nor never.

tests/unit/e2e.rs builds a node the way the runtime does, minus the process: a PanelClient::NewV2board pointed at a fake panel, a NodeManager, and a static-update channel the test holds. NodeManager::run first brings the node up (panel config, users, bind), retrying until it is up or its token is cancelled, then serves.

src/manager/node.rs
impl NodeManager {
pub fn new(
api: PanelClient,
cfg: NodeConfig,
pool: Arc<HashMap<CompactString, Arc<Outbound>>>,
) -> io::Result<Arc<Self>>;
pub async fn run(
self: Arc<Self>,
shutdown: CancellationToken,
mut static_rx: mpsc::Receiver<StaticUpdate>,
);
}

The fake panel is an HTTP/1.1 server on 127.0.0.1:0 written in the test. read_request parses each request into a Request { path, if_none_match, body }, and the panel matches on the path: a path containing /UniProxy/config gets the node config body, /UniProxy/user the current user body, and /UniProxy/push has its body captured and gets {}. Any other path gets {}. The constructors build on each other:

Constructor Adds
fake_panel(node_port, uuid) One user, uid 1001, and a plain-TCP V2ray node on node_port
fake_panel_dynamic(node_port, user_body) Serves the user body from an Arc<Mutex<String>>, so a test can change the user set between phases
fake_panel_with_config(config_body, user_body) Also takes the node config body
fake_panel_quirky(config_body, user_body, quirks) Behaves like a real panel on a bad day, set by Quirks, and also returns an Arc<AtomicUsize> counting node config requests
fake_sspanel(node_port, uuid) A separate mod_mu fake for SSpanel: one V2ray node described by the legacy server string, whose VLESS flag comes from the client’s own config, and one user, uid 1001

Quirks has two fields. config_failures answers the first N node config requests with 500 Internal Server Error: a panel that is not up yet when the node starts. etags tags every config and user answer with an ETag and answers a request whose If-None-Match matches it with 304 Not Modified, as Xboard does. wait_config_requests(requests, n) waits until the panel has been asked for the node config n times.

The default config body is {"server_port":…,"network":"tcp","tls":0}: the V2ray nodes in this suite run plain TCP only, and the transports are left to the integration tests. The Hysteria 2 tests set [node.hysteria].port locally, which makes the node skip the panel’s node config (users still come from the panel), except a_panel_described_hysteria_node_serves_obfuscated_traffic, which leaves the port at 0 and serves its own body through fake_panel_with_config.

test_node_cfg sets update_periodic = 1, so the node polls the panel and pushes traffic every second. Clients are the kernel’s own: a VMess or VLESS ProxyClientRuntime over TCP (vmess_client, vless_client), and for Hysteria 2 nodes Hy2Conn::connect with VerifyMode::Insecure.

The fixtures that tests/unit/runtime.rs shares are pub(crate):

Helper Behaviour
roundtrip(reader, writer, payload, limit) Writes payload on an open connection and expects the exact echo within limit
vmess_roundtrip, vless_roundtrip The same on a fresh VMess or VLESS connection
wait_relays(node, uuid, echo) Retries a 2 s vmess_roundtrip every 100 ms until the node relays, and panics after 10 s: a node still coming up refuses or drops connections until it is up
tcp_echo, fake_panel, fake_sspanel, test_node_cfg, build_test_pool, wait_bound The echo target, the panels, a node config for a panel address, the direct/block outbound pool, and waiting for a TCP bind

SPARE_LOOPBACK is 127.0.0.2. Every other listener in the suite is on 127.0.0.1, so a port on the spare address that a test releases can only be taken by the node meant to take it, whatever range the host hands out port 0 from.

Test Pins
vmess_traffic_is_metered_and_reported A 50 000-byte echo relays exactly, and the panel’s push bodies attribute at least that many bytes each way to uid 1001
unchanged_user_survives_user_refresh Adding a user leaves a live connection of an unchanged user working; removing that user drops it; its traffic from both phases is reported
proxy_outbound_relays_and_meters A node whose default outbound is a VLESS client to an in-test VLESS server relays and meters
route_change_drops_connections A StaticUpdate::Config with a different [node.route] drops the open connection
a_hysteria_node_relays_and_meters A Hysteria 2 node relays and bills through the connector, which is the only path a Hysteria flow shares with stream nodes
a_hysteria_node_refuses_an_unknown_credential A credential the panel never issued does not proxy
a_retired_user_stops_while_the_rest_keep_their_connections Retiring user B cuts B off while A’s existing QUIC connection keeps working
a_panel_described_hysteria_node_serves_obfuscated_traffic Port and Salamander settings taken from the panel’s node config reach the listener
repeated_user_refreshes_never_disturb_a_live_connection Six cycles of 1.1 s, each flipping the user set, leave one QUIC connection alive and relaying after every cycle
a_node_comes_up_once_the_panel_answers With config_failures: 2, the node keeps asking, binds only after at least three config requests, and relays
a_node_whose_port_is_taken_comes_up_once_it_is_free A listener holds the node’s port on SPARE_LOOPBACK against a panel with etags on. After the node has asked for its config twice, the test releases the port, and the node comes up: each attempt forgets the ETags the last one received, so the panel answers in full rather than with 304 Not Modified
a_node_that_never_bootstraps_still_stops With every config request failing, a cancelled node task ends within 2 s instead of retrying on

These tests use the real clock and poll for results (for example 150 checks 100 ms apart for reported totals), because the node’s own timers are what is under test.

tests/unit/runtime.rs is mounted from src/runtime.rs, so it calls the private identity, display_id, spawn_node and apply_reload directly. Its Runtime struct holds what run holds for its nodes (cfg, pool, root and the node handles) around one node:

Method Behaviour
start(node_cfg) Spawns one node through spawn_node with the build_test_pool pool, and records a Config with that node
reload(new) Calls apply_reload with the new Config and a log reload that does nothing
stop() Cancels the root token and awaits every node task

A test tells a respawn from an in-place reconfiguration by comparing the node handle’s static_tx with a clone taken before the reload (same_channel). The first three tests only compare identities and need no node.

Test Pins
an_sspanel_node_is_its_panel_node_id For SSpanel, only panel_type, api.host, api.node_id and api.key make an entry another node. enable_vless, node_type, vless_flow, speed_limit, rule_list_path, disable_custom_config, device_limit, timeout and the case of panel_type leave it the same node
a_newv2board_node_is_also_the_type_it_asks_for For NewV2board, the node type it asks the panel for is part of the identity: node_type and, on a V2ray node, enable_vless make it another node. The case of node_type, speed_limit, rule_list_path and timeout do not, and enable_vless on a Trojan node does not
a_node_is_logged_by_its_panel_node_not_its_key display_id renders <panel_type>@<host>#<node_id>/<type> for NewV2board, where <type> is the node type the node asks the panel for, and <panel_type>@<host>#<node_id> for SSpanel, with the panel type and node type lower-cased, and never the key
a_newv2board_type_edit_respawns_the_node Turning enable_vless off on a NewV2board node respawns it; the new node serves VMess and refuses VLESS
a_reload_with_a_node_that_does_not_build_changes_nothing A reload with a node_type typo and a route edit in the same entry keeps the running node, records neither edit, and leaves a live connection relaying
an_sspanel_api_edit_takes_effect_in_place Against fake_sspanel, turning enable_vless off reconfigures the node in place; the node rebuilds its panel client, serves VMess, drops the open VLESS connection and refuses new ones
a_client_edit_takes_effect_without_dropping_connections Setting rule_list_path to a file that forbids the echo target reconfigures in place: new flows there are refused within 10 s, and a connection already open keeps relaying

tests/support/mod.rs repeats the core of the app’s support (Proc without terminate, spawn_xray, spawn_hysteria_client, free_port, free_udp_port, wait_port, test_dir, socks5_roundtrip, certificates and pinning) and adds a reusable panel:

tests/support/mod.rs
pub struct FakePanel {
pub addr: SocketAddr,
/* private: pushes */
}
impl FakePanel {
pub async fn spawn(config_body: String, user_body: String) -> Self;
pub fn reported_totals(&self, uid: i64) -> (i64, i64);
pub async fn wait_for_traffic(&self, uid: i64, timeout: Duration) -> (i64, i64);
}
pub fn spawn_katana(dir: &Path, config: &str) -> Proc;
pub fn users_body(users: &[(i64, &str)]) -> String;
pub fn v2ray_config_body(port: u16, network: &str, tls: bool, vless: bool) -> String;
pub fn hysteria_config_body(port: u16) -> String;
pub fn trojan_config_body(port: u16) -> String;
pub async fn wait_udp_bound(port: u16);

FakePanel serves the same three UniProxy paths as the in-process fake, with fixed bodies. wait_for_traffic returns as soon as either total for the uid is above 0, or with whatever it has when the timeout elapses; the caller asserts on the result.

v2ray_config_body writes the transport settings under network_settings for VLESS and networkSettings for VMess, because that is where katana’s parser reads each. The Xray and Hysteria builds point at the sibling Etemenanki checkout (../Etemenanki/Xray-core, ../Etemenanki/hysteria), derived from CARGO_MANIFEST_DIR; a standalone katana clone prints SKIP: … not found and passes.

sequenceDiagram
  participant T as Test
  participant P as FakePanel
  participant K as katana binary
  participant X as xray client
  participant E as tcp_echo
  T->>P: FakePanel::spawn(config body, users body)
  T->>K: spawn_katana with katana.toml
  K->>P: request node config and users
  K->>K: bind the node port
  T->>K: wait_port(node_port)
  T->>X: spawn_xray, wait_port(socks_port)
  T->>X: socks5_roundtrip(echo, payload)
  X->>K: VMess, VLESS or Trojan over the chosen transport
  K->>E: direct outbound
  E-->>T: echoed bytes
  K->>P: push traffic every update_periodic of 1 s
  T->>P: wait_for_traffic(UID, 15 s)
  T->>T: assert up and down are both above 0
Module Tests
xray_interop The matrix vmess_tcp_plain, vmess_ws_plain, vmess_grpc_plain, vmess_tcp_tls, vless_ws_tls, vless_grpc_tls, trojan_tcp_tls, and with Xray’s mux enabled vmess_tcp_plain_mux, vless_ws_tls_mux, trojan_tcp_tls_mux. Each asserts the exact echo and that the panel received traffic for uid 1001. TLS cells verify katana’s self-signed leaf by pin, not by disabling verification.
sniff a_sniffed_host_reaches_a_domain_rule and disable_sniffing_stops_the_domain_rule_matching: a node whose route drops everything except a domain rule.
hysteria_interop a_real_client_proxies_through_a_katana_hysteria_node: the official client, driven through its SOCKS5 front end, authenticates with the bare UUID and relays, then relays a second time through the same client, which rides a new stream of the same QUIC connection. It does not assert the traffic report.

katana takes the kernel crates from a private Cargo registry at the version in its Cargo.lock. Its tests therefore exercise the published kernel, not the Etemenanki working tree: a kernel change reaches katana’s suite only after it is released and katana’s lockfile is bumped.

Invariant Mechanism Where
No test needs internet access, except one ignored test (building the upstream binaries may fetch Go modules) Every peer and destination is an in-test server, a child process listening on loopback, or the in-process WireGuard peer All suites; the exception is wireguard_outbound_live_env_tcp
Interop tests never fail for lack of Go, a submodule or privilege XRAY_BIN/HYSTERIA_BIN are None and each test returns early; can_create_a_device returns false on PermissionDenied app/tests/support/mod.rs, katana/tests/support/mod.rs, e2e_tun.rs
A failing test leaves no process behind Proc kills and reaps its child in Drop Both support modules
Minutes-long deadlines are tested without waiting for them start_paused = true; the kernel arms deadlines on tokio’s clock deadline_is_armed_against_the_tokio_clock, a_connection_that_stops_moving_is_dropped
A buggy core or codec fails a runtime test instead of hanging it Contract violations surface as RuntimeError or io::Error; the tests bound the wait with tokio::time::timeout a_held_range_past_the_buffer_is_rejected, handshake_step_without_progress_is_rejected, zero_length_frame_is_rejected
The two halves of a protocol are tested against each other and, where one exists, against an independent implementation Pipeline tests, then Xray and Hysteria interop protocols/tests/pipeline/, app/tests/integration/, katana/tests/integration/
Terminal window
# Everything the gate runs (protocols gets the app's hysteria and tun features)
cargo test --workspace
# One crate's unit tests only
cargo test -p etemenanki-protocols --lib
cargo test -p etemenanki-app --bin etemenanki-app
# One protocol's unit tests (module path filter)
cargo test -p etemenanki-protocols --lib trojan::
# The runtime and client-runtime tests
cargo test -p etemenanki-concepts --test runtime
cargo test -p etemenanki-concepts --test client
# One protocol's pipeline tests
cargo test -p etemenanki-protocols --test pipeline pipeline::trojan
cargo test -p etemenanki-protocols --features hysteria --test pipeline pipeline::hysteria
cargo test -p etemenanki-protocols --features tun --test pipeline pipeline::tun
# Dialers, including QUIC, which no workspace member enables
cargo test -p etemenanki-environment --features quic --test integration
# App end-to-end, one module, with SKIP lines visible
cargo test -p etemenanki-app --test integration e2e_xray -- --nocapture
# The privileged and the live tests
sudo -E cargo test -p etemenanki-app --test integration e2e_tun
cargo test -p etemenanki-app --test integration wireguard_outbound_live_env_tcp -- --ignored

A filter matches a substring of the full test path. The paths follow the module layout:

Kind Example path
Mounted unit test trojan::core::tests::header_split_across_events_opens_once_complete
Pipeline test pipeline::trojan::new_server_vs_new_client_tcp
App integration test e2e_xray::app_client_ws_xray_server_plain
katana in-process e2e e2e::vmess_traffic_is_metered_and_reported
katana reload test runtime::tests::a_newv2board_type_edit_respawns_the_node
katana integration test xray_interop::vmess_ws_plain

A change lands only after the full gate passes in every repository it touches. The CI workflows build release binaries and run none of these, so the gates are run locally.

Terminal window
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
# before a release, additionally:
cargo check --workspace --locked

--all-targets in the clippy gate lints the integration test crates too, and --all-features turns on quic, so the QUIC dialer tests are at least compiled and linted even though cargo test --workspace does not run them. The panicking-form lints are clippy lints: cargo test compiles an unwrap() in library code without complaint, and only the clippy gate rejects it. Protocol, transport, WireGuard, SOCKS and TLS changes should also run the interop suites with Go present and --nocapture, and confirm no SKIP line appears.

Constant or value Where Value
HARNESS_STAGING protocols/src/core/harness.rs 64 KiB staging per CoreHarness
MTU, SCRATCH, LISTENERS protocols/tests/support/wireguard.rs 1420, 64 KiB, 4
ECHO_PORT, SINK_PORT same 5555 (TCP and UDP echo), 5556 (TCP sink)
Stalled-flow bound protocols/tests/pipeline/wireguard.rs At most 512 KiB accepted before a write to the stalled sink waits longer than 1 s
WireGuard peer timer same update_timers every 200 ms
wait_port app and katana support 20 s, polling every 50 ms
wait_udp_port app/tests/support/mod.rs 20 s, polling every 50 ms
wait_udp_bound katana/tests/support/mod.rs 400 tries 50 ms apart (20 s), then panics
wait_bound, wait_udp_bound katana/tests/unit/e2e.rs 200 tries 50 ms apart (10 s), then panics
wait_config_requests katana/tests/unit/e2e.rs 200 tries 50 ms apart (10 s), then panics
wait_relays katana/tests/unit/e2e.rs 10 s deadline, 2 s per round trip, 100 ms between tries, then panics
roundtrip, vmess_roundtrip, vless_roundtrip katana/tests/unit/e2e.rs, katana/tests/unit/runtime.rs The caller’s limit: 10 s where the echo must arrive, 2 s where it must fail
Proc::terminate app/tests/support/mod.rs 10 s after SIGTERM, then SIGKILL from Drop
Round-trip timeouts e2e_xray, e2e_xray_vmess, e2e_xray_mux, e2e_wg 15 s per SOCKS round trip; some e2e_xray_mux cases allow 20 s or 30 s
FakePanel::wait_for_traffic katana integration Polls every 100 ms up to the caller’s timeout (15 s in xray_interop)
update_periodic in test configs katana e2e and integration 1 s
Contract-violation reads concepts/tests/client.rs 1 s timeout; hitting it means the runtime spun or hung
  1. Wire format. In protocols/tests/unit/<proto>/protocol.rs, mounted from src/<proto>/protocol.rs: encode and decode round trips, and one negative test per fixed field (version, reserved bytes, length, command or type) proving a wrong value is an error rather than a silently accepted header. Truncated input must ask for more, and a length beyond the protocol’s limit must fail. Test only what the parser does; do not assume the peer is a correct implementation.

  2. Client codec. In tests/unit/<proto>/codec.rs: seal and open with no I/O, a frame split across calls returning Opened::NeedMore, and finish. The codec_is_driven_without_any_io test in concepts/tests/client.rs is the template.

  3. Server core. In tests/unit/<proto>/core.rs, through CoreHarness: a header split across events opens only once complete; a wrong credential fails with PermissionDenied; an IP target with sniffing on holds the prefix and opens on a host or on SNIFF_TIMEOUT; a domain target is never sniffed; the relay half-closes each way and finishes on the second; ConnectFailed ends the connection the way the protocol requires; Event::Deadline before establishment fails with the handshake timeout; for UDP, a partial packet frame waits for the rest. protocols/tests/unit/trojan/core.rs has one test for each.

  4. Pipeline. A new protocols/tests/pipeline/<proto>.rs, declared in tests/pipeline.rs (behind a #[cfg(feature = …)] if the protocol is feature-gated). At least new_server_vs_new_client_tcp with a payload larger than the core’s BUF_SIZE followed by shutdown and an empty read_to_end, and new_server_vs_new_client_udp with a 1500-byte datagram and an assertion on the reply’s source. A UDP relay also needs a negative test that a datagram from a source outside the association is not relayed, as the SOCKS association tests do with recording_echo. Use serve_runtime::<{ YourCore::<()>::BUF_SIZE }, _, _> rather than a hand-picked size.

  5. App. Unit tests next to the builder (app/tests/unit/inbound.rs, outbound.rs, transport.rs) that a minimal config builds, that every required setting is enforced, that an unknown setting is refused, and that an unsupported transport or security combination fails closed with a message naming the fix. Assert on a distinctive part of the error text.

  6. Interop. If Xray or Hysteria implements the protocol, an e2e_* test in each direction the app supports, driven through socks5_roundtrip and asserting the exact echo. If mux.cool or XUDP applies, add a cell to e2e_xray_mux.

  7. katana, if it serves the protocol. Node-build tests in katana/tests/unit/inbound.rs, a cell in the xray_interop matrix that also asserts the traffic report, and, for a new credential shape, an admission test in tests/unit/connector.rs.

  8. Gates. The full gate with Go installed and --nocapture on the interop modules, checking that nothing printed SKIP.

When you change the runtime or the event contract

Section titled “When you change the runtime or the event contract”
  • Pin the behaviour with a toy core in concepts/tests/runtime.rs, or a toy codec in concepts/tests/client.rs, not with a real protocol. Extend TinyMux or Scripted when an existing toy can express the case; add a new toy when it cannot, and document its wire format in a doc comment.
  • Backpressure changes need a test where one side stalls: a small far-end pipe, as in stalled_outbound_holds_uplink_but_not_other_downlink, or a small upstream, as in backpressure_from_the_wire_reaches_the_writer. Assert both that the stalled direction waits and that unrelated directions keep moving. Any new queue or buffer on the path needs a cap and a test in which a blocked consumer stops the producer, as a_stalled_tcp_flow_blocks_its_writer and a_held_item_keeps_the_channel_unread do for the WireGuard driver.
  • Deadline changes need a start_paused test over duplex. Keep real sockets out of paused tests: tokio auto-advances a paused clock whenever the runtime has no work, including while a task waits on a real socket, so timers can fire before the socket delivers.
  • A new RuntimeError variant or a new contract check needs a test that provokes it and bounds the wait with tokio::time::timeout, so a regression hangs for a second instead of forever.
  • Datagram paths need the truncation and refusal cases: datagram_outbound_is_never_truncated_by_staging_backpressure, a_refused_datagram_send_keeps_the_key_alive, a_refused_transport_packet_is_reported_not_fatal.
  • Then run the whole pipeline target with --features hysteria,tun and the app’s e2e modules: every inbound except SOCKS runs inside the server runtime.
  • Rate limiting and metering: a start_paused test that asserts exact elapsed time, with a chunk larger than the bucket. The bucket must hold the rate however the bytes are chunked.
  • Accounting: tests in tests/unit/traffic.rs for a failed report (counters and residuals retained), a retried report (no double count), a user removed and re-added, and increments that land between snapshot and commit (commit_reported_preserves_concurrent).
  • Node lifecycle and user sync: an in-process test in tests/unit/e2e.rs using fake_panel_dynamic to change the user set while a connection is open.
  • Bootstrap, retries and conditional requests: an in-process test against fake_panel_quirky, with Quirks { config_failures } for a panel that is not up yet and Quirks { etags } for one that answers 304 Not Modified, using wait_config_requests to know how far the node got.
  • Reload and node identity: a test in tests/unit/runtime.rs through the Runtime harness. An identity change needs an identity assertion and a respawn test; any other edit needs a test that it takes effect in the running node, and that it keeps open connections when nothing a listener is built from changed.
  • Logging of credentials: when a test feeds a malformed credential, assert the error does not echo it, as a_malformed_uuid_is_refused_without_echoing_it in tests/unit/outbound.rs does.
  • A new panel field or node type: a parsing test in tests/unit/api/, and a config body helper in tests/support/mod.rs if the integration tests need it.