# LIFECYCLE-TRUTH W4 — WAVE GATE REPORT (todlando, 2026-07-07)

**Wave head: `e09ba21`** (branch `lifecycle-truth`, pushed). W4 = spawn/wake + listener
lifecycle. Two REQs built. Ruling `docs/W4-DISPATCH-RULING.md` @7448fd0. Local gate GREEN;
Linux leg is deployah's/doyle's.

## REQ-SPAWN-COLLISION-GUARD-LIVE-DUP @e09ba21 (impl+unit+int)

**Root (perri's flynn dup):** one wake processed twice within 1s → the broker spawned two
identical launch trees, both survived; the duplicate perch writers stomped info.json (an old
sid re-stamped over a fresh /clear rotation → injects routed to a contended record and lost).
Check-then-spawn TOCTOU, worsened by a `resume.pid` guard pointing at a short-lived
(dead-by-design) wrapper.

**Fix (ruling 1 — claim BROKER-SIDE, keyed by id, NOT a perch record):** the broker is the
sole spawner post-W3 (rc WMI auto-launch retired by RC-RECONNECT-TRUTH), so its own spawn
choke `dispatch_spawn` is the single-flight authority. A perch-record claim would add another
info.json writer — the exact write-stomp this REQ kills.
- `Broker::wake_inflight: Mutex<HashSet<String>>` — in-flight wake claims keyed by endpoint id.
- `dispatch_spawn`: the claim + the live-session re-check are read together under ONE atomic
  critical section (sessions + wake_inflight locked together), so a concurrent spawn sees
  either our claim or its registered session — never a gap. **NO I/O under the lock**
  (`process_id()` reads a cached pid).
- Duplicate wake for an already-live-or-in-flight endpoint → no-op ack to the existing session
  (not an error). A racer that overran a bounded window (leaked claim) is taken over
  (availability over a rare dup). Empty endpoint = ad-hoc spawn, never gated.
- RAII `WakeClaimGuard` releases the claim on EVERY exit (success, early `?` spawn/drain error,
  panic) — a failed spawn never strands an endpoint claimed. Guard held to fn end (releases
  after the session is table-visible, so there is no claim-gone-but-session-absent gap).

**RED-first PROVEN:**
- Int (`tests/wake_single_flight.rs`, REAL broker): two concurrent `KIND_SPAWN` for one
  endpoint (fired together off a barrier) → both resolve to the SAME session id, broker holds
  exactly one session. Drop the dedup gate → `sid1=1 sid2=2 session_count=2` (the dup).
- Unit: pure `wake_gate_decision` truth table (AlreadyLive / Racing / Claim over
  live × claimed × past-deadline).

## REQ-HAZARD-LISTEN-ORPHAN @e09ba21 (impl+unit)

**Root (mobile-gw RCA):** `api listen --parent-pid N` was auth-anchor ONLY — no liveness watch;
host death orphans the listener forever (perch held false-ONLINE, EVENTs stream to a dead
stdout, dead-owner rebind BLOCKED because the recorded pid is the live orphan).

**Fix (ruling 2 — POLL baseline, OS-native optional):** `spawn_parent_watchdog` (startup.rs)
polls `parent_is_gone(parent_pid)` every `PARENT_WATCH_POLL` (2 s); on parent death it prints
the loud `LISTEN_ORPHAN_EXIT` line (names self + the dead parent) and `process::exit(3)` — so
the perch's recorded pid dies → liveness flips it OFFLINE within one window → rebind unblocks.
Persistent-relay branch only (a `--once` shot has no orphan window). flynn's spt-mobile-side
job-object guard stays as independent defense-in-depth.

**Units:** `parent_is_gone` (a spawned-then-reaped-and-DROPPED child pid reads gone; this live
process reads alive) + `listen_orphan_exit_line` format (names self + parent + the REQ tag).
Windows gotcha noted in-test: a reaped `Child` keeps the pid probe-able (its held handle keeps
the process object) until `drop(child)` — production is unaffected (a listener holds no handle
to a separate parent host); only the test needed the drop.

## Local gate (this box, HFENDULEAM — Windows)
- clippy `-p spt-daemon -p spt --all-targets`: clean.
- **Full-workspace nextest: 1696/1696 passed** (8 leaky, 1 skip), exit 0 (`--no-fail-fast`).
  +4 over W3's 1692 = the 4 new tests; NO regressions from the `dispatch_spawn` seam change.
- `xtask check` (docs-drift + token, `CARGO_TARGET_DIR` unset): OK (no CLI surface changed).
- traceable-reqs check: exit 0; SPAWN-COLLISION `+impl+unit+int`, LISTEN-ORPHAN `+impl+unit`.

## Seam-change note (for the gate sweep)
`dispatch_spawn` is a shared spawn seam. The dedup gates ONLY a non-empty endpoint that already
has a LIVE session (or an in-flight claim) — a spawn/kill/respawn of the same endpoint (first
session reaped from the map) is unaffected; only OVERLAPPING same-endpoint spawns dedup. The
full-workspace run (all e2e + inject/attach seam tests) is green, confirming no test relied on
double-live-spawning one endpoint.

## Gate gotcha (unchanged from W3)
Full-workspace nextest leaks `target\debug\spt.exe` dev-daemons from e2e teardown → a follow-up
build hits `os error 5` removing the pinned exe. Kill SCOPED (Path == `<repo>\target\debug\spt.exe`
ONLY, never the `AppData\Local\spt-core\bin` live listeners) between build steps.
