Let a feed span finish once half the relays it asked have answered
This commit is contained in:
parent
c35a3c2ff0
commit
661b90dafa
6 changed files with 90 additions and 1 deletions
|
|
@ -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
|
dropped what it was holding, which is what separates history a client kept from history it is
|
||||||
reading back off the wire.
|
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
|
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
|
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
|
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
|
||||||
|
|
|
||||||
|
|
@ -1626,6 +1626,8 @@ Acceptance:
|
||||||
drawn underneath it, and never appears as an item of its own.
|
drawn underneath it, and never appears as an item of its own.
|
||||||
- Every note carries its reply count, including the ones with no replies.
|
- 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.
|
- 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
|
### US-106 — Share text into the app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
mockRelayInfo,
|
mockRelayInfo,
|
||||||
} from "./net/http"
|
} from "./net/http"
|
||||||
import type {BlossomOptions, HostingFixtures, RelayInfoOverrides} 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 {watchFaults} from "./faults"
|
||||||
import {boot} from "./app/boot"
|
import {boot} from "./app/boot"
|
||||||
import {injectNip07} from "./app/nip07"
|
import {injectNip07} from "./app/nip07"
|
||||||
|
|
@ -41,6 +41,7 @@ export {
|
||||||
getPublished,
|
getPublished,
|
||||||
getPublishedEvents,
|
getPublishedEvents,
|
||||||
getTranscript,
|
getTranscript,
|
||||||
|
silenceRelay,
|
||||||
} from "./net/websocket"
|
} from "./net/websocket"
|
||||||
export {readCachedEvents} from "./app/cache"
|
export {readCachedEvents} from "./app/cache"
|
||||||
export {
|
export {
|
||||||
|
|
@ -137,6 +138,10 @@ export type PageOptions = {
|
||||||
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for
|
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for
|
||||||
// the handle that changes it mid-test.
|
// the handle that changes it mid-test.
|
||||||
hosting?: HostingFixtures
|
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 = {
|
export type Harness = {
|
||||||
|
|
@ -240,6 +245,11 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
||||||
// the page navigates.
|
// the page navigates.
|
||||||
await installHttpRoutes(context)
|
await installHttpRoutes(context)
|
||||||
await installWebSocketRoutes(context, zooid)
|
await installWebSocketRoutes(context, zooid)
|
||||||
|
|
||||||
|
for (const url of options.silent ?? []) {
|
||||||
|
silenceRelay(context, url)
|
||||||
|
}
|
||||||
|
|
||||||
await mockRelayInfo(context, options.relayInfo ?? {})
|
await mockRelayInfo(context, options.relayInfo ?? {})
|
||||||
await mockAnalytics(context)
|
await mockAnalytics(context)
|
||||||
await mockDufflepud(context)
|
await mockDufflepud(context)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type Traffic = {
|
||||||
transcript: TranscriptEntry[]
|
transcript: TranscriptEntry[]
|
||||||
leaks: Set<string>
|
leaks: Set<string>
|
||||||
forgotten: Set<string>
|
forgotten: Set<string>
|
||||||
|
silenced: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes")
|
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 serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
|
||||||
const url = normalizeRelayUrl(route.url())
|
const url = normalizeRelayUrl(route.url())
|
||||||
const connection = call(() => {
|
const connection = call(() => {
|
||||||
|
if (traffic.silenced.has(url)) {
|
||||||
|
return openSilentRelay()
|
||||||
|
}
|
||||||
|
|
||||||
if (traffic.forgotten.has(url)) {
|
if (traffic.forgotten.has(url)) {
|
||||||
return openEmptyRelay()
|
return openEmptyRelay()
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +114,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
|
||||||
transcript: [],
|
transcript: [],
|
||||||
leaks: new Set(),
|
leaks: new Set(),
|
||||||
forgotten: new Set(),
|
forgotten: new Set(),
|
||||||
|
silenced: new Set(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return context.routeWebSocket(
|
return context.routeWebSocket(
|
||||||
|
|
@ -132,6 +147,13 @@ export const getPublishedEvents = (context: BrowserContext, kind: number) =>
|
||||||
export const forgetRelay = (context: BrowserContext, url: string) =>
|
export const forgetRelay = (context: BrowserContext, url: string) =>
|
||||||
trafficStore.get(context).forgotten.add(normalizeRelayUrl(url))
|
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
|
// Every frame in both directions, oldest first. Attach it to a failing test to see what the client
|
||||||
// actually said, and to whom.
|
// actually said, and to whom.
|
||||||
export const formatTranscript = (context: BrowserContext) =>
|
export const formatTranscript = (context: BrowserContext) =>
|
||||||
|
|
|
||||||
|
|
@ -512,6 +512,47 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) =>
|
||||||
expect(fromSpace).toEqual([])
|
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}) => {
|
test("US-106 share text into the app", async ({seed, as}) => {
|
||||||
const shared = "the offsite is moving to the 14th"
|
const shared = "the offsite is moving to the 14th"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,12 @@ export type FeedLoadState =
|
||||||
// nothing in it, and the two have to move the window differently.
|
// nothing in it, and the two have to move the window differently.
|
||||||
export type FeedSpan = {found: number; complete: boolean; exhausted: boolean}
|
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
|
// Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the
|
||||||
// history on a single request.
|
// history on a single request.
|
||||||
const SPANS_PER_TRIGGER = 3
|
const SPANS_PER_TRIGGER = 3
|
||||||
|
|
@ -576,6 +582,7 @@ export const makeFeed = ({
|
||||||
relays,
|
relays,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
threshold: SPAN_THRESHOLD,
|
||||||
filters: filters.map(filter => ({...filter, ...extension})),
|
filters: filters.map(filter => ({...filter, ...extension})),
|
||||||
onEvent: countEvent,
|
onEvent: countEvent,
|
||||||
onDuplicate: countEvent,
|
onDuplicate: countEvent,
|
||||||
|
|
@ -748,6 +755,7 @@ export const makeCalendarFeed = ({
|
||||||
relays,
|
relays,
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
threshold: SPAN_THRESHOLD,
|
||||||
filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}],
|
filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}],
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
complete = true
|
complete = true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue