Chat cursor: release it by a verified fetch, not by the subscription's EOSE
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- rust, wasm
Research direction
Start with rust/src/api/messages.rs, run_chat_subscription, and nostr-sdk/src/relay/api/fetch_events.rs to understand the current cursor flow and one-shot fetch behavior. Add verification tests for successful, failed, and truncated fetches, including the stale-EOSE replay race; done means the cursor advances only after complete verified coverage and saved events. Update specs/004-mostro-p2p-client/contracts/messages.md and the EOSE-arm comment.
Written by the indexing model from the issue text.
Description
Summary
The peer and dispute chat since cursor (chat_cursor:<order>, ChatRxState in rust/src/api/messages.rs) is released by the EOSE of the long-lived chat subscription. That EOSE cannot be bound to the relay connection that sent it, so a stale EOSE can certify a repaired subscription's catch-up that has not finished. A restart in that window permanently skips older messages.
This was found during review of #692 (Codex: https://github.com/MostroP2P/app/pull/692#discussion_r4179858930; ermeme: https://github.com/MostroP2P/app/pull/692#pullrequestreview-5408938081). #692 merges with the race documented at the EOSE arm of run_chat_subscription; this issue tracks the fix.
Background: how the cursor moves today (after #692)
- On start the chat subscribes with
since = cursor(limit = CHAT_BACKLOG_LIMIT) throughlive_subs::replace, under a fixed subscription id per chat. - Relays serve stored events newest first, so the cursor must not move while a catch-up is in progress: a newer event persisted before an older one arrives loses the older one on a restart.
ChatRxStatetherefore keeps:wanted, the newest event passed;awaiting_eose, the connected relays that held the subscription at start;eose_seen, relay → connection epoch (stats().success()) at which its EOSE arrived;caught_up.
- The cursor moves to
min(wanted, held_floor)only once every awaited relay sent EOSE (orCLOSED), no message of the trade is unsaved (has_unsaved), and the held-reaction floor allows it. live_subsrepairs the subscription (same id, same recorded filter, sosince= the subscription's start) when a relay reconnects or answersCLOSED. An event on a connection with no EOSE recorded reopens catch-up for that relay and rolls the persisted cursor back to the subscription's start (event_from,closed_by).
The race
ClientNotification::Message in nostr-sdk 0.45.2 carries only relay_url and the RelayMessage: not the connection that produced it, and there is no reconnect notification. The connection epoch is read when the loop dequeues the EOSE (connection_of).
- Relay R, connection N, sends EOSE for the chat subscription. It sits in the notification queue.
- R disconnects and reconnects (connection N+1).
live_subsre-issues the REQ with the same id andsince= subscription start. - The loop dequeues the old EOSE, reads epoch N+1 and records
eose_seen[R] = N+1. - R's replay on N+1 (newest first) is treated as live:
event_fromsees an EOSE for the current epoch and does not reopen catch-up. - A newer replayed event moves the persisted cursor past older events of the replay that have not arrived.
- A restart in that window starts at the new cursor, and the older events are never fetched again.
The window is the notification queue's latency, but it is real, and ermeme reproduced the ordering against the production eose_from/event_from code.
Conservative patches inside this design don't work:
- Rejecting an EOSE whose epoch changed since the relay was awaited leaves a connection whose replay is empty (EOSE, no events) awaited forever.
- Re-awaiting on any epoch change has the same problem.
Proposed fix: verified cursor advance
Stop using the long-lived subscription's EOSE to move the cursor. Instead, move it only after a fresh, uniquely identified fetch has confirmed every event in the range:
-
When
wanted > cursor(on the existing once-a-minute re-check inrun_chat_subscription), for each connected relay run a one-shotrelay.fetch_events(filter).timeout(..):- the chat's filter (
authors = [pub(K_sign)], kind 14) withsince = cursor,until = wanted, and alimit; - each call uses a new subscription id issued on the relay's current connection, so its EOSE cannot come from an earlier one;
- in nostr-sdk 0.45 the relay-level fetch yields
Okonly after that relay's EOSE (a timeout,CLOSEDor error comes out asErr), seenostr-sdk/src/relay/api/fetch_events.rs.
- the chat's filter (
-
Pass every returned event through
handle_chat_event, without the token bucket (it is stored backlog). Duplicates are cheap: the outer-id LRU and the durable inner-id dedup drop them. -
Move the cursor to
min(wanted, held_floor)only if:- every connected relay returned
Ok; - no relay returned
limitevents (the range was then truncated: retry next round or page withuntil); has_unsaved(order)is false.
Otherwise leave it for the next round.
- every connected relay returned
-
Remove the EOSE bookkeeping that only served cursor release:
awaiting_eose,eose_seen,connection_of,relays_holding,closed_by,event_from(and its cursor rollback),forget_gone_relays,from_catch_up.live(first EOSE turns on the token bucket) stays.
What this keeps from #692
- Cursor never advancing during catch-up (
caught_upbecomes "a verification succeeded"). - The held-reaction floor (
held_floor, 10-minute expiry,HELD_FLOOR_SECS). - The unsaved-write guard (
has_unsaved, recorded under the store lock). - The once-a-minute re-check on its own clock.
Benefits
- The race disappears: a stale EOSE no longer certifies anything.
- A token-bucket rejection is recovered by the next verification before the cursor passes it. Today a late relay's backlog is exempted from the bucket instead.
- Simpler state than the per-relay EOSE tracking.
Costs and decisions
- At most one fetch per connected relay per minute while there is new traffic; mostly already-known events.
- A range with more than
limitevents does not advance that round. Decide between paging withuntiland simply retrying. - A relay that is disconnected is not awaited (same as today's
forget_gone_relays). It may hold events no other relay has, and when it reconnects its replay is stored normally, but a restart before then could miss them. Accepted today; state it in the contract. - Web (wasm): the fetch runs on the main thread like everything else in the core (no worker handler). It must not sit in front of
subscribe_orders()or any startup path. It lives in the chat task only, which is isolated per the contract. live_subsguard test: the verification is a one-shot fetch, not a long-lived subscription. Check that the guard (client.subscribe(..).with_id(..)ban) still passes.
Tests to add
- Cursor stays put until a verification succeeds, and moves to
wantedafter one (mock or fake relay fetch). - A verification with one relay timing out or
CLOSEDdoes not move the cursor. - A truncated range (returns
limit) does not move the cursor. - Events found only by the verification are stored and bypass the token bucket.
- Regression for the race: a stale EOSE (old connection) followed by a newest-first replay cannot move the cursor past an unseen older event.
Docs
Update specs/004-mostro-p2p-client/contracts/messages.md (cursor rules) and the EOSE-arm comment in run_chat_subscription, which today documents the race and points here.
Relates to #690 and #692.
- Dominant language
- Dart
- Stars
- 11
- Forks
- 9
- Avg merge
- 17h 18m
- Merged PRs (30d)
- 195
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from MostroP2P/app
-
feat(reputation): show the source of an attestation before importing itPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
MostroP2P/app#881 · 1 comment ·
Maintainers usually reply within 1 day
-
area: rust-core bug priority: low
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
area: ui
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
PATILYASHH/XPENC#179 · 1 comment ·
-
type:bug
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 80/100
decentespresso/decaid#1028 ·
Maintainers usually reply within 1 day
-
Update checksums / Verify firmware give no success or error feedback (looks like a dead button)Open
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
canonical/firmware-updater#443 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 3 days