18 KiB
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:
/** 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
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:
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
seqis complete. Nofsyncis needed for the file to be readable after a crash: the kernel's dirty pages are the file, and a half-copied slot still hasseq == 0. seq, notcursor, is the order. After the ring wraps, the newest slot is the one with the largestseq, 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:
- enable at runtime — the probe toggles on mid-run;
storedmust equal the datagrams the exit dump counts from that instant,torn=0, andfirst_seq=1. - wrap —
--capture-packets=16against the standard mock, run long enough to wrap several times;storedexceedsslot_count,last_seq - first_seq == 15,torn=0. A wrap that loses ordering, or acursorthat disagrees withseq, 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.mditem 5's territory (opening stored waveforms) and a different feature with a different shape. - Retention beyond one deep.
capture.binandcapture-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
gzipexists.
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 atodo.mditem, 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.mdandprobe-readings.md— the probe and its two cases;docs/claude/invariants-data-path.md— one paragraph on why the hook is atrecv()and why the file is reserved rather than sparse.