Skip to content

fix(relay): run a handler's ordered prefix on the connection loop - #978

Merged
cameri merged 10 commits into
rewrite/mainfrom
factory/012-req-close-order
Oct 25, 2026
Merged

cameri merged 10 commits into
rewrite/mainfrom
factory/012-req-close-order

Conversation

@chappie-daemon

Copy link
Copy Markdown
Collaborator

Inbound frames were dispatched as independent tasks, so two frames read in order could run in either order. A REQ followed immediately by a CLOSE for the same id could have the CLOSE run first — the subscription then registered with nothing left to abort it, and the read streamed to EOSE on a subscription the client had closed. This restores the capture's guarantee for the state that matters, and closes #958.

Closes #958.

The defect, measured

Recon reproduced it before anything was designed: on a multi-thread runtime a REQ+CLOSE pair written in one flush leaves the subscription registered and streaming in 30 of 30 runs (18/20 at two workers, 29/30 at four), and in 0 of 20 on current_thread — which is why the whole suite was green.

A second symptom came out of the same cause: a pipelined AUTH followed by a restricted REQ was refused auth-required in 10 of 10 multi-thread runs, because the REQ ran before the AUTH that unlocks it.

The fix

handlers::dispatch is gone. handlers::begin(message, &Arc<HandlerContext>) -> Option<HandlerFuture> runs the message's ordering-sensitive prefix on the connection loop and hands back the continuation for the existing JoinSet:

arm what runs on the loop what stays spawned
REQ normalise_subscription, can_subscribe, subscribe, begin_read the stored read and the invite mint
CLOSE the whole handler (it has no suspension point) —
AUTH the whole handler (nip42::authenticate) —
EVENT, COUNT nothing the whole handler, by rule

The prefixes are plain fns, so a suspension point inside one is not representable; connection.rs is untouched. The capture's guarantee is the same shape — it binds each 'message' listener without awaiting it (web-socket-adapter.ts:79, :198) and awaits the handler inside that listener (:229), so its ordered prefix is likewise only the part before the first await.

Cost of the move, measured on this branch: CLOSE 0.053 µs, REQ 0.72–0.76 µs (0.11 µs normalisation + 0.61–0.65 µs subscription-table work), AUTH 34.3 µs (32.2–32.5 µs verify + 1.28 µs id hash) per message on the loop. For comparison the capture's same verify costs 1.57–1.85 ms/op of CPU on Node — the port is 46–54× cheaper and blocks one socket rather than the only thread.

Red before, green after

AC10 requires the evidence in the history, not the prose. At 2c56dce — the branch's first commit, tests only, 1 file changed, 130 insertions(+) — both new tests fail on the unfixed tree:

a closed subscription streamed: ["[\"OK\",\"4ca7ff…\",true,\"\"]",
                                "[\"EVENT\",\"s1\",{…,\"content\":\"live\",…}]"]

a_pipelined_auth_before_a_restricted_req_is_ordered ... FAILED
  left:  ["CLOSED","s1","auth-required: authentication is required to request these event kinds"]
  right: ["OK","66a61c…",true,""]

At the head both pass, and the ordering proof does not rest on a scheduler coincidence: the oracle anchors on a NOTICE that the connection loop itself writes for an undecodable third frame, so receiving it proves the loop read the two frames ahead of it. The only timed assertion left is a silence window where silence is the assertion. a_handler_prefix_runs_before_its_continuation_is_polled proves the same property without a scheduler at all.

Verification

  • cargo test --workspace --all-features: 905 tests, 0 failed, 56 test targets, in debug and release.
  • cargo fmt --all --check clean; both clippy passes clean with no warning line; release build clean.
  • Parity oracle prints its expected census line to the digit.
  • The oracle test's base-red was re-measured twice, independently, against 2b2287f: 5/5 failures with the branch's test file over base sources, 3/3 in the approver's own run.

Review

Four rounds; the executable change was stable from the first, and every finding after it was a sentence describing it. ADR 0005 records the decision, the two rejected shapes (serialized handlers, per-connection ordering token) and why. security, ux and ui stood down, with diff-grounded reasons in the review plan; the approver re-examined the security skip against the whole diff and judged it sound, noting that a security read of the next auth-adjacent job is worth its cost.

Notes

Both pairs are written in one flush, so the loop reads them back to back
and the ordering between their handlers decides the outcome. Neither test
passes on this tree: the CLOSE finds nothing to abort and the restricted
REQ is refused before its AUTH is handled.
`handlers::dispatch` gave every message's whole handler to the per-socket
`JoinSet`, so two frames read in order could run in either order: a `CLOSE`
could be handled ahead of its own `REQ` and find nothing to abort, and a
restricted `REQ` could be refused before the `AUTH` that preceded it.

`handlers::begin` runs each message's ordering-sensitive prefix on the loop
task, before the next frame is read, and returns the continuation for the
`JoinSet`. The prefixes are `subscribe` (through `begin_read`),
`unsubscribe` and `auth`; `EVENT` and `COUNT` keep their whole body as one
spawned future. Continuations stay concurrent, so a `CLOSE` during a gated
invite mint still aborts the read.
The ADR states the chosen shape (a prefix on the connection loop), the two
rejected shapes with their reasons, the `AUTH`->`REQ` symptom, the
`EVENT`/`COUNT` exclusion and the guarantee's boundary: prefix order, not
handler serialisation. Its "invite emit stays unguarded" paragraph no
longer describes a `CLOSE` overtaking its `REQ` as a live precondition.

The changeset carries the release note; the TypeScript tree is untouched.
The oracle waited 50 ms for the REQ handler to register and then read frames
for a fixed 150 ms window, asserting the window held exactly one frame. Both
are load-dependent: a loaded runner could miss the live event's OK in the
window and fail with the ordering correct, and a short wait at the head would
let the test pass without the defect being exercised.

The obstacle frame is now an undecodable one, whose NOTICE the connection loop
writes itself, so receiving it proves the loop read the REQ and CLOSE ahead of
it. The live event is sent after that barrier and its own OK, on the client's
two-second deadline, ends the wait; the only timed assertion left is the
post-gate silence, where a bounded window is the assertion.
`begin`'s docblock promised that every statement a handler runs before its
first suspension point has run in arrival order, which is false for the
`Event` and `Count` arms: they are boxed whole, so none of their body is
ordered. The postcondition is now stated about the prefix, with the
single-caller precondition the guarantee rests on, and the two excluded arms
say so at the edit site.

The reason given for excluding `EVENT` and `COUNT` was untrue: the event
limiter is keyed on this connection's address and each `EVENT` consults it
from its own task, so two pipelined events may record their hits in either
order. The module doc and ADR 0005 now give the criterion that does hold (no
later frame needs an entry these handlers establish) and record that
consequence as parity with the capture, whose limiter hit also sits after its
first `await`.

ADR 0005's cost clause now names the `AUTH` prefix, the expensive one, with
its measured cost and the interleaving it gives up.
…t figures

The event limiter is not keyed on the connection: `RateLimits::event_backoff`
keys it on the event's author and window, in the table the whole relay shares,
so the module doc and ADR 0005 now say that and keep the consequence — two
pipelined `EVENT`s from one author record their hits in poll order. Both also
give the capture's reason: it binds each `message` event to a handler it does
not await, so its hits are unordered too.

`begin`'s docblock said `None` means the prefix answered the message, which
the `CLOSE` arm disproves: it returns `None` having written no frame. `None`
now means the prefix handled the message and nothing is left to run, writing
whatever frames its handler writes.

The cost clause no longer presents a derived sum as a measurement: the `REQ`
figure is named as its parts (0.11 µs normalisation plus 0.61–0.65 µs of
subscription-table work, 0.72–0.76 µs), the verify as the 32.2–32.5 µs range
it was measured over, and the attribution says this branch. It also drops the
claim that the prefix costs only interleaving: the loop is occupied for those
34 µs, which caps a pipelining client's `AUTH` rate.

ADR 0005's front matter now names both jobs in the title, the deciders'
evidence and the context's four relationships.
Round 3's front-matter edit dropped a line from the ADR's Context paragraph,
leaving "whose only consumer was the queue grow without limit" — the frame
queue problem had vanished from the record. The clause is restored, so the
paragraph again states the four relationships it counts.

The module doc and ADR 0005 now say the one shared table the excluded handlers
touch is relay-wide — the event limiter, keyed on the author and window —
rather than denying that they touch per-connection state, which `COUNT`'s sink
write and `EVENT`'s `remote_address` read contradict.

The capture's parity mechanism is stated as it is: the socket fires each
'message' listener without awaiting it (:79, :198) and the handler is awaited
inside that listener (:229), so two pipelined `EVENT`s run as interleaved
chains there too. The old citation named the await as proof of the opposite.
@changeset-bot

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9accda1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] This PR appears safe to merge; no new blocking issues were found.

Summary

This PR runs each handler’s ordering-sensitive work on the connection loop before reading the next frame.

  • The relay applies subscription and auth changes in frame order.

No new actionable issues were found. The previous documentation finding is addressed. chappie-daemon explicitly leaves invite delivery after CLOSE unchanged to match TypeScript and tracks the broader closed-read behavior separately in #957.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Read and decode frame] --> B[Call handlers::begin]
    B --> C{Message}
    C -->|REQ| D[Check and register subscription and read]
    C -->|CLOSE| E[Remove subscription and cancel read]
    C -->|AUTH| F[Verify and save session]
    C -->|EVENT or COUNT| G[Return whole handler future]
    D --> H[Spawn remaining work]
    G --> H
    E --> I[Read next frame]
    F --> I
    H --> I
Loading

Reviews (3) · Last reviewed commit: "Merge branch 'rewrite/main' into factory..." · Reviewed by Greptile

Comment thread docs/rewrite/decisions/0005-connection-concurrency.md Outdated
…parity

Greptile P2 on #978, and it is right: the capture's AuthMessageHandler awaits
isEventIdValid and isEventSignatureValid before addAuthenticatedPubkey, so a REQ
pipelined behind an AUTH can be judged against a session that has not been
recorded and be refused. Rust verifies and records on the connection loop, so
the session exists before the next frame is read — a stronger guarantee.

ADR 0005 now says so, names the cost (the read path waits for two signature
checks, once per AUTH frame) and the module doc points at it. Behaviour is
unchanged; only the claim about it was wrong.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants