Let a feed span finish once half the relays it asked have answered

This commit is contained in:
Coracle-Bot 2026-09-15 21:42:25 +00:00
parent c35a3c2ff0
commit 661b90dafa
6 changed files with 90 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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<HarnessFixtures, HarnessWorkerFixtures>({
// 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)

View file

@ -27,6 +27,7 @@ type Traffic = {
transcript: TranscriptEntry[]
leaks: Set<string>
forgotten: Set<string>
silenced: Set<string>
}
const trafficStore = makeContextStore<Traffic>("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) =>

View file

@ -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"

View file

@ -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