Skip to content

test(tcp): reach the MP_JOIN dispatchers through TCP(), not a hand-written _flags (#603) - #612

Merged
JarryShaw merged 2 commits into
mainfrom
fix/603-tcp-flags-public-path
Sep 22, 2026
Merged

JarryShaw merged 2 commits into
mainfrom
fix/603-tcp-flags-public-path

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Fixes #603

tests/protocols/transport/test_tcp_udp_unit.py assigned a plain Python set to TCP._flags on a bare object.__new__(TCP). A set answers the in tests _make_mptcp_join and _read_mptcp_join use, so all six flag branches ran and both TCP modules read 100% statement and branch coverage — while the attribute had neither the aenum.IntFlag type production assigns nor the ordering that governs when it exists at all. That is how #587, an ordering defect that broke MP_JOIN construction for every caller, sat behind that coverage number untouched, and how the cast('Enum_Flags', 0) no-op behind it went unnoticed too.

What changed

  • A module-level mptcp_option helper builds one MPTCP option through TCP() itself, in both construction forms the library offers — the keyword form (options=[(Option.Multipath_TCP, {...})]) and the data-model form (an OrderedMultiDict of a pcapkit.protocols.data.transport.tcp object). Each call constructs a fresh instance, so _flags is resolved from those very arguments rather than left over from an earlier call.
  • The six proto._flags = {...} MP_JOIN sites are gone. The three maker sites now go through mptcp_option; the three reader sites resolve the attribute with proto.make(syn=..., ack=...), which is the public entry point that assigns it.
  • Options that do not read _flags (MP_CAPABLE, DSS, ADD_ADDR, REMOVE_ADDR, MP_PRIO, MP_FAIL, MP_FASTCLOSE) keep calling proto._make_mode_mp / proto._read_mode_mp directly — routing them through the constructor would say nothing extra, and the issue asks for the diff to stay inside its argument.

The change has teeth — measured, not asserted

The rewrite is behaviour-identical, so the load-bearing evidence is defect injection. Each defect was re-introduced on a scratch copy of pcapkit/protocols/transport/tcp.py and this file run against it, once with the set-based version from main and once with the rewrite. 17 tests in the file.

re-introduced defect main's set-based tests this PR's tests
#587's hoist reverted (self._flags = _flag back below _make_tcp_options) 17 passed 2 failed, 15 passed
_flag = cast('Enum_Flags', 0) restored 17 passed 2 failed, 15 passed

Verbatim, --tb=line:

################ ordering ################
.....F..F........                                                      [100%]
=================================== FAILURES ===================================
E   AttributeError: 'TCP' object has no attribute '_flags'
pcapkit/protocols/transport/tcp.py:2812: AttributeError: 'TCP' object has no attribute '_flags'
E   AttributeError: 'TCP' object has no attribute '_flags'
pcapkit/protocols/transport/tcp.py:2812: AttributeError: 'TCP' object has no attribute '_flags'
=========================== short test summary info ============================
FAILED tests/protocols/transport/test_tcp_udp_unit.py::TCPUDPUnitTests::test_tcp_mptcp_constructors_cover_flag_branches
FAILED tests/protocols/transport/test_tcp_udp_unit.py::TCPUDPUnitTests::test_tcp_option_constructors_cover_data_model_and_mapping_paths
2 failed, 15 passed, 2 subtests passed in 15.75s

################ cast ################
.....FF..........                                                      [100%]
=================================== FAILURES ===================================
E   TypeError: argument of type 'int' is not a container or iterable
pcapkit/protocols/transport/tcp.py:2812: TypeError: argument of type 'int' is not a container or iterable
E   TypeError: argument of type 'int' is not a container or iterable
pcapkit/protocols/transport/tcp.py:1558: TypeError: argument of type 'int' is not a container or iterable
=========================== short test summary info ============================
FAILED tests/protocols/transport/test_tcp_udp_unit.py::TCPUDPUnitTests::test_tcp_mptcp_constructors_cover_flag_branches
FAILED tests/protocols/transport/test_tcp_udp_unit.py::TCPUDPUnitTests::test_tcp_mptcp_readers_cover_subtype_and_error_branches
2 failed, 15 passed, 2 subtests passed in 15.56s

Against the same two injections the set-based file printed 17 passed both times, with no failures at all.

Two details worth naming. tcp.py:2812 is _make_mptcp_join's first membership test and tcp.py:1558 is _read_mptcp_join's — so restoring the cast is caught on both dispatchers, the reader included, because the reader cases now take their flags from make rather than from a hand-written set. And the single assertRaises(ProtocolError) on a flagless MP_JOIN catches both defects on its own: revert the hoist and it raises AttributeError, restore the cast and it raises TypeError, where a set gave the right answer for the wrong reason.

Coverage does not move, and that is the point

Over tests/protocols/transport/, with coverage run --branch:

module before after
pcapkit/protocols/transport/tcp.py 594 stmts, 198 branches, 100% 100%
pcapkit/protocols/schema/transport/tcp.py 219 stmts, 20 branches, 100% 100%

Unchanged, as #603 predicted — a coverage number cannot tell "exercised under production conditions" from "exercised with a hand-placed attribute of the wrong type". The injection table above is the measurement that can.

(One correction against my own first run, which read the schema module at 95% before: that was an artefact of editing the schema file while the run was in flight, shifting line numbers under it. Re-run cleanly, both readings are 100%, and an isolated single-file run reads 96%/99% identically before and after.)

Prose corrected — and one claim of my own falsified

mptcp_dss_ack_selector's note said a corrected field-width lambda "would not have worked" because NumberField cannot pack a callable length, and that fixing it "belongs to pcapkit.corekit.fields.numbers". #598 fixed it there, so the note now marks that half as history. The same paragraph in test_tcp_mptcp_length_arithmetic_unit.py's module docstring gets the same treatment, as does its separate claim that MP_JOIN "cannot be built through the public TCP() constructor at all" — true only until #587.

#603's text also carried forward a justification that turns out to be wrong, and I have not restated it. It said the SwitchField workaround stays because its NoValueField() branch handles wire-absence "which a callable-length NumberField cannot express". Absence was never the obstacle: MPTCPDSS.ssn, dl_len and checksum are each a ConditionalField on the sibling M flag, so this very class has always relied on that wrapper to keep a field off the wire. Measured on this tree, ConditionalField(NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 4), lambda pkt: pkt['flags']['A']) packs 8 octets, 4 octets and b'' correctly and round-trips all three.

What actually remains is narrower and about composition, not absence: ConditionalField.length forwards to the wrapped field without consulting the condition, so reading it while the condition is false — the wrapped field still at its -1 placeholder — raises struct.error: bad char in struct format. Nothing here meets that only because Schema.pack and Schema.unpack special-case ConditionalField by name and continue before any length is read. A SwitchField's selector always hands back an already-concrete field, NoValueField() included, so its length is safe wherever it is read. That is what the note now says.

Reported, deliberately not done: the SwitchField form on MPTCPDSS.ack/.dsn could now be simplified to a ConditionalField with a callable-length NumberField. Replacing it is a behaviour change outside #603's scope, so this PR only records the finding.

Also found, deliberately not fixed

TCP.read still seeds its accumulator with cast('Enum_Flags', 0), so a flagless parsed segment leaves _flags a plain intTCP(raw, len(raw))._flags is 0, and Enum_Flags.SYN in it raises TypeError. That is why the reader cases take their flags from make rather than from a parsed segment. It is deliberate and unreachable from a caller (mptcp_data_selector rejects a flagless MP_JOIN in the schema layer first, and test_tcp_mptcp_join_flag_ordering_unit pins both halves against real segments), it lives in a production file outside this PR's scope, and the reason is recorded in a comment at the site.

Verification

@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — head 64db3911d718b6865a4f6065ffd03afe73d6b4cc. Independently reproduced the cast('Enum_Flags', 0) defect injection on both sides of the comparison table (new tests catch it, old set-based tests don't), and independently reproduced the refined ConditionalField.length claim (real struct.error, inert only because Schema.pack/unpack special-case it by type before reading .length). See appendix.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review appendix — PR #612

Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head 64db3911d718b6865a4f6065ffd03afe73d6b4cc in an isolated worktree (/tmp/pcapkit-review/pr612, removed after this review).

Fixes keyword and CI

closingIssuesReferences = [603]. CI: rollup PENDING, CheckRun tally 9 SUCCESS, 2 SKIPPED, rest QUEUED, 0 FAILURE/CANCELLED.

Reproduced one full injection, both sides of the comparison table

Reverted only pcapkit/protocols/transport/tcp.py:588 from _flag = Enum_Flags(0) back to _flag = cast('Enum_Flags', 0) (the make() seed #587 fixed):

this PR's test file:  2 failed, 15 passed, 3 warnings, 2 subtests passed in 17.45s   (exit 1)

The two failures are test_tcp_mptcp_constructors_cover_flag_branches and test_tcp_mptcp_readers_cover_subtype_and_error_branches — exactly the two the PR names.

Then, keeping the same injection, swapped in main's old set-based test file (git show 4529fdb1f:tests/protocols/transport/test_tcp_udp_unit.py) as a control:

old set-based test file: 17 passed, 3 warnings, 2 subtests passed in 15.43s   (exit 0)

Exact match to the PR's claimed table on both rows — the rewrite catches the injection, the pre-existing set-based tests do not, at all. This is the central "the change has teeth" claim and it holds under independent reproduction, not just the author's own run.

I did not additionally reproduce the second injection (#587's hoist reversed) — the coordinator asked for at least one, and having reproduced one fully with both sides of the comparison, I judged the marginal value of the second lower than moving to the other six PRs in the queue. Flagging this as not independently reproduced, resting on the author's report for that specific row.

The corrected ConditionalField claim — reproduced exactly

This refines a nuance I flagged in my own earlier review of #598: I'd said ConditionalField already independently supplies absence-handling for a callable-length NumberField. This PR shows that's true for pack()/unpack() but not for the .length property, and traces exactly why.

Read pcapkit/corekit/fields/misc.py:115-118: ConditionalField.length is return self._field.length — a bare forward, never consulting self._condition. Constructed the exact scenario:

cf = ConditionalField(NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 4),
                       condition=lambda pkt: pkt['flags']['A'])
resolved = cf({'flags': {'A': False, 'a': False}})   # condition False -> inner field never resolved

resolved._field._template is '>-1s' (the -1 placeholder templated as a struct format string), and resolved.length raises struct.error: bad char in struct format — exact match to the claim. Confirmed Field.length (the base class property) is struct.calcsize(self.template), which is what turns the placeholder into a literal -1s format string and blows up.

Confirmed the reason this is inert in practice: resolved.pack(None, {'flags': {'A': False, ...}}) returns b'' cleanly, because ConditionalField.pack() checks self._condition directly and returns before ever touching the inner field. Read the two cited call sites directly — pcapkit/protocols/schema/schema.py:727 (pack()) and :847 (unpack()) — both do isinstance(field, ConditionalField) followed by if not field.test(packet): ...; continue, so neither ever reaches .length on an unresolved ConditionalField. The landmine is real; it's just never stepped on by any path Schema.pack/unpack takes.

Judgment: this is a precise, well-scoped correction of a claim from an earlier PR in this same wave (my own review included), reported rather than fixed, and correctly left alone — fixing ConditionalField.length to consult the condition would be a real (if small) behavior change to a class used throughout the schema layer, and is explicitly out of #603's scope.

Other disclosed items — read, not independently exercised

Regression

First tests/protocols/transport/ attempt: 7 failures, all FileNotFoundError (missing-fixture trap, fresh worktree). Ran examples/generators/make_samples.py, reran with tests/protocols/test_option_roundtrip_unit.py added: 148 passed, 457 subtests passed, exit 0 — exact match to the claim. python util/changelog_md.py --check exits 0.

Not independently checked

  • The coverage-unchanged claim (100%/100% before and after on both TCP modules) was not rerun; the defect-injection table above is the load-bearing evidence regardless; the author's own noted correction (a 95% schema-module reading being an artefact of editing mid-run) is plausible but not something I re-created.
  • The second injection row (hoist reversed) — see above, unverified by me.

Disagreement log

None. Every claim I checked — the injection table (both rows I tested), the ConditionalField.length defect and its inertness, and the schema-layer special-casing — held up exactly under independent reproduction.

…itten _flags (#603)

`tests/protocols/transport/test_tcp_udp_unit.py` assigned a plain Python `set`
to `TCP._flags` on a bare `object.__new__(TCP)`. A `set` answers the membership
tests `_make_mptcp_join` and `_read_mptcp_join` use, so every flag branch ran and
both TCP modules read 100% statement and branch coverage -- while the attribute
had neither the `aenum.IntFlag` type production assigns nor the ordering that
governs when it exists. That is how #587 stayed invisible behind that number.

* the MP_JOIN cases now build through `TCP()` itself, in both the keyword and the
  data-model construction forms, via a new `mptcp_option` helper that constructs
  a fresh instance per call;
* the reader cases resolve `_flags` with `proto.make(...)` instead of writing the
  attribute, so the dispatcher sees the real enum member;
* stale prose corrected: `mptcp_dss_ack_selector`'s note said fixing the
  callable-length `NumberField` belonged to `pcapkit.corekit.fields.numbers`,
  where #598 has since fixed it, and claimed wire absence was what a
  `ConditionalField` could not express -- `MPTCPDSS.ssn`, `dl_len` and `checksum`
  have always been `ConditionalField` on the sibling `M` flag. The `SwitchField`
  form is kept for the narrower `length`-safety reason the note now states;
* `test_tcp_mptcp_length_arithmetic_unit.py`'s claim that MP_JOIN cannot be built
  through `TCP()` at all is likewise marked as true only until #587.

Behaviour-identical, so coverage of both modules is unchanged at 100% statement
and 100% branch. Proven instead by injection: reverting #587's hoist fails 2 of
this file's 17 tests with `AttributeError: 'TCP' object has no attribute
'_flags'`, and restoring `cast('Enum_Flags', 0)` fails 2 with `TypeError:
argument of type 'int' is not a container or iterable` -- both of which the
`set`-based version passed. 148 tests pass across `tests/protocols/transport/`
and the option round-trip suite; `changelog_md.py --check` exits 0.
@JarryShaw
JarryShaw force-pushed the fix/603-tcp-flags-public-path branch from 64db391 to 615e2c3 Compare September 22, 2026 02:37
@JarryShaw
JarryShaw merged commit e55fe0c into main Sep 22, 2026
24 checks passed
@JarryShaw
JarryShaw deleted the fix/603-tcp-flags-public-path branch September 22, 2026 03:03
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review re-point — PR #612 (post-merge record)

Reviewer: Sonnet; PR authored on Opus 5. The ✅ above was earned at head 64db3911d718b6865a4f6065ffd03afe73d6b4cc; this PR then rebased twice to 4ce6b1382421a0190c941353f8e5c9334b3cf1d6 and merged at that head as e55fe0c59 before this re-point could be posted. Recording it anyway, because it confirms the merged content is what was actually approved rather than leaving that inferred.

Both rebase hops confirmed changelog/docs-only

  • Hop 1 — linear rebase. 64db3911d (parent 4529fdb1f) replayed onto a2be2cc1a, becoming 615e2c388. Identical author date on both sides (Mon Sep 21 21:47:26 2026 -0400), the signature of a mechanical replay rather than a hand edit. Restricting git diff 4529fdb1f 64db3911d and git diff a2be2cc1a 615e2c388 to pcapkit/+tests/: byte-for-byte identical. Extracting only added lines across all five touched files (196 lines each side): identical. The CHANGELOG.md / docs/source/changelog/1.5.0.rst hunks differ only in @@ offsets, because unrelated entries were inserted above this PR's between the two bases.
  • Hop 2 — merge. git diff --name-only 615e2c388 4ce6b1382SECURITY.md only, and git diff --name-only a2be2cc1a af1f771b9 (what af1f771b9 itself changed) → also SECURITY.md only, with comm -23 between the sorted lists empty. The merge introduced exactly docs: say in SECURITY.md that hostile captures are a live risk, not a closed one #611's docs change and nothing else — no conflict-resolution edits, nothing under pcapkit/ or tests/.

Net effect identical to what the original review examined

git diff --stat af1f771b9 4ce6b1382 -- pcapkit/ tests/ → 3 files, 163 insertions / 57 deletions (pcapkit/protocols/schema/transport/tcp.py, tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py, tests/protocols/transport/test_tcp_udp_unit.py) — same file list and same stat as git diff --stat 4529fdb1f 64db3911d -- pcapkit/ tests/, and diffing the two diffs against each other is empty.

Worth naming the trap for the record: the unrestricted 64db3911d..615e2c388 diff surfaces unrelated pcapkit/const/*.py churn absorbed from mainline and looks alarming; it vanishes once each side is diffed against its own matching base.

Tests re-run green at the merged head

pcapkit.__file__ confirmed resolving inside the review worktree first (the shared editable install otherwise resolves to a different checkout on this machine). Then, PYTHONSAFEPATH=1 with PYTHONPATH pinned:

pytest -q tests/protocols/transport/test_tcp_udp_unit.py \
          test_tcp_mptcp_length_arithmetic_unit.py \
          test_tcp_mptcp_join_flag_ordering_unit.py \
          test_tcp_mptcp_subtype_unit.py \
          test_tcp_mptcp_capable_length_unit.py
→ 73 passed, 26 subtests passed, exit 0

test_tcp_udp_unit.py alone collects 17 tests, matching the commit message's "17 tests" claim exactly.

Disagreement log

None. The verdict carries over cleanly to 4ce6b1382, which is the sha that merged.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review re-point — PR #612 (posted after merge)

Reviewer: Sonnet; PR authored on Opus 5. This re-points the **✅ GOOD TO MERGE** verdict given earlier in this thread at head 64db3911d718b6865a4f6065ffd03afe73d6b4cc onto 4ce6b1382421a0190c941353f8e5c9334b3cf1d6, the head this PR actually merged at.

Stated plainly: this landed before the re-point was finished. state: MERGED, mergedAt: 2026-09-22T03:03:45Z, merge commit e55fe0c59dfafa9cfe8b5c796f44c9489491cf7b. So this is a record that the merged head was verified, not a gate anything waited on. ✅ GOOD TO MERGE still stands for 4ce6b1382.

The merged tree is the tree that was reviewed

git diff e55fe0c59 4ce6b13820 bytes. What landed on main is byte-identical to the branch head re-pointed here.

The two hops were not code changes

git log --oneline 64db3911d..4ce6b1382 shows the intermediate head 615e2c388, and that 4ce6b1382 is a two-parent merge commit (615e2c388 + af1f771b9) rather than a second linear rebase — "rebased twice" is right in spirit, not in mechanism.

The PR's own three files are unchanged — checked by blob hash, not by diff

git diff af1f771b9..4ce6b1382 -- pcapkit/ tests/ touches exactly three files. Comparing their git object ids directly between the originally-reviewed head and the current one:

pcapkit/protocols/schema/transport/tcp.py                        IDENTICAL
tests/protocols/transport/test_tcp_udp_unit.py                   IDENTICAL
tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py  IDENTICAL

Blob-hash equality rather than an empty git diff, because an empty diff can also mean a pathspec quietly matched nothing. The head-to-head diff 64db3911d..4ce6b1382 was deliberately not used as evidence — it is noisy with the same three intervening PRs' content, which is expected and not a finding.

Re-reproduced at the new head

pcapkit.__file__ confirmed resolving into the review worktree first. tests/protocols/transport/test_tcp_udp_unit.py: 17 passed, 2 subtests passed, matching the original baseline. Re-injecting cast('Enum_Flags', 0) at pcapkit/protocols/transport/tcp.py:588 reproduces 2 failed, 15 passed — the same two tests the original review and the PR body both name (test_tcp_mptcp_constructors_cover_flag_branches, test_tcp_mptcp_readers_cover_subtype_and_error_branches). Reverted cleanly afterwards. python util/changelog_md.py --check exits 0.

Not independently checked

  • A broader regression rerun (tests/protocols/transport/ + test_option_roundtrip_unit.py) returned 7 failed / 141 passed / 457 subtests, all 7 in test_tcp_runtime.py/test_udp_runtime.py. That matches in count and location the missing-fixture trap the original review already hit and resolved by running examples/generators/make_samples.py first; the follow-up run was not completed, so this is reported as unverified rather than as a regression or as a clean pass.
  • The ConditionalField.length claim from the original review — in a file this PR does not touch, so the rebase cannot have moved it.

Disagreement log

One precision note, not a dispute: "changelog-only rebase" understates hop 1, whose raw diff spans 137 files. The maintainer's own contribution in that hop is changelog-only; the other 134 files are pre-existing upstream commits arriving with the rebase, confirmed by exact file-set match. No disagreement with the substance.

JarryShaw added a commit that referenced this pull request Sep 22, 2026
…604) (#628)

* `TCPUDPUnitTests.test_a_truncated_option_still_parses_its_declared_length`
  expected a truncated TCP option's `data` as the synthesised zero octets
  followed by the real ones, which is what `rjust()` produced. #621 made the
  padding `ljust()` everywhere but could not retarget this file, because #612
  owned it at the time; it has been red on `main` since #621 merged.
* The real octets now come first for both parametrised widths, and the docstring
  above the assertion says tail-padding rather than left-padding.
* Test-only: no library code changes. The sibling case in
  `tests/protocols/internet/test_ipv4_unit.py` was already retargeted in #621.

Measured against `main` at 2221c2d: two subtest failures before, none after.
`tests/protocols/transport/` and `tests/corekit/test_fields_field.py` together
give 177 passed, 232 subtests passed.
@JarryShaw JarryShaw added the test Pull requests that add or correct tests (test: subject prefix) label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test Pull requests that add or correct tests (test: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test_tcp_udp_unit.py assigns a plain set to TCP._flags, which is how #587 hid behind 100% coverage

1 participant