|
1 | 1 | import os |
2 | 2 | import time |
| 3 | +import sys |
| 4 | +import threading |
| 5 | +import traceback |
| 6 | +import pytest |
3 | 7 | from typing import ( |
4 | 8 | Any, |
5 | 9 | AsyncGenerator, |
|
14 | 18 | ) |
15 | 19 | from typing import Callable, TypeVar |
16 | 20 |
|
17 | | -import pytest |
18 | 21 | import pytest_asyncio |
19 | 22 | from _pytest.fixtures import SubRequest |
20 | 23 |
|
@@ -500,3 +503,66 @@ def retry_on_http_error( |
500 | 503 | raise |
501 | 504 | # This should never be reached, but satisfies the type checker |
502 | 505 | raise last_exception # type: ignore |
| 506 | + |
| 507 | + |
| 508 | +TIMEOUT_SECONDS = 300 |
| 509 | + |
| 510 | + |
| 511 | +def dump_all_stacks(): |
| 512 | + frames = sys._current_frames() |
| 513 | + lines = ["\n===== DEADLOCK DETECTED — THREAD DUMP =====\n"] |
| 514 | + for thread in threading.enumerate(): |
| 515 | + frame = frames.get(thread.ident) # pyright: ignore |
| 516 | + lines.append(f"\n--- Thread: {thread.name} (id={thread.ident}) ---") |
| 517 | + if frame: |
| 518 | + lines.append("".join(traceback.format_stack(frame))) |
| 519 | + else: |
| 520 | + lines.append(" (no frame)\n") |
| 521 | + lines.append("===========================================\n") |
| 522 | + return "\n".join(lines) |
| 523 | + |
| 524 | + |
| 525 | +class DeadlockWatchdog: |
| 526 | + def __init__(self, timeout): |
| 527 | + self.timeout = timeout |
| 528 | + self._timer = None |
| 529 | + |
| 530 | + def start(self, label): |
| 531 | + self._label = label |
| 532 | + self._timer = threading.Timer(self.timeout, self._on_timeout) |
| 533 | + self._timer.daemon = True |
| 534 | + self._timer.start() |
| 535 | + |
| 536 | + def stop(self): |
| 537 | + if self._timer: |
| 538 | + self._timer.cancel() |
| 539 | + self._timer = None |
| 540 | + |
| 541 | + def _on_timeout(self): |
| 542 | + sys.stderr.write(f"\n[WATCHDOG] Hung at: '{self._label}' after {self.timeout}s\n") |
| 543 | + sys.stderr.write(dump_all_stacks()) |
| 544 | + sys.stderr.flush() |
| 545 | + os._exit(1) # Hard kill — works reliably in xdist workers |
| 546 | + |
| 547 | + |
| 548 | +_watchdog = DeadlockWatchdog(TIMEOUT_SECONDS) |
| 549 | + |
| 550 | + |
| 551 | +# Covers setup + call + teardown |
| 552 | +@pytest.hookimpl(hookwrapper=True) |
| 553 | +def pytest_runtest_protocol(item, nextitem): |
| 554 | + _watchdog.start(item.nodeid) |
| 555 | + try: |
| 556 | + yield |
| 557 | + finally: |
| 558 | + _watchdog.stop() |
| 559 | + |
| 560 | + |
| 561 | +# Separately watch session-scoped fixture setup |
| 562 | +@pytest.hookimpl(hookwrapper=True) |
| 563 | +def pytest_sessionstart(session): |
| 564 | + _watchdog.start("session startup / session-scoped fixtures") |
| 565 | + try: |
| 566 | + yield |
| 567 | + finally: |
| 568 | + _watchdog.stop() |
0 commit comments