Files
udpscope/docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md

329 lines
18 KiB
Markdown

<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# UDP packet capture: design
`todo.md` item 4: *optional udp frame storage for debugging of strange behaviours (store last
`<N>`, default = 1000, udp packets binary content on disk for forensic analysis).*
The strange behaviours worth forensics on are all *wire* behaviours — a counter that skips, a
datagram that arrives twice, a fragment that never completes, a CONFIG whose header does not parse,
a burst that outruns the reorder ring. Every one of them is invisible by the time a
`udps_frame_t` reaches `Acquisition::on_data`: the client has already validated the magic,
reassembled the fragments, resequenced what it could and dropped what it could not. A capture that
is to answer *what did the wire actually send* must therefore sit at `recv()`, and must store the
bytes without interpreting them.
This is a debugging aid, not a feature of the instrument. It is off by default, it adds nothing to
the glance tier of the status bar, and it must not be able to cost a single packet when it is on.
## The decisions taken, and what each one rules out
| Decision | Alternative rejected |
| --- | --- |
| Always-on rolling ring while enabled; the last N are on disk at all times | Keeping the ring in RAM and flushing on an anomaly or on demand — a crash or a `SIGKILL` then leaves nothing, which is the case a forensic capture exists for |
| A fixed-size file of N slots, plus a converter to pcap in `tools/` | Writing pcap directly (grows, so "last N" needs two rotating files and the newest N is split across both) or one file per datagram (1000 files, an open/write/close per packet in the receive path) |
| `mmap` once, `memcpy` per datagram | A `pwrite` per datagram (a syscall, and on a busy disk a blocking one, between two `recv()` calls) or a third thread with a queue (a second copy of every datagram, and this application's two-thread property is load-bearing) |
| One path per source, previous capture kept one deep | A timestamped file per enable (needs the retention policy `todo.md` item 9 defines and this does not build) or a single path overwritten (re-enabling destroys the capture you enabled for) |
| `--capture-packets[=N]` as well as the popover button | Button only — by the time the window is up and clickable, the first connect and its CONFIG are already history, and those are exactly the "strange behaviours" hardest to reproduce |
## Architecture
Three parts, each with one responsibility and a seam that does not leak the other two.
### 1. The client hands over raw datagrams
`deps/udpsclient` gains **one additive entry point**:
```c
/** Every datagram as recv() returned it, before the magic check, reassembly or
* resequencing: the bytes a capture has to store, and the only place malformed
* or duplicate datagrams are still visible. Runs on the polling thread, inside
* udps_client_poll(), like every other callback. `bytes` is valid until the
* callback returns. */
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
double recv_time, void *user);
void udps_client_set_datagram_cb(udps_client_t *client,
udps_datagram_cb cb, void *user);
```
A separate setter, **not** a fourth parameter on `udps_client_set_callbacks`
(`udps_client.h:283`): that signature has call sites, and a capture hook is no reason to change
them.
The call site is `udps_client.c:1458`, immediately after
```c
ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0);
```
returns a positive `n`, and before anything is read out of `rxbuf`. `recv_time` is the same clock
the client already stamps arrivals with, so a capture's times and the stream statistics agree.
**Boundary, stated rather than discovered later:** in multicast mode CONFIG arrives over TCP
(`udps_client.c:1142`) and this hook does not see it. The capture is of the UDP socket. A malformed
*multicast* CONFIG is therefore out of reach, and `README.md` will say so.
### 2. `PacketCapture` owns the file
New unit, `src/capture.hpp` / `src/capture.cpp`. One class, one responsibility — own the ring file
and store a datagram into it. It includes no GTK, no `ScopeModel`, and no client types beyond
`<cstdint>`; its whole interface is:
```cpp
class PacketCapture {
public:
/// Renames an existing capture to `capture-prev.bin`, creates and reserves the new file,
/// maps it. Returns false and leaves the object disabled on any failure, with `error()` set.
bool open(const std::string& path, std::uint32_t slot_count);
void close(); ///< unmap, close; idempotent
bool enabled() const noexcept;
/// One datagram. Called only from the producer thread. Never allocates, never blocks,
/// never fails: an over-long datagram is truncated to the slot and counted.
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
std::uint64_t stored() const noexcept; ///< datagrams since open()
std::uint64_t truncated() const noexcept;
std::uint32_t slot_count() const noexcept;
const std::string& path() const noexcept;
/// Copies the last open() failure out, or writes an empty string. Not a
/// `const std::string&`: see "What crosses between the threads" below.
void error(char* out, std::size_t cap) const noexcept;
};
```
#### What crosses between the threads
The producer thread writes this state and the GTK thread reads it every frame for the popover, so
each field has to be publishable without a lock:
| Field | Type | Why it is safe |
| --- | --- | --- |
| `enabled_`, `stored_`, `truncated_` | `std::atomic<…>`, relaxed | counters and a flag, not a consistent set — the same treatment `StreamStats` already gets |
| `path_` | `std::string` | written **once** by the GTK thread before it ever sets `capture_want_`, and only read afterwards |
| the open failure | `char error_[128]` plus `std::atomic<bool> error_set_` | the producer fills the buffer and *then* sets the flag with a release store; the GTK thread copies the buffer only when the flag reads true |
The error is deliberately **not** a `std::string` accessor. A `std::string` written by the producer
inside `open()` and read by the GTK thread on the next frame is a data race with a reallocating
buffer — the kind that reads correctly for months. The fixed buffer with a flag published after it
is the same release-store trick the slot's `seq` field uses, for the same reason.
#### The file
```
offset 0, 64 bytes — header
magic u8[8] "UDPSCAP\0"
version u32 1
slot_count u32 N
slot_bytes u32 sizeof(record header) + UDPS_RX_BUF_BYTES, 8-byte aligned
cursor u32 next slot index to write (a hint; see below)
t0_realtime f64 CLOCK_REALTIME at open(), the epoch recv_time is relative to
peer_addr u32 the source's IPv4, network order
peer_port u16
local_port u16
reserved to 64 bytes, zeroed
offset 64 + i * slot_bytes, for i in [0, N) — slot i
t_arrival f64 the client's recv_time
len u32 bytes stored (after truncation, if any)
orig_len u32 bytes the wire delivered, so a truncation is visible
bytes u8[len]
seq u64 1-based ordinal since open(); written LAST
```
`seq` is written last, after the bytes, with a release store. Three things follow, and they are the
reason the format is this and not simpler:
- **A slot carrying a non-zero `seq` is complete.** No `fsync` is needed for the file to be
readable after a crash: the kernel's dirty pages are the file, and a half-copied slot still has
`seq == 0`.
- **`seq`, not `cursor`, is the order.** After the ring wraps, the newest slot is the one with the
largest `seq`, and the reader needs no trust in a header field that is updated out of band.
- **A torn write is detectable, not silent.** The reader reports the count of empty and torn slots
rather than averaging over them.
`cursor` is maintained anyway, because a human running `xxd` on the file wants to know where to
look.
#### Why the file is reserved, not sparse
`posix_fallocate` reserves the whole file at `open()`. The obvious alternative — `ftruncate` and
let the file stay sparse, so it costs only the bytes actually written — is wrong with `mmap`: a
store into a page the filesystem cannot back raises **`SIGBUS`**, which kills udpscope outright.
A capture must not be able to take down the instrument it is observing, and an out-of-space
condition must be an error at the moment the user clicks, not a crash ten minutes later.
At the default `N = 1000` and `UDPS_RX_BUF_BYTES = 65552`, the file is **~62.6 MiB**. That is
stated in the help text, in the tooltip and in the popover row, because it is the one cost of
switching the feature on.
### 3. `Acquisition` drives it, and the GTK thread only ever sets an atomic
`Acquisition` owns the `PacketCapture`, installs the datagram callback next to the three it already
installs (`src/acquisition.cpp:62`), and does **every** file operation on the producer thread.
```
GTK thread (the button) producer thread (the datagram callback)
--------------------------- --------------------------------------------
capture_want_.store(true) ──► reads capture_want_ at the top of each datagram
capture_path_ written once rising edge → open() : rename, create, fallocate, mmap
before the first store falling edge → close(): munmap, close
then, if enabled, store(bytes, len, recv_time)
```
No mutex, no queue, no second copy of any datagram, and the application still has exactly two
threads.
The one-off cost of a toggle is the `open()`: a rename, a create, a `posix_fallocate` of 62.6 MiB
and an `mmap`, on the producer thread. It is a user-initiated act that happens once per toggle, and
`README.md` will say that it can cost the packets in flight during it. Moving it off the producer
thread would need the lock or the thread this design exists to avoid, for an event that happens
when a human clicks a button.
The duration is **measured, not asserted**: `open()` records its own wall time and the probe prints
it as `open_ms`. `posix_fallocate` on a local extent-based filesystem is a metadata operation and
should be a handful of milliseconds, but that is a claim about the filesystem, not about this code,
and a reading above ~50 ms is a finding worth putting in `README.md` rather than a number to
predict here.
### Error handling, and only at this boundary
This is a syscall boundary, so it is validated; nothing else here is.
| Condition | Behaviour |
| --- | --- |
| `open`, `posix_fallocate` or `mmap` fails (ENOSPC, EACCES, a read-only cache directory) | capture stays off, `error()` carries `strerror`, one line on stderr, the toggle button returns to off and the popover row shows the reason |
| the cache directory does not exist | created with `g_mkdir_with_parents`, 0700; a failure there is the row above |
| `len > UDPS_RX_BUF_BYTES` | impossible — the slot is sized to the client's own receive buffer — but `store()` truncates to the slot, records `orig_len`, and counts it in `truncated()` rather than trusting the impossibility |
| the previous `capture-prev.bin` cannot be replaced | the capture still opens; the rename failure is reported, the old previous is kept |
| capture enabled with no connection yet | the button is insensitive until connected, because the path is keyed on the source; `--capture-packets` defers `open()` to the first datagram, which is what arms it before the first CONFIG |
## Surfaces
### The popover button and row
The status bar's detail popover (`src/ui.cpp:7619-7672`) is a two-column grid: four bold headings
at rows 0, 3, 5, 6 and seven value labels at rows 0..6, in `app->popover_label[11]`. It gains a
fifth heading, **Capture**, and an eighth value label; the array becomes `[13]` and the
`kHeadings` / `kHeadingRow` tables gain their entry. `update_status_popover`
(`src/ui.cpp:6101`) writes the new label like the others — `gtk_label_set_text` only, no rebuild.
The row reads one of:
```
off
on · 412/1000 slots · 26.1 MiB · ~/.cache/udpscope/192.168.1.40-5000/capture.bin
failed: No space left on device
```
Below the grid, a `GtkToggleButton` labelled **Capture packets** with the
`media-record-symbolic` icon and a tooltip naming the full path and the file's size. `toggled`
sets the atomic and nothing else. Insensitive until connected.
Nothing is added to `format_detail()`'s note string: `src/status_text.cpp:86` is a `char
note[160]` already close to its worst case, and the capture state is not something the exit dump's
one line needs.
### The command line
```
--capture-packets N store the last N raw datagrams on disk for forensics, in a
ring under the cache directory (1000 slots is ~62.6 MiB);
off unless given, and toggleable from the status bar
```
Parsed in `src/config.cpp`'s existing style — both `--capture-packets N` and
`--capture-packets=N`, `N` in 1..100000 — and the value is **required**, not optional.
`--capture-packets` with an optional value cannot be told apart from `--capture-packets` followed
by a value in this parser, and every other option in it takes the space-separated form, so a bare
flag would turn `--capture-packets 500` into `unknown argument: 500`. The 1000 default is the
slot count the **popover button** uses when the flag was not given; with the flag, the button uses
the N the flag named.
`Config::capture_packets` is a `std::uint32_t`: 0 means the flag was absent, and the button then
uses 1000.
The flag arms the ring before the first datagram; it is not persisted in the session file. That
persistence belongs with `todo.md` item 3's work, alongside the relative-time toggle deferred to
the same place.
### `tools/capture_to_pcap.py`
```
$ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap
1000 slots, 412 stored, 0 torn, 0 truncated → 412 packets, 26.1 MiB
$ wireshark out.pcap
```
Reads the header, validates the magic and version, walks the slots, orders by `seq`, skips
`seq == 0`, and emits a classic pcap (`LINKTYPE_ETHERNET`, microsecond timestamps from
`t0_realtime + t_arrival`) with a synthesised Ethernet/IPv4/UDP header per record built from the
header's `peer_addr`, `peer_port` and `local_port`. Pure standard library — the mock already
forces a hard `numpy` dependency on `tools/`, and this script has no reason to add to that.
It also prints the counts above to stderr, which is what makes it a diagnostic in its own right:
`torn` or `truncated` above zero is a finding about the capture, before anything about the stream.
## Verification
udpscope has no unit test suite; the capture is verified by the application's own headless
self-check, and the converter by running it.
`UDPSCOPE_PROBE_CAPTURE_MS` toggles capture **through the same handler the button uses**, waits a
scheduled interval, then reads the file back off disk and prints one line:
```
[capture-probe] enabled=1 path=… slots=1000 stored=412 cursor=412 torn=0 truncated=0
header_ok=1 first_seq=1 last_seq=412 payload=1648000 file=65576064
open_ms=3.1 t0=1760000000.123
```
`payload` is the sum of every stored slot's `len` — the bytes the wire actually delivered, which is
what tells you whether the ring is holding what you think. `file` is the reserved size,
`64 + slot_count * slot_bytes`, and is a constant for a given N: it is in the line so that a
mismatch against the file on disk is a reading rather than something to go and check.
What each field has to read, and the defect a wrong reading names, goes in
`docs/claude/probe-readings.md` beside the other probes. Two cases in
`docs/claude/verification.md`:
1. **enable at runtime** — the probe toggles on mid-run; `stored` must equal the datagrams the
exit dump counts from that instant, `torn=0`, and `first_seq=1`.
2. **wrap** — `--capture-packets=16` against the standard mock, run long enough to wrap several
times; `stored` exceeds `slot_count`, `last_seq - first_seq == 15`, `torn=0`. A wrap that loses
ordering, or a `cursor` that disagrees with `seq`, shows here and nowhere else.
Then the converter, run over the wrap case's file: exit 0, a record count equal to `slot_count`,
and the pcap's own first and last record timestamps inside the run's wall-clock interval. The
check is the converter's printed counts plus a `struct.unpack` of the pcap's first and last record
headers — no Wireshark, no `capinfos`, nothing outside the standard library, so it runs wherever
the self-check runs.
The standing baselines apply unchanged: a clean build at exactly four warnings, an incremental at
two, and zero `Gtk-CRITICAL` in any headless run.
## Out of scope
- **Replaying a capture back into udpscope.** That is `todo.md` item 5's territory (opening stored
waveforms) and a different feature with a different shape.
- **Retention beyond one deep.** `capture.bin` and `capture-prev.bin`, nothing more, until item 9
defines the cache-size preferences.
- **Session persistence of the enable state.** Deferred to item 3 with the relative-time toggle,
so one piece of work adds the session fields rather than two.
- **Capturing the multicast TCP CONFIG channel.** Stated as a limitation in `README.md`.
- **Compression, encryption or rotation of the ring.** A fixed file of raw bytes is what a
forensic reader wants, and `gzip` exists.
## Documentation to update
- `README.md` — the feature, the 62.6 MiB cost, the producer stall on toggle, the multicast CONFIG
limitation, the converter's usage.
- `SPECS.md` — nothing: this is a `todo.md` item, not a spec requirement, and not a deviation.
- `todo.md` — item 4 struck, on one line, with the flag's name (GFM strikethrough is inline and
cannot span list items).
- `docs/claude/build-and-run.md` — the new option; `verification.md` and `probe-readings.md` — the
probe and its two cases; `docs/claude/invariants-data-path.md` — one paragraph on why the hook is
at `recv()` and why the file is reserved rather than sparse.