libp2r2p 0.11.12 → 0.11.13

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
@@ -991,7 +991,10 @@ messages, history and files while preserving automatic NIP-65 updates. Successfu
991
991
  latency, and short-lived channel/recipient preferences avoid recently refused
992
992
  relays without changing receive subscriptions. See the
993
993
  [session coordinator](private-messenger/session/README.md) for deferred offline
994
- attempts and final failure notifications.
994
+ attempts and final failure notifications. Sends attempted while the messenger is
995
+ paused or otherwise unavailable now stay pending in the outbox and retry on a
996
+ bounded session backoff (1–30 seconds, 20% jitter) instead of surfacing a
997
+ user-facing send error.
995
998
 
996
999
 
997
1000
  ### Relay backpressure and bounded synchronization (0.11.7)
@@ -1030,10 +1033,12 @@ readiness on the replacement. Callbacks and completions are generation-fenced.
1030
1033
  Equal watch settings cannot keep a dead subscription registered as active.
1031
1034
 
1032
1035
  Overflow recovery keeps one timer per relay with exponential 1–30 second
1033
- backoff and 20% jitter, reset after a stable minute. Other relays remain live.
1034
- Removal, pause and close cancel pending work. Fragment storage stays scoped to
1035
- the same session across replacement subscriptions. Permanent refusals remain
1036
- reported and do not trigger this automatic retry loop.
1036
+ backoff and 20% jitter, reset after a stable minute. Relay-pool capacity closes
1037
+ (1013, "relay pool capacity") use the same relay backoff instead of stopping
1038
+ until an explicit rebuild, because the launcher pool bucket budget is transient.
1039
+ Other relays remain live. Removal, pause and close cancel pending work. Fragment
1040
+ storage stays scoped to the same session across replacement subscriptions.
1041
+ Permanent refusals remain reported and do not trigger this automatic retry loop.
1037
1042
 
1038
1043
  PrivateMessenger persists each interrupted interval before reopening, then
1039
1044
  uses paged `fetchHistory` to repair the gap while listening for new messages.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -21,6 +21,18 @@ function uniq (values) {
21
21
  return [...new Set((values || []).filter(Boolean))]
22
22
  }
23
23
 
24
+ // The launcher's shared relay pool closes anonymous members with 1013 when its
25
+ // per-relay buckets are all busy. That is a transient pool condition, not a
26
+ // permanent relay rejection, so the shared subscription must keep the normal
27
+ // relay backoff instead of stopping until something else re-opens it.
28
+ function isRelayPoolCapacity (error) {
29
+ for (let current = error, depth = 0; current && depth < 3; current = current.cause, depth++) {
30
+ if (current.closeCode === 1013 || current.code === 'RELAY_POOL_CAPACITY') return true
31
+ if (/relay pool capacity/i.test(current.message || '')) return true
32
+ }
33
+ return false
34
+ }
35
+
24
36
  function normalizeDeletionPubkey (deletionPubkey) {
25
37
  if (deletionPubkey === undefined) return undefined
26
38
  if (typeof deletionPubkey !== 'string' || !HEX_PUBKEY.test(deletionPubkey)) {
@@ -360,8 +372,9 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
360
372
  delay: entry.readyAt !== undefined && _now() - entry.readyAt >= 60000 ? 1000 : (previous?.delay || entry.retryDelay || 1000),
361
373
  notified: new Map(), timer: null
362
374
  }
363
- if (/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError' ||
364
- (error.code !== 'RELAY_LIVE_BUFFER_FULL' && !previous)) {
375
+ const permanent = /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError'
376
+ const retryable = error.code === 'RELAY_LIVE_BUFFER_FULL' || isRelayPoolCapacity(error) || Boolean(previous)
377
+ if (permanent || !retryable) {
365
378
  cancelRelayRecovery(relay)
366
379
  notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
367
380
  return
@@ -102,6 +102,11 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
102
102
  errors: reports.flatMap(report => report.errors || [])
103
103
  }))
104
104
  })
105
+ // A lifecycle change (signer/pause) during a batch is not a user abort:
106
+ // every failure summary stays retryable so the session can re-attempt it.
107
+ const finished = result => current()
108
+ ? finish(result)
109
+ : { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: !signal?.aborted }
105
110
  let result = { success: false }
106
111
  while ((batch ||= next())) {
107
112
  signal?.throwIfAborted()
@@ -124,15 +129,15 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
124
129
  const report = await settled
125
130
  const failed = rejected(report)
126
131
  errors.push(...(report?.errors || []))
127
- if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finish(result)
128
- if (!next()) { exclusions.clear(); return finish(result) }
129
- if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: current() }
132
+ if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finished(result)
133
+ if (!next()) { exclusions.clear(); return finished(result) }
134
+ if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: !signal?.aborted }
130
135
  remember(failed)
131
136
  }
132
- if (!current()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: false }
137
+ if (!current()) return finished(result)
133
138
  batch = null
134
139
  }
135
- return finish(result)
140
+ return finished(result)
136
141
  }
137
142
 
138
143
  return {
@@ -40,6 +40,14 @@ or cancelled/closed sends. Apps own route visibility and user-intent filtering;
40
40
  automatic retries may also fail. Callbacks must not throw. Errors are not copied
41
41
  into persisted outbox records.
42
42
 
43
+ Sends that only failed because the messenger was paused or otherwise
44
+ unavailable (`PRIVATE_MESSENGER_PAUSED`, `CHAT_UNAVAILABLE`) stay pending
45
+ without `onSendError`. The session re-pumps them on a bounded backoff
46
+ (1–30 seconds with 20% jitter) in addition to availability/connectivity
47
+ changes, so a paused messenger recovers without a manual retry. The backoff
48
+ resets on a user `retry`, on `setAvailable(true)`, and once no retryable entry
49
+ remains.
50
+
43
51
  `enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
44
52
  identities. Local message commits and remote stages have independent checkpoints.
45
53
  It sends original/thumbnail chunks before announcements, yields for small messages,
@@ -18,8 +18,20 @@ export function wireEvent (value, owner) {
18
18
  }
19
19
  // Relay rejection text must not be interpreted as a local signer denial.
20
20
  const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
21
+ const unavailableSend = /PRIVATE_MESSENGER_PAUSED|CHAT_UNAVAILABLE|MESSENGER_UNAVAILABLE/i
22
+ const isUnavailableSend = error => unavailableSend.test(`${error?.code || ''} ${error?.message || ''}`)
23
+ const SEND_RETRY_MIN_MS = 1000
24
+ const SEND_RETRY_MAX_MS = 30000
25
+ // A paused or unavailable messenger must keep the outbox entry retryable
26
+ // instead of surfacing a send failure the user has to retry by hand.
27
+ const unavailableError = (error, signal) => Object.assign(new Error(error?.message || 'CHAT_UNAVAILABLE'), {
28
+ code: error?.code,
29
+ cause: error,
30
+ retryWhenAvailable: !signal?.aborted,
31
+ retryWhenOnline: false
32
+ })
21
33
 
22
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
34
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
23
35
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
24
36
  const reportedErrors = new WeakSet()
25
37
  const reportError = error => {
@@ -40,6 +52,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
40
52
  const sendControllers = new Map()
41
53
  const offlineSends = new Set()
42
54
  let stopOnline
55
+ let sendRetryTimer
56
+ let sendRetryDelay = SEND_RETRY_MIN_MS
43
57
  let messenger
44
58
  let fileTransfers
45
59
  let downloads
@@ -60,6 +74,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
60
74
  let lifecycle = 0
61
75
  let configuring = Promise.resolve()
62
76
  let sending
77
+ let repump
63
78
  let draining
64
79
  const idleWaiters = new Set()
65
80
  const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
@@ -179,9 +194,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
179
194
  }
180
195
  async function publish (peer, event, signal) {
181
196
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
182
- if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
197
+ if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
183
198
  const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
184
- const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
199
+ let report
200
+ try {
201
+ report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
202
+ } catch (error) {
203
+ if (isUnavailableSend(error)) throw unavailableError(error, signal)
204
+ throw error
205
+ }
185
206
  // Published is relay acceptance, never a peer receipt.
186
207
  await assertMessagePublished(report, event)
187
208
  }
@@ -245,7 +266,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
245
266
  await storage.remove(entry.id)
246
267
  entries.delete(entry.id)
247
268
  emit()
248
- } catch (error) {
269
+ } catch (cause) {
270
+ // Pauses can surface at any stage (text publication, file authorization
271
+ // or chunk delivery). Normalize all of them into the retryable path.
272
+ const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
249
273
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
250
274
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
251
275
  await storage.put(entry, { existing: true }).catch(reportError)
@@ -253,12 +277,42 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
253
277
  // The failed wire event may be a quote or file chunk. Report the
254
278
  // owning outbox item separately, without mutating the native error.
255
279
  if (error.retryWhenOnline) offlineSends.add(entry.id)
280
+ if (error.retryWhenAvailable) scheduleSendRetry()
256
281
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
257
282
  }
258
283
  emit()
259
284
  } finally { sendControllers.delete(entry.id) }
260
285
  }
261
286
  function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
287
+ // Entries that failed only because the messenger was paused/unavailable do
288
+ // not receive a connectivity transition, so they own a bounded backoff timer
289
+ // instead of waiting for the next account-state change.
290
+ function clearSendRetry () {
291
+ if (sendRetryTimer) _clearTimeout(sendRetryTimer)
292
+ sendRetryTimer = undefined
293
+ }
294
+ function resetSendRetry () {
295
+ clearSendRetry()
296
+ sendRetryDelay = SEND_RETRY_MIN_MS
297
+ }
298
+ const pendingRetryable = entry => Boolean(entry.failed && !cancelled.has(entry.id) && (entry.retryable || entry.status === 'pending'))
299
+ function scheduleSendRetry () {
300
+ if (closed || !available || sendRetryTimer) return
301
+ const delay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
302
+ sendRetryDelay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * 2)
303
+ sendRetryTimer = _setTimeout(() => {
304
+ sendRetryTimer = undefined
305
+ if (closed || !available) return
306
+ let retried = false
307
+ for (const entry of entries.values()) {
308
+ if (!pendingRetryable(entry)) continue
309
+ entry.failed = false
310
+ retried = true
311
+ }
312
+ if (retried) pump()
313
+ }, delay)
314
+ sendRetryTimer.unref?.()
315
+ }
262
316
  function watchOfflineSends () {
263
317
  if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
264
318
  if (stopOnline) return
@@ -276,15 +330,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
276
330
  })
277
331
  }
278
332
  async function pump () {
279
- if (sending || !available || closed || !messenger || !storage) return
333
+ if (!available || closed || !messenger || !storage) return
334
+ // A retry that lands while a pump is already running must not be skipped
335
+ // just because that pass already walked past the entry.
336
+ if (sending) { repump = true; return }
280
337
  sending = true
281
338
  try {
282
- for (const entry of entries.values()) {
283
- if (closed || !available) break
284
- if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
285
- await sendEntry(entry)
286
- }
287
- } finally { sending = false; watchOfflineSends(); settled() }
339
+ do {
340
+ repump = false
341
+ for (const entry of entries.values()) {
342
+ if (closed || !available) break
343
+ if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
344
+ await sendEntry(entry)
345
+ }
346
+ } while (repump && !closed && available)
347
+ } finally {
348
+ sending = false
349
+ watchOfflineSends()
350
+ if (![...entries.values()].some(pendingRetryable)) resetSendRetry()
351
+ settled()
352
+ }
288
353
  }
289
354
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
290
355
  async function download (file, options = {}) {
@@ -359,10 +424,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
359
424
  async setAvailable (value) {
360
425
  available = value === true
361
426
  stopOnlineRetry(); offlineSends.clear()
427
+ if (available) resetSendRetry()
428
+ else clearSendRetry()
362
429
  lifecycle++
363
430
  if (!available) { await messenger?.pause('signer'); return }
364
431
  await ready()
365
- for (const entry of entries.values()) if (entry.retryable) entry.failed = false
432
+ for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
366
433
  await messenger?.resume('storage')
367
434
  return schedule()
368
435
  },
@@ -382,7 +449,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
382
449
  offlineSends.delete(id); watchOfflineSends()
383
450
  await ready()
384
451
  const entry = entries.get(id)
385
- if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
452
+ if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
386
453
  },
387
454
  async cancel (id) {
388
455
  cancelled.add(id)
@@ -396,6 +463,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
396
463
  async close () {
397
464
  closed = true; available = false; lifecycle++
398
465
  stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
466
+ clearSendRetry()
399
467
  for (const controller of sendControllers.values()) controller.abort()
400
468
  await Promise.allSettled([configuring, initialized])
401
469
  await messenger?.pause('closed')