pytest -n auto is safe with the default --dist load, but --dist loadfile and --dist loadscope surface a real order-dependent failure. The cause is a setUpClass module purge that nothing restores, and it is a latent defect under serial pytest too — parallelism only makes it likely.
The failure
tests/protocols/transport/test_tcp_sack_length_unit.py:212
AssertionError: 'pcapkit.corekit.fields.misc' not found in sys.modules
Reproduced with no xdist at all, two files, serial, in this order:
| Order |
Result |
test_dispatch_registry_unit.py → test_tcp_sack_length_unit.py |
1 failed, 10 passed |
test_enum_schema_registry_unit.py → test_tcp_sack_length_unit.py |
8 passed |
Origin
tests/protocols/test_dispatch_registry_unit.py:102-104:
@classmethod
def setUpClass(cls) -> None:
purge_modules(['pcapkit'])
cls.dispatch = _load_generator()
No tearDownClass or addClassCleanup in the file.
tests/conftest.py:95-153's autouse restore_module_table is function-scoped, and its own docstring states the ordering: setUpClass runs inside the snapshot, because pytest instantiates class-scoped fixtures before function-scoped ones. So the purge is captured into the snapshot and restored to rather than undone. The fixture preserves whatever state it finds on entry; it does not heal a bad one. With no tearDownClass, the class exits leaving pcapkit.* absent, and every later test's snapshot faithfully preserves that absence.
_load_generator() loads examples/generators/dispatch.py, whose module-level imports are __future__, os, struct, tempfile, typing — no pcapkit. So nothing re-imports the package and nothing heals the gap.
The discriminator is the loader, not the teardown
purge_modules appears at 159 sites across 104 files — 149 in setUp, 10 in setUpClass — and 75 of those files have no teardown of any kind. That is the norm, and it is safe: a setUp purge runs after the snapshot and is undone.
Only the 10 setUpClass sites can leak, and whether they do depends on whether their loader re-imports pcapkit at module level:
tests/protocols/test_dispatch_registry_unit.py:103 — loader does not import it. Leaks.
tests/protocols/test_option_roundtrip_unit.py:588 — examples/generators/options.py imports pcapkit.protocols.protocol at module level, which transitively reimports pcapkit.corekit.fields.misc before the first snapshot. Self-heals, does not reproduce (10 passed, 370 subtests).
The remaining 7 setUpClass sites were not tested against this failure, so the blast radius is unmeasured.
A second, independent defect
tests/const/test_const_enum_get.py:154 and tests/const/test_const_enum_builtin_parity.py:199 both call:
cls.addClassCleanup(purge_modules, ['pcapkit'])
That purges again on the way out; it does not restore. Same leak shape, reached deliberately rather than by omission. The comment above :199 says the intent is "so that pollution cannot reach another module", which is the opposite of what a second purge achieves.
Suggested fix
Prefer the architectural one — the per-file fixes have already proven insufficient once, in #660:
- Make
restore_module_table restore to a pinned known-good snapshot rather than to whatever state it found on entry. That heals a bad state instead of preserving it, and covers every current and future site.
- Or make the
test_tcp_sack_length_unit.py:212 precondition self-healing — re-import if absent — instead of a hard assertIn. Narrower, and it leaves the underlying leak in place for the next victim.
Either way, the two addClassCleanup sites want changing to restore rather than purge, and the 7 untested setUpClass sites want auditing against the loader criterion above.
Why it matters beyond xdist
-n auto --dist load measured ~3.6–3.9× on tests/corekit, tests/protocols and tests/foundation with counts byte-identical to serial. loadfile/loadscope cannot be adopted until this is fixed. See #715 for the wider CI work.
pytest -n autois safe with the default--dist load, but--dist loadfileand--dist loadscopesurface a real order-dependent failure. The cause is asetUpClassmodule purge that nothing restores, and it is a latent defect under serial pytest too — parallelism only makes it likely.The failure
Reproduced with no xdist at all, two files, serial, in this order:
test_dispatch_registry_unit.py→test_tcp_sack_length_unit.pytest_enum_schema_registry_unit.py→test_tcp_sack_length_unit.pyOrigin
tests/protocols/test_dispatch_registry_unit.py:102-104:No
tearDownClassoraddClassCleanupin the file.tests/conftest.py:95-153's autouserestore_module_tableis function-scoped, and its own docstring states the ordering:setUpClassruns inside the snapshot, because pytest instantiates class-scoped fixtures before function-scoped ones. So the purge is captured into the snapshot and restored to rather than undone. The fixture preserves whatever state it finds on entry; it does not heal a bad one. With notearDownClass, the class exits leavingpcapkit.*absent, and every later test's snapshot faithfully preserves that absence._load_generator()loadsexamples/generators/dispatch.py, whose module-level imports are__future__, os, struct, tempfile, typing— nopcapkit. So nothing re-imports the package and nothing heals the gap.The discriminator is the loader, not the teardown
purge_modulesappears at 159 sites across 104 files — 149 insetUp, 10 insetUpClass— and 75 of those files have no teardown of any kind. That is the norm, and it is safe: asetUppurge runs after the snapshot and is undone.Only the 10
setUpClasssites can leak, and whether they do depends on whether their loader re-importspcapkitat module level:tests/protocols/test_dispatch_registry_unit.py:103— loader does not import it. Leaks.tests/protocols/test_option_roundtrip_unit.py:588—examples/generators/options.pyimportspcapkit.protocols.protocolat module level, which transitively reimportspcapkit.corekit.fields.miscbefore the first snapshot. Self-heals, does not reproduce (10 passed, 370 subtests).The remaining 7
setUpClasssites were not tested against this failure, so the blast radius is unmeasured.A second, independent defect
tests/const/test_const_enum_get.py:154andtests/const/test_const_enum_builtin_parity.py:199both call:That purges again on the way out; it does not restore. Same leak shape, reached deliberately rather than by omission. The comment above
:199says the intent is "so that pollution cannot reach another module", which is the opposite of what a second purge achieves.Suggested fix
Prefer the architectural one — the per-file fixes have already proven insufficient once, in #660:
restore_module_tablerestore to a pinned known-good snapshot rather than to whatever state it found on entry. That heals a bad state instead of preserving it, and covers every current and future site.test_tcp_sack_length_unit.py:212precondition self-healing — re-import if absent — instead of a hardassertIn. Narrower, and it leaves the underlying leak in place for the next victim.Either way, the two
addClassCleanupsites want changing to restore rather than purge, and the 7 untestedsetUpClasssites want auditing against the loader criterion above.Why it matters beyond xdist
-n auto --dist loadmeasured ~3.6–3.9× ontests/corekit,tests/protocolsandtests/foundationwith counts byte-identical to serial.loadfile/loadscopecannot be adopted until this is fixed. See #715 for the wider CI work.