Skip to content

fix(httpv2): restore END_STREAM on rebuild, and seed the parsed flag accumulator as Flags (#652, #650) - #669

Merged
JarryShaw merged 1 commit into
mainfrom
fix/httpv2-flags-roundtrip
Sep 22, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/httpv2-flags-roundtrip

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Two adjacent flag defects in the HTTP/2 tier, filed separately and fixed together because they share the two files.

Fixes #652_make_http_data was the only one of the six _make_http_* methods that never read frame.flags, so END_STREAM was silently dropped on a parse → reconstruct round trip.

Fixes #650FrameType.post_process seeded its flag accumulator with a bare 0, leaving __flags__ a plain int for a flagless frame where both the schema and the data model declare a Flags. #650 was filed as an open question, not an asserted defect, on the grounds that no consumer observed the difference. That turned out to be false, and the measurement below is what settles it.

Measured on 0c7f2b7c9 (the merge base), CPython 3.14.7, tree asserted against the worktree rather than the editable install:

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

#652 — before and after

Parsing a DATA frame and feeding the resulting data object straight back to _make_http_data:

BEFORE
  parsed flags=0x00 (END_STREAM clear) info.flags.END_STREAM=False -> rebuilt flags=<Flags: 0>
  parsed flags=0x01 (END_STREAM SET  ) info.flags.END_STREAM=True  -> rebuilt flags=<Flags: 0>

AFTER
  parsed flags=0x00 (END_STREAM clear) info.flags.END_STREAM=False -> rebuilt flags=<Flags: 0>
  parsed flags=0x01 (END_STREAM SET  ) info.flags.END_STREAM=True  -> rebuilt flags=<Flags.END_STREAM: 1>

And through make, on the constructed header's flags octet — i.e. what a round trip writes to the wire:

BEFORE  original flags octet 0x01 END_STREAM -> rebuilt flags octet 0x00
AFTER   original flags octet 0x01 END_STREAM -> rebuilt flags octet 0x01

All five siblings restore theirs on both sides, which is the specification this follows rather than a mechanism invented here:

_make_http_settings(ACK=True)     -> <Flags.ACK: 1>
_make_http_ping(ACK=True)         -> <Flags.ACK: 1>
_make_http_continuation(EH=True)  -> <Flags.END_HEADERS: 4>
_make_http_headers(ES|EH=True)    -> <Flags.END_STREAM|END_HEADERS: 5>
_make_http_push_promise(EH=True)  -> <Flags.END_HEADERS: 4>

PADDED was never affected, being re-derived from pad_len rather than read from frame.flags.

#650 — the dump output does change, so it is not unobservable

#650's "not established" section says no consumer observes the plain int, having found dump output identical on both paths. The cross-review on #634 had found the equivalent TCP fix did change dump output, from int 0 to the string "Flags::None [0]". The same thing happens here. Real parsed frames, rendered through this library's own make_dumper:

BEFORE
  flags=0x00 flagless (the COMMON case):  type(__value__) = int
    json  "__value__": 0
    tree  |-- __value__ -> 0
  flags=0x01 END_STREAM set:              type(__value__) = Flags
    json  "__value__": "Flags::END_STREAM [1]"
    tree  |-- __value__ -> Flags::END_STREAM [1]

AFTER
  flags=0x00 flagless:                    type(__value__) = Flags
    json  "__value__": "Flags::None [0]"
    tree  |-- __value__ -> Flags::None [0]
  flags=0x01 END_STREAM set:              unchanged

So the observable defect is a type inconsistency in the dump, not merely an internal type: __value__ was a JSON number for a flagless frame and a JSON string for every other frame, within the same capture. It is consistently a string now. That is the same argument #634 made for TCP's connection, and #634 shipped with fix + breaking.

The TypeError goes away too — #616's message verbatim:

BEFORE  END_STREAM in __value__ -> TypeError: argument of type 'int' is not a container or iterable
AFTER   END_STREAM in __value__ -> no error

Flags(0) == 0 is True, so every int-safe consumer the issue enumerated is unaffected.

The literal None in "Flags::None [0]" is the pre-existing pcapkit/dumpkit/common.py:216 defect that #634 already identified and left to its own change; Flags(0).name is None for a bitless pseudo-member. Not touched here — that file belongs to another change in flight.

The one-token fix in the issue would have crashed

#650 proposes "the fix is one token — flags = self.Flags(0)". On Python 3.11 and later that breaks parsing outright for six of the twelve frame schemas. FrameType.Flags declares no members, and from the 3.11 enum rewrite a memberless enum.Flag subclass refuses instantiation (measured on 3.14.7):

FrameType         Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
UnassignedFrame   Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
PriorityFrame     Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
RSTStreamFrame    Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
GoawayFrame       Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
WindowUpdateFrame Flags is FrameType.Flags         BIT_ members= 0  RAISED TypeError: <flag 'Flags'> has no members
DataFrame         Flags is DataFrame.Flags         BIT_ members= 2  OK  repr=<Flags: 0>
HeadersFrame      Flags is HeadersFrame.Flags      BIT_ members= 4  OK  repr=<Flags: 0>
SettingsFrame     Flags is SettingsFrame.Flags     BIT_ members= 1  OK  repr=<Flags: 0>
PushPromiseFrame  Flags is PushPromiseFrame.Flags  BIT_ members= 2  OK  repr=<Flags: 0>
PingFrame         Flags is PingFrame.Flags         BIT_ members= 1  OK  repr=<Flags: 0>
ContinuationFrame Flags is ContinuationFrame.Flags BIT_ members= 1  OK  repr=<Flags: 0>

So the seed is guarded — self.Flags(0) if self.Flags.__members__ else 0. The five inheriting schemas keep the plain int, which nothing observes because all five pass flags=None into their data objects. The test pins both the guard and the reason for it, so it cannot be "simplified" back into a crash.

That version dependence is real, and locating it took two tries. CI runs the unit tier on 3.10 through 3.15 (python -m pytest -q --ignore=tests/integration), so an unconditional assertRaises(TypeError) would fail on any interpreter that does not refuse. The boundary is 3.11, measured across every interpreter available rather than inferred from a message string:

3.8.20   Flags(0) -> OK <Flags.0: 0>
3.9.25   Flags(0) -> OK <Flags.0: 0>
3.10.21  Flags(0) -> OK <Flags.0: 0>
3.11.15  Flags(0) -> TypeError: <flag 'Flags'> has no members defined
3.12.13  Flags(0) -> TypeError: ... has no members; specify `names=()` ...
3.14.7   Flags(0) -> TypeError: ... has no members; specify `names=()` ...

Confirmed in CPython's source too. 3.11's enum.py:1117, inside Enum.__new__, raises TypeError("%r has no members defined" % cls) when not cls._member_map_, and it runs before the _missing_ hook that manufactured the pseudo-member on 3.10; 3.10's enum.py has no such raise, its only "has no members" occurrence being a comment at :616. 3.11 is also where the metaclass was renamed — class EnumType(type) at :479 with EnumMeta = EnumType at :1052, against 3.10's class EnumMeta(type) at :161 — so "the enum rewrite" is the 3.11 release and 3.12 only reworded the message.

A first pass of this PR gated on (3, 12) and was wrong, having inferred the version from the message text measured on 3.14 rather than from the boundary itself. The cross-review caught it; it is corrected here, in the gate and in both comments. The effect of the error was under-assertion rather than a red build — on 3.11 the block was skipped though 3.11 does raise — but it was also a false statement in a shipped source comment.

The test asserts the memberless-ness unconditionally, that being the predicate the guard actually keys on, and gates only the TypeError behind sys.version_info >= (3, 11). The guard in the source is correct on every supported interpreter either way: necessary from 3.11, harmless before.

These are stdlib enum, not aenum — verified, not assumed

Schema_DataFrame.Flags.__mro__ = (<flag 'Flags'>, <flag 'Flags'>, <flag 'IntFlag'>, <class 'int'>,
                                  <enum 'ReprEnum'>, <flag 'Flag'>, <enum 'Enum'>, <class 'object'>)
issubclass(…, enum.IntFlag)  = True
issubclass(…, aenum.IntFlag) = False
TCP Flags (contrast)         = (<aenum 'Flags'>, <aenum 'IntFlag'>, …)   aenum: True

pcapkit/const/tcp/flags.py was read, not edited — it is aenum, it declares no _missing_, and #661 owns const changes.

Failing, then passing

Both sides run from immutable git archive snapshots of 0c7f2b7c9, with each file hashed. The test file is byte-identical across the two runs (6bfb2b2f…, the head's own); only the two source files differ.

Beforeschema/…/httpv2.py 6a155ba5…, application/httpv2.py 9e50fe92…:

SUBFAILED(flags='none set')  …::test_a_flagless_frame_seeds_its_flags_as_an_enum
SUBFAILED(end_stream=True)   …::test_make_http_data_restores_end_stream_from_the_frame
FAILED …::test_httpv2_frame_constructors_cover_all_frame_types_and_branches
FAILED …::test_httpv2_schema_selector_and_frame_post_process_flags
4 failed, 2 passed, 27 deselected, 2 warnings, 2 subtests passed in 2.98s
### EXIT CODE FROM FILE: 1

with, at the #652 subtest:

>       self.assertEqual(bool(rebuilt & DataFrame.Flags.END_STREAM), end_stream)
E       AssertionError: False != True

Each new test's other subtest passes on that side (end_stream=False, flags='END_STREAM'), so both fail for the reason claimed rather than incidentally.

Afterschema/…/httpv2.py 745c5aaa…, application/httpv2.py 3a404359…:

4 passed, 27 deselected, 2 warnings, 4 subtests passed in 2.91s
### EXIT CODE FROM FILE: 0

Exit codes read from a file, not a pipeline.

Coverage does not go backwards

The end_stream site and the flags = 0 seed both already executed, so fixing them moves no coverage number; the subtest count is the real evidence. coverage run --source=pcapkit over tests/protocols/application/:

base 0c7f2b7c9 this PR
protocols/application/httpv2.py 312 stmts / 122 br, 100% 313 stmts / 122 br, 100%
protocols/schema/application/httpv2.py 101 stmts / 4 br, 100% 101 stmts / 4 br, 100%
protocols/data/application/httpv2.py 41 stmts, 100% 41 stmts, 100%
tests passed 65 67
subtests passed 35 39

All three modules stay at 100%. The schema statement count is unchanged because a conditional expression is still one statement; both arms are exercised (DataFrame and UnassignedFrame).

Full tiers, in the worktree: tests/protocols/application/ 67 passed, 39 subtests, exit 0; tests/foundation/registry/test_protocols.py + tests/test_docstring_contract.py 11 passed, 82 subtests, exit 0.

EXPECTED_FAILURES — nothing moved

tests/protocols/test_option_roundtrip_unit.py still passes: 6 passed, 358 subtests, exit 0, matching #634's baseline exactly. EXPECTED_FAILURES was imported, not grepped (** unpacking hides it) — 45 entries, of which exactly one is HTTP/2:

httpv2-frame/PRIORITY -> Gap(status='CONSTRUCT', fragment='HTTP/2: [Type 2] invalid format',
    defect='pcapkit/protocols/application/httpv2.py:572 -- reads length != 9 for a frame
            make() always builds with length 14')

Unrelated to flags, still failing in the declared way, not removed. The test passing means every entry still fails as declared and nothing else regressed, so no entry became stale.

Found and deliberately NOT fixed — filed as #668

While measuring #652's round trip the payload came back empty, which turned out to be a third and larger defect in the same schema file: three payload length callbacks are mis-parenthesised, so an unpadded DATA, HEADERS or PUSH_PROMISE frame parses with its entire payload silently dropped.

pcapkit/protocols/schema/application/httpv2.py:195, :237, :329 all read

pkt['__length__'] - pkt['pad_len'] if pkt['flags']['bit_3'] else 0

A conditional expression binds looser than - — confirmed against the AST, the top-level node is the IfExp with orelse=Constant(value=0) — so the unpadded arm asks for zero octets instead of subtracting zero. The intended grouping is pkt['__length__'] - (pkt['pad_len'] if … else 0).

AS SHIPPED                            WITH THE CONDITIONAL PARENTHESISED
  LOSS DATA         unpadded  b''       OK DATA         unpadded  b'hello'
  OK   DATA         padded    b'hello'  OK DATA         padded    b'hello'
  LOSS HEADERS      unpadded  b''       OK HEADERS      unpadded  b'frag!'
  LOSS PUSH_PROMISE unpadded  b''       OK PUSH_PROMISE unpadded  b'frag!'

ContinuationFrame.fragment, UnassignedFrame.data and GoawayFrame.debug use the plain pkt['__length__'] with no conditional and are the controls that isolate the shape — CONTINUATION returns b'frag!' correctly.

Filed as #668 and deliberately left out of this PR: its fix changes parse output for essentially every HTTP/2 capture, so it wants its own review and its own regression tests rather than riding along on a flags change. It is also why the #652 test asserts the flags octet rather than whole-frame byte equality — a DATA round trip stays lossy for that separate reason until #668 lands, and claiming byte-level round-trip fidelity here would have been false.

Labels, and why breaking

fix + breaking. breaking is additive and its description is "Alters public API or wire output", and this alters output twice over: the reconstructed DATA frame's flags octet changes (0x000x01 where END_STREAM was set), and a flagless frame's dumped __value__ changes from the number 0 to the string "Flags::None [0]". Both are corrections of wrong output rather than API changes, but both are visible to anyone diffing dumps or reconstructed bytes — and #634 set the precedent for exactly this second change by carrying both labels.

Housekeeping

Sample captures were regenerated with examples/generators/make_samples.py — a fresh worktree has none, and without them 5 runtime-tier tests in tests/protocols/application/ fail on FileNotFoundError for http.pcap, unrelated to this change. examples/captures/ was not deleted; dhcp.pcapng and in.pcap are committed fixtures. No changelog bullet on this branch — it goes to #657.

No file owned by another open PR or live worker was touched: protocol.py, http.py, extraction.py, interface/core.py, corekit/io.py, hip.py, logging.py, ipv6_route.py, dumpkit/common.py, transport/tcp.py and .github/** are all untouched.

@JarryShaw JarryShaw added fix Pull requests that fix a defect (fix: subject prefix) breaking Alters public API or wire output (apply alongside the type label) labels Sep 22, 2026
JarryShaw added a commit that referenced this pull request Sep 22, 2026
#669

One bullet, because it is one round trip with a defect on each side of it, in the
same two files: `_make_http_data` never read `frame.flags` on the construct side,
and `FrameType.post_process` seeded its accumulator with a bare `0` on the parse
side.

The bullet leads with what changes rather than with the mechanism, since both
halves alter output: the reconstructed DATA frame's flags octet, and the dumped
`__value__` of a flagless frame. It says why #650 was worth fixing at all, which
its issue had left as an open question -- the dump rendered `__value__` as a JSON
number for a flagless frame and a JSON string for every other frame in the same
capture, so the fix removes a type inconsistency rather than introducing one.

It also records two things a reader would otherwise be surprised by. The seed is
guarded rather than unconditional, because `FrameType.Flags` has no members and a
memberless `enum.Flag` subclass refuses `Flags(0)` -- the one-token fix the issue
proposed would have crashed six of the twelve frame schemas. And a DATA round trip
is still lossy after this, for the unrelated mis-parenthesised length callbacks
filed as #668, so the entry does not let the reader infer a clean round trip that
does not exist yet.

The `TypeError` message had to sit on one line: `util/changelog_md.py` rejects a
`` literal spanning a line break with `ResidualMarkupError`, since its six
conversion rules do not cover it.

37 lines added to the entry file; `CHANGELOG.md` regenerated with
`util/changelog_md.py`, not edited. `--check` exits 0 and `tests/project/` is green
at 96 passed, 469 subtests.

Committed from a detached HEAD on 367b6e6 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 367b6e6, not the 69a6e13 I was given: the branch had already
moved on with #665's, #651's and #661's entries.

Refs #652
Refs #650
@JarryShaw
JarryShaw force-pushed the fix/httpv2-flags-roundtrip branch from f823c57 to 831c783 Compare September 22, 2026 18:56
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…entry

The bullet added in d14577d said a memberless `enum.Flag` subclass "refuses
`Flags(0)` outright", flatly. That is only true from Python 3.12, where the enum
rewrite made `EnumType.__call__` raise for an enum with no members; earlier
interpreters take the plain value-lookup path and hand back a pseudo-member.
Measured on the two available here:

    version 3.14.7  members: 0  Flags(0) -> TypeError: <flag 'Flags'> has no members
    version 3.7.16  members: 0  Flags(0) -> OK <Flags.0: 0>

`requires-python` is `>=3.6`, so the unqualified form overstated it. Three words
added, no other change to the bullet: the guard in #669 is correct on every
supported interpreter either way, being keyed on the memberless-ness rather than
on the refusal.

The same imprecision was corrected in #669's own source comment and PR body, and
its test now gates only the `TypeError` assertion behind
`sys.version_info >= (3, 12)` -- CI runs the unit tier on 3.10 through 3.15, so an
unconditional `assertRaises` would have gone red on the older two.

`CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited; `--check` exits
0 and `tests/project/` is green at 96 passed, 469 subtests.

Committed from a detached HEAD on 6a956c4 and pushed to the branch ref, because
`docs/changelog-1.5.0` is checked out in another agent's worktree. The branch had
moved on with #648's and #649's entries since d14577d.

Refs #652
Refs #650
…Flags

- `_make_http_data` was the only one of the six `_make_http_*` methods that
  never read `frame.flags`, so a DATA frame parsed with END_STREAM set rebuilt
  with the bit clear and the flags octet went 0x01 -> 0x00. It now restores
  `frame.flags.END_STREAM` the way its five siblings restore theirs. PADDED was
  unaffected, being re-derived from `pad_len`.
- `FrameType.post_process` seeded its flag accumulator with a bare `0`, which
  `|=` promotes only as a side effect, so a flags octet of 0x00 -- routine in
  HTTP/2 -- left `__flags__` a plain `int` against a declared `Flags`, and a
  membership test against it raised `TypeError`. It now seeds `self.Flags(0)`,
  matching the construct path's six existing sites. The seed is guarded because
  a memberless `enum.Flag` subclass refuses `Flags(0)`, which the five frame
  schemas inheriting `FrameType.Flags` unchanged would otherwise hit.
- Extended the `_make_http_data` frame stub, which had no `flags` attribute at
  all, and pinned the accumulator's type where the old assertion was type-blind.

Both alter output, so this is labelled breaking: the rebuilt flags octet, and a
flagless frame's dumped `__value__` going from `0` to `"Flags::None [0]"`.

tests/protocols/application/: 67 passed, 39 subtests, exit 0.

Fixes #652
Fixes #650
@JarryShaw
JarryShaw force-pushed the fix/httpv2-flags-roundtrip branch from 831c783 to 1b9388d Compare September 22, 2026 19:19
JarryShaw added a commit that referenced this pull request Sep 22, 2026
…11, not 3.12

b2ec64b qualified the claim with the wrong version. The refusal does not start
with a 3.12 change -- it starts at 3.11, and 3.12 only reworded the message. The
cross-review on #669 caught it; I had inferred 3.12 from the message text I
happened to measure on, which is the wrong evidence for a boundary.

Measured across every interpreter available here rather than inferred, with a bare
`class Flags(enum.IntFlag): pass`:

    3.8.20   Flags(0) -> OK <Flags.0: 0>
    3.9.25   Flags(0) -> OK <Flags.0: 0>
    3.10.21  Flags(0) -> OK <Flags.0: 0>
    3.11.15  Flags(0) -> TypeError: <flag 'Flags'> has no members defined
    3.12.13  Flags(0) -> TypeError: ... has no members; specify `names=()` ...
    3.14.7   Flags(0) -> TypeError: ... has no members; specify `names=()` ...

Confirmed in CPython's source, not just behaviourally. 3.11's `enum.py:1117`, inside
`Enum.__new__`, raises `TypeError("%r has no members defined" % cls)` when
`not cls._member_map_`, and it runs *before* the `_missing_` hook that manufactured
the pseudo-member on 3.10. 3.10's `enum.py` has no such raise -- its only "has no
members" occurrence is a comment at :616. 3.11 is also where the metaclass was
renamed (`class EnumType(type)` at :479 with `EnumMeta = EnumType` at :1052, against
3.10's `class EnumMeta(type)` at :161), so "the enum rewrite" is the 3.11 release.

One word in the bullet. #669 carries the matching correction to its source comment
and to its test's `sys.version_info` gate, which had been skipping the assertion on
3.11 -- a version the unit-test matrix runs -- even though 3.11 does raise.

`CHANGELOG.md` regenerated with `util/changelog_md.py`, not edited; `--check` exits 0
and `tests/project/` is green at 96 passed, 469 subtests.

Committed from a detached HEAD on b2ec64b and pushed to the branch ref, because
`docs/changelog-1.5.0` is checked out in another agent's worktree.

Refs #652
Refs #650
@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review: GOOD TO GO (after one round of NEEDS CHANGES, now fixed)

Independent cross-review on a different model (Sonnet), briefed to falsify rather than bless — a verdict per load-bearing claim, on evidence the reviewer obtained itself rather than from this PR's transcript. Read-only throughout; it never touched the branch. No model substitution was needed.

It ran in two rounds, and the second round found a real error, so the honest headline is: GOOD TO GO on the first pass's ten claims, NEEDS CHANGES on the amendment, and the change it asked for has landed.

Round 1 — GOOD TO GO on all ten claims

# Claim Verdict
1 #652 is real — END_STREAM clear on rebuild before the fix CONFIRMED
2 The fix matches the five siblings' mechanism and placement; nothing else left unrestored CONFIRMED
3 #650's fix changes dump output 0"Flags::None [0]" in JSON and Tree CONFIRMED
4 The naive self.Flags(0) crashes for the six memberless schemas; guard correctly shaped CONFIRMED
5 stdlib enum.IntFlag, not aenum CONFIRMED
6 Tests fail without the fix, pass with it, for the claimed reason CONFIRMED
7 Coverage does not go backwards; tiers green CONFIRMED
8 No EXPECTED_FAILURES entry moved CONFIRMED
9 #668 correctly diagnosed and correctly scoped out CONFIRMED
10 fix + breaking justified CONFIRMED

It derived #652 from its own git archive snapshots rather than reusing anything here:

BASE: parsed flags=0x01 info.flags.END_STREAM=True -> rebuilt <Flags: 0>,             wire octet 0x01 -> 0x00
PR:   parsed flags=0x01 info.flags.END_STREAM=True -> rebuilt <Flags.END_STREAM: 1>,  wire octet 0x01 -> 0x01

It built its own dumper instances for claim 3 and reached "__value__": 0"__value__": "Flags::None [0]" on a real flagless DATA frame. It reproduced the coverage table exactly, including the +1 statement, by running the base snapshot's own original test file for a true baseline. It imported EXPECTED_FAILURES rather than grepping it, and matched the single httpv2-frame/PRIORITY entry verbatim. It AST-parsed #668's three sites itself and confirmed this diff touches none of them.

On the guard-inconsistency question it traced every use site and reported that the six frame types declaring Flags members are exactly the six that surface '__value__': schema.__flags__, and the five memberless ones are exactly the five that pass flags=None and never read __flags__ at all — before and after. So the Flags/int split the guard produces was already unobservable for those five. It found no house-rule violation and no defect this PR does not already mention.

Round 2 — NEEDS CHANGES: the version boundary was wrong

Round 1 ran against f823c57a1. It did not see the later amendment that gated the empty-enum assertion behind sys.version_info >= (3, 12), so the delta went back for a second pass — and the reviewer found that the boundary is 3.11, not 3.12. It was right and this PR was wrong. Re-measured here across every interpreter available:

3.8.20   Flags(0) -> OK <Flags.0: 0>
3.9.25   Flags(0) -> OK <Flags.0: 0>
3.10.21  Flags(0) -> OK <Flags.0: 0>
3.11.15  Flags(0) -> TypeError: <flag 'Flags'> has no members defined
3.12.13  Flags(0) -> TypeError: ... has no members; specify `names=()` ...
3.14.7   Flags(0) -> TypeError: ... has no members; specify `names=()` ...

and confirmed in CPython's source rather than only behaviourally: 3.11's enum.py:1117, inside Enum.__new__, raises TypeError("%r has no members defined" % cls) when not cls._member_map_, before the _missing_ hook that manufactured the pseudo-member on 3.10 — whose enum.py has no such raise, its only "has no members" occurrence being a comment at :616. 3.11 is also where the metaclass was renamed (class EnumType(type) at :479, EnumMeta = EnumType at :1052, against 3.10's class EnumMeta(type) at :161). So "the enum rewrite" is 3.11; 3.12 only reworded the message, which is precisely what misled the first attempt — the version was inferred from message text measured on 3.14 rather than from the boundary itself.

Consequence of the error, stated plainly: not a red build, since under-gating only skips an assertion that would have passed. But on 3.11 — a version the unit-test matrix runs — the test silently failed to exercise the thing its own comment existed to pin, and the source comment shipped a false statement about CPython. Both are corrected at the head (1b9388d43): (3, 12)(3, 11) in the gate and in both comments, with the six-interpreter measurement written into the test comment so the boundary is pinned by evidence rather than by memory. tests/protocols/application/ is 67 passed, 39 subtests, exit 0 after the correction, and the failing-then-passing evidence in the PR body was re-run against the head's own test file (6bfb2b2f…).

The reviewer independently re-ran the before/after on the amended head and reproduced it, and confirmed both tiers green there.

The NONE = 0 alternative — a genuine disagreement, left unresolved and non-blocking

Round 1 raised an alternative this PR had not discussed: give FrameType.Flags a canonical NONE = 0 member so self.Flags(0) succeeds for all twelve classes and the guard disappears. We converged on the mechanism and disagreed on the verdict, so both readings are recorded rather than one folded away.

Agreed facts. A zero-valued member of a Flag is not canonical, which is exactly why it would not trip the "cannot extend" check:

Flag base     _member_names_ : []          <- NONE = 0 is not canonical
IntEnum       _member_names_ : ['ZERO']    <- and subclassing an IntEnum does raise:
IntEnum with a member, subclassed -> TypeError: <enum 'ChildOfPlain'> cannot extend <enum 'Plain'>

And the reviewer confirmed against the real classes that the five memberless schemas do not subclass Flags at all — UnassignedFrame.Flags is FrameType.Flags is True — so a base NONE = 0 would reach them, while the six with their own nested subclass would keep .name is None:

BaseWithZero(0) = <BaseWithZero.NONE: 0>   name = 'NONE'
Child(0)        = <Child: 0>               name = None

My reading: that is a new rendering split across frame types — "Flags::NONE [0]" for five, "Flags::None [0]" for six — replacing the flagless/flagged split this PR closes, and it invents a flag name RFC 9113 does not define.

The reviewer's push-back, which I accept as a correction: that split is not observable through the dump path the breaking label is actually about, because all five memberless types discard __flags__ into flags=None before any dumper sees it. So on the surface that matters, the two approaches are identical, and it called my "worse rather than partial" framing an overstatement. It also found a wrinkle neither of us had flagged: proto.__header__'s raw schema repr() — not the Data/Info dump — already shows an int-vs-Flags split across frame types under the shipped fix (DataFrame.__flags__<Flags: 0>, UnassignedFrame.__flags__0), so neither approach is clean on that internal surface. Its verdict there is "a wash", not a point against the alternative.

Both of us land on not taking the alternative in this PR either way, since it would touch the dumpkit-adjacent .name semantics deliberately scoped out here. Recording the disagreement because the reviewer's narrowing is the more accurate claim and my original framing was too strong.

@JarryShaw
JarryShaw merged commit 4b4b9bb into main Sep 22, 2026
26 checks passed
@JarryShaw
JarryShaw deleted the fix/httpv2-flags-roundtrip branch September 22, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Alters public API or wire output (apply alongside the type label) fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant