libp2r2p 0.11.14 → 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 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 and still permits automatic recovery.
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 their reconnect behavior until cancelled by their owner.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.14",
3
+ "version": "0.11.15",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -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
+ }
@@ -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 new Error('COUNT_TIMEOUT')
80
+ return relayTimeoutError('COUNT_TIMEOUT')
80
81
  }
81
82
 
82
83
  function getEventsTimeoutError () {
83
- return new Error('GET_EVENTS_TIMEOUT')
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 timer of retryTimers.values()) clearTimeout(timer)
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 scheduleReconnect = (url, reconnectDelay) => {
772
- if (isDone || retryTimers.has(url)) return
773
- const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
774
- const timer = maybeUnref(setTimeout(() => {
775
- retryTimers.delete(url)
776
- subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
777
- }, reconnectDelay))
778
- retryTimers.set(url, timer)
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 failed = false
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
- failed = true
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
- return completed && !failed
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
- reportFailure(url, asError(err))
817
- return false
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, reconnectDelay = 1000) => {
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.closed = true
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, asError(error))
890
- else {
891
- const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
892
- enqueue({ type: 'error', relay: url, error: interruption })
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, reconnectDelay)
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 (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
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
- if (isDone) return
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 urls) this.#decrementLiveSub(url)
1030
+ for (const url of [...retainedRelays]) releaseRelay(url)
964
1031
  teardown()
965
1032
  }
966
1033
  }