PacketRelayDocs

Build & test#

PacketRelay builds with one command and is gated by three. The toolchain is pinned to an exact Rust release, every test lives inside src/, and CI runs the same three commands a contributor runs locally. This page covers the pinned toolchain, the release profile, the gate ladder in the order to run it, what each of the 53 tests covers, what CI enforces, and what the suite deliberately does not reach.

The toolchain is pinned, not floated#

rust-toolchain.toml:2-4 pins channel = "1.83.0" with the clippy and rustfmt components and the minimal profile. That is an exact version, not a channel: a contributor on stable gets 1.83.0 inside this directory regardless of what their default toolchain is, because a clippy lint introduced in a later release would otherwise fail a build that the author never saw locally.

Cargo.toml:5 declares rust-version = "1.83" separately. The two serve different purposes — the toolchain file decides what compiler runs, the manifest field decides what Cargo refuses to build with.

Two dependencies are exact-pinned rather than caret-ranged: clap = "=4.5.60" (Cargo.toml:18) and time = "=0.3.36" (Cargo.toml:26). A cargo update cannot drift either. Everything else takes a caret range, and Cargo.lock is tracked — one of the twenty files in the repository — so the resolved graph is identical on every machine.

There is no rustfmt.toml, no clippy.toml and no .cargo/config.toml. The formatting and lint configuration is whatever 1.83.0 ships by default, which is why the exact pin matters more here than in a project that fixes its lint set explicitly.

The release profile#

Setting Value Effect
lto "thin" Cross-crate inlining at link time. Slower link, smaller and faster binary.
codegen-units 1 One codegen unit, so the optimiser sees the whole crate. Removes build parallelism.
strip "symbols" The shipped binary carries no symbol table, so a backtrace from a deployed process is not symbolised.
panic "abort" No unwinding. A panic in any task terminates the process rather than being caught.

All four are set on [profile.release] only — Cargo.toml:33-37. cargo test builds the test profile, so the suite runs with unwinding and symbols intact and a #[should_panic] test would still work. The abort behaviour applies to the Docker image and to any cargo build --release, and nowhere else.

Note

panic = "abort" combined with the unconditional accept loop (main.rs:357-381) means the relay has exactly one shutdown mode in production: the process dies. There is no signal handling and no drain. The restart policy in railway.json is what brings it back.

The gate ladder#

Run these in this order. Each one is cheaper than the one after it, so a failure surfaces before you have paid for a full compile.

cargo fmt --all -- --check                          # formatting only, no build
cargo clippy --locked --all-targets -- -D warnings  # compiles lib + tests + bins
cargo test --locked                                 # the 53 tests

Two optional steps confirm the binary itself, and neither is run by CI:

cargo run --locked -- --help        # clap renders every flag and default
cargo run --locked -- --port 8080   # bind 0.0.0.0:8080 and accept

--all-targets on the clippy step is load-bearing. It compiles the #[cfg(test)] modules as well as the binary, so a clippy warning inside a test module fails the gate — without it, half the crate's code would never be linted, since the test modules make up a large fraction of every file. -D warnings promotes every warning to an error, so there is no warning backlog to accumulate.

--locked appears on the clippy and test steps and on the Docker build (Dockerfile:9). It makes Cargo refuse to modify Cargo.lock, so adding a dependency and forgetting to commit the updated lockfile fails the gate rather than silently resolving a different graph in CI than the one you tested against.

The crate carries exactly one lint suppression: #[allow(clippy::enum_variant_names)] on RelayHello (protocol.rs:79), kept because every variant is named …Hello on purpose — the Rust names are held aligned with the public wire names, and renaming them to satisfy the lint would break that correspondence.

Every test is inside src/#

There is no tests/ directory. All 53 tests live in six #[cfg(test)] modules, one at the bottom of each source file.

This is not an oversight. Cargo.toml:12-14 declares a single [[bin]] target and no library target, so a file under tests/ would have nothing to use — the modules are declared in main.rs:7-11 and are private to the binary crate. Keeping the tests in-file is what makes private items reachable: is_valid_member_id and is_relay_keepalive_ping (connection.rs:472-488) are not pub, and the four tests in member_id_tests are the only thing that exercises them.

The cost is real and worth stating. Nothing tests the crate the way a client sees it, and the only way to reach a socket is for a test to bind an ephemeral listener itself — which four of the nine tests in args_tests do.

File Module Tests Covers
main.rs:502 args_tests 9 CLI defaults and validation; the legacy capacity semaphore; the 64 KiB message ceiling; request-head classification; real-listener behaviour
protocol.rs:158 wire_conformance_tests 3 session_ready and room_ready against the testdata/ fixtures; the member_joined / member_left / member_count tag shapes
connection.rs:585 member_id_tests 4 Session- and member-id charset, length and emptiness; exact keepalive-envelope recognition
rate_limit.rs:233 tests 6 Per-IP and global connection caps; rejected attempts counting toward the rate; state pruning on disconnect and by the reaper; message-count and byte budgets; window reset
room_auth.rs:352 tests 20 The whole signed-room_hello trust model: nonce window, TOFU pinning, squatting, forgery, replay, fingerprint binding, the verify/commit split, the golden vector
session.rs:490 room_tests 11 Room registration and roster; fan-out target selection; reconnect replacement; ownership-aware cleanup across all three session kinds; member, spectator and session caps; reaping

What the listener tests actually reach#

Four tests in args_tests bind 127.0.0.1:0 and drive a real socket. health_response_uses_a_real_ephemeral_listener (main.rs:562) takes a /healthz probe to a 200. The test named fragmented_request_head_is_classified_only_after_the_exact_target_arrives (main.rs:587) splits the request head across writes. slow_request_head_does_not_block_a_second_connection (main.rs:637) proves that head parsing runs in the per-connection task rather than the accept loop — a silent peer must not be able to serialise accepts. classified_listener_preserves_the_legacy_protocol (main.rs:670) is the only end-to-end WebSocket test, and it goes exactly as far as desktop_hellosession_ready.

reserved_routes_require_exact_paths_without_queries (main.rs:608) is a pure classification test and pins the route table including the two easy-to-break cases: /health?detail=1 is a Reject, and /v1/product-route is Legacy now that the product route has been Removed.

The two large modules#

room_auth.rs carries 20 of the 53 tests because it is the only part of the relay that authenticates anything. The suite covers each rejection reason separately — bad signature, a presented pubkey that is not the signing key (presented_pubkey_not_signing_key_rejected, room_auth.rs:491), a squatter with a different key, a replayed identical nonce, an older nonce, and a fingerprinted member_id presented by the wrong key before any pin exists. golden_signed_hello_payload_vector (room_auth.rs:807) asserts one literal string and is the cross-repository conformance anchor for four clients that live elsewhere.

session.rs carries 11, and three of them exist only to prove a negative: unregister_member_after_reconnect_is_noop (session.rs:587), stale_bridge_cleanup_does_not_remove_reconnected_desktop (session.rs:618) and stale_broadcast_cleanup_does_not_remove_reconnected_host (session.rs:650) each register a connection, replace it, then run the first connection's cleanup and assert nothing happened. session_cap_is_atomic_under_concurrent_creation (session.rs:750) spawns 32 concurrent creators against a cap of 4 and asserts exactly 4 created and 28 rejected.

The wire fixtures are compiled in#

protocol.rs:161-169 pulls both testdata/ files into the binary with include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/…")). The fixtures are shared JSON: the same bytes the clients deserialise, checked against what RelayResponse serialises.

Warning

Because include_str! runs at compile time, a build tree without testdata/ fails to compile — not to test. This is why Dockerfile:7 copies testdata/ alongside src/. Trimming that line to slim the build context breaks cargo build --locked --release with a macro error that names a path, not a missing test.

What CI enforces#

.github/workflows/ci.yml defines one job, test, on ubuntu-latest, running on every push and every pull_request, with permissions: contents: read. The steps are actions/checkout@v4, dtolnay/rust-toolchain@1.83.0 with clippy,rustfmt, Swatinem/rust-cache@v2, then the same three commands as the local ladder, in the same order: the format check, the clippy gate with --all-targets and -D warnings, and cargo test --locked.

The toolchain version is therefore declared twice: in rust-toolchain.toml:3 and in the workflow's action tag. They agree today at 1.83.0. Bumping one without the other splits local from CI, and the split is silent until a version-sensitive lint fires on one side only.

What CI does not do, all of it deliberate absence rather than oversight:

Not run in CI Consequence
cargo build --release The release profile — thin LTO, one codegen unit, panic = "abort" — is only exercised by the Docker build.
docker build A change that breaks the image, such as dropping the testdata/ copy, is not caught until deploy.
Dependency audit or licence check Flagged as open work at BACKLOG.md:187-194.
A platform matrix Ubuntu only. macOS and Windows builds are unproven.
Action pinning by digest checkout@v4 and rust-cache@v2 are movable major tags — same backlog item.

Coverage gaps#

The suite pins the store, the limiter and the crypto thoroughly and the wire thinly. BACKLOG.md:101-111 records the gap in the repository's own words, and the specifics are these:

  • No end-to-end listener test for forwarding. Bridge peer-to-peer delivery, broadcast host fan-out and room member_frame fan-out are all tested at the SessionStore level (session.rs:490) and never through a socket. The targeting logic is proven; the loop in connection.rs:333-409 that calls it is not.
  • No hello-timeout test. read_hello wraps the stream in tokio::time::timeout (connection.rs:499) and the expiry branch logs and drops (connection.rs:542-544). The request-head timeout on the same deadline is covered; the application-hello one is not.
  • No ping/pong test. The relay never sends a WebSocket Ping and echoes Pong on receipt (connection.rs:397-399). Nothing exercises that path. The application-level {"type":"ping"} envelope is a different thing and is covered, by keepalive_detection_requires_exact_envelope (connection.rs:610).
  • No reconnect race through sockets. The three ownership-aware cleanup tests construct the race directly against the store. No test opens two connections for the same identity and lets the operating system order them.
  • No accounting test. MessageBudget is tested in isolation (rate_limit.rs:327, rate_limit.rs:346). That keepalive frames are charged while binary frames are not — a consequence of where the charge happens (connection.rs:346) versus where binary is discarded (connection.rs:405) — is unpinned behaviour.
Note

BACKLOG.md:103 states the suite has 61 tests. The count is 53, derived by enumerating #[test] and #[tokio::test] attributes across the six modules. The backlog figure predates the removal of the product route and its tests on 2026-08-28 (CHANGELOG.md:10-28). Where the backlog and the code disagree, the code wins.

The 53 figure has one further limit worth stating plainly: it was counted from source, not read off a test-runner summary. No run of cargo test was observed in producing this page, so the pass/fail state of the suite at any given commit is not something these docs assert.