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.
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_hello →
session_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.
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_framefan-out are all tested at theSessionStorelevel (session.rs:490) and never through a socket. The targeting logic is proven; the loop inconnection.rs:333-409that calls it is not. - No hello-timeout test.
read_hellowraps the stream intokio::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, bykeepalive_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.
MessageBudgetis 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.
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.
Related#
- Agent orientation — what each source file owns before you change one.
- Invariants & tripwires — the rules these tests exist to pin.
- Run the relay — the Docker image the release profile produces.
- Limits & capacity — the caps the
rate_limitandsessiontests enforce. - Room authentication — the trust model behind the 20 crypto tests.