You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
relay: CLOSE during an invite mint is lost, and frames follow the close #951
handlers/subscribe.rs: req calls maybe_issue_invite_code(..).awaitbefore any read is registered, so a CLOSE arriving while the mint is in flight finds nothing to abort. The handler then sends the invite event and starts the stored read — frames on a subscription the client has already closed.
Fix: register cancellation before the first await, and check it before sending or starting further work.
Recon on job 010 split this into two halves. Only the first is a defect.
1. The lost stored-read abort (in scope, being fixed).req calls maybe_issue_invite_code(..).await (crates/nostream-relay/src/handlers/subscribe.rs:57) before any read is registered — the read is only created at :203 — so a CLOSE processed during that window finds nothing to abort. The handler then emits the invite EVENT (:82-90) and streams the whole stored read to EOSE.
Worse in Rust than in the capture, and for a specific reason: the capture never aborts on a plain CLOSE, so its exposure is one stray EVENT; Rust does abort (unsubscribe.rs:11-14), so losing the abort costs the entire read and its EOSE.
2. The invite emit (deliberately not guarded). The capture's emit is unguarded too (src/handlers/subscribe-message-handler.ts:111). Guarding it would be stricter than the capture at a point where no recorded session can adjudicate — none of the 16 wire sessions closes mid-stream. Parity is the acceptance criterion, so the emit stays as-is; see #957, which owns the recorded-decision question the port's CLOSE semantics still lacks.
Deciding check
The mint's only await is context.stores.invites.create(..) (:192), and only when a filter asks for kind 28935 with NIP-43 enabled and invite requests allowed. InMemoryInviteCodeRepository::create is the gate point — mirroring InMemoryEventRepository::gate_reads — so a test can hold the create, deliver CLOSE, release, and assert no invite EVENT and no frames for the stored read.
Note: RateLimits::invite_limited is sync, so a sleeping double inside it deadlocks a single-threaded test; the gate belongs on the repository.
Build started — factory job 010-relay-concurrency-fixes (spec stage done, builder running on factory/010-relay-concurrency-fixes).
Requirements for this issue (R6–R9): the read is registered with begin_read before the first .await in req, and its cancellation is checked after the mint returns and before find_by_filters. A CLOSE during the mint therefore ends the subscription with the one invite EVENT and no stored read, no EOSE. The invite emit stays unguarded on purpose (R8) — parity with the capture, whose emit leaks too; the guard question belongs to #957.
The build restores the abort before the mint's only await, which is the right fix for the window this issue describes. One residual window is not closed by it and is now tracked separately.
Inbound frames are dispatched as independent tasks (crates/nostream-relay/src/relay.rs:304JoinSet, :498handlers.spawn(..)), so a REQ followed immediately by a CLOSE can have the CLOSE processed first: abort_read finds no entry, unsubscribe removes nothing, and the REQ then registers a read that streams to EOSE. abort_read leaves no tombstone, so the later begin_read cannot learn the id was closed.
So the guarantee this issue establishes is: a CLOSE processed after the REQ has registered its read is not lost. A CLOSE that overtakes its REQ is #958, which owns the dispatch-ordering decision. The build record and ADR 0005 state the precondition rather than claiming more.
Landed in #962 (merge commit bca0f6f) on rewrite/main.
Cost — job 010, actual vs expected: $2.34 actual against $250 expected (0.9%). Measured by factory-spans.ts over 17 sessions, 802 requests, 6.0 h summed wall, 3.3M in / 0.7M out / 85.2M cache-read. The job fixed all three defects in one branch; pro-rata by changed source lines this issue's share is about $0.47 (cancellation before the invite mint: subscribe.rs 54 + the handler test, shared with #950), which is an attribution and not a separate bill — the job is the billing unit.
The estimate was the defect, not the work: the model is being replaced with one built from measured jobs (see .factory/estimates.md).
Activity
chappie-daemon commentedon Oct 24, 2026
Recon on job 010 split this into two halves. Only the first is a defect.
1. The lost stored-read abort (in scope, being fixed).
reqcallsmaybe_issue_invite_code(..).await(crates/nostream-relay/src/handlers/subscribe.rs:57) before any read is registered — the read is only created at:203— so aCLOSEprocessed during that window finds nothing to abort. The handler then emits the inviteEVENT(:82-90) and streams the whole stored read toEOSE.Worse in Rust than in the capture, and for a specific reason: the capture never aborts on a plain
CLOSE, so its exposure is one strayEVENT; Rust does abort (unsubscribe.rs:11-14), so losing the abort costs the entire read and itsEOSE.2. The invite emit (deliberately not guarded). The capture's emit is unguarded too (
src/handlers/subscribe-message-handler.ts:111). Guarding it would be stricter than the capture at a point where no recorded session can adjudicate — none of the 16 wire sessions closes mid-stream. Parity is the acceptance criterion, so the emit stays as-is; see #957, which owns the recorded-decision question the port'sCLOSEsemantics still lacks.Deciding check
The mint's only await is
context.stores.invites.create(..)(:192), and only when a filter asks for kind 28935 with NIP-43 enabled and invite requests allowed.InMemoryInviteCodeRepository::createis the gate point — mirroringInMemoryEventRepository::gate_reads— so a test can hold the create, deliverCLOSE, release, and assert no inviteEVENTand no frames for the stored read.Note:
RateLimits::invite_limitedis sync, so a sleeping double inside it deadlocks a single-threaded test; the gate belongs on the repository.chappie-daemon commentedon Oct 24, 2026
Build started — factory job
010-relay-concurrency-fixes(spec stage done, builder running onfactory/010-relay-concurrency-fixes).Requirements for this issue (R6–R9): the read is registered with
begin_readbefore the first.awaitinreq, and its cancellation is checked after the mint returns and beforefind_by_filters. ACLOSEduring the mint therefore ends the subscription with the one inviteEVENTand no stored read, noEOSE. The invite emit stays unguarded on purpose (R8) — parity with the capture, whose emit leaks too; the guard question belongs to #957.chappie-daemon commentedon Oct 24, 2026
The build restores the abort before the mint's only
await, which is the right fix for the window this issue describes. One residual window is not closed by it and is now tracked separately.Inbound frames are dispatched as independent tasks (
crates/nostream-relay/src/relay.rs:304JoinSet,:498handlers.spawn(..)), so aREQfollowed immediately by aCLOSEcan have theCLOSEprocessed first:abort_readfinds no entry,unsubscriberemoves nothing, and theREQthen registers a read that streams toEOSE.abort_readleaves no tombstone, so the laterbegin_readcannot learn the id was closed.So the guarantee this issue establishes is: a
CLOSEprocessed after theREQhas registered its read is not lost. ACLOSEthat overtakes itsREQis #958, which owns the dispatch-ordering decision. The build record and ADR 0005 state the precondition rather than claiming more.fix(relay): read ownership, cancellation before the mint, and a bound…
chappie-daemon commentedon Oct 24, 2026
Landed in #962 (merge commit
bca0f6f) onrewrite/main.Cost — job 010, actual vs expected: $2.34 actual against $250 expected (0.9%). Measured by
factory-spans.tsover 17 sessions, 802 requests, 6.0 h summed wall, 3.3M in / 0.7M out / 85.2M cache-read. The job fixed all three defects in one branch; pro-rata by changed source lines this issue's share is about $0.47 (cancellation before the invite mint: subscribe.rs 54 + the handler test, shared with #950), which is an attribution and not a separate bill — the job is the billing unit.The estimate was the defect, not the work: the model is being replaced with one built from measured jobs (see
.factory/estimates.md).