# Sniffgate: Design and Measurement of a Compact TCP/UDP Failover Gateway

Daozhendong Yang, School of Intelligent Science and Engineering,
Yunnan Technology and Business University, Kunming, China — <hi@yxpil.com>

Paper about **sniffgate**, a single-process TCP/UDP failover gateway written in
Rust. It routes each listener to one active backend, probes nodes over TCP or
UDP, ejects them on forwarding failures, hot-reloads TOML, and drains old TCP
connections after a switch. The paper evaluates the implementation as it stands,
including four behaviors that contradict or complicate its documentation.

## arXiv classification

- **Primary:** `cs.NI` — Networking and Internet Architecture
- **Cross-lists:** `cs.SE` — Software Engineering

The same classification is stated in the header comment of `main.tex`.

## Abstract (plain-text rendering)

Sniffgate is a single-process TCP/UDP failover gateway written in Rust:
immutable routing snapshots published through ArcSwap, active probes over either
transport, passive ejection on forwarding failures, hot configuration reload,
and deadline-based draining. This paper measures the implementation at commit
1793344 (3,215 lines, a 2.88 MiB binary) on Windows 11 loopback, using a harness
that kills real backends and records every latency in a JSON artifact. Under an
identical fail-stop, detection is 259.69 ms with UDP probes and 1,143.67 ms with
TCP probes, because Windows drops SYN packets to closed ports but resets UDP
sockets immediately. An open-but-silent listener takes 1,138.34 ms, which is the
probe timeout rather than the transport. The first request after a kill reaches
the standby in 510.73 ms through connect retry, well before the health plane
switches at 1,198.72 ms; after restart, traffic returns in 195.24 ms. A grid
over the probe interval and the failure threshold shows that detection is a
schedule, not a constant: it is roughly t probe cycles, and across all 24
configurations the client-visible first request stays between 304.01 ms and
327.19 ms. We also report negative results. `probe = none` is implemented as a
successful probe, so it clears the passive failure window: six requests each pay
about 507.87 ms and no ejection occurs, and with a threshold of one a dead
primary is resurrected twice, 57.18 ms and 24.16 ms after the two ejections.
Draining is keyed to the union of TCP and UDP roles, so a TCP-only switch leaves
the old connection alive beyond 2,203.69 ms, and a zero drain timeout disables
draining. A UDP-only failure is invisible to TCP probes: all 100 datagrams are
lost over 3,095.63 ms while the node is healthy. Overhead is 1.749x persistent
RTT and 0.031x bulk throughput, with 7.57 MB RSS. Six lessons follow.

## What is measured

Each scenario starts a fresh gateway process and fresh tagged echo backends on
Windows loopback, injects a real failure, and records every observation in
`scripts/results/sniffgate_measure.json`. The paper's tables and figures are
generated from that file.

| Scenario | Failure or action | What it answers |
| --- | --- | --- |
| `startup` | none | startup, admin latency, idle probes, hostile input, RSS |
| `failover` | kill active backend, 5 trials | detection, first request, recovery |
| `gray` | listener accepts, never answers | detection of an open-but-silent backend |
| `passive` | no probes; kill; threshold 2 or 1 | passive ejection, no-op-probe interaction, resurrection |
| `udp` | close only the UDP socket; switch mid-burst | UDP blind spot and session rebinding |
| `probe` | same fail-stop, UDP vs TCP probe, 3+3 trials | transport effect on detection |
| `drain` | explicit switch, TCP-only/both/zero timeout | who drains, when, and for how long |
| `reload` | add, invalid, remove node | convergence, rejection, epoch behavior |
| `overhead` | RTT, 32 MiB bulk, 100 connections | forwarding cost |
| `failmode` | all backends dead | fail-open vs fail-closed |

A second harness, `scripts/extra_measure.py`, adds the detection grid, the
cross-protocol blindness cells, the flap sweep, the passive-under-load arms,
and the `probe = none` sweep. Its artifact is
`scripts/results/sniffgate_extra.json`. The paper reports the grid and the
blindness cells; the remaining arms are kept in the artifact for reuse.

## File manifest

| Path | Contents |
| --- | --- |
| `main.tex` | Paper source: 12 pages, two columns, four figures, eleven tables |
| `refs.bib` | Bibliography database |
| `common.tex` | Shared preamble, copied from `papers/_shared/common.tex` |
| `numbers.tex` | Generated `\newcommand` definitions for every inline number |
| `tables/*.tex` | Generated table environments (semantics, config, control, failover, passive, drain, failmode, udp, grid, blind, overhead) |
| `figures/fig1_architecture.pdf` | Data and control paths |
| `figures/fig2_failover.pdf` | Detection and recovery by failure manifestation |
| `figures/fig3_passive.pdf` | Ejection disabled by no-op probes; resurrection timeline |
| `figures/fig4_overhead.pdf` | Persistent RTT and bulk throughput |
| `scripts/failover_measure.py` | Measurement harness (Python 3.13, standard library only) |
| `scripts/extra_measure.py` | Second harness: detection grid, blindness, flap, load arms |
| `scripts/make_figures.py` | Figure generator (matplotlib, PDF output) |
| `scripts/make_tables.py` | Generates the configuration, control, failover, passive, and failure-mode tables |
| `scripts/make_artifacts.py` | Generates the remaining tables, `numbers.tex`, and calls the figure script |
| `scripts/results/sniffgate_measure.json` | Every measured number of the main harness |
| `scripts/results/sniffgate_extra.json` | Every measured number of the extra harness |
| `scripts/results/sniffgate_measure_pretty.json` | Indented copy for reading, not used by the build |
| `README.md` | This file |

## Build

Requires a TeX distribution with `pdflatex`, `bibtex`, `natbib`, and
`abbrvnat.bst` (TeX Live or MiKTeX), or Tectonic.

```bash
tectonic main.tex
# or
pdflatex main && bibtex main && pdflatex main && pdflatex main
```

## arXiv submission

Upload the contents of `dist/sniffgate.tar.gz` (built with
`bash ../make_package.sh sniffgate`) at <https://arxiv.org/submit>. The archive
contains `main.tex`, `refs.bib`, `common.tex`, `numbers.tex`, `tables/`, and
`figures/`; including the generated `main.bbl` is also fine. Choose `cs.NI` as
the primary category and `cs.SE` as the cross-list. The paper has no
supplementary data beyond the JSON artifact described above, and `main.tex`
compiles with both Tectonic and `pdflatex` + `bibtex`.

## Reproduce the measurements

Requirements: Windows (the harness uses Win32 process and timer APIs), Python
3.13, a Rust toolchain, and the sniffgate repository with its release binary.

```bash
# in the sniffgate repository
cargo build --release

# in this paper directory
python scripts/failover_measure.py \
  --binary /path/to/sniffgate/target/release/sniffgate.exe \
  --repo /path/to/sniffgate
python scripts/extra_measure.py
python scripts/make_tables.py
python scripts/make_artifacts.py
```

Defaults for `--binary`, `--repo`, and `--out` point at the workspace layout used
when the artifact was produced (`_repos/sniffgate` and
`scripts/results/sniffgate_measure.json`). A full run takes several minutes: ten
scenarios, repeated failover trials, and a fresh process pair for each trial. Use
`--scenarios startup,overhead` to run a subset and `--keep-workdir` to keep the
temporary configs and logs.

`make_tables.py` writes the five tables that come from the main harness and
exposes the prose macro family. `make_artifacts.py` writes the remaining
tables, merges both macro families into `numbers.tex`, and then calls
`make_figures.py`, so the two scripts must be run in that order.

All experiments use the tuning in Table 1 of the paper, not the gateway's stock
defaults. The harness shortens the probe interval (100 ms vs 1,000 ms), probe
timeout (300 ms vs 800 ms), connect timeout (500 ms vs 3,000 ms), drain timeout
(1,000 ms vs 10,000 ms), and config poll (100 ms vs 1,000 ms) so that each
experiment completes in bounded time. Comparisons always use the same values in
both arms. Scenarios that override a value (passive threshold, success
threshold, probe kind, drain zero) say so in the paper.

## Key results

- Startup: admin socket at 72.13 ms, data plane at 90.34 ms; `status` p50
  82.60 us; idle probe rate 9.00–9.25 per node/s; RSS 7.12 MB.
- Active failover (5 trials): switch p50 1,198.72 ms, first request after kill
  p50 510.73 ms (connect retry beats the health plane), recovery p50 195.24 ms.
- Probe transport: UDP 259.69 ms vs TCP 1,143.67 ms under the same fail-stop;
  gray failure 1,138.34 ms.
- Detection grid (24 cells): TCP detection grows by one interval plus one
  probe timeout per unit of the threshold; UDP by one interval per unit. Across
  every cell the first request after the kill took 304.01–327.19 ms.
- `probe = none` with threshold 2: six attempts at 505.56–521.90 ms, zero
  ejections, +45 successful probes. With threshold 1: two ejections and two
  resurrections, 57.18 ms and 24.16 ms after ejection.
- Drain: both protocols close the old TCP connection at 1,011.13 ms; TCP-only
  leaves it alive beyond 2,203.69 ms (`draining = false`); `drain_timeout_ms = 0`
  disables draining, with the old connection alive after 1.5 s.
- UDP: 100/100 datagrams lost over 3,095.63 ms while the node is healthy; a
  switch during a 200-datagram burst loses 0 datagrams, with the first node-b
  reply 2.46 ms after the command.
- Overhead: 1.749x persistent TCP RTT, 1.831x UDP, 0.031x bulk throughput,
  8.45% of one core during the 32 MiB transfer, 7.57 MB RSS.
- Fail-open with all backends dead: accepts in 9.81 ms, closes after 1,019.21 ms
  with zero bytes.

## Documentation vs. measured behavior

The paper reports both the intended semantics and what the code does. The main
divergences are:

1. `probe = none` is documented as relying on passive ejection only. In the
   code, `probe_once` returns `Ok(0)` and `record_probe(true, ...)` clears the
   passive window on every interval, so passive ejection never reaches its
   threshold and a dead node can be promoted again.
2. `drain_timeout_ms = 0` is documented as an immediate disconnect. The
   publisher only starts a drain when the timeout is positive, so zero disables
   draining; an old TCP connection remains open past 1.5 s.
3. Drain membership is the union of the TCP and UDP active sets. A TCP-only
   switch therefore does not drain the TCP connection, because the node is still
   active for UDP.
4. The admin parser has no authentication and no line-length cap. It is bound
   to loopback by default, which is the only access control. A 65,536-byte line
   produced a 65,645-byte response.

## Artifact provenance

- Repository commit: `1793344ed70535267803e0257c6485b1eeea36b5` (2026-10-04)
- Binary SHA-256: `0d051174308af21c624ef5faa4c01e3ec3d91c945dc961504c7d05d3b9ef1c7a`
- Binary size: 3,015,168 bytes (2.88 MiB)
- Source: 3,215 lines in 11 files; 13 unit and 3 integration tests
- Compiler: rustc 1.98.1
- Host: Windows 11 (10.0.26300), Intel Core Ultra 5 125H
- Artifact generated: 2026-10-24T17:52:54Z
- Harness: Python 3.13.14, `perf_counter`, 1 ms timer resolution requested

The measurements are host-specific. In particular, Windows silently drops SYNs
to closed TCP ports and returns `WSAECONNRESET` for UDP immediately; a Linux host
would likely change the TCP-probe arm substantially. The JSON records the host
and binary hash so that the same experiments can be repeated elsewhere.
