From 661b90dafad56daed6b46f5071b2673ce8a8dfed Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Tue, 15 Sep 2026 21:42:25 +0000 Subject: [PATCH] Let a feed span finish once half the relays it asked have answered --- e2e/ARCHITECTURE.md | 6 +++++ e2e/USER_STORIES.md | 2 ++ e2e/harness/index.ts | 12 +++++++++- e2e/harness/net/websocket.ts | 22 ++++++++++++++++++ e2e/specs/notifications.spec.ts | 41 +++++++++++++++++++++++++++++++++ src/app/feeds.ts | 8 +++++++ 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index d7549035..8350a096 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -75,6 +75,12 @@ on, without the url becoming a leak. A reload after it is a client coming back t dropped what it was holding, which is what separates history a client kept from history it is reading back off the wire. +Silence is expressible the same way, and it is the fault a client cannot see: a relay that takes the +socket and then sends nothing at all leaves every request it was given indistinguishable from one +still in flight. `as(user, path, {silent: [url]})` is that from the first connection, which is where +a spec about a page failing to fill needs it; `silenceRelay(context, url)` applies it mid-test. Such +a url needs no tenant behind it, since nothing it says is ever served. + Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index f30571b9..7ae976a6 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1626,6 +1626,8 @@ Acceptance: drawn underneath it, and never appears as an item of its own. - Every note carries its reply count, including the ones with no replies. - Scrolling to the end of the feed loads more rather than asking her to. +- The section fills from the relays that answer. One that takes the connection + and then says nothing does not hold it empty. ### US-106 — Share text into the app diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index c6cf93f8..6fcf6b01 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -16,7 +16,7 @@ import { mockRelayInfo, } from "./net/http" import type {BlossomOptions, HostingFixtures, RelayInfoOverrides} from "./net/http" -import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket" +import {assertNoLeaks, installWebSocketRoutes, silenceRelay} from "./net/websocket" import {watchFaults} from "./faults" import {boot} from "./app/boot" import {injectNip07} from "./app/nip07" @@ -41,6 +41,7 @@ export { getPublished, getPublishedEvents, getTranscript, + silenceRelay, } from "./net/websocket" export {readCachedEvents} from "./app/cache" export { @@ -137,6 +138,10 @@ export type PageOptions = { // What the hosting backend already knows about this user. Read `getHosting(page.context())` for // the handle that changes it mid-test. hosting?: HostingFixtures + // Relay urls that take the socket and answer nothing, from the page's first connection onward. + // `silenceRelay(page.context(), url)` is the same fault applied mid-test, which takes effect on + // the next connection rather than this one. + silent?: string[] } export type Harness = { @@ -240,6 +245,11 @@ export const test = base.extend({ // the page navigates. await installHttpRoutes(context) await installWebSocketRoutes(context, zooid) + + for (const url of options.silent ?? []) { + silenceRelay(context, url) + } + await mockRelayInfo(context, options.relayInfo ?? {}) await mockAnalytics(context) await mockDufflepud(context) diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts index fa23b292..f8cc03b4 100644 --- a/e2e/harness/net/websocket.ts +++ b/e2e/harness/net/websocket.ts @@ -27,6 +27,7 @@ type Traffic = { transcript: TranscriptEntry[] leaks: Set forgotten: Set + silenced: Set } const trafficStore = makeContextStore("installWebSocketRoutes") @@ -51,9 +52,22 @@ const openEmptyRelay = (): RelayConnection => { } } +// A relay that takes the socket and then says nothing at all: no events, no eose, no closed. It is +// the fault a client cannot see, since a request it never answers is indistinguishable from one it +// is still working on, and the only way out is the caller's own deadline. +const openSilentRelay = (): RelayConnection => ({ + onMessage() {}, + send() {}, + close() {}, +}) + const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => { const url = normalizeRelayUrl(route.url()) const connection = call(() => { + if (traffic.silenced.has(url)) { + return openSilentRelay() + } + if (traffic.forgotten.has(url)) { return openEmptyRelay() } @@ -100,6 +114,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => transcript: [], leaks: new Set(), forgotten: new Set(), + silenced: new Set(), }) return context.routeWebSocket( @@ -132,6 +147,13 @@ export const getPublishedEvents = (context: BrowserContext, kind: number) => export const forgetRelay = (context: BrowserContext, url: string) => trafficStore.get(context).forgotten.add(normalizeRelayUrl(url)) +// Makes a relay answer nothing at all, without its url becoming a leak. Silence is all this relay +// is, so it needs no tenant behind it: the url never reaches the container, and naming it here is +// what says the app was meant to open it. Resolved at open, like `forgetRelay`, so a spec that +// wants a page to boot into the fault passes `silent` to `as` instead of calling this. +export const silenceRelay = (context: BrowserContext, url: string) => + trafficStore.get(context).silenced.add(normalizeRelayUrl(url)) + // Every frame in both directions, oldest first. Attach it to a failing test to see what the client // actually said, and to whom. export const formatTranscript = (context: BrowserContext) => diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index a7a96574..00fbe4f8 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -512,6 +512,47 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => expect(fromSpace).toEqual([]) }) +test("US-117 read the network feed when one relay never answers", async ({seed, as}) => { + // Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and + // then says nothing, which is all the spec asks of it. + const stalled = "wss://stalled.test/" + const note = "the drawbridge has been stuck open since noon" + + await seed(({relay, open, user, at}) => { + const space = relay("space") + const indexer = open("indexer") + const outbox = open("outbox") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + // The relays the feed will ask are somewhere she writes and nowhere she reads, which is what + // lets her client identify to them: zooid answers no REQ without nip-42, and Flotilla only + // identifies to relays her own lists name. + indexer.relayList(user.alice, { + read: [space.url, indexer.url], + write: [space.url, indexer.url, outbox.url, stalled], + }) + indexer.follows(user.alice, [user.bob]) + + // Bob writes to both, so the feed asks both, and one of them is never going to answer. + indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url, stalled]}) + outbox.profile(user.bob, {name: "Bob Barker"}) + outbox.note(user.bob, note, at(30, MINUTE)) + }) + + const page = await as(users.alice, "/home", {silent: [stalled]}) + + const network = page + .locator("section") + .filter({has: page.getByRole("heading", {name: "Network"})}) + + // A span releases the events it found once it is done waiting, so a span that waits on every + // relay it asked is a span one silent relay holds empty. + await expect(network.getByText(note)).toBeVisible({timeout: 20_000}) + await expect(network.getByText("Bob Barker")).toBeVisible() +}) + test("US-106 share text into the app", async ({seed, as}) => { const shared = "the offsite is moving to the 14th" diff --git a/src/app/feeds.ts b/src/app/feeds.ts index 94a24ef8..48412214 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -369,6 +369,12 @@ export type FeedLoadState = // nothing in it, and the two have to move the window differently. export type FeedSpan = {found: number; complete: boolean; exhausted: boolean} +// The share of the relays a span asked that have to answer before it stops waiting on the rest. A +// request otherwise waits on every one of them, and a relay that accepts a socket and then says +// nothing neither answers nor drops. A span is also what releases the events it found, so one +// silent relay leaves the feed empty rather than slow. +const SPAN_THRESHOLD = 0.5 + // Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the // history on a single request. const SPANS_PER_TRIGGER = 3 @@ -576,6 +582,7 @@ export const makeFeed = ({ relays, autoClose: true, signal: controller.signal, + threshold: SPAN_THRESHOLD, filters: filters.map(filter => ({...filter, ...extension})), onEvent: countEvent, onDuplicate: countEvent, @@ -748,6 +755,7 @@ export const makeCalendarFeed = ({ relays, autoClose: true, signal: controller.signal, + threshold: SPAN_THRESHOLD, filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}], onEose: () => { complete = true