Files
udpscope/src/capture.hpp
T

125 lines
5.5 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <atomic>
#include <cstdint>
#include <string>
namespace udpscope {
/// The largest datagram the client's socket can deliver, and therefore the payload
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
/// over-long datagram is impossible by construction, and `store()` counts the
/// impossibility rather than trusting it.
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
/// word that is written last. 8-byte aligned.
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
inline constexpr std::uint32_t kSlotBytes =
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
/// whole world is bytes, a clock reading and one file.
///
/// Everything here runs on the producer thread except `open()`'s caller choice and
/// the GTK thread's reads of the counters and the error, which are publishable
/// without a lock: see the field table in the design note
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
class PacketCapture {
public:
PacketCapture() = default;
~PacketCapture() { close(); }
PacketCapture(const PacketCapture&) = delete;
PacketCapture& operator=(const PacketCapture&) = delete;
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
/// creates and reserves the new file, maps it, writes the header. Returns false
/// and leaves the object disabled on any failure, with `error()` set.
bool open(const std::string& path, std::uint32_t slot_count);
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
void close() noexcept;
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
/// 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 { return stored_.load(std::memory_order_relaxed); }
std::uint64_t truncated() const noexcept {
return truncated_.load(std::memory_order_relaxed);
}
std::uint32_t slot_count() const noexcept { return slot_count_; }
const std::string& path() const noexcept { return path_; }
/// The source's IPv4, in network order, and its port: written into the header at
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
/// from the thread that owns the decision -- the same write-once discipline as the
/// path. `local_port` stays zero: the client does not expose its own socket.
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
peer_addr_ = addr_net_order;
peer_port_ = port;
}
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
/// Copies the last open() failure out, or writes an empty string. Deliberately not
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
/// the flag with a release store, and the GTK thread copies only when the flag reads
/// true -- a `std::string` shared like that is a data race with a reallocating
/// buffer, the kind that reads correctly for months.
void error(char* out, std::size_t cap) const noexcept;
private:
void set_error(const char* what) noexcept;
std::string path_;
std::uint32_t slot_count_ = 0;
int fd_ = -1;
std::uint8_t* map_ = nullptr; ///< whole file, header first
std::uint64_t map_bytes_ = 0;
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
std::uint16_t peer_port_ = 0;
std::atomic<bool> enabled_{false};
std::atomic<std::uint64_t> stored_{0};
std::atomic<std::uint64_t> truncated_{0};
std::atomic<bool> error_set_{false};
std::atomic<double> open_ms_{0.0};
mutable char error_[128] = {0};
};
/// What a capture file says when read back off disk. `stored` counts slots with a
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
struct CaptureReadStats {
bool header_ok = false;
std::uint32_t slot_count = 0;
std::uint32_t slot_bytes = 0;
std::uint32_t cursor = 0;
double t0_realtime = 0.0;
std::uint64_t stored = 0;
std::uint64_t torn = 0;
std::uint64_t empty = 0;
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
std::uint64_t first_seq = 0;
std::uint64_t last_seq = 0;
std::uint64_t file_bytes = 0;
};
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
bool read_capture(const std::string& path, CaptureReadStats* out);
} // namespace udpscope