// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include 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 `` 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 enabled_{false}; std::atomic stored_{0}; std::atomic truncated_{0}; std::atomic error_set_{false}; std::atomic 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 ``; 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