fix(tcp): seed TCP.read's flag accumulator with Enum_Flags(0), not a no-op cast (#616) - #634
Conversation
c56c0b2 to
af09f0e
Compare
Cross-review verdict: NEEDS CHANGES → addressedPer the standing rule that an agent-raised change gets a cross-review on a different model, this PR was reviewed by a Sonnet subagent (the PR was authored by Opus), briefed to falsify rather than bless and run strictly read-only. No model substitution was needed. Its verdict, verbatim on its first line:
Both findings were real. I reproduced each independently before acting on it, rather than taking the report on trust. Finding 1 — two stale docstrings the PR missed. CONFIRMED, fixed.
Both false after this change. Both corrected — docstring text only, no assertion touched. Finding 2 — the dump format change was understated. CONFIRMED, disclosed and pinned.The first draft said only "no longer an Affects all three of the JSON, tree and PLIST outputs. Two points I'd add to the reviewer's framing, both measured:
Actions taken: the PR body and the changelog bullet now state the rendering change explicitly; a new assertion pins Findings I did not accept as blockingThe reviewer's third item suggested optionally fixing Confirmed by the review (independently derived, not just echoed)
Not verifiedThe reviewer did not re-run It also surfaced, out of scope, that This PR is unmerged and awaiting review; I have not merged, tagged or released anything. |
…t a no-op cast
* `TCP.read` seeded `_flag` with `cast('Enum_Flags', 0)`. `typing.cast` is a
runtime no-op, so a segment whose flags octet is all zero left `self._flags`
a plain `int` -- the `|=` below is the only thing that promotes it, and it
never runs. A membership test then raised `TypeError: argument of type 'int'
is not a container or iterable`, and `TCP.connection` returned an `int` where
it and `Data_TCP.connection` both annotate `Flags`. Seed `Enum_Flags(0)`,
matching what #597 did to the sibling accumulator in `make`.
* Correct the prose that recorded the read path as deliberately keeping its
`cast`: the NOTE in `make`, one NOTE in `test_tcp_udp_unit.py`, and two
docstrings in `test_tcp_mptcp_join_flag_ordering_unit.py`.
* Add `test_a_flagless_segment_seeds_its_connection_flags_as_an_enum`, four
subtests over the flags octet, asserting the *type* -- `0 == Enum_Flags(0)`,
so equality cannot discriminate -- plus that `_read_mptcp_join` now reaches
its own `ProtocolError` rather than a bare `TypeError`, plus the one
observable difference: a flagless segment's `connection` dumps as
`'Flags::None [0]'` where it dumped as `0`. That removes a type inconsistency
(number when flagless, string otherwise); the literal `None` is a separate
rendering defect in `pcapkit.dumpkit.common` and is left to its own change.
Coverage cannot see the fix: the line already executed, and `tcp.py` reads
100%/100% either side. Subtests over `tests/protocols/transport/` go 99 -> 103.
Verified against the unfixed snapshot: the new test fails `AssertionError:
<class 'int'> is not <aenum 'Flags'>`, exit 1; with the fix, exit 0. No
caller-visible parse change -- a flagless MP_JOIN still stops at
`mptcp_data_selector`'s `FieldError`, measured identical both sides.
Fixes #616
d3e39a4 to
24d45c7
Compare
Rewritten: changelog entry moved out, rebased onto current
|
The bullet #634 originally carried, moved here verbatim so that #634 touches only `pcapkit/protocols/transport/tcp.py` and its two test files. Covers: `TCP.read` seeding its connection-flag accumulator with a `typing.cast` no-op rather than `Flags(0)`, so a flagless segment left `self._flags` a plain `int`. 35 lines added to the entry file; `CHANGELOG.md` regenerated, not edited.
… [0]` (#648) (#670) BREAKING CHANGE to dumped output. `make_dumper`'s `object_hook` interpolated `o.name` unguarded, and a `Flag` value composed entirely of undeclared bits has `name is None` rather than a string -- so the literal four characters `None` landed in the name half and `pcapkit.const.tcp.flags.Flags(0)` rendered as `Flags::None [0]` in `json`, `tree`, `text`, `txt`, `plist` and `xml`, out of both `Extractor` and `TraceFlow`. * Three sites, not one, all with character-for-character the same interpolation: the `MultiDict`/`OrderedMultiDict` *key* path, the `addon` branch's `'enum'` key, and the scalar return. They now share one `render_enum` helper, so the guard cannot be applied to two of three. * The fallback is the value's own decimal spelling -- `Flags::0 [0]`, `Flags::8 [8]`. That is what the enumeration libraries already use for an undeclared residue: `Flags(2057).name` is `'ACK|9'`, naming the declared bit and giving the leftovers as one number, so a wholly-undeclared value is the same rendering with no declared bit in front. It also cannot be mistaken for a member name, since a Python identifier may not begin with a digit -- none of the 1867 identifiers under `pcapkit/const` is a bare decimal. `'None'` could be mistaken for one, and `NONE` is a real declared name elsewhere. * Not limited to zero, so the guard is on `name is None` and not on the value. `Flags(1)`, `Flags(8)`, `Flags(9)` and `Flags(65536)` are equally nameless; a fix keyed on zero would have left four of five cases emitting `None`. * Not an `aenum` quirk either -- a stdlib `enum.IntFlag` answers `name is None` identically on CPython 3.14.7, so the guard belongs at the interpolation rather than in a choice of enumeration library. * Five of the seven flag registries in the library are nameless at zero, not one: `Flags` plus the four Mobility Header flag registries. The new test discovers them rather than naming them. * #634 pinned the old string in `test_tcp_udp_unit.py` and said it was "left to its own change"; that assertion and its prose now read `Flags::0 [0]`. A flagless TCP segment is the library's only wire-reachable producer of a nameless member, which makes it the test that notices if the guard goes away. `examples/captures/out.json`, `out.plist` and `out.txt` do not move: regenerating all three from `in.pcap` with and without the change gives byte-identical output, because `render_enum` is the identity on every member that has a name and no committed fixture contains a nameless one. `tests/dumpkit/ tests/foundation/ tests/protocols/transport/test_tcp_udp_unit.py` passes 256 tests and 397 subtests. The new module is 5 tests and 21 subtests and fails on `main` with `'Flags::None [65536]' != 'Flags::65536 [65536]'` among 18 failures; the amended #634 assertion fails there with `'Flags::None [0]' != 'Flags::0 [0]'`. `pcapkit/dumpkit/common.py` holds 100% statement and branch coverage across the change, 71 statements and 32 branches before, 76 and 34 after. One pre-existing failure is unrelated and unchanged by this commit: `test_tcp_runtime.py::…::test_sample_capture_reassembles_every_stream_byte_exactly` raises `FileNotFoundError` for a generated fixture in a tree where `make samples` has not been run, identically with and without the change. Fixes #648
TCP.readseeded its connection-flag accumulator withcast('Enum_Flags', 0).typing.castis a runtime no-op — it returns its second argument unchanged — so the accumulator began life as the plainint0, and the|=in the loop below it is the only thing that ever promotes it to aFlagsmember. A segment whose flags octet is all zero promotes nothing, soself._flagsstayed anintand a membership test against it raisedTypeErrorinstead of answering.Fixes #616.
Re-verified on current
mainThe issue's line numbers had shifted. Measured on
ead73b204(the merge base), CPython 3.14.7:So the issue's
:481is real and:563is now:588and was already fixed — only the read path remained.Enum_Flags(0)is sound here — it is not the #623 defect#623 is a
_missing_inpcapkit/const/mh/*_flag.pythat endsreturn cls(value)with noextend_enumfallback, soF(0)recurses.pcapkit/const/tcp/flags.pydeclares no_missing_at all, soFlags(0)is an ordinaryaenum.IntFlagpseudo-member. Measured side by side:#623 does not block this PR, and no file under
pcapkit/const/mh/**is touched.Failing, then passing
Both sides run from immutable
git archivesnapshots in/tmpwith the file hashed each side. The test file is byte-identical across the two runs (6a5afc06…); onlytcp.pydiffers.Before —
tcp.py0643bdd4…:with, at both the subtest and the dispatcher block:
The three flagful subtests (
0x02,0x10,0x12) pass on that side, so the test is failing for the reason claimed rather than incidentally.After —
tcp.py85969673…:Exit codes are read from a file, not a pipeline.
Rebased twice during this work as
mainmoved — ontoead73b204(#628) and thencfb81d3f6(#630) — both conflict-free. The whole transport tier after the final rebase:143 passed, 103 subtests passed, exit 0.Coverage cannot see this; the subtest count is the evidence
Line 481 already executed, so a fix to it moves no coverage number. Measured with
coverage runovertests/protocols/transport/:ead73b204tcp.pystmts / branchestcp.pycoverageCoverage does not go backwards; +1 test and +4 subtests are the real delta.
What changes, and what does not
Nothing a caller can reach changes.
_read_mptcp_joinpicks between RFC 8684 §3.2's three MP_JOIN layouts from these very membership tests, butmptcp_data_selectorrejects a flagless MP_JOIN in the schema layer first — measured identical either side:That is what made the
TypeErrorlatent rather than live — latent only by virtue of a guard in a different file. Called directly on a flagless parsed segment the dispatcher now reaches the library's own error:The one observable difference is the type of the reported
connectionfor a flagless parsed segment:Numerically equal (
0 == Flags(0)), identical on the wire (bytes(header)round-trips on both sides), and now conforming to whatTCP.connectionandData_TCP.connectionhave always annotated. Flagful segments are unchanged.That difference reaches the dump output, and it is worth stating plainly
The cross-review (below) caught that the first draft of this PR understated it. It is not merely "no longer an
int" — the rendered value changes, in all three output formats:Two things about that:
connectionwas a JSON number for a flagless segment and a JSON string for every other segment. It is consistently a string now.Noneis a pre-existing defect elsewhere, newly exposed.pcapkit/dumpkit/common.py:216buildsf'{type(o).__name__}::{o.name} [{o.value}]', andFlags(0).name is Nonebecause a bitlessaenum.IntFlagpseudo-member has no name. That would spell any zero-valued flag enumeration in the library the same way.dumpkit/common.pyis outside this change's scope and fixing it would alter output for every such enum, so it is left to its own change and instead pinned by an assertion here so it cannot drift unnoticed.The committed example dumps are unaffected:
examples/captures/out.jsonandout.txtcarryconnectiononly for flagful segments (Flags::ACK [2048],Flags::ACK|FIN [34816]) —in.pcapcontains no flagless segment. No test compares against those committed files (tests/foundation/test_extraction.pywrites its ownout.jsoninto a temp dir), so nothing golden depends on this either way.tests/protocols/test_option_roundtrip_unit.pystill passes (6 passed, 358 subtests, exit 0).EXPECTED_FAILURESwas imported rather than grepped, since**unpacking hides it: its 4 TCP/flag entries areQuick_Start_Response,User_Timeout_Option, and the two pcapngepb_flags/pack_flagsones — none related, none newly passing.Staying clear of #612 and #627
e55fe0c59) did not touchpcapkit/protocols/transport/tcp.pyat all — it is test-only plus a docstring in the schema module. It added themptcp_optionhelper and replaced six hand-writtenproto._flags = {Flags.SYN}fakes with realTCP()/make()calls, and it explicitly named this defect under "Also found, deliberately not fixed". The long NOTE abovemake'sEnum_Flags(0)came from fix(tcp): resolve the connection flags before building the options (#587) #597 (83987fbca), not test(tcp): reach the MP_JOIN dispatchers through TCP(), not a hand-written _flags (#603) #612.readstill accumulates intocast('Enum_Flags', 0)". This PR makes that false, so the comment is corrected in place — comment text only, no assertion orproto.make(...)call changed. The neighbouring NOTE about the make path is still accurate and was left alone.tests/protocols/transport/test_tcp_mptcp_join_flag_ordering_unit.py(from fix(tcp): resolve the connection flags before building the options (#587) #597, untouched here): one stating "the read path still seeds withcast('Enum_Flags', 0)— measured: a flaglessTCP(...)reports_flagsas the plainint0. That read path is deliberately unchanged", and one stating itscast"was deliberately left as it was". Both are now false and both are corrected — docstring text only, no assertion touched.main(#604) (#621) #627 (6c3d1b0d9) touched onlytest_a_truncated_option_still_parses_its_declared_length; test(tcp): retarget the last assertion pinning the old padding side (#604) #628 (ead73b204) landed during this work and retargeted one more assertion in it. Rebased ontoead73b204with no conflict, and test(tcp): retarget the last assertion pinning the old padding side (#604) #628'sassertEqual(unassigned.data, trailing + b'\x00' * zeroes)is intact.Found, deliberately not fixed
pcapkit/dumpkit/common.py:216spells a nameless flag memberNone.f'{type(o).__name__}::{o.name} [{o.value}]'renders a bitlessaenum.IntFlagpseudo-member asFlags::None [0]. It affects every zero-valued flag enumeration in the library, not just TCP's, and fixing it would change dump output well beyond this issue. Pinned by a test here; wants its own issue._read_mptcp_join's error string has a stray doubled separator —f'{self.alias}: : [OptNo …'renders asTCP: : [OptNo 30] 1: invalid flags combination. Cosmetic, sits in a message other tests may match on, unrelated to the flag seed.pcapkit/protocols/application/httpv2.py'spost_processseedsflags = 0and never promotes to itsFlagssubclass when no bits are set — arguably the same latent-int-seed shape as this issue, on stdlibenumrather thanaenum. Found by the cross-review; not investigated further here and not in scope.Verification provenance
PYTHONPATHcannot defeat this repo's editable install: the venv installs aMetaPathFindermappingpcapkitto the main checkout, and a meta-path finder runs beforesys.path. Homebrew's own stdlibsitecustomize.pyalso shadows aPYTHONPATHone. Every measurement above therefore strips__editable__*fromsys.meta_pathin-process before the firstimport pcapkit, puts the tree atsys.path[0], and asserts and prints what it got:Sample captures were regenerated with
examples/generators/make_samples.py(a fresh worktree has none; without them 7 runtime-tier tests fail onFileNotFoundError, unrelated to this change).CHANGELOG.mdwas regenerated withpython util/changelog_md.py, never hand-edited;--checkexits 0.