f2205dc97e
The two-phone repro on commit cb61082 showed the cliff still triggers
at ~13 s with framesPerGroup=10 (last drainOneGroup at ts 14:26:14,
broadcaster keeps pushing through 14:26:25+ unimpeded), but the cliff
detector did NOT fire — no `cliff-detector: announced+subscribed but
silent…` log in the receiver's NestRx output despite the gap clearly
exceeding the 4 s threshold. Three changes here narrow the gap.
1. Extract `computeStalledSpeakers` as a pure top-level internal
function over `Map<String, TimeMark>`. The detector loop now calls
it; the rest of the predicate logic (cooldown, announced ∩ active
∩ stale, ≥ inclusive boundary) is now testable without standing up
the NestViewModel coroutine machinery.
2. New `CliffDetectorTest` (commonTest, 12 tests) drives
`computeStalledSpeakers` with a `kotlin.time.TestTimeSource`.
Pins the boundary cases the next refactor would otherwise drift
on: at-threshold inclusive, just-under empty, no-frame-yet ignored,
not-announced ignored, multi-speaker classification, cooldown
suppress, cooldown release, and a few more. All 12 pass.
3. Add diagnostic logging to the live cliff detector. The two-phone
logs proved the predicate's logic works (the `lastFrameAt` mark
was clearly aged past the threshold), so the silence has to be
in the gating: `listener` null, connection state not Connected,
or `_announcedSpeakers` not containing the pubkey at scan time.
New logs:
- "cliff-detector launched" once on start
- "cliff-detector tick=N gated: listener=… conn=…" every 5 s
when gated
- "cliff-detector tick=N active=… announced=… lastFrameAges=…"
every 5 s when running
- "cliff-detector EXITED after N ticks (closed=…)" on cancel
so the next repro tells us which gate is failing.
4. Defensive restart on `NestsListenerState.Connected`. The Closed
branch in `observeListenerState` calls `teardown(targetState =
Closed)`, which cancels `cliffDetectorJob` along with the rest of
the per-session jobs. If the cliff detector itself is what
triggered the recycle, the wrapper emits Closed → Reconnecting
→ Connected; we'd come back online with a dead detector and the
next cliff event would slip past undetected. Calling
`startCliffDetector()` (idempotent) on every Connected entry
keeps the detector alive across the cycle.
Defaults stay where they were: 4 s `ROOM_AUDIO_CLIFF_TIMEOUT_MS`,
1 s scan, 8 s cooldown — the unit tests also pin those default
constants by relying on the function's defaults in two of the cases.