interruptible 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interruptible/__init__.py +35 -0
- interruptible/_child.py +118 -0
- interruptible/_posix.py +38 -0
- interruptible/_version.py +24 -0
- interruptible/_windows.py +186 -0
- interruptible/core.py +331 -0
- interruptible-1.0.0.dist-info/METADATA +214 -0
- interruptible-1.0.0.dist-info/RECORD +11 -0
- interruptible-1.0.0.dist-info/WHEEL +5 -0
- interruptible-1.0.0.dist-info/licenses/LICENSE +21 -0
- interruptible-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``interruptible`` -- reliable Ctrl+C for Python programs.
|
|
2
|
+
|
|
3
|
+
Wrap a program's entry point to guarantee that Ctrl+C terminates it promptly,
|
|
4
|
+
even when it is blocked inside a native C/Rust extension that fails to honour
|
|
5
|
+
signals::
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import interruptible
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
... # may block in a C extension
|
|
12
|
+
return 0
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(interruptible.run(main))
|
|
16
|
+
|
|
17
|
+
The goal is transparency: the wrapped program behaves as though this module were
|
|
18
|
+
not present, except that interrupts always work.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .core import DEFAULT_KILL_TIMEOUT, TIMEOUT_EXIT_CODE, interruptible, run
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'run',
|
|
27
|
+
'interruptible',
|
|
28
|
+
'DEFAULT_KILL_TIMEOUT',
|
|
29
|
+
'TIMEOUT_EXIT_CODE',
|
|
30
|
+
]
|
|
31
|
+
try: # pragma: no cover - the version is generated by setuptools-scm
|
|
32
|
+
from ._version import __version__, __version_tuple__
|
|
33
|
+
except ImportError: # pragma: no cover
|
|
34
|
+
__version__ = '0.0.0.dev0'
|
|
35
|
+
__version_tuple__ = (0, 0, 0, 'dev0')
|
interruptible/_child.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Code that runs inside the child process.
|
|
2
|
+
|
|
3
|
+
This module must be importable by a spawned child. On platforms that use the
|
|
4
|
+
``spawn`` start method (notably Windows and macOS since 3.8) the child
|
|
5
|
+
re-imports the target as a pickle reference, so everything here must be
|
|
6
|
+
importable without side effects and must not depend on the parent's state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
if sys.platform == 'win32': # pragma: no cover - platform dispatch
|
|
19
|
+
from . import _windows as _platform
|
|
20
|
+
else:
|
|
21
|
+
from . import _posix as _platform
|
|
22
|
+
|
|
23
|
+
__all__ = ['child_main', 'run_inline']
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure_child() -> None:
|
|
27
|
+
"""Platform hook run in the child before the target starts."""
|
|
28
|
+
try:
|
|
29
|
+
_platform.configure_child()
|
|
30
|
+
except Exception: # pragma: no cover - never let setup break the child
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def child_main(
|
|
35
|
+
target: Callable[..., Any],
|
|
36
|
+
args: tuple[Any, ...],
|
|
37
|
+
kwargs: dict[str, Any],
|
|
38
|
+
inherit_environ: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Entry point executed in the child process.
|
|
41
|
+
|
|
42
|
+
The target's return value becomes the child's exit code. ``multiprocessing``
|
|
43
|
+
ignores a target's return value, so the code is applied via ``sys.exit``.
|
|
44
|
+
A ``SystemExit`` raised by the target is honoured (its code is used), and any
|
|
45
|
+
other exception is reported to stderr with a traceback before exiting with
|
|
46
|
+
code 1 -- mirroring what an uncaught exception does in a plain Python
|
|
47
|
+
program.
|
|
48
|
+
"""
|
|
49
|
+
# Mark the environment so nested ``interruptible.run`` calls run inline
|
|
50
|
+
# rather than spawning another child. This happens before any environment
|
|
51
|
+
# clearing so the marker is always present in the child.
|
|
52
|
+
os.environ['INTERRUPTIBLE_CHILD'] = '1'
|
|
53
|
+
if not inherit_environ:
|
|
54
|
+
# Present an empty environment to the target, matching what a program
|
|
55
|
+
# started with ``env -i`` would see. The marker is kept so nested runs
|
|
56
|
+
# are still detected.
|
|
57
|
+
marker = {key: os.environ[key] for key in ('INTERRUPTIBLE_CHILD',) if key in os.environ}
|
|
58
|
+
os.environ.clear()
|
|
59
|
+
os.environ.update(marker)
|
|
60
|
+
# Detach into our own process group (POSIX) so the parent can signal the
|
|
61
|
+
# whole subtree -- including any grandchildren -- during cleanup.
|
|
62
|
+
configure_child()
|
|
63
|
+
try:
|
|
64
|
+
result = target(*args, **kwargs)
|
|
65
|
+
except SystemExit as exc:
|
|
66
|
+
sys.exit(normalize_exit_code(exc.code))
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
# Match CPython's behaviour for a program interrupted by Ctrl+C: print
|
|
69
|
+
# the traceback and exit with ``128 + SIGINT``. Reporting 1 here would
|
|
70
|
+
# break the transparency guarantee, since a plain program reports 130.
|
|
71
|
+
traceback.print_exc()
|
|
72
|
+
sys.stderr.flush()
|
|
73
|
+
sys.exit(128 + signal.SIGINT)
|
|
74
|
+
except BaseException:
|
|
75
|
+
traceback.print_exc()
|
|
76
|
+
sys.stderr.flush()
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
sys.exit(normalize_exit_code(result))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_inline(target: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> int:
|
|
82
|
+
"""Run ``target`` in the current process and return its exit code.
|
|
83
|
+
|
|
84
|
+
Used for nested ``run`` calls inside an existing child, where spawning
|
|
85
|
+
another process would be pointless. Exceptions behave as in ``child_main``
|
|
86
|
+
except that they are not swallowed: an uncaught exception is printed and
|
|
87
|
+
reported as exit code 1, matching what the surrounding program would see.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
result = target(*args, **kwargs)
|
|
91
|
+
except SystemExit as exc:
|
|
92
|
+
return normalize_exit_code(exc.code)
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
# As in ``child_main``, an interrupt reports ``128 + SIGINT``.
|
|
95
|
+
traceback.print_exc()
|
|
96
|
+
sys.stderr.flush()
|
|
97
|
+
return 128 + signal.SIGINT
|
|
98
|
+
except BaseException:
|
|
99
|
+
traceback.print_exc()
|
|
100
|
+
sys.stderr.flush()
|
|
101
|
+
return 1
|
|
102
|
+
return normalize_exit_code(result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_exit_code(code: Any) -> int:
|
|
106
|
+
"""Coerce a user-provided exit code into an 8-bit process exit status."""
|
|
107
|
+
if code is None:
|
|
108
|
+
return 0
|
|
109
|
+
if isinstance(code, bool):
|
|
110
|
+
return int(code)
|
|
111
|
+
if isinstance(code, int):
|
|
112
|
+
# POSIX exit statuses are 8-bit; emulate that truncation so behaviour
|
|
113
|
+
# matches a plain ``sys.exit(n)``.
|
|
114
|
+
return code & 0xFF
|
|
115
|
+
# Non-integer, non-None values passed to SystemExit are printed and the
|
|
116
|
+
# process exits with status 1, exactly as CPython does.
|
|
117
|
+
print(code, file=sys.stderr)
|
|
118
|
+
return 1
|
interruptible/_posix.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""POSIX-specific process group helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess # noqa: F401 (imported for typing clarity only)
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ['configure_child', 'kill_tree']
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure_child() -> None:
|
|
13
|
+
"""Run in the child, before the target, to detach its process group.
|
|
14
|
+
|
|
15
|
+
Making the child a process-group leader means we can signal the entire
|
|
16
|
+
group (the child plus anything it spawned) with a single call, which is
|
|
17
|
+
what makes cleanup reliable when the target spawns its own children.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
os.setpgid(0, 0)
|
|
21
|
+
except OSError:
|
|
22
|
+
# Already a group leader, or the platform lacks setpgid. Not fatal:
|
|
23
|
+
# we fall back to signalling the child pid directly.
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def kill_tree(process: Any, sig: int) -> None:
|
|
28
|
+
"""Send ``sig`` to the child's process group, falling back to the pid."""
|
|
29
|
+
pid = process.pid
|
|
30
|
+
if pid is None: # pragma: no cover - defensive
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
os.killpg(pid, sig)
|
|
34
|
+
except OSError:
|
|
35
|
+
try:
|
|
36
|
+
os.kill(pid, sig)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Windows-specific helpers built on ctypes (no third-party dependencies).
|
|
2
|
+
|
|
3
|
+
Two problems must be solved on Windows:
|
|
4
|
+
|
|
5
|
+
1. Ctrl+C must reach the child. A child started by ``multiprocessing`` on
|
|
6
|
+
Windows is created without ``CREATE_NEW_PROCESS_GROUP``, so it shares the
|
|
7
|
+
parent's console process group. A console Ctrl+C is therefore delivered by
|
|
8
|
+
Windows to the child *directly*, at the same moment the parent sees it, and
|
|
9
|
+
the child raises ``KeyboardInterrupt`` as a normal program would. No
|
|
10
|
+
explicit forwarding is needed on the normal path; ``send_ctrl_event``
|
|
11
|
+
exists only for a child deliberately started in its own group, and
|
|
12
|
+
``forward`` does not call it.
|
|
13
|
+
|
|
14
|
+
2. When the child ignores the signal (the entire reason this package exists), we
|
|
15
|
+
must terminate the *whole process tree*. ``Process.kill`` only kills the
|
|
16
|
+
direct child; grandchildren survive. We attach the child to a Windows Job
|
|
17
|
+
Object with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE``, so the tree dies with the
|
|
18
|
+
job.
|
|
19
|
+
|
|
20
|
+
Everything here is imported unconditionally, so it must work (as inert stubs)
|
|
21
|
+
on non-Windows platforms.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ctypes
|
|
27
|
+
import sys
|
|
28
|
+
from ctypes import wintypes
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'create_job',
|
|
33
|
+
'assign_process_to_job',
|
|
34
|
+
'close_job',
|
|
35
|
+
'configure_child',
|
|
36
|
+
'kill_tree',
|
|
37
|
+
'send_ctrl_event',
|
|
38
|
+
'IS_WINDOWS',
|
|
39
|
+
]
|
|
40
|
+
IS_WINDOWS = sys.platform == 'win32'
|
|
41
|
+
|
|
42
|
+
# Access rights used by OpenProcess.
|
|
43
|
+
PROCESS_SET_QUOTA = 0x0100
|
|
44
|
+
PROCESS_TERMINATE = 0x0001
|
|
45
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
46
|
+
|
|
47
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
48
|
+
JobObjectExtendedLimitInformation = 9
|
|
49
|
+
CTRL_C_EVENT = 0
|
|
50
|
+
CTRL_BREAK_EVENT = 1
|
|
51
|
+
if IS_WINDOWS: # pragma: no cover - Windows only
|
|
52
|
+
_kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
53
|
+
|
|
54
|
+
class _IO_COUNTERS(ctypes.Structure):
|
|
55
|
+
_fields_ = [
|
|
56
|
+
('ReadOperationCount', ctypes.c_ulonglong),
|
|
57
|
+
('WriteOperationCount', ctypes.c_ulonglong),
|
|
58
|
+
('OtherOperationCount', ctypes.c_ulonglong),
|
|
59
|
+
('ReadTransferCount', ctypes.c_ulonglong),
|
|
60
|
+
('WriteTransferCount', ctypes.c_ulonglong),
|
|
61
|
+
('OtherTransferCount', ctypes.c_ulonglong),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
65
|
+
_fields_ = [
|
|
66
|
+
('PerProcessUserTimeLimit', wintypes.LARGE_INTEGER),
|
|
67
|
+
('PerJobUserTimeLimit', wintypes.LARGE_INTEGER),
|
|
68
|
+
('LimitFlags', wintypes.DWORD),
|
|
69
|
+
('MinimumWorkingSetSize', ctypes.c_size_t),
|
|
70
|
+
('MaximumWorkingSetSize', ctypes.c_size_t),
|
|
71
|
+
('ActiveProcessLimit', wintypes.DWORD),
|
|
72
|
+
('Affinity', ctypes.c_size_t),
|
|
73
|
+
('PriorityClass', wintypes.DWORD),
|
|
74
|
+
('SchedulingClass', wintypes.DWORD),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
78
|
+
_fields_ = [
|
|
79
|
+
('BasicLimitInformation', _JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
80
|
+
('IoInfo', _IO_COUNTERS),
|
|
81
|
+
('ProcessMemoryLimit', ctypes.c_size_t),
|
|
82
|
+
('JobMemoryLimit', ctypes.c_size_t),
|
|
83
|
+
('PeakProcessMemoryUsed', ctypes.c_size_t),
|
|
84
|
+
('PeakJobMemoryUsed', ctypes.c_size_t),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
_kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
88
|
+
_kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
|
|
89
|
+
_kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
90
|
+
_kernel32.SetInformationJobObject.argtypes = [
|
|
91
|
+
wintypes.HANDLE,
|
|
92
|
+
ctypes.c_int,
|
|
93
|
+
wintypes.LPVOID,
|
|
94
|
+
wintypes.DWORD,
|
|
95
|
+
]
|
|
96
|
+
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
97
|
+
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
98
|
+
_kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
99
|
+
_kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
100
|
+
_kernel32.TerminateJobObject.restype = wintypes.BOOL
|
|
101
|
+
_kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
|
102
|
+
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
103
|
+
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
104
|
+
_kernel32.GenerateConsoleCtrlEvent.restype = wintypes.BOOL
|
|
105
|
+
_kernel32.GenerateConsoleCtrlEvent.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_job() -> int | None:
|
|
109
|
+
"""Create a kill-on-close job object; return its handle, or ``None``."""
|
|
110
|
+
if not IS_WINDOWS: # pragma: no cover
|
|
111
|
+
return None
|
|
112
|
+
handle = _kernel32.CreateJobObjectW(None, None)
|
|
113
|
+
if not handle:
|
|
114
|
+
return None
|
|
115
|
+
info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
116
|
+
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
117
|
+
ok = _kernel32.SetInformationJobObject(
|
|
118
|
+
handle,
|
|
119
|
+
JobObjectExtendedLimitInformation,
|
|
120
|
+
ctypes.byref(info),
|
|
121
|
+
ctypes.sizeof(info),
|
|
122
|
+
)
|
|
123
|
+
if not ok:
|
|
124
|
+
_kernel32.CloseHandle(handle)
|
|
125
|
+
return None
|
|
126
|
+
return handle
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def assign_process_to_job(job: int | None, pid: int) -> bool:
|
|
130
|
+
"""Attach the process (and thus its future children) to ``job``."""
|
|
131
|
+
if not IS_WINDOWS or job is None: # pragma: no cover
|
|
132
|
+
return False
|
|
133
|
+
access = PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION
|
|
134
|
+
process_handle = _kernel32.OpenProcess(access, False, pid)
|
|
135
|
+
if not process_handle:
|
|
136
|
+
return False
|
|
137
|
+
try:
|
|
138
|
+
return bool(_kernel32.AssignProcessToJobObject(job, process_handle))
|
|
139
|
+
finally:
|
|
140
|
+
_kernel32.CloseHandle(process_handle)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def close_job(job: int | None) -> None:
|
|
144
|
+
"""Close the job handle, which kills any surviving processes in it."""
|
|
145
|
+
if not IS_WINDOWS or job is None: # pragma: no cover
|
|
146
|
+
return
|
|
147
|
+
_kernel32.CloseHandle(job)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def send_ctrl_event(process_group_id: int, sig: int) -> bool:
|
|
151
|
+
"""Send a console control event to a process group.
|
|
152
|
+
|
|
153
|
+
``GenerateConsoleCtrlEvent`` addresses a process *group* id, and it only
|
|
154
|
+
succeeds when the caller and the target group share a console. Returns
|
|
155
|
+
``True`` when the event was delivered.
|
|
156
|
+
|
|
157
|
+
Only meaningful for a group created with ``CREATE_NEW_PROCESS_GROUP``, whose
|
|
158
|
+
group id is the pid of the process that created it. ``multiprocessing``
|
|
159
|
+
does not create such a group on Windows, so this is not used for the normal
|
|
160
|
+
path -- the console delivers Ctrl+C to the whole group on its own.
|
|
161
|
+
"""
|
|
162
|
+
if not IS_WINDOWS: # pragma: no cover
|
|
163
|
+
return False
|
|
164
|
+
event = CTRL_C_EVENT if sig == 2 else CTRL_BREAK_EVENT
|
|
165
|
+
return bool(_kernel32.GenerateConsoleCtrlEvent(event, process_group_id))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def configure_child() -> None:
|
|
169
|
+
"""No-op on Windows; console/group handling is done by the parent."""
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def kill_tree(process: Any, sig: int) -> None:
|
|
174
|
+
"""Force-terminate the child tree.
|
|
175
|
+
|
|
176
|
+
Used only after the child has ignored the graceful signal for
|
|
177
|
+
``kill_timeout`` seconds. The job object is closed by the caller, which
|
|
178
|
+
kills the whole tree; this additionally kills the direct child so a failure
|
|
179
|
+
to create/assign the job still cleans up.
|
|
180
|
+
"""
|
|
181
|
+
if not IS_WINDOWS: # pragma: no cover
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
process.kill()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
interruptible/core.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Core implementation of the transparent Ctrl+C guarantee.
|
|
2
|
+
|
|
3
|
+
Design principle: *the module should be invisible*. A program wrapped with
|
|
4
|
+
``interruptible.run`` must behave, from the outside, exactly like a program
|
|
5
|
+
without it -- same exit codes, same output, same signal semantics -- with the
|
|
6
|
+
single addition that Ctrl+C is honoured *promptly* even when the target is stuck
|
|
7
|
+
inside a native extension that never returns to the bytecode evaluator.
|
|
8
|
+
|
|
9
|
+
How that is achieved:
|
|
10
|
+
|
|
11
|
+
* The target runs in a child process. The parent does almost nothing except
|
|
12
|
+
wait, so it is never blocked inside a native call and always reacts to signals
|
|
13
|
+
immediately.
|
|
14
|
+
* On SIGINT/SIGTERM the parent forwards the *same* signal to the child. A
|
|
15
|
+
well-behaved program (including plain CPython) turns SIGINT into
|
|
16
|
+
``KeyboardInterrupt`` and unwinds normally -- indistinguishable from no
|
|
17
|
+
wrapper at all.
|
|
18
|
+
* If the child does not exit within ``kill_timeout`` -- the case this package
|
|
19
|
+
exists for -- the parent escalates to SIGKILL (POSIX, killing the whole
|
|
20
|
+
process group) or job-object termination (Windows, killing the whole tree).
|
|
21
|
+
* The reported exit code follows the shell convention ``128 + signum`` for
|
|
22
|
+
signal-induced death, matching what the user would have seen without the
|
|
23
|
+
wrapper.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import multiprocessing
|
|
29
|
+
import os
|
|
30
|
+
import signal
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from . import _child
|
|
37
|
+
|
|
38
|
+
if sys.platform == 'win32': # pragma: no cover - platform dispatch
|
|
39
|
+
from . import _windows as _platform
|
|
40
|
+
else:
|
|
41
|
+
from . import _posix as _platform
|
|
42
|
+
|
|
43
|
+
__all__ = ['run', 'interruptible']
|
|
44
|
+
#: Set in the child environment to detect that a run is already inside a
|
|
45
|
+
#: child, so nested ``run`` calls execute inline rather than spawning.
|
|
46
|
+
CHILD_ENV_VAR = 'INTERRUPTIBLE_CHILD'
|
|
47
|
+
#: Exit code reported when the ``timeout`` argument expires.
|
|
48
|
+
TIMEOUT_EXIT_CODE = 127
|
|
49
|
+
#: Default number of seconds to wait for the child to exit gracefully before
|
|
50
|
+
#: escalating to a forced kill.
|
|
51
|
+
DEFAULT_KILL_TIMEOUT = 5.0
|
|
52
|
+
#: How often the parent wakes to check for pending signals or deadline expiry.
|
|
53
|
+
#: Small enough to feel instant to a human pressing Ctrl+C.
|
|
54
|
+
POLL_INTERVAL = 0.05
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(
|
|
58
|
+
target: Callable[..., int | None],
|
|
59
|
+
*args: Any,
|
|
60
|
+
timeout: float | None = None,
|
|
61
|
+
kill_timeout: float = DEFAULT_KILL_TIMEOUT,
|
|
62
|
+
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
|
|
63
|
+
inherit_environ: bool = True,
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> int:
|
|
66
|
+
"""Run ``target(*args, **kwargs)`` with guaranteed interruptibility.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
target: The function to run, typically a program's ``main``.
|
|
70
|
+
*args: Positional arguments forwarded to ``target``.
|
|
71
|
+
timeout: Maximum runtime in seconds, or ``None`` for unlimited. On
|
|
72
|
+
expiry the child is signalled and ``127`` is returned.
|
|
73
|
+
kill_timeout: Seconds to wait after forwarding a signal before forcing
|
|
74
|
+
the child (and its process tree) to die.
|
|
75
|
+
passthrough_signals: Signals that, when received by the parent, are
|
|
76
|
+
forwarded to the child (and escalated if ignored).
|
|
77
|
+
inherit_environ: Whether the child inherits the parent's environment.
|
|
78
|
+
**kwargs: Keyword arguments forwarded to ``target``.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The child's exit code. Signal-induced deaths are reported as
|
|
82
|
+
``128 + signum`` (SIGINT -> 130, SIGTERM -> 143), matching shell and
|
|
83
|
+
CPython conventions. A timeout returns ``127``.
|
|
84
|
+
|
|
85
|
+
Nested calls (i.e. ``run`` executed inside a child) run ``target`` inline.
|
|
86
|
+
|
|
87
|
+
Note:
|
|
88
|
+
On platforms whose start method is ``spawn`` -- Windows, and macOS since
|
|
89
|
+
Python 3.8 -- ``target`` must be **picklable**, which in practice means
|
|
90
|
+
a module-level function. A nested function, lambda, closure, or bound
|
|
91
|
+
method defined inside another function cannot be sent to the child and
|
|
92
|
+
raises ``AttributeError: Can't pickle local object``. Pass a
|
|
93
|
+
module-level function instead::
|
|
94
|
+
|
|
95
|
+
def main():
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
if __name__ == '__main__':
|
|
99
|
+
sys.exit(interruptible.run(main))
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
if os.environ.get(CHILD_ENV_VAR):
|
|
103
|
+
# Already inside a child process; do not nest. Run the target inline
|
|
104
|
+
# and return its exit code, exactly as CPython would from a plain
|
|
105
|
+
# ``main()`` call.
|
|
106
|
+
return _child.run_inline(target, args, kwargs)
|
|
107
|
+
|
|
108
|
+
# Use the platform's default start method. Windows and macOS (since 3.8)
|
|
109
|
+
# use ``spawn``, which requires the target to be picklable -- see the
|
|
110
|
+
# docstring on ``run``. Forcing ``fork`` on POSIX was rejected: it is
|
|
111
|
+
# unsafe in a process with threads, it makes behaviour differ between
|
|
112
|
+
# platforms, and it hides this requirement from anyone developing on Linux.
|
|
113
|
+
ctx = multiprocessing.get_context()
|
|
114
|
+
process = ctx.Process(
|
|
115
|
+
target=_child.child_main,
|
|
116
|
+
args=(target, args, kwargs, inherit_environ),
|
|
117
|
+
daemon=False,
|
|
118
|
+
)
|
|
119
|
+
job = None
|
|
120
|
+
if sys.platform == 'win32': # pragma: no cover - Windows only
|
|
121
|
+
job = windows_job()
|
|
122
|
+
|
|
123
|
+
process.start()
|
|
124
|
+
if job is not None: # pragma: no cover - Windows only
|
|
125
|
+
windows_assign(job, process.pid)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
return wait(process, timeout, kill_timeout, passthrough_signals, job)
|
|
129
|
+
finally:
|
|
130
|
+
# Never leave a zombie or a live grandchild behind.
|
|
131
|
+
cleanup(process, job)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def wait(
|
|
135
|
+
process: Any,
|
|
136
|
+
timeout: float | None,
|
|
137
|
+
kill_timeout: float,
|
|
138
|
+
passthrough_signals: tuple[int, ...],
|
|
139
|
+
job: Any,
|
|
140
|
+
) -> int:
|
|
141
|
+
"""Wait for the child, forwarding signals, with timeout and escalation."""
|
|
142
|
+
state: dict[str, Any] = {'pending_signal': None}
|
|
143
|
+
|
|
144
|
+
def handler(signum: int, _frame: Any) -> None:
|
|
145
|
+
state['pending_signal'] = signum
|
|
146
|
+
|
|
147
|
+
installed: list[tuple[int, Any]] = []
|
|
148
|
+
for sig in passthrough_signals:
|
|
149
|
+
try:
|
|
150
|
+
previous = signal.signal(sig, handler)
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
# Signals that cannot be installed in this context (e.g. SIGTERM is
|
|
153
|
+
# fine, but a non-main thread or an unsupported signum is not).
|
|
154
|
+
continue
|
|
155
|
+
installed.append((sig, previous))
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# Wait for the child. Poll in short slices so that a signal arriving
|
|
159
|
+
# mid-wait is noticed promptly rather than only when a large timeout
|
|
160
|
+
# expires. ``deadline`` is None for an unlimited wait.
|
|
161
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
162
|
+
while True:
|
|
163
|
+
if join(process, POLL_INTERVAL):
|
|
164
|
+
return process.exitcode & 0xFF
|
|
165
|
+
if state['pending_signal'] is not None:
|
|
166
|
+
break
|
|
167
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
pending = state['pending_signal']
|
|
171
|
+
if pending is None:
|
|
172
|
+
# Timed out with no signal: treat as a timeout.
|
|
173
|
+
escalate(process, signal.SIGTERM, kill_timeout, job)
|
|
174
|
+
return TIMEOUT_EXIT_CODE
|
|
175
|
+
|
|
176
|
+
# A signal arrived: forward it, then wait for a graceful exit.
|
|
177
|
+
forward(process, pending, job)
|
|
178
|
+
if join(process, kill_timeout):
|
|
179
|
+
return signal_exit_code(process, pending)
|
|
180
|
+
|
|
181
|
+
# The child ignored the forwarded signal -- force it.
|
|
182
|
+
force_kill(process, job)
|
|
183
|
+
join(process, kill_timeout)
|
|
184
|
+
return signal_exit_code(process, pending)
|
|
185
|
+
finally:
|
|
186
|
+
for sig, previous in installed:
|
|
187
|
+
try:
|
|
188
|
+
signal.signal(sig, previous)
|
|
189
|
+
except (OSError, ValueError): # pragma: no cover - defensive
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def join(process: Any, timeout: float | None) -> bool:
|
|
194
|
+
"""Join the child, returning ``True`` if it exited."""
|
|
195
|
+
process.join(timeout)
|
|
196
|
+
return not process.is_alive()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def forward(process: Any, signum: int, job: Any) -> None:
|
|
200
|
+
"""React to ``signum`` by making the child stop.
|
|
201
|
+
|
|
202
|
+
On Windows a child spawned by ``multiprocessing`` is created in the
|
|
203
|
+
parent's console process group, so a console Ctrl+C is delivered to the
|
|
204
|
+
child *by the console itself*, at the same moment the parent sees it.
|
|
205
|
+
Forwarding is therefore unnecessary for the common case and harmful when
|
|
206
|
+
attempted incorrectly: ``GenerateConsoleCtrlEvent`` takes a process *group*
|
|
207
|
+
id, not a pid, and an invalid id makes the call fail.
|
|
208
|
+
|
|
209
|
+
So the child is given the chance to handle the interrupt on its own, which
|
|
210
|
+
is what a well-behaved program does (raising ``KeyboardInterrupt``). If it
|
|
211
|
+
is still alive when the caller's ``kill_timeout`` expires, the caller
|
|
212
|
+
force-kills the process tree.
|
|
213
|
+
|
|
214
|
+
The one case needing an explicit event is a child that is *not* in our
|
|
215
|
+
console group, which is what ``GenerateConsoleCtrlEvent`` is for; it is
|
|
216
|
+
attempted with the child's process group id, and a failure simply leaves
|
|
217
|
+
the escalation path to clean up.
|
|
218
|
+
"""
|
|
219
|
+
if sys.platform == 'win32': # pragma: no cover - Windows only
|
|
220
|
+
# Nothing to forward: see the docstring above. The child has already
|
|
221
|
+
# been signalled by the console if it shares our console group, and the
|
|
222
|
+
# caller escalates if it is still alive after ``kill_timeout``.
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
_platform.kill_tree(process, signum)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def escalate(process: Any, signum: int, kill_timeout: float, job: Any) -> None:
|
|
229
|
+
"""Forward ``signum`` and force-kill if the child lingers."""
|
|
230
|
+
forward(process, signum, job)
|
|
231
|
+
if not join(process, kill_timeout):
|
|
232
|
+
force_kill(process, job)
|
|
233
|
+
join(process, kill_timeout)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def force_kill(process: Any, job: Any) -> None:
|
|
237
|
+
"""Unconditionally terminate the child and its descendants."""
|
|
238
|
+
if sys.platform == 'win32': # pragma: no cover - Windows only
|
|
239
|
+
windows_close_job(job)
|
|
240
|
+
_platform.kill_tree(process, signal.SIGTERM)
|
|
241
|
+
return
|
|
242
|
+
_platform.kill_tree(process, signal.SIGKILL)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def signal_exit_code(process: Any, signum: int) -> int:
|
|
246
|
+
"""Return the exit code to report after a signal-triggered shutdown.
|
|
247
|
+
|
|
248
|
+
Transparency means reporting what a plain program would have reported: the
|
|
249
|
+
signal *the user sent* (so Ctrl+C always yields 130), regardless of which
|
|
250
|
+
signal happened to actually kill the child during cleanup.
|
|
251
|
+
"""
|
|
252
|
+
return 128 + int(signum)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cleanup(process: Any, job: Any) -> None:
|
|
256
|
+
"""Ensure no child or grandchild survives this call."""
|
|
257
|
+
if process.is_alive(): # pragma: no cover - defensive
|
|
258
|
+
force_kill(process, job)
|
|
259
|
+
join(process, DEFAULT_KILL_TIMEOUT)
|
|
260
|
+
if sys.platform == 'win32': # pragma: no cover - Windows only
|
|
261
|
+
windows_close_job(job)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def interruptible(
|
|
265
|
+
*dargs: Any,
|
|
266
|
+
timeout: float | None = None,
|
|
267
|
+
kill_timeout: float = DEFAULT_KILL_TIMEOUT,
|
|
268
|
+
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
|
|
269
|
+
inherit_environ: bool = True,
|
|
270
|
+
**dkwargs: Any,
|
|
271
|
+
) -> Callable[..., Any]:
|
|
272
|
+
"""Decorator form of :func:`run`.
|
|
273
|
+
|
|
274
|
+
Can be used bare (::
|
|
275
|
+
|
|
276
|
+
@interruptible
|
|
277
|
+
def main(): ...
|
|
278
|
+
|
|
279
|
+
) or with configuration::
|
|
280
|
+
|
|
281
|
+
@interruptible(timeout=60)
|
|
282
|
+
def main(): ...
|
|
283
|
+
|
|
284
|
+
The decorated function returns the child's exit code as an ``int``.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
def decorate(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
288
|
+
def wrapper(*args: Any, **kwargs: Any) -> int:
|
|
289
|
+
return run(
|
|
290
|
+
func,
|
|
291
|
+
*args,
|
|
292
|
+
timeout=timeout,
|
|
293
|
+
kill_timeout=kill_timeout,
|
|
294
|
+
passthrough_signals=passthrough_signals,
|
|
295
|
+
inherit_environ=inherit_environ,
|
|
296
|
+
**kwargs,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
wrapper.__wrapped__ = func # type: ignore[attr-defined]
|
|
300
|
+
wrapper.__name__ = getattr(func, '__name__', 'wrapper')
|
|
301
|
+
wrapper.__doc__ = getattr(func, '__doc__', None)
|
|
302
|
+
wrapper.__module__ = getattr(func, '__module__', wrapper.__module__)
|
|
303
|
+
return wrapper
|
|
304
|
+
|
|
305
|
+
if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
|
|
306
|
+
# Used bare: @interruptible
|
|
307
|
+
return decorate(dargs[0])
|
|
308
|
+
return decorate
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Imported here rather than at module import time so that a missing/broken
|
|
312
|
+
# ctypes binding cannot break the package on Windows until it is actually used.
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def windows_job() -> Any: # pragma: no cover - Windows only
|
|
316
|
+
from . import _windows
|
|
317
|
+
|
|
318
|
+
return _windows.create_job()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def windows_assign(job: Any, pid: int | None) -> None: # pragma: no cover
|
|
322
|
+
from . import _windows
|
|
323
|
+
|
|
324
|
+
if pid is not None:
|
|
325
|
+
_windows.assign_process_to_job(job, pid)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def windows_close_job(job: Any) -> None: # pragma: no cover - Windows only
|
|
329
|
+
from . import _windows
|
|
330
|
+
|
|
331
|
+
_windows.close_job(job)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interruptible
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Guaranteed, transparent Ctrl+C for Python programs
|
|
5
|
+
Author-email: David Manthey <manthey@orbitals.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/manthey/interruptible
|
|
8
|
+
Project-URL: Repository, https://github.com/manthey/interruptible
|
|
9
|
+
Project-URL: Issues, https://github.com/manthey/interruptible/issues
|
|
10
|
+
Keywords: ctrl-c,sigint,signal,interrupt,keyboardinterrupt,subprocess,timeout
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Operating System Kernels
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# interruptible
|
|
30
|
+
|
|
31
|
+
**Guaranteed, transparent Ctrl+C for Python programs.**
|
|
32
|
+
|
|
33
|
+
`interruptible` makes Ctrl+C work *promptly* even when your program is blocked
|
|
34
|
+
inside a native C/Rust extension that never returns to the Python bytecode
|
|
35
|
+
evaluator -- for example an HTTP request through a library that does not poll
|
|
36
|
+
for interrupts, a database driver, or a long-running computation in NumPy.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import sys
|
|
40
|
+
import interruptible
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
# Anything at all here, including blocking C calls.
|
|
44
|
+
...
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(interruptible.run(main))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Press Ctrl+C and the program stops, now.
|
|
52
|
+
|
|
53
|
+
## Why this is needed
|
|
54
|
+
|
|
55
|
+
Python delivers signals by setting a flag that the interpreter checks *between
|
|
56
|
+
bytecodes*. If your code is blocked inside a C extension, the interpreter is
|
|
57
|
+
never reached, so `KeyboardInterrupt` is not raised until the call returns --
|
|
58
|
+
which may be never. On Windows it is worse: a blocking call is not interrupted
|
|
59
|
+
at all.
|
|
60
|
+
|
|
61
|
+
There is no way to fix this from inside the blocked process. So `interruptible`
|
|
62
|
+
runs your `main` in a child process. The parent does nothing but wait, so it is
|
|
63
|
+
always able to react to Ctrl+C immediately; on interrupt it forwards the signal
|
|
64
|
+
to the child and, if the child is one of the ill-behaved ones, forcibly
|
|
65
|
+
terminates the child's entire process tree.
|
|
66
|
+
|
|
67
|
+
## Transparency
|
|
68
|
+
|
|
69
|
+
The design goal is that a wrapped program is indistinguishable from an unwrapped
|
|
70
|
+
one, apart from interrupts always working:
|
|
71
|
+
|
|
72
|
+
| Behaviour | `interruptible` result |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `main()` returns `0` | exit code `0` |
|
|
75
|
+
| `main()` returns `N` | exit code `N` |
|
|
76
|
+
| `main()` raises | traceback on stderr, exit code `1` |
|
|
77
|
+
| `sys.exit(N)` | exit code `N` |
|
|
78
|
+
| Ctrl+C, child exits on its own | exit code `130` (`128 + SIGINT`) |
|
|
79
|
+
| `SIGTERM` | exit code `143` (`128 + SIGTERM`) |
|
|
80
|
+
| Ctrl+C, child ignores it | child tree killed after `kill_timeout`; exit code `130` |
|
|
81
|
+
| `timeout=` expires | child signalled, exit code `127` |
|
|
82
|
+
| stdout/stderr | forwarded live to the parent's streams |
|
|
83
|
+
|
|
84
|
+
The signal the *user* sent determines the exit code, so Ctrl+C always reports
|
|
85
|
+
`130`, exactly as an unwrapped program would.
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pip install interruptible
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
No runtime dependencies. Python 3.10+.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Function
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
sys.exit(interruptible.run(main, timeout=60, kill_timeout=5.0))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Decorator
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@interruptible.interruptible(timeout=300)
|
|
107
|
+
def main():
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### API
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
def run(
|
|
118
|
+
target: Callable[..., int | None],
|
|
119
|
+
*args,
|
|
120
|
+
timeout: float | None = None,
|
|
121
|
+
kill_timeout: float = 5.0,
|
|
122
|
+
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
|
|
123
|
+
inherit_environ: bool = True,
|
|
124
|
+
**kwargs,
|
|
125
|
+
) -> int: ...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
* `target` -- the function to run. On spawn platforms (Windows, macOS) it must
|
|
129
|
+
be picklable, which means a module-level function in a real file; see the FAQ.
|
|
130
|
+
* `timeout` -- maximum runtime in seconds (`None` = unlimited). On expiry the
|
|
131
|
+
child is signalled and `127` is returned.
|
|
132
|
+
* `kill_timeout` -- seconds to wait for a graceful exit after forwarding a
|
|
133
|
+
signal before force-killing the child's process tree.
|
|
134
|
+
* `passthrough_signals` -- signals received by the parent that are forwarded to
|
|
135
|
+
the child.
|
|
136
|
+
* `inherit_environ` -- whether the child inherits the environment.
|
|
137
|
+
|
|
138
|
+
## FAQ
|
|
139
|
+
|
|
140
|
+
**Why a subprocess?** Because there is no other way to be responsive while the
|
|
141
|
+
main thread is stuck inside native code. A thread or an asyncio task cannot
|
|
142
|
+
help: they cannot preempt a blocked C call either.
|
|
143
|
+
|
|
144
|
+
**Doesn't `signal.set_wakeup_fd` / `faulthandler` solve this?** Those change how
|
|
145
|
+
the *interpreter* notices signals; they do not make a blocked native call
|
|
146
|
+
return.
|
|
147
|
+
|
|
148
|
+
**What about child processes I spawn myself?** On POSIX the child runs in its
|
|
149
|
+
own process group, and the whole group is signalled during cleanup. On Windows
|
|
150
|
+
the child is attached to a Job Object configured to kill all member processes
|
|
151
|
+
when the job closes, so grandchildren die too.
|
|
152
|
+
|
|
153
|
+
**Nested use.** If `run()` is called from inside a child (detected via the
|
|
154
|
+
`INTERRUPTIBLE_CHILD` environment variable), the target runs inline instead of
|
|
155
|
+
spawning another process.
|
|
156
|
+
|
|
157
|
+
**Why did I get `Can't pickle local object`?** On Windows, and on macOS since
|
|
158
|
+
Python 3.8, the child is started with `spawn`, which sends the target to the new
|
|
159
|
+
interpreter by pickling it *by module and name*. Two things therefore cannot be
|
|
160
|
+
used as a target:
|
|
161
|
+
|
|
162
|
+
* a nested function, lambda, closure, or bound method defined inside another
|
|
163
|
+
function;
|
|
164
|
+
* a function defined in a `python -c` string or an interactive session, because
|
|
165
|
+
its `__main__` has no importable name.
|
|
166
|
+
|
|
167
|
+
Both fail with `AttributeError` once the child tries to unpickle the target. Use
|
|
168
|
+
a module-level function in a real file:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# do this
|
|
172
|
+
def main():
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
if __name__ == '__main__':
|
|
176
|
+
sys.exit(interruptible.run(main))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# not this -- main cannot be pickled
|
|
180
|
+
if __name__ == '__main__':
|
|
181
|
+
def main():
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
sys.exit(interruptible.run(main))
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Platform notes
|
|
188
|
+
|
|
189
|
+
`run()` uses the platform's default `multiprocessing` start method. That is
|
|
190
|
+
`spawn` on Windows and macOS and `fork` on Linux, and the difference matters:
|
|
191
|
+
`spawn` requires a picklable (module-level) target, while `fork` accepts
|
|
192
|
+
anything. Forcing `fork` on Linux was deliberately rejected -- it is unsafe in
|
|
193
|
+
a process with threads and it would hide this requirement from anyone
|
|
194
|
+
developing on Linux. To check your code under the stricter `spawn` rules on
|
|
195
|
+
Linux, set the start method before calling `run()`:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import multiprocessing
|
|
199
|
+
|
|
200
|
+
multiprocessing.set_start_method('spawn')
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
* **Spawn platforms (Windows, macOS)** -- the target must be picklable.
|
|
204
|
+
* **POSIX** -- the parent forwards the exact signal it received (so the child
|
|
205
|
+
sees a normal `KeyboardInterrupt`), then `SIGKILL`s the process group if the
|
|
206
|
+
child has not exited within `kill_timeout`.
|
|
207
|
+
* **Windows** -- a child started by `multiprocessing` shares the parent's
|
|
208
|
+
console process group, so the console delivers Ctrl+C to it directly; no
|
|
209
|
+
explicit forwarding is needed. The Job Object guarantees the whole tree is
|
|
210
|
+
cleaned up if the child ignores it.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interruptible/__init__.py,sha256=IONX1mMHo5ghuoeTdERIkd6b-PTkDSRWncbewvSQ4d0,1009
|
|
2
|
+
interruptible/_child.py,sha256=Tl5YAdKiOBKKQsCDySDIerMAUl53IBIan42nH2wZc1U,4501
|
|
3
|
+
interruptible/_posix.py,sha256=NLcbdMCPFAfDEGy1ONwIQEqnpRHjmqJfkhdKUxtNCyM,1127
|
|
4
|
+
interruptible/_version.py,sha256=JAAyU3al4wmBf3BF-Umm1J_GrCIT-yS_yWEGHqKRVHc,520
|
|
5
|
+
interruptible/_windows.py,sha256=0TBy8kZQvzcOjdA6PS1Y4_OItIWre0kWiY6oWfij3jY,7092
|
|
6
|
+
interruptible/core.py,sha256=uqG2PPHEXmgAs8zWk9TaTMtiNcW_BU6j_fqv506AwxA,12573
|
|
7
|
+
interruptible-1.0.0.dist-info/licenses/LICENSE,sha256=7KeTyrmHpDRxetbz5o2eMV6Ss4rcf2A9mG_UqTgZbf0,1070
|
|
8
|
+
interruptible-1.0.0.dist-info/METADATA,sha256=eowfPcoO3BfazrskKVUPUxMtAUUvFtUt0KkhoOvQ15A,7446
|
|
9
|
+
interruptible-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
interruptible-1.0.0.dist-info/top_level.txt,sha256=rNthht8IykZLVulnlj_RWLcf4BSvoi2EsE5sKYZXlEA,14
|
|
11
|
+
interruptible-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Manthey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
interruptible
|