libp2r2p 0.10.20 → 0.10.21
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 +68 -2
- package/package.json +1 -1
- package/relay/helpers/read-admission.js +92 -0
- package/relay/services/relay-pool.js +210 -58
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ partial results. Its report distinguishes actual relay EOSE from other outcomes:
|
|
|
30
30
|
| --- | --- |
|
|
31
31
|
| `eose` | The relay sent EOSE. |
|
|
32
32
|
| `satisfied` | The requested limit or IDs were satisfied, so the read closed early. |
|
|
33
|
-
| `timeout` | The initial
|
|
33
|
+
| `timeout` | The initial relay attempt deadline elapsed. Also emits an error item. |
|
|
34
34
|
| `cutoff` | The grace period after the first qualifying EOSE/early completion elapsed. |
|
|
35
35
|
| `closed` | The subscription ended without EOSE or an explicit error. |
|
|
36
36
|
| `error` | Connection or subscription failed. Also emits an error item. |
|
|
@@ -71,7 +71,7 @@ tracks currently ready relays and can change after that snapshot. Timeouts and
|
|
|
71
71
|
cutoffs are not acknowledgements of readiness. Reconnections do not repeat the
|
|
72
72
|
initial marker. Both APIs require consuming the iterator to start its work.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Read iterators have a synchronous, idempotent `stopAndDrain()` method.
|
|
75
75
|
It closes subscription input and cancels reconnections and outstanding historical
|
|
76
76
|
queries, while retaining items already accepted by receive callbacks. Continue
|
|
77
77
|
consuming to obtain those items and completion. This includes initial history,
|
|
@@ -101,6 +101,72 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
|
|
|
101
101
|
and retain their higher-level event contracts. Count, publication and disconnect
|
|
102
102
|
return formats are unchanged.
|
|
103
103
|
|
|
104
|
+
### Read admission and bounded snapshots (0.10.21)
|
|
105
|
+
|
|
106
|
+
Each pool coordinates event reads by normalized connection URL. Constructor
|
|
107
|
+
options are `maxSubscriptionsPerRelay: 28`,
|
|
108
|
+
`maxConcurrentHistoryPerRelay: 2`, and `maxQueuedReadsPerRelay: 256`.
|
|
109
|
+
These are local budgets, not negotiated relay limits. A stricter relay can still
|
|
110
|
+
reject subscriptions. Count and publication contracts are unchanged and do not
|
|
111
|
+
consume event-read slots.
|
|
112
|
+
|
|
113
|
+
Admission is FIFO per connection. A history+live feed reserves two slots
|
|
114
|
+
atomically before either REQ opens; completing history releases only its slot.
|
|
115
|
+
One-shot queries and reconnect gap reads share this budget. Reconnection reserves
|
|
116
|
+
live+history together when recovery is needed. Caller cancellation removes queued
|
|
117
|
+
work; `disconnect(url)` also rejects queued work for that connection. Existing
|
|
118
|
+
live streams retain their reconnect behavior until cancelled by their owner.
|
|
119
|
+
|
|
120
|
+
All event reads accept `queueTimeout: 30000` (milliseconds, `null` disables).
|
|
121
|
+
The network `timeout` starts **after admission for each relay**, including its
|
|
122
|
+
connection setup; time in the queue does not consume it. The first-EOSE grace
|
|
123
|
+
can still cut short other relay attempts. Admission failures are ordinary error
|
|
124
|
+
envelopes/report entries, with `error.code`, `error.relay` and
|
|
125
|
+
`error.phase === 'admission'`: `RELAY_READ_QUEUE_FULL`,
|
|
126
|
+
`RELAY_READ_QUEUE_TIMEOUT`, `RELAY_READ_CAPACITY`, or `RELAY_DISCONNECTED`.
|
|
127
|
+
Cancellation rejects an outstanding direct `getEvents()` call; cancelled iterators
|
|
128
|
+
stop delivering. Failures on one relay continue to allow results from others.
|
|
129
|
+
|
|
130
|
+
Live and feed readers limit each pending live buffer to 1,000 envelopes and
|
|
131
|
+
8 MiB of serialized UTF-8 data. Options `maxBufferedLiveEvents` and
|
|
132
|
+
`maxBufferedLiveBytes` customize those positive limits. They cover consumer
|
|
133
|
+
backlog and reconnect buffers as well as live events waiting for history. These
|
|
134
|
+
are bounds on queued data, not a measurement of total JavaScript heap usage.
|
|
135
|
+
Overflow fails the attempt with `RELAY_LIVE_BUFFER_FULL` (`phase: 'live-buffer'`),
|
|
136
|
+
closes input and discards pending live data. Callers decide whether to retry;
|
|
137
|
+
events already delivered are not recalled. Ordinary relay failures retain the
|
|
138
|
+
existing partial-result behavior; they do not automatically discard the feed.
|
|
139
|
+
|
|
140
|
+
With `snapshot: true`, a feed waits for its initial live readiness window before
|
|
141
|
+
capturing the historical `until`. `filter.since` defaults to zero; `filter.until`
|
|
142
|
+
can provide an earlier cutoff but cannot extend a snapshot into the future.
|
|
143
|
+
The historical completion includes the actual inclusive bounds:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const stream = relayPool.getEventsFeedGenerator(
|
|
147
|
+
{ authors: [pubkey], kinds: [1], since: lastConfirmed - 600, limit: 200 },
|
|
148
|
+
[relay],
|
|
149
|
+
{ snapshot: true, timeoutAfterFirstEose: null, signal }
|
|
150
|
+
)
|
|
151
|
+
// Historical completion:
|
|
152
|
+
// { type: 'eose', relays: [...], snapshot: { since, until } }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Only history is bounded: the live REQ has no `until` in this mode. Events around
|
|
156
|
+
the boundary can appear in both sources and are deduplicated by ID. Empty history
|
|
157
|
+
still reports its bounds. With `live: false`, the cutoff is captured when the
|
|
158
|
+
iterator starts; `limit: 0` remains live-only, without a historical snapshot.
|
|
159
|
+
Default `snapshot: false` retains the existing filter time-range behavior.
|
|
160
|
+
|
|
161
|
+
This is a bounded time interval, not a transactional relay snapshot or proof of
|
|
162
|
+
complete coverage. Check each relay's outcome, exhaust pagination and commit
|
|
163
|
+
received events before checkpointing. `satisfied` can mean a truncated page;
|
|
164
|
+
EOSE can also terminate a limited response. A consumer that rejects a historical
|
|
165
|
+
attempt can call `return()`/abort when it receives its report, before the buffered
|
|
166
|
+
live events are released. Use `stopAndDrain()` only when those pending events
|
|
167
|
+
should be preserved. Slow/failed live readiness is still reported by the live
|
|
168
|
+
stream according to the existing partial-failure rules.
|
|
169
|
+
|
|
104
170
|
## Private Messenger
|
|
105
171
|
|
|
106
172
|
The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ValidationError } from '../../error/index.js'
|
|
2
|
+
import { maybeUnref } from './timer.js'
|
|
3
|
+
|
|
4
|
+
export function admissionError (code, relay) {
|
|
5
|
+
return Object.assign(new Error(code), { code, relay, phase: 'admission' })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// One FIFO per pooled connection. A feed reserves both REQs atomically; its
|
|
9
|
+
// history and live leases can subsequently be released independently.
|
|
10
|
+
export class ReadAdmission {
|
|
11
|
+
constructor ({ maxSubscriptionsPerRelay = 28, maxConcurrentHistoryPerRelay = 2, maxQueuedReadsPerRelay = 256 } = {}) {
|
|
12
|
+
for (const value of [maxSubscriptionsPerRelay, maxConcurrentHistoryPerRelay, maxQueuedReadsPerRelay]) {
|
|
13
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new ValidationError('INVALID_RELAY_READ_CAPACITY')
|
|
14
|
+
}
|
|
15
|
+
Object.assign(this, { maxSubscriptionsPerRelay, maxConcurrentHistoryPerRelay, maxQueuedReadsPerRelay })
|
|
16
|
+
this.states = new Map()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
acquire (relay, { history = false, live = false, signal, queueTimeout = 30000 } = {}) {
|
|
20
|
+
if (queueTimeout !== null && (!Number.isFinite(queueTimeout) || queueTimeout < 0)) throw new ValidationError('INVALID_RELAY_TIMEOUT')
|
|
21
|
+
const weight = Number(history) + Number(live)
|
|
22
|
+
if (!weight || weight > this.maxSubscriptionsPerRelay) return Promise.reject(admissionError('RELAY_READ_CAPACITY', relay))
|
|
23
|
+
if (signal?.aborted) return Promise.reject(admissionError('RELAY_READ_CANCELLED', relay))
|
|
24
|
+
let state = this.states.get(relay)
|
|
25
|
+
if (!state) {
|
|
26
|
+
state = { active: 0, history: 0, queue: [] }
|
|
27
|
+
this.states.set(relay, state)
|
|
28
|
+
}
|
|
29
|
+
const deferred = Promise.withResolvers()
|
|
30
|
+
let timer
|
|
31
|
+
const job = {
|
|
32
|
+
history, weight, grant: () => {
|
|
33
|
+
cleanup()
|
|
34
|
+
state.active += weight
|
|
35
|
+
state.history += Number(history)
|
|
36
|
+
const leases = {}
|
|
37
|
+
for (const kind of ['history', 'live']) {
|
|
38
|
+
if (!(kind === 'history' ? history : live)) continue
|
|
39
|
+
let released = false
|
|
40
|
+
leases[kind] = {
|
|
41
|
+
release: () => {
|
|
42
|
+
if (released) return
|
|
43
|
+
released = true
|
|
44
|
+
state.active--
|
|
45
|
+
if (kind === 'history') state.history--
|
|
46
|
+
this.drain(relay, state)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
deferred.resolve(leases)
|
|
51
|
+
}, cancel: code => {
|
|
52
|
+
const index = state.queue.indexOf(job)
|
|
53
|
+
if (index < 0) return
|
|
54
|
+
state.queue.splice(index, 1)
|
|
55
|
+
cleanup()
|
|
56
|
+
deferred.reject(admissionError(code, relay))
|
|
57
|
+
this.drain(relay, state)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const abort = () => job.cancel('RELAY_READ_CANCELLED')
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
signal?.removeEventListener('abort', abort)
|
|
64
|
+
}
|
|
65
|
+
if (state.queue.length >= this.maxQueuedReadsPerRelay) return Promise.reject(admissionError('RELAY_READ_QUEUE_FULL', relay))
|
|
66
|
+
state.queue.push(job)
|
|
67
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
68
|
+
if (queueTimeout !== null) timer = maybeUnref(setTimeout(() => job.cancel('RELAY_READ_QUEUE_TIMEOUT'), queueTimeout))
|
|
69
|
+
this.drain(relay, state)
|
|
70
|
+
return deferred.promise
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
drain (relay, state) {
|
|
74
|
+
while (state.queue.length) {
|
|
75
|
+
const job = state.queue[0]
|
|
76
|
+
if (state.active + job.weight > this.maxSubscriptionsPerRelay || (job.history && state.history >= this.maxConcurrentHistoryPerRelay)) break
|
|
77
|
+
state.queue.shift().grant()
|
|
78
|
+
}
|
|
79
|
+
if (!state.active && !state.queue.length && this.states.get(relay) === state) this.states.delete(relay)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cancelQueued (relay) {
|
|
83
|
+
const state = this.states.get(relay)
|
|
84
|
+
if (!state) return
|
|
85
|
+
// Remove the entire queue first: cancelling one entry must not admit another.
|
|
86
|
+
const jobs = state.queue.splice(0)
|
|
87
|
+
for (const job of jobs) {
|
|
88
|
+
state.queue.push(job)
|
|
89
|
+
job.cancel('RELAY_DISCONNECTED')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ValidationError } from '../../error/index.js'
|
|
2
2
|
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
3
3
|
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
4
|
+
import { ReadAdmission } from '../helpers/read-admission.js'
|
|
4
5
|
import { drainableStream } from '../helpers/drainable-stream.js'
|
|
5
6
|
import { maybeUnref } from '../helpers/timer.js'
|
|
6
7
|
import { categorizeRelayError } from '../helpers/error.js'
|
|
@@ -91,6 +92,12 @@ function assertReadOptions (filter, timeouts) {
|
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
function assertBufferOptions (options) {
|
|
96
|
+
for (const value of [options.maxBufferedLiveEvents === undefined ? 1000 : options.maxBufferedLiveEvents, options.maxBufferedLiveBytes === undefined ? 8 * 1024 * 1024 : options.maxBufferedLiveBytes]) {
|
|
97
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new ValidationError('INVALID_RELAY_BUFFER_CAPACITY')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
94
101
|
function normalizedRelayUrls (relays) {
|
|
95
102
|
const urls = []
|
|
96
103
|
const seen = new Set()
|
|
@@ -127,14 +134,19 @@ export class RelayPool {
|
|
|
127
134
|
#liveSubCounts = new Map() // url -> number of active live subscriptions
|
|
128
135
|
#timeout = 30000 // 30 seconds
|
|
129
136
|
#createRelay
|
|
137
|
+
#admission
|
|
130
138
|
|
|
131
|
-
constructor ({ _createRelay = url => new RelayConnection(url) } = {}) {
|
|
139
|
+
constructor ({ _createRelay = url => new RelayConnection(url), ...capacity } = {}) {
|
|
132
140
|
this.#createRelay = _createRelay
|
|
141
|
+
this.#admission = new ReadAdmission(capacity)
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
#scheduleIdleDisconnect (url) {
|
|
136
145
|
clearTimeout(this.#relayTimeouts.get(url))
|
|
137
|
-
this.#relayTimeouts.set(url, maybeUnref(setTimeout(() =>
|
|
146
|
+
this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => {
|
|
147
|
+
if (this.#admission.states.get(url)?.active) this.#scheduleIdleDisconnect(url)
|
|
148
|
+
else this.disconnect(url)
|
|
149
|
+
}, this.#timeout)))
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
// Opens a normalized pooled connection. Failed connects are evicted so a later
|
|
@@ -191,6 +203,7 @@ export class RelayPool {
|
|
|
191
203
|
// Disconnect from a relay
|
|
192
204
|
async disconnect (url) {
|
|
193
205
|
const normalizedUrl = normalizeRelayUrl(url)
|
|
206
|
+
this.#admission.cancelQueued(normalizedUrl)
|
|
194
207
|
if (this.#relays.has(normalizedUrl)) {
|
|
195
208
|
const relay = this.#relays.get(normalizedUrl)
|
|
196
209
|
if (relay.ws?.readyState < 2) await relay.close()?.catch(console.log)
|
|
@@ -202,7 +215,7 @@ export class RelayPool {
|
|
|
202
215
|
|
|
203
216
|
// Disconnect from all relays
|
|
204
217
|
async disconnectAll () {
|
|
205
|
-
for (const url of [...this.#relays.keys()]) {
|
|
218
|
+
for (const url of new Set([...this.#relays.keys(), ...this.#admission.states.keys()])) {
|
|
206
219
|
await this.disconnect(url)
|
|
207
220
|
}
|
|
208
221
|
}
|
|
@@ -352,8 +365,8 @@ export class RelayPool {
|
|
|
352
365
|
// grace window; null disables that window so callers wait for every relay or
|
|
353
366
|
// the operation deadline. Disabling cross-relay deduplication still suppresses
|
|
354
367
|
// repeated ids from the same relay; callbacks remain immediate in both modes.
|
|
355
|
-
async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true } = {}) {
|
|
356
|
-
assertReadOptions(filter, { timeout, timeoutAfterFirstEose })
|
|
368
|
+
async getEvents (filter, relays, { timeout = 5000, timeoutAfterFirstEose = 500, callback, signal, deduplicateAcrossRelays = true, queueTimeout = 30000, _admissions } = {}) {
|
|
369
|
+
assertReadOptions(filter, { timeout, timeoutAfterFirstEose, queueTimeout })
|
|
357
370
|
if (typeof deduplicateAcrossRelays !== 'boolean') throw new ValidationError('INVALID_DEDUPLICATE_ACROSS_RELAYS')
|
|
358
371
|
if (signal?.aborted) throw new Error('Aborted')
|
|
359
372
|
const urls = normalizedRelayUrls(relays)
|
|
@@ -364,15 +377,21 @@ export class RelayPool {
|
|
|
364
377
|
const eventIds = deduplicateAcrossRelays ? new Set() : null
|
|
365
378
|
let isResolved = false
|
|
366
379
|
let eoseTimer = null
|
|
367
|
-
|
|
380
|
+
const timers = new Map()
|
|
381
|
+
const leases = new Map()
|
|
382
|
+
const admissionAbort = new AbortController()
|
|
383
|
+
const admissionSignal = signal ? AbortSignal.any([signal, admissionAbort.signal]) : admissionAbort.signal
|
|
368
384
|
|
|
369
385
|
return await new Promise((resolve, reject) => {
|
|
370
386
|
const cleanup = () => {
|
|
371
|
-
|
|
387
|
+
admissionAbort.abort()
|
|
388
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
372
389
|
clearTimeout(eoseTimer)
|
|
373
390
|
signal?.removeEventListener('abort', onAbort)
|
|
374
391
|
for (const sub of subscriptions.values()) sub.close()
|
|
375
392
|
subscriptions.clear()
|
|
393
|
+
for (const lease of leases.values()) lease.release()
|
|
394
|
+
leases.clear()
|
|
376
395
|
}
|
|
377
396
|
const fail = error => {
|
|
378
397
|
if (isResolved) return
|
|
@@ -386,6 +405,11 @@ export class RelayPool {
|
|
|
386
405
|
const settleRelay = (relay, status, error) => {
|
|
387
406
|
if (isResolved || outcomes.has(relay)) return
|
|
388
407
|
outcomes.set(relay, { relay, status, ...(error ? { error } : {}) })
|
|
408
|
+
clearTimeout(timers.get(relay))
|
|
409
|
+
subscriptions.get(relay)?.close()
|
|
410
|
+
subscriptions.delete(relay)
|
|
411
|
+
leases.get(relay)?.release()
|
|
412
|
+
leases.delete(relay)
|
|
389
413
|
if (error) {
|
|
390
414
|
errors.push({ relay, reason: error })
|
|
391
415
|
notify({ type: 'error', relay, error })
|
|
@@ -412,12 +436,22 @@ export class RelayPool {
|
|
|
412
436
|
const finishIfComplete = () => { if (outcomes.size === urls.length) finish() }
|
|
413
437
|
const onAbort = () => fail(new Error('Aborted'))
|
|
414
438
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
415
|
-
if (timeout !== null) timeoutTimer = maybeUnref(setTimeout(() => finish('timeout'), timeout))
|
|
416
439
|
if (!urls.length) { finish(); return }
|
|
417
440
|
|
|
418
441
|
for (const url of urls) {
|
|
419
442
|
const seenIds = eventIds ?? new Set()
|
|
420
|
-
this.#
|
|
443
|
+
const reservation = _admissions?.get(url) ?? this.#admission.acquire(normalizeRelayUrl(url), { history: true, signal: admissionSignal, queueTimeout })
|
|
444
|
+
reservation.then(async slots => {
|
|
445
|
+
const lease = slots.history
|
|
446
|
+
if (isResolved || outcomes.has(url)) { lease.release(); return }
|
|
447
|
+
leases.set(url, lease)
|
|
448
|
+
if (timeout !== null) {
|
|
449
|
+
timers.set(url, maybeUnref(setTimeout(() => {
|
|
450
|
+
settleRelay(url, 'timeout', getEventsTimeoutError())
|
|
451
|
+
finishIfComplete()
|
|
452
|
+
}, timeout)))
|
|
453
|
+
}
|
|
454
|
+
const relay = await this.#getRelay(url)
|
|
421
455
|
if (isResolved || outcomes.has(url)) return
|
|
422
456
|
let hasEvents = false
|
|
423
457
|
// Subscription callbacks may run before subscribe() returns.
|
|
@@ -467,7 +501,11 @@ export class RelayPool {
|
|
|
467
501
|
})
|
|
468
502
|
}
|
|
469
503
|
|
|
470
|
-
|
|
504
|
+
getEventsGenerator (filter, relays, options = {}) {
|
|
505
|
+
return drainableStream(options => this.#getEventsGenerator(filter, relays, options), options)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async * #getEventsGenerator (filter, relays, options = {}) {
|
|
471
509
|
const queue = []
|
|
472
510
|
let p = Promise.withResolvers()
|
|
473
511
|
let isDone = false
|
|
@@ -504,7 +542,8 @@ export class RelayPool {
|
|
|
504
542
|
// Returns a strictly-live stream. `ready` reports the first initial EOSE window,
|
|
505
543
|
// while `readyRelays` follows relays that are currently past their own EOSE.
|
|
506
544
|
getLiveEventsGenerator (filter, relays, options = {}) {
|
|
507
|
-
|
|
545
|
+
assertBufferOptions(options)
|
|
546
|
+
assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
|
|
508
547
|
const ready = Promise.withResolvers()
|
|
509
548
|
const readyRelays = new Set()
|
|
510
549
|
const stream = drainableStream(options => this.#getLiveEventsGenerator(filter, relays, options, { ready, readyRelays }), options)
|
|
@@ -530,6 +569,10 @@ export class RelayPool {
|
|
|
530
569
|
_stopSignal,
|
|
531
570
|
timeout = 5000,
|
|
532
571
|
timeoutAfterFirstEose = 500,
|
|
572
|
+
queueTimeout = 30000,
|
|
573
|
+
_admissions,
|
|
574
|
+
maxBufferedLiveEvents = 1000,
|
|
575
|
+
maxBufferedLiveBytes = 8 * 1024 * 1024,
|
|
533
576
|
timeoutForReconnectGap = 5000,
|
|
534
577
|
timeoutAfterFirstReconnectGapEose = 500,
|
|
535
578
|
_gapEventsGenerator = (...args) => this.getEventsGenerator(...args)
|
|
@@ -539,12 +582,17 @@ export class RelayPool {
|
|
|
539
582
|
let p = Promise.withResolvers()
|
|
540
583
|
let isDone = false
|
|
541
584
|
let draining = false
|
|
585
|
+
let failure
|
|
586
|
+
let queuedBytes = 0
|
|
587
|
+
const encoder = new TextEncoder()
|
|
542
588
|
const gapTasks = new Set()
|
|
543
589
|
const liveSubs = new Map() // url → live sub
|
|
544
590
|
const retryTimers = new Map()
|
|
545
591
|
const initialPending = new Set(urls)
|
|
546
592
|
const initialOutcomes = new Map()
|
|
547
|
-
|
|
593
|
+
const readyTimeouts = new Map()
|
|
594
|
+
const liveLeases = new Map()
|
|
595
|
+
const initialAdmissions = new Set()
|
|
548
596
|
let readyTimer = null
|
|
549
597
|
let isReady = false
|
|
550
598
|
|
|
@@ -566,16 +614,31 @@ export class RelayPool {
|
|
|
566
614
|
const seenIds = new Set()
|
|
567
615
|
|
|
568
616
|
let untilTimer = null
|
|
617
|
+
const overflow = relay => {
|
|
618
|
+
failure = Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer' })
|
|
619
|
+
teardown()
|
|
620
|
+
}
|
|
569
621
|
const enqueue = item => {
|
|
570
|
-
|
|
622
|
+
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
623
|
+
if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
|
|
624
|
+
overflow(item.relay)
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
queuedBytes += bytes
|
|
628
|
+
queue.push({ item, bytes })
|
|
571
629
|
p.resolve()
|
|
572
630
|
p = Promise.withResolvers()
|
|
573
631
|
}
|
|
632
|
+
const shift = () => {
|
|
633
|
+
const { item, bytes } = queue.shift()
|
|
634
|
+
queuedBytes -= bytes
|
|
635
|
+
return item
|
|
636
|
+
}
|
|
574
637
|
const finishReady = (pendingStatus = 'cutoff', emit = true) => {
|
|
575
638
|
if (isReady) return
|
|
576
639
|
isReady = true
|
|
577
640
|
clearTimeout(readyTimer)
|
|
578
|
-
clearTimeout(
|
|
641
|
+
for (const timer of readyTimeouts.values()) clearTimeout(timer)
|
|
579
642
|
for (const relay of initialPending) {
|
|
580
643
|
const error = pendingStatus === 'timeout' ? getEventsTimeoutError() : undefined
|
|
581
644
|
initialOutcomes.set(relay, { relay, status: pendingStatus, ...(error ? { error } : {}) })
|
|
@@ -594,7 +657,7 @@ export class RelayPool {
|
|
|
594
657
|
if (isDone && (drain || !draining)) return
|
|
595
658
|
draining = drain
|
|
596
659
|
isDone = true
|
|
597
|
-
if (!drain) queue.length = 0
|
|
660
|
+
if (!drain) { queue.length = 0; queuedBytes = 0 }
|
|
598
661
|
clearTimeout(untilTimer)
|
|
599
662
|
finishReady('closed', false)
|
|
600
663
|
gapAc.abort()
|
|
@@ -602,6 +665,8 @@ export class RelayPool {
|
|
|
602
665
|
retryTimers.clear()
|
|
603
666
|
liveSubs.forEach(sub => sub.close())
|
|
604
667
|
liveSubs.clear()
|
|
668
|
+
for (const lease of liveLeases.values()) lease.release()
|
|
669
|
+
liveLeases.clear()
|
|
605
670
|
p.resolve()
|
|
606
671
|
}
|
|
607
672
|
|
|
@@ -625,13 +690,12 @@ export class RelayPool {
|
|
|
625
690
|
signal?.addEventListener('abort', abort, { once: true })
|
|
626
691
|
_stopSignal?.addEventListener('abort', stop, { once: true })
|
|
627
692
|
|
|
628
|
-
if (timeout !== null) readyTimeout = maybeUnref(setTimeout(() => finishReady('timeout'), timeout))
|
|
629
|
-
|
|
630
693
|
const maybeFinishInitialReady = () => {
|
|
631
694
|
if (initialPending.size === 0) finishReady()
|
|
632
695
|
}
|
|
633
696
|
|
|
634
697
|
const markInitialEose = (url) => {
|
|
698
|
+
clearTimeout(readyTimeouts.get(url))
|
|
635
699
|
readyRelays.add(url)
|
|
636
700
|
if (isReady || !initialPending.delete(url)) return
|
|
637
701
|
initialOutcomes.set(url, { relay: url, status: 'eose' })
|
|
@@ -645,12 +709,14 @@ export class RelayPool {
|
|
|
645
709
|
if (isDone) return
|
|
646
710
|
enqueue({ type: 'error', relay: url, error })
|
|
647
711
|
if (!isReady && initialPending.delete(url)) {
|
|
712
|
+
clearTimeout(readyTimeouts.get(url))
|
|
648
713
|
initialOutcomes.set(url, { relay: url, status: 'error', error })
|
|
649
714
|
maybeFinishInitialReady()
|
|
650
715
|
}
|
|
651
716
|
}
|
|
652
717
|
const reportClosed = url => {
|
|
653
718
|
if (!isReady && initialPending.delete(url)) {
|
|
719
|
+
clearTimeout(readyTimeouts.get(url))
|
|
654
720
|
initialOutcomes.set(url, { relay: url, status: 'closed' })
|
|
655
721
|
maybeFinishInitialReady()
|
|
656
722
|
}
|
|
@@ -674,10 +740,12 @@ export class RelayPool {
|
|
|
674
740
|
|
|
675
741
|
// Runs a reconnect gap fill for a single relay and returns a promise that resolves
|
|
676
742
|
// when it completes. now is shared with the live sub so both use the same boundary.
|
|
677
|
-
const runReconnectGapFill = (url, gapSince, now) => {
|
|
743
|
+
const runReconnectGapFill = (url, gapSince, now, slots) => {
|
|
678
744
|
const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
|
|
679
745
|
const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
|
|
680
746
|
const gapGen = _gapEventsGenerator(gapFilter, [url], {
|
|
747
|
+
_admissions: new Map([[url, Promise.resolve(slots)]]),
|
|
748
|
+
queueTimeout,
|
|
681
749
|
timeout: timeoutForReconnectGap,
|
|
682
750
|
timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
|
|
683
751
|
signal,
|
|
@@ -690,20 +758,41 @@ export class RelayPool {
|
|
|
690
758
|
}
|
|
691
759
|
})().catch(err => {
|
|
692
760
|
reportFailure(url, asError(err))
|
|
693
|
-
})
|
|
761
|
+
}).finally(() => slots.history.release())
|
|
694
762
|
}
|
|
695
763
|
|
|
696
764
|
const subscribeToRelay = (url, gapSince, reconnectDelay = 1000) => {
|
|
697
|
-
|
|
765
|
+
let now = Math.floor(Date.now() / 1000)
|
|
698
766
|
// Don't reconnect if we're past the until boundary
|
|
699
767
|
if (filterUntil !== null && now >= filterUntil) return
|
|
700
|
-
|
|
701
|
-
|
|
768
|
+
const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
|
|
769
|
+
const recovering = gapSince !== null && gapSince > 0
|
|
770
|
+
let recoveryLease
|
|
771
|
+
initialAdmissions.add(url)
|
|
772
|
+
Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
|
|
773
|
+
const lease = slots.live
|
|
774
|
+
recoveryLease = recovering ? slots.history : null
|
|
775
|
+
if (isDone) { lease.release(); recoveryLease?.release(); return }
|
|
776
|
+
liveLeases.set(url, lease)
|
|
777
|
+
if (timeout !== null && initialPending.has(url)) {
|
|
778
|
+
readyTimeouts.set(url, maybeUnref(setTimeout(() => {
|
|
779
|
+
if (!initialPending.delete(url)) return
|
|
780
|
+
const error = getEventsTimeoutError()
|
|
781
|
+
initialOutcomes.set(url, { relay: url, status: 'timeout', error })
|
|
782
|
+
enqueue({ type: 'error', relay: url, error })
|
|
783
|
+
maybeFinishInitialReady()
|
|
784
|
+
}, timeout)))
|
|
785
|
+
}
|
|
786
|
+
const relay = await this.#getRelay(url)
|
|
787
|
+
if (isDone) { recoveryLease?.release(); return }
|
|
788
|
+
now = Math.floor(Date.now() / 1000)
|
|
789
|
+
if (filterUntil !== null && now >= filterUntil) { lease.release(); recoveryLease?.release(); return }
|
|
702
790
|
|
|
703
791
|
// Buffer post-EOSE live events while a reconnect gap fill is running so
|
|
704
792
|
// the historical gap is yielded before newer socket events.
|
|
705
793
|
let liveBuffer = (gapSince !== null && gapSince > 0) ? [] : null
|
|
706
794
|
let liveEose = false
|
|
795
|
+
let bufferedBytes = 0
|
|
707
796
|
|
|
708
797
|
// Open the live sub first so the relay starts buffering incoming events
|
|
709
798
|
// before we scan its database for the reconnect gap fill.
|
|
@@ -715,12 +804,21 @@ export class RelayPool {
|
|
|
715
804
|
// A limit:0 relay may still send retained events before EOSE. Do not
|
|
716
805
|
// expose them from a strictly-live stream.
|
|
717
806
|
if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
|
|
718
|
-
if (liveBuffer)
|
|
719
|
-
|
|
807
|
+
if (liveBuffer) {
|
|
808
|
+
const bytes = encoder.encode(JSON.stringify(event)).byteLength
|
|
809
|
+
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
810
|
+
overflow(url)
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
bufferedBytes += bytes
|
|
814
|
+
liveBuffer.push(event)
|
|
815
|
+
} else pushEvent(event, url)
|
|
720
816
|
},
|
|
721
817
|
onclose: error => {
|
|
722
818
|
if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
|
|
723
819
|
else if (liveSubs.has(url)) return
|
|
820
|
+
lease.release()
|
|
821
|
+
if (liveLeases.get(url) === lease) liveLeases.delete(url)
|
|
724
822
|
readyRelays.delete(url)
|
|
725
823
|
if (!isDone) {
|
|
726
824
|
if (error !== undefined) reportFailure(url, asError(error))
|
|
@@ -735,11 +833,11 @@ export class RelayPool {
|
|
|
735
833
|
markInitialEose(url)
|
|
736
834
|
}
|
|
737
835
|
})
|
|
738
|
-
if (isDone) { liveSub.close(); return }
|
|
836
|
+
if (isDone) { liveSub.close(); recoveryLease?.release(); return }
|
|
739
837
|
liveSubs.set(url, liveSub)
|
|
740
838
|
|
|
741
839
|
if (gapSince !== null && gapSince > 0) {
|
|
742
|
-
const task = runReconnectGapFill(url, gapSince, now).finally(() => {
|
|
840
|
+
const task = runReconnectGapFill(url, gapSince, now, slots).finally(() => {
|
|
743
841
|
const buf = liveBuffer
|
|
744
842
|
liveBuffer = null
|
|
745
843
|
for (const event of buf) pushEvent(event, url, true)
|
|
@@ -749,6 +847,9 @@ export class RelayPool {
|
|
|
749
847
|
gapTasks.add(task)
|
|
750
848
|
}
|
|
751
849
|
}).catch(err => {
|
|
850
|
+
recoveryLease?.release()
|
|
851
|
+
liveLeases.get(url)?.release()
|
|
852
|
+
liveLeases.delete(url)
|
|
752
853
|
readyRelays.delete(url)
|
|
753
854
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
754
855
|
reportFailure(url, reason)
|
|
@@ -759,7 +860,10 @@ export class RelayPool {
|
|
|
759
860
|
|
|
760
861
|
if (!urls.length) {
|
|
761
862
|
finishReady()
|
|
762
|
-
try {
|
|
863
|
+
try {
|
|
864
|
+
if (failure) throw failure
|
|
865
|
+
yield shift()
|
|
866
|
+
} finally {
|
|
763
867
|
teardown()
|
|
764
868
|
signal?.removeEventListener('abort', abort)
|
|
765
869
|
_stopSignal?.removeEventListener('abort', stop)
|
|
@@ -776,9 +880,10 @@ export class RelayPool {
|
|
|
776
880
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
777
881
|
while (!isDone || (draining && gapTasks.size > 0) || queue.length > 0) {
|
|
778
882
|
if (signal?.aborted) break
|
|
779
|
-
if (queue.length > 0) yield
|
|
883
|
+
if (queue.length > 0) yield shift()
|
|
780
884
|
else { await p.promise; p = Promise.withResolvers() }
|
|
781
885
|
}
|
|
886
|
+
if (failure) throw failure
|
|
782
887
|
} finally {
|
|
783
888
|
signal?.removeEventListener('abort', abort)
|
|
784
889
|
_stopSignal?.removeEventListener('abort', stop)
|
|
@@ -789,8 +894,8 @@ export class RelayPool {
|
|
|
789
894
|
|
|
790
895
|
// All-in-one event feed generator. For live:true, handles the full sequence:
|
|
791
896
|
//
|
|
792
|
-
// - live:true (default): unless filter.limit === 0,
|
|
793
|
-
//
|
|
897
|
+
// - live:true (default): unless filter.limit === 0, reserves live+history capacity,
|
|
898
|
+
// starts live input, then runs an initial one-shot fetch of stored events
|
|
794
899
|
// concurrently, yields stored events first, then flushes buffered live events (deduped
|
|
795
900
|
// against stored ones), then yields live events indefinitely. With limit:0 the relay
|
|
796
901
|
// sends no stored events, so the fetch is skipped and only the live sub runs.
|
|
@@ -798,9 +903,13 @@ export class RelayPool {
|
|
|
798
903
|
// short-circuits after the fastest relay with events EOSEs, or waits for all
|
|
799
904
|
// relays when null.
|
|
800
905
|
//
|
|
906
|
+
// snapshot:true captures a historical until after initial live readiness;
|
|
907
|
+
// that bound is reported in the history marker and never stops live input.
|
|
801
908
|
// All underlying generators are injectable for testing.
|
|
802
909
|
getEventsFeedGenerator (filter, relays, options = {}) {
|
|
803
|
-
|
|
910
|
+
if (options.snapshot !== undefined && typeof options.snapshot !== 'boolean') throw new ValidationError('INVALID_RELAY_SNAPSHOT')
|
|
911
|
+
assertBufferOptions(options)
|
|
912
|
+
assertReadOptions(filter, { timeout: options.timeout === undefined ? 5000 : options.timeout, timeoutAfterFirstEose: options.timeoutAfterFirstEose === undefined ? 500 : options.timeoutAfterFirstEose, queueTimeout: options.queueTimeout === undefined ? 30000 : options.queueTimeout })
|
|
804
913
|
return drainableStream(options => this.#getEventsFeedGenerator(filter, relays, options), options)
|
|
805
914
|
}
|
|
806
915
|
|
|
@@ -808,95 +917,138 @@ export class RelayPool {
|
|
|
808
917
|
signal,
|
|
809
918
|
_stopSignal,
|
|
810
919
|
live = true,
|
|
920
|
+
snapshot = false,
|
|
811
921
|
timeout = 5000,
|
|
812
922
|
timeoutAfterFirstEose = 500,
|
|
923
|
+
queueTimeout = 30000,
|
|
924
|
+
maxBufferedLiveEvents = 1000,
|
|
925
|
+
maxBufferedLiveBytes = 8 * 1024 * 1024,
|
|
813
926
|
_liveGenerator = (...args) => this.getLiveEventsGenerator(...args),
|
|
814
927
|
_eventsGenerator = (...args) => this.getEventsGenerator(...args)
|
|
815
928
|
} = {}) {
|
|
816
929
|
if (signal.aborted || _stopSignal.aborted) return
|
|
930
|
+
const options = { timeout, timeoutAfterFirstEose, queueTimeout, signal, _stopSignal }
|
|
817
931
|
if (!live) {
|
|
818
|
-
const
|
|
819
|
-
for await (const item of
|
|
932
|
+
const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
|
|
933
|
+
for await (const item of _eventsGenerator(snapshot ? { ...filter, ...bounds } : filter, relays, options)) {
|
|
820
934
|
if (signal.aborted) return
|
|
821
|
-
yield item
|
|
935
|
+
yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
|
|
822
936
|
}
|
|
823
937
|
return
|
|
824
938
|
}
|
|
825
939
|
|
|
826
|
-
// limit:0
|
|
940
|
+
// limit:0 is a live-only stream and reports that stream's initial window.
|
|
827
941
|
if (filter.limit === 0) {
|
|
828
|
-
|
|
829
|
-
if (signal.aborted) return
|
|
830
|
-
yield event
|
|
831
|
-
}
|
|
942
|
+
yield * _liveGenerator(filter, relays, options)
|
|
832
943
|
return
|
|
833
944
|
}
|
|
834
945
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const
|
|
946
|
+
const urls = normalizedRelayUrls(relays)
|
|
947
|
+
const inputAbort = new AbortController()
|
|
948
|
+
const inputSignal = AbortSignal.any([signal, inputAbort.signal])
|
|
949
|
+
const admissionSignal = AbortSignal.any([inputSignal, _stopSignal])
|
|
950
|
+
const reservations = new Map()
|
|
951
|
+
const leases = new Set()
|
|
952
|
+
for (const url of urls) {
|
|
953
|
+
const reservation = this.#admission.acquire(normalizeRelayUrl(url), { history: true, live: true, signal: admissionSignal, queueTimeout }).then(slots => {
|
|
954
|
+
for (const lease of Object.values(slots)) {
|
|
955
|
+
leases.add(lease)
|
|
956
|
+
if (admissionSignal.aborted) lease.release()
|
|
957
|
+
}
|
|
958
|
+
return slots
|
|
959
|
+
})
|
|
960
|
+
// Both readers consume the same attempt, including admission failures.
|
|
961
|
+
reservation.catch(() => {})
|
|
962
|
+
reservations.set(url, reservation)
|
|
963
|
+
}
|
|
964
|
+
const readOptions = { ...options, signal: inputSignal, _admissions: reservations, maxBufferedLiveEvents, maxBufferedLiveBytes }
|
|
965
|
+
const liveFilter = { ...filter }
|
|
966
|
+
if (snapshot) delete liveFilter.until
|
|
967
|
+
const liveGen = _liveGenerator(liveFilter, urls, readOptions)
|
|
840
968
|
const liveBuffer = []
|
|
969
|
+
const encoder = new TextEncoder()
|
|
970
|
+
let bufferedBytes = 0
|
|
841
971
|
let liveDone = false
|
|
842
972
|
let liveFailure
|
|
843
973
|
let liveWake = Promise.withResolvers()
|
|
844
|
-
|
|
974
|
+
const initialReady = Promise.withResolvers()
|
|
975
|
+
const shift = () => {
|
|
976
|
+
const entry = liveBuffer.shift()
|
|
977
|
+
bufferedBytes -= entry.bytes
|
|
978
|
+
return entry.item
|
|
979
|
+
}
|
|
845
980
|
const bgLoop = (async () => {
|
|
846
981
|
try {
|
|
847
|
-
for await (const
|
|
848
|
-
if (
|
|
849
|
-
if (
|
|
982
|
+
for await (const item of liveGen) {
|
|
983
|
+
if (inputSignal.aborted) break
|
|
984
|
+
if (item.type === 'eose') initialReady.resolve()
|
|
985
|
+
else {
|
|
986
|
+
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
987
|
+
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
988
|
+
throw Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay: item.relay, phase: 'live-buffer' })
|
|
989
|
+
}
|
|
990
|
+
liveBuffer.push({ item, bytes })
|
|
991
|
+
bufferedBytes += bytes
|
|
992
|
+
}
|
|
850
993
|
liveWake.resolve()
|
|
851
994
|
liveWake = Promise.withResolvers()
|
|
852
995
|
}
|
|
853
996
|
} catch (error) {
|
|
854
997
|
liveFailure = error
|
|
998
|
+
if (error.code === 'RELAY_LIVE_BUFFER_FULL') inputAbort.abort()
|
|
855
999
|
} finally {
|
|
856
1000
|
liveDone = true
|
|
1001
|
+
initialReady.resolve()
|
|
857
1002
|
liveWake.resolve()
|
|
858
1003
|
}
|
|
859
1004
|
})()
|
|
860
1005
|
|
|
861
1006
|
try {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1007
|
+
if (snapshot) await initialReady.promise
|
|
1008
|
+
if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
|
|
1009
|
+
if (signal.aborted) return
|
|
1010
|
+
const bounds = { since: filter.since ?? 0, until: Math.min(filter.until ?? Infinity, Math.floor(Date.now() / 1000)) }
|
|
1011
|
+
const historyFilter = snapshot ? { ...filter, ...bounds } : filter
|
|
1012
|
+
const fetchGen = _eventsGenerator(historyFilter, urls, readOptions)
|
|
865
1013
|
const seenIds = new Set()
|
|
866
1014
|
for await (const item of fetchGen) {
|
|
1015
|
+
if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
|
|
867
1016
|
if (signal.aborted) return
|
|
868
1017
|
if (item?.type === 'event' && !seenIds.has(item.event.id)) {
|
|
869
1018
|
seenIds.add(item.event.id)
|
|
870
1019
|
yield item
|
|
871
|
-
} else if (item?.type !== 'event')
|
|
1020
|
+
} else if (item?.type !== 'event') {
|
|
1021
|
+
yield snapshot && item.type === 'eose' ? { ...item, snapshot: bounds } : item
|
|
1022
|
+
}
|
|
872
1023
|
}
|
|
1024
|
+
if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
|
|
873
1025
|
|
|
874
|
-
// Flush buffered live events that arrived during the initial fetch, deduping
|
|
875
|
-
// against stored ones (overlap is possible around the fetch's until boundary)
|
|
876
1026
|
while (liveBuffer.length > 0) {
|
|
877
1027
|
if (signal.aborted) return
|
|
878
|
-
const item =
|
|
1028
|
+
const item = shift()
|
|
879
1029
|
if (item.type !== 'event' || !seenIds.has(item.event.id)) {
|
|
880
1030
|
if (item.type === 'event') seenIds.add(item.event.id)
|
|
881
1031
|
yield item
|
|
882
1032
|
}
|
|
883
1033
|
}
|
|
884
|
-
|
|
885
1034
|
seenIds.clear()
|
|
886
1035
|
|
|
887
|
-
// Yield subsequent live events directly — no more overlap with stored events
|
|
888
1036
|
// eslint-disable-next-line no-unmodified-loop-condition
|
|
889
1037
|
while (!liveDone || liveBuffer.length > 0) {
|
|
1038
|
+
if (liveFailure?.code === 'RELAY_LIVE_BUFFER_FULL') throw liveFailure
|
|
890
1039
|
while (liveBuffer.length > 0) {
|
|
891
1040
|
if (signal.aborted) return
|
|
892
|
-
yield
|
|
1041
|
+
yield shift()
|
|
893
1042
|
}
|
|
894
1043
|
if (!liveDone) await liveWake.promise
|
|
895
1044
|
}
|
|
896
1045
|
if (liveFailure) throw liveFailure
|
|
897
1046
|
} finally {
|
|
1047
|
+
inputAbort.abort()
|
|
898
1048
|
await liveGen.return()
|
|
899
1049
|
await bgLoop
|
|
1050
|
+
liveBuffer.length = 0
|
|
1051
|
+
for (const lease of leases) lease.release()
|
|
900
1052
|
}
|
|
901
1053
|
}
|
|
902
1054
|
|