# v0.22.0 — Cutover guide

> **Audience:** consumer agents (and humans relaying for them)
> upgrading to `mgf-common` 0.22.0.
> **What this is:** the canonical migration document for this
> release. Per [`RELEASING.md`](../standards/RELEASING.md) **REL-09**
> + the cutover-guide pattern, individual per-entry notification
> paragraphs are pointers into this guide.

This is a **polish release** for the v0.21.0 `mgf.common.process`
feature — three reliability fixes, simplicity cleanups, and one
small additive method (`Service.wait()`). **No behaviour breaks
for existing consumers.** Every v0.21.0 test passes unchanged
against the refactored internals; the surface contracts for
`run()`, `Service.start()`, `Service.stop()`, readiness
predicates, and the `Process*` exception taxonomy are identical.

---

## At-a-glance

| What landed | Severity | What it covers |
|---|---|---|
| **Early-exit detection in `Service.start()`** | reliability | Child exits before ready → `ServiceNotReady` raised in ~250ms (not after the full `timeout_s` window). Returncode + stderr tail in `last_error`. |
| **Thread-safe `LogLineReady` snapshot** | reliability | High-volume child stdout no longer races the streamer thread (`RuntimeError: deque mutated during iteration` is impossible). |
| **Setup-failure rollback** | reliability | The "no leaked process on failure" contract now covers every code path between `Popen` and ready-resolved, including `KeyboardInterrupt`. |
| **`Service.wait(timeout=None) -> int \| None`** | feature (additive) | Block until natural exit or timeout. The escape hatch from "start the service, let it run until it crashes / completes." |
| **Internal simplifications** | maintainability | Shared `resolve_redactor()` / `make_streamers()` helpers; `_LineSnapshot` class → closure; bespoke `suppress` → stdlib; ~50 LOC removed. |

**Pin bump:** `mgf-common = ">=0.22,<0.23"` per the 0.x window
discipline.

**Public-Python-surface change:** +1 method (`Service.wait()`).
Top-level `__all__` exports unchanged. Total surface still 193
names.

**Behaviour change:** none for existing v0.21.0 callers. The
polish reliability fixes change the **timing** of `ServiceNotReady`
errors (faster) and the **content** of `last_error` (more
diagnostic), but no caller pattern stops working.

**No deprecation cycles introduced.**

---

## Why these polish items matter

### Early-exit detection — the load-bearing fix

Before v0.22.0, this code:

```python
Service(
    ["/typo/in/command/path"],
    ready=PortReady("127.0.0.1", 5432),
    timeout_s=30.0,   # generous default
).start()
```

…would burn the full 30 seconds before raising
`ServiceNotReady("port localhost:5432 did not resolve")`. The
real answer was "your command doesn't exist; the child exited
with returncode 127 after 5ms" — but `start()` had no way to
surface that.

Post v0.22.0, the same code raises `ServiceNotReady` in ~250ms
with `last_error="child exited with returncode 127 before
becoming ready; stderr tail: …"`. The diagnostic is
load-bearing for fixture authors who write code like:

```python
@pytest.fixture
def pg_service(tmp_path):
    with Service([...], ready=PortReady(...), timeout_s=30.0) as svc:
        yield svc
```

…and want broken-fixture turnaround in seconds, not minutes.

### Thread-safe `LogLineReady` — the rare-but-real race

`LogLineReady`'s `_capture_provider` thunk used to do a `for line
in capture._captured` walk under the hood. Under
`Service(poll_interval_s=0.001)` against a child emitting
hundreds of lines per second, the deque could be mutated mid-
iteration → `RuntimeError`. Post-fix uses
`list(self._captured)`, which compiles to a single CPython
`LIST_APPEND`-loop bytecode under the GIL — atomic against the
appending streamer thread. Pinned by a 2000-line stress test.

You probably never hit this in practice with default settings
(`poll_interval_s=0.2`); the regression guard exists for the
test-fixture-author who reaches for `poll_interval_s=0.001`
to minimise the under-the-bar startup latency.

### Setup-failure rollback — the no-leak guarantee

Pre-v0.22.0's `start()` had separate try/except blocks for the
readiness wait. A `KeyboardInterrupt` during `wait_for_ready`
could escape with the child still alive. Post-v0.22.0 wraps the
entire setup-to-ready window in one `try/except BaseException`
that always tears down. Important when consumer test runs are
killed mid-fixture-setup.

### `Service.wait()` — the missing escape

`with Service(...)` always called `stop()` on exit. If your
fixture is "run my dev server until it dies naturally," you had
no way to express it without bypassing the context manager.
`wait()` returns the `returncode` on natural exit (or `None` on
timeout), so:

```python
with Service([...], ready=PortReady("127.0.0.1", 8000)) as svc:
    rc = svc.wait(timeout=600.0)  # let it run up to 10 min
    if rc is None:
        print("still alive after 10 min")
    elif rc != 0:
        print(f"crashed with code {rc}")
# context-exit calls stop() — graceful even if wait() returned a code
```

---

## Per-consumer migration notes

### VManager (Qt desktop)

**No action required.** The polish changes are transparent to
existing v0.21.0 callers. If you've started adopting
`mgf.common.process.run` for `scripts/smoke-real-libvirt-interactive.py`
(per the v0.21.0 cutover guide), the refactor doesn't affect
your call sites.

If you're spinning up libvirt-related services for forensics
testing, the new `Service.wait()` may simplify "let qemu run
until guest dies naturally" patterns. Optional.

Pin: `mgf-common = ">=0.22,<0.23"`.

### inthewords (Django web)

**No action required.** Same as VManager — the polish round is
internal to `mgf.common.process` and doesn't touch your existing
adoption surface.

If you're working on the `tools/run_stack.py` rewrite (the bash
→ Python migration mentioned in v0.21.0's cutover guide), three
items help:

- **Early-exit detection** means a typo in any of the docker /
  celery / uvicorn commands fails fast in your dev loop instead
  of burning the readiness timeout.
- **`Service.wait()`** lets the rewrite express "uvicorn runs in
  the foreground; Ctrl-C cleans up the rest via `ExitStack`" —
  matching the bash script's `wait $UVICORN_PID` semantics
  exactly.
- **Setup-failure rollback** means a partially-up stack is
  always cleaned up before the error escapes — even on Ctrl-C
  during setup.

Pin: `mgf-common = ">=0.22,<0.23"`.

### PlasmaMapper (FastAPI scientific platform)

**No action required.** The polish changes are transparent.

If you've started using `Service` for pytest fixtures (Postgres-
for-integration), the early-exit detection means a fixture
broken by a missing extension or wrong PG version fails the
test in seconds instead of waiting for the readiness timeout —
faster CI turnaround on broken fixtures.

Pin: `mgf-common = ">=0.22,<0.23"`.

---

## Engineering note

The v0.21.0 release was the audit-first feature shape. v0.22.0
is the **stress-test-first polish round** — three of the four
reliability fixes were caught by re-reading the code with
"what's the worst-case caller behaviour?" in mind, not by
consumer-side bug reports. (The thread-race fix is the one
that's almost certainly never been triggered in production,
but the guard is cheap and the fix is two characters.)

The simplicity cleanups (helpers extracted, `_LineSnapshot` →
closure, bespoke suppress → stdlib) are pure maintainability
work. They make the next round of changes easier to review;
they don't change behaviour.

The "no behaviour breaks" property is enforced by 64 v0.21.0
tests passing unchanged + 11 new v0.22.0 tests for the new
behaviour. If a future polish round breaks v0.22.0 tests
without a corresponding cutover-guide entry explaining why,
that's a regression — the test-pinned contract is the bar.
