A pcap/pcapng reader that takes bytes, not filenames (#100) - #142
Merged
Conversation
New netprotocols.pcap: read_captures(buffer) auto-detects classic pcap vs. pcapng from its magic number and yields CapturedFrame (timestamp in nanoseconds since the Unix epoch, normalized from whatever resolution the source recorded; data, the frame's raw bytes) -- read_pcap()/read_pcapng() are the same for a caller who already knows the format. pcapng support covers exactly the block types frames can come from -- Section Header, Interface Description (read only for its if_tsresol option), Enhanced Packet, and Simple Packet (which the format gives no timestamp at all, hence 0) -- every other block type is skipped wholesale. A malformed or truncated capture raises the new MalformedCaptureError (ProtocolError family, no lax mode: a corrupt container is a different failure shape than a malformed header inside one already-extracted frame). Format detection is eager; producing frames is lazy, so a bad record downstream doesn't invalidate what already iterated cleanly. tests/conftest.py drops the private classic-pcap reader every test file reached for -- pcap_frames() is a thin adapter over the shipped read_pcap(), and the ~10 dependent test files are migrated onto it. tests/test_pcap.py keeps its own independent reference reader rather than reusing either, so a bug shared between builder and reader under test can't cancel itself out -- the same precedent scripts/benchmark.py and scripts/check_fixtures.py already set. One design idea was tried and reverted on measurement: slicing each frame lazily out of a memoryview over the whole buffer, to keep large captures zero-copy. Measured across synthetic captures up to ~140MB, it was never faster and sometimes slower than one upfront bytes(buffer) copy plus ordinary bytes slicing -- a real capture is many small frames, and a memoryview slice's own overhead is paid per frame, which is #88's identical single-frame finding generalized rather than contradicted. docs/CLAIMS.md 5.8 is corrected accordingly: it previously forward-referenced this issue with an unverified "1.8x" figure that this implementation does not reproduce. Closes#100. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDTcK51CWcy6PrNetN213
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tier 4 (#106), item 3 of 3 — the largest and the only one adding public API surface. Closes#100.
What's included
netprotocols.pcap:read_captures(buffer: bytes | memoryview) -> Iterator[CapturedFrame]auto-detects classic pcap vs. pcapng from the magic bytes;read_pcap()/read_pcapng()are the same for a caller who already knows the format.CapturedFrame: aNamedTuple(timestamp: int,data: bytes) — mirrors the existingFlowKeyprecedent for "a derived value, not a wire format, is aNamedTuple". Every timestamp format (classic pcap µs/ns from its magic; pcapng's per-interfaceif_tsresol-scaled Enhanced Packet Block timestamp) is normalized to nanoseconds since the Unix epoch at read time. A pcapng Simple Packet Block carries no timestamp at all (the block format has none) — those frames report0, not a guess.if_tsresol), Enhanced Packet (EPB), Simple Packet (SPB). Every other block type (Interface Statistics, Name Resolution, Decryption Secrets, vendor/custom) is skipped wholesale. Multiple concatenated sections in one buffer are supported — each section's byte order and interface list are independent, per spec.MalformedCaptureError(ProtocolErrorfamily, inutils/exceptions.py): a malformed/truncated capture raises this rather than a bareValueError. Nolax=Truemode — a corrupt container is a different failure shape than a malformed header inside an already-extracted frame (decode_frame'slaxis for the latter).decode_frame's immediate-raise contract.tests/conftest.pydrops its private classic-pcap reader (acceptance criterion 4) —pcap_frames()is now a thin adapter over the shippedread_pcap(), and all ~10 dependent test files (test_corpus.py,test_dhcp.py,test_dns.py,test_gre.py,test_igmp.py,test_ipv6_ext.py, plustest_checksum.py/test_docs.py/test_fuzz.py/test_walk.pywhich only neededcorpus_frames()and required no changes) are migrated onto it.tests/test_pcap.pykeeps its own independent reference reader for its cross-check — deliberately never importing the module under test, the same "standalone, so a shared bug can't cancel itself out" precedentscripts/benchmark.pyandscripts/check_fixtures.pyalready set.if_tsresolvalues essentially never occur in real traffic, sotests/test_pcap.pyincludes small little-/big-endian block builders instead of adding non-real binary fixtures totests/fixtures/, keeping that directory's real-capture-only guarantee (MANIFEST.md) intact. Classic-pcap coverage reuses the real corpus as normal.docs/CLAIMS.md: 3.1 already referenced the memoryview-vs-bytes benchmark as motivation for this issue — see the note below on why that specific number needed correcting, not just citing.README.md: new "Reading captures" section (non-comparative, no README/CLAIMS embargo implications).ARCHITECTURE.md's layout map gets a one-line entry for the new module.CHANGELOG.mdentry under## [Unreleased].A design idea tried and reverted — read before reviewing pcap.py
docs/CLAIMS.md5.8 forward-referenced this issue with an unverified "1.8x" figure for slicing frames lazily out of amemoryviewover the whole capture buffer (zero-copy) instead of copying the buffer once up front and slicing plainbytesper frame. I implemented the memoryview-passthrough version first and could not reproduce any speedup — measured across synthetic captures from ~6MB to ~140MB, it was 0.91x–0.98x (never faster, sometimes slower). The reason generalizes issue #88's own already-documented finding (amemoryviewcosts more to build than the copy it saves for one small frame) to many small frames: a real capture is overwhelmingly small frames, not one giant one, and the per-framememoryviewslice overhead outweighs the one-time copy it was meant to avoid.Reverted to: copy the input once up front (
bytes(buffer)), return plainbytesper frame regardless of whetherbytesormemoryviewwas passed in.docs/CLAIMS.md5.8 is corrected with the real measurement rather than left with the unverified forward-reference — flagging this prominently since it's a case of the PR not matching the issue's own pre-written motivating text, on purpose, with numbers to back it.Verification
uv run ruff checkanduv run ruff format --checkare cleanuv run mypyis clean (strict)uv run pytestpasses locally — full suite green,netprotocols/pcap.pyand the migrated test files at 100%/full coverage (--cov=netprotocolsreports 99.95% overall, gate is 98%)CHANGELOG.mdhas an entry under## [Unreleased]uv run --group bench python scripts/benchmark.py --check --threshold 15— within threshold (this PR doesn't touch the decode hot path at all; one transient run on the shared sandbox briefly read -15.5%, three clean re-runs afterward landed at -11.6% to -12.8%, consistent with the benchmark script's own documented "a shared runner can lose a slice of CPU to a neighbour" caveat, not a real regression)tests/test_pcap.py's classic-pcap tests cross-check every real corpus frame (97 frames, 17 files) against an independent reference reader — zero mismatchesread_captures()example runs end-to-end against a real fixtureNotes
Q7 (whether to attempt #100 this tier at all) was resolved "yes" up front, along with the rest of the Tier 4 design questions, before any code was written.
Closes#100.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MGDTcK51CWcy6PrNetN213
Generated by Claude Code