libp2r2p 0.11.11 → 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
@@ -541,6 +541,22 @@ and the uncovered subranges plus the pre-oldest left edge are requested from
541
541
  seeders. Offline attempts never consume the budget, mark coverage or contact
542
542
  seeders; they wait for connectivity. The per-relay tracker is process-local, so a
543
543
  restart falls back to the persisted whole interval.
544
+
545
+ `prioritizeRange(channelPubkey, { since, until, type })` registers an
546
+ application-directed priority window for the next recovery pass. `unread-page`
547
+ requires `since` (a read anchor); `tail` derives a bounded window from the
548
+ newest persisted range using `priorityTailSeconds` (six hours by default).
549
+ Priority subranges are fetched before older history; relays are ordered by
550
+ recent EOSE, consecutive failures and latency; and the first priority page may
551
+ hedge up to `priorityHedgeRelays` (two) relays with `priorityHedgeDelayMs`
552
+ (500 ms), aborting the loser. When relays do not cover the window, seeders with
553
+ recent presence (`announcedAt`/`lastActiveAt` within `seederOnlineSeconds`) are
554
+ asked only for that subrange; a successful ask counts as coverage. Priority
555
+ probes never consume the relay tier budget, and priorities/statistics are
556
+ process-local: they expire after `priorityTtlMs` (ten minutes) or once covered,
557
+ and clear with channel/recovery state. Presence payloads and persisted ranges
558
+ are unchanged.
559
+
544
560
  `private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
545
561
  used by messenger recovery. It accepts the signers, processor/fragment options,
546
562
  callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
@@ -975,7 +991,10 @@ messages, history and files while preserving automatic NIP-65 updates. Successfu
975
991
  latency, and short-lived channel/recipient preferences avoid recently refused
976
992
  relays without changing receive subscriptions. See the
977
993
  [session coordinator](private-messenger/session/README.md) for deferred offline
978
- 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.
979
998
 
980
999
 
981
1000
  ### Relay backpressure and bounded synchronization (0.11.7)
@@ -1014,10 +1033,12 @@ readiness on the replacement. Callbacks and completions are generation-fenced.
1014
1033
  Equal watch settings cannot keep a dead subscription registered as active.
1015
1034
 
1016
1035
  Overflow recovery keeps one timer per relay with exponential 1–30 second
1017
- backoff and 20% jitter, reset after a stable minute. Other relays remain live.
1018
- Removal, pause and close cancel pending work. Fragment storage stays scoped to
1019
- the same session across replacement subscriptions. Permanent refusals remain
1020
- 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.
1021
1042
 
1022
1043
  PrivateMessenger persists each interrupted interval before reopening, then
1023
1044
  uses paged `fetchHistory` to repair the gap while listening for new messages.
package/helpers/ranges.js CHANGED
@@ -29,3 +29,19 @@ export function subtractRanges (range, covered = []) {
29
29
  if (cursor <= range.end) out.push({ start: cursor, end: range.end })
30
30
  return out
31
31
  }
32
+
33
+ export function intersectRanges (left = [], right = []) {
34
+ const a = mergeRanges(left)
35
+ const b = mergeRanges(right)
36
+ const out = []
37
+ let i = 0
38
+ let j = 0
39
+ while (i < a.length && j < b.length) {
40
+ const start = Math.max(a[i].start, b[j].start)
41
+ const end = Math.min(a[i].end, b[j].end)
42
+ if (start <= end) out.push({ start, end })
43
+ if (a[i].end < b[j].end) i++
44
+ else j++
45
+ }
46
+ return out
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.11",
3
+ "version": "0.11.13",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -46,6 +46,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
46
46
  }
47
47
  for (const relay of urls) {
48
48
  const relayStartCount = receivedEventCount
49
+ let relayElapsedMs = 0
49
50
  const intervals = resumeIntervalsFor({ resume, relay, since, until })
50
51
  .map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
51
52
  const covered = []
@@ -83,7 +84,11 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
83
84
  outcome = { relay, status: 'error', error }
84
85
  intervals.push(interval)
85
86
  break
86
- } finally { elapsedMs += performance.now() - started }
87
+ } finally {
88
+ const took = performance.now() - started
89
+ elapsedMs += took
90
+ relayElapsedMs += took
91
+ }
87
92
  signal?.throwIfAborted()
88
93
  const status = page.relays?.find(item => item.relay === relay)
89
94
  if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
@@ -123,7 +128,8 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
123
128
  ? {
124
129
  covered: mergeRanges(covered),
125
130
  pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
126
- events: receivedEventCount - relayStartCount
131
+ events: receivedEventCount - relayStartCount,
132
+ elapsedMs: Math.round(relayElapsedMs)
127
133
  }
128
134
  : {})
129
135
  })
@@ -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 {
@@ -43,7 +43,7 @@
43
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
44
44
 
45
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
- import { mergeRanges, subtractRanges } from '../helpers/ranges.js'
46
+ import { mergeRanges, subtractRanges, intersectRanges } from '../helpers/ranges.js'
47
47
  import * as privateMessage from '../private-message/index.js'
48
48
  import { isOnline } from '../network/index.js'
49
49
  import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
@@ -130,6 +130,10 @@ export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
130
130
  allFailed: 10
131
131
  })
132
132
  const RECOVERY_ONLINE_CACHE_MS = 5000
133
+ const DEFAULT_PRIORITY_TAIL_SECONDS = 6 * 3600
134
+ const DEFAULT_PRIORITY_TTL_MS = 10 * 60 * 1000
135
+ const DEFAULT_PRIORITY_HEDGE_DELAY_MS = 500
136
+ const DEFAULT_PRIORITY_HEDGE_RELAYS = 2
133
137
 
134
138
  function textToBase64 (text) {
135
139
  return bytesToBase64(encoder.encode(text))
@@ -213,6 +217,10 @@ export class PrivateMessenger {
213
217
  reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
214
218
  seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
215
219
  seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
220
+ priorityTailSeconds = DEFAULT_PRIORITY_TAIL_SECONDS,
221
+ priorityTtlMs = DEFAULT_PRIORITY_TTL_MS,
222
+ priorityHedgeDelayMs = DEFAULT_PRIORITY_HEDGE_DELAY_MS,
223
+ priorityHedgeRelays = DEFAULT_PRIORITY_HEDGE_RELAYS,
216
224
  maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
217
225
  messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
218
226
  seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
@@ -247,6 +255,19 @@ export class PrivateMessenger {
247
255
  this.reloadGapDelayMs = reloadGapDelayMs
248
256
  this.seederPresenceIntervalMs = seederPresenceIntervalMs
249
257
  this.seederOnlineSeconds = seederOnlineSeconds
258
+ this.priorityTailSeconds = Number.isFinite(priorityTailSeconds) && priorityTailSeconds >= 0
259
+ ? Math.floor(priorityTailSeconds)
260
+ : DEFAULT_PRIORITY_TAIL_SECONDS
261
+ this.priorityTtlMs = Number.isFinite(priorityTtlMs) && priorityTtlMs > 0
262
+ ? Math.floor(priorityTtlMs)
263
+ : DEFAULT_PRIORITY_TTL_MS
264
+ this.priorityHedgeDelayMs = Number.isFinite(priorityHedgeDelayMs) && priorityHedgeDelayMs >= 0
265
+ ? Math.floor(priorityHedgeDelayMs)
266
+ : DEFAULT_PRIORITY_HEDGE_DELAY_MS
267
+ const hedgeRelays = Math.floor(Number(priorityHedgeRelays))
268
+ this.priorityHedgeRelays = Number.isSafeInteger(hedgeRelays) && hedgeRelays >= 1
269
+ ? Math.min(hedgeRelays, 3)
270
+ : DEFAULT_PRIORITY_HEDGE_RELAYS
250
271
  this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
251
272
  this.messageQueueMaxBytes = messageQueueMaxBytes
252
273
  this.seedQueueMaxBytes = seedQueueMaxBytes
@@ -299,6 +320,7 @@ export class PrivateMessenger {
299
320
  this.recoveryAdmission = createAbortableSemaphore(2)
300
321
  this.recoveryControllers = new Set()
301
322
  this.recoveryRanges = new Map()
323
+ this.recoveryPriority = new Map()
302
324
  this.recoveryOnline = { at: 0, value: true }
303
325
  this.resumeWork = null
304
326
  this.capacityTimer = null
@@ -675,7 +697,7 @@ export class PrivateMessenger {
675
697
 
676
698
  if (removedPubkeys.length) {
677
699
  await this.stampChannelActivity(removedPubkeys)
678
- for (const pubkey of removedPubkeys) this.dropRecoveryRanges(pubkey)
700
+ for (const pubkey of removedPubkeys) this.dropRecoveryState(pubkey)
679
701
  }
680
702
 
681
703
  const storageSnapshot = await activatePrivateMessengerStorage({
@@ -1214,6 +1236,7 @@ export class PrivateMessenger {
1214
1236
  start: range.start,
1215
1237
  end: range.end,
1216
1238
  covered: [],
1239
+ claimed: [],
1217
1240
  oldestCreatedAt: null,
1218
1241
  relays: new Map(),
1219
1242
  tier: '',
@@ -1241,20 +1264,34 @@ export class PrivateMessenger {
1241
1264
  dead: false,
1242
1265
  eose: false,
1243
1266
  events: 0,
1244
- error: null
1267
+ error: null,
1268
+ consecutiveFailures: 0,
1269
+ latencyMs: null,
1270
+ lastEoseAt: 0,
1271
+ lastErrorAt: 0
1245
1272
  })
1246
1273
  }
1247
1274
  }
1248
1275
 
1276
+ orderedRecoveryRelays (record) {
1277
+ return [...record.relays.entries()]
1278
+ .filter(([, state]) => !state.dead && !state.eose && state.pending?.length)
1279
+ .sort((a, b) => {
1280
+ const failures = (a[1].consecutiveFailures || 0) - (b[1].consecutiveFailures || 0)
1281
+ if (failures) return failures
1282
+ const latency = (a[1].latencyMs ?? Infinity) - (b[1].latencyMs ?? Infinity)
1283
+ if (latency) return latency
1284
+ return (b[1].lastEoseAt || 0) - (a[1].lastEoseAt || 0)
1285
+ })
1286
+ }
1287
+
1249
1288
  recoveryRelayPlan (record) {
1250
1289
  const plan = {}
1251
- let hasWork = false
1252
- for (const [relay, state] of record.relays) {
1253
- if (state.dead || state.eose || !state.pending?.length) continue
1290
+ const ordered = this.orderedRecoveryRelays(record)
1291
+ for (const [relay, state] of ordered) {
1254
1292
  plan[relay] = state.pending
1255
- hasWork = true
1256
1293
  }
1257
- return hasWork ? plan : null
1294
+ return ordered.length ? plan : null
1258
1295
  }
1259
1296
 
1260
1297
  hasActiveRecoveryRelays (channelPubkey) {
@@ -1289,7 +1326,65 @@ export class PrivateMessenger {
1289
1326
  }
1290
1327
  }
1291
1328
 
1292
- updateRecoveryRecord (record, history) {
1329
+ dropRecoveryState (channelPubkey) {
1330
+ this.dropRecoveryRanges(channelPubkey)
1331
+ this.recoveryPriority.delete(channelPubkey)
1332
+ }
1333
+
1334
+ recoveryPriorities (channelPubkey) {
1335
+ const entries = this.recoveryPriority.get(channelPubkey) || []
1336
+ if (!entries.length) return []
1337
+ const now = Date.now()
1338
+ const active = entries.filter(entry => entry.expiresAt > now)
1339
+ if (active.length !== entries.length) {
1340
+ if (active.length) this.recoveryPriority.set(channelPubkey, active)
1341
+ else this.recoveryPriority.delete(channelPubkey)
1342
+ }
1343
+ return active
1344
+ }
1345
+
1346
+ async prioritizeRange (channelPubkey, { since, until, type = 'unread-page', ttlMs } = {}) {
1347
+ if (typeof channelPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(channelPubkey)) throw new ValidationError('INVALID_CHANNEL_PUBKEY')
1348
+ if (type !== 'unread-page' && type !== 'tail') throw new ValidationError('INVALID_PRIORITY_TYPE')
1349
+ if (since !== undefined && !Number.isSafeInteger(since)) throw new ValidationError('INVALID_PRIORITY_RANGE')
1350
+ if (until !== undefined && !Number.isSafeInteger(until)) throw new ValidationError('INVALID_PRIORITY_RANGE')
1351
+ if (type === 'unread-page' && since === undefined) throw new ValidationError('PRIORITY_SINCE_REQUIRED')
1352
+
1353
+ const now = nowSeconds()
1354
+ const ranges = (this.readState().channels[channelPubkey]?.offlineRanges || []).slice()
1355
+ if (!ranges.length) return false
1356
+
1357
+ let start = since
1358
+ let end = until
1359
+ if (type === 'unread-page') {
1360
+ const range = ranges.filter(candidate => candidate.end >= start).sort((a, b) => a.start - b.start)[0]
1361
+ if (!range) return false
1362
+ start = Math.max(start, range.start)
1363
+ if (end === undefined) end = Math.min(range.end, now)
1364
+ } else {
1365
+ const range = ranges.sort((a, b) => b.end - a.end)[0]
1366
+ if (end === undefined) end = Math.min(range.end, now)
1367
+ if (start === undefined) start = Math.max(range.start, end - this.priorityTailSeconds)
1368
+ }
1369
+
1370
+ const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
1371
+ if (recoverySeconds) {
1372
+ start = Math.max(start, now - recoverySeconds)
1373
+ end = Math.min(end, now)
1374
+ }
1375
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return false
1376
+
1377
+ const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : this.priorityTtlMs
1378
+ const entries = this.recoveryPriority.get(channelPubkey) || []
1379
+ const createdAt = Date.now()
1380
+ if (!entries.some(entry => entry.start === start && entry.end === end && entry.type === type)) {
1381
+ entries.push({ start, end, type, createdAt, expiresAt: createdAt + ttl })
1382
+ this.recoveryPriority.set(channelPubkey, entries)
1383
+ }
1384
+ return true
1385
+ }
1386
+
1387
+ updateRecoveryRecord (record, history, { completeRelays = new Set((history.relays || []).map(entry => entry.relay)) } = {}) {
1293
1388
  record.covered = mergeRanges([
1294
1389
  ...record.covered,
1295
1390
  ...(history.relays || []).flatMap(entry => entry.covered || [])
@@ -1306,13 +1401,30 @@ export class PrivateMessenger {
1306
1401
  const relayErrors = [...(entry.errors || [])]
1307
1402
  if (entry.error) relayErrors.push(entry.error)
1308
1403
  state.error = relayErrors[0] || null
1309
- const done = entry.status === 'eose' && !(entry.pending?.length)
1404
+ const done = entry.status === 'eose' && !(entry.pending?.length) && completeRelays.has(relay)
1310
1405
  state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
1311
1406
  state.eose = done
1312
- state.pending = done ? [] : subtractRanges({ start: record.start, end: record.end }, state.covered)
1407
+ state.pending = done
1408
+ ? []
1409
+ : subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
1313
1410
  state.events += entry.events || 0
1411
+ if (Number.isFinite(entry.elapsedMs)) {
1412
+ state.latencyMs = state.latencyMs == null
1413
+ ? entry.elapsedMs
1414
+ : Math.round(state.latencyMs * 0.7 + entry.elapsedMs * 0.3)
1415
+ }
1314
1416
  if (done) {
1315
1417
  state.attempts = 0
1418
+ state.consecutiveFailures = 0
1419
+ state.lastEoseAt = nowSeconds()
1420
+ } else if (entry.status === 'eose') {
1421
+ state.consecutiveFailures = 0
1422
+ state.lastEoseAt = nowSeconds()
1423
+ } else {
1424
+ state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
1425
+ state.lastErrorAt = nowSeconds()
1426
+ }
1427
+ if (entry.status === 'eose') {
1316
1428
  record.everEose = true
1317
1429
  if (state.events > 0) record.everEoseWithEvents = true
1318
1430
  }
@@ -1321,6 +1433,52 @@ export class PrivateMessenger {
1321
1433
  record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
1322
1434
  }
1323
1435
 
1436
+ applyRecoveryCoverage (record, ranges) {
1437
+ if (!ranges?.length) return
1438
+ record.claimed = mergeRanges([...record.claimed, ...ranges])
1439
+ record.covered = mergeRanges([...record.covered, ...ranges])
1440
+ for (const state of record.relays.values()) {
1441
+ if (state.eose) continue
1442
+ state.pending = subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
1443
+ }
1444
+ }
1445
+
1446
+ activeRecoverySeeders (channelPubkey) {
1447
+ if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
1448
+ const channel = this.channels.get(channelPubkey)
1449
+ if (!channel?.signer) return []
1450
+ const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
1451
+ const cutoff = nowSeconds() - this.seederOnlineSeconds
1452
+ const candidates = new Set([...(channel.seeders || []), ...Object.keys(activity)])
1453
+ return [...candidates].filter(seeder => {
1454
+ if (!seeder || seeder === this.userPubkey) return false
1455
+ const entry = activity[seeder]
1456
+ const recent = Math.max(entry?.announcedAt || 0, entry?.lastActiveAt || 0)
1457
+ return recent >= cutoff
1458
+ })
1459
+ }
1460
+
1461
+ prunePriorityEntries (channelPubkey) {
1462
+ const entries = this.recoveryPriorities(channelPubkey)
1463
+ if (!entries.length) return
1464
+ const records = [...this.recoveryRanges.values()].filter(record => record.channelPubkey === channelPubkey)
1465
+ const next = entries.filter(entry => {
1466
+ let intersects = false
1467
+ for (const record of records) {
1468
+ const parts = intersectRanges(
1469
+ [{ start: entry.start, end: entry.end }],
1470
+ [{ start: record.start, end: record.end }]
1471
+ )
1472
+ if (!parts.length) continue
1473
+ intersects = true
1474
+ if (subtractRanges(parts[0], record.covered).length) return true
1475
+ }
1476
+ return !intersects
1477
+ })
1478
+ if (next.length) this.recoveryPriority.set(channelPubkey, next)
1479
+ else this.recoveryPriority.delete(channelPubkey)
1480
+ }
1481
+
1324
1482
  applyRecoveryRetryBudget (record) {
1325
1483
  const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
1326
1484
  for (const state of record.relays.values()) {
@@ -1462,7 +1620,7 @@ export class PrivateMessenger {
1462
1620
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1463
1621
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1464
1622
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1465
- for (const pubkey of pubkeys) this.dropRecoveryRanges(pubkey)
1623
+ for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
1466
1624
  this.recordInterruption(pubkeys)
1467
1625
  for (const controller of this.recoveryControllers) {
1468
1626
  if (pubkeys.includes(controller.channelPubkey)) controller.abort()
@@ -2156,7 +2314,7 @@ export class PrivateMessenger {
2156
2314
  this.writeState(state)
2157
2315
  await this.flushStateWrites()
2158
2316
  for (const channel of channels) {
2159
- if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryRanges(channel.pubkey)
2317
+ if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2160
2318
  }
2161
2319
  for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2162
2320
  })
@@ -2231,15 +2389,15 @@ export class PrivateMessenger {
2231
2389
  return asks
2232
2390
  }
2233
2391
 
2234
- async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
2392
+ async #askSeedersForMissingRangeAttempt (channelPubkey, since, until, { seeders } = {}) {
2235
2393
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
2236
2394
  if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
2237
- const seeders = this.recoverySeeders(channelPubkey)
2238
- if (!seeders.length || until < since) return { asks: [], failures: [] }
2395
+ const candidates = seeders ?? this.recoverySeeders(channelPubkey)
2396
+ if (!candidates.length || until < since) return { asks: [], failures: [] }
2239
2397
 
2240
2398
  const asks = []
2241
2399
  const failures = []
2242
- for (const seeder of seeders) {
2400
+ for (const seeder of candidates) {
2243
2401
  try {
2244
2402
  const ask = await this.ask({
2245
2403
  channelPubkey,
@@ -2385,6 +2543,146 @@ export class PrivateMessenger {
2385
2543
  return failures
2386
2544
  }
2387
2545
 
2546
+ recoveryFetchOptions ({ channelPubkey, channel, relays, since, until, signal, partial = false, resume = null }) {
2547
+ return {
2548
+ _acquirePage: pageSignal => this.recoveryAdmission.acquire(pageSignal),
2549
+ signal,
2550
+ receivedChunkScope: this.storageLeaseId,
2551
+ receiverSigner: this.userSigner,
2552
+ iykcSigner: this.contentKeySigner,
2553
+ privateChannelSigner: channel.signer,
2554
+ privateChannelReaderSigner: channel.readerSigner,
2555
+ privateChannelReaderPubkey: channel.readerPubkey,
2556
+ privateChannelPubkeys: [channelPubkey],
2557
+ receiverPubkey: this.userPubkey,
2558
+ relays,
2559
+ since: Math.max(0, since),
2560
+ until,
2561
+ partial,
2562
+ resume,
2563
+ mode: channel.mode,
2564
+ modeByPubkey: { [channelPubkey]: channel.mode },
2565
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2566
+ receivedChunkIndexedDB: this._indexedDB,
2567
+ onEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2568
+ onNymEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2569
+ onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
2570
+ onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
2571
+ onError: err => { throw err }
2572
+ }
2573
+ }
2574
+
2575
+ async hedgedPriorityFetch ({ channelPubkey, channel, candidates, since, until }) {
2576
+ const launched = []
2577
+ let hedgeTimer = null
2578
+ let secondStarted = false
2579
+ const launch = candidate => {
2580
+ const controller = new AbortController()
2581
+ const promise = this._privateChannel
2582
+ .fetchHistory(this.recoveryFetchOptions({
2583
+ channelPubkey,
2584
+ channel,
2585
+ relays: [candidate.relay],
2586
+ since,
2587
+ until,
2588
+ signal: controller.signal,
2589
+ partial: true,
2590
+ resume: { [candidate.relay]: candidate.pending }
2591
+ }))
2592
+ .then(
2593
+ history => ({ ok: true, relay: candidate.relay, controller, history }),
2594
+ error => ({ ok: false, relay: candidate.relay, controller, error })
2595
+ )
2596
+ promise.catch(() => {})
2597
+ launched.push({ promise, controller })
2598
+ return promise
2599
+ }
2600
+ try {
2601
+ const first = launch(candidates[0])
2602
+ const hedge = candidates.length > 1
2603
+ ? new Promise(resolve => {
2604
+ hedgeTimer = this._setTimeout(() => {
2605
+ secondStarted = true
2606
+ resolve(launch(candidates[1]))
2607
+ }, this.priorityHedgeDelayMs)
2608
+ })
2609
+ : null
2610
+ let settled = hedge ? await Promise.race([first, hedge]) : await first
2611
+ if (!settled.ok) {
2612
+ if (secondStarted && launched.length > 1) settled = await launched[launched.length - 1].promise
2613
+ else if (candidates.length > 1) settled = await launch(candidates[1])
2614
+ }
2615
+ if (!settled.ok) return null
2616
+ for (const entry of launched) {
2617
+ if (entry.controller === settled.controller) continue
2618
+ try { entry.controller.abort() } catch { /* aborted */ }
2619
+ }
2620
+ return settled
2621
+ } finally {
2622
+ if (hedgeTimer) this._clearTimeout(hedgeTimer)
2623
+ }
2624
+ }
2625
+
2626
+ async askPrioritySeeders (channelPubkey, intervals) {
2627
+ if (!intervals.length) return []
2628
+ if (!await this.checkRecoveryOnline()) return []
2629
+ const seeders = this.activeRecoverySeeders(channelPubkey)
2630
+ if (!seeders.length) return []
2631
+ const failures = []
2632
+ const claimed = []
2633
+ for (const interval of mergeRanges(intervals)) {
2634
+ const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, interval.start, interval.end, { seeders })
2635
+ failures.push(...attempt.failures.map(failure => failure.error))
2636
+ if (!attempt.failures.length) claimed.push(interval)
2637
+ }
2638
+ if (claimed.length) {
2639
+ for (const record of this.recoveryRanges.values()) {
2640
+ if (record.channelPubkey !== channelPubkey) continue
2641
+ const applied = intersectRanges(claimed, [{ start: record.start, end: record.end }])
2642
+ if (applied.length) this.applyRecoveryCoverage(record, applied)
2643
+ }
2644
+ }
2645
+ return failures
2646
+ }
2647
+
2648
+ async runPriorityLane (channelPubkey, channel, ranges) {
2649
+ const priorities = this.recoveryPriorities(channelPubkey)
2650
+ if (!priorities.length) return
2651
+ const fetchRelays = await this.resolveWatchRelays(channel)
2652
+ for (const range of ranges) {
2653
+ const record = this.recoveryRangeRecord(channelPubkey, range)
2654
+ this.reconcileRecoveryRelays(record, fetchRelays)
2655
+ const priorityIntervals = intersectRanges(
2656
+ priorities.map(entry => ({ start: entry.start, end: entry.end })),
2657
+ [{ start: range.start, end: range.end }]
2658
+ )
2659
+ if (!priorityIntervals.length) continue
2660
+ const pending = subtractRanges({ start: range.start, end: range.end }, record.covered)
2661
+ const wanted = intersectRanges(priorityIntervals, pending)
2662
+ if (!wanted.length) continue
2663
+ const candidates = this.orderedRecoveryRelays(record)
2664
+ .map(([relay, state]) => ({ relay, pending: intersectRanges(state.pending, wanted) }))
2665
+ .filter(candidate => candidate.pending.length)
2666
+ .slice(0, this.priorityHedgeRelays)
2667
+ if (candidates.length) {
2668
+ const winner = await this.hedgedPriorityFetch({
2669
+ channelPubkey,
2670
+ channel,
2671
+ candidates,
2672
+ since: wanted[0].start,
2673
+ until: wanted[wanted.length - 1].end
2674
+ })
2675
+ if (winner?.history) {
2676
+ if (this.isPartialRecoveryHistory(winner.history)) this.updateRecoveryRecord(record, winner.history, { completeRelays: new Set() })
2677
+ else this.applyRecoveryCoverage(record, wanted)
2678
+ }
2679
+ }
2680
+ const uncovered = intersectRanges(wanted, subtractRanges({ start: range.start, end: range.end }, record.covered))
2681
+ if (uncovered.length) await this.askPrioritySeeders(channelPubkey, uncovered)
2682
+ }
2683
+ this.prunePriorityEntries(channelPubkey)
2684
+ }
2685
+
2388
2686
  async recoverChannelRanges (channels) {
2389
2687
  const failures = []
2390
2688
  const state = this.readState()
@@ -2395,17 +2693,21 @@ export class PrivateMessenger {
2395
2693
  const channel = this.channels.get(pubkey)
2396
2694
  const current = state.channels[pubkey]
2397
2695
  if (!channel || !current?.offlineRanges?.length) {
2398
- this.dropRecoveryRanges(pubkey)
2696
+ this.dropRecoveryState(pubkey)
2399
2697
  continue
2400
2698
  }
2401
2699
  const recoverySeconds = this.offlineRecoverySecondsFor(channel)
2402
2700
  if (!recoverySeconds) {
2403
- this.dropRecoveryRanges(pubkey)
2701
+ this.dropRecoveryState(pubkey)
2404
2702
  continue
2405
2703
  }
2406
2704
  const minStart = now - recoverySeconds
2407
2705
  const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
2408
2706
 
2707
+ if (online) {
2708
+ try { await this.runPriorityLane(pubkey, channel, current.offlineRanges) } catch (error) { this.onError?.(error) }
2709
+ }
2710
+
2409
2711
  const remaining = []
2410
2712
  let recoveredThrough = current.recoveredThrough || 0
2411
2713
  for (const range of current.offlineRanges) {
@@ -2423,32 +2725,16 @@ export class PrivateMessenger {
2423
2725
  this.reconcileRecoveryRelays(record, fetchRelays)
2424
2726
  const plan = this.recoveryRelayPlan(record)
2425
2727
  if (plan) {
2426
- const history = await this._privateChannel.fetchHistory({
2427
- _acquirePage: signal => this.recoveryAdmission.acquire(signal),
2428
- signal: controller.signal,
2429
- receivedChunkScope: this.storageLeaseId,
2430
- receiverSigner: this.userSigner,
2431
- iykcSigner: this.contentKeySigner,
2432
- privateChannelSigner: channel.signer,
2433
- privateChannelReaderSigner: channel.readerSigner,
2434
- privateChannelReaderPubkey: channel.readerPubkey,
2435
- privateChannelPubkeys: [pubkey],
2436
- receiverPubkey: this.userPubkey,
2728
+ const history = await this._privateChannel.fetchHistory(this.recoveryFetchOptions({
2729
+ channelPubkey: pubkey,
2730
+ channel,
2437
2731
  relays: Object.keys(plan),
2438
- since: Math.max(0, range.start),
2732
+ since: range.start,
2439
2733
  until: range.end,
2734
+ signal: controller.signal,
2440
2735
  partial: true,
2441
- resume: plan,
2442
- mode: channel.mode,
2443
- modeByPubkey: { [pubkey]: channel.mode },
2444
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2445
- receivedChunkIndexedDB: this._indexedDB,
2446
- onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2447
- onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2448
- onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
2449
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
2450
- onError: err => { throw err }
2451
- })
2736
+ resume: plan
2737
+ }))
2452
2738
  controller.signal.throwIfAborted()
2453
2739
  if (this.isPartialRecoveryHistory(history)) {
2454
2740
  this.updateRecoveryRecord(record, history)
@@ -2517,7 +2803,7 @@ export class PrivateMessenger {
2517
2803
  async clearChannel (pubkey) {
2518
2804
  await this.unwatch(pubkey)
2519
2805
  await this._privateMessage.clearChannelState?.(pubkey)
2520
- this.dropRecoveryRanges(pubkey)
2806
+ this.dropRecoveryState(pubkey)
2521
2807
  return this.runQueueOperation(async () => {
2522
2808
  this.channels.delete(pubkey)
2523
2809
  this.removeChannelState(pubkey)
@@ -2551,7 +2837,7 @@ export class PrivateMessenger {
2551
2837
  await this.queue?.removeBy('byChannel', pubkey)
2552
2838
  await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2553
2839
  }
2554
- for (const pubkey of stalePubkeys) this.dropRecoveryRanges(pubkey)
2840
+ for (const pubkey of stalePubkeys) this.dropRecoveryState(pubkey)
2555
2841
  this.state = state
2556
2842
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
2557
2843
  })
@@ -2643,6 +2929,7 @@ export class PrivateMessenger {
2643
2929
  })
2644
2930
  }
2645
2931
  this.recoveryRanges.clear()
2932
+ this.recoveryPriority.clear()
2646
2933
  if (unwatchError) throw unwatchError
2647
2934
  })()
2648
2935
  return this.closePromise
@@ -17,9 +17,17 @@ key published after an earlier miss. Set it to `false` when the signer owns that
17
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
18
18
 
19
19
  Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
20
- `enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
20
+ `prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
21
+ `cancelDownload`, and `close`.
21
22
  `prefetchContentKeys` warms the content-key lookup cache for the owner and the
22
23
  given peers (default: current peers); it is best-effort and never rejects.
24
+ `prioritizeRange` registers an in-memory priority window for a conversation:
25
+ `type: 'unread-page'` requires `since` (the read anchor), while `type: 'tail'`
26
+ defaults to the newest persisted range minus `priorityTailSeconds` (6 hours).
27
+ The coordinator resolves the peer's channel and queues the request until the
28
+ channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
29
+ covered, and are dropped with recovery state. They are process-local and reset
30
+ on reload.
23
31
  Callbacks are `onOutbox`, `onMedia`,
24
32
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
25
33
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
@@ -32,6 +40,14 @@ or cancelled/closed sends. Apps own route visibility and user-intent filtering;
32
40
  automatic retries may also fail. Callbacks must not throw. Errors are not copied
33
41
  into persisted outbox records.
34
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
+
35
51
  `enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
36
52
  identities. Local message commits and remote stages have independent checkpoints.
37
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,12 +52,15 @@ 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
46
60
  const downloadIntents = new Map()
47
61
  const downloadEpochs = new Map()
48
62
  const activeDownloads = new Map()
63
+ const pendingPriorityRanges = new Map()
49
64
  let downloadWriteTail = Promise.resolve()
50
65
  const writeDownloadIntent = operation => {
51
66
  const work = downloadWriteTail.catch(() => {}).then(operation)
@@ -59,6 +74,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
59
74
  let lifecycle = 0
60
75
  let configuring = Promise.resolve()
61
76
  let sending
77
+ let repump
62
78
  let draining
63
79
  const idleWaiters = new Set()
64
80
  const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
@@ -89,6 +105,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
89
105
  if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
90
106
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
91
107
  await messenger.update({ channels: values })
108
+ if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
109
+ for (const [peer, list] of [...pendingPriorityRanges]) {
110
+ const channel = channels.get(peer)
111
+ if (!channel) continue
112
+ for (const request of list) {
113
+ try { await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error) }
114
+ }
115
+ pendingPriorityRanges.delete(peer)
116
+ }
117
+ }
92
118
  await messenger.resume('signer')
93
119
  if (!fileTransfers) {
94
120
  fileTransfers = FileTransfer({
@@ -168,9 +194,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
168
194
  }
169
195
  async function publish (peer, event, signal) {
170
196
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
171
- 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)
172
198
  const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
173
- 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
+ }
174
206
  // Published is relay acceptance, never a peer receipt.
175
207
  await assertMessagePublished(report, event)
176
208
  }
@@ -234,7 +266,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
234
266
  await storage.remove(entry.id)
235
267
  entries.delete(entry.id)
236
268
  emit()
237
- } 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)
238
273
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
239
274
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
240
275
  await storage.put(entry, { existing: true }).catch(reportError)
@@ -242,12 +277,42 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
242
277
  // The failed wire event may be a quote or file chunk. Report the
243
278
  // owning outbox item separately, without mutating the native error.
244
279
  if (error.retryWhenOnline) offlineSends.add(entry.id)
280
+ if (error.retryWhenAvailable) scheduleSendRetry()
245
281
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
246
282
  }
247
283
  emit()
248
284
  } finally { sendControllers.delete(entry.id) }
249
285
  }
250
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
+ }
251
316
  function watchOfflineSends () {
252
317
  if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
253
318
  if (stopOnline) return
@@ -265,15 +330,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
265
330
  })
266
331
  }
267
332
  async function pump () {
268
- 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 }
269
337
  sending = true
270
338
  try {
271
- for (const entry of entries.values()) {
272
- if (closed || !available) break
273
- if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
274
- await sendEntry(entry)
275
- }
276
- } 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
+ }
277
353
  }
278
354
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
279
355
  async function download (file, options = {}) {
@@ -322,6 +398,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
322
398
  return {}
323
399
  }
324
400
  },
401
+ async prioritizeRange (peer, options = {}) {
402
+ if (!/^[0-9a-f]{64}$/.test(peer || '')) throw new TypeError('INVALID_PRIORITY_PEER')
403
+ const { since, until, type = 'unread-page' } = options || {}
404
+ if (type !== 'unread-page' && type !== 'tail') throw new TypeError('INVALID_PRIORITY_TYPE')
405
+ if (since !== undefined && !Number.isSafeInteger(since)) throw new TypeError('INVALID_PRIORITY_RANGE')
406
+ if (until !== undefined && !Number.isSafeInteger(until)) throw new TypeError('INVALID_PRIORITY_RANGE')
407
+ if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
408
+ const request = { since, until, type }
409
+ const channel = channels.get(peer)
410
+ if (channel && typeof messenger?.prioritizeRange === 'function') {
411
+ try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
412
+ }
413
+ const list = pendingPriorityRanges.get(peer) || []
414
+ list.push(request)
415
+ pendingPriorityRanges.set(peer, list)
416
+ return true
417
+ },
325
418
  async setPeers (values) {
326
419
  peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
327
420
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
@@ -331,10 +424,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
331
424
  async setAvailable (value) {
332
425
  available = value === true
333
426
  stopOnlineRetry(); offlineSends.clear()
427
+ if (available) resetSendRetry()
428
+ else clearSendRetry()
334
429
  lifecycle++
335
430
  if (!available) { await messenger?.pause('signer'); return }
336
431
  await ready()
337
- 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
338
433
  await messenger?.resume('storage')
339
434
  return schedule()
340
435
  },
@@ -354,7 +449,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
354
449
  offlineSends.delete(id); watchOfflineSends()
355
450
  await ready()
356
451
  const entry = entries.get(id)
357
- 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() }
358
453
  },
359
454
  async cancel (id) {
360
455
  cancelled.add(id)
@@ -367,7 +462,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
367
462
  },
368
463
  async close () {
369
464
  closed = true; available = false; lifecycle++
370
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
465
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
466
+ clearSendRetry()
371
467
  for (const controller of sendControllers.values()) controller.abort()
372
468
  await Promise.allSettled([configuring, initialized])
373
469
  await messenger?.pause('closed')