Repository navigation
Schema subclasses share one ABCMeta cache on Python <=3.10, so isinstance/issubclass return whichever answer was asked first #439
Description
On Python ≤ 3.10, every Schema subclass shares Schema's _abc_impl instead of getting its own. One ABCMeta subclass-check cache therefore serves the entire schema family, and because it is keyed only on the class being tested and not on the class it is tested against, the first answer for a given class is cached and returned for every later question about it.
The result: issubclass and isinstance against schema classes return whichever answer was asked for first.
Reproduction
Two fresh 3.10 interpreters, same tree, differing only in the order of the two questions:
import sys; sys.path.insert(0, '<tree>')
from pcapkit.protocols.schema.schema import Schema
from pcapkit.protocols.schema.internet.mh import (
ANIGeoLocationSuboption as G, ANINetworkIdentifierSuboption as N)| order asked | first answer | second answer |
|---|---|---|
issubclass(G, Schema) then issubclass(G, N) |
True ✔ |
True ✘ (siblings) |
issubclass(G, N) then issubclass(G, Schema) |
False ✔ |
False ✘ (it is a Schema) |
Python 3.10.20: G._abc_impl is Schema._abc_impl -> True
Python 3.14.7 : G._abc_impl is Schema._abc_impl -> False (and both answers correct)
The first question is always right; the second always inherits the first's answer. No instances are involved, so this is not about __eq__, __hash__ or value equality — it is a shared cache object. SchemaMeta derives from abc.ABCMeta (pcapkit/protocols/schema/schema.py:136) and every schema descends from collections.abc.Mapping.
Verified on 3.10.20 and 3.14.7. CPython reworked the _abc caches across the 3.11/3.12 line, which is why 3.14 is unaffected — but 3.10 is in the supported matrix (pyproject.toml claims >=3.6, and CI runs 3.10 through 3.15).
Why this is worth fixing rather than avoiding
It surfaced through #437, where eight new Mobility Header helpers dispatched sub-options through a chain of isinstance(schema, Schema_X) tests. On 3.10 a false positive sent a sub-option down a sibling's branch (three AttributeErrors), and a cached negative made ListField.pack's isinstance(item, Schema) false, producing FieldValueError and zero-filled reconstructions — six CI failures on 3.10 while 3.14 was entirely green. Those helpers have been changed to dispatch on the wire type code, which is what the rest of that module does, so #437 no longer depends on this. The underlying defect is untouched.
grep finds 39 isinstance(…, Schema…) sites under pcapkit/ on main. Most ask a single question and so cannot see the bug. The ones that ask several in sequence can, and the clearest candidate is HTTP/2 frame dispatch:
pcapkit/protocols/application/httpv2.py:316 if isinstance(frame, Schema_UnassignedFrame):
pcapkit/protocols/application/httpv2.py:318 if isinstance(frame, Schema_DataFrame):
pcapkit/protocols/application/httpv2.py:321 if isinstance(frame, Schema_HeadersFrame):
pcapkit/protocols/application/httpv2.py:325 if isinstance(frame, Schema_PriorityFrame):
pcapkit/protocols/application/httpv2.py:327 if isinstance(frame, Schema_RSTStreamFrame):
That is the same shape that failed in MH. Whether it actually misbehaves on 3.10 depends on which question the process asked first, which makes it order- and import-dependent — the worst kind of latent defect, since it can pass CI and fail for a user. Others worth checking in the same pass: corekit/fields/collections.py:111, corekit/fields/misc.py:326, protocols/protocol.py:467, protocols/internet/ipv4.py:1195, protocols/internet/hopopt.py:1266, protocols/internet/esp.py:1321.
Two ways to fix, and they are not equivalent
- Make each schema class get its own
_abc_impl— the root fix. It restores correctisinstance/issubclasseverywhere at once, but it touchesSchemaMeta, which every protocol in the tree depends on, so it needs its own change with a full byte-identical sweep behind it. - Stop dispatching on
isinstancewhere a type code is available — what protocols: complete the Mobility Header registry -- all 24 message types, 70 of 71 options, all 4 CGA extensions #437 did. Narrower, and arguably better design regardless, since dispatching on the wire code is what the registry already keys on. It does not fix the 37 other sites.
Both are probably wanted: (2) removes the dependency wherever a code exists, (1) stops the trap being re-laid by the next person who writes an isinstance chain.
Deliberately not folded into #437 — the blast radius belongs in its own change rather than in a Mobility Header PR.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
tests: record the seven option cases whose outcome depends on the int…
JarryShaw commentedon Sep 17, 2026
Second confirmed victim: PCAP-NG name resolution — and a correction to my own earlier claim
I previously stated that #440's seven Python 3.10 failures were not this issue. That was wrong. They are this issue, and the way I got it wrong is worth recording because it will mislead the next person the same way.
I ran two checks and both were individually correct while both missed the actual site:
EndRecord._abc_impl is Info._abc_implon the data models inpcapkit.protocols.data.misc.pcapng—Falseon both interpreters. True, and irrelevant:Infosubclasses really are unaffected.pcapkit/protocols/misc/pcapng.py, the protocol module, for anisinstance-against-schema chain — none there. Also true.The chain is in the schema module, and schema classes are exactly what shares the cache:
Measured on the same tree, same script,
mainatc28ffc287:EndRecord._abc_impl is IPv4Record._abc_implTrueFalseEndRecord._abc_impl is Schema._abc_implTrueFalseEndRecord._abc_impl is Info._abc_implFalseFalseSo on 3.10 an
EndRecordanswersTruetoisinstance(record, (IPv4Record, IPv6Record)),NameResolutionBlock.post_processthen readsrecord.names, and it dies withAttributeError: 'EndRecord' object has no attribute 'names'. Every Name Resolution Block carries a terminatingEndRecord, which is why the block, its threens_dns*options and its three records all fail together — seven cases from one wrong answer.A detail that matters for anyone trying to reproduce this: two synthetic reproductions did not show the wrong answer. The cache is order-sensitive, so whether it is poisoned depends on which question the process asked first — only driving the real packing path produced the failure. That is very likely why an earlier attempt at ruling this in or out came back clean, and it is the property that makes this defect dangerous: it can pass a targeted test and fail in situ.
Where that leaves the issue
The prediction in the original report was that
httpv2.py:316-327was the likeliest next victim, since it has the same chain shape. That remains unverified — nobody has driven it on 3.10. PCAP-NG name resolution is now a confirmed second site, found by accident rather than by the sweep, which argues the other 37isinstance(…, Schema…)sites deserve the same treatment: exercised on 3.10 through their real path, not probed synthetically.Note also that fixing this turns #440's Python 3.10 job red, by design — that PR records the seven as interpreter-dependent gaps asserted to fail in exactly the recorded way, so the fix is what flips them.
JarryShaw commentedon Sep 18, 2026
Root cause found, reproduced in five lines, and a candidate fix verified. This has resisted synthetic reproduction because the missing ingredient was never the cache — it was the question order, and specifically that
Mappinghas to be asked beforeSchema.The cause is an explicit version-guarded workaround
pcapkit/protocols/schema/schema.py:238-241:type.__new__bypassesabc.ABCMeta.__new__, andABCMeta.__new__is what calls_abc_init(cls)to give each new class its own_abc_impl— the per-class registry, negative cache and positive cache. So on Python < 3.11 everySchemasubclass never gets one and inheritsSchema's, and a single ABCMeta cache serves the entire family. That is the whole defect, and thesys.version_info < (3, 11)guard is exactly why it is 3.10-and-below only.Why
dictis the thing that leaksSchemainheritsMapping—class Schema(Mapping[str, _VT], Generic[_VT], metaclass=SchemaMeta)at:245— anddictis a registered virtual subclass ofcollections.abc.Mapping. So aTruecached from theMappingquestion is served straight back forSchemaand for every subclass.Reproduction on 3.10.20, against
94a93e721Asked cold, without the
Mappingquestion first,isinstance({}, Schema)correctly returnsFalse. That ordering dependence is why every synthetic probe in this programme has failed to show it.Where it bites in real code
pcapkit/protocols/schema/schema.py:519-520:A plain
dicttestsTrue, soto_dict()is called on it —AttributeError: 'dict' object has no attribute 'to_dict'at:520. This currently turnsPython 3.10andIntegration Python 3.10red on PR #457, intests/protocols/misc/test_pcapng_unit.py::PCAPNGUnitTests::test_pcapng_remaining_constructor_branches_and_custom_dispatch. That PR'sChainMapchange does not introduce a defect — it changes the order in which schema classes are first touched and so flips this latent case into an active one. Confirmed by its reviewer: the same test passes onorigin/mainunder the identical 3.10.20 interpreter and fails on the PR head.Candidate fix, verified
Giving each class its own
_abc_implrestores correct answers without touching anything else. Measured on 3.10.20:So the fix direction is to stop bypassing
ABCMeta.__new__on < 3.11. Three ways, roughly in order of preference:super().__new__"will cause an error for duplicated keyword arguments in class definition" — that is thecode=-style class keyword this codebase uses widely (class CommentOption(Option, code=…)). On < 3.11ABCMeta.__new__forwards**kwargsin a way that collides with__init_subclass__receiving them too. Consuming those kwargs before delegating, rather than skippingABCMeta.__new__, keeps the ABC machinery intact.type.__new__but restore the invariant explicitly — callabc._abc_init(new_cls)on the result. Minimal and verified above, but_abc_initis private API.Mapping, which removes thedict-shaped leak specifically. This does not fix the shared cache in general — any other virtual-subclass relationship would still leak — so it treats the symptom.Whichever is taken, the regression test has to ask
MappingbeforeSchema, or it will pass on the broken code. That is the property that makes this bug invisible.Consequence for sequencing
Earlier notes in this programme had #439 recorded as unblocked by #446/#445 landing. That is backwards: #439 blocks PR #457, which cannot go green on 3.10 until this is fixed or that one test is guarded. And the
INTERPRETER_GAPStable intests/protocols/test_option_roundtrip_unit.pyis not an available route here — it serves the option round-trip harness, whereas this failure is a plain unit test.corekit: stop nested_packet_context from disturbing the #439 abc cache
corekit: make NestedPacketContext a dict subclass; fix mypy and a cit…
JarryShaw commentedon Sep 18, 2026
The
duplicated keyword argumentsNOTE is explainedThe code comment at
pcapkit/protocols/schema/schema.py:238-239says:Measured, and it is not mysterious:
ABCMeta.__new__'s parameters becamepositional-only in Python 3.11, and seven schema classes are declared with a
class keyword argument called
namespace, which collides with one of them on3.10 and below.
The signature change
The guard's
sys.version_info < (3, 11)boundary is exactly where the/wasadded. That is the whole coincidence.
The collision, reproduced
With the guard removed, so
SchemaMeta.__new__delegates viasuper().__new__(cls, name, bases, attrs, **kwargs):namespaceis the one that changes behaviour between the two, and it is the onethis codebase actually uses.
attrsis passed positionally into ABCMeta'snamespaceslot whilenamespace='...'also arrives in**kwargs, so on 3.10the same parameter is bound twice.
The seven declarations that trigger it
The consumer is
Option.__init_subclass__atpcapkit/protocols/schema/misc/pcapng.py:401-402, whose signature is(cls, /, code=None, namespace=None, *args, **kwargs); it assignscls.__namespace__ = namespaceat:432.Why this matters for the fix
The version guard was a workaround for a name collision, not for anything
about ABCMeta's behaviour — so it can be removed outright once the collision is
gone, rather than being replaced by an
abc._abc_initcall that undoes thedamage of having bypassed the public path.
Two directions, in preference order:
namespace=tosomething like
ns=oropt_namespace=. That is seven class declarationsplus the
__init_subclass__signature and its docstring, all in one file,and it lets
SchemaMeta.__new__become an unconditionalsuper().__new__(...). Note thatcode=is unaffected and needs no change.Worth checking whether
name/basesare used as class keywords anywheretoo — I found none under
pcapkit/, but those would collide on everyversion, not just 3.10.
abc._abc_init(klass)before returning. Thisworks — see the measurements in the earlier comment — but it relies on a
private CPython API to repair a problem that direction 1 removes.
For reference, the bug the guard causes is that no
Schemaclass ever gets itsown
_abc_impl, soSchema._abc_impl is collections.abc.Mapping._abc_implisTrueand the corruption runs both ways: askingMappingfirst makesisinstance({}, Schema)returnTrue, and askingSchemafirst makesisinstance({}, Mapping)returnFalse.corekit: back to collections.ChainMap, no dedicated class
corekit: stop asserting the ChainMap/#439 history as settled fact (#445)
corekit: return a plain dict from nested_packet_context, not a ChainM…
fix(corekit): stop a malformed TCP SACK's exception type depending on…
fix(corekit): stop a malformed TCP SACK's exception type depending on…
docs(protocols): second pass over the IPv6 extension headers (#1103) (#…