fix(corekit): stop a malformed TCP SACK's exception type depending on sys.modules state (#525) - #562
Conversation
… sys.modules state (#525) `ListField.unpack` resolved `SchemaField` through a function-local import, re-run on every call, instead of once at module load. That is a cheap `sys.modules` hit almost always -- except when something has popped `pcapkit.corekit.fields.misc` out of `sys.modules` since this `ListField` was built, which is exactly what the `#439` ABC-cache regression tests do in every case's `setUp`/`tearDown` to get a clean cache between cases. The next call then re-executes that module and mints a second, distinct `SchemaField` class, while the SACK `sack` field's `item_type` -- built long before, from the first one -- is still an instance of the original. `isinstance` against the new class answers `False` for an item that plainly is a `SchemaField`, so `ListField.unpack` takes its other branch, which bills each item by its *declared* length unconditionally instead of by what it actually consumed. For a malformed SACK that lets the length accounting go negative and raise `FieldValueError` from `pcapkit/corekit/fields/collections.py` before `TCP._read_mode_sack` is ever reached, instead of the documented `ProtocolError`. - Hoist the `SchemaField` import to module level in `collections.py`. There is no cyclic import to dodge by keeping it local -- confirmed `pcapkit.corekit.fields.misc` does not import this module -- so it was simply resolving the same name a second, riskier way for no reason. This also makes the `FieldValueError` path provably unreachable here: the schema branch's by-bytes-consumed accounting can never go negative, since it is bounded by the buffer size the caller already sized to the field's own declared length. - Narrow `test_invalid_sack_length_is_rejected`'s assertion from the `(ProtocolError, FieldValueError)` union it carried as a recorded workaround down to `ProtocolError` alone, now that the type is deterministic. - Add `test_sack_exception_type_survives_a_sys_modules_purge`, which reproduces the exact `sys.modules` manipulation that caused this, without needing the schema test suite at all, and pins that the module does not silently come back afterwards. - `pcapkit/protocols/transport/tcp.py` needs no change: `_read_mode_sack`'s docstring already promised `ProtocolError` only, and that promise now holds unconditionally. Verified with `PYTHONSAFEPATH=1` and `PYTHONPATH` at the worktree root, and `pcapkit.__file__` asserted before every measurement. An isolated `sys.modules.pop('pcapkit.corekit.fields.misc', None)` with nothing else running reproduces the flip on its own; running `tests/protocols/schema/` then the SACK test in one process now yields `ProtocolError` deterministically (29 passed); an A/B toggle against the pre-fix file confirms the new regression test fails (exit 1) without this change and passes (exit 0) with it, exit codes read from files throughout.
63df80f to
86f5619
Compare
|
✅ GOOD TO MERGE Cross-model review (Opus 5; the PR was authored on Sonnet), head I reproduced the entire mechanism independently, and every element of it holds. On Both of your open questions, answered:
I also independently confirmed the two things I was asked to be sceptical about: the hoist introduces no import cycle ( Your full-suite numbers reproduce exactly: 1261 passed, 17 skipped, 2850 subtests passed, exit code 0 (read from a file, 19:59). Changelog drift check exits 0. The test genuinely fails without the fix — I measured it. Three follow-up notes, none blocking, in the detailed comment below. The most useful one: the same identity pattern survives at |
Detailed cross-review — #562 @
|
| tree | exit code (from file) | result |
|---|---|---|
main source + this PR's test file |
1 | FAILED …::test_sack_exception_type_survives_a_sys_modules_purge |
| this branch, whole file | 0 | 4 passed, 10 subtests |
and on main it fails for exactly the right reason, not incidentally:
E pcapkit.utilities.exceptions.FieldValueError: Field sack has invalid length.
pcapkit/corekit/fields/collections.py:193: FieldValueError
On the collection-order question: the new test does not need a subprocess, and I think that is the right call. It performs the sys.modules manipulation itself rather than depending on another suite having run first, so it fails on broken code in an ordinary in-process pytest run — measured above. It also cannot pass on broken code by ordering luck, because assertNotIn('pcapkit.corekit.fields.misc', sys.modules) can only hold when the import is resolved at module level; on main the parse demonstrably puts the module back.
Note that the narrowing of test_invalid_sack_length_is_rejected is not itself a fails-without test — it passes on main too, since a clean pytest process already produces ProtocolError. The new purge test is what carries the regression coverage. That follows from the mechanism and is not a criticism, but it is worth being clear that the union-narrowing alone would not have caught a regression.
5. length == 2, and the deliberate non-tightening
Still accepted, and still pinned. The implementation is if (schema.length - 2) % 8 != 0: raise, so length=2 satisfies it; test_documented_rule_is_the_implemented_rule asserts parse(sack_option(2, 0)) succeeds and that a five-block SACK cannot even be expressed (data offset is 4 bits → options area ≤ 40 octets). That test is untouched by this diff. Tightening past the docstring was correctly left alone.
6. Follow-up: the same identity pattern survives in ListField.pack
ListField.pack — two methods above the one fixed — still resolves Schema through a function-local import:
# pcapkit/corekit/fields/collections.py:105
from pcapkit.protocols.schema.schema import \
Schema # pylint: disable=import-outside-top-level
...
elif isinstance(item, Schema):This one cannot be hoisted — pcapkit/protocols/schema/schema.py:11 does from pcapkit.corekit.fields.collections import ListField, OptionField, so a module-level import here is a genuine cycle. So it is not an oversight in this PR, and I am not asking for it here.
But the hazard is real and I demonstrated it. Popping pcapkit.protocols.schema.schema and re-packing a SACK schema:
schema module re-imported by the pack? True
Schema v1 is v2? False
-> second distinct Schema class minted: True
isinstance(a real SACKBlock, ORIGINAL Schema) = True
isinstance(a real SACKBlock, FRESH Schema) = False
The isinstance does misclassify a genuine schema. In this path the outcome is benign — the bytes are identical (050a0000000100000002 both times) because self._item_type is not None for SACK, so the next branch packs the item the same way. The masking is incidental, though: for a ListField with _item_type is None carrying Schema items, the misclassification falls through to
raise FieldValueError(f'Field {self.name} has invalid value.')which is the #525 shape exactly. I did not establish whether any such field exists in the library today, so I am not claiming a live bug — but the description's own framing ("an import-time identity bug reachable by any ListField whose module got evicted") raises the question and does not answer it. Worth an issue, with the cycle noted so nobody tries to fix it by hoisting.
7. Changelog
Generated, not hand-matched — verified with the repository's own check, which is what CI runs as Changelog drift:
$ python util/changelog_md.py --check
CHANGELOG.md is in step with docs/source/changelog/1.5.0.rst
exit 0
The entry itself is accurate against everything I measured, including the behaviour-change warning that a caller relying on FieldValueError now gets ProtocolError. That warning is correct and is the right thing to have flagged.
8. Full suite
Ran pytest tests (no coverage, per the host memory constraint — a coverage run -m pytest tests from a sibling worktree reached 36.9 GB RSS earlier and exhausted this machine). This worktree was fresh, so examples/generators/make_samples.py ran first (exit 0).
1261 passed, 17 skipped, 11167 warnings, 2850 subtests passed in 1199.43s (0:19:59)
pytest exit code: 0
That matches the description's claimed numbers exactly — 1261 / 17 / 2850 / exit 0. The provenance caveat about unverified test results can be dropped; the numbers are now independently reproduced.
9. Minor, non-blocking
The new test opens with
self.assertIn('pcapkit.corekit.fields.misc', sys.modules,
'test setup expects pcapkit to already be imported')which asserts a precondition about global state the test does not itself establish — and several suites in this repository purge pcapkit.* from sys.modules (test_schema_metaclass_abc_cache_unit.py via purge_modules, and tests/cli/test_main.py directly). I could not make it fail: it passes with the whole ABC-cache module running first (exit 0), and with only the tearDown-bearing class running immediately before (exit 0), and in the full suite. So this is a robustness suggestion, not a finding — importlib.import_module('pcapkit.corekit.fields.misc') would make the precondition true by construction rather than by whatever happened to run first, and would avoid a future confusing failure that has nothing to do with SACK.
Could not verify
- CI. Reached deliberately on local evidence only; I did not wait on GitHub Actions and make no claim about its tally.
- Any interpreter other than CPython 3.14.7. Single-version throughout.
- Whether a
ListFieldwith_item_type is NoneandSchemaitems exists today (see §6) — I did not enumerate everyListFieldconstruction in the library. - The original Malformed TCP SACK raises ProtocolError or FieldValueError depending on process state #525 report's trigger. I reproduced the mechanism by popping the module by hand; I did not confirm which specific historical suite ordering produced the originally-observed
FieldValueError. The description doesn't claim to either, and givenpurge_modulesis called fromsetUp/tearDownthe route is clear enough.
Closes #525.
Which exception a malformed TCP SACK option produced depended on whether the schema layer had already been exercised in the same process —
ProtocolErrorin a clean process,FieldValueErrorfrompcapkit/corekit/fields/collections.pyaftertests/protocols/schema/had run. The two are siblings in the exception hierarchy, so no singleexceptclause caught both, and which one you got was not a property of the input. A consumer writingexcept ProtocolErroraroundExtractorgot code that worked in tests and short scripts and stopped working in a long-lived process.The issue recorded the mechanism as never established. It is established now, and it is not caching or lazy initialisation as the issue guessed:
ListField.unpackresolvedSchemaFieldthrough a function-local import, re-run on every call instead of once at module load. That is a cheapsys.moduleshit almost always — except when something has poppedpcapkit.corekit.fields.miscout ofsys.modulessince thatListFieldwas built, which is exactly what #439's ABC-cache regression tests do in every case'ssetUp/tearDownto get a clean cache. The next call re-executes the module and mints a second, distinctSchemaFieldclass, while the SACKsackfield'sitem_type— built long before from the first one — is still an instance of the original.isinstanceagainst the new class answersFalsefor an item that plainly is aSchemaField, soListField.unpacktakes its other branch, which bills each item by its declared length unconditionally rather than by what it consumed. For a malformed SACK the length accounting goes negative and raisesFieldValueErrorbeforeTCP._read_mode_sackis ever reached.So the state dependence was never about SACK at all — it was an import-time identity bug reachable by any
ListFieldwhose module got evicted.SchemaFieldimport to module level incollections.py.tests/protocols/transport/test_tcp_sack_length_unit.py's union assertion to the single correct type, now that the behaviour is deterministic.length == 2remains accepted:(2 - 2) % 8 == 0satisfies the documented rule exactly. RFC 2018 also requires at least one block, so a stricter check is arguably warranted, but tightening past the docstring was deliberately left alone.4 files, +148/-39, one commit, on top of
8cfd6ab01.Verification (replaces the earlier "not verified" note — CI was failing on this branch for a separate, now-fixed reason): CI was red on 13 checks, all traced to one cause named by the
Changelog driftcheck: the committedCHANGELOG.mdhad been hand-edited rather than generated, and differed fromutil/changelog_md.py's output only in where the bold markers sit around theFieldValueError/ProtocolErrorliterals in the SACK entry —docs/source/changelog/1.5.0.rstalready has the bold correctly split around each literal (reStructuredText has no way to span inline markup across a literal), and the hand-writtenCHANGELOG.mdhad merged it into one run. Fixed by regenerating:python util/changelog_md.py, confirmed withpython util/changelog_md.py --check(exit 0, "is in step with"). This branch also needed rebasing onto currentmainto flatten aMerge branch 'main'commit the owner had added; the rebase replayed cleanly with no conflicts, and I confirmed by hand that both PR #560'sEnumSchema.registrychangelog entry and this PR's SACK entry are present in the rebasedCHANGELOG.mdand1.5.0.rst.tests/project/test_changelog_md.py: 34 passed, 37 subtests passed (exit 0). Full unit tier,pytest tests(no coverage, per host memory limits): first run showed 1 failure intests/protocols/test_option_coverage_runtime.py::OptionCoverageCaptureTests::test_option_captures_are_what_the_generator_says_they_are— traced to a stale, gitignored local fixture (examples/captures/options-ipv4.pcap) generated before PR #559's IPv4TSoption round-trip fix landed onmain; CI regenerates this fixture fresh viaexamples/generators/make_samples.pyon every run (.github/workflows/unit-tests.yml), so it never sees this staleness, and confirmed the fix by regenerating the fixture locally (examples/generators/make_samples.py, exit 0) and re-running: 1261 passed, 17 skipped, 2850 subtests passed (exit 0). All exit codes read from files, not from summary lines. Amended into the single commit and force-pushed.One open question for the reviewer, unchanged from before: the fix lands entirely in
collections.pyand does not touchpcapkit/protocols/transport/tcp.py, which follows from the mechanism above but is worth confirming, along with whether any docstring still promises the exception that no longer escapes.