docs(nests): update plan doc for round-2 listener survival fixes

Captures the group-rotation, orchestrator-break-on-Closed removal,
ensureAnnounceWatchStarted, handleInboundBidi refactor, and
removeInboundSubscription changes that landed in d8ab4fd9. All
three reconnecting-listener interop scenarios now pass.
This commit is contained in:
Claude
2026-04-28 17:23:10 +00:00
parent d8ab4fd99b
commit 461147af81
@@ -1,9 +1,10 @@
# Listener-survives-publisher-recycle: resolution + open follow-ups # Listener-survives-publisher-recycle: resolution log
**Status:**Resolved for the production-relevant case **Status:**All three reconnecting-listener interop scenarios
(publisher recycles, listener doesn't). One pre-existing test pass against the real moq-rs relay: happy-path, session-swap,
exposes a separate, narrower issue that is **out of scope here** and listener-survives-publisher-recycle. Two further fixes landed
— see "Open: session-swap test" below. on top of `851045c6` to close the session-swap gap; see "Round 2"
below.
## What was broken (recap) ## What was broken (recap)
@@ -44,70 +45,44 @@ test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests still multiple speaker JWT-refresh cycles. Speaker reconnect tests still
pass too. pass too.
## Open: session-swap test ## Round 2 — closing the session-swap gap (commit `d8ab4fd9`)
`NostrNestsReconnectingListenerInteropTest.reconnecting_wrapper_keeps_handle_alive_across_session_swap` The `reconnecting_wrapper_keeps_handle_alive_across_session_swap`
still fails in interop runs (with both pre-existing and current test exposed two compounding issues that round 1 didn't address:
code). Investigation revealed **two compounding issues**, only one
of which my fix addresses:
1. **Orchestrator break-on-Closed** (one cause): the orchestrator's 1. **Orchestrator break-on-Closed** `if (terminal is Closed) break`
`if (terminal is NestsListenerState.Closed) break` exits whenever in `ReconnectingNestsListener.kt` exited whenever the inner
the inner listener goes Closed for ANY reason. This includes listener went Closed for ANY reason, including the test's own
the test's `firstListener.close()` call. Pre-fix the orchestrator `firstListener.close()`. Removed the break: user-driven
stopped → no reconnect → `opens=1`. Removing the break (orchestrator `reconnecting.close()` already cancels the orchestrator
loops on Closed too) gets `opens=2`, but exposes the second coroutine separately, so any other Closed (peer transport drop,
issue below. Decision: **keep the break-on-Closed for now** recycle) is now a reconnect trigger.
user-driven `reconnecting.close()` already cancels the
orchestrator coroutine separately, and removing the break would
fight a deeper publisher issue without a corresponding fix.
2. **Publisher single-group architecture** (the deeper cause): 2. **Publisher single-group architecture**
`NestMoqLiteBroadcaster` only ever calls `publisher.send(opus)` `NestMoqLiteBroadcaster` only ever called `publisher.send(opus)`,
never `startGroup()` / `endGroup()`. So the entire broadcast never `endGroup()`. The entire broadcast was one giant moq-lite
is one giant moq-lite group. A subscriber that joins group; a subscriber that joined mid-broadcast got nothing
mid-broadcast (the test's listener-2 case) gets nothing because because `from-latest` subscribe semantics give the NEXT group's
moq-lite's "from-latest" subscribe semantics give the next frames, and the publisher was in a never-ending group. Fixed by
group's frames; if the publisher is in the middle of a adding `publisher.endGroup()` after each send — one Opus frame
never-ending group, the new subscriber waits indefinitely. per moq-lite group, mirroring the kixelated reference's audio
publish path.
The listener-survives-publisher-recycle path doesn't hit this Three companion changes in `MoqLiteSession.kt` were needed to make
because each speaker JWT cycle creates a fresh publisher session those work cleanly:
with a fresh group — the listener-side resubscribe naturally
lands on a new group.
Fixing this properly would require periodic group rotation in - `ensureAnnounceWatchStarted()` runs synchronously before the
`NestMoqLiteBroadcaster` (e.g. one group per second, or per N first subscribe, so the relay sees us as an audience member
frames). That's a substantive audio-pipeline change with its before we ask to subscribe (otherwise it returns "not found").
own concerns (jitter buffer interaction, listener seek - `handleInboundBidi` refactored to a single long-running
semantics) — out of scope for the listener-survival work. collector with the varint `typeCode` hoisted outside `collect`
(an earlier draft re-read the body bytes as the type code on the
second collect pass).
- `removeInboundSubscription(sub)` FINs the publisher's
`currentGroup` when an inbound subscribe bidi closes, so the next
send opens a fresh uni stream keyed off a live subscriber rather
than the dead one that was first in the inboundSubs set.
The interop test's expectation — that closing the inner listener The round-trip interop test's groupId assertion was updated from
mid-stream forces a clean session swap with continuous frame flow `groupId == 0` to `groupId == idx` to match the new
— is unrealistic against the current publisher architecture. The one-group-per-frame contract.
test was passing pre-my-changes because the orchestrator broke on
Closed (issue 1) BEFORE issue 2 could be observed; with the break
in place, the test never gets to verify frame continuity, and
fails earlier with `opens=1`. Either way, the test fails — it just
fails for different reasons before vs. after issue 1 is fixed.
For follow-up:
- Consider rotating moq-lite groups in `NestMoqLiteBroadcaster`
on a fixed cadence so mid-stream listener subscribes work.
- Once rotation lands, consider removing the
orchestrator's `break-on-Closed` so that listener-side recycles
via `firstListener.close()` (or analogous transport-peer-close
paths) trigger a wrapper-level reconnect. Today, the only
documented path for the wrapper to spin up a fresh inner listener
is via the JWT refresh window or a Failed terminal state.
## Files relevant to follow-up
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/NestMoqLiteBroadcaster.kt`
— where to add periodic `endGroup()` + new group on send.
- `nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt:174`
— where the break-on-Closed lives.
- `nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsReconnectingListenerInteropTest.kt`
`reconnecting_wrapper_keeps_handle_alive_across_session_swap`
is the failing-but-pre-existing test that captures both issues.