Repository navigation
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This was referenced Oct 15, 2026
Contributor
Author
|
This PR depends on #1496 to go green. The failing "Check stable" job isn't caused by this PR: |
yukibtc
reviewed
Oct 19, 2026
The relay answers every REQ with a new stored-events query, but the sender reset a subscription's EOSE flag and pre-EOSE event count only when it dispatched the REQ on a new session. A REQ sent again on the same connection (e.g., CLOSE followed by REQ) kept the state of the previous REQ, so the EOSE flag and the `verify_subscriptions` limit check described the old query: the limit check skipped the replayed events, or counted them on top of the earlier ones. Reset both whenever the sender dispatches a tracked REQ, under the subscriptions write lock that EOSE handling also takes. The new-session behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notification receivers that fall behind lose messages, EOSE included, and `NotificationStream::with_gaps` reports that a gap happened but not which subscriptions lost their EOSE. To repair a long-lived subscription after a gap, a client needs to know whether the relay already answered its current REQ with EOSE and, if not, reissue it as CLOSE followed by REQ. The two frames must be queued together: a lone CLOSE leaves the relay without the subscription, and a lone REQ repeats an open ID, which some relays reject as a duplicate, after which the subscription is dropped. Two `send_msg` calls can split, since the outbound queue is bounded and fed with `try_send`. Return `SubscriptionData` from `Relay::subscriptions` and `Relay::subscription` instead of the filters. Its fields are private, and its getters expose the filters, whether the current REQ received EOSE (the flag the relay read loop already records), and whether the relay closed the subscription. Add `Relay::reissue_subscription`, which reserves two slots in the outbound queue and queues CLOSE and a REQ with the stored filters, or nothing. The REQ is tracked as pending, so the restoration on reconnect doesn't send another one, also when the reissue is queued while disconnected. This is narrower than the `batch_msgs` methods removed in nostrdevkit#1240: the only atomic sequence needed is the reissue of a registered subscription. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent-p1p
force-pushed
the
sdk/reissue-subscription
branch
from
October 19, 2026 10:03
a24efae to
f20d3f6
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Relay and client notifications are broadcast channels, so a receiver that falls behind loses messages,
EOSEincluded.with_gaps()(#1489) reports that a gap happened, but not which subscriptions lost theirEOSE. A client that keeps long-lived subscriptions open, such as MDK, then can't tell whether a subscription's stored events all arrived, and can't safely ask the relay to send them again, so its users can end up with silently missing messages. Re-sending as twosend_msgcalls can split on the bounded outbound queue: a loneCLOSEleaves the relay without the subscription, and a loneREQrepeats an open ID, which relays such as strfry reject withCLOSED duplicate:, after which the SDK drops the subscription for good.Fix
Relay::subscriptionandRelay::subscriptionsnow return aSubscriptionDatathat reports, among other things, whether the subscription's currentREQhas been answered withEOSE, read from the state the relay's read loop already keeps. It can also reissue a registered long-lived subscription:CLOSEand aREQwith the stored filters are queued as one unit (both or neither), and theREQcounts as pending, so the restoration on reconnect doesn't send a second one. The sender now restartsEOSEand limit tracking whenever it dispatches aREQ, not only on a new connection, so both describe the query the relay is actually answering. This is a narrow, subscription-specific method, not a return of the generalbatch_msgsremoved in #1240.Description
MDK's repair after a notification gap, for each live subscription on each relay:
REQwithEOSE, nothing needs doing.New API:
Relay::subscription(&self, id: &SubscriptionId) -> Option<SubscriptionData>andRelay::subscriptions(&self) -> HashMap<SubscriptionId, SubscriptionData>now return the registeredSubscriptionDatainstead of its filters. Its fields are private, and it has getters only, with no constructors:filters() -> &[Filter],received_eose() -> boolandis_closed() -> bool. Repair step 1 isrelay.subscription(id).await.map(|s| s.received_eose()).Client::subscription/subscriptionsstill return per-relay filters.Relay::reissue_subscription(&self, id: &SubscriptionId) -> ReissueSubscription<'_, '_>: anIntoFuturebuilder with outputResult<(), Error>, likeRelay::unsubscribe. It reserves two slots withSender::try_reserve_many(2)and queuesCLOSEthenREQunder the subscriptions lock, likequeue_msg. It errors if the subscription isn't registered (NotFound), is auto-closing (Invalid), if the relay can't read (Unsupported) or isn't operational, or if the queue can't fit both frames (State).Behaviour fix (separate first commit):
subscription_req_startedresetreceived_eoseand the pre-EOSEevent count only whenconnection > subscribed_connection. AREQsent again on the same connection therefore keptreceived_eose = true, so the EOSE query and theverify_subscriptionslimit check both described the previousREQ. The reset now happens every time the sender dispatches a trackedREQ, under the subscriptions write lock thatEOSEhandling also takes. New-session behaviour is unchanged. This also covers aREQre-sent withsend_msgfor a registered subscription, which the relay answers with a new stored-events query too.Notes to the reviewers
batch_msgs. sdk: redesign send msg APIs #1240 removedRelay::batch_msgsandClient::batch_msgsin the API redesign (sdk: redesign APIs #920). The only atomic sequence needed here is "reissue this registered subscription". A narrow method keeps the queue reservation internal, always uses the stored filters (so callers can't desync the registry), and reuses the pending-REQbookkeeping from sdk: avoid duplicate REQ for subscriptions queued while disconnected #1492 and de3031a. NoClientcounterpart for now; MDK can useclient.relay(url).send_msgandsubscribe, and avoids a status check that could race with a reconnect. The queuedREQcounts as pending, so the restoration inpost_connectionskips the subscription. The next session sendsCLOSE(for an ID that session doesn't know) followed by exactly oneREQ. A test checks this with a relay that answers duplicateREQs withCLOSED duplicate:. The reset happens on dispatch, not on enqueue, soSubscriptionData::received_eosedescribes the previousREQuntil then.REQbefore it handled theCLOSEmay arrive after the reset. A lateEOSEsets the flag early. That is acceptable because the filters are the same, so thatEOSEcovers the same stored events. Likewise, late events from the previousREQcount toward the newREQ's limit. This only matters withverify_subscriptionsand a single filter with alimit, and the two queries can't be told apart on the wire.Relay::subscription_received_eose. Following review, the state is exposed throughSubscriptionDatainstead. Because the registry sits behind an async lock, the getters return an owned snapshot.Cloneis implemented by hand because the pre-EOSEevent count is anAtomicUsize.Sender::try_reserve_manyis available since tokio 1.37, the workspace minimum.test_utils. They cover:SubscriptionData: no entry before subscribing, then the stored filters withreceived_eose() == false, thentrueafterEOSE;is_closed()after anauth-required:CLOSED;CLOSE+REQon a live connection, with the EOSE flag and the limit counter restarting;REQafter reconnect;REQresend for the fix commit.just precommitpasses.Checklist
CHANGELOG.md(if applicable)🤖 Generated with Claude Code