|
| 1 | +import threading |
| 2 | +import time |
| 3 | +from contextlib import AbstractContextManager |
| 4 | +from types import TracebackType |
| 5 | +from typing import Optional, Type |
| 6 | + |
| 7 | +_cooperation = threading.local() |
| 8 | + |
| 9 | + |
| 10 | +class CooperativeTimeoutError(TimeoutError): |
| 11 | + """An exception raised when a cooperative timeout is exceeded.""" |
| 12 | + |
| 13 | + |
| 14 | +def cooperate() -> None: |
| 15 | + """Method to be called periodically to cooperate with the timeout mechanism.""" |
| 16 | + |
| 17 | + deadline = getattr(_cooperation, "deadline", None) |
| 18 | + if deadline is not None and deadline < time.perf_counter_ns(): |
| 19 | + raise CooperativeTimeoutError("CooperativeTimeout deadline exceeded") |
| 20 | + |
| 21 | + |
| 22 | +class CooperativeTimeout(AbstractContextManager): |
| 23 | + """A cooperative timeout mechanism. |
| 24 | +
|
| 25 | + Getting code to time out in Python is actually rather tricky. Common approaches include: |
| 26 | +
|
| 27 | + - Using the signal module to set a signal handler that raises an exception |
| 28 | + after a certain time. Unfortunately, this approach only works on the main |
| 29 | + thread, and is not available on Windows. |
| 30 | + - Creating a separate process to run the code and then killing it if it hasn't |
| 31 | + finished by the deadline. This usually requires that all arguments/return |
| 32 | + types are pickleable so that they can be passed between processes. Overall, |
| 33 | + this approach is heavy-handed and can be tricky to implement correctly. |
| 34 | + - Using `threading` is not an option, since Python threads are not interruptible |
| 35 | + (unless you're willing to use some hacks https://stackoverflow.com/a/61528202). |
| 36 | + Attempting to forcibly terminate a thread can deadlock on the GIL. |
| 37 | +
|
| 38 | + In cases where (1) we have control over the code that we want to time out and |
| 39 | + (2) we can modify it to regularly and reliably call a specific function, we can |
| 40 | + use a cooperative timeout mechanism instead. |
| 41 | +
|
| 42 | + This is not reentrant and cannot be used in nested contexts. It can be used |
| 43 | + in multi-threaded contexts, so long as the cooperative function is called |
| 44 | + from the same thread that created the timeout. |
| 45 | +
|
| 46 | + Args: |
| 47 | + timeout: The timeout in seconds. If None, the timeout is disabled. |
| 48 | + """ |
| 49 | + |
| 50 | + def __init__(self, timeout: Optional[None] = None): |
| 51 | + self.timeout = timeout |
| 52 | + |
| 53 | + def __enter__(self) -> "CooperativeTimeout": |
| 54 | + if hasattr(_cooperation, "deadline"): |
| 55 | + raise RuntimeError("CooperativeTimeout already active") |
| 56 | + if self.timeout is not None: |
| 57 | + _cooperation.deadline = ( |
| 58 | + time.perf_counter_ns() + self.timeout * 1_000_000_000 |
| 59 | + ) |
| 60 | + return self |
| 61 | + |
| 62 | + def __exit__( |
| 63 | + self, |
| 64 | + exc_type: Optional[Type[BaseException]], |
| 65 | + exc_val: Optional[BaseException], |
| 66 | + exc_tb: Optional[TracebackType], |
| 67 | + ) -> None: |
| 68 | + if self.timeout is not None: |
| 69 | + del _cooperation.deadline |
0 commit comments