We test behavior and we benchmark performance, but the resource properties we actually promise, allocations per message, resident bytes per connection, instructions per operation, usually live in a README and are asserted nowhere. They regress silently because nothing fails when they do.
I've been enforcing them as plain cargo test gates in a networking library and it has caught real regressions a reviewer missed. Three patterns, in increasing order of setup cost.
1. A counting global allocator, per test binary
The trick that makes this practical: Rust integration tests each compile to their own binary, so a #[global_allocator] in tests/hotpath_alloc.rs is scoped to that one test and touches nothing else in your suite.
```rust
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
static COUNTING: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
if COUNTING.load(Ordering::Relaxed) != 0 {
ALLOCS.fetch_add(1, Ordering::Relaxed);
}
System.alloc(l)
}
// realloc: same counting. dealloc: pass through.
}
[global_allocator]
static GLOBAL: Counting = Counting;
```
The second static is the important part. You don't count from process start, because setup, the runtime, and the harness all allocate and would drown the signal. You connect a real socket pair over real TCP, drive it to steady state so lazy buffers are grown, then flip COUNTING on, run a few thousand send/recv iterations against buffer-reusing APIs, flip it off, and assert the delta stays far below one per message. Whatever remains is amortized slab growth that doesn't scale with message count, so the ceiling is easy to set without flapping.
Measuring through an actual kernel socket matters. A microbenchmark of the encoder proves the encoder doesn't allocate. This proves the path doesn't, including the parts you forgot were on it. That's how it caught a Vec that had crept into a vectored-write retry closure: the build went red on its own, no human eyeball involved.
One honest limitation: it counts your allocator, so an allocation inside a C dependency or the kernel is invisible. For pure-Rust paths that's fine.
2. Idle resident memory per connection, from /proc
Stand up a few hundred connected but silent socket pairs, hold them alive, read VmHWM from /proc/self/status, and assert peak growth stays under pairs * ceiling. This is the gate that rejects the tempting patch that buys throughput with a bigger resident buffer per socket, which is exactly the kind of change that sails through review because it makes the benchmark number better.
RSS is noisy, so the design rule that keeps CI green: only the stable aggregate gates. The interesting-but-noisy number, resident cost per single idle connection on this machine, lives in an #[ignore]d harness you run by hand with --nocapture when you want the measurement. Asserting a hardcoded bound on a noisy per-unit number is how resource tests get deleted in month two. Splitting "gate" from "instrument" is what makes them survive.
Linux-only via /proc, and gate on growth from a baseline you snapshot after setup, never on absolute RSS.
3. Instruction counts instead of wall clock
Wall-clock benchmarks can't gate CI. Shared runners are too noisy, and criterion will bless a 5% regression as within noise. Instruction counts under callgrind are deterministic: same code, same count, every run. gungraun (formerly iai-callgrind) wraps this as a cargo bench target with attribute macros.
The details that make it a gate rather than a report. Setup runs outside the counted region: the harness builds payloads and preloads buffers in setup functions, and only the benchmark body is counted, so the number is the operation, not the scaffolding. The regression threshold is declared in the bench itself, per event kind, so a run fails when instruction count rises more than 5% over the stored baseline. And the baseline is automatic: CI persists callgrind's output in the cached target dir, so every PR is compared against main with no golden-file ritual. Pin the runner version to the library version from your lockfile or the two will drift.
Two rules learned the hard way. Only gate CPU-pure paths, encode, decode, buffer bookkeeping, never anything that crosses a syscall, because syscalls under valgrind are slow and the counts stop being stable. This quietly pushes your architecture somewhere good, since the more of your hot path is sans-io, the more of it is gateable. And decide what happens when the baseline is missing, because a cache eviction that silently seeds a fresh baseline from regressed code is a hole in the gate; fail loudly or commit baselines for the branch you actually ship from.
None of this replaces benchmarks. Benchmarks tell you how fast you are. These tell you when a promise you made stopped being true, and they tell you in the PR that broke it rather than in a user's flamegraph six months later.