Skip to content

fix(tcp): drop the doubled separator from four MPTCP error messages (#649) - #671

Merged
JarryShaw merged 1 commit into
mainfrom
fix/mptcp-doubled-separator-649
Sep 22, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/mptcp-doubled-separator-649

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Fixes #649.

Cosmetic. No behavioural change: the exception type, the option number and the subtype were always correct, and nothing downstream parses these strings. What changes is the rendered text a user sees when an MP_JOIN or DSS option is rejected.

The defect

Four ProtocolError messages in the MPTCP option handlers were spelled f'{self.alias}: : [OptNo …]', so they rendered with an empty field between the protocol alias and the option number:

TCP: : [OptNo 30] 1: invalid flags combination

An empty field reads as a value that failed to interpolate, which sends a reader looking for a missing variable that was never there.

All four sites

All in pcapkit/protocols/transport/tcp.py. Line numbers had shifted from the issue's by 13, so they are given as found:

line method before after
:1577 _read_mptcp_join TCP: : [OptNo 30] 1: invalid flags combination TCP: [OptNo 30] 1: invalid flags combination
:2831 _make_mptcp_join TCP: : [OptNo 30] 1: invalid flags combination TCP: [OptNo 30] 1: invalid flags combination
:2997 _make_mptcp_dss TCP: : [OptNo 30] 2: missing required fields TCP: [OptNo 30] 2: missing required fields
:2999 _make_mptcp_dss TCP: : [OptNo 30] 2: missing required fields TCP: [OptNo 30] 2: missing required fields

30 is Option.Multipath_TCP, 1 is MPTCPOption.MP_JOIN and 2 is MPTCPOption.DSS; all three format as their integer value. The before/after strings above are rendered by execution, not read off the f-strings.

Why this form, and not a preference

The correct form is the local majority in the very same file:

doubled  f'{self.alias}: : '   :  4  ->  0
single   f'{self.alias}: [OptNo':  28  -> 32

The four were outliers against a 28-site house form in their own module, and the fix moves them into it. grep -rn "alias}: : " pcapkit/ returns nothing outside this file, so the defect is confined to these four lines and is now gone from the package entirely. Two sibling test modules already quote the correct single-separator form in their docstrings (test_tcp_mptcp_subtype_unit.py:279, test_tcp_mptcp_capable_length_unit.py:195), which is further evidence of the intended shape.

The test, and why a substring assertion would not have done

New module tests/protocols/transport/test_tcp_mptcp_error_message_unit.py, 4 tests / 2 subtests. Every message is asserted in full, and that is the point of the change. Two existing assertions already exercise these messages and neither could have caught this, because both match a substring that straddles the defect:

# test_tcp_mptcp_join_flag_ordering_unit.py:404  (_make_mptcp_join)
self.assertIn('invalid flags combination', str(caught.exception))

# test_tcp_udp_unit.py:1629  (_read_mptcp_join)
with self.assertRaisesRegex(ProtocolError, 'invalid flags combination'):

Both pass identically before and after this fix. Both modules are left untouched — a new module rather than an edit to either, given both have been repaired recently (#627, #628, #634).

(Corrected after cross-review: an earlier draft of this description named only the first of the two.)

Four things the module does deliberately:

  • Reaches each of the two _make_mptcp_dss guards separatelydsn without its data-level fields, and a data-level field without a dsn. They carry the identical message, so asserting the string once would not show that both lines were fixed.
  • Goes through the public constructor for the two _make_* sites, TCP(syn=…, ack=…, options=[(Option.Multipath_TCP, …)], …), rather than poking a bare object.__new__(TCP).
  • Parses a real flagless segment for _read_mptcp_join rather than hand-writing _flags, for the reason test_tcp_udp_unit.py assigns a plain set to TCP._flags, which is how #587 hid behind 100% coverage #603/test(tcp): reach the MP_JOIN dispatchers through TCP(), not a hand-written _flags (#603) #612 give. That branch is not reachable from a caller — mptcp_data_selector rejects a flagless MP_JOIN with a FieldError first — so it is latent, but latent only by virtue of a guard in a different file, and the string is still what a caller reaching it sees. Noted in the test rather than left to be rediscovered.
  • Sweeps the source for the pattern and pins the count at 32, so a fifth site cannot be added unnoticed and "fixing" the four by deleting them would not pass. Three of the four sites were themselves found by grepping rather than by reading the one that was reported, which is precisely why the absence is asserted over the whole file. That exact count is knowingly a tripwire on the whole ~3000-line module rather than on these four lines, so the assertion carries a note telling a future editor to update the number rather than loosen it; the == 0 on the doubled form above it is the one that must hold forever. Raised by the cross-review as a footgun and kept deliberately, with the warning added.

Failing, then passing

Same harness as its sibling PR: __editable__* stripped from sys.meta_path, worktree at sys.path[0], pcapkit.__file__ asserted and printed before any other import, and pytest's exit code read from a file rather than a pipeline.

MEASURED pcapkit.__file__ = …/.claude/worktrees/agent-a3c59f390d4ed0e74/pcapkit/__init__.py

On main (fix reverted, test present):

AssertionError: 'TCP: : [OptNo 30] 1: invalid flags combination'
             != 'TCP: [OptNo 30] 1: invalid flags combination'
AssertionError: 'TCP: : [OptNo 30] 2: missing required fields'
             != 'TCP: [OptNo 30] 2: missing required fields'
AssertionError: "f'{self.alias}: : " unexpectedly found in <source>

5 failed, 1 passed
exit code (from file): 1

All four tests fail, covering all four sites.

With the fix:

4 passed, 2 subtests passed
exit code (from file): 0

Scoped directory run, tests/protocols/transport/ unit tier:

140 passed, 105 subtests passed
exit code (from file): 0

The full directory additionally shows 7 failures, all in test_tcp_runtime.py / test_udp_runtime.py, all FileNotFoundError for generated captures in a tree where make samples has not been run. That is the fixture-dependent tier documented in tests/_tiers.py, and it is unaffected by this change.

The suite was never run whole; every run was scoped.

Coverage

pcapkit/protocols/transport/tcp.py, statement and branch:

statements branches cover tests subtests
before 594 198 100% 136 103
after 594 198 100% 140 105

The four changed lines already executed, so per the usual rule the subtest count is the number that moves: 103 → 105, with tests 136 → 140. The statement and branch counts are necessarily unchanged because the change is to string literals inside existing raise statements, and coverage holds at 100%.

Provenance

All four lines arrived together in 3bba8a1748 (2023-04-10, "revised TCP schemas with OptionField/etc. & redesigned read funcs") and were untouched by any later change.

Label

fix alone. Not breaking: no public API, wire output or dump output changes — only the text of an exception message, and nothing in the package or the tests parses it.

Cross-review

Dispatched on a different model, briefed to falsify rather than bless; verdict posted as a comment below.

@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…s in #670 and #671

Two bullets, not one, because the two defects are unrelated: one changes what the
dumpers emit, the other only the text of four exception messages. They share a
file only by accident of being found in the same pass.

The #648 bullet leads with the output change and says so in bold, because that is
what a reader upgrading needs to see: a flag value with no declared bits dumped
as `Type::None [0]` in all six textual format names, out of both `Extractor` and
`TraceFlow`. It then justifies the *replacement* rather than just stating it,
since "render it as its decimal value" looks arbitrary until you know the
enumeration libraries already spell an undeclared residue that way -- and that a
decimal cannot collide with a member name where `None` can, `NONE` being a real
declared name elsewhere. Three things a reader would otherwise get wrong are
recorded: three sites carried the interpolation and not one, the guard is on
`name is None` rather than on zero because the defect never was about zero, and
it is not an `aenum` quirk since stdlib `enum.IntFlag` behaves identically.

It also corrects the issue on a point of fact. #648 said `Flags` was the only
registry nameless at zero; a sweep of all seven finds five, the four Mobility
Header flag registries included. And it states that the committed example dumps
do not move, which was measured by regenerating all three with and without the
change rather than assumed -- a reader of a bullet this emphatic will otherwise
wonder whether `examples/captures/` drifted.

The #649 bullet says "cosmetic" in its second sentence so nobody reads it as a
behavioural change, then gives the one reason it was worth doing at all: it is
the text a user sees when an option is rejected. It names all four sites, and the
28-against-4 count in the same file, because that count is what makes the correct
form a fact about the module rather than a preference.

Neither bullet claims a guard it does not have: #648's third site, the `addon`
branch, is not reachable from any registry in the library today, and the bullet
does not imply otherwise.

`CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited by hand.
`--check` exits 0 and `tests/project/` is green at 96 passed, 469 subtests. Each
`` literal is kept on one line, since the generator rejects one spanning a line
break with `ResidualMarkupError`.

Committed from a detached HEAD on d14577d and pushed to the branch ref, because
`docs/changelog-1.5.0` is checked out in another agent's worktree and could not be
taken here. Note d14577d, not the 69a6e13 I was given: the branch had already
moved on with #665's, #651's, #661's and #652/#650's entries.

Refs #648
Refs #649
…649)

Four `ProtocolError` messages in the MPTCP option handlers were spelled
`f'{self.alias}: : [OptNo …]'`, so they rendered with an empty field between the
protocol alias and the option number:

    TCP: : [OptNo 30] 1: invalid flags combination

Cosmetic -- the exception type, the option number and the subtype were always
right, and nothing downstream parses these strings -- but it is the string a user
sees when an MP_JOIN or DSS option is rejected, and an empty field reads as a
value that failed to interpolate.

* Four sites, not the one it was reported against. `_read_mptcp_join` and
  `_make_mptcp_join` ("invalid flags combination"), and both guards of
  `_make_mptcp_dss` ("missing required fields"). The other three were found by
  grepping for the pattern; `grep -rn "alias}: : " pcapkit/` returns nothing
  outside this file.
* The correct form is the local majority, not a preference: the same file spells
  the same prefix without the doubling 28 other times, all
  `f'{self.alias}: [OptNo {schema.kind}] …'`. The count is 32 after this change
  and the doubled count is 0.
* New `test_tcp_mptcp_error_message_unit.py` asserts each message in **full**
  rather than by substring, which is the point: the two places that already
  exercise these messages both match a substring straddling the defect and so
  pass either side of it -- `test_tcp_mptcp_join_flag_ordering_unit.py:404`
  (`assertIn`, against `_make_mptcp_join`) and `test_tcp_udp_unit.py:1629`
  (`assertRaisesRegex`, against `_read_mptcp_join`). Two, not one. Neither module
  is touched.
* The two `_make_mptcp_dss` guards carry the identical message, so they are
  reached separately -- `dsn` without its data-level fields, and a data-level
  field without a `dsn` -- since asserting the string once would not show both
  were fixed. Coverage confirms all four `raise` lines execute.
* The test also sweeps the source for the pattern and pins the 32, so a fifth
  site cannot be added unnoticed and "fixing" the four by deleting them would
  not pass. That exactness makes it a tripwire on the whole module, so the
  assertion carries a note telling a future editor to update the number rather
  than loosen it.

All four lines arrived together in `3bba8a1748` (2023-04-10) and were untouched
until now.

`tests/protocols/transport/` unit tier passes 140 tests and 105 subtests, up from
136 and 103. The new module is 4 tests and 2 subtests, and fails on `main` with
`'TCP: : [OptNo 30] 1: invalid flags combination' != 'TCP: [OptNo 30] 1: invalid
flags combination'` among 5 failures. `pcapkit/protocols/transport/tcp.py` holds
100% statement and branch coverage either side, at an unchanged 594 statements and
198 branches -- the change is to string literals, so it adds no statement to
cover. The seven `test_tcp_runtime.py`/`test_udp_runtime.py` failures in the full
directory are the fixture-dependent tier raising `FileNotFoundError` in a tree
where `make samples` has not been run, and are unaffected by this commit.

Fixes #649
@JarryShaw
JarryShaw force-pushed the fix/mptcp-doubled-separator-649 branch from afa0216 to 0e616ac Compare September 22, 2026 19:09
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict: GOOD TO GO

Independent cross-review, run on a different model (Sonnet) from the one that authored the change, in its own worktree, briefed to falsify rather than bless and to treat the issue's own measurements as claims rather than ground truth. It was read-only with respect to this PR: no amend, no push, no commit to the branch.

Tree it measured: …/.claude/worktrees/agent-a5561bffde954eb30/pcapkit/__init__.py. Worth recording how it got there, because it found a sharper version of the editable-install trap than the brief described: the finder is registered as a bare class, so type(f).__name__ reads 'type' rather than anything containing __editable__, and only checking f.__name__ / f.__module__ identifies it. It confirmed the finder maps pcapkit to the main checkout (still carrying the pre-fix doubled separator) and that PathFinder wins only because pytest's prepend import mode puts the worktree at sys.path[0].

Per-claim verdicts

claim verdict evidence it obtained
A four sites, now zero, none missed repo-wide TRUE source.count("f'{self.alias}: : ") = 4 before, 0 after. Broader sweeps for : : and \}: : across pcapkit/, tests/, docs/, examples/ found nothing outside the new test's own documentary literals. No near-miss variant anywhere.
B the exact before/after strings, by execution TRUE Confirmed Option.Multipath_TCP30, MPTCPOption.MP_JOIN1, MPTCPOption.DSS2, and reproduced both messages by triggering the real ProtocolErrors — via the public constructor and via a parsed flagless segment — not by reading the f-strings.
C no space lost after OptNo, nothing else touched TRUE, byte-for-byte Against the PR's true recorded base (baseRefOid 0c7f2b7c9): exactly 4 changed lines in pcapkit/, at 1577 / 2831 / 2997 / 2999. Each line shrinks by exactly 2 bytes, the sole divergence is "}: : [""}: [", and before.replace("self.alias}: : ", "self.alias}: ") reconstructs after exactly — True for all four. No missing space, no doubled space, no other line altered.
D 28→32 and 4→0 TRUE Derived independently on both trees. Matches the test's hardcoded 32. Flagged as a footgun — see below.
E fails on main, passes with the fix TRUE, exact match Pre-fix: 5 failed / 1 passed, exit file 1. Post-fix: 4 passed / 2 subtests, exit file 0. Restored its worktree immediately after; git status clean.
F all four raise lines actually execute TRUE — the important one coverage run --branch against the new module alone: lines 1577, 2831, 2997 and 2999 all EXECUTED. Both _make_mptcp_dss guards fire independently despite emitting an identical string.
G the pre-existing substring assertion could not have caught this TRUE, and incomplete …join_flag_ordering_unit.py:404 is exactly as quoted. But it found a second such assertion I had not named — see below. Confirmed neither file was touched.
H the source-sweep test is robust TRUE A read failure raises rather than passing vacuously; an empty read would still fail == 32; and (via E) it genuinely fails on unfixed code. Its dependence on tcp_module.__file__ resolving to the worktree is a pre-existing property of every test in this suite, not introduced here.
I nothing else in the repo depends on these strings TRUE Swept invalid flags combination / missing required fields repo-wide: only the four raise sites, the two substring assertions, and the new test's literals. No .rst or docstring quotes the old doubled form.
J fix alone, not breaking Agreed An exception's str() is not a documented output contract and nothing in-repo parses it. It explicitly contrasted this with sibling #670, which does carry breaking because it changes dumped JSON/Tree/PLIST — a format downstream tooling parses. It judged the two cases legitimately different and the repo's own convention to support both self-assessments.

Structural checks also passed: one commit, author Jarry Shaw <jarryshaw@icloud.com>, Fixes #649, ProtocolError from pcapkit.utilities.exceptions, only .py touched, no GH-nnn. Directory run reproduced my own numbers exactly: 140 passed / 105 subtests, with 7 FileNotFoundError failures in the fixture-dependent tier.

What it disputed, and what I changed

Both findings acted on rather than argued away. The commit was amended and the branch force-pushed; the fix itself is unchanged.

  1. My narrative said "the existing assertion" when there are two. Besides …join_flag_ordering_unit.py:404 (assertIn, against _make_mptcp_join) there is test_tcp_udp_unit.py:1629 (assertRaisesRegex(ProtocolError, 'invalid flags combination'), against _read_mptcp_join) — the same limitation, equally unable to catch this. I verified both exist before accepting it. The module docstring, the commit message and the PR body now name both. Neither file is touched.

  2. The hardcoded 32 is a tripwire on the whole module, not on these four lines. The reviewer called it "a minor NEEDS-CHANGES-caliber nit rather than a blocker": any future change that adds or removes any f'{self.alias}: [OptNo …]' message in this ~3000-line file will fail the assertion for an unrelated reason. I kept the exactness deliberately — asserting only the absence would pass if the four lines were deleted outright, which is the failure mode it guards — but added a note at the assertion telling a future editor to update the number rather than loosen it, and marking the == 0 on the doubled form as the one that must hold forever.

It also observed that this branch looked 5 commits behind main. That one I checked and did not act on: origin/main is still 0c7f2b7c9, exactly the commit this branch is cut from. The 8cfd6ab01 it compared against is a local, unpushed main, so no rebase is needed and GitHub reports the PR mergeable.

Nothing in A–J was left unverified.

@JarryShaw
JarryShaw merged commit a18846c into main Sep 22, 2026
26 checks passed
@JarryShaw
JarryShaw deleted the fix/mptcp-doubled-separator-649 branch September 22, 2026 22:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

four MPTCP ProtocolError messages carry a doubled separator, rendering as 'TCP: : [OptNo 30] 1: ...'

1 participant