add streaming zstd spec
This commit is contained in:
@@ -0,0 +1,475 @@
|
|||||||
|
# Streaming `.pcap.zst` Format
|
||||||
|
|
||||||
|
**Status:** implemented (Rust reference: `pcapstream-core`, `pcapstream-server`, `pcapstream-client`)
|
||||||
|
**Audience:** an implementer writing a C++ reader that streams pcap records out of a `.pcap.zst`
|
||||||
|
file *while it is still being written*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this format is and why it exists
|
||||||
|
|
||||||
|
A pcap-stream `.pcap.zst` is a **plain concatenation of independent zstd frames**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[skippable marker frame] identity + version (carries no pcap bytes)
|
||||||
|
[zstd frame] compress(24-byte pcap global header)
|
||||||
|
[zstd frame] compress(record-aligned chunk of pcap records) \
|
||||||
|
[zstd frame] compress(record-aligned chunk of pcap records) | repeats
|
||||||
|
[zstd frame] ... /
|
||||||
|
```
|
||||||
|
|
||||||
|
Two properties fall out of that, and both are load-bearing:
|
||||||
|
|
||||||
|
1. **It is a valid `.zst` file.** `zstd -d file.pcap.zst` concatenates the frame outputs and
|
||||||
|
reproduces the original `.pcap` byte for byte. The marker is a zstd *skippable* frame, so
|
||||||
|
compliant decoders ignore it. Nothing about this format requires a special tool to read
|
||||||
|
offline.
|
||||||
|
2. **It is streamable and seekable at frame granularity.** Every data frame is cut on a pcap
|
||||||
|
*record boundary* and declares its decompressed size in its header. A reader can therefore
|
||||||
|
walk the file frame by frame, know exactly how many uncompressed pcap bytes each frame
|
||||||
|
contributes, and decompress any single frame independently — without decompressing anything
|
||||||
|
before it.
|
||||||
|
|
||||||
|
The point of the format is that a file written this way can be tailed as it grows and served (or
|
||||||
|
relayed) **without recompression**: each stored frame is already exactly the payload you'd put on
|
||||||
|
the wire.
|
||||||
|
|
||||||
|
### What the marker frame buys us
|
||||||
|
|
||||||
|
The marker is the only thing that distinguishes *our* file from an arbitrary `zstd some.pcap`
|
||||||
|
produced by the CLI. Both have the extension `.pcap.zst`; both decode to the same pcap. But a
|
||||||
|
CLI-produced file is typically **one enormous frame** whose boundaries have nothing to do with
|
||||||
|
pcap records, so none of the streaming properties hold. A reader that assumed frame boundaries
|
||||||
|
were record-aligned would hand out torn records.
|
||||||
|
|
||||||
|
So: **a file without a valid leading marker frame must be rejected**, not "best-efforted".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The marker frame (byte level)
|
||||||
|
|
||||||
|
A zstd skippable frame is `magic (4 LE) | payload_size (4 LE) | payload`. Skippable magics occupy
|
||||||
|
the range `0x184D2A50 .. 0x184D2A5F`; we write the base value.
|
||||||
|
|
||||||
|
| Offset | Size | Value | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | 4 | `0x184D2A50` (LE) | zstd skippable-frame magic |
|
||||||
|
| 4 | 4 | `6` (LE) | payload size |
|
||||||
|
| 8 | 4 | `"PSZ1"` (`0x50 0x53 0x5A 0x31`) | pcap-stream zst marker magic |
|
||||||
|
| 12 | 1 | `1` | format version |
|
||||||
|
| 13 | 1 | `0` | flags (reserved; must be written as 0) |
|
||||||
|
|
||||||
|
Total: **14 bytes**, always at physical offset 0.
|
||||||
|
|
||||||
|
Hex, in full:
|
||||||
|
|
||||||
|
```
|
||||||
|
50 2A 4D 18 06 00 00 00 50 53 5A 31 01 00
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader rules:
|
||||||
|
|
||||||
|
* The **first frame in the file must be a skippable frame whose payload is a valid marker**
|
||||||
|
(magic `PSZ1`, version `1`). Anything else → reject the file (`NotMarked`).
|
||||||
|
* Validation reads only the first 4 payload bytes (magic) and the version byte. **Ignore the
|
||||||
|
flags byte and any trailing payload bytes** — a future version may extend the payload, and the
|
||||||
|
`payload_size` field lets you skip whatever you don't understand.
|
||||||
|
* An unknown *version* is a hard reject, not a warning. Version is bumped only on an
|
||||||
|
incompatible container change.
|
||||||
|
* Skippable frames may in principle appear elsewhere in the file. Treat any skippable frame as
|
||||||
|
contributing **zero source bytes** and step over it. (Today only the leading marker exists.)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frame invariants a reader may rely on
|
||||||
|
|
||||||
|
These are guaranteed by every writer of this format. A file that violates them is malformed and
|
||||||
|
should be rejected rather than reinterpreted.
|
||||||
|
|
||||||
|
1. **Frame 0 (physical offset 0) is the marker.** See above.
|
||||||
|
2. **Frame 1 is the header frame:** it decompresses to exactly the **24-byte pcap global header**,
|
||||||
|
and it covers source range `[0, 24)`. If its declared content size is not 24, the file is
|
||||||
|
malformed. Parse it as a classic pcap global header (any of the four magics — LE/BE ×
|
||||||
|
µs/ns; pcapng is rejected upstream).
|
||||||
|
3. **Every subsequent frame is a data frame** whose decompressed bytes are a whole number of
|
||||||
|
complete pcap records — never a partial record header, never a partial payload. Concatenating
|
||||||
|
all data frames' outputs yields the pcap body (everything after byte 24).
|
||||||
|
4. **Every frame declares its decompressed content size** in its zstd frame header (the writer
|
||||||
|
compresses with the input size known, so the field is always present). A frame with an
|
||||||
|
undeclared content size is malformed. *This is what lets a reader map physical → source
|
||||||
|
offsets without decompressing anything.*
|
||||||
|
5. **Every frame carries a zstd content checksum.** Decompression verifies it, so single-bit
|
||||||
|
corruption surfaces as a decode error rather than as corrupt packets.
|
||||||
|
6. **Frames are contiguous in source coordinates.** Frame *n*'s source range starts exactly where
|
||||||
|
frame *n−1*'s ended. There are no gaps and no overlaps.
|
||||||
|
7. **Frames are append-only and immutable once written.** Bytes already on disk are never
|
||||||
|
rewritten in place. The only exception is truncation of a torn trailing frame at writer
|
||||||
|
restart (§7), which happens before any new bytes are appended and always cuts at a frame
|
||||||
|
boundary.
|
||||||
|
8. Data frames target **~256 KiB of uncompressed source bytes** (`frame_target_bytes`, default
|
||||||
|
262144) but may be smaller: a flush timer (default 100 ms) cuts a short frame to bound latency,
|
||||||
|
and the last frame of a file is whatever is left. **Do not assume any frame size.** Size the
|
||||||
|
read window generously (1 MiB is comfortable) and grow it if a single frame ever exceeds it.
|
||||||
|
|
||||||
|
Note that packet counts are **not** carried in the container. If you need them, count records
|
||||||
|
while decompressing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Two coordinate systems
|
||||||
|
|
||||||
|
Keep these strictly separate; conflating them is the single easiest way to get this wrong.
|
||||||
|
|
||||||
|
* **Source offset** — a byte offset into the *decompressed* pcap. The global header occupies
|
||||||
|
`[0, 24)`; the first data frame starts at 24. This is the coordinate space of the uncompressed pcap.
|
||||||
|
* **Physical offset** — a byte offset into the `.pcap.zst` on disk. Includes the 14-byte marker
|
||||||
|
and all compressed frame bytes.
|
||||||
|
|
||||||
|
A frame table entry is therefore four numbers:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct ZstFrame {
|
||||||
|
uint64_t src_start; // source (uncompressed pcap) offset of first byte
|
||||||
|
uint64_t src_end; // exclusive; src_end - src_start == declared content size
|
||||||
|
uint64_t phys_start; // physical offset of the compressed frame
|
||||||
|
uint64_t phys_len; // compressed frame size on disk
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`src_end - src_start` comes from `ZSTD_getFrameContentSize`; `phys_len` comes from
|
||||||
|
`ZSTD_findFrameCompressedSize`. Neither requires decompressing the frame — which is the whole
|
||||||
|
trick: you can build the frame table for a multi-GB file by reading only frame headers, and you
|
||||||
|
only decompress the frames a consumer actually asks for.
|
||||||
|
|
||||||
|
The marker frame (and any future skippable frame) advances `phys` and **not** `src`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Walking the frames
|
||||||
|
|
||||||
|
### 5.1 The two zstd calls you need
|
||||||
|
|
||||||
|
| Purpose | libzstd (C) |
|
||||||
|
|---|---|
|
||||||
|
| Skippable magic test | `(magic & ZSTD_MAGIC_SKIPPABLE_MASK) == ZSTD_MAGIC_SKIPPABLE_START` |
|
||||||
|
| Compressed size of the frame at `p` | `ZSTD_findFrameCompressedSize(p, n)` |
|
||||||
|
| Declared decompressed size | `ZSTD_getFrameContentSize(p, n)` |
|
||||||
|
| Decompress one frame | `ZSTD_decompress(dst, dstCap, p, csize)` |
|
||||||
|
|
||||||
|
Both size calls are **header-only** reads. Important subtleties:
|
||||||
|
|
||||||
|
* `ZSTD_findFrameCompressedSize` returns an error code (test with `ZSTD_isError`) when the buffer
|
||||||
|
doesn't contain the whole frame — but it returns the *same* kind of error for a genuinely
|
||||||
|
corrupt frame. **You cannot distinguish "need more bytes" from "corrupt" by the error alone.**
|
||||||
|
Disambiguate with position: if you have read to end-of-file, treat it as a torn trailing frame
|
||||||
|
(stop, retry later); if not, grow your window and retry. This is exactly what the reference
|
||||||
|
implementation does.
|
||||||
|
* `ZSTD_findFrameCompressedSize` may also return a size **larger than the buffer you passed**.
|
||||||
|
Guard with `csize <= n` and treat a larger value as "need more bytes".
|
||||||
|
* `ZSTD_getFrameContentSize` returns `ZSTD_CONTENTSIZE_UNKNOWN` or `ZSTD_CONTENTSIZE_ERROR` as
|
||||||
|
sentinels. For this format, either is **malformed** (invariant 4) — do not fall back to
|
||||||
|
decompressing to learn the size.
|
||||||
|
* Skippable frames have no content size. Compute their length yourself: `8 + payload_size`, where
|
||||||
|
`payload_size` is the LE `uint32` at offset 4.
|
||||||
|
|
||||||
|
### 5.2 The walk
|
||||||
|
|
||||||
|
State: `phys_pos` (next physical byte to parse — always a frame boundary), `src_pos` (next source
|
||||||
|
offset — the source frontier), plus `saw_marker`, `header`, `file_id`.
|
||||||
|
|
||||||
|
```
|
||||||
|
loop until phys_pos == file_size (fstat each poll; the file grows under you):
|
||||||
|
read a window at phys_pos (1 MiB; short reads are normal — retry/accumulate)
|
||||||
|
at_eof := (bytes_read == file_size - phys_pos)
|
||||||
|
|
||||||
|
if window starts with a skippable magic:
|
||||||
|
if the full skippable frame isn't in the window:
|
||||||
|
if !at_eof: grow window, retry
|
||||||
|
else: stop (torn trailing skippable) — retry on a later poll
|
||||||
|
if phys_pos == 0: it must be a valid marker, else reject NotMarked
|
||||||
|
phys_pos += 8 + payload_size # src_pos unchanged
|
||||||
|
continue
|
||||||
|
|
||||||
|
if !saw_marker: reject NotMarked # a data frame before any marker
|
||||||
|
|
||||||
|
csize := ZSTD_findFrameCompressedSize(window)
|
||||||
|
if error or csize > bytes_read:
|
||||||
|
if at_eof: stop (torn trailing frame) — retry on a later poll
|
||||||
|
else: grow window, retry
|
||||||
|
|
||||||
|
content := ZSTD_getFrameContentSize(window[0..csize])
|
||||||
|
if unknown or error: reject Malformed
|
||||||
|
|
||||||
|
if header not yet seen: # this is the header frame
|
||||||
|
if content != 24: reject Malformed
|
||||||
|
header := parse_pcap_global_header(decompress(window[0..csize]))
|
||||||
|
src_pos += 24 # -> 24
|
||||||
|
phys_pos += csize
|
||||||
|
continue
|
||||||
|
|
||||||
|
emit ZstFrame{ src_start: src_pos, src_end: src_pos + content,
|
||||||
|
phys_start: phys_pos, phys_len: csize }
|
||||||
|
src_pos += content
|
||||||
|
phys_pos += csize
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
* **A torn trailing frame is normal, not an error.** While the writer is mid-append you will
|
||||||
|
routinely see a partial frame at EOF. Stop the walk *before* it, leave `phys_pos` pointing at
|
||||||
|
its start, and pick it up on a later poll. Never surface a frame you haven't fully read.
|
||||||
|
* **`phys_pos` only ever advances across complete frames**, so it is always a valid resume point.
|
||||||
|
* Cap the work per poll (the reference consumes ~8 MiB of physical bytes per call, rounded up to
|
||||||
|
the frame boundary crossed) so that catching up on a large backlog doesn't stall your event
|
||||||
|
loop.
|
||||||
|
* Everything above is `pread`-based on a **held-open fd** — see §7.3 for why that matters.
|
||||||
|
|
||||||
|
To read the packets for a frame: `pread` `phys_len` bytes at `phys_start`, `ZSTD_decompress` into
|
||||||
|
a buffer of `src_end - src_start` bytes, and you have a run of complete pcap records. To relay the
|
||||||
|
frame to a downstream zstd-speaking consumer: send those `phys_len` bytes **verbatim**. No
|
||||||
|
decompress, no recompress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. File identity (`FileId`)
|
||||||
|
|
||||||
|
Filenames are not unique — a capture restart can recreate the same name with different content —
|
||||||
|
so files are identified by content, not by path.
|
||||||
|
|
||||||
|
```
|
||||||
|
FileId = { first_packet_ts : u64, // nanoseconds
|
||||||
|
header_crc32 : u32 }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `header_crc32` = **CRC-32 (IEEE, the zlib/`crc32fast` polynomial, init 0xFFFFFFFF, reflected,
|
||||||
|
final xor)** over the 40 bytes `global_header[0..24] || first_record_header[0..16]` — i.e. over
|
||||||
|
the *decompressed* source bytes `[0, 40)`.
|
||||||
|
* `first_packet_ts` = the first record's timestamp normalized to nanoseconds: `ts_sec * 1e9 +
|
||||||
|
ts_frac * 1000` for a µs-resolution file (magic `0xA1B2C3D4` / `0xD4C3B2A1`), or `ts_sec * 1e9 +
|
||||||
|
ts_frac` for a ns-resolution file (magic `0xA1B23C4D` / `0x4D3CB2A1`). Fields are read in the
|
||||||
|
file's endianness.
|
||||||
|
* All-zeros is the **provisional** id: the file exists and has a header but no first packet yet.
|
||||||
|
Recompute once the first data frame lands.
|
||||||
|
|
||||||
|
For a `.pcap.zst`, this means: decompress the header frame (24 bytes) and just enough of the
|
||||||
|
**first data frame** to get its first 16 bytes, concatenate, CRC.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Detecting completion: the `.partial` rename
|
||||||
|
|
||||||
|
**This is the part that took the most iteration. Read this section carefully — the wrong approach
|
||||||
|
looks like it works and then loses data or reports the wrong file size in production.**
|
||||||
|
|
||||||
|
### 7.1 The scheme
|
||||||
|
|
||||||
|
A file being written exists on disk under a name with an in-progress suffix, and is **atomically
|
||||||
|
renamed** to its final name when — and only when — it is complete:
|
||||||
|
|
||||||
|
```
|
||||||
|
20260714/cap-20260714-14:30:00.pcap.zst.partial ← still growing
|
||||||
|
│
|
||||||
|
│ rename(2) — atomic, same directory
|
||||||
|
▼
|
||||||
|
20260714/cap-20260714-14:30:00.pcap.zst ← complete, immutable
|
||||||
|
```
|
||||||
|
|
||||||
|
The suffix is configurable (`partial_suffix`, default `.partial`) and matches SolarCapture's own
|
||||||
|
convention. The four physical forms a reader must recognize are:
|
||||||
|
|
||||||
|
```
|
||||||
|
<name>.pcap <name>.pcap.partial
|
||||||
|
<name>.pcap.zst <name>.pcap.zst.partial
|
||||||
|
```
|
||||||
|
|
||||||
|
Strip the partial suffix **first**, then a `.zst` suffix, to recover the **logical name**
|
||||||
|
(`<name>.pcap`). The logical name is what appears in the protocol, in resume tokens, and in your
|
||||||
|
own bookkeeping — it is *stable across the rename*, which is the whole point. Two flags fall out
|
||||||
|
of the stripping: `partial` (is it still growing?) and `zst` (is it compressed?).
|
||||||
|
|
||||||
|
|
||||||
|
### 7.2 The rule
|
||||||
|
|
||||||
|
> **The rename is the completion signal. Nothing else is.**
|
||||||
|
|
||||||
|
Concretely, a reader declares a file complete when **both** of these hold:
|
||||||
|
|
||||||
|
1. **The rename has been observed** — the final path exists.
|
||||||
|
2. **The file is fully drained** — you have walked every byte of it: `phys_pos == file_size`
|
||||||
|
(fresh `fstat`), i.e. no torn trailing frame remains.
|
||||||
|
|
||||||
|
Only then do you know the final size, which for a `.pcap.zst` is the **source frontier**
|
||||||
|
(`src_pos`), *not* the physical file size. (For a raw `.pcap` the two coincide, which is a trap if
|
||||||
|
you generalize from the raw path.)
|
||||||
|
|
||||||
|
If (1) holds but (2) doesn't — the rename landed but there's still an unframed tail — **wait**.
|
||||||
|
Do not declare completion with a size you'd have to revise. Poll again; the tail is already fully
|
||||||
|
written, so this resolves on the next tick.
|
||||||
|
|
||||||
|
### 7.3 What NOT to do (each of these was tried and removed)
|
||||||
|
|
||||||
|
* ❌ **Do not use a quiet period / size-stability heuristic** ("no growth for N seconds ⇒ done").
|
||||||
|
A capture stall is indistinguishable from a finished file, and it fires early under load. This
|
||||||
|
was ripped out in favor of the rename signal alone.
|
||||||
|
* ❌ **Do not treat the appearance of the *next* file as the completion signal for the current
|
||||||
|
one.** Same commit, same reason: it couples two files' lifecycles and it's wrong at a capture
|
||||||
|
restart.
|
||||||
|
* ❌ **Do not re-open the file by path when you notice the rename.** The reader must keep serving
|
||||||
|
from the **fd it already holds**. A rename does not invalidate an open fd — it still points at
|
||||||
|
the same inode, and reads continue to work through the rename *and even through an unlink*. If
|
||||||
|
you close and reopen, you introduce a window where the `.partial` path is gone and the reader
|
||||||
|
ENOENTs, and you break continuity for any consumer mid-file.
|
||||||
|
* ❌ **Do not assume the on-disk file size is the source size for a `.zst`.** It's the compressed
|
||||||
|
size. Track the source frontier separately.
|
||||||
|
* ❌ **Do not re-scan and re-register the renamed file as a new file.** A directory scan after the
|
||||||
|
rename sees a *new* directory entry (the final name). Because the logical name is
|
||||||
|
suffix-stripped, it maps to the file you already know — dedupe on the **logical name**, not the
|
||||||
|
physical path.
|
||||||
|
|
||||||
|
### 7.4 The one subtlety that bites: the ENOENT window
|
||||||
|
|
||||||
|
There is a gap between "the rename happened" and "we have finished draining and finalized the
|
||||||
|
file". That gap can be seconds (framing the tail, serving a backlog). During that gap:
|
||||||
|
|
||||||
|
* the `.partial` path **no longer exists** (rename is atomic — the old name is gone the instant
|
||||||
|
the new one appears), but
|
||||||
|
* your in-memory state may still be pointing at the `.partial` path for any *new* open.
|
||||||
|
|
||||||
|
Anything that opens the file by path in that window — a lazily-rebuilt frame, a new consumer
|
||||||
|
attaching, a cache miss — gets **ENOENT**, on a file that is perfectly healthy.
|
||||||
|
|
||||||
|
The fix (commit `4c5a0e8`) is: **the moment you observe the rename, repoint your stored serve
|
||||||
|
path at the final path**, immediately — long before you finalize the file. Keep draining through
|
||||||
|
the fd you already hold (which is unaffected), but make sure any *new* open uses the final name.
|
||||||
|
|
||||||
|
```
|
||||||
|
on scan tick, for each growing file:
|
||||||
|
if is_partial && !complete_signal && exists(final_path):
|
||||||
|
complete_signal = true
|
||||||
|
serve_path = final_path # ← do this NOW, not at finalization
|
||||||
|
# keep tailing via the already-open fd; finalize once drained (§7.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
That ordering — signal and repoint first, drain second, finalize third — is the whole rename
|
||||||
|
protocol. Get it wrong and you get intermittent ENOENTs under exactly the conditions that are
|
||||||
|
hardest to reproduce (a consumer reconnecting during the rename).
|
||||||
|
|
||||||
|
### 7.5 Detecting the rename
|
||||||
|
|
||||||
|
Poll: on each scan, for every file you are tailing that is still `partial`, `stat` its final path.
|
||||||
|
Existence of the final path = the rename happened. This is cheap (one `stat` per growing file, and
|
||||||
|
there is normally exactly one) and needs no inotify. The reference implementation drives this on a
|
||||||
|
1 s timer; a caller-driven scan loop can poll as often as it likes.
|
||||||
|
|
||||||
|
If you prefer inotify, watch for `IN_MOVED_TO` in the capture directory — but keep the `stat`
|
||||||
|
fallback: inotify queues overflow, and missing a rename means never completing a file.
|
||||||
|
|
||||||
|
### 7.6 Files discovered already-final
|
||||||
|
|
||||||
|
A file discovered with **no** partial suffix (final name, no `.partial` sibling) is **born
|
||||||
|
complete** — it finished before you ever saw it. Register it as complete immediately. Don't wait
|
||||||
|
for a rename that already happened.
|
||||||
|
|
||||||
|
A reader that builds a frame table (§4) to serve random-access requests should build it lazily,
|
||||||
|
the first time a consumer actually asks for the file — walking/framing every already-final file at
|
||||||
|
discovery reads every byte of every file in a directory of a day's captures for nothing. A reader
|
||||||
|
that just streams packets out sequentially (walking §5.2 once, start to finish, with no frame
|
||||||
|
table) doesn't have this concern either way.
|
||||||
|
|
||||||
|
### 7.7 Writer restart and truncation (reader's perspective)
|
||||||
|
|
||||||
|
You never write these files, but the process that does can restart mid-file. On restart it walks
|
||||||
|
its own `.partial`, and truncates it back to **a frame boundary** — never mid-frame — before
|
||||||
|
resuming appends.
|
||||||
|
|
||||||
|
The guarantee that matters to a reader: **content at a given source offset never changes.** A
|
||||||
|
restart can only shrink the file back to an earlier frame boundary and re-append from there; the
|
||||||
|
capture data being re-appended is the same data, so whatever lands at a given source offset is
|
||||||
|
byte-for-byte identical to what would have been there without the restart. Only the physical
|
||||||
|
framing (where frame boundaries fall) can differ across a restart — never the decompressed
|
||||||
|
content.
|
||||||
|
|
||||||
|
Two cases follow from that:
|
||||||
|
|
||||||
|
* **Ordinary case — the truncated bytes were never surfaced.** A writer only ever truncates a
|
||||||
|
*torn* trailing frame (invariant 7 in §3), and a reader following §5.2 never surfaces a torn
|
||||||
|
trailing frame in the first place — it stops before it and waits. So `phys_pos` already sits at
|
||||||
|
or before the truncation point. There is nothing to undo: the next poll simply finds the file
|
||||||
|
has grown again (with corrected content) from the same boundary you were already parked at, and
|
||||||
|
the walk continues exactly as if nothing happened.
|
||||||
|
* **Defensive case — the file shrinks below `phys_pos`.** Check this on every poll with a fresh
|
||||||
|
`fstat`, the same as you already do to detect EOF: if `file_size < phys_pos`, trust the frames
|
||||||
|
you have already read — they decoded and checksummed cleanly, so they're not in question — and
|
||||||
|
simply stop advancing. Wait, polling as usual, until `file_size` grows back past `phys_pos`
|
||||||
|
again, then resume the walk from `phys_pos` exactly as before. Because content per source offset
|
||||||
|
is guaranteed stable, whatever now occupies the bytes at and beyond `phys_pos` is the correct
|
||||||
|
continuation, so no rewind or re-parse of already-consumed frames is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error handling
|
||||||
|
|
||||||
|
| Condition | Action |
|
||||||
|
|---|---|
|
||||||
|
| First frame is not a valid marker | **Reject** the file (`NotMarked`). It's a foreign `.zst` whose frames don't align to records. Log the path; do not attempt to stream it. |
|
||||||
|
| Marker version unknown | **Reject.** |
|
||||||
|
| Header frame's content size ≠ 24 | **Reject** (`Malformed`). |
|
||||||
|
| Any frame with undeclared/error content size | **Reject** (`Malformed`). |
|
||||||
|
| Torn frame at EOF | **Not an error.** Stop before it; retry on the next poll. |
|
||||||
|
| zstd decode error / checksum failure | **Reject and alarm.** Never resync by scanning forward for a plausible frame magic — you'd hand out garbage records. Stop tailing this file. |
|
||||||
|
| `incl_len` > 256 KiB (`MAX_INCL_LEN`) or > snaplen | Treat as corruption; stop and alarm, don't resync. (Relevant when validating decompressed records.) |
|
||||||
|
|
||||||
|
The consistent principle: **fail loudly, never resync.** A file that violates an invariant is
|
||||||
|
either corrupt or not ours, and silently guessing at frame boundaries is worse than stopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Constants
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SKIPPABLE_MAGIC` | `0x184D2A50` (range `0x184D2A50..=0x184D2A5F`) |
|
||||||
|
| `MARKER_MAGIC` | `"PSZ1"` |
|
||||||
|
| `MARKER_VERSION` | `1` |
|
||||||
|
| Marker frame length | 14 bytes |
|
||||||
|
| `GLOBAL_HEADER_LEN` | 24 |
|
||||||
|
| `RECORD_HEADER_LEN` | 16 |
|
||||||
|
| `MAX_INCL_LEN` | 262144 (256 KiB) |
|
||||||
|
| `frame_target_bytes` (default) | 262144 (~256 KiB of *source* bytes) |
|
||||||
|
| `frame_flush_ms` (default) | 100 (may cut a short frame) |
|
||||||
|
| `zstd_level` (default) | 2 (writer-side; irrelevant to a reader) |
|
||||||
|
| `partial_suffix` (default) | `.partial` |
|
||||||
|
| Read window | 1 MiB, grown (×2) if a single frame exceeds it |
|
||||||
|
| Scan interval (reference default; caller-driven elsewhere) | 1000 ms |
|
||||||
|
| Poll interval (reference default; caller-driven elsewhere) | 20 ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Conformance checklist
|
||||||
|
|
||||||
|
A C++ reader is done when it passes these.
|
||||||
|
|
||||||
|
- [ ] Walks a marked file (marker + header frame + N data frames) and produces a frame table whose
|
||||||
|
first entry starts at source offset 24 and whose entries are contiguous.
|
||||||
|
- [ ] Rejects an unmarked file — e.g. one produced by plain `zstd file.pcap` — with a distinct
|
||||||
|
"not our format" error.
|
||||||
|
- [ ] Rejects a marker with a bad version byte.
|
||||||
|
- [ ] Handles a truncated marker (fewer than 14 bytes on disk) as "need more", not as an error.
|
||||||
|
- [ ] Tails a growing file: a frame appended mid-poll is surfaced on a subsequent poll, and a torn
|
||||||
|
trailing frame is *not* surfaced until complete.
|
||||||
|
- [ ] Whole-file `zstd -d` output equals the source `.pcap` byte for byte.
|
||||||
|
- [ ] Decompressing frame *n* alone yields exactly `src_end - src_start` bytes, and those bytes are
|
||||||
|
a whole number of pcap records.
|
||||||
|
- [ ] A corrupted byte inside a frame surfaces as a decode error (checksum), not as packets.
|
||||||
|
- [ ] `FileId` computed from a `.pcap.zst` equals the `FileId` computed from the same capture
|
||||||
|
stored as a raw `.pcap`.
|
||||||
|
- [ ] **Rename:** a `.partial` renamed mid-stream is detected; the file is finalized with the
|
||||||
|
**source** size (not the compressed size); a consumer attached across the rename sees no
|
||||||
|
interruption and no ENOENT; a new consumer attaching *during* the drain window opens the
|
||||||
|
final path successfully.
|
||||||
|
- [ ] A file discovered already-final (no `.partial`) is treated as complete without waiting for a
|
||||||
|
rename.
|
||||||
|
|
||||||
Reference in New Issue
Block a user