libp2r2p 0.11.13 → 0.11.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -3
- package/package.json +1 -1
- package/private-messenger/helpers/send-routing.js +2 -10
- package/relay/helpers/failure.js +15 -0
- package/relay/helpers/read-admission.js +1 -1
- package/relay/helpers/read-retry.js +106 -0
- package/relay/index.js +1 -0
- package/relay/services/events.js +39 -10
- package/relay/services/query.js +74 -47
- package/relay/services/relay-pool.js +108 -41
package/README.md
CHANGED
|
@@ -136,12 +136,39 @@ coverage of a disconnected interval. This is client-observed continuity, not a
|
|
|
136
136
|
relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
|
|
137
137
|
can still leave gaps. A backward local clock cannot regress an emitted boundary.
|
|
138
138
|
|
|
139
|
-
Every involuntary live close emits an error
|
|
139
|
+
Every involuntary live close emits an error. Recoverable failures permit automatic recovery.
|
|
140
140
|
A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
|
|
141
141
|
and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
|
|
142
142
|
initial report. Explicit causes are preserved. Caller cancellation, draining and
|
|
143
143
|
expiry of `filter.until` do not manufacture interruption errors.
|
|
144
144
|
|
|
145
|
+
### Failure-aware live recovery (0.11.15)
|
|
146
|
+
|
|
147
|
+
Live readers retry connection/transport/timeouts and structured `rate-limited:`
|
|
148
|
+
or `error:` refusals. Policy refusals (`blocked:`, `restricted:`, `auth-required:`,
|
|
149
|
+
`pow:`), local authentication/validation failures and unknown errors stop that
|
|
150
|
+
relay for this iterator. Other relays continue. When every route has stopped,
|
|
151
|
+
the iterator finishes after draining accepted events and original diagnostics;
|
|
152
|
+
its readiness snapshot never claims a failed route sent EOSE. A new explicit
|
|
153
|
+
reader starts a new operation; refusals do not blacklist a relay across readers.
|
|
154
|
+
|
|
155
|
+
Retry delays are 1, 2, 4... seconds, capped at five minutes without an attempt
|
|
156
|
+
limit. The next attempt waits until max(backoff deadline, relay retryAt). A
|
|
157
|
+
confirmed offline failure spends no backoff step: it waits for the existing
|
|
158
|
+
shared `onOnline` monitor, without holding read-admission slots. Connectivity
|
|
159
|
+
checks are bounded and shared per pool; structured relay responses need no new
|
|
160
|
+
internet probe unless the browser indicates offline. Cancelled readers release
|
|
161
|
+
their own waits without cancelling another reader's recovery.
|
|
162
|
+
|
|
163
|
+
Successful live EOSE and any required reconnect history reset the delay.
|
|
164
|
+
Transient reconnect-history failures retry while retaining their original gap
|
|
165
|
+
baseline; definitive failures stop that route without certifying its gap.
|
|
166
|
+
Accepted history and buffered live remain deliverable. Local queue-full,
|
|
167
|
+
queue-timeout and disconnect admission failures retain local recovery; impossible
|
|
168
|
+
capacity is terminal. Caller cancellation is silent. Native timeout messages stay
|
|
169
|
+
unchanged and now consistently carry category `timeout`. Constructor hooks
|
|
170
|
+
`_isOnline`/`_onOnline` are internal test injections, not an app bridge API.
|
|
171
|
+
|
|
145
172
|
Process Nostr events only under `item.type === 'event'`. Handle controls you need
|
|
146
173
|
and ignore unknown types; a new control is neither an event nor stream completion.
|
|
147
174
|
The internal query, private-channel and NIP-46 consumers retain their existing
|
|
@@ -161,7 +188,7 @@ atomically before either REQ opens; completing history releases only its slot.
|
|
|
161
188
|
One-shot queries and reconnect gap reads share this budget. Reconnection reserves
|
|
162
189
|
live+history together when recovery is needed. Caller cancellation removes queued
|
|
163
190
|
work; `disconnect(url)` also rejects queued work for that connection. Existing
|
|
164
|
-
live streams retain
|
|
191
|
+
live streams retain recovery for eligible failures until cancelled by their owner.
|
|
165
192
|
|
|
166
193
|
All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
|
|
167
194
|
The network `timeout` starts **after admission for each relay**, including its
|
|
@@ -877,9 +904,42 @@ the first pass routes through up to `maxPerPubkey` shared relays, and missing
|
|
|
877
904
|
authors are retried on every remaining relay plus `fallbackRelays` (default:
|
|
878
905
|
the first three `freeRelays`), excluding what was already queried. It accepts
|
|
879
906
|
`relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
|
|
880
|
-
discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
|
|
907
|
+
discovered) and returns `{ events, byPubkey, relaysByPubkey, requests }` so the merged
|
|
881
908
|
relay map can be passed back on later calls.
|
|
882
909
|
|
|
910
|
+
|
|
911
|
+
Read diagnostics preserve native failures rather than treating an empty result
|
|
912
|
+
as proof that every relay completed successfully. `requests` contains records
|
|
913
|
+
`{ phase, authors, relays, errors?, error? }`: phase is `discovery`, `primary` or
|
|
914
|
+
`fallback`; `relays` uses the pool's `{ relay, status, error? }` outcomes. An
|
|
915
|
+
operation-wide exception is retained as `error`. Events remain in the existing
|
|
916
|
+
result fields, never in diagnostic records. `onQueryResult(record)` receives each
|
|
917
|
+
record as the underlying query settles, including before a later cancellation.
|
|
918
|
+
|
|
919
|
+
Both discovery and latest-event queries accept `signal`. Cancelling one consumer
|
|
920
|
+
of shared NIP-65 discovery releases only its interest; the underlying query is
|
|
921
|
+
aborted when its last consumer leaves. Discovery's optional `onQueryResult`
|
|
922
|
+
receives the same native report for every joining caller. Incomplete/failed
|
|
923
|
+
lookups can use fallback relays for that call but never negatively cache a
|
|
924
|
+
missing relay list for forty minutes. Actual lists and completely empty EOSE
|
|
925
|
+
responses retain their normal cache behavior.
|
|
926
|
+
|
|
927
|
+
`getLatestEventsByPubkey` accepts `excludeRelaysByPubkey` (object or Map) for both
|
|
928
|
+
passes. `getRelaysByPubkey` accepts `excludeRelays` for seed discovery; pass it
|
|
929
|
+
through `relayListOptions` when using the two-pass query. Different exclusion
|
|
930
|
+
sets do not share in-flight discovery that could contact a refused relay.
|
|
931
|
+
|
|
932
|
+
`isRetryableRelayFailure(error)` identifies connection/transport/timeouts and
|
|
933
|
+
leading `rate-limited:`/`error:` relay failures. `isReplaceableRelayFailure(error)`
|
|
934
|
+
also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
|
|
935
|
+
`pow:`) for routing to alternatives, preserving the messenger's existing policy.
|
|
936
|
+
Local authentication denials, invalid events and unknown errors qualify for
|
|
937
|
+
neither policy. Predicates classify individual native errors; callers decide
|
|
938
|
+
how to handle mixed per-relay outcomes and application-specific aggregates.
|
|
939
|
+
Existing rate-limit parsing preserves numeric positive `retry_after` seconds as
|
|
940
|
+
`retryAfterMs` and absolute `retryAt`, bounded to five minutes. Schedulers must
|
|
941
|
+
use the later of their backoff deadline and `retryAt`, not add the two delays.
|
|
942
|
+
|
|
883
943
|
## Binary encodings
|
|
884
944
|
|
|
885
945
|
Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
|
package/package.json
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
|
+
import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
3
4
|
|
|
4
|
-
const REJECTION_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', 'rate-limited', 'error'])
|
|
5
|
-
const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
|
|
6
5
|
const EXCLUSION_MS = 5 * 60 * 1000
|
|
7
6
|
const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { return '' } }
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
// local signer denials and unknown prose must not rotate relays.
|
|
11
|
-
export function isReplaceableRelayFailure (error) {
|
|
12
|
-
if (error?.name === 'Nip42AuthenticationError') return false
|
|
13
|
-
if (TRANSPORT_CATEGORIES.has(error?.category)) return true
|
|
14
|
-
if (error?.category && error.category !== 'relay') return false
|
|
15
|
-
return REJECTION_PREFIXES.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
|
|
16
|
-
}
|
|
8
|
+
export { isReplaceableRelayFailure }
|
|
17
9
|
|
|
18
10
|
export function normalizeFallbackRelays (value = []) {
|
|
19
11
|
if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
|
|
2
|
+
const RETRY_PREFIXES = new Set(['rate-limited', 'error'])
|
|
3
|
+
const REPLACEMENT_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
|
|
4
|
+
|
|
5
|
+
// Retry and replacement are different policies: a refusal can justify another
|
|
6
|
+
// relay without making the same operation retryable on the refusing relay.
|
|
7
|
+
function matches (error, prefixes) {
|
|
8
|
+
if (error?.name === 'Nip42AuthenticationError') return false
|
|
9
|
+
if (TRANSPORT_CATEGORIES.has(error?.category)) return true
|
|
10
|
+
if (error?.category && error.category !== 'relay') return false
|
|
11
|
+
return prefixes.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isRetryableRelayFailure = error => matches(error, RETRY_PREFIXES)
|
|
15
|
+
export const isReplaceableRelayFailure = error => matches(error, REPLACEMENT_PREFIXES)
|
|
@@ -2,7 +2,7 @@ import { ValidationError } from '../../error/index.js'
|
|
|
2
2
|
import { maybeUnref } from './timer.js'
|
|
3
3
|
|
|
4
4
|
export function admissionError (code, relay) {
|
|
5
|
-
return Object.assign(new Error(code), { code, relay, phase: 'admission' })
|
|
5
|
+
return Object.assign(new Error(code), { code, relay, phase: 'admission', ...(code === 'RELAY_READ_QUEUE_TIMEOUT' ? { category: 'timeout' } : {}) })
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
// One FIFO per pooled connection. A feed reserves both REQs atomically; its
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isOnline, onOnline } from '../../network/index.js'
|
|
2
|
+
import { isRetryableRelayFailure } from './failure.js'
|
|
3
|
+
import { maybeUnref } from './timer.js'
|
|
4
|
+
|
|
5
|
+
const LOCAL_RETRY_CODES = new Set(['RELAY_READ_QUEUE_FULL', 'RELAY_READ_QUEUE_TIMEOUT', 'RELAY_DISCONNECTED'])
|
|
6
|
+
|
|
7
|
+
export function isRetryableReadFailure (error) {
|
|
8
|
+
if (error?.name === 'ValidationError' || error?.name === 'Nip42AuthenticationError') return false
|
|
9
|
+
if (error?.phase === 'admission') return LOCAL_RETRY_CODES.has(error.code)
|
|
10
|
+
return isRetryableRelayFailure(error)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function needsConnectivityCheck (error) {
|
|
14
|
+
return globalThis.navigator?.onLine === false || (error?.phase !== 'admission' && ['connection', 'transport', 'timeout'].includes(error?.category))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function waitUntil (deadline, signal) {
|
|
18
|
+
signal.throwIfAborted()
|
|
19
|
+
if (deadline <= Date.now()) return Promise.resolve()
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); resolve() }
|
|
22
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason) }
|
|
23
|
+
const timer = maybeUnref(setTimeout(finish, Math.max(0, deadline - Date.now())))
|
|
24
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function waitFor (promise, signal) {
|
|
29
|
+
signal.throwIfAborted()
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason) }
|
|
32
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
33
|
+
promise.then(value => { signal.removeEventListener('abort', abort); resolve(value) }, error => {
|
|
34
|
+
signal.removeEventListener('abort', abort); reject(error)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One connectivity check/online subscription per pool, with independent waits.
|
|
40
|
+
// Ending one reader must never cancel recovery still needed by another reader.
|
|
41
|
+
export class ReadRetry {
|
|
42
|
+
#checkOnline
|
|
43
|
+
#watchOnline
|
|
44
|
+
#probe
|
|
45
|
+
#monitor
|
|
46
|
+
#waiters = new Set()
|
|
47
|
+
|
|
48
|
+
constructor ({ checkOnline = isOnline, watchOnline = onOnline } = {}) {
|
|
49
|
+
this.#checkOnline = checkOnline
|
|
50
|
+
this.#watchOnline = watchOnline
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async confirmOnline (signal) {
|
|
54
|
+
signal.throwIfAborted()
|
|
55
|
+
if (globalThis.navigator?.onLine === false) return false
|
|
56
|
+
if (!this.#probe || this.#probe.controller.signal.aborted) {
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
const record = { controller, users: 0, finished: false }
|
|
59
|
+
const stopped = new Promise(resolve => controller.signal.addEventListener('abort', () => resolve(false), { once: true }))
|
|
60
|
+
const timer = maybeUnref(setTimeout(() => controller.abort(), 6000))
|
|
61
|
+
record.promise = Promise.race([
|
|
62
|
+
Promise.resolve().then(() => this.#checkOnline({ signal: controller.signal })).catch(() => false), stopped
|
|
63
|
+
]).then(value => value === true).finally(() => {
|
|
64
|
+
record.finished = true
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
controller.abort()
|
|
67
|
+
if (this.#probe === record) this.#probe = null
|
|
68
|
+
})
|
|
69
|
+
this.#probe = record
|
|
70
|
+
}
|
|
71
|
+
const record = this.#probe
|
|
72
|
+
record.users++
|
|
73
|
+
try { return await waitFor(record.promise, signal) } finally {
|
|
74
|
+
if (--record.users === 0 && !record.finished) record.controller.abort()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
waitUntilOnline (signal) {
|
|
79
|
+
signal.throwIfAborted()
|
|
80
|
+
const ready = Promise.withResolvers()
|
|
81
|
+
this.#waiters.add(ready)
|
|
82
|
+
if (!this.#monitor) {
|
|
83
|
+
const monitor = { stop: () => {}, ended: false }
|
|
84
|
+
this.#monitor = monitor
|
|
85
|
+
monitor.stop = this.#watchOnline(() => {
|
|
86
|
+
if (this.#monitor !== monitor) return
|
|
87
|
+
for (const waiter of this.#waiters) waiter.resolve()
|
|
88
|
+
this.#waiters.clear()
|
|
89
|
+
this.#stopIfIdle()
|
|
90
|
+
})
|
|
91
|
+
if (monitor.ended) monitor.stop()
|
|
92
|
+
}
|
|
93
|
+
return waitFor(ready.promise, signal).finally(() => {
|
|
94
|
+
this.#waiters.delete(ready)
|
|
95
|
+
this.#stopIfIdle()
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#stopIfIdle () {
|
|
100
|
+
if (this.#waiters.size || !this.#monitor) return
|
|
101
|
+
const monitor = this.#monitor
|
|
102
|
+
this.#monitor = null
|
|
103
|
+
monitor.ended = true
|
|
104
|
+
monitor.stop()
|
|
105
|
+
}
|
|
106
|
+
}
|
package/relay/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { freeRelays, nappRelays, seedRelays } from './constants/index.js'
|
|
2
|
+
export { isRetryableRelayFailure, isReplaceableRelayFailure } from './helpers/failure.js'
|
|
2
3
|
export { pickRelaysForPubkeys } from './helpers/routing.js'
|
|
3
4
|
export { RelayPool, relayPool } from './services/relay-pool.js'
|
|
4
5
|
export { getLatestEventsByPubkey } from './services/events.js'
|
package/relay/services/events.js
CHANGED
|
@@ -45,7 +45,7 @@ function getSelectedRelaysByPubkey (pubkeys, relayToAuthors) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Fetches batched events and keeps the newest event per address.
|
|
48
|
-
async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents }) {
|
|
48
|
+
async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents, report, phase, signal }) {
|
|
49
49
|
const requests = []
|
|
50
50
|
for (const [relay, authors] of relayToAuthors) {
|
|
51
51
|
const authorsByD = new Map()
|
|
@@ -57,8 +57,17 @@ async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey,
|
|
|
57
57
|
for (const [d, dAuthors] of authorsByD) {
|
|
58
58
|
const filter = { kinds, authors: dAuthors }
|
|
59
59
|
if (d) filter['#d'] = [d]
|
|
60
|
-
requests.push(
|
|
61
|
-
|
|
60
|
+
requests.push(Promise.resolve().then(async () => {
|
|
61
|
+
signal?.throwIfAborted()
|
|
62
|
+
try {
|
|
63
|
+
const response = await getEvents(filter, [relay], signal ? { signal } : {})
|
|
64
|
+
report({ phase, authors: dAuthors, relays: response.relays || [], errors: response.errors || [] })
|
|
65
|
+
return { requested: new Set(dAuthors), events: (response.result || []).map(({ event }) => event) }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
report({ phase, authors: dAuthors, relays: [{ relay, status: 'error', error }], error })
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}))
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -95,11 +104,20 @@ export async function getLatestEventsByPubkey (pubkeys, {
|
|
|
95
104
|
fallbackRelays = freeRelays.slice(0, DEFAULT_FALLBACK_RELAY_COUNT),
|
|
96
105
|
relaysByPubkey,
|
|
97
106
|
relayListOptions,
|
|
107
|
+
excludeRelaysByPubkey,
|
|
108
|
+
signal,
|
|
109
|
+
onQueryResult,
|
|
98
110
|
_getRelaysByPubkey = getRelaysByPubkey,
|
|
99
111
|
_getEvents = getEvents
|
|
100
112
|
} = {}) {
|
|
113
|
+
signal?.throwIfAborted()
|
|
114
|
+
const requests = []
|
|
115
|
+
const report = request => { requests.push(request); onQueryResult?.(request) }
|
|
116
|
+
const excluded = pubkey => excludeRelaysByPubkey instanceof Map
|
|
117
|
+
? excludeRelaysByPubkey.get(pubkey) || []
|
|
118
|
+
: excludeRelaysByPubkey?.[pubkey] || []
|
|
101
119
|
const authors = [...new Set(pubkeys || [])].filter(Boolean)
|
|
102
|
-
if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
|
|
120
|
+
if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {}, requests }
|
|
103
121
|
if (!Array.isArray(kinds) || kinds.length === 0) {
|
|
104
122
|
throw new ValidationError('MISSING_EVENT_KINDS', { message: 'Missing kinds' })
|
|
105
123
|
}
|
|
@@ -110,9 +128,16 @@ export async function getLatestEventsByPubkey (pubkeys, {
|
|
|
110
128
|
if (missingRelayAuthors.length) {
|
|
111
129
|
let discovered
|
|
112
130
|
try {
|
|
113
|
-
discovered = await _getRelaysByPubkey(missingRelayAuthors,
|
|
131
|
+
discovered = await _getRelaysByPubkey(missingRelayAuthors, {
|
|
132
|
+
...relayListOptions,
|
|
133
|
+
...(signal ? { signal } : {}),
|
|
134
|
+
onQueryResult: request => { report({ ...request, phase: 'discovery' }); relayListOptions?.onQueryResult?.(request) }
|
|
135
|
+
})
|
|
114
136
|
} catch (error) {
|
|
115
|
-
|
|
137
|
+
signal?.throwIfAborted()
|
|
138
|
+
if (!requests.some(request => request.phase === 'discovery' && request.error === error)) {
|
|
139
|
+
report({ phase: 'discovery', authors: missingRelayAuthors, relays: [], error })
|
|
140
|
+
}
|
|
116
141
|
discovered = Object.fromEntries(missingRelayAuthors.map(pubkey => [pubkey, { read: [], write: [] }]))
|
|
117
142
|
}
|
|
118
143
|
for (const pubkey of missingRelayAuthors) {
|
|
@@ -120,14 +145,17 @@ export async function getLatestEventsByPubkey (pubkeys, {
|
|
|
120
145
|
}
|
|
121
146
|
}
|
|
122
147
|
|
|
148
|
+
signal?.throwIfAborted()
|
|
123
149
|
const primaryAuthors = authors.filter(pubkey => relaysByAuthor[pubkey]?.[type]?.length)
|
|
124
|
-
const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType })
|
|
150
|
+
const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType, excludeRelaysByPubkey, emptyRelaysFallback: [] })
|
|
125
151
|
const selectedRelays = getSelectedRelaysByPubkey(authors, primaryRoutes)
|
|
152
|
+
for (const pubkey of authors) for (const relay of excluded(pubkey)) selectedRelays[pubkey].add(relay)
|
|
126
153
|
const latestByAddress = await fetchLatestEventsByRelay(primaryRoutes, {
|
|
127
154
|
kinds,
|
|
128
155
|
dTagsByPubkey,
|
|
129
|
-
getEvents: _getEvents
|
|
156
|
+
getEvents: _getEvents, report, phase: 'primary', signal
|
|
130
157
|
})
|
|
158
|
+
signal?.throwIfAborted()
|
|
131
159
|
const foundPubkeys = new Set(Object.values(latestByAddress).map(event => event.pubkey))
|
|
132
160
|
const missingAuthors = authors.filter(pubkey => !foundPubkeys.has(pubkey))
|
|
133
161
|
|
|
@@ -145,13 +173,14 @@ export async function getLatestEventsByPubkey (pubkeys, {
|
|
|
145
173
|
await fetchLatestEventsByRelay(fallbackRoutes, {
|
|
146
174
|
kinds,
|
|
147
175
|
dTagsByPubkey,
|
|
148
|
-
getEvents: _getEvents
|
|
176
|
+
getEvents: _getEvents, report, phase: 'fallback', signal
|
|
149
177
|
})
|
|
150
178
|
))
|
|
151
179
|
}
|
|
152
180
|
|
|
181
|
+
signal?.throwIfAborted()
|
|
153
182
|
const events = Object.values(latestByAddress)
|
|
154
183
|
const byPubkey = {}
|
|
155
184
|
for (const event of events) byPubkey[event.pubkey] = event
|
|
156
|
-
return { events, byPubkey, relaysByPubkey: relaysByAuthor }
|
|
185
|
+
return { events, byPubkey, relaysByPubkey: relaysByAuthor, requests }
|
|
157
186
|
}
|
package/relay/services/query.js
CHANGED
|
@@ -224,28 +224,27 @@ export function subscribeRelayListUpdates (pubkeys, {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
async function loadMissingRelays (missingPubkeys, {
|
|
227
|
-
getEvents,
|
|
228
|
-
|
|
229
|
-
timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
|
|
230
|
-
timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
|
|
231
|
-
relayUrlPolicy,
|
|
232
|
-
emptyRelaysFallback = freeRelays.slice(0, 2)
|
|
227
|
+
getEvents, cacheMs, timeout, timeoutAfterFirstEose, relayUrlPolicy,
|
|
228
|
+
emptyRelaysFallback, excludeRelays, signal
|
|
233
229
|
}) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
authors: missingPubkeys,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
230
|
+
let response
|
|
231
|
+
try {
|
|
232
|
+
response = await getEvents({ kinds: [10002], authors: missingPubkeys, limit: missingPubkeys.length },
|
|
233
|
+
seedRelays.filter(relay => !excludeRelays.includes(relay)), { timeout, timeoutAfterFirstEose, signal })
|
|
234
|
+
signal.throwIfAborted()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { entries: {}, report: { authors: missingPubkeys, relays: [], error } }
|
|
237
|
+
}
|
|
238
|
+
const report = { authors: missingPubkeys, relays: response.relays || [], errors: response.errors || [] }
|
|
239
|
+
const complete = response.relays
|
|
240
|
+
? response.relays.length > 0 && response.relays.every(item => item.status === 'eose')
|
|
241
|
+
: !response.errors?.length
|
|
243
242
|
const latestByPubkey = {}
|
|
244
|
-
for (const { event } of
|
|
243
|
+
for (const { event } of response.result || []) {
|
|
245
244
|
if (!missingPubkeys.includes(event.pubkey)) continue
|
|
246
245
|
if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
|
|
247
246
|
}
|
|
248
|
-
|
|
247
|
+
const entries = {}
|
|
249
248
|
for (const pubkey of missingPubkeys) {
|
|
250
249
|
const fetchedEvent = latestByPubkey[pubkey]
|
|
251
250
|
const cachedEvent = relayCacheEventByPubkey[pubkey] || null
|
|
@@ -253,9 +252,29 @@ async function loadMissingRelays (missingPubkeys, {
|
|
|
253
252
|
const relays = event
|
|
254
253
|
? parseRelayListEvent(event, relayUrlPolicy)
|
|
255
254
|
: { read: [...emptyRelaysFallback], write: [...emptyRelaysFallback] }
|
|
256
|
-
|
|
255
|
+
entries[pubkey] = { ...relays, event }
|
|
256
|
+
// A failed/incomplete lookup may use fallbacks for this call, but must not
|
|
257
|
+
// turn that failure into a forty-minute negative discovery cache entry.
|
|
258
|
+
if (fetchedEvent || complete) setCachedRelays(pubkey, relays, event, cacheMs)
|
|
257
259
|
}
|
|
258
260
|
pruneRelayCache()
|
|
261
|
+
return { entries, report }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Each caller owns its wait, not another caller's shared discovery request.
|
|
265
|
+
function waitForDiscovery (record, signal) {
|
|
266
|
+
signal?.throwIfAborted()
|
|
267
|
+
record.users++
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const abort = () => reject(signal.reason)
|
|
270
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
271
|
+
record.promise.then(resolve, reject).finally(() => signal?.removeEventListener('abort', abort))
|
|
272
|
+
}).finally(() => {
|
|
273
|
+
if (--record.users === 0 && !record.finished) {
|
|
274
|
+
record.controller.abort()
|
|
275
|
+
record.remove()
|
|
276
|
+
}
|
|
277
|
+
})
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
export async function getRelaysByPubkey (pubkeys, {
|
|
@@ -266,40 +285,48 @@ export async function getRelaysByPubkey (pubkeys, {
|
|
|
266
285
|
timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
|
|
267
286
|
timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
|
|
268
287
|
relayUrlPolicy,
|
|
269
|
-
emptyRelaysFallback = freeRelays.slice(0, 2)
|
|
288
|
+
emptyRelaysFallback = freeRelays.slice(0, 2),
|
|
289
|
+
excludeRelays = [],
|
|
290
|
+
signal,
|
|
291
|
+
onQueryResult
|
|
270
292
|
} = {}) {
|
|
293
|
+
signal?.throwIfAborted()
|
|
271
294
|
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
272
295
|
if (!pubkeyList.length) return {}
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(pubkey))
|
|
296
|
+
// Different exclusions cannot share a request that contacts a refused relay.
|
|
297
|
+
const scope = JSON.stringify([...new Set(excludeRelays)].sort())
|
|
298
|
+
const key = pubkey => `${pubkey}:${scope}`
|
|
299
|
+
const loadPubkeys = forceRefresh ? pubkeyList : pubkeyList.filter(pubkey => !hasCachedKey(relaysByPubkey, pubkey))
|
|
300
|
+
const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(key(pubkey)))
|
|
278
301
|
if (pubkeysToLoad.length) {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
cacheMs,
|
|
282
|
-
timeout,
|
|
283
|
-
timeoutAfterFirstEose,
|
|
284
|
-
relayUrlPolicy,
|
|
285
|
-
emptyRelaysFallback
|
|
286
|
-
}).finally(() => {
|
|
302
|
+
const record = { controller: new AbortController(), users: 0, finished: false }
|
|
303
|
+
record.remove = () => {
|
|
287
304
|
for (const pubkey of pubkeysToLoad) {
|
|
288
|
-
if (relayRequestsByPubkey.get(pubkey) ===
|
|
305
|
+
if (relayRequestsByPubkey.get(key(pubkey)) === record) relayRequestsByPubkey.delete(key(pubkey))
|
|
289
306
|
}
|
|
290
|
-
}
|
|
291
|
-
|
|
307
|
+
}
|
|
308
|
+
record.promise = loadMissingRelays(pubkeysToLoad, {
|
|
309
|
+
getEvents: _getEvents, cacheMs, timeout, timeoutAfterFirstEose, relayUrlPolicy,
|
|
310
|
+
emptyRelaysFallback, excludeRelays, signal: record.controller.signal
|
|
311
|
+
}).finally(() => { record.finished = true; record.remove() })
|
|
312
|
+
for (const pubkey of pubkeysToLoad) relayRequestsByPubkey.set(key(pubkey), record)
|
|
292
313
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
314
|
+
const entries = {}
|
|
315
|
+
const records = [...new Set(loadPubkeys.map(pubkey => relayRequestsByPubkey.get(key(pubkey))).filter(Boolean))]
|
|
316
|
+
await Promise.all(records.map(async record => {
|
|
317
|
+
const result = await waitForDiscovery(record, signal)
|
|
318
|
+
signal?.throwIfAborted()
|
|
319
|
+
// Deliver the same native diagnostics to every interested caller, including
|
|
320
|
+
// callers that joined an already running query.
|
|
321
|
+
onQueryResult?.(result.report)
|
|
322
|
+
if (result.report.error) throw result.report.error
|
|
323
|
+
Object.assign(entries, result.entries)
|
|
324
|
+
}))
|
|
325
|
+
signal?.throwIfAborted()
|
|
326
|
+
return Object.fromEntries(pubkeyList.map(pubkey => {
|
|
327
|
+
const source = hasCachedKey(relaysByPubkey, pubkey) ? relaysByPubkey[pubkey] : entries[pubkey]
|
|
328
|
+
const entry = cloneRelays(source || { read: emptyRelaysFallback, write: emptyRelaysFallback })
|
|
329
|
+
if (includeEvents) entry.event = cloneRelayListEvent(relayCacheEventByPubkey[pubkey] || entries[pubkey]?.event)
|
|
330
|
+
return [pubkey, entry]
|
|
331
|
+
}))
|
|
305
332
|
}
|
|
@@ -5,7 +5,8 @@ import { createPublishSettlements, firstFulfillment, publishSummary } from '../h
|
|
|
5
5
|
import { ReadAdmission } from '../helpers/read-admission.js'
|
|
6
6
|
import { drainableStream } from '../helpers/drainable-stream.js'
|
|
7
7
|
import { maybeUnref } from '../helpers/timer.js'
|
|
8
|
-
import { categorizeRelayError } from '../helpers/error.js'
|
|
8
|
+
import { categorizeRelayError, relayTimeoutError } from '../helpers/error.js'
|
|
9
|
+
import { ReadRetry, isRetryableReadFailure, needsConnectivityCheck, waitUntil } from '../helpers/read-retry.js'
|
|
9
10
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
10
11
|
import { RelayConnection } from './relay-connection.js'
|
|
11
12
|
|
|
@@ -76,11 +77,11 @@ function countResponseError () {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
function countTimeoutError () {
|
|
79
|
-
return
|
|
80
|
+
return relayTimeoutError('COUNT_TIMEOUT')
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function getEventsTimeoutError () {
|
|
83
|
-
return
|
|
84
|
+
return relayTimeoutError('GET_EVENTS_TIMEOUT')
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
function asError (error) {
|
|
@@ -138,11 +139,13 @@ export class RelayPool {
|
|
|
138
139
|
#createRelay
|
|
139
140
|
#WebSocket
|
|
140
141
|
#admission
|
|
142
|
+
#readRetry
|
|
141
143
|
|
|
142
|
-
constructor ({ _createRelay, WebSocket: WebSocketImpl, ...capacity } = {}) {
|
|
144
|
+
constructor ({ _createRelay, _isOnline, _onOnline, WebSocket: WebSocketImpl, ...capacity } = {}) {
|
|
143
145
|
this.#WebSocket = WebSocketImpl
|
|
144
146
|
this.#createRelay = _createRelay ?? (url => new RelayConnection(url, this.#WebSocket ? { WebSocket: this.#WebSocket } : undefined))
|
|
145
147
|
this.#admission = new ReadAdmission(capacity)
|
|
148
|
+
this.#readRetry = new ReadRetry({ checkOnline: _isOnline, watchOnline: _onOnline })
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
// Injects the WebSocket implementation used by new pooled connections (e.g.
|
|
@@ -600,6 +603,9 @@ export class RelayPool {
|
|
|
600
603
|
const gapTasks = new Set()
|
|
601
604
|
const liveSubs = new Map() // url → live sub
|
|
602
605
|
const retryTimers = new Map()
|
|
606
|
+
const retryDelays = new Map()
|
|
607
|
+
const stoppedRelays = new Set()
|
|
608
|
+
const retainedRelays = new Set()
|
|
603
609
|
const initialPending = new Set(urls)
|
|
604
610
|
const initialOutcomes = new Map()
|
|
605
611
|
const readyTimeouts = new Map()
|
|
@@ -708,7 +714,7 @@ export class RelayPool {
|
|
|
708
714
|
clearTimeout(attempt.timer)
|
|
709
715
|
attempt.stop.abort()
|
|
710
716
|
}
|
|
711
|
-
for (const
|
|
717
|
+
for (const retry of retryTimers.values()) retry.abort()
|
|
712
718
|
retryTimers.clear()
|
|
713
719
|
liveSubs.forEach(sub => sub.close())
|
|
714
720
|
liveSubs.clear()
|
|
@@ -768,14 +774,61 @@ export class RelayPool {
|
|
|
768
774
|
}
|
|
769
775
|
}
|
|
770
776
|
|
|
771
|
-
const
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
777
|
+
const releaseRelay = url => {
|
|
778
|
+
if (retainedRelays.delete(url)) this.#decrementLiveSub(url)
|
|
779
|
+
}
|
|
780
|
+
const closeAttempt = (url, attempt) => {
|
|
781
|
+
if (!attempt || attempt.closed) return
|
|
782
|
+
attempt.closed = true
|
|
783
|
+
clearTimeout(attempt.timer)
|
|
784
|
+
attempt.stop.abort()
|
|
785
|
+
const sub = liveSubs.get(url)
|
|
786
|
+
liveSubs.delete(url)
|
|
787
|
+
sub?.close()
|
|
788
|
+
liveLeases.get(url)?.release()
|
|
789
|
+
liveLeases.delete(url)
|
|
790
|
+
readyRelays.delete(url)
|
|
791
|
+
}
|
|
792
|
+
const stopRelay = url => {
|
|
793
|
+
stoppedRelays.add(url)
|
|
794
|
+
retryTimers.get(url)?.abort()
|
|
795
|
+
retryTimers.delete(url)
|
|
796
|
+
closeAttempt(url, attempts.get(url))
|
|
797
|
+
releaseRelay(url)
|
|
798
|
+
if (stoppedRelays.size === urls.length) { finishReady(); teardown(true) }
|
|
799
|
+
}
|
|
800
|
+
const scheduleReconnect = (url, error) => {
|
|
801
|
+
if (isDone || stoppedRelays.has(url) || retryTimers.has(url)) return
|
|
802
|
+
if (!isRetryableReadFailure(error)) { stopRelay(url); return }
|
|
803
|
+
const retry = new AbortController()
|
|
804
|
+
const retrySignal = AbortSignal.any([gapAc.signal, retry.signal])
|
|
805
|
+
const failedAt = Date.now()
|
|
806
|
+
const delay = retryDelays.get(url) ?? 1000
|
|
807
|
+
const retryAt = Number.isFinite(error?.retryAt)
|
|
808
|
+
? error.retryAt
|
|
809
|
+
: Number.isFinite(error?.retryAfterMs) && error.retryAfterMs > 0 ? failedAt + Math.min(error.retryAfterMs, 300000) : 0
|
|
810
|
+
retryTimers.set(url, retry)
|
|
811
|
+
;(async () => {
|
|
812
|
+
const online = !needsConnectivityCheck(error) || await this.#readRetry.confirmOnline(retrySignal)
|
|
813
|
+
retrySignal.throwIfAborted()
|
|
814
|
+
const deadline = Math.max(failedAt + (online ? delay : 0), retryAt)
|
|
815
|
+
if (online) retryDelays.set(url, Math.min(delay * 2, 300000))
|
|
816
|
+
else await this.#readRetry.waitUntilOnline(retrySignal)
|
|
817
|
+
while (!retrySignal.aborted) {
|
|
818
|
+
await waitUntil(deadline, retrySignal)
|
|
819
|
+
if (needsConnectivityCheck(error) && !await this.#readRetry.confirmOnline(retrySignal)) {
|
|
820
|
+
await this.#readRetry.waitUntilOnline(retrySignal)
|
|
821
|
+
continue
|
|
822
|
+
}
|
|
823
|
+
retrySignal.throwIfAborted()
|
|
824
|
+
if (isDone || stoppedRelays.has(url) || retryTimers.get(url) !== retry) return
|
|
825
|
+
retryTimers.delete(url)
|
|
826
|
+
subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null)
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
})().catch(failure => {
|
|
830
|
+
if (!retrySignal.aborted && !isDone) { reportFailure(url, asError(failure)); stopRelay(url) }
|
|
831
|
+
})
|
|
779
832
|
}
|
|
780
833
|
|
|
781
834
|
// Schedule teardown when the wall clock reaches filter.until
|
|
@@ -799,26 +852,36 @@ export class RelayPool {
|
|
|
799
852
|
})
|
|
800
853
|
return (async () => {
|
|
801
854
|
let completed = false
|
|
802
|
-
let
|
|
855
|
+
let error
|
|
803
856
|
for await (const item of gapGen) {
|
|
804
857
|
if (item?.type === 'event') {
|
|
805
858
|
observeEvent(item.event, url)
|
|
806
859
|
pushEvent(item.event, url, true)
|
|
807
860
|
} else if (item?.type === 'error') {
|
|
808
|
-
|
|
861
|
+
if (!error || !isRetryableReadFailure(item.error)) error = item.error
|
|
809
862
|
if (!isDone) enqueue(item)
|
|
810
863
|
} else if (item?.type === 'eose') {
|
|
811
864
|
completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
|
|
865
|
+
if (!completed && !error) {
|
|
866
|
+
error = item.relays?.[0]?.error || categorizeRelayError(new Error('RELAY_RECONNECT_GAP_INCOMPLETE'), 'timeout')
|
|
867
|
+
if (!isDone) enqueue({ type: 'error', relay: url, error })
|
|
868
|
+
}
|
|
812
869
|
}
|
|
813
870
|
}
|
|
814
|
-
|
|
871
|
+
if (!completed && !error && !attempt.stop.signal.aborted && !isDone) {
|
|
872
|
+
error = new Error('RELAY_RECONNECT_GAP_INCOMPLETE')
|
|
873
|
+
reportFailure(url, error)
|
|
874
|
+
}
|
|
875
|
+
return { recovered: completed && !error, error }
|
|
815
876
|
})().catch(err => {
|
|
816
|
-
|
|
817
|
-
|
|
877
|
+
const error = asError(err)
|
|
878
|
+
if (!attempt.stop.signal.aborted) reportFailure(url, error)
|
|
879
|
+
return { recovered: false, error }
|
|
818
880
|
}).finally(() => slots.history.release())
|
|
819
881
|
}
|
|
820
882
|
|
|
821
|
-
const subscribeToRelay = (url, gapSince
|
|
883
|
+
const subscribeToRelay = (url, gapSince) => {
|
|
884
|
+
if (isDone || stoppedRelays.has(url)) return
|
|
822
885
|
let now = Math.floor(Date.now() / 1000)
|
|
823
886
|
// Don't reconnect if we're past the until boundary
|
|
824
887
|
if (filterUntil !== null && now >= filterUntil) return
|
|
@@ -829,10 +892,10 @@ export class RelayPool {
|
|
|
829
892
|
attempts.set(url, attempt)
|
|
830
893
|
let recoveryLease
|
|
831
894
|
initialAdmissions.add(url)
|
|
832
|
-
Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
|
|
895
|
+
Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: AbortSignal.any([gapAc.signal, attempt.stop.signal]), queueTimeout })).then(async slots => {
|
|
833
896
|
const lease = slots.live
|
|
834
897
|
recoveryLease = recovering ? slots.history : null
|
|
835
|
-
if (isDone) { lease.release(); recoveryLease?.release(); return }
|
|
898
|
+
if (isDone || attempt.closed) { lease.release(); recoveryLease?.release(); return }
|
|
836
899
|
liveLeases.set(url, lease)
|
|
837
900
|
if (timeout !== null && initialPending.has(url)) {
|
|
838
901
|
readyTimeouts.set(url, maybeUnref(setTimeout(() => {
|
|
@@ -844,7 +907,7 @@ export class RelayPool {
|
|
|
844
907
|
}, timeout)))
|
|
845
908
|
}
|
|
846
909
|
const relay = await this.#getRelay(url)
|
|
847
|
-
if (isDone) { recoveryLease?.release(); return }
|
|
910
|
+
if (isDone || attempt.closed) { lease.release(); recoveryLease?.release(); return }
|
|
848
911
|
now = Math.floor(Date.now() / 1000)
|
|
849
912
|
if (filterUntil !== null && now >= filterUntil) { lease.release(); recoveryLease?.release(); return }
|
|
850
913
|
|
|
@@ -878,37 +941,39 @@ export class RelayPool {
|
|
|
878
941
|
},
|
|
879
942
|
onclose: error => {
|
|
880
943
|
if (attempt.closed || attempts.get(url) !== attempt) return
|
|
881
|
-
attempt
|
|
882
|
-
clearTimeout(attempt.timer)
|
|
883
|
-
attempt.stop.abort()
|
|
884
|
-
if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
|
|
885
|
-
lease.release()
|
|
886
|
-
if (liveLeases.get(url) === lease) liveLeases.delete(url)
|
|
887
|
-
readyRelays.delete(url)
|
|
944
|
+
closeAttempt(url, attempt)
|
|
888
945
|
if (isDone) return
|
|
889
|
-
if (error !== undefined) reportFailure(url,
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
reportClosed(url, interruption)
|
|
946
|
+
if (error !== undefined) { error = asError(error); reportFailure(url, error) } else {
|
|
947
|
+
error = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
|
|
948
|
+
enqueue({ type: 'error', relay: url, error })
|
|
949
|
+
reportClosed(url, error)
|
|
894
950
|
}
|
|
895
|
-
scheduleReconnect(url,
|
|
951
|
+
scheduleReconnect(url, error)
|
|
896
952
|
},
|
|
897
953
|
oneose: () => {
|
|
898
954
|
if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
|
|
899
955
|
liveEose = true
|
|
900
956
|
attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
|
|
901
957
|
markInitialEose(url)
|
|
958
|
+
if (attempt.recovered) retryDelays.delete(url)
|
|
902
959
|
scheduleProgress(attempt)
|
|
903
960
|
}
|
|
904
961
|
})
|
|
905
|
-
if (isDone) { liveSub.close(); recoveryLease?.release(); return }
|
|
962
|
+
if (isDone || attempt.closed) { liveSub.close(); lease.release(); recoveryLease?.release(); return }
|
|
906
963
|
liveSubs.set(url, liveSub)
|
|
907
964
|
|
|
908
965
|
if (recovering) {
|
|
909
|
-
const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
|
|
966
|
+
const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(({ recovered, error }) => {
|
|
910
967
|
attempt.recovered = recovered
|
|
911
|
-
if (
|
|
968
|
+
if (!attempt.closed && attempts.get(url) === attempt) {
|
|
969
|
+
if (recovered) {
|
|
970
|
+
pendingGaps.delete(url)
|
|
971
|
+
if (liveEose) retryDelays.delete(url)
|
|
972
|
+
} else if (error && !isDone) {
|
|
973
|
+
closeAttempt(url, attempt)
|
|
974
|
+
scheduleReconnect(url, error)
|
|
975
|
+
}
|
|
976
|
+
}
|
|
912
977
|
}).finally(() => {
|
|
913
978
|
const buf = liveBuffer
|
|
914
979
|
liveBuffer = null
|
|
@@ -925,9 +990,10 @@ export class RelayPool {
|
|
|
925
990
|
liveLeases.delete(url)
|
|
926
991
|
readyRelays.delete(url)
|
|
927
992
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
993
|
+
if (isDone || attempt.closed) return
|
|
994
|
+
closeAttempt(url, attempt)
|
|
928
995
|
reportFailure(url, reason)
|
|
929
|
-
|
|
930
|
-
scheduleReconnect(url, reconnectDelay)
|
|
996
|
+
scheduleReconnect(url, reason)
|
|
931
997
|
})
|
|
932
998
|
}
|
|
933
999
|
|
|
@@ -946,6 +1012,7 @@ export class RelayPool {
|
|
|
946
1012
|
|
|
947
1013
|
for (const url of urls) {
|
|
948
1014
|
this.#incrementLiveSub(url)
|
|
1015
|
+
retainedRelays.add(url)
|
|
949
1016
|
subscribeToRelay(url, null) // no initial gap fill — that's getEventsFeedGenerator's job
|
|
950
1017
|
}
|
|
951
1018
|
|
|
@@ -960,7 +1027,7 @@ export class RelayPool {
|
|
|
960
1027
|
} finally {
|
|
961
1028
|
signal?.removeEventListener('abort', abort)
|
|
962
1029
|
_stopSignal?.removeEventListener('abort', stop)
|
|
963
|
-
for (const url of
|
|
1030
|
+
for (const url of [...retainedRelays]) releaseRelay(url)
|
|
964
1031
|
teardown()
|
|
965
1032
|
}
|
|
966
1033
|
}
|