fix(dm): gate forwarded callbacks on the bound scope (single-active orchestrator)

The single-active BackwardRelayPager applies forwarded relay callbacks to
whichever scope is currently bound. Its doc already states this is "safe as long
as the caller only advances the bound scope", but a subscription for a
*just-backgrounded* scope (conversation navigation overlap, account switch, a
second pane) can still deliver a late onEvent/onEose/onClosed — which would move
the newly-bound scope's cursors instead. Now that those cursors persist on the
Chatroom/ChatroomList model, that corruption would stick.

Add BackwardRelayPager.isBoundTo(cursors) (cursor identity == scope identity) and
gate each manager's forwarded callbacks on it, so a stray callback from a
non-bound scope is dropped, not mis-applied. The framework's own newEose
bookkeeping still runs. No-op on the happy single-scope path.
This commit is contained in:
Claude
2026-06-07 14:48:40 +00:00
parent 6aaed71eea
commit e9ff46ac46
4 changed files with 42 additions and 18 deletions
@@ -111,22 +111,26 @@ class AccountGiftWrapsHistoryEoseManager(
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener =
object : SubscriptionListener {
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.giftWrapHistory
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onEvent(relay, event.createdAt)
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.onEose(relay)) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until its marker asks for the next page.
@@ -138,7 +142,7 @@ class AccountGiftWrapsHistoryEoseManager(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onClosed(relay, message)
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
@@ -146,9 +150,10 @@ class AccountGiftWrapsHistoryEoseManager(
message: String,
forFilters: List<Filter>?,
) {
pager.onCannotConnect(relay, message)
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
companion object {
private const val TAG = "DMPagination"
@@ -114,22 +114,26 @@ class ChatroomNip04HistorySubAssembler(
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: ChatroomQueryState): SubscriptionListener =
object : SubscriptionListener {
private fun historyListener(key: ChatroomQueryState): SubscriptionListener {
// A just-backgrounded room's subscription can still deliver after the orchestrator rebinds to
// another room; gate the pager (single-active) on whether it's still bound to THIS room's cursors
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onEvent(relay, event.createdAt)
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.onEose(relay)) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" }
}
newEose(key, relay, TimeUtils.now(), forFilters)
@@ -140,7 +144,7 @@ class ChatroomNip04HistorySubAssembler(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onClosed(relay, message)
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
@@ -148,7 +152,8 @@ class ChatroomNip04HistorySubAssembler(
message: String,
forFilters: List<Filter>?,
) {
pager.onCannotConnect(relay, message)
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -104,22 +104,26 @@ class ChatroomListNip04HistorySubAssembler(
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: ChatroomListState): SubscriptionListener =
object : SubscriptionListener {
private fun historyListener(key: ChatroomListState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.nip04History
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onEvent(relay, event.createdAt)
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.onEose(relay)) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" }
}
newEose(key, relay, TimeUtils.now(), forFilters)
@@ -130,7 +134,7 @@ class ChatroomListNip04HistorySubAssembler(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
pager.onClosed(relay, message)
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
@@ -138,7 +142,8 @@ class ChatroomListNip04HistorySubAssembler(
message: String,
forFilters: List<Filter>?,
) {
pager.onCannotConnect(relay, message)
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
}
@@ -147,6 +147,15 @@ class BackwardRelayPager(
recomputeExhausted()
}
/**
* Whether [c] is the currently-bound scope's cursor object. The orchestrator is single-active: it can
* only correctly process callbacks for the bound scope. A caller whose subscription may still be alive
* for a *just-backgrounded* scope (navigation overlap, a second pane) must gate its forwarded callbacks
* on this — otherwise a late EOSE from scope A would move scope B's cursors. The cursor object is the
* scope identity (one per `Chatroom`/`ChatroomList`), so reference identity is the check.
*/
fun isBoundTo(c: RelayLoadingCursors): Boolean = c === cursors
// --- Filter building support: the caller assembles the actual REQ from these. ---
/** Relays of the active scope that have been advanced (armed) and aren't done — i.e. carry a REQ. */