Skip to content
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

Merged
merged 16 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/13016.improvement.rst
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 unraisable exceptions.
graingert marked this conversation as resolved.
Show resolved Hide resolved
* 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.
227 changes: 144 additions & 83 deletions src/_pytest/threadexception.py
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
Copy link
Member Author

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?

Copy link
Member

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.

Copy link
Member

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

removed in #13027

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))

Check warning on line 41 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L41

Added line #L41 was not covered by tests


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

Check warning on line 71 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L68-L71

Added lines #L68 - L71 were not covered by tests

msg = meta.msg
try:
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))
except pytest.PytestUnhandledThreadExceptionWarning as e:

Check warning on line 76 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L73-L76

Added lines #L73 - L76 were not covered by tests
# 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)

Check warning on line 82 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L80-L82

Added lines #L80 - L82 were not covered by tests

if len(errors) == 1:
raise errors[0]

Check warning on line 85 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L85

Added line #L85 was not covered by tests
if errors:
raise ExceptionGroup("multiple thread exception warnings", errors)

Check warning on line 87 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L87

Added line #L87 was not covered by tests
Copy link
Member Author

Choose a reason for hiding this comment

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

this is absolutely definitely covered by tests:

@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)"]
)

what's going on?

Copy link
Member

Choose a reason for hiding this comment

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

resolved, yeah? Looks like 100% coverage wherever I look

Copy link
Member Author

Choose a reason for hiding this comment

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

Screenshot_20241204-114858

Copy link
Member

@jakkdl jakkdl Dec 4, 2024

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I think this is due to 2024-12-04T13:24:59,188624264+01:00
as seen on https://docs.codecov.com/docs/github-checks

Which I think means we (and every project) needs to disable annotations

Copy link
Member

Choose a reason for hiding this comment

The 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(

Check warning on line 114 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L111-L114

Added lines #L111 - L114 were not covered by tests
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

Check warning on line 123 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L121-L123

Added lines #L121 - L123 were not covered by tests

append(

Check warning on line 125 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L125

Added line #L125 was not covered by tests
ThreadExceptionMeta(
# Compute these strings here as they might change later
Copy link
Member

Choose a reason for hiding this comment

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

stale/misplaced comment?

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Check warning on line 135 in src/_pytest/threadexception.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/threadexception.py#L133-L135

Added lines #L133 - L135 were not covered by tests


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)
100 changes: 96 additions & 4 deletions testing/test_threadexception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =*",
Expand Down Expand Up @@ -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 =*",
Expand Down Expand Up @@ -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 =*",
Expand Down Expand Up @@ -130,4 +130,96 @@ 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:
# see: https://github.com/pytest-dev/pytest/issues/10404
graingert marked this conversation as resolved.
Show resolved Hide resolved
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
Copy link
Member

Choose a reason for hiding this comment

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

Should we wait for this? Or open an issue to track it?

Copy link
Member Author

Choose a reason for hiding this comment

The 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")
Loading