fix(corekit): recompute _need_process from the width in force, not once from the placeholder (#591) - #598
Conversation
…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
cee1cdb to
84d8386
Compare
|
✅ GOOD TO MERGE — head |
Cross-review appendix — PR #598Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head
|
| test | reverted | fixed |
|---|---|---|
test_a_callable_length_packs_exactly_as_the_same_static_length_does |
1 | 0 |
test_a_callable_length_roundtrips_through_pack_and_unpack |
1 | 0 |
test_a_callable_resolving_to_a_byte_packed_width_still_needs_processing |
0 | 0 |
test_an_unresolved_field_repairs_its_length_and_honours_the_new_template |
1 | 0 |
test_every_native_width_was_affected_not_only_the_reported_eight |
1 | 0 |
test_the_flag_always_agrees_with_the_template |
1 | 0 |
test_the_placeholder_itself_still_needs_processing |
0 | 0 |
test_the_reported_case_a_callable_resolving_to_eight_packs |
1 | 0 |
test_a_subclass_fixing_a_template_keeps_working_either_way |
0 | 0 |
test_an_enum_field_with_a_callable_length_packs |
1 | 0 |
7 of 10 fail without the fix, exactly as claimed, and the 3 that pass either way map exactly to the PR's three named guard categories (byte-packed width, unresolved placeholder, __template__ subclass) — one test each.
The SwitchField/NoValueField judgment call (as asked)
Read mptcp_dss_ack_selector/mptcp_dss_dsn_selector in pcapkit/protocols/schema/transport/tcp.py and ConditionalField/NoValueField in pcapkit/corekit/fields/misc.py directly, rather than accepting the PR's framing.
The PR's claim, verbatim: "Its NoValueField() branch handles the field being absent from the wire when the DSS flag is clear, which a NumberField(length=<callable>) cannot express whatever this fix does."
Narrowly true, but incomplete. A bare NumberField genuinely has no notion of "don't participate in packing at all" — it always packs some resolved-width bytes. But ConditionalField (already in the same file, misc.py:74-219, not something #598 would need to add) independently supplies exactly that: ConditionalField.pack() is if not self._condition(packet): return b'' and ConditionalField.unpack() is if not self._condition(packet): return self._field.default — it never touches the inner field at all when its own condition is false. So ConditionalField(NumberField(length=lambda pkt: 8 if pkt['flags']['a'] else 4), condition=lambda pkt: pkt['flags']['A']) would, as far as I can tell from reading both classes, now work correctly for presence and width, given #598's fix to NumberField.
This matters because the docstring says the pre-#576 code was exactly ConditionalField wrapping a NumberField(length=lambda pkt: ...) — but that lambda conflated presence and width into one return value (8 if pkt['flags']['a'] else 0, using 0 to mean "absent" rather than using ConditionalField's own condition for that), which is a different and more fragile design than a properly separated presence-lambda/width-lambda pair would be. So the fix that would have made ConditionalField+NumberField viable again isn't really "fix NumberField's callable length" (which #598 does) plus nothing else — it's that, plus decoupling the presence and width lambdas, which is a design change beyond this PR's stated scope.
My judgment: leaving the SwitchField/NoValueField/UInt32Field/UInt64Field structure in place is the right scope decision — it works, it's tested, and swapping it for a ConditionalField-based design would be an unrelated refactor of a file the PR itself says is "owned by #587" (correctly declining to touch it). But the stated reason overclaims slightly: it isn't that NumberField(length=<callable>) categorically cannot participate in an absence-aware design at all — ConditionalField already supplies that half independently. Not a blocking defect — the code and scope decision are both fine — but a place where the prose says more than the code proves, worth recording since it's exactly the kind of claim the review programme wants challenged rather than passed through.
One smaller, non-blocking note: the PR body attributes this structure to "#585's SwitchField," while the in-code comments say mptcp_dss_ack_selector's replacement happened "until #576" and mptcp_dss_dsn_selector's says "C.f. #576" — #585 and #576 both appear to be real, related MPTCP length-arithmetic issues (per PR #597's body, which cites test_tcp_mptcp_length_arithmetic_unit.py as "#576/#585"), so this may be a case of the wrong one being named rather than a fabricated citation, but I didn't chase down which issue actually introduced this specific selector.
Other verification
python util/changelog_md.py --checkexits 0.- The disclosed-but-not-fixed
math.ceil(value.bit_length() // 8)under-counting bug: confirmed directly —(256).bit_length()is 9,9 // 8is 1 (math.ceilon anintis a no-op, exactly as the PR notes), and(256).to_bytes(1, 'big')raisesOverflowError: int too big to convert. This is a real, separate, pre-existing defect on a path this PR only makes internally consistent; correctly left alone and correctly described. tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py+tests/protocols/test_option_roundtrip_unit.py: 30 passed, 363 subtests passed, exit 0 — the fix(tcp): correct MPTCP option length arithmetic at all six #576 sites #585 DSS 8-octet forms still pack correctly, unaffected by this PR.coverage run --include='pcapkit/corekit/fields/numbers.py' -m pytest tests/corekit/: 134 passed, 195 subtests passed, exit 0;numbers.pyat 96% statement/branch (116 stmts/4 missed, 32 branches/2 partial) — close to, though not an exact re-derivation of, the PR's own coverage claim (I did not chase the precise before/after percentages, treating the pass/fail test evidence above as the load-bearing check).
Not independently checked
- The exact
mypy"no new finding" claim was not rerun. - I did not verify every one of the PR's cited failure messages verbatim (e.g. the exact
errors=8/failures=21unittest tally); the per-test exit-code table above, which I built independently, is the check I'm relying on instead and it reconciles (7 distinct failing tests match their "7 of 10" claim).
Disagreement log
One nuance, not a blocking defect: the SwitchField/NoValueField justification ("a NumberField(length=<callable>) cannot express wire-absence whatever this fix does") is true of NumberField alone but doesn't account for ConditionalField, which already independently supplies absence-handling and could pair with the now-fixed NumberField. The PR's decision not to redesign the schema is still correct; its stated reason for that decision overclaims. Everything else — the core mechanism, all four native widths on both the pack and unpack sides, the 7-of-10 test evidence, the disclosed math.ceil defect, and the regression runs — 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.
…itten _flags (#603) (#612) `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.
The defect
A
NumberFieldwhoselengthis a callable could not pack or parse at any widthstructhas a native integer code for.pcapkit/corekit/fields/field.py:541-543turns a callablelengthinto a placeholder of-1.pcapkit/corekit/fields/numbers.py:109clears_need_processin__init__, thenbuild_templateraises itTruein its fall-through branch — which-1takes, because-1is not one of{1, 2, 4, 8}. Nothing ever put the flag back, so it was a latch.__call__resolved the real width and rebuilt the template correctly, the latch survived,pre_processtook the process path,self._lengthwas no longer negative so the_length < 0repair was skipped, and it returnedvalue.to_bytes(...)into a template that was by then>Q.Parsing was broken in the mirror direction by the same flag:
post_processcalledint.from_byteson the integerstruct.unpackhad already produced.Measured, on
fa6d18e31The comparison is the proof — identical resolved length, identical template, differing only in how the length was supplied:
All four native widths were affected, not only the 8 that #591 reproduces. #591 left this open explicitly; it is established here rather than assumed. The latch has nothing to do with the width it latches into:
After the fix every callable row matches its static counterpart exactly, width for width, and the 3-octet row is unchanged.
EnumFieldwas affected identically, since it also leaves__template__unset. The eight subclasses that fix__template__(UInt32Fieldand friends) never latched anything —__init__skipsbuild_templateentirely for them — and are unchanged.Placeholder vs. genuine need
build_templatetakes the same fall-through branch for any width outside{1, 2, 4, 8}, so a callable resolving to 3 legitimately still needs byte packing. Clearing the flag on__call__would have broken that.The fix does not track "was a placeholder" at all. It makes the flag a function of the width currently in force rather than a latch:
build_templatenow assigns_need_processinstead of only ever raising it. The answer for-1isTrue, the answer for8isFalse, and whichever width is in force is the one that decides. The placeholder'sTrueis still correct for the placeholder — it is simply no longer sticky.One consequence, handled:
pre_processnow consults the flag after the_length < 0repair rebuilds the template rather than before it. That repair can itself land on a native width, and deciding first then rebuilding second is precisely how the template and the returned value came to disagree in the first place.Evidence, verbatim
New tests in
tests/corekit/test_fields_numbers_callable_length.py, run against a pristine export offa6d18e31and then against this branch.Before:
with, among them:
After:
7 of the 10 tests fail without the fix. The 3 that pass either way are deliberate guards against over-correcting: the byte-packed widths (3, 5, 6, 7, 9, 16) must keep
_need_process, the unresolved placeholder must keep it, and the__template__subclasses must be untouched.Other verification
coverage run --include='pcapkit/corekit/fields/numbers.py' -m pytest tests/corekit/— 134 passed, 195 subtests, exit 0.tests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py— 24 passed, 5 subtests. The 8-octet DSS forms still pack, so fix(tcp): correct MPTCP option length arithmetic at all six #576 sites #585's workaround is undisturbed.tests/protocols/test_option_roundtrip_unit.py— 6 passed, 358 subtests. NoEXPECTED_FAILURESentry flipped to passing.tests/protocols/ tests/corekit/— 702 passed, 1622 subtests. The 28 failures in that sweep are all missing sample captures in a fresh worktree; afterexamples/generators/make_samples.pythe eight affected files are 32 passed, 7 subtests.mypyon the changed module reports no new finding (the one remaining error is pre-existing and present on the unfixed file at the same site).python util/changelog_md.py --checkexits 0.Deliberately not done
#585's
SwitchFieldoverUInt32Field/UInt64Fieldinpcapkit/protocols/schema/transport/tcp.pyis not redundant now and is left in place. ItsNoValueField()branch handles the field being absent from the wire when the DSS flag is clear, which aNumberField(length=<callable>)cannot express whatever this fix does. What has gone stale is only the justification recorded in its docstring —mptcp_dss_ack_selector's note says a corrected lambda "would not have worked" and that fixing it "belongs topcapkit.corekit.fields.numbers", which is now done. That file is owned by #587 right now, so the prose is left for its owner, as is the same stale paragraph intests/protocols/transport/test_tcp_mptcp_length_arithmetic_unit.py.Separately,
pre_process's length repair computesmath.ceil(value.bit_length() // 8).math.ceilon anintis a no-op, so that isbit_length() // 8, which under-counts: a value of 256 hasbit_length()9 and is sized at 1 octet, andto_bytes(1)then raisesOverflowError. That is a distinct defect on a path this PR only makes self-consistent, not one it introduces, and it is left alone rather than folded in.Fixes #591