Skip to content

Surface filter-promoted unraisable warnings directly (#14263)#14499

Merged
Zac-HD merged 10 commits into
pytest-dev:mainfrom
paulzuradzki:bugfix/14263-unraisable-warning-direct-raise
May 27, 2026
Merged

Surface filter-promoted unraisable warnings directly (#14263)#14499
Zac-HD merged 10 commits into
pytest-dev:mainfrom
paulzuradzki:bugfix/14263-unraisable-warning-direct-raise

Conversation

@paulzuradzki
Copy link
Copy Markdown
Contributor

@paulzuradzki paulzuradzki commented May 19, 2026

Summary

Refs #14263.

At session end, unraisableexception forces a gc.collect() so leaked objects finalize and their warnings reach sys.unraisablehook. That collection used to run from a config.add_cleanup callback. Those callbacks pop in reverse order, so another plugin's cleanup could tear down a warning filter before the collection ran. The leaked warning then fired with no filter active, so pytest dropped it with no output.

This moves the collection into pytest_unconfigure, which runs before pytest closes the cleanup stack. The warning filters and sys.unraisablehook are both still in place there, so the leak surfaces no matter what order plugins clean up in. That's approach 2 from the issue. I left the collection in cleanup() too, as a final sweep for anything freed while the cleanup stack unwinds.

A surfaced unraisable is still wrapped in PytestUnraisableExceptionWarning. To fail a run on these, use a filter that matches the wrapper: bare error, -W error, or error::pytest.PytestUnraisableExceptionWarning. A class-only filter like error::ResourceWarning surfaces the warning but doesn't fail the run on its own (see Scope).

Testing

Automated

pytest testing/test_unraisableexception.py

New/changed tests:

  • test_unraisable_decouples_from_cleanup_stack_order, the regression test. A conftest.py with @hookimpl(trylast=True) registers a warnings.resetwarnings cleanup that pops before unraisableexception's own cleanup, recreating the bad order that apply warnings filter as soon as possible, and remove it as late as possible #13057's plugin reorder doesn't cover. A __del__ raises ValueError under an error::pytest.PytestUnraisableExceptionWarning filter. Red on main (filter gone before the collection runs, exit 0); green here (collection runs in pytest_unconfigure, filter still active, exit 1).
  • I dropped the tests that only covered the old direct-raise branch (test_refcycle_resource_warning_filter, test_refcycle_userwarning_filter, test_unraisable_warning_filter_add_note_dedups), since that branch is gone.

Manual. Save as test_del.py:

import gc; gc.disable()  # let the cycle survive to session-end gc

class BrokenDel:
    def __init__(self):
        self.self = self  # reference cycle

    def __del__(self):
        raise ValueError("del is broken")

def test_it():
    BrokenDel()
  • pytest -W error test_del.py exits 1 (wrapper promoted, leak fails the run).
  • pytest test_del.py exits 0 (wrapper logged, no failure).

To see the cleanup-order fix by hand, swap src/_pytest/unraisableexception.py between main and this branch and rerun test_unraisable_decouples_from_cleanup_stack_order: main exits 0, this branch exits 1.

If you run from inside the pytest source tree, the repo's pyproject.toml sets filterwarnings = ['error', ...], which promotes every warning. Comment out the bare 'error' entry or run the snippet from outside the repo.

Reproducer from the issue

My adapted version of the original gist. The original references an undefined stderr_lines in its run_scenario helper; mine applies a one-line fix.

Scope

collect_unraisable wraps every queued unraisable in PytestUnraisableExceptionWarning and emits it through warnings.warn. An earlier revision of this PR added an unwrap branch: when an active error::<class> filter matched the inner warning, it re-raised that warning directly so error::ResourceWarning failed the test. Per reviewer feedback I dropped that branch. Re-raising lost the traceback the hook captures as a string, and the filter check (action == "error" plus issubclass) only approximated Python's real filter matching. So pytest keeps wrapping and lets the warnings machinery decide.

The issue's exact error::ResourceWarning config now surfaces the leak (logged as PytestUnraisableExceptionWarning) instead of dropping it silently, but doesn't fail the run on its own. Suites using bare error, including this repo, do fail, since error matches the wrapper.

Branch structure

The branch builds the original direct-raise approach across red→green commits, then a final commit drops it and keeps the timing fix.

  • Early commits added the unwrap branch and its tests, moved the GC into pytest_unconfigure, and guarded pytest_unconfigure against an unset stash when another plugin's pytest_configure raises UsageError.
  • cd7e0432e moved gc.collect() and queue processing into pytest_unconfigure.
  • The final feedback commit drops the unwrap branch and _warning_class_has_error_filter, keeps the GC in cleanup() as well as pytest_unconfigure, removes the tests that only covered the dropped behavior, and reworks the cleanup-order test to raise ValueError from __del__ under an error::pytest.PytestUnraisableExceptionWarning filter.
Relationship to #14273

#14273 attempted approach 2 (move GC to pytest_unconfigure). Maintainers closed it unmerged: #13057 (Dec 2024) had already shipped approach 1 by reordering default_plugins, so the move alone showed no behavior difference under the built-in plugin order. A maintainer asked for "a regression test which demonstrates that the unraisable hook is now subject to warnings."

test_unraisable_decouples_from_cleanup_stack_order answers that. The @hookimpl(trylast=True) conftest registers a warnings.resetwarnings cleanup that pops before unraisableexception's own, defeating the static default_plugins order. On main the run exits 0 (filter gone before the collection runs); here it exits 1 (collection runs in pytest_unconfigure, filter still active).

AI disclosure

Changelog

changelog/14263.bugfix.rst.

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label May 19, 2026
@paulzuradzki paulzuradzki force-pushed the bugfix/14263-unraisable-warning-direct-raise branch 3 times, most recently from 476e4f5 to 4cf46d4 Compare May 19, 2026 21:39
Copy link
Copy Markdown

@FuzzysTodd FuzzysTodd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest_unconfigure fires before _cleanup_stack.close(), so warning
filters managed via the cleanup stack (the warnings plugin's
catch_warnings context, in particular) are guaranteed active when GC

@paulzuradzki paulzuradzki changed the title Surface filter-promoted unraisable warnings directly (#14263) WIP: Surface filter-promoted unraisable warnings directly (#14263) May 20, 2026
@paulzuradzki paulzuradzki force-pushed the bugfix/14263-unraisable-warning-direct-raise branch from 63a5109 to 56f71b7 Compare May 24, 2026 12:59
filterwarnings = error::ResourceWarning does not fail tests that leak
resources through a reference cycle. collect_unraisable wraps the
captured ResourceWarning in PytestUnraisableExceptionWarning, a class
the user has no filter for, so the run exits 0.

This test pins that contract: on a refcycle-leaking test with
error::ResourceWarning configured, pytest should exit non-zero and the
output should show the inner ResourceWarning rather than the wrapping
PytestUnraisableExceptionWarning. Fails at this commit; the next
commit ships the fix that turns it green.

Refs pytest-dev#14263.
When sys.unraisablehook captures a Warning subclass instance and the
user has an active ``error::<that class>`` filter, raise the original
warning rather than wrapping in PytestUnraisableExceptionWarning. The
wrap path remains for any case where no matching error filter is set,
so suites that don't use ``error::<warning>`` filters see no change.

Filter matching is approximate: category only, not message/module/lineno.
The check errs toward false negatives, never false positives.

The regression test added in the previous commit now passes. Additional
coverage:

- test_refcycle_userwarning_filter: locks the contract for a non-builtin
  Warning subclass.
- test_unraisable_warning_without_filter_still_wraps: scope guard. A
  Warning raised from __del__ without a matching error filter must
  still be wrapped, not raised directly.
- test_unraisable_warning_filter_add_note_dedups: covers the duplicate-
  note guard in the unwrap path for singleton/cached Warning instances.

Tightens the ``errors`` list type from list[Exception] to
list[Warning | RuntimeError]. Adds Paul Zuradzki to AUTHORS. Notes in
test_create_task_raises_unraisable_warning_filter that the propagated
class is now bare RuntimeWarning rather than the wrapping
PytestUnraisableExceptionWarning (because -Werror activates the new
unwrap path).

Closes pytest-dev#14263.
Forces the bad LIFO order with a conftest that registers a
warnings.resetwarnings cleanup via @hookimpl(trylast=True): it pops
before unraisableexception's cleanup and clears the user's
error::ResourceWarning filter before GC runs, so the leak exits 0. The
next commit moves GC into pytest_unconfigure (runs before the cleanup
stack closes), making the test pass regardless of plugin order.

Refs pytest-dev#14263.
Register only the hook-restore + stash-cleanup as the
config.add_cleanup callback. Move the GC pump and collect_unraisable
call into a new pytest_unconfigure(config) hook.

pytest_unconfigure fires before _cleanup_stack.close(), so warning
filters managed via the cleanup stack (the warnings plugin's
catch_warnings context, in particular) are guaranteed active when GC
runs. This decouples the unraisable step from plugin registration
order in default_plugins. The previous arrangement worked only because
of LIFO ordering on _cleanup_stack; pytest-dev#13057 (Dec 2024) reordered
default_plugins to make that ordering correct, but the structural
fragility remained.

No observable behavior change. The 141 existing tests in
test_unraisableexception + test_warnings + test_recwarn +
test_threadexception still pass. The previous commit's
test_refcycle_resource_warning_filter continues to fail on main and
pass here.

This is the structural side of the issue's two proposed fixes; the
user-visible side shipped in the previous commit.
After GC moved into pytest_unconfigure, a plugin whose pytest_configure
raises UsageError leaves the stash key unset while pytest_unconfigure
still runs. Without a presence check it hits KeyError and reports
INTERNALERROR instead of USAGE_ERROR. The next commit adds the guard.

Refs pytest-dev#14263.
When another plugin's pytest_configure raises (e.g. pytest.UsageError
in testing/acceptance_test.py::test_config_error), pluggy skips
remaining configure hooks. unraisableexception.pytest_configure never
runs, config.stash[unraisable_exceptions] is never set. The previous
config.add_cleanup callback wasn't registered in that case either, so
cleanup was a no-op. The pytest_unconfigure hook introduced in the
previous commit ran unconditionally and hit KeyError on the unset
stash, surfacing as INTERNAL_ERROR where pytest should exit with
USAGE_ERROR.

Guard with a stash-presence check at the top of pytest_unconfigure.
test_config_error catches the regression direction.
…gure

pytest-dev#14441 reduced the default gc_collect_harder passes to 1 on CPython
(5 on PyPy, where __del__ can resurrect objects). That change lived in
cleanup(), which this branch emptied when it moved GC into
pytest_unconfigure. Carry the same default into the new location so the
relocation does not silently revert pytest-dev#14441. CPython still collects the
refcycle regression tests in a single pass.
@paulzuradzki paulzuradzki force-pushed the bugfix/14263-unraisable-warning-direct-raise branch from 30ff861 to 8f303fc Compare May 24, 2026 19:25
Copy link
Copy Markdown
Contributor Author

@paulzuradzki paulzuradzki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-review


msg = meta.msg
try:
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explainer:

  • Builds a PytestUnraisableExceptionWarning wrapper and emits that via warnings.warn. The user's filter is error::ResourceWarning.
  • PytestUnraisableExceptionWarning is a pytest.PytestWarning subclass; no relationship to Python ResourceWarning in class hierarchy.
  • So the user's filter doesn't match the wrapper. The wrapper gets emitted as a regular warning (just a stderr line in the warnings summary), not promoted to an error, and nothing fails the test.
  • For the user's error::ResourceWarning filter to fail the suite on main, they'd have to also write error::PytestUnraisableExceptionWarning. This would be filtering on pytest's internal wrapping class. Leaky abstraction. They want to express "fail if a ResourceWarning leaks from a finalizer," not "fail if pytest's internal wrapper class fires."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 4cf46 says, "if user said error::ResourceWarning, an unraisable ResourceWarning becomes a real raised ResourceWarning"

Comment thread src/_pytest/unraisableexception.py Outdated
errors.append(hook_error)
continue

if isinstance(meta.exc_value, Warning) and _warning_class_has_error_filter(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When collect_unraisable sees an unraisable whose exc_value is a Warning subclass instance, re-raise it directly instead of wrapping w/ pytest.PytestUnraisableExceptionWarning

Comment thread src/_pytest/unraisableexception.py Outdated
if sys.version_info >= (3, 11):
if meta.cause_msg not in getattr(meta.exc_value, "__notes__", []):
meta.exc_value.add_note(meta.cause_msg)
errors.append(meta.exc_value)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Append meta.exc_value (the actual ResourceWarning instance) to errors (what eventually gets raise)

Comment thread src/_pytest/unraisableexception.py Outdated
):
# Honor the user's error filter on the inner warning class
# rather than wrapping in PytestUnraisableExceptionWarning. See #14263.
if sys.version_info >= (3, 11):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Python 3.11+, attach meta.cause_msg as a PEP 678 note

Comment thread src/_pytest/unraisableexception.py Outdated
# still active. This decouples the GC step from plugin registration order.
# A single collection doesn't necessarily collect everything; the
# iteration count was determined experimentally by the Trio project.
if unraisable_exceptions not in config.stash:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug made possible by -- this PR -- moving GC and collect_unraisable from cleanup callback into pytest_unconfigure hook.

Guard checks: if our configure never ran, stash key is not there, and there's nothing to drain deque, so early return.

Else, this bug was possile:

  • configure step crashes before def pytest_configure(config): ... config.stash[unraisable_exceptions] = deque
  • stash never created
  • unconfigure runs anyway
  • -> tries to read missing stash
  • -> KeyError

Comment thread testing/test_unraisableexception.py Outdated

# TODO: should be a test failure or error. Currently the exception
# propagates all the way to the top resulting in exit code 1.
assert result.ret == 1
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed similar TODOs elsewhere.

AI explainer:

The exit code 1 comes from pytest's session-end unraisable handling, not from runpytest_subprocess() (that helper just reports the child's exit code). The leaked warning fires during session-end GC in pytest_unconfigure, after test_it has already been reported as passed, so it propagates to the top of the run instead of failing a specific test. Ideally we'd get failed=1 attributed to test_it; that needs allocation tracking and is out of scope here.

@paulzuradzki paulzuradzki marked this pull request as ready for review May 24, 2026 19:37
@paulzuradzki
Copy link
Copy Markdown
Contributor Author

paulzuradzki commented May 24, 2026

cc: @Zac-HD - this is the PR/GH Issue we chatted about at PyCon US sprints last Monday

@paulzuradzki paulzuradzki changed the title WIP: Surface filter-promoted unraisable warnings directly (#14263) Surface filter-promoted unraisable warnings directly (#14263) May 24, 2026
Copy link
Copy Markdown
Member

@Zac-HD Zac-HD left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the gc logic to pytest-unconfigure looks good to me, but I'd make two tweaks:

  • keep the gc logic in cleanup() too, in case of resources which are (partly) freed between those two locations
  • drop the magic warnings handling. While tempting, we can't fully handle e.g. the tracebacks which should be present, and so I think continuing to raise PytestUnraisableExceptionWarning is the right approach here.

…rnings

Reviewer feedback on pytest-dev#14499:

- Keep the GC + queue drain in cleanup() as well as pytest_unconfigure, so
  objects freed while the cleanup stack unwinds still surface.
- Drop the direct-raise path (_warning_class_has_error_filter plus the unwrap
  branch in collect_unraisable). Re-raising the inner warning lost the
  traceback the hook captures as a string, and the filter match was only
  approximate. collect_unraisable now always wraps in
  PytestUnraisableExceptionWarning and lets the warnings machinery decide.

Tests dropped or reworked:

- Removed test_refcycle_resource_warning_filter: with only error::ResourceWarning
  the leak no longer fails the suite, which was the dropped direct-raise.
- Removed test_refcycle_userwarning_filter: redundant with
  test_refcycle_unraisable_warning_filter once the wrapper is always raised.
- Removed test_unraisable_warning_filter_add_note_dedups: covered the deleted
  add_note code.
- Reworked test_unraisable_decouples_from_cleanup_stack_order to raise
  ValueError from __del__ and filter on error::pytest.PytestUnraisableExceptionWarning.
  A ResourceWarning leak only enters the unraisable pipeline through an
  error::ResourceWarning filter, which no longer promotes the wrapper; a raised
  exception is wrapped unconditionally, so the timing fix stays observable.

Changelog reworded to describe the pytest_unconfigure timing fix instead of the
dropped direct-raise behavior.
@Zac-HD Zac-HD merged commit c9d940f into pytest-dev:main May 27, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants