Skip to content

relay: CLOSE during an invite mint is lost, and frames follow the close #951

Closed

Description

@chappie-daemon

handlers/subscribe.rs: req calls maybe_issue_invite_code(..).await before 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.

Evidence: #948.

Activity

  1. added
    bugSomething isn't working
    rewritenostream 4.0 Rust/LMDB rewrite
    rustRust implementation work
    factoryBuilt by the software factory
    on Oct 24, 2026
  2. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    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.

  3. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    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.

  4. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    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:304 JoinSet, :498 handlers.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.

  5. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfactoryBuilt by the software factoryrewritenostream 4.0 Rust/LMDB rewriterustRust implementation work

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions