Stop a silent relay holding up the save path #1

Merged
laoc merged 1 commit from feat/save-latency into main 2026-08-05 18:51:21 +00:00
Owner

Saving a bookmark into a list stalled for 12 seconds after the user had already approved the signature in Amber.

syncListMembership re-reads the user's lists before editing them — correct, it's a read-modify-write on a replaceable event. But it took the complete-answer branch of fetchMapped, which waits for the slowest relay. relay.nostr.band is in DEFAULT_RELAYS and never sends EOSE for these queries (measured 2026-08-05: a kind-39701 REQ returned 0 events and never EOSEd), so every list re-read paid the full FETCH_TIMEOUT.

Measured on-device by the new test, against unfixed code:

AssertionError: waited 12088ms for a relay that never answers (bound 6000ms)

The fix

The complete-answer path now streams and stops once relays go quiet — 3s for the first event, 1.2s of quiet after each — the same shape fetchFollows already uses for the identical problem (reported by laoc 2026-08-04). A healthy set answers in 250-350ms and the stream ends by itself when every relay has sent EOSE; the windows only ever cut off a straggler.

The trade is deliberate: a relay lagging more than SETTLE_QUIET_MS behind its peers no longer contributes to the version we edit from. FETCH_TIMEOUT was never a completeness guarantee either, only a longer bound.

This also fixes the same stall in editList (:1493), the other read-modify-write path.

Testing

SilentRelayE2ETest, backed by scripts/never-eose-relay.py — a relay that completes the websocket upgrade, so rust-nostr counts it as live with an open subscription, and then answers nothing. A closed port would not do: that's refused in ~115ms and exercises a different branch.

  • completeness asserted before latency, so a fast return that lost the healthy relay's copy cannot pass
  • a control test proving the stand-in really is silent (otherwise the timing assertion could pass for the wrong reason)
RED   (unfixed):  expected=2 started=2 passed=0 failed=2   waited 12088ms
GREEN (fixed):    expected=2 started=2 passed=2 failed=0
full suite:       expected=35 started=35 passed=35 failed=0
JVM unit suite:   BUILD SUCCESSFUL

Note on a number I got wrong

I first reported this stall as ~7s, reading nak's 7122/7131ms as a fixed server-side delay. It isn't — that's nak's own client timeout, proven by the stand-in relay (which sends nothing at all) producing the identical 7119ms. The app's bound is 12s, so the real stall was worse than I described.

Not merged yet

0.11.0 is mid-release from main at 668be3d and the signed APK under test was built there, so I've left this for you to merge when that's settled.

Saving a bookmark into a list stalled for **12 seconds after the user had already approved the signature in Amber**. `syncListMembership` re-reads the user's lists before editing them — correct, it's a read-modify-write on a replaceable event. But it took the complete-answer branch of `fetchMapped`, which waits for the *slowest* relay. `relay.nostr.band` is in `DEFAULT_RELAYS` and never sends EOSE for these queries (measured 2026-08-05: a kind-39701 REQ returned 0 events and never EOSEd), so every list re-read paid the full `FETCH_TIMEOUT`. Measured on-device by the new test, against unfixed code: ``` AssertionError: waited 12088ms for a relay that never answers (bound 6000ms) ``` ### The fix The complete-answer path now streams and stops once relays go quiet — 3s for the first event, 1.2s of quiet after each — the same shape `fetchFollows` already uses for the identical problem (reported by laoc 2026-08-04). A healthy set answers in 250-350ms and the stream ends by itself when every relay has sent EOSE; the windows only ever cut off a straggler. **The trade is deliberate:** a relay lagging more than `SETTLE_QUIET_MS` behind its peers no longer contributes to the version we edit from. `FETCH_TIMEOUT` was never a completeness guarantee either, only a longer bound. This also fixes the same stall in `editList` (:1493), the other read-modify-write path. ### Testing `SilentRelayE2ETest`, backed by `scripts/never-eose-relay.py` — a relay that completes the websocket upgrade, so rust-nostr counts it as live with an open subscription, and then answers nothing. A closed port would not do: that's refused in ~115ms and exercises a different branch. - **completeness asserted before latency**, so a fast return that lost the healthy relay's copy cannot pass - a control test proving the stand-in really is silent (otherwise the timing assertion could pass for the wrong reason) ``` RED (unfixed): expected=2 started=2 passed=0 failed=2 waited 12088ms GREEN (fixed): expected=2 started=2 passed=2 failed=0 full suite: expected=35 started=35 passed=35 failed=0 JVM unit suite: BUILD SUCCESSFUL ``` ### Note on a number I got wrong I first reported this stall as ~7s, reading nak's 7122/7131ms as a fixed server-side delay. It isn't — that's **nak's own client timeout**, proven by the stand-in relay (which sends nothing at all) producing the identical 7119ms. The app's bound is 12s, so the real stall was worse than I described. ### Not merged yet 0.11.0 is mid-release from `main` at `668be3d` and the signed APK under test was built there, so I've left this for you to merge when that's settled.
Stop a silent relay holding up the save path
All checks were successful
build / build (push) Successful in 7m52s
build / build (pull_request) Successful in 6m39s
76539d46ac
Saving a bookmark into a list re-reads the user's lists first — correct,
it is a read-modify-write on a replaceable event. But it took the
complete-answer branch of fetchMapped, which waits for the SLOWEST relay,
so one relay that never sends EOSE cost the full 12s FETCH_TIMEOUT
*after* the user had already approved the signature in Amber.

relay.nostr.band is in DEFAULT_RELAYS and behaves exactly that way:
measured 2026-08-05, a kind-39701 REQ returned 0 events and never sent
EOSE. (The 7.1s such a query takes under nak is nak's own client timeout,
not a server-side delay — never-eose-relay.py, which sends nothing at
all, reproduces the same 7119ms. The app's bound is 12s, and the
instrumented test measured 12088ms on-device.)

The complete-answer path now streams and stops when the relays go quiet,
the same shape fetchFollows already uses for the same problem. A healthy
set answers in 250-350ms and the stream ends by itself once every relay
has sent EOSE; the windows only ever cut off a straggler.

The trade is deliberate: a relay lagging more than SETTLE_QUIET_MS behind
its peers no longer contributes to the version we edit from. FETCH_TIMEOUT
was never a completeness guarantee either, only a longer bound.

Tested by SilentRelayE2ETest against never-eose-relay.py, which completes
the websocket upgrade — so rust-nostr counts it as a live relay with an
open subscription — and then answers nothing. A closed port would not do:
that is refused in ~115ms and exercises a different branch. Completeness
is asserted before latency, so a fast return that lost the healthy relay's
copy cannot pass.
laoc merged commit 8748419774 into main 2026-08-05 18:51:21 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
laoc/stash-bookmark!1
No description provided.