1 import io

2 import os

3 import sys

4 from typing import Generator

5 from typing import TextIO

6

7 import pytest

8 from _pytest.config import Config

9 from _pytest.config.argparsing import Parser

10 from _pytest.nodes import Item

11 from _pytest.stash import StashKey

12

13

14 fault_handler_stderr_key = StashKey[TextIO]()

15 fault_handler_originally_enabled_key = StashKey[bool]()

16

17

18 def pytest_addoption(parser: Parser) -> None:

19 help = (

20 "Dump the traceback of all threads if a test takes "

21 "more than TIMEOUT seconds to finish"

22 )

23 parser.addini("faulthandler_timeout", help, default=0.0)

24

25

26 def pytest_configure(config: Config) -> None:

27 import faulthandler

28

29 stderr_fd_copy = os.dup(get_stderr_fileno())

30 config.stash[fault_handler_stderr_key] = open(stderr_fd_copy, "w")

31 config.stash[fault_handler_originally_enabled_key] = faulthandler.is_enabled()

32 faulthandler.enable(file=config.stash[fault_handler_stderr_key])

33

34

35 def pytest_unconfigure(config: Config) -> None:

36 import faulthandler

37

38 faulthandler.disable()

39 # Close the dup file installed during pytest_configure.

40 if fault_handler_stderr_key in config.stash:

41 config.stash[fault_handler_stderr_key].close()

42 del config.stash[fault_handler_stderr_key]

43 if config.stash.get(fault_handler_originally_enabled_key, False):

44 # Re-enable the faulthandler if it was originally enabled.

45 faulthandler.enable(file=get_stderr_fileno())

46

47

48 def get_stderr_fileno() -> int:

49 try:

50 fileno = sys.stderr.fileno()

51 # The Twisted Logger will return an invalid file descriptor since it is not backed

52 # by an FD. So, let's also forward this to the same code path as with pytest-xdist.

53 if fileno == -1:

54 raise AttributeError()

55 return fileno

56 except (AttributeError, io.UnsupportedOperation):

57 # pytest-xdist monkeypatches sys.stderr with an object that is not an actual file.

58 # https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors

59 # This is potentially dangerous, but the best we can do.

60 return sys.__stderr__.fileno()

61

62

63 def get_timeout_config_value(config: Config) -> float:

64 return float(config.getini("faulthandler_timeout") or 0.0)

65

66

67@pytest.hookimpl(hookwrapper=True, trylast=True)

68 def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:

69 timeout = get_timeout_config_value(item.config)

70 stderr = item.config.stash[fault_handler_stderr_key]

71 if timeout > 0 and stderr is not None:

72 import faulthandler

73

74 faulthandler.dump_traceback_later(timeout, file=stderr)

75 try:

76 yield

77 finally:

78 faulthandler.cancel_dump_traceback_later()

79 else:

80 yield

81

82

83@pytest.hookimpl(tryfirst=True)

84 def pytest_enter_pdb() -> None:

85 """Cancel any traceback dumping due to timeout before entering pdb."""

86 import faulthandler

87

88 faulthandler.cancel_dump_traceback_later()

89

90

91@pytest.hookimpl(tryfirst=True)

92 def pytest_exception_interact() -> None:

93 """Cancel any traceback dumping due to an interactive exception being

94 raised."""

95 import faulthandler

96

97 faulthandler.cancel_dump_traceback_later()