diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c5dd4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - run: pip install . pytest + - run: pytest tests.py -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8daeab --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ +*.so +.pytest_cache/ +.tox/ diff --git a/README.md b/README.md index 511f4e7..4f85b95 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,36 @@ -Limit the function execution time -================================= +# kill-timeout + +Limit the function execution time. This module provides a decorator which runs the function in a separate -multiprocessing.Process and sends SIGKILL after the specified timeout if the -function didn't complete. +`multiprocessing.Process` and sends `SIGKILL` after the specified timeout if +the function didn't complete. + +**Note:** This uses `SIGKILL` and `multiprocessing.Process`, so it only works +on POSIX systems (Linux, macOS, etc.), not on Windows. -Requirements ------------- +## Requirements -* Python 3.7+ (needed for multiprocessing.Process.kill) -* tblib (to pass the traceback from separate process) +* Python 3.10+ +* [tblib](https://pypi.org/project/tblib/) (to preserve tracebacks from the subprocess) -Install -------- +## Install ```bash pip install kill-timeout ``` -Usage ------ +## Usage ```python from kill_timeout import kill_timeout - limit_in_seconds = 5 @kill_timeout(limit_in_seconds) def long_running_function(**parameters): - """Function which makes some computations + """Function which makes some computations. It could take too long for some parameters. """ @@ -38,10 +38,19 @@ def long_running_function(**parameters): try: result = long_running_function(iterations=9001) - print("Function returned: %r" % result) + print(f"Function returned: {result!r}") except TimeoutError: - print("Function didn't finished in %d seconds" % limit_in_seconds) + print(f"Function didn't finish in {limit_in_seconds} seconds") except Exception: print("Function failed with its internal error! Its original traceback:") raise ``` + +## How it works + +Each call to a decorated function: + +1. Spawns a new `multiprocessing.Process` to run the function. +2. Waits for the process to complete within the timeout using `process.join(seconds)`. +3. If the process is still alive after the timeout, sends `SIGKILL` and reaps it. +4. If the function raised an exception, re-raises it in the caller with the original traceback (via `tblib`). diff --git a/kill_timeout.py b/kill_timeout.py index bce59f7..9aaca2b 100644 --- a/kill_timeout.py +++ b/kill_timeout.py @@ -3,6 +3,8 @@ from tblib.decorators import return_error, Error +_fork_ctx = multiprocessing.get_context("fork") + class TimeoutError(TimeoutError): """Custom TimeoutError @@ -13,52 +15,61 @@ class TimeoutError(TimeoutError): pass +def _target(func, child_conn, args, kwargs): + """Top-level target function for the subprocess (must be picklable).""" + try: + child_conn.send(return_error(func)(*args, **kwargs)) + finally: + child_conn.close() + + def kill_timeout(seconds): """Decorator to limit function execution time It runs the function in separate multiprocessing.Process and sends SIGKILL after the specified timeout if the function didn't complete. + + Note: return values larger than the OS pipe buffer (~64KB on Linux) may cause + a false timeout because the child blocks on send() while the parent waits on + join(). Keep return values small or return None and communicate large results + through files or shared memory. """ def decorator(func): - manager = multiprocessing.Manager() - results = manager.dict() - - def target(key, *args, **kwargs): - results[key] = return_error(func)(*args, **kwargs) - @wraps(func) def wrapper(*args, **kwargs): - # key object would be uniq in the parent process, but it can't be - # used directly, because when it goes to manager the new object is - # created, so the `id(key)` should be used as the real key - key = object() - process = multiprocessing.Process( - target=target, - args=(id(key),) + args, - kwargs=kwargs, + parent_conn, child_conn = _fork_ctx.Pipe() + process = _fork_ctx.Process( + target=_target, + args=(func, child_conn, args, kwargs), ) process.start() + child_conn.close() process.join(seconds) if process.is_alive(): - # NOTE: killing the process without getting its return code via - # .join() would cause the zombie process to stay until the next - # multiprocessing.Process() run, it is acceptable, since it - # shouldn't end up with many zombie processes, and the - # alternatives like creating the daemon threads to catch the - # zombies don't look like a good fit process.kill() - if id(key) in results: - result = results.pop(id(key)) + process.join() + parent_conn.close() + raise TimeoutError( + f"function {func.__name__} didn't complete in {seconds} seconds" + ) + if parent_conn.poll(): + result = parent_conn.recv() + parent_conn.close() if isinstance(result, Error): result.reraise() - else: - return result - else: - raise TimeoutError("function %s didn't complete in %s seconds" % ( - func.__name__, seconds - )) + return result + parent_conn.close() + if process.exitcode and process.exitcode < 0: + raise ChildProcessError( + f"function {func.__name__} was killed by signal " + f"{-process.exitcode}" + ) + raise ChildProcessError( + f"function {func.__name__} exited unexpectedly " + f"(exit code {process.exitcode})" + ) return wrapper diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..01aed7b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "kill-timeout" +version = "0.1.0" +description = "Limit the function execution time" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + {name = "Andrew Grigorev", email = "andrew@ei-grad.ru"}, +] +keywords = ["kill", "timeout", "multiprocessing", "decorator"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "tblib>=3.0", +] + +[project.urls] +Homepage = "https://github.com/ei-grad/kill-timeout" +"Bug Tracker" = "https://github.com/ei-grad/kill-timeout/issues" +"Source Code" = "https://github.com/ei-grad/kill-timeout" + +[tool.setuptools] +py-modules = ["kill_timeout"] + +[tool.pytest.ini_options] +testpaths = ["tests.py"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a2f137b..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -tblib diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bde10d4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,26 +0,0 @@ -[metadata] -name = kill_timeout -author = Andrew Grigorev -author-email = andrew@ei-grad.ru -summary = Limit the function execution time -description-file = README.md -description-content-type = text/markdown; charset=UTF-8 -home-page = https://github.com/ei-grad/kill-timeout -project_urls = - Bug Tracker = https://github.com/ei-grad/kill-timeout/issues - Source Code = https://github.com/ei-grad/kill-timeout.git -license = MIT -classifier = - Development Status :: 4 - Beta - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python -keywords = - kill - timeout -python_requires = >=3.7 - -[files] -modules = - kill_timeout diff --git a/setup.py b/setup.py deleted file mode 100644 index aa2d8a0..0000000 --- a/setup.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - -setup( - setup_requires=['pbr'], - pbr=True, -)