libp2r2p 0.11.10 → 0.11.11

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
@@ -532,15 +532,26 @@ are attached. These diagnostics reach the messenger's `onError` unchanged.
532
532
  A single incomplete relay keeps the interval pending even if another relay
533
533
  returned events; received messages may already have been queued. This is a
534
534
  history completeness failure, not evidence that an outgoing message failed.
535
- Existing rewatch/resume and relay-list refresh paths retry pending intervals;
536
- there is no dedicated periodic retry timer for a failed history fetch.
535
+ Rewatch/resume, relay-list refresh and reload-gap paths schedule bounded retries
536
+ for pending intervals (1s doubling to 30s). Recovery tracks per-relay coverage in
537
+ memory: a tier where some relay returned events and EOSE retries the remaining
538
+ relays a few times (3), an empty-EOSE tier gets more attempts (6), and an
539
+ all-failed tier gets the most (10) before coverage from EOSE relays is accepted
540
+ and the uncovered subranges plus the pre-oldest left edge are requested from
541
+ seeders. Offline attempts never consume the budget, mark coverage or contact
542
+ seeders; they wait for connectivity. The per-relay tracker is process-local, so a
543
+ restart falls back to the persisted whole interval.
537
544
  `private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
538
545
  used by messenger recovery. It accepts the signers, processor/fragment options,
539
546
  callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
540
547
  (defaults: 0 and the start time of the call). It returns only
541
548
  `{ oldestCreatedAt, receivedEventCount, relays }`; an empty scan has a null oldest
542
- time. No complete-history event array is retained. `fetch` keeps its existing
543
- single-query array contract and optional limit.
549
+ time. Passing `partial: true` (used by messenger recovery) returns per-relay
550
+ `covered`/`pending`/`events` plus `pendingByRelay`, `anyEose`, `anyEoseWithEvents`
551
+ and `allFailed` instead of throwing on partial failure, and `resume` restricts
552
+ each relay to the given pending subranges. No complete-history event array is
553
+ retained. `fetch` keeps its existing single-query array contract and optional
554
+ limit.
544
555
 
545
556
  History queries each relay independently with limit 16. A response with 16 events
546
557
  or status `satisfied` is saturated, even if it already reports EOSE or contains
@@ -0,0 +1,31 @@
1
+ // Inclusive integer ranges [start, end]. Merge treats adjacent ranges as one.
2
+ export function mergeRanges (ranges = []) {
3
+ const sorted = (ranges || [])
4
+ .filter(range => range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start)
5
+ .map(range => ({ start: range.start, end: range.end }))
6
+ .sort((a, b) => a.start - b.start || a.end - b.end)
7
+ const out = []
8
+ for (const range of sorted) {
9
+ const last = out[out.length - 1]
10
+ if (!last || range.start > last.end + 1) out.push(range)
11
+ else last.end = Math.max(last.end, range.end)
12
+ }
13
+ return out
14
+ }
15
+
16
+ // Parts of `range` not covered by `covered`.
17
+ export function subtractRanges (range, covered = []) {
18
+ if (!range || range.end < range.start) return []
19
+ const merged = mergeRanges(covered)
20
+ const out = []
21
+ let cursor = range.start
22
+ for (const entry of merged) {
23
+ if (entry.end < cursor) continue
24
+ if (entry.start > range.end) break
25
+ if (entry.start > cursor) out.push({ start: cursor, end: Math.min(entry.start - 1, range.end) })
26
+ cursor = Math.max(cursor, entry.end + 1)
27
+ if (cursor > range.end) break
28
+ }
29
+ if (cursor <= range.end) out.push({ start: cursor, end: range.end })
30
+ return out
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.10",
3
+ "version": "0.11.11",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -1,6 +1,7 @@
1
1
  import { ValidationError } from '../../error/index.js'
2
2
  import { normalizeRelayUrl } from '../../url/index.js'
3
3
  import { createAbortableSemaphore } from '../../helpers/abortable-semaphore.js'
4
+ import { mergeRanges } from '../../helpers/ranges.js'
4
5
  import { incompleteFetchError } from './fetch-error.js'
5
6
 
6
7
  const PAGE_SIZE = 16
@@ -23,7 +24,7 @@ function acquireRelay (relay, signal) {
23
24
 
24
25
  // Queries bounded temporal leaves, oldest first. A saturated one-second leaf
25
26
  // cannot be split using standard NIP-01; expand it only up to the safe ceiling.
26
- export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage }) {
27
+ export async function readHistory ({ filter, relays, receiverPubkey, signal, getEvents, processEvent, acquirePage, partial = false, resume = null }) {
27
28
  const since = filter.since ?? 0
28
29
  const until = filter.until ?? Math.floor(Date.now() / 1000)
29
30
  if (![since, until].every(value => Number.isSafeInteger(value) && value >= 0) || since > until) throw new ValidationError('INVALID_PRIVATE_CHANNEL_HISTORY_RANGE')
@@ -44,7 +45,10 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
44
45
  }
45
46
  }
46
47
  for (const relay of urls) {
47
- const intervals = [{ since, until, limit: PAGE_SIZE }]
48
+ const relayStartCount = receivedEventCount
49
+ const intervals = resumeIntervalsFor({ resume, relay, since, until })
50
+ .map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
51
+ const covered = []
48
52
  let outcome = { relay, status: 'eose' }
49
53
  while (intervals.length) {
50
54
  signal?.throwIfAborted()
@@ -77,6 +81,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
77
81
  signal?.throwIfAborted()
78
82
  errors.push({ relay, reason: error })
79
83
  outcome = { relay, status: 'error', error }
84
+ intervals.push(interval)
80
85
  break
81
86
  } finally { elapsedMs += performance.now() - started }
82
87
  signal?.throwIfAborted()
@@ -85,6 +90,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
85
90
  await process(page.result)
86
91
  errors.push(...(page.errors ?? []))
87
92
  outcome = status ?? { relay, status: 'error' }
93
+ intervals.push(interval)
88
94
  break
89
95
  }
90
96
  if (status.status === 'satisfied' || page.result.length >= interval.limit) {
@@ -98,10 +104,12 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
98
104
  const error = pageLimitError()
99
105
  errors.push({ relay, reason: error })
100
106
  outcome = { relay, status: 'error', error }
107
+ intervals.push(interval)
101
108
  break
102
109
  }
103
110
  } else {
104
111
  await process(page.result)
112
+ covered.push({ start: interval.since, end: interval.until })
105
113
  }
106
114
  signal?.throwIfAborted()
107
115
  } finally {
@@ -109,9 +117,34 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
109
117
  releaseRelay?.()
110
118
  }
111
119
  }
112
- report.push(outcome)
120
+ report.push({
121
+ ...outcome,
122
+ ...(partial
123
+ ? {
124
+ covered: mergeRanges(covered),
125
+ pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
126
+ events: receivedEventCount - relayStartCount
127
+ }
128
+ : {})
129
+ })
113
130
  }
114
131
  signal?.throwIfAborted()
132
+ if (partial) {
133
+ const pendingByRelay = {}
134
+ for (const entry of report) {
135
+ if (entry.pending?.length) pendingByRelay[entry.relay] = entry.pending
136
+ }
137
+ return {
138
+ oldestCreatedAt,
139
+ receivedEventCount,
140
+ elapsedMs: Math.round(elapsedMs),
141
+ relays: report,
142
+ pendingByRelay,
143
+ anyEose: report.some(entry => entry.status === 'eose'),
144
+ anyEoseWithEvents: report.some(entry => entry.status === 'eose' && (entry.events ?? 0) > 0),
145
+ allFailed: report.every(entry => entry.status !== 'eose')
146
+ }
147
+ }
115
148
  if (errors.length || report.some(item => item.status !== 'eose')) {
116
149
  const error = incompleteFetchError({ errors, report, receivedEventCount, elapsedMs: Math.round(elapsedMs), request: { relays: urls, channelPubkeys: filter.authors ?? [], receiverPubkey, since, until, limit: PAGE_SIZE, timeoutMs: TIMEOUT_MS } })
117
150
  error.operation = 'private-channel.fetchHistory'
@@ -119,3 +152,14 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
119
152
  }
120
153
  return { oldestCreatedAt, receivedEventCount, relays: report }
121
154
  }
155
+
156
+ function resumeIntervalsFor ({ resume, relay, since, until }) {
157
+ const pending = resume && Object.prototype.hasOwnProperty.call(resume, relay) ? resume[relay] : null
158
+ const intervals = Array.isArray(pending) ? pending : [{ start: since, end: until }]
159
+ return mergeRanges(intervals
160
+ .map(interval => ({
161
+ start: Math.max(since, Math.floor(Number(interval?.start))),
162
+ end: Math.min(until, Math.floor(Number(interval?.end)))
163
+ }))
164
+ .filter(interval => Number.isSafeInteger(interval.start) && Number.isSafeInteger(interval.end) && interval.end >= interval.start))
165
+ }
@@ -972,12 +972,12 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
972
972
 
973
973
  // Complete interval recovery with bounded pages and one fragment processor.
974
974
  // Existing fetch() retains its one-query/event-array contract.
975
- export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, ...options }) {
975
+ export async function fetchHistory ({ receiverSigner, privateChannelSigner = receiverSigner, _getEvents = getEvents, _acquirePage, partial = false, resume = null, ...options }) {
976
976
  const authors = privateChannelPubkeyList(options)
977
977
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
978
978
  const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
979
979
  try {
980
- return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage })
980
+ return await readHistory({ filter, relays: options.relays, receiverPubkey: options.receiverPubkey, signal: options.signal, getEvents: _getEvents, processEvent: processOuterEvent, acquirePage: _acquirePage, partial, resume })
981
981
  } finally { processOuterEvent.close() }
982
982
  }
983
983
 
@@ -24,3 +24,19 @@ export function recoveryRetryDelay (errors, delay) {
24
24
  errors.forEach(visit)
25
25
  return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
26
26
  }
27
+
28
+ // Permanent per-relay failures must stop that relay's retries instead of
29
+ // consuming the range retry budget.
30
+ export function isPermanentRecoveryError (error) {
31
+ const seen = new Set()
32
+ const visit = value => {
33
+ if (!value || seen.has(value)) return false
34
+ seen.add(value)
35
+ if (value.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || value.name === 'ValidationError' ||
36
+ /^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(value.message || '')) return true
37
+ for (const child of value.errors || []) if (visit(child)) return true
38
+ for (const entry of value.relays || []) if (visit(entry.error)) return true
39
+ return visit(value.cause)
40
+ }
41
+ return visit(error)
42
+ }
@@ -1,4 +1,3 @@
1
- import { recoveryRetryDelay } from './helpers/recovery-retry.js'
2
1
  // Expected use:
3
2
  // const messenger = await createPrivateMessenger({
4
3
  // userSigner,
@@ -44,9 +43,12 @@ import { recoveryRetryDelay } from './helpers/recovery-retry.js'
44
43
  // - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
45
44
 
46
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
+ import { mergeRanges, subtractRanges } 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'
50
+ import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
51
+ import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
50
52
  import { normalizeRelayUrl } from '../url/index.js'
51
53
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
52
54
  import { bytesToBase64 } from '../base64/index.js'
@@ -122,6 +124,13 @@ const SEED_QUEUE_INDEXES = {
122
124
  const encoder = new TextEncoder()
123
125
  const noContentKeys = async () => ({})
124
126
 
127
+ export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
128
+ partial: 3,
129
+ emptyEose: 6,
130
+ allFailed: 10
131
+ })
132
+ const RECOVERY_ONLINE_CACHE_MS = 5000
133
+
125
134
  function textToBase64 (text) {
126
135
  return bytesToBase64(encoder.encode(text))
127
136
  }
@@ -289,6 +298,8 @@ export class PrivateMessenger {
289
298
  this.recoveries = new Map()
290
299
  this.recoveryAdmission = createAbortableSemaphore(2)
291
300
  this.recoveryControllers = new Set()
301
+ this.recoveryRanges = new Map()
302
+ this.recoveryOnline = { at: 0, value: true }
292
303
  this.resumeWork = null
293
304
  this.capacityTimer = null
294
305
  this.capacityCheck = null
@@ -664,6 +675,7 @@ export class PrivateMessenger {
664
675
 
665
676
  if (removedPubkeys.length) {
666
677
  await this.stampChannelActivity(removedPubkeys)
678
+ for (const pubkey of removedPubkeys) this.dropRecoveryRanges(pubkey)
667
679
  }
668
680
 
669
681
  const storageSnapshot = await activatePrivateMessengerStorage({
@@ -1174,7 +1186,211 @@ export class PrivateMessenger {
1174
1186
  this.liveRecoveryTimers.delete(pubkey)
1175
1187
  }
1176
1188
 
1189
+ async checkRecoveryOnline () {
1190
+ const now = Date.now()
1191
+ if (now - this.recoveryOnline.at < RECOVERY_ONLINE_CACHE_MS) return this.recoveryOnline.value
1192
+ let value = true
1193
+ try {
1194
+ if (globalThis.navigator?.onLine === false) value = false
1195
+ else if (this._isOnline !== isOnline || typeof globalThis.document !== 'undefined') {
1196
+ value = (await this._isOnline()) !== false
1197
+ }
1198
+ } catch { value = false }
1199
+ this.recoveryOnline = { at: Date.now(), value }
1200
+ return value
1201
+ }
1202
+
1203
+ recoveryRangeKey (channelPubkey, range) {
1204
+ return `${channelPubkey}:${range.start}:${range.end}`
1205
+ }
1206
+
1207
+ recoveryRangeRecord (channelPubkey, range) {
1208
+ const key = this.recoveryRangeKey(channelPubkey, range)
1209
+ let record = this.recoveryRanges.get(key)
1210
+ if (!record) {
1211
+ record = {
1212
+ key,
1213
+ channelPubkey,
1214
+ start: range.start,
1215
+ end: range.end,
1216
+ covered: [],
1217
+ oldestCreatedAt: null,
1218
+ relays: new Map(),
1219
+ tier: '',
1220
+ everEose: false,
1221
+ everEoseWithEvents: false,
1222
+ attempts: 0,
1223
+ lastErrors: []
1224
+ }
1225
+ this.recoveryRanges.set(key, record)
1226
+ }
1227
+ return record
1228
+ }
1229
+
1230
+ reconcileRecoveryRelays (record, relays) {
1231
+ const current = new Set(relays)
1232
+ for (const relay of [...record.relays.keys()]) {
1233
+ if (!current.has(relay)) record.relays.delete(relay)
1234
+ }
1235
+ for (const relay of current) {
1236
+ if (record.relays.has(relay)) continue
1237
+ record.relays.set(relay, {
1238
+ pending: [{ start: record.start, end: record.end }],
1239
+ covered: [],
1240
+ attempts: 0,
1241
+ dead: false,
1242
+ eose: false,
1243
+ events: 0,
1244
+ error: null
1245
+ })
1246
+ }
1247
+ }
1248
+
1249
+ recoveryRelayPlan (record) {
1250
+ const plan = {}
1251
+ let hasWork = false
1252
+ for (const [relay, state] of record.relays) {
1253
+ if (state.dead || state.eose || !state.pending?.length) continue
1254
+ plan[relay] = state.pending
1255
+ hasWork = true
1256
+ }
1257
+ return hasWork ? plan : null
1258
+ }
1259
+
1260
+ hasActiveRecoveryRelays (channelPubkey) {
1261
+ for (const record of this.recoveryRanges.values()) {
1262
+ if (record.channelPubkey !== channelPubkey) continue
1263
+ if (this.recoveryRecordActive(record)) return true
1264
+ }
1265
+ return false
1266
+ }
1267
+
1268
+ recoveryRecordActive (record) {
1269
+ for (const state of record.relays.values()) {
1270
+ if (!state.dead && !state.eose && state.pending?.length) return true
1271
+ }
1272
+ return false
1273
+ }
1274
+
1275
+ hasPendingRecovery (channelPubkey) {
1276
+ return (this.readState().channels[channelPubkey]?.offlineRanges || []).length > 0
1277
+ }
1278
+
1279
+ dropRecoveryRanges (channelPubkey) {
1280
+ for (const [key, record] of this.recoveryRanges) {
1281
+ if (record.channelPubkey === channelPubkey) this.recoveryRanges.delete(key)
1282
+ }
1283
+ }
1284
+
1285
+ pruneRecoveryRanges (channelPubkey, ranges) {
1286
+ const keep = new Set((ranges || []).map(range => this.recoveryRangeKey(channelPubkey, range)))
1287
+ for (const [key, record] of this.recoveryRanges) {
1288
+ if (record.channelPubkey === channelPubkey && !keep.has(key)) this.recoveryRanges.delete(key)
1289
+ }
1290
+ }
1291
+
1292
+ updateRecoveryRecord (record, history) {
1293
+ record.covered = mergeRanges([
1294
+ ...record.covered,
1295
+ ...(history.relays || []).flatMap(entry => entry.covered || [])
1296
+ ])
1297
+ if (history.oldestCreatedAt != null) {
1298
+ record.oldestCreatedAt = record.oldestCreatedAt == null
1299
+ ? history.oldestCreatedAt
1300
+ : Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
1301
+ }
1302
+ const byRelay = new Map((history.relays || []).map(entry => [entry.relay, entry]))
1303
+ for (const [relay, state] of record.relays) {
1304
+ const entry = byRelay.get(relay)
1305
+ if (!entry) continue
1306
+ const relayErrors = [...(entry.errors || [])]
1307
+ if (entry.error) relayErrors.push(entry.error)
1308
+ state.error = relayErrors[0] || null
1309
+ const done = entry.status === 'eose' && !(entry.pending?.length)
1310
+ state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
1311
+ state.eose = done
1312
+ state.pending = done ? [] : subtractRanges({ start: record.start, end: record.end }, state.covered)
1313
+ state.events += entry.events || 0
1314
+ if (done) {
1315
+ state.attempts = 0
1316
+ record.everEose = true
1317
+ if (state.events > 0) record.everEoseWithEvents = true
1318
+ }
1319
+ }
1320
+ record.tier = record.everEoseWithEvents ? 'partial' : record.everEose ? 'emptyEose' : 'allFailed'
1321
+ record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
1322
+ }
1323
+
1324
+ applyRecoveryRetryBudget (record) {
1325
+ const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
1326
+ for (const state of record.relays.values()) {
1327
+ if (state.eose || state.dead || !state.pending?.length) continue
1328
+ state.attempts++
1329
+ if (state.attempts >= limit || isPermanentRecoveryError(state.error)) state.dead = true
1330
+ }
1331
+ record.attempts++
1332
+ }
1333
+
1334
+ recoveryIncompleteError (record, history, range) {
1335
+ const errors = []
1336
+ for (const entry of history.relays || []) {
1337
+ for (const reason of entry.errors || []) errors.push({ relay: entry.relay, reason })
1338
+ if (entry.error) errors.push({ relay: entry.relay, reason: entry.error })
1339
+ }
1340
+ const error = incompleteFetchError({
1341
+ errors,
1342
+ report: (history.relays || []).map(entry => ({
1343
+ relay: entry.relay,
1344
+ status: entry.status,
1345
+ ...(entry.error ? { error: entry.error } : {})
1346
+ })),
1347
+ receivedEventCount: history.receivedEventCount || 0,
1348
+ elapsedMs: history.elapsedMs || 0,
1349
+ request: {
1350
+ relays: [...record.relays.keys()],
1351
+ channelPubkeys: [record.channelPubkey],
1352
+ receiverPubkey: this.userPubkey,
1353
+ since: range.start,
1354
+ until: range.end,
1355
+ limit: 16,
1356
+ timeoutMs: 5000
1357
+ }
1358
+ })
1359
+ error.operation = 'private-channel.fetchHistory'
1360
+ return error
1361
+ }
1362
+
1363
+ isPartialRecoveryHistory (history) {
1364
+ return Boolean(history) && !Array.isArray(history) &&
1365
+ Array.isArray(history.relays) &&
1366
+ history.pendingByRelay !== undefined &&
1367
+ typeof history.anyEose === 'boolean'
1368
+ }
1369
+
1370
+ async finalizeRecoveryRange ({ channelPubkey, range, record }) {
1371
+ if (!await this.checkRecoveryOnline()) return { complete: false, failures: [], offline: true }
1372
+ const uncovered = subtractRanges({ start: range.start, end: range.end }, record.covered)
1373
+ const oldest = record.oldestCreatedAt
1374
+ const leftEdge = oldest == null
1375
+ ? [{ start: range.start, end: range.end }]
1376
+ : oldest > range.start
1377
+ ? [{ start: range.start, end: Math.min(oldest, range.end) }]
1378
+ : []
1379
+ const seedRanges = mergeRanges([...uncovered, ...leftEdge])
1380
+ const failures = []
1381
+ for (const seedRange of seedRanges) {
1382
+ const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, seedRange.start, seedRange.end)
1383
+ failures.push(...attempt.failures.map(failure => failure.error))
1384
+ }
1385
+ record.lastErrors = failures
1386
+ return { complete: failures.length === 0, failures }
1387
+ }
1388
+
1177
1389
  scheduleLiveRecovery (pubkey) {
1390
+ return this.scheduleOfflineRecovery(pubkey)
1391
+ }
1392
+
1393
+ scheduleOfflineRecovery (pubkey, { startDelayMs = 0 } = {}) {
1178
1394
  const current = this.liveRecoveryTimers.get(pubkey)
1179
1395
  if (current) { current.again = true; return }
1180
1396
  const work = { delay: 1000, timer: null, again: false }
@@ -1199,7 +1415,11 @@ export class PrivateMessenger {
1199
1415
  }
1200
1416
  if (!isCurrent()) return
1201
1417
  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
1418
+ let retry = null
1419
+ if (pending) {
1420
+ retry = recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4)))
1421
+ if (retry === null && this.hasActiveRecoveryRelays(pubkey)) retry = Math.min(30000, work.delay)
1422
+ }
1203
1423
  if (work.again || retry !== null) {
1204
1424
  schedule(work.again ? 0 : retry)
1205
1425
  work.delay = Math.min(30000, work.delay * 2)
@@ -1207,7 +1427,7 @@ export class PrivateMessenger {
1207
1427
  }, delay)
1208
1428
  work.timer?.unref?.()
1209
1429
  }
1210
- schedule(0)
1430
+ schedule(startDelayMs)
1211
1431
  }
1212
1432
 
1213
1433
  recordInterruption (channels, at = nowSeconds()) {
@@ -1242,6 +1462,7 @@ export class PrivateMessenger {
1242
1462
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1243
1463
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1244
1464
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1465
+ for (const pubkey of pubkeys) this.dropRecoveryRanges(pubkey)
1245
1466
  this.recordInterruption(pubkeys)
1246
1467
  for (const controller of this.recoveryControllers) {
1247
1468
  if (pubkeys.includes(controller.channelPubkey)) controller.abort()
@@ -1278,6 +1499,9 @@ export class PrivateMessenger {
1278
1499
  if (this.pauseReasons.size || this.closePromise) return
1279
1500
  await this.reconcilePresencePublishers()
1280
1501
  await this.recoverOfflineRanges(channels)
1502
+ for (const pubkey of channels) {
1503
+ if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1504
+ }
1281
1505
  for (const extension of this.extensions) await extension.resume?.()
1282
1506
  })()
1283
1507
  this.resumeWork = work
@@ -1348,6 +1572,9 @@ export class PrivateMessenger {
1348
1572
  }
1349
1573
  await this.watch(channelPubkeys, { scheduleReloadGap: false })
1350
1574
  await this.recoverOfflineRanges(channelPubkeys)
1575
+ for (const pubkey of channelPubkeys) {
1576
+ if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1577
+ }
1351
1578
  }
1352
1579
 
1353
1580
  async handleAsk (channelPubkey, message) {
@@ -1928,6 +2155,9 @@ export class PrivateMessenger {
1928
2155
  }
1929
2156
  this.writeState(state)
1930
2157
  await this.flushStateWrites()
2158
+ for (const channel of channels) {
2159
+ if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryRanges(channel.pubkey)
2160
+ }
1931
2161
  for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
1932
2162
  })
1933
2163
  }
@@ -1968,6 +2198,9 @@ export class PrivateMessenger {
1968
2198
  if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
1969
2199
  if (start != null) this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
1970
2200
  try { await this.recoverOfflineRanges([pubkey]) } catch (err) { this.onError?.(err) }
2201
+ if (!this.closePromise && !this.pauseReasons.size && this.desiredChannels.has(pubkey) && this.hasPendingRecovery(pubkey)) {
2202
+ this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
2203
+ }
1971
2204
  }, this.reloadGapDelayMs)
1972
2205
  this.reloadGapTimers.set(pubkey, { timer, token, revision })
1973
2206
  }
@@ -2156,13 +2389,20 @@ export class PrivateMessenger {
2156
2389
  const failures = []
2157
2390
  const state = this.readState()
2158
2391
  const now = nowSeconds()
2392
+ const online = await this.checkRecoveryOnline()
2159
2393
 
2160
2394
  for (const pubkey of uniq(channels)) {
2161
2395
  const channel = this.channels.get(pubkey)
2162
2396
  const current = state.channels[pubkey]
2163
- if (!channel || !current?.offlineRanges?.length) continue
2397
+ if (!channel || !current?.offlineRanges?.length) {
2398
+ this.dropRecoveryRanges(pubkey)
2399
+ continue
2400
+ }
2164
2401
  const recoverySeconds = this.offlineRecoverySecondsFor(channel)
2165
- if (!recoverySeconds) continue
2402
+ if (!recoverySeconds) {
2403
+ this.dropRecoveryRanges(pubkey)
2404
+ continue
2405
+ }
2166
2406
  const minStart = now - recoverySeconds
2167
2407
  const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
2168
2408
 
@@ -2170,6 +2410,7 @@ export class PrivateMessenger {
2170
2410
  let recoveredThrough = current.recoveredThrough || 0
2171
2411
  for (const range of current.offlineRanges) {
2172
2412
  if (range.end < minStart) continue
2413
+ if (!online) { remaining.push(range); continue }
2173
2414
  const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
2174
2415
  const controller = new AbortController()
2175
2416
  controller.channelPubkey = pubkey
@@ -2178,38 +2419,81 @@ export class PrivateMessenger {
2178
2419
  if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
2179
2420
  const fetchRelays = await this.resolveWatchRelays(channel)
2180
2421
  controller.signal.throwIfAborted()
2181
- const history = await this._privateChannel.fetchHistory({
2182
- _acquirePage: signal => this.recoveryAdmission.acquire(signal),
2183
- signal: controller.signal,
2184
- receivedChunkScope: this.storageLeaseId,
2185
- receiverSigner: this.userSigner,
2186
- iykcSigner: this.contentKeySigner,
2187
- privateChannelSigner: channel.signer,
2188
- privateChannelReaderSigner: channel.readerSigner,
2189
- privateChannelReaderPubkey: channel.readerPubkey,
2190
- privateChannelPubkeys: [pubkey],
2191
- receiverPubkey: this.userPubkey,
2192
- relays: fetchRelays,
2193
- since: Math.max(0, range.start),
2194
- until: range.end,
2195
- mode: channel.mode,
2196
- modeByPubkey: { [pubkey]: channel.mode },
2197
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
2198
- receivedChunkIndexedDB: this._indexedDB,
2199
- onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2200
- onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
2201
- onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
2202
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
2203
- onError: err => { throw err }
2204
- })
2205
- controller.signal.throwIfAborted()
2206
- const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
2422
+ const record = this.recoveryRangeRecord(pubkey, range)
2423
+ this.reconcileRecoveryRelays(record, fetchRelays)
2424
+ const plan = this.recoveryRelayPlan(record)
2425
+ 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,
2437
+ relays: Object.keys(plan),
2438
+ since: Math.max(0, range.start),
2439
+ until: range.end,
2440
+ 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
+ })
2452
+ controller.signal.throwIfAborted()
2453
+ if (this.isPartialRecoveryHistory(history)) {
2454
+ this.updateRecoveryRecord(record, history)
2455
+ this.applyRecoveryRetryBudget(record)
2456
+ if ((history.relays || []).some(entry => entry.status !== 'eose' || entry.pending?.length)) {
2457
+ const error = this.recoveryIncompleteError(record, history, range)
2458
+ this.onError?.(error)
2459
+ failures.push(error)
2460
+ }
2461
+ } else {
2462
+ // Legacy fetch seam (tests/custom channels): resolves only when
2463
+ // the whole range was read, so treat it as fully relay-covered.
2464
+ record.covered = mergeRanges([...record.covered, { start: range.start, end: range.end }])
2465
+ if (history?.oldestCreatedAt != null) {
2466
+ record.oldestCreatedAt = record.oldestCreatedAt == null
2467
+ ? history.oldestCreatedAt
2468
+ : Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
2469
+ }
2470
+ for (const state of record.relays.values()) {
2471
+ state.eose = true
2472
+ state.pending = []
2473
+ }
2474
+ record.tier = 'partial'
2475
+ record.lastErrors = []
2476
+ }
2477
+ }
2207
2478
  const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
2208
2479
  (this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
2209
- failures.push(...attempt.failures.map(failure => failure.error))
2210
- if (lifecycleChanged || attempt.failures.length) remaining.push(range)
2211
- else recoveredThrough = Math.max(recoveredThrough, range.end)
2480
+ if (lifecycleChanged) {
2481
+ remaining.push(range)
2482
+ continue
2483
+ }
2484
+ if (this.recoveryRecordActive(record)) {
2485
+ remaining.push(range)
2486
+ continue
2487
+ }
2488
+ const result = await this.finalizeRecoveryRange({ channelPubkey: pubkey, range, record })
2489
+ failures.push(...result.failures)
2490
+ if (result.complete) recoveredThrough = Math.max(recoveredThrough, range.end)
2491
+ else remaining.push(range)
2212
2492
  } catch (err) {
2493
+ const record = this.recoveryRanges.get(this.recoveryRangeKey(pubkey, range))
2494
+ if (record && isPermanentRecoveryError(err)) {
2495
+ for (const state of record.relays.values()) state.dead = true
2496
+ }
2213
2497
  if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
2214
2498
  failures.push(err)
2215
2499
  remaining.push(range)
@@ -2223,6 +2507,7 @@ export class PrivateMessenger {
2223
2507
  recoveredThrough: Math.max(fresh.channels[pubkey]?.recoveredThrough || 0, recoveredThrough),
2224
2508
  offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
2225
2509
  }
2510
+ this.pruneRecoveryRanges(pubkey, remaining)
2226
2511
  this.writeState(fresh)
2227
2512
  await this.flushStateWrites()
2228
2513
  }
@@ -2232,6 +2517,7 @@ export class PrivateMessenger {
2232
2517
  async clearChannel (pubkey) {
2233
2518
  await this.unwatch(pubkey)
2234
2519
  await this._privateMessage.clearChannelState?.(pubkey)
2520
+ this.dropRecoveryRanges(pubkey)
2235
2521
  return this.runQueueOperation(async () => {
2236
2522
  this.channels.delete(pubkey)
2237
2523
  this.removeChannelState(pubkey)
@@ -2265,6 +2551,7 @@ export class PrivateMessenger {
2265
2551
  await this.queue?.removeBy('byChannel', pubkey)
2266
2552
  await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
2267
2553
  }
2554
+ for (const pubkey of stalePubkeys) this.dropRecoveryRanges(pubkey)
2268
2555
  this.state = state
2269
2556
  if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
2270
2557
  })
@@ -2355,22 +2642,13 @@ export class PrivateMessenger {
2355
2642
  temporaryStorageArea: this.temporaryStorageArea
2356
2643
  })
2357
2644
  }
2645
+ this.recoveryRanges.clear()
2358
2646
  if (unwatchError) throw unwatchError
2359
2647
  })()
2360
2648
  return this.closePromise
2361
2649
  }
2362
2650
  }
2363
2651
 
2364
- function mergeRanges (ranges) {
2365
- const out = []
2366
- for (const range of ranges) {
2367
- const last = out[out.length - 1]
2368
- if (!last || range.start > last.end + 1) out.push({ ...range })
2369
- else last.end = Math.max(last.end, range.end)
2370
- }
2371
- return out
2372
- }
2373
-
2374
2652
  function eventType (event) {
2375
2653
  if (event.kind === privateMessage.ASK_KIND) return 'ask'
2376
2654
  if (event.kind === privateMessage.REPLY_KIND) return 'reply'
@@ -21,6 +21,14 @@ const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|P
21
21
 
22
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' }) }) {
23
23
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
24
+ const reportedErrors = new WeakSet()
25
+ const reportError = error => {
26
+ if (error && (typeof error === 'object' || typeof error === 'function')) {
27
+ if (reportedErrors.has(error)) return
28
+ reportedErrors.add(error)
29
+ }
30
+ onError(error)
31
+ }
24
32
  const userSigner = messengerSigner(signer)
25
33
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
26
34
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -37,6 +45,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
37
45
  let downloads
38
46
  const downloadIntents = new Map()
39
47
  const downloadEpochs = new Map()
48
+ const activeDownloads = new Map()
40
49
  let downloadWriteTail = Promise.resolve()
41
50
  const writeDownloadIntent = operation => {
42
51
  const work = downloadWriteTail.catch(() => {}).then(operation)
@@ -72,18 +81,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
72
81
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
73
82
  channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
74
83
  channels.set(peer, channel)
75
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
84
+ } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
76
85
  }
77
86
  if (closed || !available || version !== lifecycle) return
78
87
  values.push(channel)
79
88
  }
80
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError })
89
+ if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
81
90
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
82
91
  await messenger.update({ channels: values })
83
92
  await messenger.resume('signer')
84
93
  if (!fileTransfers) {
85
94
  fileTransfers = FileTransfer({
86
- messenger, onError,
95
+ messenger, onError: reportError,
87
96
  resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
88
97
  storage: chunkStorage,
89
98
  seedStorage: recoveryStorage?.seeds,
@@ -95,20 +104,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
95
104
  onMedia({ ...state, peer })
96
105
  if (state.status === 'complete') {
97
106
  const id = intentId({ peer, root: state.root })
98
- if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
107
+ if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(reportError)
99
108
  }
100
109
  }
101
110
  })
102
111
  downloads = await openDownloads({ owner, signer })
103
112
  for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
104
113
  }
105
- for (const entry of downloadIntents.values()) if (peers.has(entry.file.peer)) download(entry.file, { manual: true }).catch(onError)
114
+ for (const entry of downloadIntents.values()) {
115
+ if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
116
+ const work = download(entry.file, { manual: true })
117
+ .catch(error => {
118
+ // A failed intent stays queued for the next configure; report each
119
+ // real attempt once even if configure runs while it is in flight.
120
+ if (downloadIntents.has(entry.id)) reportError(error)
121
+ })
122
+ .finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
123
+ activeDownloads.set(entry.id, work)
124
+ }
106
125
  drain(); pump()
107
126
  }
108
127
  function schedule () {
109
128
  const work = configuring.catch(() => {}).then(configure)
110
129
  configuring = work
111
- work.catch(onError)
130
+ work.catch(reportError)
112
131
  return work
113
132
  }
114
133
  async function drain () {
@@ -130,7 +149,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
130
149
  const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
131
150
  if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
132
151
  if (event.kind === 34601) {
133
- try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
152
+ try { decodeIrfsChunk(event) } catch (error) { reportError(error); await ack(); continue }
134
153
  }
135
154
  if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
136
155
  if (!available || closed) { await nack(); break }
@@ -139,7 +158,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
158
  await ack()
140
159
  } catch (error) {
141
160
  await nack()
142
- onError(error)
161
+ reportError(error)
143
162
  // Retain the failed record and suspend network ingestion until recovery.
144
163
  await messenger.pause('storage')
145
164
  break
@@ -218,8 +237,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
218
237
  } catch (error) {
219
238
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
220
239
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
221
- await storage.put(entry, { existing: true }).catch(onError)
222
- onError(error)
240
+ await storage.put(entry, { existing: true }).catch(reportError)
241
+ reportError(error)
223
242
  // The failed wire event may be a quote or file chunk. Report the
224
243
  // owning outbox item separately, without mutating the native error.
225
244
  if (error.retryWhenOnline) offlineSends.add(entry.id)
@@ -348,14 +367,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
348
367
  },
349
368
  async close () {
350
369
  closed = true; available = false; lifecycle++
351
- stopOnlineRetry(); offlineSends.clear()
370
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
352
371
  for (const controller of sendControllers.values()) controller.abort()
353
372
  await Promise.allSettled([configuring, initialized])
354
373
  await messenger?.pause('closed')
355
374
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
356
375
  await messenger?.close()
357
376
  await storage?.close()
358
- await downloadWriteTail.catch(onError)
377
+ await downloadWriteTail.catch(reportError)
359
378
  await downloads?.close()
360
379
  }
361
380
  }