# 08 — Progress: cancellation + reporting

`CancellationToken` (sync) + `AsyncCancellationToken` (asyncio)
+ `ProgressReporter` (Protocol). The cooperative-cancellation
primitives every long-running operation needs.

> **Read first:** [`library_common.md`](library_common.md),
> [`02_exceptions.md`](02_exceptions.md) (`CancellationError`).

---

## Motivation

Long-running operations (subprocess waits, HTTP calls in a
loop, file processing) need cooperative cancellation: a way
for a controlling thread / event loop to ask the work to stop
without resorting to `Thread.kill` (which doesn't exist) or
`asyncio.Task.cancel` (which only works inside coroutines).

`CancellationToken` is the threading-friendly primitive: a
shared object the controller calls `.cancel()` on; the worker
checks `.is_cancelled()` (or calls `.raise_if_cancelled()`) at
safe points. `AsyncCancellationToken` is the asyncio sibling.

Pre-every consumer rolled its own. `mgf.common.progress`
ships the canonical shape so cross-cutting features (SSH,
service-manager, retry/backoff when they land) cooperate
cleanly.

---

## Goal

```python
from mgf.common.progress import CancellationToken
from mgf.common.exceptions import CancellationError

def long_running(items: list, cancel: CancellationToken) -> list:
    results = []
    for item in items:
        cancel.raise_if_cancelled()  # raises CancellationError
        results.append(_process(item))
    return results

# From the controller:
cancel = CancellationToken()
worker_thread = Thread(target=long_running, args=(items, cancel))
worker_thread.start()
# ... later ...
cancel.cancel()
worker_thread.join()
```

`CancellationError` is a subclass of `OperationError` — every
existing `except OperationError:` clause catches it without
code change.

---

## Quick start

### Sync

```python
from mgf.common.progress import CancellationToken, RecordingReporter

cancel = CancellationToken()
reporter = RecordingReporter()

def work(items, cancel, reporter):
    for i, item in enumerate(items):
        cancel.raise_if_cancelled()
        reporter.report(f"processing {item.name}", current=i, total=len(items))
        _process(item)

work(items, cancel, reporter)
print(reporter.calls) # [(message, current, total), ...]
```

### Async

```python
import asyncio
from mgf.common.progress.asyncio import AsyncCancellationToken

async def long_async_op(cancel: AsyncCancellationToken) -> None:
    while not cancel.is_cancelled():
        await asyncio.sleep(0.1)
        await _do_one_step()

cancel = AsyncCancellationToken()
task = asyncio.create_task(long_async_op(cancel))
# ... later ...
cancel.cancel()
await task
```

The async sibling is bound to a single event loop — NOT
thread-safe across loops.

---

## Public API

### Sync (`mgf.common.progress`)

| Name | Purpose |
|---|---|
| [`CancellationToken`](../../PUBLIC_API.md#mgfcommonprogress) | Thread-safe sync token. `cancel() / is_cancelled() / wait(timeout) / raise_if_cancelled()`. ~50ns is_cancelled cost. |
| [`ProgressReporter`](../../PUBLIC_API.md#mgfcommonprogress) | Ergonomic alias for `mgf.common.typing.ReporterProtocol`. |
| [`NullReporter`](../../PUBLIC_API.md#mgfcommonprogress) | No-op concrete impl. Optionally takes `token=` for `is_cancelled` forwarding. |
| [`RecordingReporter`](../../PUBLIC_API.md#mgfcommonprogress) | Test double. `.calls: list[tuple[str, int, int]]`; `.reset()` between sub-tests. |

### Async (`mgf.common.progress.asyncio`)

| Name | Purpose |
|---|---|
| [`AsyncCancellationToken`](../../PUBLIC_API.md#mgfcommonprogressasyncio) | asyncio-native sibling. `cancel() / is_cancelled()` (sync) + `await wait(timeout)` (async) + `raise_if_cancelled()` (sync helper). |

### Typed Protocols (cross-reference to `mgf.common.typing`)

| Name | Methods |
|---|---|
| [`CancellationProtocol`](../../PUBLIC_API.md#mgfcommontyping) | `cancel()`, `is_cancelled()` |
| [`ReporterProtocol`](../../PUBLIC_API.md#mgfcommontyping) | `report(message, current=0, total=0)`, `is_cancelled()` |

Code:
[`src/mgf/common/progress/`](../../src/mgf/common/progress/).

---

## Patterns

### Test double (`RecordingReporter`)

```python
from mgf.common.progress import RecordingReporter

def test_my_op() -> None:
    reporter = RecordingReporter()
    my_op(reporter=reporter)

    assert ("starting", 0, 3) in reporter.calls
    assert ("done", 3, 3) in reporter.calls
```

### Forwarding cancellation through a NullReporter

When a function takes a `ProgressReporter` and uses it for both
status messages AND cancellation checks:

```python
from mgf.common.progress import CancellationToken, NullReporter

cancel = CancellationToken()
reporter = NullReporter(token=cancel) # is_cancelled() forwards to cancel
worker_thread = Thread(target=do_work, args=(reporter,))
```

### Async wait with timeout

```python
async def work_with_timeout(cancel: AsyncCancellationToken) -> bool:
    try:
        await asyncio.wait_for(cancel.wait(), timeout=30.0)
        return False  # cancelled
    except asyncio.TimeoutError:
        return True  # timed out (still running)
```

### `CancellationError` as a typed exception

`CancellationError` is a subclass of `OperationError`. The CLI
translator (see [`09_cli.md`](09_cli.md)) maps it to
`EXIT_OPERATION_ERROR=2` by default. CLIs wanting a distinct
cancellation exit code (e.g., 130 = 128 + SIGINT) MUST pass
`exit_code_map={CancellationError: 130}` to `run_and_translate`.

---

## Links

- **Public API:**
  [`mgf.common.progress`](../../PUBLIC_API.md#mgfcommonprogress) /
  [`mgf.common.progress.asyncio`](../../PUBLIC_API.md#mgfcommonprogressasyncio) /
  [`mgf.common.typing`](../../PUBLIC_API.md#mgfcommontyping)
- **Engineering standards:**
  [`DESIGN_PRINCIPLES.md`](../standards/DESIGN_PRINCIPLES.md) DP-09 (cancellation), DP-11 (async ownership)
- **Code:**
  [`src/mgf/common/progress/__init__.py`](../../src/mgf/common/progress/__init__.py),
  [`asyncio.py`](../../src/mgf/common/progress/asyncio.py)
- **Related docs:**
  - [`02_exceptions.md`](02_exceptions.md) — `CancellationError`
    is in the typed hierarchy
  - [`09_cli.md`](09_cli.md) — exit-code translator handles
    `CancellationError` (default to `EXIT_OPERATION_ERROR`;
    override per CLI)
