Metadata-Version: 2.4
Name: interruptible-threading
Version: 0.0.1
Summary: Cooperative, prompt thread interruption for CPython (Linux / Darwin)
Home-page: https://github.com/smacke/python-interruptible-threads
Author: Stephen Macke
Author-email: stephen.macke@gmail.com
License: BSD-3-Clause
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Natural Language :: English
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Topic :: Software Development :: Libraries
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8
License-File: docs/LICENSE.txt
Provides-Extra: test
Requires-Dist: black<24; extra == "test"
Requires-Dist: isort; extra == "test"
Requires-Dist: mypy; extra == "test"
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: pytest-timeout; extra == "test"
Requires-Dist: ruff; extra == "test"
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: pycln; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: versioneer; extra == "dev"
Requires-Dist: black<24; extra == "dev"
Requires-Dist: isort; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: pytest-timeout; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Dynamic: license-file

# Python Interruptible Threads

Cooperative, prompt thread interruption for CPython. Call `thread.interrupt()` and a
target thread raises an exception (`ThreadInterrupted` by default) — even when it is
parked in `time.sleep`, `select`, `asyncio.sleep`, an `Event`/`Queue`/`Condition` wait,
or a (helper-wrapped) blocking socket read.

It uses `ctypes` to reach `PyThreadState_SetAsyncExc` and patches a curated set of
stdlib blocking primitives, so it works only on **CPython** and **POSIX (Linux / Darwin)**.

```python
from interruptible_threading import InterruptibleThread, ThreadInterrupted
import time

InterruptibleThread.install_patches()

def sleep_forever():
    try:
        while True:
            time.sleep(10)
    except ThreadInterrupted:
        print("interrupted")

t = InterruptibleThread(target=sleep_forever, daemon=True)
t.start()
...
t.interrupt()   # output: "interrupted"
```

> **Back-compat:** earlier versions raised `KeyboardInterrupt`. To keep that behavior,
> `InterruptibleThread.install_patches(interrupt_exc=KeyboardInterrupt)`, or
> `install_patches(legacy_keyboardinterrupt=True)` to deliver an exception caught by
> *both* `ThreadInterrupted` and `KeyboardInterrupt` handlers.

## How it works

Pure-Python code is interrupted with `PyThreadState_SetAsyncExc`, an async exception
that fires at the next bytecode boundary. That cannot break a thread sitting in a
C-level blocking call, so `install_patches()` replaces `time.sleep`,
`selectors.DefaultSelector`, `select.select`, and `threading.Condition.wait` (which
also covers `Event.wait` and `queue.Queue`) with versions that wake promptly via a
per-thread self-pipe / chunked polling.

The design rests on a single **durable per-thread "interrupt pending" flag**.
`interrupt()` sets the flag first (under one lock), then issues a wakeup nudge; every
blocking primitive checks the flag *before* parking and *again* after waking. This
removes the time-of-check/time-of-use races inherent in choosing a delivery path from
transient state, and makes interrupts maskable and pollable.

### Why not `signal.pthread_kill`?

`pthread_kill` can unblock a syscall but cannot deliver an *exception* to a worker
thread: CPython runs Python-level signal handlers only on the main thread, and PEP 475's
EINTR auto-retry loops call `PyErr_CheckSignals()` — a no-op off the main thread — without
consulting `tstate->async_exc`, so the syscall is transparently retried. The self-pipe +
cooperative-primitive approach is the only way to get prompt, exception-bearing
interruption of worker threads on CPython.

## API

| Name | Purpose |
| --- | --- |
| `InterruptibleThread(...)` | `threading.Thread` subclass with `.interrupt(recursive=False)`. |
| `InterruptibleThread.install_patches(interrupt_exc=ThreadInterrupted, legacy_keyboardinterrupt=False, monkeypatch_socket=False)` | Install the stdlib patches (process-wide). |
| `InterruptibleThread.uninstall_patches()` | Restore the originals. |
| `InterruptibleThread.run_interruptible(coro)` | Run a coroutine via `asyncio.run` with clean, cancellation-based interruption. |
| `ThreadInterrupted` | Default interrupt exception (subclass of `BaseException`). |
| `is_interrupted(thread=None)` | Whether an interrupt is pending (non-consuming). |
| `clear_interrupt()` | Consume a pending interrupt without raising; returns whether one was pending. |
| `check_interrupt()` / `interruptible_checkpoint()` | Raise if pending and not masked — for CPU-bound loops. |
| `periodic_checkpoint(every=N)` | Context manager yielding a `.tick()` that checkpoints every N calls. |
| `critical_section()` / `interrupts_disabled()` | Defer delivery during cleanup; deliver on exit. |
| `interruptible_recv/send/accept(sock, ...)` | Interruptible blocking socket ops. |
| `set_poll_interval(seconds)` | Tune the chunked-poll wakeup latency (default 50 ms). |

### Masking critical sections

```python
from interruptible_threading import critical_section

with critical_section():
    commit()          # interrupts that arrive here are deferred...
release_resources()   # ...and delivered exactly once when the block exits
```

### CPU-bound loops

```python
from interruptible_threading import periodic_checkpoint

with periodic_checkpoint(every=1000) as ck:
    for row in huge_iterable:
        ck.tick()     # raises ThreadInterrupted promptly once interrupted
        crunch(row)
```

### asyncio

```python
import asyncio
from interruptible_threading import InterruptibleThread

def worker():
    try:
        InterruptibleThread.run_interruptible(main_coro())
    except ThreadInterrupted:
        print("cancelled cleanly")
```

`run_interruptible` cancels the loop's tasks via `call_soon_threadsafe` (proper
`finally` / `async with` unwind) instead of injecting an exception into the selector.

## Limitations

- **CPython + POSIX only.** Relies on `PyThreadState_SetAsyncExc`, `os.pipe`, and `select`.
- **Uncovered blocking calls** stay blocked until they return: synchronous regular-file
  disk I/O (`open().read()`, `os.read` on files), raw blocking `socket.recv` (use the
  `interruptible_recv` helpers or `monkeypatch_socket=True`), `os.waitpid`, and
  `Lock.acquire` on a builtin lock. The pending flag is honored at the next patched
  primitive / checkpoint, but the in-progress call is not broken.
- **C extensions that release the GIL and never re-enter Python** (heavy NumPy kernels,
  compiled crypto) cannot be preempted; only cooperative `check_interrupt()` helps.
- **Chunked-poll primitives** (`Event`/`Queue`/`Condition`) have up to `_POLL_INTERVAL`
  (default 50 ms) latency, tunable via `set_poll_interval`.
- **Async injection lands at an arbitrary bytecode boundary** and is un-recallable, so
  `critical_section()` is airtight only for the flag-driven paths; prefer
  checkpoints/blocking primitives inside code that must not be interrupted mid-cleanup.
- **Catch-and-continue clears the flag explicitly.** The interrupt-pending flag is
  durable (so an interrupt is never lost if the target parks in a blocking call before
  async injection can fire). Consequently, if you *catch* `ThreadInterrupted` and want to
  keep running, call `clear_interrupt()` — otherwise the next `check_interrupt()` /
  blocking primitive re-raises. This mirrors Java's `Thread.interrupted()`.
- **`install_patches()` mutates global stdlib state.** Code that captured references
  before install (e.g. `from time import sleep`) keeps the originals. Not for libraries
  to call implicitly.
- **The main thread is intentionally not interruptible** by this mechanism, preserving
  real Ctrl-C / `KeyboardInterrupt` semantics.

## Development

```sh
pip install -e .[test]   # or: make devdeps
make check               # blackcheck + ruff + mypy + pytest (with coverage)
pytest                   # just the tests
```
