Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Chat cursor: release it by a verified fetch, not by the subscription's EOSE

Open
#695 1 comment 0 reactions 0 assignees View on GitHub

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

area: chat area: relays area: rust-core bug priority: medium

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) through live_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.
  • ChatRxState therefore 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 (or CLOSED), no message of the trade is unsaved (has_unsaved), and the held-reaction floor allows it.
  • live_subs repairs the subscription (same id, same recorded filter, so since = the subscription's start) when a relay reconnects or answers CLOSED. 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).

  1. Relay R, connection N, sends EOSE for the chat subscription. It sits in the notification queue.
  2. R disconnects and reconnects (connection N+1). live_subs re-issues the REQ with the same id and since = subscription start.
  3. The loop dequeues the old EOSE, reads epoch N+1 and records eose_seen[R] = N+1.
  4. R's replay on N+1 (newest first) is treated as live: event_from sees an EOSE for the current epoch and does not reopen catch-up.
  5. A newer replayed event moves the persisted cursor past older events of the replay that have not arrived.
  6. 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:

  1. When wanted > cursor (on the existing once-a-minute re-check in run_chat_subscription), for each connected relay run a one-shot relay.fetch_events(filter).timeout(..):

    • the chat's filter (authors = [pub(K_sign)], kind 14) with since = cursor, until = wanted, and a limit;
    • 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 Ok only after that relay's EOSE (a timeout, CLOSED or error comes out as Err), see nostr-sdk/src/relay/api/fetch_events.rs.
  2. 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.

  3. Move the cursor to min(wanted, held_floor) only if:

    • every connected relay returned Ok;
    • no relay returned limit events (the range was then truncated: retry next round or page with until);
    • has_unsaved(order) is false.

    Otherwise leave it for the next round.

  4. 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_up becomes "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 limit events does not advance that round. Decide between paging with until and 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_subs guard 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 wanted after one (mock or fake relay fetch).
  • A verification with one relay timing out or CLOSED does 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from MostroP2P/app

All issues in MostroP2P/app

Similar issues

More Dart issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.