fix(mh): stop the four flag enums recursing on any non-member value (#623) - #632
Conversation
…623) * pcapkit/vendor/mh/binding_ack_flag.py, binding_update_flag.py, handover_ack_flag.py, handover_initiate_flag.py: `_missing_` ended in `return cls(value)` -- the same constructor that had just failed to find the value -- so every in-range value that is not already a member re-entered `_missing_` unbounded and raised RecursionError. Because these are IntFlag types whose members are single bits, the reachable hole was `F(0)`, no flags set, and every composite of two defined bits with it. Defining `_missing_` at all is what caused it: the override shadowed the aenum Flag machinery that resolves exactly those values, which is why pcapkit/const/tcp/flags.py, which defines none, never had the defect. All four now end in `return super()._missing_(value)`, the tail pcapkit/vendor/default.py emits for every other generated enumeration and that 75 of the 117 modules under pcapkit/const/ already carry, so `F(0)` is an empty flag and `BindingACKFlag(0x06)` is `S|D`. The range guard above it is untouched. * pcapkit/const/mh/: regenerated from those four templates rather than hand-edited, these being generated output the next crawl would revert. The crawlers reproduce the committed files byte for byte -- md5 2ddc85e2.., 5b15dcd1.., b7a9d045.., fc0a68f0.. unchanged across the regeneration. * tests/const/test_const_enum_lookup.py: a companion IntFlag sweep. #492's own sweep excludes IntFlag by construction, which is why it never caught this. 6 new tests, 16 subtests that fail without the fix, covering `F(0)`, an unassigned single bit, a composite of two defined bits, the surviving range guard, and template/generated agreement character for character. Scoped runs green: tests/const/ 20 passed / 249 subtests, tests/vendor/ 55 passed, tests/protocols/internet/test_mh_unit.py 50 passed, tests/project/ 96 passed. Coverage on the four const modules rose from 87/89/85/86% to 94/94/93/93%. Fixes #623
Cross-review verdict: GOOD TO GOIndependent adversarial review by a subagent on a different model (Sonnet) — the authoring model was Opus. It was briefed to falsify rather than bless, given the eight load-bearing claims explicitly, and told that disagreements were the valuable output. It ran read-only: 85 tool calls, no repository file amended, Measured against
House rules: all satisfied — author, one commit, What it verified that I had notC1. It read C2. It judged my proof method weak — correctly — and replaced it with a better one. Rather than re-feeding the committed C3. It categorised the last line of C4. Confirmed for the #616 worker, and enumerated the whole population: of 117 files under C6. It ran the experiment I should have run: reverted only the four The one caveat, and why I am leaving it
Leaving it as written, deliberately, for two reasons. It matches the existing precedent in Not attemptedNo mutation testing, and no fuzzing of the guard's boundary arithmetic beyond the probes above. Nothing observed suggests either would change the verdict. Pre-existing, out of scope, not fixedThe |
Fixes #623.
The defect, re-verified on current
main_missing_in the four Mobility Header flag enumerations ended inreturn cls(value)— the same constructor that had just failed to find the value — so every in-range value that is not already a member re-entered_missing_unbounded.Measured on
6c3d1b0d9(the base this branch was cut from), CPython 3.14.7, with the editable install's_EditableFinderstripped fromsys.meta_pathand the worktree asserted atsys.path[0],pcapkit.__file__printed as…/.claude/worktrees/agent-a70c31eba5d7b14b1/pcapkit/__init__.py:All four behave the same way. Two points the issue's framing understates:
0. Every in-range non-member recursed —1,6,255.0is merely the most obviously reachable one.0x06isS|D. These areIntFlagtypes whose members are single bits, so a composite of two defined bits is also not a member, and also recursed. That is an ordinary value for a flag octet to carry, not an exotic one.Why it happened, and why the fix is the shape it is
Defining
_missing_at all is the cause.aenum'sFlag._missing_is what resolves zero and composite values — it calls_create_pseudo_member_. Overriding_missing_shadowed it.pcapkit/const/tcp/flags.pydefines no_missing_and has never had the defect (Flags(0)→<Flags: 0>).So the fix is to keep the range guard and delegate the rest:
if not ({FLAG}): raise ValueError('%r is not a valid %s' % (value, cls.__name__)) - return cls(value) + return super()._missing_(value)This is not a second idiom for the same problem — it is the repo's existing one.
pcapkit/vendor/default.py:110emits exactlyreturn super()._missing_(value)as the tail of every generated_missing_, and 75 of the 117 modules underpcapkit/const/already carry it. The four MH flag templates are hand-rolledLINEtemplates that never adopted it.The
extend_enumalternative was measured and rejectedThe other idiom in the tree mints a member for an unassigned integer. Both candidates stop the recursion; they do not behave the same way:
super()._missing_(value)extend_enum(cls, 'Unassigned_0x%02x' % value, value)F(0)<A: 0>, falsy<B.Unassigned_0x00: 0>F(0x06)<A.D|S: 6><B.Unassigned_0x06: 6>F(0x100)ValueErrorValueError_member_map_after['B','D','S']— unchanged['BB','D','S','Unassigned_0x00','Unassigned_0x01','Unassigned_0x06','Unassigned_0x0e','Unassigned_0xff']extend_enumhides the composite behind an opaque name and pollutes_member_map_with an entry per bit pattern — up to 65536 of them forBindingUpdateFlag, whose guard runs to0xFFFF. Wrong for a flags type, sosuper()it is.After
The guard above the changed line is untouched, so out-of-range and non-integer values raise the same
ValueErroras before.Fixed in the vendor templates, then regenerated
pcapkit/const/mh/is generated output, so aconst/-only fix would be reverted by the next crawl. The change is in the fourpcapkit/vendor/mh/templates;pcapkit/const/mh/was then produced by actually running the crawlers against IANA (all four CSV endpoints returned 200), with a guard that recomputedVendor.__init__'s target path and refused to proceed unless it landed inside this worktree — the editable install points at the main checkout, andpcapkit-vendor/make vendorresolve through it.Byte-identical by md5, the standard #511 set — hashes before the regeneration, and
md5sum -cafter:git diff --stat -- pcapkit/const/mh/after regenerating shows4 files changed, 4 insertions(+), 4 deletions(-)— the one changed line per file, and nothing else.Tests
Added to
tests/const/test_const_enum_lookup.py, which is where_missing_correctness already lives. That file's own #492 sweep excludesIntFlagby construction (and not issubclass(obj, IntFlag), "a different value-lookup contract") — which is precisely why #492 did not catch this, in the class of enum it declined to walk. The newConstFlagMissingRecursionTestsis the mirror-image sweep, plus named cases for the four registries:F(0), an unassigned single bit (1), a composite of two defined bits, the surviving range guard, and template↔generated agreement character for character.Without the fix — all four
const/and all fourvendor/files reverted toHEAD, exit code read from a file rather than a pipeline:with, for instance:
With the fix:
16 subtests move from failing to passing (118 → 134), four per enum. The tests assert the resolved value rather than catching
RecursionError, so an unfixed tree fails on the error itself.test_the_range_guard_still_rejectspasses both with and without the fix — deliberately, since it pins the part of_missing_that was already correct.Scoped runs
Never the whole suite. Exit codes read from files.
tests/const/+tests/project/(post-rebase)116 passed, 718 subtests passed, exit 0tests/vendor/55 passed, 54 subtests passed, exit 0tests/protocols/internet/test_mh_unit.py50 passed, 468 subtests passed, exit 0python util/changelog_md.py --checkCoverage
coverage run --source=pcapkit.const.mh -m pytest tests/const/, branch coverage on, atHEADversus this branch:pcapkit/const/mh/binding_ack_flag.pypcapkit/const/mh/binding_update_flag.pypcapkit/const/mh/handover_ack_flag.pypcapkit/const/mh/handover_initiate_flag.pyThe changed line was previously unexecuted in all four (
binding_ack_flag.pymissing61, 73→ missing61), which is another way of saying nothing tested it. The surviving miss in each is the pre-existingget()default-fallback line, untouched here.Scope deliberately not widened
git grep -n "return cls(value)" -- pcapkit/const/ pcapkit/vendor/finds six modules on each side, not four:pcapkit/const/pcapng/record_type.pyandpcapkit/const/pcapng/secrets_type.py— not defective. Their_missing_runsextend_enum(cls, 'Unassigned_0x%04x' % value, value)first, so by the timecls(value)runs the member exists and there is no second_missing_call. Verified, and left alone. Their vendor side emits the same pair as amisslist (pcapkit/vendor/pcapng/record_type.py:68).No other enum in the tree shares the defect. Nothing else was found and left unfixed.
For the #616 worker (TCP
_flagsno-opcast)pcapkit/const/tcp/flags.pydoes not share this defect. It defines no_missing_at all (grep -c "_missing_" pcapkit/const/tcp/flags.py→0), so it usesaenum's ownIntFlag._missing_, and a zero-valued flag construction works on unpatchedmain:Flags(-1)resolves to all bits set rather than raising, since there is no range guard — worth knowing, but not this issue.Relation to #596
#596 (
545b174bf) touched these same four vendor templates, but only theirget()method — the integer path that dropped the caller'sdefault. It left_missing_alone, and its own commit message says so.tests/const/test_const_enum_get.py:58-65, added by #596, names this recursion explicitly and routes around it, choosing1 << 70as its unresolvable probe because an in-range value would have hit it. This PR is the follow-up that fixes what that comment describes, in the same templates, using the tail #596's own reference template already emitted.AI Usage
Written with Claude Code. The defect was re-verified, both fix candidates measured against
aenum3.1.17 rather than reasoned about, the regeneration run end-to-end and checked by md5, and the failing-then-passing evidence captured with exit codes read from files. A cross-review subagent on a different model reviewed the change; its verdict is posted as a comment below.