Skip to content

Commit 3253c63

Browse files
committed
Add basic timeout helper
It's not perfect, as it doesn't work if the block is outside Python.
1 parent ea0f451 commit 3253c63

File tree

3 files changed

+58
-1
lines changed

3 files changed

+58
-1
lines changed

django_tasks/exceptions.py

+6
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ class InvalidTaskBackendError(ImproperlyConfigured):
1313

1414
class ResultDoesNotExist(ObjectDoesNotExist):
1515
pass
16+
17+
18+
class TimeoutException(BaseException):
19+
"""
20+
Something timed out.
21+
"""

django_tasks/utils.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import ctypes
12
import inspect
23
import json
34
import random
45
import time
6+
from contextlib import contextmanager
57
from functools import wraps
8+
from threading import Timer, current_thread
69
from traceback import format_exception
7-
from typing import Any, Callable, TypeVar
10+
from typing import Any, Callable, Iterator, TypeVar
811

912
from django.utils.crypto import RANDOM_STRING_CHARS
1013
from typing_extensions import ParamSpec
1114

15+
from .exceptions import TimeoutException
16+
1217
T = TypeVar("T")
1318
P = ParamSpec("P")
1419

@@ -74,3 +79,40 @@ def get_random_id() -> str:
7479
it's not cryptographically secure.
7580
"""
7681
return "".join(random.choices(RANDOM_STRING_CHARS, k=32))
82+
83+
84+
def _do_timeout(tid: int) -> None:
85+
"""
86+
Raise `TimeoutException` in the given thread.
87+
88+
Here be dragons.
89+
"""
90+
# Since we're in Python, no GIL lock is needed
91+
ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
92+
ctypes.c_ulong(tid), ctypes.py_object(TimeoutException)
93+
)
94+
95+
if ret == 0:
96+
raise RuntimeError("Timeout failed - thread not found")
97+
elif ret != 1:
98+
ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None)
99+
raise RuntimeError("Timeout failed")
100+
101+
102+
@contextmanager
103+
def timeout(timeout: float) -> Iterator[Timer]:
104+
"""
105+
Run the wrapped code for at most `timeout` seconds before aborting.
106+
107+
This works by starting a timer thread, and using "magic" raises an exception
108+
in the main process after the timer expires.
109+
110+
Raises `TimeoutException` when the timeout occurs.
111+
"""
112+
timeout_timer = Timer(timeout, _do_timeout, args=[current_thread().ident])
113+
try:
114+
timeout_timer.start()
115+
116+
yield timeout_timer
117+
finally:
118+
timeout_timer.cancel()

tests/tests/test_utils.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import datetime
22
import subprocess
3+
import time
34
from unittest.mock import Mock
45

56
from django.test import SimpleTestCase
67

78
from django_tasks import utils
9+
from django_tasks.exceptions import TimeoutException
810
from tests import tasks as test_tasks
911

1012

@@ -116,3 +118,10 @@ def test_complex_exception(self) -> None:
116118
self.assertIn("KeyError: datetime.datetime", traceback)
117119
else:
118120
self.fail("KeyError not raised")
121+
122+
123+
class TimeoutTestCase(SimpleTestCase):
124+
def test_sleep_timeout(self) -> None:
125+
with self.assertRaises(TimeoutException):
126+
with utils.timeout(0.25):
127+
time.sleep(0.5)

0 commit comments

Comments
 (0)