core/teardown: an unconfirmed stop is not a clean stop

Codex's re-review of the branch returned "approve with follow-ups" — no
blocking findings, five P3s. All five are applied here rather than carried as
debt, since each is a few lines.

The one with user-visible consequences: `stop_host` returned a bare "was
sharing" bool, so the single case where the availability-first policy gives up
(SIGKILL queued, reap never confirmed) still sent `ScreenShareStopped` with
nothing else. The UI would say sharing had ended while pixelpass might still be
alive and fanning out — a claim the user cannot see through. `shutdown` now
returns `StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an
unconfirmed *user-initiated* stop raises a UI error naming the stray process.
Session and viewer teardown discard the outcome deliberately: nobody is waiting
on an answer there, and the residual risk is already logged.

Also: the three failure diagnoses in `shutdown` (the signal never left, the
child ignored it, the wait itself broke) were collapsed into one log line and
are now distinct — they mean different things to whoever reads the log.

The second cancellation gate is the one worth keeping. The review pointed out
that all cancellation coverage sat in the *graceful* wait, so a mutant that
disarmed the wrapper between the two waits would survive. It was right, with a
wrinkle: the naive mutant does not compile, because the child is borrowed from
`self` for the whole function — the borrow checker is doing real work here. The
restructured form (`self.child.take()` once cooperation has failed) does
compile, and the pre-existing mid-wait test passes it.
`cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.

Mutation-verified, both new gates: reporting an unconfirmed stop as `Reaped`
fails exactly `a_failed_wait_is_not_treated_as_a_confirmed_reap`; disarming
between the waits fails the new cancellation test (and the failed-wait test,
which also asserts armedness) while leaving the old mid-wait test green — which
is the proof the new test is not redundant. The logging split is diagnostics
only and has no gate; said plainly rather than dressed up as covered.

Docs: the "four mutations" line is now an explicit table naming each target and
its test, with 0c's pair counted under 0c; and the aggregate teardown latency is
recorded as a deferred item with a trigger (a fourth routine child, or a
measured teardown over 5 s) instead of an unwritten known cost.

638 lib tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:43:04 -04:00
co-authored by Claude Opus 5
parent 3aa768af52
commit 9f06741b99
3 changed files with 185 additions and 35 deletions
+41 -4
View File
@@ -832,10 +832,21 @@ mutation is retired as vacuous.** Reached independently by both reviewers, then
- **Rejected: an integration test built to preserve the number five.** It would pay for a
full iroh core plus test-only observability and prove only that a method was called — not
the ordering invariant, which is the thing that actually breaks.
- The 0b gate is therefore **four mutations** (no wait · reversed field order · no reap loop ·
plus the 0c pair below), each killed by its own named test, with 4-vs-5 verified separated:
reversing the field order leaves the reap test green and removing the reap loop leaves the
ordering test green.
- The 0b gate is therefore **four mutations**, enumerated exactly (round 16 P3-5 — the earlier
wording said "three plus the 0c pair", which reads as five and blurred what 0b owns):
| # | mutation | killed by | status |
|---|----------|-----------|--------|
| 1 (old gate 3) | remove the wait after the host kill | `explicit_shutdown_reaps_the_host_before_the_aec_can_unload` | killed now |
| 2 (old gate 4) | reverse `ScreenshareTeardown`'s field declaration order | `the_aec_unloads_after_the_children_on_the_drop_path` | killed now |
| 3 (old gate 5) | remove the reap loop from `ReapOnDrop::drop` | `dropping_a_guard_kills_and_then_reaps_the_child` | killed now |
| 4 (old 1) | remove the teardown at the hoisted post-loop call site | — | **deferred to the phase-9 row** *drop the controller / close the command channel while sharing* |
4-vs-5 separation verified: reversing the field order leaves the reap test green, and
removing the reap loop leaves the ordering test green. **0c's own pair (no SIGINT · no
SIGKILL fallback) is counted under 0c, not here**, along with the round-15/16 additions
(disarm the wrapper at entry · disarm it between the waits · treat a wait error as a reap ·
report an unconfirmed stop as clean · zero the grace).
**Round 15 (2026-07-26) — review of the 0b/0c-peerspeak implementation returned two blocking
findings, both accepted.** Recorded because both are the same shape: a defence that existed
@@ -854,6 +865,32 @@ but was disarmed exactly when it was needed.
elapsed-time assertion compared against `STOP_GRACE` itself, so zeroing the constant left
it trivially true. `the_grace_is_a_real_interval` now pins the constant to a band.
**Round 16 (2026-07-26) — the re-review of the 0b/0c-peerspeak fixes returned *approve with
follow-ups*: no blocking findings, five P3s, all five applied before the merge.** The two that
carry design content:
- **An unconfirmed stop was reported to the user as a clean one.** `stop_host` returned a bare
"was sharing" bool, so the one case where availability-first gives up (SIGKILL queued, reap
never confirmed) still emitted `ScreenShareStopped` with no warning — the UI would say
sharing ended while pixelpass might still be fanning out. `ReapOnDrop::shutdown` now returns
`StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an `Unconfirmed` user-initiated
stop raises a UI error naming the stray process. Session/viewer teardown discards the outcome
on purpose: no user is waiting on an answer there and the risk is already logged.
- **Cancellation coverage only reached the graceful wait.** The mid-wait test could not kill a
mutant that disarmed the wrapper *between* the two waits. Verified: the naive form of that
mutant does not compile (the child is borrowed from `self`), but the restructured form —
`self.child.take()` once cooperation has failed — compiles, and the pre-existing test passes
it. `cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.
**Deferred item — aggregate teardown latency (round 16 P3-4).** Bounds are per child, not per
teardown. Sequential drain gives `2 × STOP_GRACE` per unconfirmed child inline (≈4 s), plus
`REAP_BUDGET` (250 ms) per child on the `Drop` path: three wedged children ≈6 s of command-loop
stall, ≈12.75 s worst case including drop retries. Accepted as-is for 0b — one host plus one or
two viewers is the real shape, and concurrency here would mean detaching children from the
session that owns the AEC's lifetime. **Trigger to revisit: a fourth tracked child becomes
routine, or a measured teardown exceeds 5 s.** The fix, when triggered, is to drain viewers
concurrently while still owned by `shutdown_children` — not to detach them.
**Round 1 — 13 items, 12 accepted.** Phase reorder (AEC machine before dry-run); typed capture
plan (accepted, moved *earlier* than proposed); Phase 3 five-part gate; tag-consumption gating;
Phase 6 matrix mandatory; 0b unwind backstop restored **and my mutation test corrected — it