libp2r2p 0.11.7 → 0.11.8

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
@@ -985,3 +985,39 @@ publication failures retain the payload/index for retry. Outbound channel
985
985
  fragmentation streams from prepared encrypted rows, cleans partial writes and
986
986
  removes failed new keys from the temporary-storage registry. It does not raise
987
987
  the browser's Web Storage quota or delete unrelated application data.
988
+
989
+
990
+ ### Private subscription recovery (0.11.8)
991
+
992
+ `privateChannel.subscribe()` returns `{ ready, done, close }`. `done` always
993
+ resolves to `{ status: 'closed' | 'ended' | 'failed', error? }` after processing
994
+ stops. Explicit cancellation is `closed`; a naturally exhausted stream is
995
+ `ended`; a terminal error is `failed` and still reaches `onError`.
996
+
997
+ `private-message.watch()` accepts an optional asynchronous
998
+ `onSubscriptionState({ state, relay, since, until?, error? })` callback.
999
+ `interrupted` identifies the conservative lower recovery boundary; after a
1000
+ buffer overflow, every affected channel must finish this callback before its
1001
+ shared relay subscription reopens. `ready` follows successful initial relay
1002
+ readiness on the replacement. Callbacks and completions are generation-fenced.
1003
+ Equal watch settings cannot keep a dead subscription registered as active.
1004
+
1005
+ Overflow recovery keeps one timer per relay with exponential 1–30 second
1006
+ backoff and 20% jitter, reset after a stable minute. Other relays remain live.
1007
+ Removal, pause and close cancel pending work. Fragment storage stays scoped to
1008
+ the same session across replacement subscriptions. Permanent refusals remain
1009
+ reported and do not trigger this automatic retry loop.
1010
+
1011
+ PrivateMessenger persists each interrupted interval before reopening, then
1012
+ uses paged `fetchHistory` to repair the gap while listening for new messages.
1013
+ Transient history failures retry with bounded backoff and honor `retryAt`;
1014
+ dense-page limits, permanent refusals and unknown failures stay pending without
1015
+ an automatic loop. Disabled historical recovery restores only live input.
1016
+ Existing persisted ranges, checkpoints, recovery windows and ack/nack semantics
1017
+ are unchanged; newer live arrivals cannot erase a pending interval.
1018
+
1019
+ `RELAY_LIVE_BUFFER_FULL` retains `relay`, `phase`, `recoverySince` and a bounded
1020
+ `buffer` diagnostic: stage (`delivery`, `reconnect`, `history-wait`), queued
1021
+ count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
1022
+ serialized volume, not heap use. Diagnostics never retain event contents or
1023
+ filters. Existing 1,000-item / 8-MiB live limits are unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -5,7 +5,7 @@ export function subscriptionError (reason, relay) {
5
5
  const error = reason instanceof AggregateError
6
6
  ? new AggregateError(reason.errors, message, { cause: reason })
7
7
  : new Error(message, { cause: reason })
8
- for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean']) {
8
+ for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean', 'phase', 'buffer', 'recoverySince']) {
9
9
  if (reason?.[key] !== undefined) error[key] = reason[key]
10
10
  }
11
11
  error.operation = 'private-channel.subscribe'
@@ -984,6 +984,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
984
984
  })
985
985
 
986
986
  async function consumeEvents () {
987
+ let result = { status: 'ended' }
987
988
  try {
988
989
  for await (const item of events) {
989
990
  if (controller.signal.aborted) continue
@@ -991,19 +992,26 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
991
992
  else if (item.type === 'event') await processOuterEvent(item.event)
992
993
  }
993
994
  } catch (error) {
994
- if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
995
+ if (!controller.signal.aborted) {
996
+ const diagnostic = subscriptionError(error, error?.relay)
997
+ result = { status: 'failed', error: diagnostic }
998
+ // Consumer error callbacks must not turn `done` into an unhandled rejection.
999
+ try { onError?.(diagnostic) } catch {}
1000
+ }
995
1001
  } finally {
996
1002
  processOuterEvent.close()
997
1003
  }
1004
+ return controller.signal.aborted ? { status: 'closed' } : result
998
1005
  }
999
1006
 
1000
- const consumePromise = consumeEvents()
1007
+ const done = consumeEvents()
1001
1008
 
1002
1009
  return {
1003
1010
  ready: events.ready || Promise.resolve(),
1011
+ done,
1004
1012
  close () {
1005
1013
  controller.abort()
1006
- return consumePromise
1014
+ return done
1007
1015
  }
1008
1016
  }
1009
1017
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeRelayUrl } from '../url/index.js'
1
2
  import { isValidEvent } from '../event/index.js'
2
3
  import { ValidationError } from '../error/index.js'
3
4
  import { generateKeypair } from '../key/index.js'
@@ -144,9 +145,10 @@ async function ownPrivateChannelPubkey (signer) {
144
145
 
145
146
  // A session owns its callbacks and relay reads. Other consumers, including
146
147
  // the other side of the same shared channel, cannot overwrite or stop them.
147
- export function createPrivateMessageSession () {
148
+ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearTimeout = clearTimeout, _now = Date.now, _random = Math.random } = {}) {
148
149
  const watchesByChannel = new Map()
149
150
  const subsByRelay = new Map()
151
+ const relayRecoveries = new Map()
150
152
  let nextWatchRevision = 1
151
153
  let requestSequence = 0
152
154
  const watchRequests = new Map()
@@ -301,11 +303,113 @@ export function createPrivateMessageSession () {
301
303
  }
302
304
  }
303
305
 
304
- function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true } = {}) {
306
+ function reportSubscriptionError (channels, error) {
307
+ try { watchCallbacks([...channels][0]).onError?.(error) } catch {}
308
+ }
309
+
310
+ function cancelRelayRecovery (relay) {
311
+ const recovery = relayRecoveries.get(relay)
312
+ if (recovery?.timer != null) _clearTimeout(recovery.timer)
313
+ relayRecoveries.delete(relay)
314
+ }
315
+
316
+ async function notifyInterruption (relay, recovery) {
317
+ const channels = desiredRelayState().get(relay) || []
318
+ // Each consumer must persist its gap before this shared subscription restarts.
319
+ // Keep acknowledgments on partial failure; new/replaced watches participate too.
320
+ const outcomes = await Promise.allSettled([...channels].map(async channel => {
321
+ const watch = watchesByChannel.get(channel)
322
+ if (recovery.notified.get(channel) === watch) return
323
+ await watch.callbacks.onSubscriptionState?.({ state: 'interrupted', relay, error: recovery.error, since: recovery.since })
324
+ recovery.notified.set(channel, watch)
325
+ }))
326
+ const failed = outcomes.find(result => result.status === 'rejected')
327
+ if (failed) throw failed.reason
328
+ if (relayRecoveries.get(relay) === recovery && [...(desiredRelayState().get(relay) || [])].some(channel => recovery.notified.get(channel) !== watchesByChannel.get(channel))) {
329
+ await notifyInterruption(relay, recovery)
330
+ }
331
+ }
332
+
333
+ function scheduleRelayRecovery (relay, recovery) {
334
+ if (relayRecoveries.get(relay) !== recovery || recovery.timer || !desiredRelayState().has(relay)) return
335
+ const delay = Math.min(30000, recovery.delay * (0.8 + _random() * 0.4))
336
+ recovery.delay = Math.min(30000, recovery.delay * 2)
337
+ recovery.timer = _setTimeout(async () => {
338
+ recovery.timer = null
339
+ try {
340
+ await notifyInterruption(relay, recovery)
341
+ if (relayRecoveries.get(relay) !== recovery || !desiredRelayState().has(relay)) return
342
+ await rebuildSubscriptions({ retryRelay: relay })
343
+ } catch (error) {
344
+ reportSubscriptionError(desiredRelayState().get(relay) || [], error)
345
+ scheduleRelayRecovery(relay, recovery)
346
+ }
347
+ }, delay)
348
+ recovery.timer?.unref?.()
349
+ }
350
+
351
+ function subscriptionEnded (relay, entry, result) {
352
+ if (subsByRelay.get(relay) !== entry) return
353
+ subsByRelay.delete(relay)
354
+ if (result.status === 'closed' || !desiredRelayState().has(relay)) return
355
+ const error = result.error || Object.assign(new Error('PRIVATE_CHANNEL_SUBSCRIPTION_ENDED'), { code: 'PRIVATE_CHANNEL_SUBSCRIPTION_ENDED', relay })
356
+ const previous = relayRecoveries.get(relay)
357
+ const recovery = {
358
+ error,
359
+ since: Math.min(entry.since, error.recoverySince ?? entry.since, previous?.since ?? Infinity),
360
+ delay: entry.readyAt !== undefined && _now() - entry.readyAt >= 60000 ? 1000 : (previous?.delay || entry.retryDelay || 1000),
361
+ notified: new Map(), timer: null
362
+ }
363
+ if (/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError' ||
364
+ (error.code !== 'RELAY_LIVE_BUFFER_FULL' && !previous)) {
365
+ cancelRelayRecovery(relay)
366
+ notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
367
+ return
368
+ }
369
+ cancelRelayRecovery(relay)
370
+ relayRecoveries.set(relay, recovery)
371
+ // Start persistence immediately, without overlapping a later retry callback.
372
+ recovery.preparing = notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
373
+ recovery.preparing.finally(() => scheduleRelayRecovery(relay, recovery))
374
+ }
375
+
376
+ async function subscriptionReady (relay, entry, ready) {
377
+ if (subsByRelay.get(relay) !== entry) return
378
+ const recovery = relayRecoveries.get(relay)
379
+ if (!ready?.relays?.includes(relay)) {
380
+ if (recovery) {
381
+ const error = ready?.errors?.[0]?.reason || Object.assign(new Error('RELAY_LIVE_NOT_READY'), { code: 'RELAY_LIVE_NOT_READY', relay })
382
+ subscriptionEnded(relay, entry, { status: 'failed', error })
383
+ await entry.sub.close()
384
+ }
385
+ return
386
+ }
387
+ entry.readyAt = _now()
388
+ if (!recovery) return
389
+ try {
390
+ const until = Math.floor(_now() / 1000)
391
+ await Promise.all([...entry.channels].map(async channel => {
392
+ if (subsByRelay.get(relay) !== entry || !watchesByChannel.has(channel)) return
393
+ await watchCallbacks(channel).onSubscriptionState?.({ state: 'ready', relay, since: recovery.since, until })
394
+ }))
395
+ if (subsByRelay.get(relay) === entry && relayRecoveries.get(relay) === recovery) {
396
+ entry.retryDelay = recovery.delay
397
+ cancelRelayRecovery(relay)
398
+ await rebuildSubscriptions()
399
+ }
400
+ } catch (error) {
401
+ reportSubscriptionError(entry.channels, error)
402
+ subscriptionEnded(relay, entry, { status: 'failed', error })
403
+ await entry.sub.close()
404
+ }
405
+ }
406
+
407
+ function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true, retryRelay } = {}) {
305
408
  subscribe = _subscribe === privateChannel.subscribe ? subscribe : _subscribe
306
409
  _subscribe = subscribe
307
410
  const desired = desiredRelayState()
308
411
  const closing = []
412
+ for (const relay of relayRecoveries.keys()) if (!desired.has(relay)) cancelRelayRecovery(relay)
309
413
 
310
414
  for (const [relay, current] of subsByRelay) {
311
415
  const nextChannels = desired.get(relay)
@@ -321,6 +425,8 @@ export function createPrivateMessageSession () {
321
425
  const current = subsByRelay.get(relay)
322
426
  if (doesSubscriptionMatch(current, channels)) continue
323
427
 
428
+ if (relayRecoveries.has(relay) && retryRelay !== relay) continue
429
+ const since = nowSeconds()
324
430
  const channelList = [...channels]
325
431
  const firstWatch = watchesByChannel.get(channelList[0])
326
432
  const sub = _subscribe({
@@ -345,7 +451,7 @@ export function createPrivateMessageSession () {
345
451
  ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
346
452
  ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
347
453
  limit: 0,
348
- since: nowSeconds(),
454
+ since,
349
455
  liveOnly: true,
350
456
  onChunk: handleChunk,
351
457
  onEvent: (event, outer, meta) => {
@@ -358,14 +464,24 @@ export function createPrivateMessageSession () {
358
464
  return dispatchSeedEvent(seed)
359
465
  },
360
466
  onContentKeyUsage: dispatchContentKeyUsage,
361
- onError: err => firstWatch.callbacks.onError?.(err)
467
+ onError: err => reportSubscriptionError(channels, err)
362
468
  })
363
469
 
364
- subsByRelay.set(relay, {
470
+ const entry = {
471
+ since,
365
472
  channels: new Set(channels),
366
473
  revisions: watchRevisionsForChannels(channelList),
367
474
  sub
368
- })
475
+ }
476
+ subsByRelay.set(relay, entry)
477
+ if (sub.done) sub.done.then(result => subscriptionEnded(relay, entry, result)).catch(error => reportSubscriptionError(channels, error))
478
+ if (sub.ready) {
479
+ sub.ready.then(ready => subscriptionReady(relay, entry, ready)).catch(error => {
480
+ reportSubscriptionError(channels, error)
481
+ subscriptionEnded(relay, entry, { status: 'failed', error })
482
+ Promise.resolve(sub.close()).catch(() => {})
483
+ })
484
+ }
369
485
  if (current) {
370
486
  const close = closeSubscription(current.sub, gracefulClose)
371
487
  if (close) closing.push(close)
@@ -393,6 +509,7 @@ export function createPrivateMessageSession () {
393
509
  onSeed,
394
510
  onChunk,
395
511
  onContentKeyUsage,
512
+ onSubscriptionState,
396
513
  onError,
397
514
  receivedChunkTtlMs,
398
515
  receivedChunkMaxBytes,
@@ -403,6 +520,7 @@ export function createPrivateMessageSession () {
403
520
  _subscribe = privateChannel.subscribe
404
521
  }) {
405
522
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
523
+ relays = uniq(relays.map(normalizeRelayUrl))
406
524
  const request = ++requestSequence
407
525
  const channelList = uniq(channels?.length ? channels : [await ownPrivateChannelPubkey(privateChannelSigner)])
408
526
  for (const channel of channelList) {
@@ -411,9 +529,8 @@ export function createPrivateMessageSession () {
411
529
  const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
412
530
  if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
413
531
  identity = ownPubkey
414
- const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onError }
532
+ const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
415
533
 
416
- let changed = false
417
534
  for (const channel of channelList) {
418
535
  if (watchRequests.get(channel) !== request) continue
419
536
  const next = {
@@ -457,10 +574,9 @@ export function createPrivateMessageSession () {
457
574
  continue
458
575
  }
459
576
  watchesByChannel.set(channel, next)
460
- changed = true
461
577
  }
462
578
 
463
- if (changed) await rebuildSubscriptions({ _subscribe })
579
+ await rebuildSubscriptions({ _subscribe })
464
580
  return () => {
465
581
  const owned = channelList.filter(channel => watchRequests.get(channel) === request)
466
582
  return unwatch(owned)
@@ -0,0 +1,26 @@
1
+ // Retry operational interruptions, never dense-page limits or permanent refusals.
2
+ export function recoveryRetryDelay (errors, delay) {
3
+ let retry = false
4
+ let blocked = false
5
+ let retryAt = 0
6
+ const seen = new Set()
7
+ function visit (error) {
8
+ if (!error || seen.has(error)) return
9
+ seen.add(error)
10
+ if (error.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || error.name === 'ValidationError' ||
11
+ /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '')) blocked = true
12
+ if (['transport', 'timeout'].includes(error.category) ||
13
+ ['QuotaExceededError', 'InvalidStateError', 'UnknownError'].includes(error.name) ||
14
+ /^(rate-limited:|error:)/.test(error.message || '') ||
15
+ ['RELAY_LIVE_BUFFER_FULL', 'RELAY_LIVE_NOT_READY'].includes(error.code)) retry = true
16
+ if (Number.isFinite(error.retryAt)) retryAt = Math.max(retryAt, Math.min(Date.now() + 300000, error.retryAt))
17
+ for (const child of error.errors || []) visit(child)
18
+ for (const entry of error.relays || []) {
19
+ if (['timeout', 'cutoff', 'closed'].includes(entry.status)) retry = true
20
+ visit(entry.error)
21
+ }
22
+ visit(error.cause)
23
+ }
24
+ errors.forEach(visit)
25
+ return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
26
+ }
@@ -1,3 +1,4 @@
1
+ import { recoveryRetryDelay } from './helpers/recovery-retry.js'
1
2
  // Expected use:
2
3
  // const messenger = await createPrivateMessenger({
3
4
  // userSigner,
@@ -293,6 +294,8 @@ export class PrivateMessenger {
293
294
  this.capacityCheck = null
294
295
  this.capacityRequiredBytes = 0
295
296
  this.reloadGapTimers = new Map()
297
+ this.liveInterruptions = new Map()
298
+ this.liveRecoveryTimers = new Map()
296
299
  this.watchRevisionByChannel = new Map()
297
300
  this.presenceTimers = new Map()
298
301
  this.stopRelayListWatcher = null
@@ -1103,6 +1106,7 @@ export class PrivateMessenger {
1103
1106
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1104
1107
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1105
1108
  receivedChunkIndexedDB: this._indexedDB,
1109
+ onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1106
1110
  onError: err => this.onError?.(err)
1107
1111
  })
1108
1112
  if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
@@ -1136,6 +1140,76 @@ export class PrivateMessenger {
1136
1140
  return this
1137
1141
  }
1138
1142
 
1143
+ async handleSubscriptionState (pubkey, { state, relay, since, until }) {
1144
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
1145
+ if (!this.offlineRecoverySecondsFor(pubkey)) return
1146
+ let interrupted = this.liveInterruptions.get(pubkey)
1147
+ if (state === 'interrupted') {
1148
+ if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
1149
+ const start = Math.min(interrupted.get(relay) ?? Infinity, since)
1150
+ interrupted.set(relay, start)
1151
+ this.cancelReloadGap(pubkey)
1152
+ for (const controller of this.recoveryControllers) {
1153
+ if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
1154
+ }
1155
+ this.recordInterruption([pubkey], start)
1156
+ this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1157
+ // This rejection prevents the session from reopening the shared relay.
1158
+ await this.flushStateWrites()
1159
+ } else if (state === 'ready' && interrupted?.has(relay)) {
1160
+ const start = interrupted.get(relay)
1161
+ this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), until)
1162
+ if (interrupted.size === 1) this.closeOpenOfflineRanges([pubkey])
1163
+ await this.flushStateWrites()
1164
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || this.liveInterruptions.get(pubkey) !== interrupted) return
1165
+ interrupted.delete(relay)
1166
+ if (!interrupted.size) this.liveInterruptions.delete(pubkey)
1167
+ this.scheduleLiveRecovery(pubkey)
1168
+ }
1169
+ }
1170
+
1171
+ cancelLiveRecovery (pubkey) {
1172
+ const work = this.liveRecoveryTimers.get(pubkey)
1173
+ if (work?.timer != null) this._clearTimeout(work.timer)
1174
+ this.liveRecoveryTimers.delete(pubkey)
1175
+ }
1176
+
1177
+ scheduleLiveRecovery (pubkey) {
1178
+ const current = this.liveRecoveryTimers.get(pubkey)
1179
+ if (current) { current.again = true; return }
1180
+ const work = { delay: 1000, timer: null, again: false }
1181
+ this.liveRecoveryTimers.set(pubkey, work)
1182
+ const isCurrent = () => this.liveRecoveryTimers.get(pubkey) === work && !this.closePromise &&
1183
+ !this.pauseReasons.size && this.desiredChannels.has(pubkey)
1184
+ const schedule = delay => {
1185
+ work.timer = this._setTimeout(async () => {
1186
+ work.timer = null
1187
+ if (!isCurrent()) return
1188
+ work.again = false
1189
+ let errors
1190
+ try {
1191
+ // A ready notification may overtake cancellation of the previous scan.
1192
+ // Let that scan retain its range, then start a fresh recovery attempt.
1193
+ await this.recoveries.get(pubkey)
1194
+ if (!isCurrent()) return
1195
+ errors = await this.recoverOfflineRanges([pubkey])
1196
+ } catch (error) {
1197
+ this.onError?.(error)
1198
+ errors = [error]
1199
+ }
1200
+ if (!isCurrent()) return
1201
+ const pending = this.readState().channels[pubkey]?.offlineRanges?.length
1202
+ const retry = pending ? recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4))) : null
1203
+ if (work.again || retry !== null) {
1204
+ schedule(work.again ? 0 : retry)
1205
+ work.delay = Math.min(30000, work.delay * 2)
1206
+ } else this.liveRecoveryTimers.delete(pubkey)
1207
+ }, delay)
1208
+ work.timer?.unref?.()
1209
+ }
1210
+ schedule(0)
1211
+ }
1212
+
1139
1213
  recordInterruption (channels, at = nowSeconds()) {
1140
1214
  const state = this.readState()
1141
1215
  for (const pubkey of channels) {
@@ -1152,6 +1226,8 @@ export class PrivateMessenger {
1152
1226
  const closing = []
1153
1227
  for (const pubkey of channels) {
1154
1228
  this.cancelReloadGap(pubkey)
1229
+ this.cancelLiveRecovery(pubkey)
1230
+ this.liveInterruptions.delete(pubkey)
1155
1231
  this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
1156
1232
  const close = this.stopByChannel.get(pubkey)?.()
1157
1233
  if (close?.then) closing.push(close)
@@ -2063,6 +2139,7 @@ export class PrivateMessenger {
2063
2139
  }
2064
2140
 
2065
2141
  async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
2142
+ const failures = []
2066
2143
  for (const pubkey of uniq(channels)) {
2067
2144
  if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) continue
2068
2145
  let work = this.recoveries.get(pubkey)
@@ -2070,11 +2147,13 @@ export class PrivateMessenger {
2070
2147
  work = this.recoverChannelRanges([pubkey])
2071
2148
  this.recoveries.set(pubkey, work)
2072
2149
  }
2073
- try { await work } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
2150
+ try { failures.push(...await work) } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
2074
2151
  }
2152
+ return failures
2075
2153
  }
2076
2154
 
2077
2155
  async recoverChannelRanges (channels) {
2156
+ const failures = []
2078
2157
  const state = this.readState()
2079
2158
  const now = nowSeconds()
2080
2159
 
@@ -2125,12 +2204,14 @@ export class PrivateMessenger {
2125
2204
  })
2126
2205
  controller.signal.throwIfAborted()
2127
2206
  const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
2128
- const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2207
+ const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2129
2208
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
2209
+ failures.push(...attempt.failures.map(failure => failure.error))
2130
2210
  if (lifecycleChanged || attempt.failures.length) remaining.push(range)
2131
2211
  else recoveredThrough = Math.max(recoveredThrough, range.end)
2132
2212
  } catch (err) {
2133
- this.onError?.(err)
2213
+ if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
2214
+ failures.push(err)
2134
2215
  remaining.push(range)
2135
2216
  } finally { this.recoveryControllers.delete(controller) }
2136
2217
  }
@@ -2145,6 +2226,7 @@ export class PrivateMessenger {
2145
2226
  this.writeState(fresh)
2146
2227
  await this.flushStateWrites()
2147
2228
  }
2229
+ return failures
2148
2230
  }
2149
2231
 
2150
2232
  async clearChannel (pubkey) {
@@ -0,0 +1,15 @@
1
+ // Bounded diagnostics only: never attach queued payloads or subscription filters.
2
+ export function liveBufferError ({ relay, stage, queue, queuedBytes, incoming, incomingBytes, maxEvents, maxBytes, since }) {
3
+ const now = Date.now()
4
+ const boundaries = [since, incoming?.event?.created_at, ...queue.map(entry => entry.item?.event?.created_at)]
5
+ .filter(value => Number.isFinite(value) && value >= 0)
6
+ return Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), {
7
+ code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer',
8
+ recoverySince: Math.min(Math.floor(now / 1000), ...boundaries),
9
+ buffer: {
10
+ stage, queuedEvents: queue.length, queuedBytes, incomingBytes,
11
+ limits: { events: maxEvents, bytes: maxBytes },
12
+ oldestQueuedMs: queue.length ? Math.max(0, now - queue[0].receivedAt) : 0
13
+ }
14
+ })
15
+ }
@@ -1,3 +1,4 @@
1
+ import { liveBufferError } from '../helpers/live-buffer-error.js'
1
2
  import { ValidationError } from '../../error/index.js'
2
3
  import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
3
4
  import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
@@ -651,18 +652,23 @@ export class RelayPool {
651
652
  const seenIds = new Set()
652
653
 
653
654
  let untilTimer = null
654
- const overflow = relay => {
655
- failure = Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer' })
655
+ const overflow = (item, bytes, stage = 'delivery', entries = queue, volume = queuedBytes) => {
656
+ if (isDone) return
657
+ failure = liveBufferError({
658
+ relay: item.relay, stage, queue: entries, queuedBytes: volume, incoming: item, incomingBytes: bytes,
659
+ maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes,
660
+ since: Math.min(sinceFloor, ...pendingGaps.values(), ...queue.map(entry => entry.item?.event?.created_at).filter(Number.isFinite))
661
+ })
656
662
  teardown()
657
663
  }
658
664
  const enqueue = item => {
659
665
  const bytes = encoder.encode(JSON.stringify(item)).byteLength
660
666
  if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
661
- overflow(item.relay)
667
+ overflow(item, bytes)
662
668
  return
663
669
  }
664
670
  queuedBytes += bytes
665
- queue.push({ item, bytes })
671
+ queue.push({ item, bytes, receivedAt: Date.now() })
666
672
  p.resolve()
667
673
  p = Promise.withResolvers()
668
674
  }
@@ -863,11 +869,11 @@ export class RelayPool {
863
869
  if (liveBuffer) {
864
870
  const bytes = encoder.encode(JSON.stringify(event)).byteLength
865
871
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
866
- overflow(url)
872
+ overflow({ event, relay: url }, bytes, 'reconnect', liveBuffer, bufferedBytes)
867
873
  return
868
874
  }
869
875
  bufferedBytes += bytes
870
- liveBuffer.push(event)
876
+ liveBuffer.push({ item: { event, relay: url }, receivedAt: Date.now() })
871
877
  } else pushEvent(event, url)
872
878
  },
873
879
  onclose: error => {
@@ -906,7 +912,7 @@ export class RelayPool {
906
912
  }).finally(() => {
907
913
  const buf = liveBuffer
908
914
  liveBuffer = null
909
- for (const event of buf) pushEvent(event, url, true)
915
+ for (const entry of buf) pushEvent(entry.item.event, url, true)
910
916
  gapTasks.delete(task)
911
917
  scheduleProgress(attempt)
912
918
  p.resolve()
@@ -1052,9 +1058,9 @@ export class RelayPool {
1052
1058
  else {
1053
1059
  const bytes = encoder.encode(JSON.stringify(item)).byteLength
1054
1060
  if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
1055
- throw Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), { code: 'RELAY_LIVE_BUFFER_FULL', relay: item.relay, phase: 'live-buffer' })
1061
+ throw liveBufferError({ relay: item.relay, stage: 'history-wait', queue: liveBuffer, queuedBytes: bufferedBytes, incoming: item, incomingBytes: bytes, maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes, since: filter.since ?? 0 })
1056
1062
  }
1057
- liveBuffer.push({ item, bytes })
1063
+ liveBuffer.push({ item, bytes, receivedAt: Date.now() })
1058
1064
  bufferedBytes += bytes
1059
1065
  }
1060
1066
  liveWake.resolve()