interruptible
Guaranteed, transparent Ctrl+C for Python programs.
interruptible makes Ctrl+C work promptly even when your program is blocked
inside a native C/Rust extension that never returns to the Python bytecode
evaluator -- for example an HTTP request through a library that does not poll
for interrupts, a database driver, or a long-running computation in NumPy.
import sys
import interruptible
def main():
# Anything at all here, including blocking C calls.
...
return 0
if __name__ == "__main__":
sys.exit(interruptible.run(main))
Press Ctrl+C and the program stops, now.
Why this is needed
Python delivers signals by setting a flag that the interpreter checks between
bytecodes. If your code is blocked inside a C extension, the interpreter is
never reached, so KeyboardInterrupt is not raised until the call returns --
which may be never. On Windows it is worse: a blocking call is not interrupted
at all.
There is no way to fix this from inside the blocked process. So interruptible
runs your main in a child process. The parent does nothing but wait, so it is
always able to react to Ctrl+C immediately; on interrupt it forwards the signal
to the child and, if the child is one of the ill-behaved ones, forcibly
terminates the child's entire process tree.
Transparency
The design goal is that a wrapped program is indistinguishable from an unwrapped one, apart from interrupts always working:
| Behaviour | interruptible result |
|---|---|
main() returns 0 |
exit code 0 |
main() returns N |
exit code N |
main() raises |
traceback on stderr, exit code 1 |
sys.exit(N) |
exit code N |
| Ctrl+C, child exits on its own | exit code 130 (128 + SIGINT) |
SIGTERM |
exit code 143 (128 + SIGTERM) |
| Ctrl+C, child ignores it | child tree killed after kill_timeout; exit code 130 |
timeout= expires |
child signalled, exit code 127 |
| stdout/stderr | forwarded live to the parent's streams |
The signal the user sent determines the exit code, so Ctrl+C always reports
130, exactly as an unwrapped program would.
Install
pip install interruptible
No runtime dependencies. Python 3.10+.
Usage
Function
sys.exit(interruptible.run(main, timeout=60, kill_timeout=5.0))
Decorator
@interruptible.interruptible(timeout=300)
def main():
...
if __name__ == "__main__":
sys.exit(main())
API
def run(
target: Callable[..., int | None],
*args,
timeout: float | None = None,
kill_timeout: float = 5.0,
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
inherit_environ: bool = True,
**kwargs,
) -> int: ...
target-- the function to run. On spawn platforms (Windows, macOS) it must be picklable, which means a module-level function in a real file; see the FAQ.timeout-- maximum runtime in seconds (None= unlimited). On expiry the child is signalled and127is returned.kill_timeout-- seconds to wait for a graceful exit after forwarding a signal before force-killing the child's process tree.passthrough_signals-- signals received by the parent that are forwarded to the child.inherit_environ-- whether the child inherits the environment.
FAQ
Why a subprocess? Because there is no other way to be responsive while the main thread is stuck inside native code. A thread or an asyncio task cannot help: they cannot preempt a blocked C call either.
Doesn't signal.set_wakeup_fd / faulthandler solve this? Those change how
the interpreter notices signals; they do not make a blocked native call
return.
What about child processes I spawn myself? On POSIX the child runs in its own process group, and the whole group is signalled during cleanup. On Windows the child is attached to a Job Object configured to kill all member processes when the job closes, so grandchildren die too.
Nested use. If run() is called from inside a child (detected via the
INTERRUPTIBLE_CHILD environment variable), the target runs inline instead of
spawning another process.
Why did I get Can't pickle local object? On Windows, and on macOS since
Python 3.8, the child is started with spawn, which sends the target to the new
interpreter by pickling it by module and name. Two things therefore cannot be
used as a target:
- a nested function, lambda, closure, or bound method defined inside another function;
- a function defined in a
python -cstring or an interactive session, because its__main__has no importable name.
Both fail with AttributeError once the child tries to unpickle the target. Use
a module-level function in a real file:
# do this
def main():
...
if __name__ == '__main__':
sys.exit(interruptible.run(main))
# not this -- main cannot be pickled
if __name__ == '__main__':
def main():
...
sys.exit(interruptible.run(main))
Platform notes
run() uses the platform's default multiprocessing start method. That is
spawn on Windows and macOS and fork on Linux, and the difference matters:
spawn requires a picklable (module-level) target, while fork accepts
anything. Forcing fork on Linux was deliberately rejected -- it is unsafe in
a process with threads and it would hide this requirement from anyone
developing on Linux. To check your code under the stricter spawn rules on
Linux, set the start method before calling run():
import multiprocessing
multiprocessing.set_start_method('spawn')
- Spawn platforms (Windows, macOS) -- the target must be picklable.
- POSIX -- the parent forwards the exact signal it received (so the child
sees a normal
KeyboardInterrupt), thenSIGKILLs the process group if the child has not exited withinkill_timeout. - Windows -- a child started by
multiprocessingshares the parent's console process group, so the console delivers Ctrl+C to it directly; no explicit forwarding is needed. The Job Object guarantees the whole tree is cleaned up if the child ignores it.
License
MIT
Release files for interruptible 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| interruptible-1.0.0.tar.gz | 22.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| interruptible-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 38.4 kB
Release files / interruptible-1.0.0.tar.gz
| Download URL | interruptible-1.0.0.tar.gz |
|---|---|
| Size | 22.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e93f1f04c2a3dc4c1220020d2d45040becd52e4fda68e7e4227eff64ff1f2837
|
|
BLAKE2b-256 checksum How to use checksums |
d4ec323a4acca707c9e09feb071186117ccebdb3da0616b1ac76081b4b9c3d73
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / interruptible-1.0.0-py3-none-any.whl
| Download URL | interruptible-1.0.0-py3-none-any.whl |
|---|---|
| Size | 15.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2f706b115fc7431f18829c96f97402c9cf6cf70ec97704c9a18763bf6faffce6
|
|
BLAKE2b-256 checksum How to use checksums |
ba65906f5a4c41a7fcbb6cb7993a691374266f4367dbeea1f38eb49f1b3c22b6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log