-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
threadexception enhancements #13016
threadexception enhancements #13016
Changes from 12 commits
e44969c
2615916
c24c03e
f1195c3
ee5cb32
d2377b5
ec782db
bf770cf
82f60d6
4efa96a
9e26040
faf769c
7a56153
6224f17
8af4991
4b95be7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
A number of :ref:`threadexception <unraisable>` enhancements: | ||
|
||
* Set the excepthook as early as possible and unset it as late as possible, to collect the most possible number of unhandled exceptions from threads. | ||
* join threads for 1 second just before unsetting the excepthook, to collect any straggling exceptions | ||
* Collect multiple thread exceptions per test phase. | ||
* Report the :mod:`tracemalloc` allocation traceback (if available). | ||
* Avoid using a generator based hook to allow handling :class:`StopIteration` in test failures. | ||
* Report the thread exception as the cause of the :class:`pytest.PytestUnhandledThreadExceptionWarning` exception if raised. | ||
* Extract the ``name`` of the thread object in the excepthook which should help with resurrection of the thread. |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,97 +1,158 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import collections | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from collections.abc import Callable | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from collections.abc import Generator | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import functools | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import sys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import threading | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import traceback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from types import TracebackType | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from typing import Any | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from typing import NamedTuple | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from typing import TYPE_CHECKING | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import warnings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from _pytest.config import Config | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from _pytest.nodes import Item | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from _pytest.stash import StashKey | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from _pytest.tracemalloc import tracemalloc_message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import pytest | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if TYPE_CHECKING: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from typing_extensions import Self | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Copied from cpython/Lib/test/support/threading_helper.py, with modifications. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
class catch_threading_exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Context manager catching threading.Thread exception using | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
threading.excepthook. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Storing exc_value using a custom hook can create a reference cycle. The | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
reference cycle is broken explicitly when the context manager exits. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Storing thread using a custom hook can resurrect it if it is set to an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
object which is being finalized. Exiting the context manager clears the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stored object. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Usage: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with threading_helper.catch_threading_exception() as cm: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# code spawning a thread which raises an exception | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# check the thread exception: use cm.args | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
... | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# cm.args attribute no longer exists at this point | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# (to break a reference cycle) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def __init__(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.args: threading.ExceptHookArgs | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._old_hook: Callable[[threading.ExceptHookArgs], Any] | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def _hook(self, args: threading.ExceptHookArgs) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.args = args | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def __enter__(self) -> Self: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._old_hook = threading.excepthook | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
threading.excepthook = self._hook | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def __exit__( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc_type: type[BaseException] | None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc_val: BaseException | None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc_tb: TracebackType | None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
assert self._old_hook is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
threading.excepthook = self._old_hook | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._old_hook = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
del self.args | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def thread_exception_runtest_hook() -> Generator[None]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with catch_threading_exception() as cm: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if sys.version_info < (3, 11): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from exceptiongroup import ExceptionGroup | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def join_threads() -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
start = time.monotonic() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
current_thread = threading.current_thread() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# This function is executed right at the end of the pytest run, just | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# before we return an exit code, which is where the interpreter joins | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# any remaining non-daemonic threads anyway, so it's ok to join all the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# threads. However there might be threads that depend on some shutdown | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# signal that happens after pytest finishes, so we want to limit the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# join time somewhat. A one second timeout seems reasonable. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
timeout = 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
for thread in threading.enumerate(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if thread is not current_thread and not thread.daemon: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# TODO: raise an error/warning if there's dangling threads. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
thread.join(timeout - (time.monotonic() - start)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
class ThreadExceptionMeta(NamedTuple): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg: str | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cause_msg: str | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc_value: BaseException | None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
thread_exceptions: StashKey[collections.deque[ThreadExceptionMeta | BaseException]] = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
StashKey() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def collect_thread_exception(config: Config) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
pop_thread_exception = config.stash[thread_exceptions].pop | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
errors: list[pytest.PytestUnhandledThreadExceptionWarning | RuntimeError] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
meta = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
hook_error = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
while True: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
meta = pop_thread_exception() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except IndexError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if isinstance(meta, BaseException): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
hook_error = RuntimeError("Failed to process thread exception") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
hook_error.__cause__ = meta | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
errors.append(hook_error) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg = meta.msg | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except pytest.PytestUnhandledThreadExceptionWarning as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# This except happens when the warning is treated as an error | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if meta.exc_value is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Exceptions have a better way to show the traceback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
e.args = (meta.cause_msg,) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
e.__cause__ = meta.exc_value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
errors.append(e) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if len(errors) == 1: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raise errors[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if errors: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raise ExceptionGroup("multiple thread exception warnings", errors) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is absolutely definitely covered by tests: pytest/testing/test_threadexception.py Lines 136 to 162 in 7a56153
what's going on? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. resolved, yeah? Looks like 100% coverage wherever I look There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, in the review window codecov is being dumb. idk what exactly triggers it but the codecov app/bot/thing is fairly often behind (not just in this repo), and the CI run is the best place to go look for coverage status. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI refers to https://github.com/pytest-dev/pytest/pull/13016/checks?check_run_id=33901516563, but the "Files Changed" codecov alerts refers to https://github.com/pytest-dev/pytest/pull/13016/checks?check_run_id=33900516688 - which while on the same commit it displays an orange "refresh" button that will redirect to 33901516563. Not sure how those runs differ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think this is due to Which I think means we (and every project) needs to disable annotations There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. opened codecov/codecov-action#1710 requesting the default be changed, but will otherwise open PR's/edit the org-level yaml to disable them. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
finally: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
del errors, meta, hook_error | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def cleanup( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
*, config: Config, prev_hook: Callable[[threading.ExceptHookArgs], object] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
yield | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
join_threads() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
collect_thread_exception(config) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
finally: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if cm.args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
thread_name = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"<unknown>" if cm.args.thread is None else cm.args.thread.name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg = f"Exception in thread {thread_name}\n\n" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg += "".join( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
traceback.format_exception( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cm.args.exc_type, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cm.args.exc_value, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cm.args.exc_traceback, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(wrapper=True, trylast=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_setup() -> Generator[None]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
yield from thread_exception_runtest_hook() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(wrapper=True, tryfirst=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_call() -> Generator[None]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
yield from thread_exception_runtest_hook() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(wrapper=True, tryfirst=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_teardown() -> Generator[None]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
yield from thread_exception_runtest_hook() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
threading.excepthook = prev_hook | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
finally: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
del config.stash[thread_exceptions] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def thread_exception_hook( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
args: threading.ExceptHookArgs, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
/, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
*, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
append: Callable[[ThreadExceptionMeta | BaseException], object], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
thread_name = "<unknown>" if args.thread is None else args.thread.name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
summary = f"Exception in thread {thread_name}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
traceback_message = "\n\n" + "".join( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
traceback.format_exception( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
args.exc_type, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
args.exc_value, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
args.exc_traceback, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
tracemalloc_tb = "\n" + tracemalloc_message(args.thread) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg = summary + traceback_message + tracemalloc_tb | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cause_msg = summary + tracemalloc_tb | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
append( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ThreadExceptionMeta( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Compute these strings here as they might change later | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. stale/misplaced comment? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is valid, I meant compute the strings in the hook rather than later on in the collect-from-deque phase There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've moved the comment to the correct place. And I've gone through and checked the comments match the unraisablehook (or diverge where changes are needed) Note the first commit in this PR was generated by claude - given the unraisablehook patch and the existing threadexception.py. It did well on the code but not so well on the comments! |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
msg=msg, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cause_msg=cause_msg, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc_value=args.exc_value, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except BaseException as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
append(e) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raise | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_configure(config: Config) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
prev_hook = threading.excepthook | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
deque: collections.deque[ThreadExceptionMeta | BaseException] = collections.deque() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
config.stash[thread_exceptions] = deque | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
config.add_cleanup(functools.partial(cleanup, config=config, prev_hook=prev_hook)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
threading.excepthook = functools.partial(thread_exception_hook, append=deque.append) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(trylast=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_setup(item: Item) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
collect_thread_exception(item.config) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(trylast=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_call(item: Item) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
collect_thread_exception(item.config) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@pytest.hookimpl(trylast=True) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def pytest_runtest_teardown(item: Item) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
collect_thread_exception(item.config) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,7 +23,7 @@ def test_2(): pass | |
) | ||
result = pytester.runpytest() | ||
assert result.ret == 0 | ||
assert result.parseoutcomes() == {"passed": 2, "warnings": 1} | ||
result.assert_outcomes(passed=2, warnings=1) | ||
result.stdout.fnmatch_lines( | ||
[ | ||
"*= warnings summary =*", | ||
|
@@ -59,7 +59,7 @@ def test_2(): pass | |
) | ||
result = pytester.runpytest() | ||
assert result.ret == 0 | ||
assert result.parseoutcomes() == {"passed": 2, "warnings": 1} | ||
result.assert_outcomes(passed=2, warnings=1) | ||
result.stdout.fnmatch_lines( | ||
[ | ||
"*= warnings summary =*", | ||
|
@@ -96,7 +96,7 @@ def test_2(): pass | |
) | ||
result = pytester.runpytest() | ||
assert result.ret == 0 | ||
assert result.parseoutcomes() == {"passed": 2, "warnings": 1} | ||
result.assert_outcomes(passed=2, warnings=1) | ||
result.stdout.fnmatch_lines( | ||
[ | ||
"*= warnings summary =*", | ||
|
@@ -130,4 +130,95 @@ def test_2(): pass | |
) | ||
result = pytester.runpytest() | ||
assert result.ret == pytest.ExitCode.TESTS_FAILED | ||
assert result.parseoutcomes() == {"passed": 1, "failed": 1} | ||
result.assert_outcomes(passed=1, failed=1) | ||
|
||
|
||
@pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning") | ||
def test_threadexception_warning_multiple_errors(pytester: Pytester) -> None: | ||
pytester.makepyfile( | ||
test_it=""" | ||
import threading | ||
|
||
def test_it(): | ||
def oops(): | ||
raise ValueError("Oops") | ||
|
||
t = threading.Thread(target=oops, name="MyThread") | ||
t.start() | ||
t.join() | ||
|
||
t = threading.Thread(target=oops, name="MyThread2") | ||
t.start() | ||
t.join() | ||
|
||
def test_2(): pass | ||
""" | ||
) | ||
result = pytester.runpytest() | ||
assert result.ret == pytest.ExitCode.TESTS_FAILED | ||
result.assert_outcomes(passed=1, failed=1) | ||
result.stdout.fnmatch_lines( | ||
[" | *ExceptionGroup: multiple thread exception warnings (2 sub-exceptions)"] | ||
) | ||
|
||
|
||
def test_unraisable_collection_failure(pytester: Pytester) -> None: | ||
pytester.makepyfile( | ||
test_it=""" | ||
import threading | ||
|
||
class Thread(threading.Thread): | ||
@property | ||
def name(self): | ||
raise RuntimeError("oops!") | ||
|
||
def test_it(): | ||
def oops(): | ||
raise ValueError("Oops") | ||
|
||
t = Thread(target=oops, name="MyThread") | ||
t.start() | ||
t.join() | ||
|
||
def test_2(): pass | ||
""" | ||
) | ||
|
||
result = pytester.runpytest() | ||
assert result.ret == 1 | ||
result.assert_outcomes(passed=1, failed=1) | ||
result.stdout.fnmatch_lines( | ||
["E RuntimeError: Failed to process thread exception"] | ||
) | ||
|
||
|
||
def test_unhandled_thread_exception_after_teardown(pytester: Pytester) -> None: | ||
pytester.makepyfile( | ||
test_it=""" | ||
import time | ||
import threading | ||
import pytest | ||
|
||
@pytest.fixture(scope="session") | ||
def thread(): | ||
yield | ||
|
||
def oops(): | ||
time.sleep(0.5) | ||
raise ValueError("Oops") | ||
|
||
t = threading.Thread(target=oops, name="MyThread") | ||
t.start() | ||
|
||
def test_it(thread): | ||
pass | ||
""" | ||
) | ||
|
||
result = pytester.runpytest() | ||
|
||
# TODO: should be a test failure or error | ||
assert result.ret == pytest.ExitCode.INTERNAL_ERROR | ||
Comment on lines
+217
to
+218
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we wait for this? Or open an issue to track it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think open an issue to track this, the unraisablehook code has the same issue |
||
|
||
result.assert_outcomes(passed=1) | ||
result.stderr.fnmatch_lines("ValueError: Oops") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is the bit of the PR I'm most unsure about. I think maybe we want to raise a deprecation warning for straggling threads - and make it into a error in pytest 9?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that seems reasonable to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is a reasonable thing for someone to run some thread unrelated to pytest, like a monitoring thread or similar (there are many which do this), and then run
pytest.main()
. If I understand correctly, this would then cause an unnecessary 1s delay to the pytest run. Best case, they report a bug, worst case they just think pytest is slow and suffer through it.So IMO, unless we can somehow restrict
join_threads
only to threads started by tests, we shouldn't do it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed in #13027