Files
zulip/zerver/lib/timeout.py
Alex Vandiver 3af2c8d9a3 timeout: Warn if the thread did not exit.
As noted in the docstring for this function, the timeout is
best-effort only -- if the thread is blocked in a syscall, it will not
service the exception until it returns.  It can also choose to catch
and ignore the TimeoutExpired; in either case it will still be running
even after the `timeout()` function returns.

Raising a vare TimeoutExpired it still somewhat accurate, but obscures
that the backend thread may still be running along merrily.  Notice
such cases, and log a warning about them.
2022-04-07 17:26:01 -07:00

99 lines
3.4 KiB
Python

import ctypes
import logging
import sys
import threading
import time
from types import TracebackType
from typing import Callable, Optional, Tuple, Type, TypeVar
# Based on https://code.activestate.com/recipes/483752/
class TimeoutExpired(Exception):
"""Exception raised when a function times out."""
def __str__(self) -> str:
return "Function call timed out."
ResultT = TypeVar("ResultT")
def timeout(timeout: float, func: Callable[[], ResultT]) -> ResultT:
"""Call the function in a separate thread.
Return its return value, or raise an exception,
within approximately 'timeout' seconds.
The function may receive a TimeoutExpired exception
anywhere in its code, which could have arbitrary
unsafe effects (resources not released, etc.).
It might also fail to receive the exception and
keep running in the background even though
timeout() has returned.
This may also fail to interrupt functions which are
stuck in a long-running primitive interpreter
operation."""
class TimeoutThread(threading.Thread):
def __init__(self) -> None:
threading.Thread.__init__(self)
self.result: Optional[ResultT] = None
self.exc_info: Tuple[
Optional[Type[BaseException]],
Optional[BaseException],
Optional[TracebackType],
] = (None, None, None)
# Don't block the whole program from exiting
# if this is the only thread left.
self.daemon = True
def run(self) -> None:
try:
self.result = func()
except BaseException:
self.exc_info = sys.exc_info()
def raise_async_timeout(self) -> None:
# Called from another thread.
# Attempt to raise a TimeoutExpired in the thread represented by 'self'.
assert self.ident is not None # Thread should be running; c_long expects int
tid = ctypes.c_long(self.ident)
ctypes.pythonapi.PyThreadState_SetAsyncExc(
tid, ctypes.py_object(TimeoutExpired)
)
thread = TimeoutThread()
thread.start()
thread.join(timeout)
if thread.is_alive():
# Gamely try to kill the thread, following the dodgy approach from
# https://stackoverflow.com/a/325528/90777
#
# We need to retry, because an async exception received while the
# thread is in a system call is simply ignored.
for i in range(10):
thread.raise_async_timeout()
time.sleep(0.1)
if not thread.is_alive():
break
if thread.exc_info[1] is not None:
# Re-raise the exception we sent, if possible, so the
# stacktrace originates in the slow code
raise thread.exc_info[1].with_traceback(thread.exc_info[2])
# If we don't have that for some reason (e.g. we failed to
# kill it), just raise from here; the thread _may still be
# running_ because it failed to see any of our exceptions, and
# we just ignore it.
if thread.is_alive():
logging.warning("Failed to time out backend thread")
raise TimeoutExpired
if thread.exc_info[1] is not None:
# Died with some other exception; re-raise it
raise thread.exc_info[1].with_traceback(thread.exc_info[2])
assert thread.result is not None # assured if above did not reraise
return thread.result