Skip to content

foundation: give IP reassembly the RFC timeout, and trace TCP flows bidirectionally - #435

Merged
JarryShaw merged 8 commits into
mainfrom
feat/reassembly-timeout-traceflow-bidi
Sep 17, 2026
Merged

JarryShaw merged 8 commits into
mainfrom
feat/reassembly-timeout-traceflow-bidi

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Closes the two reassembly and traceflow items on the Help Wanted page, docs/source/pep.rst under "Reassembly Beyond IP and TCP" (added by #419, mirroring discussion #106 #discussioncomment-18470233):

The TCP tracer itself closes a flow on FIN but never on RST, which is not in its packet model at all, and treats each direction of a connection as a separate flow.

Nothing ever times a partial datagram out. RFC 791 gives IP reassembly a 15-second timer and RFC 8200 gives IPv6 60 seconds; neither is implemented, and neither can be until the buffer models carry a timestamp.

What the timeout means for an offline parser

The clock is the capture's own timestamps. Wall clock says nothing about a file, and time.time() would make the same capture reassemble differently on every run — which would break both the fixtures and any reproducible comparison. A fragment arriving is the only evidence that capture time advanced, so the sweep runs when a packet is handed over rather than on a timer.

That has a consequence worth documenting rather than hiding: the clock only advances while that particular reassembler is fed. For IPv4 and TCP that is nearly every frame; for IPv6 it is only fragments. So a stalled IPv6 buffer with nothing behind it reports PARTIAL, not TIMEOUT — the honest answer, since the capture never shows the deadline passing.

60 seconds for both IPv4 and IPv6, and the page's 15 seconds was wrong. RFC 1122 §3.3.2 supersedes RFC 791 here: the timeout "SHOULD be a fixed value, not set from the remaining TTL… between 60 seconds and 120 seconds". RFC 791's 15 s is an initial setting that MAX(TIMER, TTL) raises toward the 4.25-minute TTL ceiling, so it is not a deadline — and TTL is not in the packet model anyway. pep.rst is corrected in this PR. TCP gets no timeout by default: no specification gives stream reassembly one and an idle connection is ordinary, but timeout= enables one.

Two behaviour changes, both opt-out-able

1. Datagram.completed widens from bool to a Completion enumCOMPLETE / PARTIAL / TIMEOUT, same field, same channel, so a caller learns why a datagram is incomplete without a parallel vocabulary. Only COMPLETE is truthy, so if datagram.completed: and assertTrue/assertFalse are unchanged. datagram.completed == True no longer holds — that is the breaking shape.

2. Bidirectional TCP flow tracing, on by default, with trace_bidirectional=False on extract(), Extractor and follow_tcp_stream(), and bidirectional=False on TraceFlow. TCP.make_bufid() orders the endpoints canonically so both halves reduce to one key; the label still comes from the packet that opened the flow, so labels are unchanged. Index gains forward and reverse. A flow closes once both halves have FINed — closing on the first would cut the peer's FIN and final ACK out, and they would then open a second flow under the same ID, which is the very split being removed.

Default-on because pep.rst lists per-direction keying as a defect, beside "closes on FIN but never on RST", rather than as a missing option — and because the module is called "Follow TCP Stream", which everywhere else means the conversation.

Flow output changes, and the argument for it

An earlier revision of this description had these numbers wrong, and the way they were wrong is worth recording: it reported http.pcap at 220 flows, of which 109 were single-frame, and read those 109 as one-directional conversations. They were not — they were the defect. Each conversation was a 9-frame flow plus a 1-frame stray tail, created when the flow was finalised on the teardown and the final acknowledgement then opened a second buffer under the same ID. Fixing the close condition removes the tails.

capture main this PR single-frame flows
http.pcap 331 111 109 → 0
tcp.pcap 4 2
in.pcap 3 2
corpus 355 122

http.pcap has 111 conversations and every one of them now carries both directions in a single flow. bidirectional=False reproduces main exactly at 355 flows.

traced_frames is identical in every capture — 1222 total, nothing lost and nothing double-counted, which is what makes the flow-count drop a merge rather than a loss. Concretely on in.pcap: frame 3 is 123.129.210.135:80 → 192.168.1.100:55232 and frame 4 is the same connection reversed; they now form one flow with forward=(3,) and reverse=(4,). Same address pair, same port pair, opposite directions — one TCP connection.

Verified independently through follow_tcp_stream on in.pcap, with payload conserved in every configuration:

origin/main                     3 streams: 70 + 70 + 70 = 210 octets
this PR, default                2 streams: 140 + 70      = 210 octets
this PR, trace_bidirectional=0  3 streams: 70 + 70 + 70  = 210 octets   (identical to main)

So the merged stream carries both directions' payload, which is what following a stream means, and the opt-out reproduces main exactly.

Also fixed, beyond the brief

  • Three strict=False cases, not two, and they are not symmetric — an earlier draft of this description got that wrong.

    • TCP: payload unchanged — the buffer with its holes zero-filled, which is what follow_tcp_stream reconstructs a stream from. Only completed stopped claiming a holed datagram was complete.
    • IP, final fragment received (TDL > 0): payload unchanged, holes zero-filled, as TCP does. Only completed became honest.
    • IP, final fragment never received (TDL still -1): the payload changed. It was datagram[:-1] — 65534 octets of preallocated buffer, reported complete. It is now the contiguous prefix, offset zero to the first hole; not b'', because those octets really did arrive. strict=True, the default, still lists the runs.

    The original wording, kept for the record of what was wrong: TCP reported a hole-ridden buffer as complete, passing zero-filled gaps off as received data; there the payload shape really is unchanged, a test pins it and follow_tcp_stream depends on the blob, so only completed became honest. IP is different. It sliced datagram[:TDL] with TDL still -1, handing back 65534 octets of preallocated buffer — almost all of them zeros the sender never sent — and calling it complete. That payload could not be left as it was. It now reports the contiguous prefix: every octet from offset zero up to the first hole. That is the longest run whose extent is known without knowing the total length, it is all genuinely received, and it is the part a caller asking for one blob can actually use, since a parser reading from the start cannot use a run that begins after an unmeasured gap. Reporting b'' was considered and rejected as discarding data that really arrived; anything past the first hole is still reported by strict=True, which lists the runs precisely because their offsets cannot be conveyed in a blob. RCVBT records receipt in 8-octet units, so the prefix ends on that boundary.

  • Scapy's traceflow reported time.time() as the flow timestamp, putting the parse moment into every label and output filename. Now float(packet.time), i.e. deterministic.

  • Datagram.__init__ overloads removed. They promised complete ⇒ bytes and incomplete ⇒ tuple, which only held because loose mode was lying, and could not be selected from a runtime-computed value anyway.

  • DPKT is the one adapter that cannot self-supply a timestamp — it returns (ts, buf) and the engine keeps only the packet — so its three reassembly adapters take it positionally. follow_tcp_stream binds 0.0 there via functools.partial, safe because TCP has no timeout by default, and documented at the call site.

Deliberately not done

Wiring traceflow's analyze is not in this PR, and the record says it should not be. PR #424 #discussion_r4031491063 settled it: traceflow buffers no parsed protocol at all — only a dumper, frame indices and a label — and grep finds no analyze() call anywhere under foundation/traceflow/. So there is no second parse to postpone; adding one is a new capability needing a design decision first (does the tracer grow a per-direction payload buffer, or delegate to TCP_Reassembly as follow_tcp_stream already does?). Recorded in pep.rst as the remaining open item there.

Verification

Suite 893 passed / 17 skipped / 848 subtests / 0 failed, against a branch-point baseline of 859 / 35 / 844 — collection 910 versus 894, exactly the +16 tests added. The 18 extra baseline skips are all tests/test_tier_guard.py, because a git archive scratch tree is not a checkout; worth knowing for anyone measuring a baseline that way.

All 15 captures produce byte-identical tree, json and reassembly output with ip/tcp/reassembly=True; no capture hits a timeout — 1479 datagrams, all COMPLETE. make_samples.py regenerates byte-identically. mypy 127 errors versus 128 on the baseline; pylint 4689 versus 4706, rating 8.76 → 8.77. Docs build clean for the changed content.

Comment thread pcapkit/foundation/traceflow/tcp.py Outdated
Comment thread pcapkit/foundation/reassembly/ip.py Outdated
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of #435 at a939c4f3b3364649c6980794ec94d869d555e57b

Reviewed as a stand-in for Copilot (out of tokens). Checked out the branch in an isolated worktree, cloned a pristine main (44aa38ae8) for comparison, and read the full 41-file diff.

What I ran

  • Generated the sample corpus (python examples/generators/make_samples.py) — 15 captures, matching the PR body.
  • Full suite: PYTHONSAFEPATH=1 PYTHONPATH=<worktree> .venv/bin/python -m pytest tests/ -q893 passed, 17 skipped, 848 subtests passed, 0 failed — matches the PR body exactly.
  • Extracted all 15 generated captures with ip=True, tcp=True, reassembly=True1479 datagrams, all COMPLETE, matching the body's "no capture hits a timeout" claim.
  • Verified the RFC quotes against the actual RFC text (fetched rfc-editor.org/rfc/rfc791.txt, rfc1122.txt, rfc8200.txt): the RFC 791 "15 seconds initial timer, TIMER <- MAX(TIMER,TTL), ~4.25 min ceiling" description, the RFC 1122 §3.3.2 "SHOULD be a fixed value, not set from the remaining TTL... between 60 seconds and 120 seconds" quote, and the RFC 8200 §4.5 "within 60 seconds of the reception of the first-arriving fragment" quote are all verbatim-accurate. The claim that RFC 1122 supersedes RFC 791's scheme is a fair reading of the text. No finding here — this part of the PR description checks out.
  • Independently constructed synthetic packets (bypassing the toolkit adapters) to probe: Completion truthiness across every .completed consumer in the repo (grepped the whole tree — every test uses assertTrue/assertFalse/assertIs, none compare == True/== False, all consistent with the enum's __bool__), timestamps going backwards (deterministic, no crash), an infinite/negative/explicit timeout, PCAP-NG differing per-interface timestamp resolution (already normalized upstream by _get_resolution, unaffected by this PR), and the DPKT functools.partial(..., timestamp=0.0) binding used by follow_tcp_stream (safe, since TCP's default timeout is math.inf).
  • Reproduced the two strict=False fixes directly against both trees (see the two inline comments) and the TCP bidirectional-tracing closing/merging behaviour against synthetic 4-way-close + port-reuse sequences.

Findings posted inline (2):

  1. pcapkit/foundation/traceflow/tcp.py:216 — the bidirectional close condition fires on the connection's second FIN, before its final ACK typically arrives, so that ACK opens a stray one-packet buffer under the same canonical key; a later, unrelated connection reusing the same 4-tuple then merges into that stray buffer, undermining the "one flow per connection" property the bidirectional default exists to provide. Reproduced with concrete frame numbers.
  2. pcapkit/foundation/reassembly/ip.py:233 — for the IP strict=False + never-completed-datagram fix, the PR description's "payload shape is deliberately unchanged" doesn't hold: the payload goes from 65534 (mostly garbage) bytes to 0 bytes, discarding the legitimately-received prefix too, and no test pins the new value the way the analogous TCP fix is pinned. Verified against both trees.

What I did not fully verify: a byte-for-byte corpus-wide diff of tree/json dump output between main and the branch (I spot-checked the reassembly completion counts across all 15 captures instead, which matched); the mypy/pylint counts (explicitly out of scope per the review brief); and I did not exercise the RST-not-handled gap beyond confirming it's still accurately documented (no rst field exists in the traceflow Packet model, as claimed).

Verdict: Not clean. Finding 1 is a real correctness gap in the PR's own headline feature — a common capture pattern (graceful close followed by port reuse) can silently merge two unrelated TCP connections into one reported flow / one reconstructed follow_tcp_stream conversation. I would not call this good-to-merge as-is; I'd want finding 1 fixed (or at least explicitly documented as a known limitation) before merging, and finding 2 addressed or the description corrected. Everything else I checked — the RFC claims, the enum truthiness change, the timeout determinism, the test-suite and datagram-count claims — held up exactly as described.

JarryShaw added a commit that referenced this pull request Sep 17, 2026
…mode claim

Two findings from the review of #435.

## A flow closed one packet too early, and reused ports merged (blocking)

`closed = len(buffer.fin) >= 2` submitted a bidirectional flow on the second FIN
of its four-way close. The close is FIN, ACK, FIN, ACK, so the final ACK arrived
after the buffer had been popped and opened a fresh buffer under the same
canonical BUFID -- which a later connection reusing those endpoints then merged
into. The NOTE above that line predicted exactly this defect for closing on the
*first* FIN and was only half-applied. Reproduced on the reviewed head with a
four-way close plus reuse: frames (6, 7, 8) came back as one flow, mixing
connection one's final ACK with connection two.

It was visible in the corpus all along. http.pcap traced 220 flows, of which
**109 were single-frame stray tails** beside 109 nine-frame flows -- each
conversation split in two. It now traces 111, every one two-way, none of one
frame, all 1117 frames still in exactly one flow. My earlier report read those
109 as one-directional conversations; they were the bug.

The fix is not a later close condition but a different question. Observing a
teardown is not knowing that nothing more will arrive: duplicates of the final ACK
can follow, so no rule naming the last packet of the exchange can hold. So a
teardown -- FIN from both endpoints, or RST from either -- is *recorded* and
finalises nothing; the flow keeps the packets that belong to it. It is finalised
only by proof that no more can come: a new connection's SYN on the same endpoints,
or the end of the capture, via the new `TraceFlow.finish()` that
`Extractor._cleanup` calls. Callbacks fire there, so they see the whole
conversation. `submit()` still reports an unfinalised flow, so reading `index`
mid-capture cannot strand the rest of a conversation in a second flow.

Telling that SYN from the peer's SYN-ACK is what the recorded teardown is for --
a SYN-ACK cannot follow a completed teardown. That also closes the RST gap
`pep.rst` documents: `rst` is now on the traceflow packet model and reported by
all six adapters that build one.

`bidirectional=False` still closes on FIN, ignores RST, and reproduces main
exactly at 355 flows.

## The IP loose-mode payload *did* change (correcting the earlier claim)

The previous commit message said of both `strict=False` fixes that "the payload
shape is unchanged, so follow_tcp_stream still reconstructs what it did". That is
true of the TCP fix and **not** of the IP one, where the payload changed. Stated
properly:

- **TCP**: payload unchanged -- the buffer with its holes zero-filled, which is
  what `follow_tcp_stream` reconstructs a stream from. Only `completed` stopped
  claiming a holed datagram was complete.
- **IP, final fragment received** (`TDL > 0`): payload unchanged, holes
  zero-filled, as TCP does. Only `completed` became honest.
- **IP, final fragment never received** (`TDL` still `-1`): the payload changed.
  It was `datagram[:-1]`, 65534 octets of preallocated buffer reported complete.
  It is now the **contiguous prefix** -- offset zero to the first hole -- rather
  than the `b''` an earlier draft of this branch returned, because those octets
  really did arrive and a blob cannot convey the offset of a run that starts after
  an unmeasured gap. `strict=True`, the default, still lists the runs.

Both branches now have tests; the IP one had none.

Suite 901 passed / 17 skipped / 848 subtests, 0 failed. All 15 sample captures
still give byte-identical tree, json and reassembly output against the branch
point, and still regenerate byte-identically; 1479 datagrams, all COMPLETE.
mypy 127 against 128; pylint 4686 against 4706.
Comment thread docs/source/pep.rst Outdated
@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewed at head a206459f3 (the sha named in the review brief; the PR has since gained an unrelated merge to 87099110f bringing in HIP/IPv4 protocol changes from maingit diff a206459f3 8709911 touches only docs/source/ext.rst, docs/source/pcapkit/protocols/internet/{hip,ipv4}.rst, pcapkit/foundation/registry/protocols.py, pcapkit/protocols/internet/{hip,ipv4}.py and their tests; nothing under traceflow/, reassembly/, or pep.rst, so everything below still applies unchanged).

CI at a206459f3: all green — 21 checks SUCCESS, 2 SKIPPED (Docs test gate, Gate (full suite, Python 3.14), both legitimately skipped, not failed), plus pyup.io/safety-ci SUCCESS. Confirmed via gh pr view 435 --json statusCheckRollup directly against the API, not a local suite.

What I checked

Finding 1 (traceflow redesign). Read pcapkit/foundation/traceflow/tcp.py, traceflow/data/tcp.py, traceflow/traceflow.py, foundation/extraction.py's wiring of finish()/trace_bidirectional, and interface/core.py / interface/misc.py's parameter threading. Traced the logic by hand through every scenario called out in the brief:

  • Capture starting mid-connection, no SYN ever seen: buffer just accumulates under whatever origin the first-seen packet gives it; never superseded without a SYN, sits until finish(). No defect — matches the disclosed "undecided" case.
  • Retransmitted SYN (either side): _ended() is false on a live buffer, so the supersede branch never fires; the retransmit just joins the buffer as an ordinary packet. Correct.
  • Simultaneous open / SYN arriving before any teardown recorded: _ended() false → merged into the existing buffer rather than treated as a new flow. This is exactly the behaviour pep.rst discloses as deliberately undecided (no ACK flag to disambiguate a genuine reuse from a continuation).
  • RST followed by more packets of the same connection: reset=True is sticky and _ended() goes true immediately, but nothing pops the buffer until a superseding SYN or finish() — later packets keep landing in the same buffer/forward/reverse lists as intended.
  • finish() called twice, or with nothing buffered: it drains self._buffer with list(self._buffer), so a second call (or a call on an empty tracer) is a no-op. Verified against tests/foundation/traceflow/test_tcp.py:278-285, which asserts this directly.
  • make_bufid canonicalisation: verified algebraically that both directions of a conversation always reduce to the same tuple regardless of which endpoint is numerically smaller.
  • Long-lived-connection memory question: a completed bidirectional flow now stays in self._buffer (not moved to self._stream) until superseded or finish(), rather than closing as soon as its own FIN went out. I could not turn this into a demonstrable regression: dictdumper.Dumper.__call__ opens/writes/closes the output file on every call (verified by reading the installed dictdumper source), so no file descriptor is held open across the delay, and self._stream already accumulated every finished flow for the life of the object in the pre-PR code too, so total retained data is the same order either way. Flagging only as something to keep an eye on for very large captures with many long-lived, never-superseded connections, not as a defect.

Finding 2 (IP strict=False contiguous prefix). Read pcapkit/foundation/reassembly/ip.py's new submit() branch and the RCVBT bit-table semantics. TDL > 0 path is untouched (zero-filled buffer, as before). TDL == -1 path walks RCVBT from the front and stops at the first clear bit, stop = received * 8 — confirmed this returns b'' when the very first 8-octet unit is missing (not an error), and correctly returns nothing past the first hole. strict=True branch's payload-building loop is untouched by the diff (only completed changed type); confirmed by diff inspection.

Everywhere Completion flows: read reassembly/data/data.py's enum, __bool__/__str__, and every submit() in ip.py/tcp.py that constructs it. TCP's strict=False path is explicitly documented as keeping the old zero-filled-whole-buffer payload, only completed changes — confirmed by diff, matches follow_tcp_stream's use of it.

Toolkit adapters: confirmed all 6 engines that support TCP trace (dpkt, pcap, pcapng, pypcapfile, pyshark, scapy) now populate Packet.rst, reading the right flag bit/attribute for each engine's packet model. Noticed in passing (not a finding, a good catch already in this diff) that toolkit/scapy.py's tcp_traceflow used to stamp timestamp=time.time() — the host clock — and now uses float(packet.time), the capture's own clock, consistent with the rest of the PR's "capture's own clock" design.

Claims independently reproduced

Checked out a206459f3 in a clean tree, regenerated examples/captures/ via make_samples.py, and ran with pcapkit.__file__ printed and asserted to be under the tree being tested (PYTHONSAFEPATH=1, tree inserted at sys.path[0]):

  • pytest tests/ -q917 passed, 17 skipped, 862 subtests passed, 0 failed (550s). Matches exactly.
  • Flow counts across the full 15-capture corpus, both modes:
    • default (bidirectional): 122 flows, 1222 traced frames total; http.pcap 111 flows, 0 single-frame (was 331/109 in nobidir mode below).
    • trace_bidirectional=False: 355 flows, 1222 traced frames total — and this reproduces, file-for-file, an actual git clone + checkout of origin/main (84233da3a) run through the same script: 355/117-single/1222, identical per capture. Not just self-consistent with the PR's own claims — checked against real old code.
  • Reassembly: 1479 datagrams, all Completion.COMPLETE, across the same corpus. Matches.
  • mypy pcapkit --config-file mypy.ini: 127 errors on a206459f3 vs 128 on origin/main. Diffed the two error lists line-by-line: the delta is exactly one removed error (tcp.py's old @overload-based Datagram.__init__ mismatch, which the PR deliberately removed) plus line-number shifts of pre-existing errors. Matches, and the mechanism checks out.
  • pylint cyclic-import on the same unchanged tree, two consecutive runs: 130 then 117 lines. Confirms the count is non-deterministic run-to-run on identical code, consistent with the "28-message swing on an unchanged baseline" claim — spot-checked, not fully re-derived.

Findings posted

One inline comment on docs/source/pep.rst (line 580, anchored on a206459f3, auto-remapped by GitHub to the current head 87099110f since that file is untouched between the two): the "wiring the application layer into flow tracing" paragraph is pasted twice, verbatim, in the flow-tracing bullet (lines 563-572 and 580-589). It's a documentation duplication, not a code defect — the code matches the (de-duplicated) disclosure. Worth a cleanup pass before merge but not blocking.

Not covered

Did not review: the docs/source/*.rst API-doc changes beyond pep.rst (mechanical, tracks the code); tests/ file contents beyond spot-reading test_tcp.py (traceflow) and skimming test_timeout.py's structure — I verified their outcomes by running the suite rather than auditing every assertion; the RCVBT marking arithmetic in reassembly/ip.py's reassembly() method itself (start/stop-on-8-octet-boundary bit-setting) is pre-existing code untouched by this diff, so I didn't re-litigate it even though the new contiguous-prefix code depends on it being accurate.

Verdict

No blocking defects found. One minor, non-blocking documentation issue posted inline (duplicate paragraph in pep.rst). CI is green at a206459f3. Good to merge, modulo the doc cleanup at the reviewer's discretion.

Comment thread pcapkit/foundation/reassembly/data/data.py Outdated
Comment thread docs/source/pep.rst Outdated
Comment thread pcapkit/foundation/reassembly/ip.py
Comment thread pcapkit/foundation/traceflow/tcp.py Outdated
Comment thread pcapkit/interface/misc.py Outdated
JarryShaw added a commit that referenced this pull request Sep 17, 2026
…ding timestamps

Five review comments on #435.

## The DPKT engine was throwing the timestamp away (substantive)

The fix was not where the comment sat. DPKT's reader yields ``(timestamp, bytes)``
and only the octets became a packet, so a *stored* frame did not know when it was
captured and ``follow_tcp_stream`` had nothing to pass its reassembler but a bound
``functools.partial(timestamp=0.0)``. The timestamp was never unavailable -- it was
being discarded at the engine.

``DPKT.read_frame`` now attaches it (``pcapkit.toolkit.dpkt.attach_timestamp``,
read back by ``packet2timestamp``, which raises rather than defaulting for a frame
that never came through the engine), the partial is gone, and the NOTE that
asserted DPKT "cannot read a frame's capture timestamp off the frame" -- the
framing that led to the wrong fix -- is corrected here and in the three adapter
docstrings that repeated it.

**No behaviour change**, as expected: TCP reassembly has no timeout by default, so
the value reaches no decision. ``follow_tcp_stream`` on the DPKT engine returns
the same streams and conversations as the default engine, which is now pinned by a
test; the timestamps attached to ``in.pcap`` are the capture's own
(1511106545.471719 …), not zeros.

## The application layer is wired into flow tracing (scope addition)

Previously declined on the grounds that traceflow buffers no payload so there is
no second parse to postpone. That is true, which is why this is a capability
rather than a deferral -- and of the two designs the page posed, the tracer
**delegates to** ``reassembly.tcp.TCP`` rather than growing a payload buffer of
its own: a buffer concatenating payloads in capture order is silently wrong on the
first retransmission or reordered segment, where RFC 815's hole-descriptor
algorithm already in the reassembler is not. A test delivers segments out of order
and asserts sequence order comes back.

So the traceflow packet carries the four segment fields that reassembler needs
(``seq``, ``ack``, ``header``, ``payload``) from exactly where each engine's
sibling ``tcp_reassembly`` adapter already reads them, each flow owns a
reassembler, and ``Index.packet`` postpones twice: reading it flushes the flow's
reassembler, and each datagram's own ``packet`` is parsed later still -- the same
``Deferred`` arrangement as the reassembly side, mirrored in
``traceflow/data/data.py`` for the same reason those two modules already mirror
each other.

**Opt-in** (``analyse=True``, ``trace_analyse=True`` on Extractor, extract() and
follow_tcp_stream): buffering every traced payload is a cost tracing does not
otherwise pay, and tracing's per-packet cost is something this package has
deliberately driven down. Off by default nothing is buffered, reassembled or
parsed, and flow counts are unchanged. Refused on the ``pyshark`` engine, which
reports dissected fields rather than octets -- the same reason it has no
reassembly adapter.

## Completion uses StrEnum (question)

``pcapkit.utilities.compat.StrEnum``, as ``httpv1.Type`` and
``pcapng.TLSKeyLabel`` do. Verified on both the stdlib and aenum paths that it
keeps every property the contract needs: only COMPLETE truthy, ``== True`` and
``== False`` both false. It adds ``json.dumps`` support -- a plain Enum raises
TypeError, and ``to_dict()`` hands the field straight out -- and
``completed == 'timeout'``. The one cost is documented in a Warning: a non-empty
string that tests false, so ``bool(x)`` and ``bool(str(x))`` disagree. The
redundant ``__str__`` is gone, StrEnum already giving it.

## Quoting and alignment (questions)

``Buffer[_AT]`` unquoted, matching ``Packet[_AT]`` beside it and the sibling
``reassembly.ip.IP``. Comment columns re-aligned in both reassembly modules, where
``TS = info.timestamp`` had pushed its own comment a column right of the rest.

Also silences, on both DeferredPacket mixins, the mypy ``[misc]`` and pylint
``no-member`` false positives about ``super()`` calls undefined on a mixin --
pylint rates those as *errors*.

Suite 930 passed / 17 skipped / 929 subtests, 0 failed; 947 collected against 916
on origin/main, the difference being this branch's own tests. All 15 sample
captures give byte-identical tree, json and reassembly output against origin/main
and still regenerate byte-identically; 1479 datagrams, all COMPLETE. Flow counts
unchanged at 122 bidirectional / 355 unidirectional, 1222 traced frames either
way. mypy 124 against 128; pylint 4712 against 4723.
Comment thread pcapkit/foundation/traceflow/tcp.py Outdated
Comment thread pcapkit/foundation/traceflow/tcp.py
Comment thread pcapkit/foundation/reassembly/tcp.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of PR #435 at head c4934656c

Acting as the automated reviewer (GitHub Copilot is out of tokens). This is a full review of the current head, not a delta from the two earlier verdicts (a939c4f3b, a206459f3) — item 3 below was added after those.

CI

gh pr view 435 --json statusCheckRollup / gh pr checks 435, both re-run against head c4934656c2804cc05b77fa58040eb6a60c8074fe: every real job is SUCCESS/passAnalyze, all Python 3.10-3.15, all Compat Python 3.10-3.15, all Integration Python 3.10-3.15, CodeQL, deploy-pages, pyup.io/safety-ci. Docs test gate and Gate (full suite, Python 3.14) show SKIPPED/skipping; checked .github/workflows/unit-tests.yml — both are gated on inputs.gate-only == true, which is a workflow_call input never set for a pull_request trigger, so skipping here is expected, not a masked failure. No red anywhere.

What I checked

  • Confirmed origin/main (c28ffc287) is fully contained in the PR branch (git rev-list --count pr-435..origin/main = 0; PR branch is 9 commits ahead) — read all "what main contains" diffs via git show origin/main:<path> / git diff origin/main...pr-435, never the working tree.
  • Read the full diff (45 files, +2561/-240) and the PR body against it. The three claimed capabilities (RFC-1122 60s IPv4/IPv6 reassembly timeout on the capture's own clock, default-on bidirectional TCP flow tracing with teardown-recorded-not-finalizing semantics, and Deferred wired into flow tracing via delegation to reassembly.tcp.TCP) all match code that is actually present in the diff.
  • Verified the Completion StrEnum contract by hand (bool(COMPLETE) True, bool(PARTIAL)/bool(TIMEOUT) False, COMPLETE == True is False, json.dumps round-trips, == 'timeout' works) — matches the PR body exactly.
  • Read the DPKT attach_timestamp/packet2timestamp change (pcapkit/foundation/engines/dpkt.py, pcapkit/toolkit/dpkt.py) — raises UnsupportedCall rather than defaulting, as claimed, and tcp_traceflow now supplies rst/seq/ack/header/payload, consistent with what analyse=True needs.
  • Read the IP strict=False, TDL-never-received change (pcapkit/foundation/reassembly/ip.py): confirmed it reports the contiguous prefix up to the first hole (RCVBT-derived), not b'' and not the old datagram[:-1] 65534-octet slice.
  • Spot-checked the disclosed pylint suppressions: both DeferredPacket mixins (pcapkit/foundation/traceflow/data/data.py, pcapkit/foundation/reassembly/data/data.py) carry the same super()-on-a-mixin rationale for the E1101/no-member suppressions, as described.
  • Built and ran targeted probes against this exact checkout (PYTHONSAFEPATH=1, PYTHONPATH at this worktree, pcapkit.__file__ printed and confirmed under it each time, interpreter .venv/bin/python 3.14) covering every scenario named for item 3: a flow with no payload at all (clean, empty tuple, no crash), a flow finalised by finish() with no FIN/RST ever seen (reports Completion.COMPLETE rather than PARTIAL — pre-existing reassembler semantics, see inline note), reading Index.packet twice (cached, same object, no double work), reading it after the tracer object is garbage-collected (no crash — each flow's Deferred owns its own reassembler independently), a retransmission with different payload at the same sequence (silent last-write-wins, still COMPLETE), analyse=True with bidirectional=False (works correctly, two independent unidirectional reassemblers), and a multi-round-trip bidirectional exchange (see finding below — this is the one that doesn't hold up).

Findings (posted inline)

  1. pcapkit/foundation/traceflow/tcp.py:84Index.packet does not hold "one reassembled datagram per direction" in general; it holds one datagram per distinct ACK value seen in that direction, inherited from reassembly.tcp.TCP's (BUFID, ACK)-keyed buffering. A 3-round-trip exchange on one connection comes back as 6 Datagram entries, not 2. The PR's own test for this (test_analyse_gives_a_flow_its_application_layer_per_direction) only ever uses a constant ACK per side, so it can't see this. This is the substantive one — it means the new capability doesn't deliver "one datagram per direction" for any realistic keep-alive/RPC/chat-style connection, only for a single request/response.
  2. pcapkit/foundation/traceflow/tcp.py:459submit()'s Deferred snapshots of a still-open flow freeze permanently on first read (via DeferredPacket caching), and can go stale relative to what the same flow's final, finish()-produced Index.packet later shows, with nothing marking the snapshot as partial. Documentation gap more than a bug.
  3. pcapkit/foundation/reassembly/tcp.py:290 — pre-existing reassembler behavior, newly load-bearing here: a conflicting retransmission (different bytes, same sequence range) is silently resolved last-write-wins and still reported COMPLETE. Informational, not asking for a fix.

Nothing else I probed broke, and everything in the "claims to test rather than trust" list that I directly checked (CI, Completion contract, DPKT timestamp plumbing, IP prefix-to-first-hole) held up. I did not re-run the full 930-test suite or regenerate the 15-capture corpus myself (each is a multi-minute job and CI already ran the equivalent matrix green); I relied on CI for those broad claims and spent the review budget on hands-on probing of item 3, per the brief.

Coverage

Covered in depth: pcapkit/foundation/traceflow/tcp.py, pcapkit/foundation/traceflow/data/{data,tcp}.py, pcapkit/foundation/reassembly/tcp.py, pcapkit/foundation/reassembly/reassembly.py, pcapkit/foundation/reassembly/ip.py, pcapkit/foundation/reassembly/data/data.py, pcapkit/foundation/engines/dpkt.py, pcapkit/toolkit/dpkt.py, pcapkit/interface/misc.py.

Read but not exercised beyond the diff itself: pcapkit/foundation/reassembly/ipv4.py, ipv6.py, pcapkit/foundation/traceflow/traceflow.py, pcapkit/interface/core.py, pcapkit/toolkit/{pcap,pcapng,pypcapfile,pyshark,scapy}.py, and the docs/source/** and tests/** changes (read for consistency with the code, not independently re-run beyond what CI already ran).

Verdict

Given CI is fully green and findings 2 and 3 are minor/informational, this is not a blocker from me, but finding 1 is a real correctness gap in the flagship new capability's documented contract ("one reassembled datagram per direction") for any connection with more than one exchange per side — worth a decision (fix the buffering, or narrow the documented guarantee) before leaning on analyse=True for anything beyond a single request/response. I'd call this good to merge only if finding 1 is either accepted as a known, documented limitation or addressed — it's not a crash or data-loss bug, just a capability that's narrower than advertised.

Comment thread docs/source/pcapkit/foundation/traceflow/tcp.rst Outdated
Comment thread pcapkit/foundation/traceflow/tcp.py
@JarryShaw

Copy link
Copy Markdown
Owner Author

Review of PR #435 at head 3b750a662d5e36f617d03184e12371ec36aafe53

Standing in for Copilot (out of tokens). This is a delta review, not a fresh full-diff pass: the last verdict was at c4934656c and raised three findings, all of which are answered by the only new content since then, commit 908913106 ("traceflow: state what analyse=True actually returns, and pin it") — confirmed via git show 908913106 --no-patch --format='%P' (parent c4934656c) and git show 3b750a662 --no-patch --format='%P' (parents 908913106 and e4b6dd86b, the latter a pass-through merge of origin/main that brought in PR #436 and touches nothing under traceflow/, reassembly/, or this docstring — confirmed by inspecting its diff separately). git rev-list --count origin/main..3b750a662 = 12; git rev-list --count 3b750a662..origin/main = 0. origin/main = f50436a8a1f4cfd9e2f9dc96080fa9f4d8ca08a4, matching the brief. Fetched refs/pull/435/head and confirmed FETCH_HEAD = 3b750a662d5e36f617d03184e12371ec36aafe53 exactly; gh pr view 435 --json headRefOid agrees.

CI

gh pr view 435 --json statusCheckRollup — waited for it to settle (dispatch-time snapshot had 11 jobs still IN_PROGRESS), then re-polled to completion. Final state, 24 checks total: 21 SUCCESS (Analyze/CodeQL, Compat Python 3.10-3.15, Python 3.10-3.15, Integration Python 3.10-3.15, deploy-pages), 2 SKIPPED (Docs test gate, Gate (full suite, Python 3.14) — both gated on a workflow_call input never set for a pull_request trigger, confirmed against .github/workflows/unit-tests.yml in the prior verdict and unchanged since), plus pyup.io/safety-ci SUCCESS. No red anywhere.

What I checked, and what I ran

All three claims in 908913106's commit message, against the code at 3b750a662 (never the ambient working tree for reading — used git show <ref>:<path>; checked out the exact PR sha in this dedicated worktree, verified HEAD == 3b750a662d5e36f617d03184e12371ec36aafe53, only to run tests):

1. "One datagram per direction per ACK, not per direction." Read pcapkit/foundation/reassembly/tcp.py (reassembly() and submit()) and reassembly/reassembly.py (datagram/fetch()), plus traceflow/tcp.py's _make_segment. Confirmed the mechanism exactly as described: self._buffer[BUFID].ack[ACK] is the bucket key, submit() in the reassembler emits one Datagram per non-empty bucket, and _make_segment passes ack=packet.ack straight through with no arithmetic (unlike dsn, which does get the SYN +1 offset — that adjustment happens on the receiving side in reassembly(), not here). Ran the new test test_each_exchange_of_a_multi_round_trip_flow_is_its_own_datagram directly:

PYTHONSAFEPATH=1 PYTHONPATH=<worktree> .venv/bin/python -m unittest tests.foundation.traceflow.test_tcp.TCPTraceFlowTests.test_each_exchange_of_a_multi_round_trip_flow_is_its_own_datagram -v

ok. Also walked its nine-packet sequence through the bucketing logic by hand (not just trusting the assertion): the SYN, SYN-ACK and the bare ACK at index 3 land in empty-payload buckets that get stripped (if payload: in submit()), and the six non-empty buckets are exactly {(12345,501):req1, (12345,506):req2, (12345,511):req3, (443,105):resp1, (443,109):resp2, (443,113):resp3} — which matches both the test's own got dict and the docstring's six-row table verbatim. Six is right, and the table is accurate. The test is not vacuous: it asserts the dict equality and two assertNotIns, not just a length.

Left one inline comment on traceflow/tcp.py:106 on a real but minor overclaim: "the acknowledgement number advances exactly when the peer has spoken, so bucketing on it splits a conversation at its message boundaries" is true for the lock-step request/response the test drives, but the ACK field tracks how much of the peer's data has been acknowledged, not how many messages the local side sent — a direction that pipelines two messages before the peer replies keeps one ACK across both and gets them concatenated into one bucket, same as the "merging would concatenate" failure this paragraph warns about. Not a defect, not blocking, just a caveat the phrasing doesn't carry.

2. Docs-not-code was the right call. Given (1) is correct behaviour and not a bug, rewriting the reassembler to merge across ACK buckets would be the change that introduces a real defect: req1+req2+req3 would concatenate into b'req1req2req3', and Datagram.packet's on-demand analyze() would parse only the head of that blob as a single message, silently dropping the rest — which is precisely the failure mode the commit message says merging causes. The existing per-ACK-bucket granularity is what makes each datagram one parseable unit for the lock-step case, and RFC 815 hole-descriptor buffering is already keyed that way for a reason unrelated to this PR. So: sound call, and it's not a case where I'd want the code changed instead — the mechanism was already right, only the doc claim was wrong.

3. Open-flow snapshot freezing. Read traceflow/tcp.py's submit()/trace()/finish(), traceflow/data/data.py's Deferred/DeferredPacket, and reassembly/reassembly.py's datagram/fetch() properties. Confirmed both halves: DeferredPacket.__analyse__ resolves a Deferred on first read and overwrites self.__dict__[key] with the concrete tuple on that specific Index instance — a mechanism entirely separate from TraceFlow.__cached__['submit']. Both trace() (self.__cached__['submit'] = None at the top) and finish() (same, at the end) do clear that cache, exactly as claimed — but clearing it only changes what a subsequent call to submit() builds (a fresh Index with a fresh Deferred); it does nothing to an Index object a caller is already holding, since that object's .packet was already overwritten with a plain tuple and no longer contains a Deferred to re-resolve. Ran the new test:

PYTHONSAFEPATH=1 PYTHONPATH=<worktree> .venv/bin/python -m unittest tests.foundation.traceflow.test_tcp.TCPTraceFlowTests.test_an_open_flows_application_layer_is_a_snapshot_of_when_it_was_read -v

ok. The assertIs(held.packet, resolved, ...) is meaningful, not coincidental — traced why identity holds: once __analyse__ replaces the dict entry with the resolved tuple, later reads take the not isinstance(value, Deferred) branch and return the same object unchanged. Non-vacuous: the test also proves the fresh Index from finish() sees the new segment ([b'firstsecond']), so it isn't merely asserting "nothing ever changes."

Amended assertion message. 'one acknowledgement number each way, so one datagram each way' — true: every segment in that older test carries the default ack=0, which is the one shape that collapses to "one per direction." Confirmed by re-running it (below) and by the same by-hand bucket trace.

Docs move. Deferred/DeferredPacket moved from traceflow/tcp.rst (where they sat under the .. module:: pcapkit.foundation.traceflow.data.tcp directive, though they live in data/data.py, not data/tcp.py) into traceflow/index.rst, immediately after TraceFlowData, under .. module:: ...traceflow.data / .. module:: ...traceflow.data.data / .. currentmodule:: ...traceflow — grepped docs/source/pcapkit/foundation/reassembly/index.rst and confirmed this is exactly its existing convention for ReassemblyData/Deferred/DeferredPacket. Grepped the whole docs/ tree for traceflow.data.data.Deferred(Packet)? and each name now appears exactly once, only in index.rst, so nothing is doubly declared. Cross-references in tcp.py's docstrings use the fully-qualified dotted path (:class:~pcapkit.foundation.traceflow.data.data.Deferred``), which Sphinx resolves independent of which .rst file carries the `autoclass`, so nothing breaks by the move.

Test run. All 14 tests in tests/foundation/traceflow/test_tcp.py pass (Ran 14 tests ... OK). I also ran the full suite (python -m unittest discover -s tests), which requires the generated sample corpus (examples/generators/make_samples.py) — my first pass, before generating it, threw 130 FileNotFoundErrors and 3 unrelated engine_parity failures, none of which are attributable to this commit (all in protocols/integration runtime tests that read examples/captures/*.pcap). After generating the corpus, the full suite is clean: Ran 851 tests in 454.6s — OK (skipped=13). I could not reproduce the commit message's exact "932 passed, 17 skipped" via bare unittest discover (I get 838 passed + 13 skipped = 851 total, not 949) — the difference is almost certainly methodology (the PR's own review history shows earlier passes measured via pytest with a subtests-counting plugin, which reports subTest instances as additional discrete counts that unittest discover's tally doesn't split out the same way), not a real regression: zero failures, zero errors, in an environment proven to be this tree (pcapkit.__file__ printed and asserted to be under the worktree before trusting any of the above).

Verdict

GOOD TO MERGE at 3b750a662d5e36f617d03184e12371ec36aafe53. All three prior findings are correctly and durably fixed by 908913106 — the mechanism claim, the six-datagram count, the docstring table, and the snapshot-freezing explanation are all accurate and independently verified against the actual code paths, not just against the new tests' own assertions. The docs-vs-code call is sound with evidence either way. The two new tests are non-vacuous and the amended assertion message is true. The docs move follows the established reassembly/index.rst convention with no duplicate declarations or broken cross-references. CI is fully green (21 SUCCESS, 2 legitimately SKIPPED, pyup.io/safety-ci SUCCESS). One non-blocking nit left inline on traceflow/tcp.py:106 about the "splits at message boundaries" phrasing overstating its own generality (pipelined sends within one ACK bucket still concatenate) — worth a clause next time this docstring is touched, not worth a revision on its own.

…directionally

Both items are asked for on the Help Wanted page (docs/source/pep.rst, from #419)
and in discussion #106.

Reassembly timeout
- The clock is the capture's own timestamps, never the host's: an offline parser
  has no other notion of time passing, and keying on time.time() would make the
  same file reassemble differently on every run. Packet and Buffer models gained
  a `timestamp`, and Reassembly.expire() abandons a buffer whose first-arriving
  fragment is older than `timeout` seconds, swept when a packet is handed over --
  the only evidence capture time has advanced.
- 60s for IPv4 and IPv6. RFC 1122 s3.3.2 supersedes RFC 791's TTL-derived timer
  ("SHOULD be a fixed value, not set from the remaining TTL... between 60 and 120
  seconds") and RFC 8200 s4.5 mandates 60; RFC 791's 15s is an initial lower
  bound that MAX(TIMER,TTL) raises, not a deadline. TCP gets no timeout: no
  specification gives stream reassembly one, and an idle connection is ordinary.
- Datagram.completed widens from bool to Completion -- COMPLETE, PARTIAL,
  TIMEOUT -- so an abandoned datagram is distinguishable from one that was merely
  unfinished. Only COMPLETE is truthy, so `if datagram.completed` is unchanged.
- Fixes two cases where strict=False reported an incomplete datagram as
  *complete*: TCP passed off zero-filled holes as received data, and IP sliced
  datagram[:TDL] with TDL still -1, handing back 65534 octets of preallocated
  buffer. Both now report PARTIAL; the payload shape is unchanged, so
  follow_tcp_stream still reconstructs what it did.

Bidirectional flow tracing
- TCP.make_bufid() orders the two endpoints canonically, so both halves of a
  conversation share one buffer, label and output file. Index gained forward and
  reverse so per-direction ordering stays recoverable. A flow closes once *both*
  halves have FINed, since a connection is not over while one direction is still
  sending.
- Default; bidirectional=False (trace_bidirectional= on Extractor, extract() and
  follow_tcp_stream) restores per-direction flows and reproduces main exactly.
- Also fixes the Scapy adapter reporting time.time() as a flow timestamp, which
  put the moment of parsing into every label.

Verified against a git archive of the branch point: all 15 sample captures give
byte-identical tree, json and reassembly output with ip/tcp/reassembly enabled,
and no capture hits a timeout (1479 datagrams, all COMPLETE). Flow tracing goes
355 -> 234 flows over the corpus with traced frames unchanged at 1222, the drop
equalling the 121 two-way conversations exactly. make_samples.py regenerates
byte-identically. Suite 893 passed / 17 skipped; mypy 127 errors against 128 on
the branch point.
…sable reaches

``# pylint: disable`` is line-scoped, and ``unused-argument`` is reported against
the ``def`` -- so wrapping a signature leaves every parameter on a continuation
line outside the disable's reach. Splitting Buffer's and Index's stubs to fit the
new fields therefore leaked seven unused-argument messages, and the shorter form
already there was leaking three of its own plus two super-init-not-called.

Both stubs go back on one line, as every other data model in pcapkit writes them,
with a note saying why the long line is deliberate. Pylint: 4689 messages against
4706 on the branch point.
…mode claim

Two findings from the review of #435.

## A flow closed one packet too early, and reused ports merged (blocking)

`closed = len(buffer.fin) >= 2` submitted a bidirectional flow on the second FIN
of its four-way close. The close is FIN, ACK, FIN, ACK, so the final ACK arrived
after the buffer had been popped and opened a fresh buffer under the same
canonical BUFID -- which a later connection reusing those endpoints then merged
into. The NOTE above that line predicted exactly this defect for closing on the
*first* FIN and was only half-applied. Reproduced on the reviewed head with a
four-way close plus reuse: frames (6, 7, 8) came back as one flow, mixing
connection one's final ACK with connection two.

It was visible in the corpus all along. http.pcap traced 220 flows, of which
**109 were single-frame stray tails** beside 109 nine-frame flows -- each
conversation split in two. It now traces 111, every one two-way, none of one
frame, all 1117 frames still in exactly one flow. My earlier report read those
109 as one-directional conversations; they were the bug.

The fix is not a later close condition but a different question. Observing a
teardown is not knowing that nothing more will arrive: duplicates of the final ACK
can follow, so no rule naming the last packet of the exchange can hold. So a
teardown -- FIN from both endpoints, or RST from either -- is *recorded* and
finalises nothing; the flow keeps the packets that belong to it. It is finalised
only by proof that no more can come: a new connection's SYN on the same endpoints,
or the end of the capture, via the new `TraceFlow.finish()` that
`Extractor._cleanup` calls. Callbacks fire there, so they see the whole
conversation. `submit()` still reports an unfinalised flow, so reading `index`
mid-capture cannot strand the rest of a conversation in a second flow.

Telling that SYN from the peer's SYN-ACK is what the recorded teardown is for --
a SYN-ACK cannot follow a completed teardown. That also closes the RST gap
`pep.rst` documents: `rst` is now on the traceflow packet model and reported by
all six adapters that build one.

`bidirectional=False` still closes on FIN, ignores RST, and reproduces main
exactly at 355 flows.

## The IP loose-mode payload *did* change (correcting the earlier claim)

The previous commit message said of both `strict=False` fixes that "the payload
shape is unchanged, so follow_tcp_stream still reconstructs what it did". That is
true of the TCP fix and **not** of the IP one, where the payload changed. Stated
properly:

- **TCP**: payload unchanged -- the buffer with its holes zero-filled, which is
  what `follow_tcp_stream` reconstructs a stream from. Only `completed` stopped
  claiming a holed datagram was complete.
- **IP, final fragment received** (`TDL > 0`): payload unchanged, holes
  zero-filled, as TCP does. Only `completed` became honest.
- **IP, final fragment never received** (`TDL` still `-1`): the payload changed.
  It was `datagram[:-1]`, 65534 octets of preallocated buffer reported complete.
  It is now the **contiguous prefix** -- offset zero to the first hole -- rather
  than the `b''` an earlier draft of this branch returned, because those octets
  really did arrive and a blob cannot convey the offset of a run that starts after
  an unmeasured gap. `strict=True`, the default, still lists the runs.

Both branches now have tests; the IP one had none.

Suite 901 passed / 17 skipped / 848 subtests, 0 failed. All 15 sample captures
still give byte-identical tree, json and reassembly output against the branch
point, and still regenerate byte-identically; 1479 datagrams, all COMPLETE.
mypy 127 against 128; pylint 4686 against 4706.
…r verbatim

A ``code`` span is not markup inside a ``.. code-block:: text``, so the two
lines added for 'forward' and 'reverse' were showing their backticks in the
rendered page. Nothing else in that diagram uses them.
…ding timestamps

Five review comments on #435.

## The DPKT engine was throwing the timestamp away (substantive)

The fix was not where the comment sat. DPKT's reader yields ``(timestamp, bytes)``
and only the octets became a packet, so a *stored* frame did not know when it was
captured and ``follow_tcp_stream`` had nothing to pass its reassembler but a bound
``functools.partial(timestamp=0.0)``. The timestamp was never unavailable -- it was
being discarded at the engine.

``DPKT.read_frame`` now attaches it (``pcapkit.toolkit.dpkt.attach_timestamp``,
read back by ``packet2timestamp``, which raises rather than defaulting for a frame
that never came through the engine), the partial is gone, and the NOTE that
asserted DPKT "cannot read a frame's capture timestamp off the frame" -- the
framing that led to the wrong fix -- is corrected here and in the three adapter
docstrings that repeated it.

**No behaviour change**, as expected: TCP reassembly has no timeout by default, so
the value reaches no decision. ``follow_tcp_stream`` on the DPKT engine returns
the same streams and conversations as the default engine, which is now pinned by a
test; the timestamps attached to ``in.pcap`` are the capture's own
(1511106545.471719 …), not zeros.

## The application layer is wired into flow tracing (scope addition)

Previously declined on the grounds that traceflow buffers no payload so there is
no second parse to postpone. That is true, which is why this is a capability
rather than a deferral -- and of the two designs the page posed, the tracer
**delegates to** ``reassembly.tcp.TCP`` rather than growing a payload buffer of
its own: a buffer concatenating payloads in capture order is silently wrong on the
first retransmission or reordered segment, where RFC 815's hole-descriptor
algorithm already in the reassembler is not. A test delivers segments out of order
and asserts sequence order comes back.

So the traceflow packet carries the four segment fields that reassembler needs
(``seq``, ``ack``, ``header``, ``payload``) from exactly where each engine's
sibling ``tcp_reassembly`` adapter already reads them, each flow owns a
reassembler, and ``Index.packet`` postpones twice: reading it flushes the flow's
reassembler, and each datagram's own ``packet`` is parsed later still -- the same
``Deferred`` arrangement as the reassembly side, mirrored in
``traceflow/data/data.py`` for the same reason those two modules already mirror
each other.

**Opt-in** (``analyse=True``, ``trace_analyse=True`` on Extractor, extract() and
follow_tcp_stream): buffering every traced payload is a cost tracing does not
otherwise pay, and tracing's per-packet cost is something this package has
deliberately driven down. Off by default nothing is buffered, reassembled or
parsed, and flow counts are unchanged. Refused on the ``pyshark`` engine, which
reports dissected fields rather than octets -- the same reason it has no
reassembly adapter.

## Completion uses StrEnum (question)

``pcapkit.utilities.compat.StrEnum``, as ``httpv1.Type`` and
``pcapng.TLSKeyLabel`` do. Verified on both the stdlib and aenum paths that it
keeps every property the contract needs: only COMPLETE truthy, ``== True`` and
``== False`` both false. It adds ``json.dumps`` support -- a plain Enum raises
TypeError, and ``to_dict()`` hands the field straight out -- and
``completed == 'timeout'``. The one cost is documented in a Warning: a non-empty
string that tests false, so ``bool(x)`` and ``bool(str(x))`` disagree. The
redundant ``__str__`` is gone, StrEnum already giving it.

## Quoting and alignment (questions)

``Buffer[_AT]`` unquoted, matching ``Packet[_AT]`` beside it and the sibling
``reassembly.ip.IP``. Comment columns re-aligned in both reassembly modules, where
``TS = info.timestamp`` had pushed its own comment a column right of the rest.

Also silences, on both DeferredPacket mixins, the mypy ``[misc]`` and pylint
``no-member`` false positives about ``super()`` calls undefined on a mixin --
pylint rates those as *errors*.

Suite 930 passed / 17 skipped / 929 subtests, 0 failed; 947 collected against 916
on origin/main, the difference being this branch's own tests. All 15 sample
captures give byte-identical tree, json and reassembly output against origin/main
and still regenerate byte-identically; 1479 datagrams, all COMPLETE. Flow counts
unchanged at 122 bidirectional / 355 unidirectional, 1222 traced frames either
way. mypy 124 against 128; pylint 4712 against 4723.
Three review findings, none of which changes behaviour -- the behaviour was
right and the documentation overpromised.

The class docstring said `Index.packet` "holds one reassembled datagram per
direction". It holds one per direction *per acknowledgement number*: the
reassembler buckets as `_buffer[BUFID].ack[ACK]` and emits one datagram per
bucket, and `_make_segment` passes `ack` through untouched. Measured on three
request/response round trips over one connection: six datagrams, three each
way. That is the useful shape rather than an accident -- the acknowledgement
number advances exactly when the peer has spoken, so each datagram's payload
is one application message that `Datagram.packet` can parse alone, where
merging a direction would hand the parser several concatenated messages and
have it read only the first. The docstring now says so and shows the six.

The existing test could not see this: every segment it sends carries the
default `ack=0`, which is the one shape where "one per direction" is the whole
truth. Its assertion message said the general rule; it now says the reason,
and a new test drives three real round trips and pins all six datagrams by
(source port, acknowledgement number).

`submit()` did not say that an open flow's `packet` is a mid-capture snapshot.
The `Deferred` resolves on first read and is fixed there, so an `Index` a
caller kept cannot see later segments and carries no marker distinguishing it
from a final result. Measured: read at two frames gives `b'first'`, stays
`b'first'` after the third arrives, and is `b'firstsecond'` after `finish()`.
Documented, and pinned by a test. This method's own cache is not the cause --
`trace()` and `finish()` both clear it -- and the docstring says that too, so
the note is not mistaken for a caching bug later.

Docs: `Deferred` and `DeferredPacket` move from `traceflow/tcp.rst` to
`traceflow/index.rst`, next to `TraceFlowData`, which is where reassembly
declares its equivalents. They live in `traceflow/data/data.py`, so declaring
them under `tcp.rst`'s `data.tcp` module directive was wrong twice over.

A fourth finding is pre-existing and filed as #443 rather than folded in here:
conflicting retransmissions are resolved last-write-wins and still reported
COMPLETE. `git diff origin/main` over `reassembly/tcp.py` touches neither
overlap branch.

Full suite on 3.14.7: 932 passed, 17 skipped (930 before, plus the two tests
added here).
…boundary

Review finding, and correct. The docstring added in 9089131 said bucketing on
the acknowledgement number "splits a conversation at its message boundaries",
so each datagram's payload is one parseable application message. That holds
only where an exchange is one request and one reply.

Under pipelining it does not, because several requests in flight before any
reply all carry the same acknowledgement number and so share a bucket.
Measured on this tree: two requests back to back on one ACK come back as a
single datagram carrying b'req1req2', where the wording implied two.

So the docstring now says the boundary is the peer's turn -- one datagram per
exchange -- and states plainly that this narrows the concatenation to within an
exchange rather than eliminating it, and that a parser handed a pipelined
datagram still sees only the first message. The reason for documenting the
behaviour rather than changing it survives that correction: merging a whole
direction concatenates every message it ever sent, which is strictly worse than
concatenating within one exchange. The claim was too absolute, not wrong about
which option is better.

Pinned by test_pipelined_sends_share_a_datagram_because_the_ack_never_moved,
since a limitation that lives only in prose is one the next reader has to
rediscover. tests/foundation/traceflow: 18 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant