Skip to content

fix(tcp): correct MPTCP option length arithmetic at all six #576 sites - #585

Merged
JarryShaw merged 1 commit into
mainfrom
fix/576-mptcp-length-arithmetic
Sep 21, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/576-mptcp-length-arithmetic

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Fixes #576

Re-derived all six lengths from RFC 8684 directly, rather than from the issue body or from
the correcting comment. Two of the issue's own claims did not survive that: one site is not a
defect where the issue puts it, and one section citation is wrong. Both are called out below.

The six sites

# Option RFC 8684 Correct length Was
1 MP_FASTCLOSE §3.5, fig 14 12 maker 12 (right), schema packed 11, reader required 16
2 MP_JOIN-SYN/ACK §3.2, fig 6 16 maker 12, reader required 20
3 MP_JOIN-ACK §3.2, fig 7 24 maker 8 (reader already 24)
4 REMOVE_ADDR §3.4.2, fig 13 3 + n constant 4
5 MP_PRIO §3.3.8, fig 11 3 (4 for the RFC 6824 form) constant 4
6 DSS §3.3, fig 9 maker's length already correct ack/dsn packed 0 octets, not 4

All six are genuine defects. One — DSS — is not a defect at the line the issue's item 6 names;
see below. Separately, MP_JOIN-SYN, which the issue's body groups with the other two MP_JOIN
forms and its comment exonerates, is confirmed correct and is not changed.

The arithmetic

Every length is the sum of the octets the named figure draws. The fixed head is 2 octets of
Kind/Length plus the subtype row — 1 octet where the subtype's 4 bits are followed by 4
bits of flags or reserved, 2 octets where they are followed by 12 reserved bits.

  1. MP_FASTCLOSE, §3.5 fig 14 — Kind 1 + Length 1 + subtype-and-12-reserved-bits 2 +
    receiver's key 8 = 12. Three sites disagreed: the maker declared the correct 12, but
    MPTCPFastclose had no reserved field at all and so packed 11, and
    _read_mptcp_fastclose required 16 — a number the RFC never produces for this option.
    Net effect: constructing an MP_FASTCLOSE raised ProtocolError: TCP: [OptNo 30] invalid format, the maker's correct length failing the parser's wrong check.
    Note: §3.5, not §3.7. §3.7 is Fallback (MP_FAIL). The issue body says 3.7, and so does
    the EXPECTED_FAILURES entry it left behind.
  2. MP_JOIN-SYN/ACK, §3.2 fig 6 — Kind 1 + Length 1 + subtype/rsv/B 1 + Address ID
    1 + truncated HMAC 8 + random number 4 = 16. The maker wrote 12, which is
    _make_join_syn's own correct length for figure 5's SYN form — that one carries a
    4-octet token where this carries an 8-octet HMAC. _read_join_synack independently
    required 20, contradicting its own docstring figure, which the issue does not mention:
    fixing only the maker would have left the form unusable.
    The if opt is not None: branch also set nonce twice and never set hmac, so
    reconstructing a parsed option substituted the bytes(8) default for the HMAC that was on
    the wire.
  3. MP_JOIN-ACK, §3.2 fig 7 — Kind 1 + Length 1 + subtype-and-12-reserved-bits 2 + the
    full 160-bit HMAC 20 = 24. The maker wrote 8. _read_join_ack already required 24, so
    nothing the maker produced could be parsed back at all.
  4. REMOVE_ADDR, §3.4.2 fig 13, which states Length = 3 + n outright — head 3 + one octet
    per Address ID. The constant 4 is right for exactly one list length, and
    examples/generators/options.py passes addr_id=[1], which is why the round-trip suite
    never saw it. MPTCPRemoveAddress.addr_id sizes its list as pkt['length'] - 3, so the
    constant mis-sized the parse as well as the pack.
  5. MP_PRIO, §3.3.8 fig 11 — head 3 and nothing else. §5 of RFC 8684 records that the
    document "specifies the removal of the AddrID field [RFC6824] in the MP_PRIO option",
    closing a theoretical attack in which a subflow could be forced into backup mode. So
    3, with RFC 6824's 4-octet form still accepted as legacy, hence
    length=3 if addr_id is None else 4 rather than a flat 3. The old constant 4 satisfied
    MPTCPPriority.addr_id's own pkt['length'] == 4 predicate, so declaring the legacy
    length created the legacy field: addr_id=None packed 1e045000, a phantom all-zero
    Address ID. The opt branch also dropped backup, which is the option's entire payload.
  6. DSS, §3.3 fig 9 — see below.

Site 6 (DSS) is a real defect, but not at the line the issue points to

To be explicit about scope, since DSS is the one site where the fix is not a changed length=:
the field widths are what #576 asks for. Its own "Suggested split" says "DSS needs its
schema's ack/dsn field-length lambdas corrected to 4/8 (not 0/8)", and its item 6 is titled
"declared length does not match what the schema actually packs". That is exactly and only what
is changed here. Nothing outside #576 is touched.

What did not survive re-derivation is the rest of that sentence — "which will also change what
its maker needs to compute" — and item 6's primary target, _make_mptcp_dss's length expression
at tcp.py:2873. Re-derived from figure 9, that expression is already correct and is left
untouched (with a comment saying so, so a later pass does not "fix" it). Read as a base plus
widening increments rather than one term per field:

4 + (4 if flag_A else 0) + (4 if flag_a else 0) + (12 if flag_M else 0) + (4 if flag_m else 0)

4 is the head; A contributes the 4-octet Data ACK and a a further 4 to widen it to 8; M
contributes 12 (DSN 4 + SSN 4 + Data-Level Length 2 + Checksum 2) and m a further 4 to widen
the DSN to 8. All flags set gives 4 + 4 + 4 + 12 + 4 = 28, which is the maximum §3.3 states
independently in prose.

What was wrong is the schema that expression describes. MPTCPDSS.ack and .dsn read
NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 0)0 octets where the figure
says 4 — so the option went onto the wire 4 or 8 octets shorter than it declared and the
ack/dsn values were not present at all.

A second defect sat behind the first. Correcting the lambda to 8 if ... else 4 would
still not pack: NumberField calls build_template once at __init__ with the placeholder
length -1, which latches _need_process = True, and nothing clears it when __call__ later
resolves the real length and rebuilds the template as >I/>Q. pre_process then hands
struct.pack bytes for an integer template. Measured on the 8-octet form the old lambda did
reach:

_make_mptcp_dss(DSS, ack=1 << 40)
  -> struct.error: required argument is not an integer

So every extended (8-octet) DSS form was unpackable outright, not merely mislabelled. That
belongs to pcapkit/corekit/fields/numbers.py and is not fixed here — the schema instead
selects between UInt32Field and UInt64Field through a SwitchField, both of which fix
__template__ at class level, following the mptcp_add_address_selector pattern already in
that module.

MP_JOIN-SYN is not a defect either

The issue body groups the three MP_JOIN forms and the comment on the issue corrects the
attribution. Confirmed independently: §3.2 fig 5 gives Length = 12 — head 4 + token 4 +
random number 4 — _make_join_syn writes 12, _read_join_syn guards != 12. All three agree.
TCPMPTCPJoinSYNIsCorrectUnitTests pins that so a later pass reading the body alone does not
"correct" it.

Tests

New module tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py, one class per
site so a regression names the option it broke. Each asserts the byte-exact packed option,
not only its length octet — declared and packed length disagreeing is the whole defect, so both
are checked. Where the reader guard was also wrong (sites 1 and 2) a second test splices
hand-built, spec-correct octets into a real TCP segment and parses them back through TCP
proper, so a pack-side and a parse-side bug cannot cancel out behind a closed round trip.

Every test was run against the unfixed tree first. Verbatim, coverage run -m pytest -v:

TCPMPTCPFastcloseLengthUnitTests::test_maker_packs_twelve_octets                     FAILED
  E   AssertionError: 11 != 12 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPFastcloseLengthUnitTests::test_spec_octets_parse_back                        FAILED
  E   pcapkit.utilities.exceptions.ProtocolError: TCP: [OptNo 30] invalid format
TCPMPTCPFastcloseLengthUnitTests::test_public_constructor_round_trips                FAILED
  E   pcapkit.utilities.exceptions.ProtocolError: TCP: [OptNo 30] invalid format
TCPMPTCPJoinSYNACKLengthUnitTests::test_maker_packs_sixteen_octets                   FAILED
  E   AssertionError: 12 != 16 : the declared length octet must match the wire length
TCPMPTCPJoinSYNACKLengthUnitTests::test_spec_octets_parse_back                       FAILED
  E   pcapkit.utilities.exceptions.ProtocolError: TCP: [OptNo 30] invalid format
TCPMPTCPJoinSYNACKLengthUnitTests::test_reconstruction_keeps_the_parsed_hmac         FAILED
  E   pcapkit.utilities.exceptions.ProtocolError: TCP: [OptNo 30] invalid format
TCPMPTCPJoinACKLengthUnitTests::test_maker_packs_twenty_four_octets                  FAILED
  E   AssertionError: 8 != 24 : the declared length octet must match the wire length
TCPMPTCPJoinACKLengthUnitTests::test_maker_output_satisfies_its_own_reader           FAILED
  E   AssertionError: 8 != 24 : the declared length octet must match the wire length
TCPMPTCPRemoveAddressLengthUnitTests::test_two_address_ids_pack_five_octets          FAILED
  E   AssertionError: 4 != 5 : the declared length octet must match the wire length
TCPMPTCPRemoveAddressLengthUnitTests::test_public_constructor_reports_every_...      FAILED
  E       AssertionError: 4 != 6
TCPMPTCPRemoveAddressLengthUnitTests::test_length_tracks_the_number_of_address_ids   PASSED
  SUBFAILED(addr_ids=[])            E  AssertionError: 4 != 3
  SUBFAILED(addr_ids=[1, 2])        E  AssertionError: 4 != 5
  SUBFAILED(addr_ids=[1, 2, 3, 4])  E  AssertionError: 4 != 7
  SUBFAILED(addr_ids=[1, 2, ...20]) E  AssertionError: 4 != 23
TCPMPTCPPriorityLengthUnitTests::test_no_address_id_packs_three_octets               FAILED
  E   AssertionError: 4 != 3 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPPriorityLengthUnitTests::test_backup_flag_is_packed_without_an_address_id    FAILED
  E   AssertionError: 4 != 3 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPPriorityLengthUnitTests::test_reconstruction_keeps_the_parsed_backup_flag    FAILED
  E       AssertionError: b'\x1e\x04P\x00' != b'\x1e\x03Q'
TCPMPTCPPriorityLengthUnitTests::test_explicit_address_id_keeps_the_legacy_...       PASSED
TCPMPTCPDSSLengthUnitTests::test_data_ack_only_packs_eight_octets                    FAILED
  E   AssertionError: 4 != 8 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPDSSLengthUnitTests::test_mapping_only_packs_sixteen_octets                   FAILED
  E   AssertionError: 12 != 16 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPDSSLengthUnitTests::test_generator_override_arguments_pack_twenty_octets     FAILED
  E   AssertionError: 12 != 20 : the option must occupy the octet count RFC 8684 gives
TCPMPTCPDSSLengthUnitTests::test_ack_and_dsn_survive_a_real_byte_round_trip          FAILED
  E       AssertionError: 0 != 287454020
TCPMPTCPDSSExtendedFieldsUnitTests::test_extended_data_ack_packs_eight_octets        FAILED
  E       struct.error: required argument is not an integer
TCPMPTCPDSSExtendedFieldsUnitTests::test_extended_dsn_packs_eight_octets             FAILED
  E       struct.error: required argument is not an integer
TCPMPTCPDSSExtendedFieldsUnitTests::test_all_flags_set_packs_the_rfc_maximum_of_28   FAILED
  E       struct.error: required argument is not an integer
TCPMPTCPJoinSYNIsCorrectUnitTests::test_maker_packs_twelve_octets                    PASSED
TCPMPTCPJoinSYNIsCorrectUnitTests::test_spec_octets_parse_back_in_a_syn_segment      PASSED

=== EXIT CODE: 1 ===

The four pre-fix passes are deliberate: the two JoinSYNIsCorrect tests pin a non-defect, and
test_explicit_address_id_keeps_the_legacy_four_octet_form guards against over-correcting
MP_PRIO to a flat 3. test_length_tracks_the_number_of_address_ids prints PASSED while four
of its subtests fail — pytest 9.1.1 here has no pytest-subtests, so a failing subtest does not
fail its parent; test_two_address_ids_pack_five_octets exists so that site has a test that
fails outright, and the run's exit code is 1 either way.

Post-fix, same module:

24 passed, 1 warning, 5 subtests passed in 0.67s
=== EXIT CODE: 0 ===

Whole transport suite:

124 passed, 10 warnings, 80 subtests passed in 47.95s
=== EXIT CODE: 0 ===

Coverage of the two changed files, coverage run -m pytest over
tests/protocols/transport/ (never pytest-cov):

without the new module with it
pcapkit/protocols/schema/transport/tcp.py 98% (2 missed) 100%
pcapkit/protocols/transport/tcp.py 100% 100%
total 99% 100%

pylint with the project's own Makefile flags, message set diffed against main: one
warning removed (W0109: Duplicate key 'A' in dictionary, the duplicate 'A': flag_A the
issue notes in the DSS flags literal), none added.

Two tests that pinned the old constants, now updated

Also in this PR: the EXPECTED_FAILURES entry this fix makes stale

EXPECTED_FAILURES['tcp-mptcp/MP_FASTCLOSE'] is deleted, in
tests/protocols/test_option_roundtrip_unit.py — a file outside the six sites, hence this
note. The entry existed because of #576, so it is stale precisely as a consequence of this
fix, and the suite is built to say so: "a case present in it must still fail ... so that fixing
the defect turns this red and the entry gets deleted rather than left behind". Removing it on
main separately would turn main red until this merged, so it belongs here.

tests/protocols/test_option_roundtrip_unit.py alone:

with the entry with it deleted
result 1 failed, 6 passed, 357 subtests passed 6 passed, 358 subtests passed
exit code 1 0

The subtest count rises by one because tcp-mptcp/MP_FASTCLOSE moves from a recorded gap to a
passing round-trip case.

tcp-mptcp/MP_JOIN is kept: it fails for an unrelated reason (below), not for anything
#576 describes. The narrative comment above both entries is updated to record why MP_FASTCLOSE
left the table, and to note that REMOVE_ADDR, MP_PRIO and DSS read 'OK' in this suite
throughout without being correct — it only checks the cycle is self-consistent, which a wrong
length can be, which is why #576's coverage is per option against RFC 8684 instead.

Deliberately not fixed

  • NumberField(length=<callable>) cannot pack — latched _need_process, detailed above.
    pcapkit/corekit/fields/numbers.py, out of scope here; worked around in the schema.
  • tcp-mptcp/MP_JOIN still cannot be constructed through TCP(). _make_mptcp_join
    dispatches on self._flags, which TCP._make assigns after it has already built the
    options (pcapkit/protocols/transport/tcp.py: options at :547, _flags at :567), so
    construction raises AttributeError: 'TCP' object has no attribute '_flags'. Already recorded
    in EXPECTED_FAILURES with that exact diagnosis, unchanged by this work; it is why the
    MP_JOIN tests here drive the makers directly and reach the readers by parsing bytes.
  • REMOVE_ADDR with zero Address IDs. Figure 13 shows one Address ID plus "n-1 ... if
    required", so n = 0 is not a described form, but _read_mptcp_remove permits length >= 3.
    Tightening that rejects input the library previously accepted, which is beyond this change;
    noted in the reader's docstring.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Flagging the one red in CI so it is not misread as a broken fix.

The only failing test in the whole suite is a stale expected-failure record, not a defect in this change:

SUBFAILED(case='tcp-mptcp/MP_FASTCLOSE') tests/protocols/test_option_roundtrip_unit.py::OptionRoundTripTests::test_round_trip_is_identity_or_a_recorded_gap
E  AssertionError: 'OK' != 'CONSTRUCT'
E  : tcp-mptcp/MP_FASTCLOSE was recorded as failing with CONSTRUCT (...) but came back OK: .
E    If the defect is fixed, delete its EXPECTED_FAILURES entry.

That is the suite working as designed — its own docstring says "a case present in it must still fail ... so that fixing the defect turns this red and the entry gets deleted rather than left behind". MP_FASTCLOSE was in EXPECTED_FAILURES because of #576, and #576 is what this PR fixes, so the entry has to go.

The required change is deleting the 'tcp-mptcp/MP_FASTCLOSE' entry from EXPECTED_FAILURES in tests/protocols/test_option_roundtrip_unit.py. It is deliberately not done here because that file is outside this change's scope. tcp-mptcp/MP_JOIN stays, correctly: it fails for an unrelated reason (_make_mptcp_join reads self._flags, which TCP._make assigns after it has already built the options) that this PR does not touch.

Two incidental notes on that entry's recorded text, for whoever removes it: it cites RFC 8684 §3.7 for MP_FASTCLOSE, but §3.7 is Fallback (MP_FAIL) — Fast Close is §3.5, figure 14. The same mis-citation is in #576's body. And its line references (tcp.py:1893, :3054, schema/transport/tcp.py:907) are pre-#579 and have since moved.

Re-derived every length from RFC 8684 rather than from the issue body, which
mis-cites one section and files one site against the wrong line.

- MP_FASTCLOSE (s3.5 fig 14, 12 octets): the schema had no reserved field, so it
  packed 11 against a declared 12, and `_read_mptcp_fastclose` required 16.
  Added the reserved octet and corrected the guard to 12; the maker was already
  right.
- MP_JOIN-SYN/ACK (s3.2 fig 6, 16 octets): `_make_join_synack` wrote 12, the SYN
  form's length, and `_read_join_synack` required 20, contradicting its own
  docstring. Both now 16. The `opt` branch also never assigned `hmac`, so
  reconstruction substituted `bytes(8)` for the parsed HMAC.
- MP_JOIN-ACK (s3.2 fig 7, 24 octets): `_make_join_ack` wrote 8. Its reader
  already required 24, so nothing it produced could be parsed back.
- REMOVE_ADDR (s3.4.2 fig 13, `3 + n`): length was a constant 4, right for
  exactly the one-ID case the fixture happens to use.
- MP_PRIO (s3.3.8 fig 11, 3 octets): length was a constant 4, which satisfied
  `MPTCPPriority.addr_id`'s own `length == 4` predicate and so packed a phantom
  all-zero Address ID. The `opt` branch also dropped `backup`.
- DSS (s3.3 fig 9): the maker's length expression is RFC-correct and unchanged;
  the defect is the schema field widths #576's own split names, `MPTCPDSS.ack`
  and `.dsn` packing 0 octets rather than 4 when unextended. Both now select
  `UInt32Field`/`UInt64Field` through a `SwitchField`, because
  `NumberField(length=<callable>)` cannot pack at all.

MP_JOIN-SYN is not a defect: figure 5 gives 12 and all three sites agree.

Deletes the now-stale `tcp-mptcp/MP_FASTCLOSE` entry from EXPECTED_FAILURES,
which this fix makes pass; `tcp-mptcp/MP_JOIN` stays, failing for an unrelated
`self._flags` ordering defect. Adds the two new selectors to the docs.

New per-site tests in test_tcp_mptcp_length_arithmetic_unit.py, each failing on
the unfixed tree. Coverage of the two changed modules 99% -> 100%.
@JarryShaw
JarryShaw force-pushed the fix/576-mptcp-length-arithmetic branch from 708d98d to 86d225c Compare September 21, 2026 21:04
@JarryShaw JarryShaw changed the title fix(tcp): correct MPTCP option lengths at five sites, and DSS's packed widths (#576) fix(tcp): correct MPTCP option length arithmetic at all six #576 sites Sep 21, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Superseding my earlier comment: the EXPECTED_FAILURES entry is now deleted in this PR, so there is no remaining red to explain.

tests/protocols/test_option_roundtrip_unit.py in isolation:

with the entry with it deleted
result 1 failed, 6 passed, 357 subtests passed 6 passed, 358 subtests passed
exit code 1 0

tcp-mptcp/MP_JOIN is kept — it fails for the unrelated self._flags statement-ordering defect in TCP._make, which is being filed separately.

Rebased onto current main (9c240a60e) and squashed to one commit. The RFC 8684 §3.5-not-§3.7 correction for MP_FASTCLOSE is being applied to #576's body separately; the mis-citation that lived in the deleted EXPECTED_FAILURES entry went with it.

@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO GO at head sha 86d225c2d796d9edc217cb95bf40d0479f5ecb29: all six length sites re-derived independently against RFC 8684's own text (figures 5/6/7/9/11/13/14, sections 3.2/3.3/3.3.8/3.4.2/3.5) match the PR exactly; the two new/changed test files were proven to fail on the unfixed source and pass on the fix by reverting only pcapkit/protocols/schema/transport/tcp.py and pcapkit/protocols/transport/tcp.py and rerunning; Fixes #576 is present; no CheckRun has failed (11 SUCCESS, 11 IN_PROGRESS, 2 SKIPPED, 0 FAILURE at last check).

@JarryShaw

Copy link
Copy Markdown
Owner Author

Reviewer: Sonnet; PR authored on Opus 5.

Falsify-not-bless pass on PR #585 (fix/576-mptcp-length-arithmetic, head 86d225c2d796d9edc217cb95bf40d0479f5ecb29), closing #576.

1. RFC 8684 re-derivation, from https://www.rfc-editor.org/rfc/rfc8684.txt directly, not from the PR's prose

Downloaded the plaintext RFC and checked every figure/section citation against the RFC's own headers and diagrams:

  • Figure map confirmed (grep -n "Figure [0-9]" + section headers): Fig 4 = MP_CAPABLE §3.1, Fig 5 = MP_JOIN-SYN §3.2, Fig 6 = MP_JOIN-SYN/ACK §3.2, Fig 7 = MP_JOIN-ACK §3.2, Fig 9 = DSS §3.3, Fig 11 = MP_PRIO §3.3.8, Fig 13 = REMOVE_ADDR §3.4.2, Fig 14 = MP_FASTCLOSE §3.5, Fig 16 = MP_FAIL/Fallback §3.7. Every citation in the PR matches this map exactly; none of the historical Fig-4/Fig-5 misattribution recurs here.
  • MP_JOIN-SYN/ACK, Fig 6: RFC literally labels the header row Length = 16. PR's claimed length: 16. Match.
  • MP_JOIN-ACK, Fig 7: RFC literally labels Length = 24. PR's claimed length: 24. Match.
  • MP_JOIN-SYN, Fig 5: RFC literally labels Length = 12, and the PR treats this as already-correct (not one of the six sites). Match — confirmed independently, not merely because the PR agrees with itself.
  • REMOVE_ADDR, Fig 13: RFC literally labels Length = 3 + n. PR's claim: 3 + len(addr_id_list). Match, stated almost verbatim in the RFC itself.
  • MP_PRIO, Fig 11: my own bit count of the diagram (Kind(1)+Length(1)+Subtype/rsv/B(1)) = 3 octets. PR's claim: 3. Match. RFC §5 (line 2713-2714 of the plaintext) states verbatim: "this document specifies the removal of the AddrID field [RFC6824] in the MP_PRIO option (Section 3.3.8)" — matches the PR's quote of this almost word for word.
  • MP_FASTCLOSE, Fig 14: my own bit count (Kind(1)+Length(1)+Subtype+12-bit-reserved(2)+Option Receiver's Key(8)) = 12 octets. PR's claim: 12. Match.
  • DSS, Fig 9: RFC states verbatim "The maximum length of this option, with all flags set, is 28 octets." PR's formula 4 + 4(A) + 4(a) + 12(M) + 4(m) gives 28 when all flags set. Match, and the intermediate terms (Data ACK 4-or-8, DSN 4-or-8, SSN 4 fixed, Data-Level Length 2 fixed, Checksum 2 fixed) all match the diagram's field widths directly.

2. Code-level claims, verified against the diff and by revert-and-rerun

Read pcapkit/protocols/schema/transport/tcp.py and pcapkit/protocols/transport/tcp.py diffs directly. Every "before" defect the PR describes is visible in the diff's own removed (-) lines: NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 0, ...) (0, not 4, confirmed as the literal old line), MPTCPFastclose with no reserved field, length=12/length=8 constants for the two MP_JOIN makers, length=4 constants for REMOVE_ADDR and MP_PRIO, a dropped hmac/backup in two reconstruction branches, and a duplicate 'A': flag_A key. None of this is taken on the PR's word — I read the actual hunks.

Checked out 86d225c2d into a throwaway worktree (/tmp/review-585v2, removed after use):

tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py
24 passed, 1 warning, 5 subtests passed in 0.65s   (exit 0)

Then git checkout origin/main -- pcapkit/protocols/schema/transport/tcp.py pcapkit/protocols/transport/tcp.py (source only) and reran the same file:

24 failed, 4 passed, 2 warnings, 1 subtests passed in 1.27s   (exit 1)

Confirms the new tests genuinely pin the fix (numbers differ slightly in aggregate count from the PR's own reported per-case breakdown because of how pytest 9.1.1 folds subtests without pytest-subtests, but the fail/pass split is the same shape: everything not guarding a non-defect fails without the fix).

3. The test_option_roundtrip_unit.py stale-entry deletion, reproduced exactly

Ran the file as shipped (MP_FASTCLOSE Gap entry deleted): 6 passed, 1 warning, 358 subtests passed in 0.96s, exit 0 — matches the PR's claim exactly.

Then reverted only tests/protocols/test_option_roundtrip_unit.py to origin/main (restoring the stale Gap entry) while keeping the PR's fixed source, and reran: 1 failed, 6 passed, 1 warning, 357 subtests passed in 0.97s, exit 1, with the failure being exactly what the PR's reasoning predicts —

AssertionError: 'OK' != 'CONSTRUCT'
: tcp-mptcp/MP_FASTCLOSE was recorded as failing with CONSTRUCT (... length=12,
which is the RFC 8684 section 3.7 value ...) but came back OK ... If the defect
is fixed, delete its EXPECTED_FAILURES entry.

This also independently confirms the PR's claim that the stale entry (before this PR) cited RFC 8684 section 3.7 for MP_FASTCLOSE — the diff's removed lines literally read length=12, which is the RFC 8684 section 3.7 value, which is wrong (3.7 is Fallback/MP_FAIL; 3.5 is Fast Close, confirmed in §1 above). The PR's replacement comment states this correction explicitly. And tcp-mptcp/MP_JOIN's Gap entry is confirmed kept, with an added comment attributing its failure to the _flags-ordering defect rather than to #576.

4. Fixes #576 literal line

Present, verbatim, first line of the PR body: Fixes #576 (no trailing period, but the literal Fixes #576 text GitHub's parser needs is there and PR metadata confirms it will close #576 on merge).

5. CI (CheckRun tally, StatusContext excluded)

At last check (24 total CheckRuns): 11 SUCCESS, 11 IN_PROGRESS, 2 SKIPPED, 0 FAILURE. statusCheckRollup.state: PENDING. No force-push has occurred on this branch (single commit throughout my review), so no CANCELLED/FAILURE conflation risk.

Corrections to two claims relayed to me about this PR (not from the PR itself)

I was told the PR's title claims five sites where #576 describes six, and that it changes "DSS's packed widths" as an out-of-scope addition #576 did not ask for. Checked both against primary sources directly:

Flagging this since acting on an inaccurate characterization of a PR's own scope, rather than checking it, is exactly the failure mode this review process exists to catch.

Disagreements / open items

  • None on the fix itself — all six sites check out against the primary RFC text and the tests genuinely pin them.
  • 22 CheckRuns resolved out of 24 as of this writing with zero failures; the remaining two (last IN_PROGRESS at time of posting) are the standard version/integration matrix, not touching anything specific to this diff.

@JarryShaw
JarryShaw merged commit 337476a into main Sep 21, 2026
25 checks passed
@JarryShaw
JarryShaw deleted the fix/576-mptcp-length-arithmetic branch September 21, 2026 22:06
JarryShaw added a commit that referenced this pull request Sep 21, 2026
…ce from the placeholder (#591)

A `NumberField` whose `length` was a callable could not pack or parse at any
width `struct` has a native integer code for. `length` is a placeholder of `-1`
until `__call__` resolves the callable, `-1` has no native code, and
`build_template` raised `_need_process` for it and never put it back -- so the
flag was a latch. Resolving the real width rebuilt the template and left the
latch set, and `pre_process` then handed bytes to a template that had become
`>Q`, raising `struct.error: required argument is not an integer`.

- `build_template` now *assigns* `_need_process` rather than only ever raising
  it, so the flag always describes the length that template was built for.
  That is what tells a placeholder apart from a width that genuinely needs byte
  packing without tracking that a placeholder was ever in play: the answer for
  `-1` is True, the answer for `8` is False, and the width in force decides. A
  callable resolving to 3 still takes the fall-through branch and still gets
  True, so clearing the flag unconditionally -- which would have been the
  one-line fix -- is not what happens here.
- All four native widths were affected, not only the 8 that #591 reproduces.
  The latch has nothing to do with the width it latches into, so 1, 2 and 4
  failed identically. Measured on `NumberField` and on `EnumField`, both of
  which leave `__template__` unset; the eight subclasses that fix
  `__template__` never latched anything and are unchanged.
- Parsing was broken in the mirror direction and is fixed with it:
  `post_process` called `int.from_bytes` on the integer `struct.unpack` had
  already produced from a `>Q` template.
- `pre_process` consults the flag *after* the `_length < 0` repair rebuilds the
  template rather than before it. That repair can land on a native width, and
  deciding first and rebuilding second is how the template and the returned
  value came to disagree in the first place.

This is what made every extended 8-octet MPTCP DSS form unbuildable, since
those widths are chosen at runtime from the DSS flags and so must come from a
callable. #585 worked around it in the TCP schema alone, leaving every other
caller exposed; that workaround is left in place, because its `NoValueField`
branch for an absent field is load-bearing independently of this defect.

New tests in tests/corekit/test_fields_numbers_callable_length.py proven to
fail without the fix: 21 failures and 8 errors across 7 of 10 tests before, all
10 passing after. The 3 that pass either way are the guards against
over-correcting -- the byte-packed widths, the unresolved placeholder, and the
`__template__` subclasses. tests/corekit/ 134 passed, 195 subtests, and the
MPTCP length-arithmetic suite 24 passed, 5 subtests. No new mypy finding.

Fixes #591
JarryShaw added a commit that referenced this pull request Sep 21, 2026
…ce from the placeholder (#591) (#598)

A `NumberField` whose `length` was a callable could not pack or parse at any
width `struct` has a native integer code for. `length` is a placeholder of `-1`
until `__call__` resolves the callable, `-1` has no native code, and
`build_template` raised `_need_process` for it and never put it back -- so the
flag was a latch. Resolving the real width rebuilt the template and left the
latch set, and `pre_process` then handed bytes to a template that had become
`>Q`, raising `struct.error: required argument is not an integer`.

- `build_template` now *assigns* `_need_process` rather than only ever raising
  it, so the flag always describes the length that template was built for.
  That is what tells a placeholder apart from a width that genuinely needs byte
  packing without tracking that a placeholder was ever in play: the answer for
  `-1` is True, the answer for `8` is False, and the width in force decides. A
  callable resolving to 3 still takes the fall-through branch and still gets
  True, so clearing the flag unconditionally -- which would have been the
  one-line fix -- is not what happens here.
- All four native widths were affected, not only the 8 that #591 reproduces.
  The latch has nothing to do with the width it latches into, so 1, 2 and 4
  failed identically. Measured on `NumberField` and on `EnumField`, both of
  which leave `__template__` unset; the eight subclasses that fix
  `__template__` never latched anything and are unchanged.
- Parsing was broken in the mirror direction and is fixed with it:
  `post_process` called `int.from_bytes` on the integer `struct.unpack` had
  already produced from a `>Q` template.
- `pre_process` consults the flag *after* the `_length < 0` repair rebuilds the
  template rather than before it. That repair can land on a native width, and
  deciding first and rebuilding second is how the template and the returned
  value came to disagree in the first place.

This is what made every extended 8-octet MPTCP DSS form unbuildable, since
those widths are chosen at runtime from the DSS flags and so must come from a
callable. #585 worked around it in the TCP schema alone, leaving every other
caller exposed; that workaround is left in place, because its `NoValueField`
branch for an absent field is load-bearing independently of this defect.

New tests in tests/corekit/test_fields_numbers_callable_length.py proven to
fail without the fix: 21 failures and 8 errors across 7 of 10 tests before, all
10 passing after. The 3 that pass either way are the guards against
over-correcting -- the byte-packed widths, the unresolved placeholder, and the
`__template__` subclasses. tests/corekit/ 134 passed, 195 subtests, and the
MPTCP length-arithmetic suite 24 passed, 5 subtests. No new mypy finding.

Fixes #591
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: 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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_make_mptcp_*/_read_mptcp_* length arithmetic is wrong at six more sites beyond MP_CAPABLE

1 participant