Skip to content

Constructing a pcapkit warning mutates global warnings.filters #364

Description

@JarryShaw

Summary

BaseWarning.__init__ calls warnings.simplefilter('ignore', type(self)) when not in devmode. warnings.simplefilter mutates the process-global warnings.filters list and inserts at index 0, so merely constructing a pcapkit warning — or parsing one packet — permanently overrides whatever warning configuration the host application, the test runner, or python -W had set for that category. It also invalidates every module's __warningregistry__, which makes unrelated non-pcapkit warnings re-fire.

A side effect is that pcapkit's own warnings are invisible by default, and there is no way for a caller to re-enable them: any filter they install is overtaken by the next warning construction.

file:line

pcapkit/utilities/warnings.py:74, in BaseWarning.__init__ (lines 65-77):

65    def __init__(self, *args: 'Any', **kwargs: 'Any') -> 'None':  # pylint: disable=useless-super-delegation
66        # log warning
67        if DEVMODE:
68            if VERBOSE:
69                logger.warning(str(self), exc_info=self, stack_info=True,
70                            stacklevel=stacklevel_calculator())
71            else:
72                logger.warning(str(self))
73        else:
74            warnings.simplefilter('ignore', type(self))
75
76        # warnings.simplefilter('default')
77        super().__init__(*args, **kwargs)

The offending expression, executed from a constructor:

warnings.simplefilter('ignore', type(self))

Observable symptom 1 — the global filter list changes

Constructing one instance. No warn() call, no parse. Python 3.14.7:

warnings.filters BEFORE any pcapkit warning:  (5 entries)
  [0] ('default', None, <class 'DeprecationWarning'>, '__main__', 0)
  [1] ('ignore', None, <class 'DeprecationWarning'>, None, 0)
  [2] ('ignore', None, <class 'PendingDeprecationWarning'>, None, 0)
  [3] ('ignore', None, <class 'ImportWarning'>, None, 0)
  [4] ('ignore', None, <class 'ResourceWarning'>, None, 0)

--- constructing ONE pcapkit warning instance, nothing else ---
constructed: SchemaWarning('just constructing the object')

warnings.filters AFTER:  (6 entries)
  [0] ('ignore', None, <class 'pcapkit.utilities.warnings.SchemaWarning'>, None, 0)
  [1] ('default', None, <class 'DeprecationWarning'>, '__main__', 0)
  [2] ('ignore', None, <class 'DeprecationWarning'>, None, 0)
  [3] ('ignore', None, <class 'PendingDeprecationWarning'>, None, 0)
  [4] ('ignore', None, <class 'ImportWarning'>, None, 0)
  [5] ('ignore', None, <class 'ResourceWarning'>, None, 0)

NEW entries introduced by merely constructing SchemaWarning():
  + ('ignore', None, <class 'pcapkit.utilities.warnings.SchemaWarning'>, None, 0)

The entry lands at index 0, ahead of everything the interpreter and the application put there, and is never removed or restored.

Observable symptom 2 — the caller's configuration is overridden

Run under python -W error::UserWarning. A plain UserWarning from user code correctly raises; pcapkit's ProtocolWarning, which is a UserWarning subclass, does not:

sys.warnoptions = ['error::UserWarning']
warnings.filters at start:
  [0] ('error', None, <class 'UserWarning'>, None, 0)
  ...

--- a NON-pcapkit warning of the same base category, for comparison ---
  RESULT: raised UserWarning: plain UserWarning from user code  (i.e. -W error DID apply)

--- a pcapkit warning ---
  RESULT: no exception  (-W error was defeated; warning silently swallowed)

warnings.filters at end:
  [0] ('ignore', None, <class 'pcapkit.utilities.warnings.ProtocolWarning'>, None, 0)
  [1] ('error', None, <class 'UserWarning'>, None, 0)
  ...

The same holds for a filter the application sets programmatically before the pcapkit warning — pcapkit's entry jumps ahead of it:

--- host app calls warnings.simplefilter('always') ---
    warnings recorded by the host app : 0 []
    exception raised                  : None
    filters len before/after warn()   : 7 -> 8
    filters[0] is now                 : ('ignore', None, <class 'pcapkit.utilities.warnings.ProtocolWarning'>, None, 0)

--- host app calls warnings.simplefilter('error') ---
    warnings recorded by the host app : 0 []
    exception raised                  : None
    filters len before/after warn()   : 7 -> 8
    filters[0] is now                 : ('ignore', None, <class 'pcapkit.utilities.warnings.ProtocolWarning'>, None, 0)

Observable symptom 3 — unrelated, non-pcapkit warnings re-fire

warnings.simplefilter calls warnings._filters_mutated(), which invalidates every module's __warningregistry__. A once-per-location warning raised by code that never heard of pcapkit gets reported a second time. With a control arm, so the effect is attributed correctly — the helper module does not import pcapkit and owns its own category:

############ CONTROL: middle step does nothing pcapkit-related ############
--- CONTROL: middle = a plain stdlib warnings.warn() of an unrelated category ---
  host-app warnings shown after 2 identical calls : 1
  host-app warnings shown after the 3rd call      : 1
  the 3rd call re-fired                          : False

############ TEST: middle step is ONE pcapkit warning ############
--- TEST: middle = pcapkit warn("...", SchemaWarning) ---
  host-app warnings shown after 2 identical calls : 1
  host-app warnings shown after the 3rd call      : 2
  the 3rd call re-fired                          : True

############ TEST: middle step is a real capture parse ############
  (parsed 26 frames)
--- TEST: middle = pcapkit.extract() on http6.cap ---
  host-app warnings shown after 2 identical calls : 1
  host-app warnings shown after the 3rd call      : 2
  the 3rd call re-fired                          : True

This is the effect that reaches categories pcapkit has never heard of.

Growth is bounded, not unbounded

warnings._add_filter removes an identical tuple before re-inserting, so the list grows by at most one entry per distinct warning class:

filters len before parsing a whole capture: 6
frames parsed: 26
filters len after  parsing a whole capture: 7 (delta +1)

--- 5000 constructions of the SAME category ---
filters len 7 -> 7 (delta +0)

--- one construction of EVERY BaseWarning subclass ---
23 BaseWarning subclasses; filters len 7 -> 28 (delta +21)

The ceiling is roughly the 23 BaseWarning subclasses in pcapkit/utilities/warnings.py's __all__. A full parse of examples/captures/http6.cap adds exactly one entry. Recording this because it would be easy to overstate the defect as unbounded growth, and it is not.

A precision point on the blast radius

"Silences a warning category for the entire host application, including code unrelated to pcapkit" is half right and worth splitting. The inserted filter is keyed on type(self), a pcapkit class, so the silencing directly reaches only pcapkit's own categories and their subclasses. What genuinely reaches unrelated code is (i) the filter-list mutation itself, which overrides the application's and the interpreter's own -W/simplefilter configuration at index 0 for those categories, and (ii) the __warningregistry__ invalidation in symptom 3, which makes any unrelated 'default'/'once' warning re-fire.

Minimal reproduction

hostapp.py — stands in for unrelated application code, and does not import pcapkit:

import warnings


class HostAppWarning(UserWarning):
    pass


def emit():
    # stacklevel=1 keeps the (message, category, location) registry key identical
    warnings.warn('host app warning A', HostAppWarning, stacklevel=1)

repro.py:

import warnings

from pcapkit.utilities.warnings import SchemaWarning, warn

import hostapp

# 1. the filter list changes from merely constructing the object
before = list(warnings.filters)
SchemaWarning('just constructing the object')
print('new filter entries:', [f for f in warnings.filters if f not in before])

# 2. unrelated host-app warnings re-fire
for label, middle in (('CONTROL', lambda: warnings.warn('x', DeprecationWarning)),
                      ('pcapkit', lambda: warn('y', SchemaWarning))):
    with warnings.catch_warnings(record=True) as rec:
        warnings.resetwarnings()
        warnings.simplefilter('default')
        hostapp.emit()
        hostapp.emit()
        n = len([r for r in rec if r.category is hostapp.HostAppWarning])
        middle()
        hostapp.emit()
        m = len([r for r in rec if r.category is hostapp.HostAppWarning])
    print('%-8s deduplicated=%d  after=%d  re-fired=%s' % (label, n, m, m > n))

Output:

new filter entries: [('ignore', None, <class 'pcapkit.utilities.warnings.SchemaWarning'>, None, 0)]
CONTROL  deduplicated=1  after=1  re-fired=False
pcapkit  deduplicated=1  after=2  re-fired=True

For symptom 2, run a script that calls warn('...', ProtocolWarning) under python -W error::UserWarning. Note that plain python -W error cannot be used: importing pcapkit trips an unrelated DeprecationWarning: VueJS is deprecated from a dependency at import time.

Blast radius, including this project's own tests

tests/utilities/test_exceptions_warnings.py:49-55 currently asserts the suppression as intended behaviour:

49    def test_warn_suppresses_base_warning_categories_outside_dev_mode(self) -> None:
50        with mock.patch.object(self.warnings, 'DEVMODE', False):
51            with pywarnings.catch_warnings(record=True) as records:
52                pywarnings.simplefilter('always')
53                self.warnings.warn('careful', self.warnings.FormatWarning, stacklevel=1)
54
55        self.assertEqual(records, [])

That test asserts the warning is swallowed even though it explicitly set simplefilter('always') — which is exactly the override described above, encoded as an expectation. So the suppression may well be deliberate; what this issue is about is the mechanism: an unscoped, unrestorable, process-global mutation performed from inside a constructor at arbitrary points during parsing. tests/toolkit/test_dpkt_unit.py:129-130 also uses catch_warnings + simplefilter('ignore') and should be reviewed.

Downstream applications may unknowingly depend on pcapkit warnings being silent today, so a fix will make previously invisible warnings appear. That belongs in the release notes.

What a fix would need to touch

  • pcapkit/utilities/warnings.py:74, in BaseWarning.__init__ (65-77) — remove the global mutation from the constructor. The dead line 76, # warnings.simplefilter('default'), should go with it.
  • If "pcapkit warnings are off by default" is the intent, express it somewhere a caller can see and override: once at import in pcapkit/__init__.py, behind a documented opt-in, or left to the application (which is what the warnings module is for). Wrapping warn() in warnings.catch_warnings() would scope it but would also mean pcapkit never emits through that channel at all, which folds into the separate design question about which channel owns a warning (pcapkit/utilities/warnings.py:52-54).
  • tests/utilities/test_exceptions_warnings.py:49-55 will fail with any fix and needs rewriting to state the intended contract.
  • tests/toolkit/test_dpkt_unit.py:129-130 — check for accidental dependence on the current suppression.

Observed vs inferred

  • Observed by running code: the exact filter-list diff from constructing one instance; the inserted entry at index 0; the -W error::UserWarning defeat with a working control on a non-pcapkit UserWarning; the defeat of programmatic simplefilter('always') and simplefilter('error'); unrelated host-app warnings re-firing, with a control arm that does not re-fire; +1 filter entry for a whole http6.cap parse, +0 for 5000 same-category constructions, +21 for 23 distinct subclasses.
  • Inferred by reading code: that warnings._add_filter's remove-then-insert is what bounds the growth (the bound was measured, the CPython path was read); that _filters_mutated() clearing __warningregistry__ is the mechanism behind the re-fire (the effect is measured and controlled, the mechanism is read from the stdlib); that test_warn_suppresses_base_warning_categories_outside_dev_mode reflects deliberate intent rather than an accident — the suite was not run.

Related

Same file and same family, filed separately because no single change fixes both: the double/triple emission at pcapkit/utilities/warnings.py:52-54. The two interact — the emission counts measured there depend on this filter mutation — so decide them together, but they are two changes in two functions. Also related in shape: #362, the ERROR-level logging of quiet=True exceptions, whose constructor has a comparable unscoped global side effect (sys.tracebacklimit = 0).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions