libp2r2p 0.11.9 → 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
@@ -2,11 +2,13 @@ import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
2
2
  import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
3
3
 
4
4
  const QUERY_CACHE_MS = 40 * 60 * 1000
5
+ const NEGATIVE_CACHE_MS = 5 * 60 * 1000
5
6
  const IYKC_CACHE_MAX_ITEMS = 10000
6
7
  const HEX_PUBKEY = /^[0-9a-f]{64}$/i
7
8
  const contentKeysByPubkey = Object.create(null)
8
9
  const iykcCacheTimersByPubkey = Object.create(null)
9
10
  const iykcCacheAddedAtByPubkey = Object.create(null)
11
+ const inFlightByPubkey = new Map()
10
12
 
11
13
  const getEvents = (...args) => relayPool.getEvents(...args)
12
14
 
@@ -68,42 +70,30 @@ export function clearContentKeyCache () {
68
70
  for (const key of Object.keys(contentKeysByPubkey)) delete contentKeysByPubkey[key]
69
71
  for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
70
72
  for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
73
+ inFlightByPubkey.clear()
71
74
  }
72
75
 
73
- export async function getIykcProofs (pubkeys, {
74
- _getEvents = getEvents,
75
- _getRelaysByPubkey = getRelaysByPubkey,
76
- cacheMs = QUERY_CACHE_MS
77
- } = {}) {
78
- const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
79
- if (!pubkeyList.length) return {}
80
-
81
- const out = {}
82
- const missingPubkeys = []
83
- for (const pubkey of pubkeyList) {
84
- if (!hasCachedKey(contentKeysByPubkey, pubkey)) {
85
- missingPubkeys.push(pubkey)
86
- continue
87
- }
88
- const cached = cloneContentKey(contentKeysByPubkey[pubkey])
89
- if (cached) out[pubkey] = cached
90
- }
91
- if (!missingPubkeys.length) return out
92
-
93
- const relaysByPubkey = await _getRelaysByPubkey(missingPubkeys, { _getEvents, cacheMs })
94
- const relayToAuthors = pickRelaysForPubkeys(missingPubkeys, relaysByPubkey)
76
+ async function queryContentKeyProofs (pubkeyList, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs }) {
77
+ const relaysByPubkey = await _getRelaysByPubkey(pubkeyList, { _getEvents, cacheMs })
78
+ const relayToAuthors = pickRelaysForPubkeys(pubkeyList, relaysByPubkey)
95
79
  const eventGroups = await Promise.all(
96
80
  [...relayToAuthors.entries()]
97
81
  .map(async ([relay, authors]) => {
98
- const { result } = await _getEvents({
99
- kinds: [CONTENT_KEY_KIND],
100
- authors,
101
- limit: authors.length
102
- }, [relay], {
103
- timeout: 5000,
104
- timeoutAfterFirstEose: null
105
- })
106
- return result.map(({ event }) => event)
82
+ try {
83
+ const { result } = await _getEvents({
84
+ kinds: [CONTENT_KEY_KIND],
85
+ authors,
86
+ limit: authors.length
87
+ }, [relay], {
88
+ timeout: 5000,
89
+ timeoutAfterFirstEose: null
90
+ })
91
+ return result.map(({ event }) => event)
92
+ } catch {
93
+ // Discovery is best-effort: a failing relay group must not fail an
94
+ // otherwise publishable message.
95
+ return []
96
+ }
107
97
  })
108
98
  )
109
99
 
@@ -116,14 +106,67 @@ export async function getIykcProofs (pubkeys, {
116
106
  }
117
107
  }
118
108
 
119
- for (const pubkey of missingPubkeys) {
109
+ const out = {}
110
+ for (const pubkey of pubkeyList) {
120
111
  const entry = latestByPubkey[pubkey]
121
112
  const proof = entry
122
113
  ? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
123
114
  : null
124
- setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
125
- if (proof) out[pubkey] = proof
115
+ if (proof) {
116
+ setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
117
+ out[pubkey] = proof
118
+ } else if (negativeCacheMs > 0) {
119
+ // Misses expire quickly so a peer that publishes a key right after a
120
+ // prefetch is picked up without waiting for the positive cache window.
121
+ setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, null, negativeCacheMs)
122
+ }
126
123
  }
127
124
  pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
128
125
  return out
129
126
  }
127
+
128
+ export async function getIykcProofs (pubkeys, {
129
+ _getEvents = getEvents,
130
+ _getRelaysByPubkey = getRelaysByPubkey,
131
+ cacheMs = QUERY_CACHE_MS,
132
+ negativeCacheMs = NEGATIVE_CACHE_MS
133
+ } = {}) {
134
+ const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
135
+ if (!pubkeyList.length) return {}
136
+
137
+ const out = {}
138
+ const waiting = []
139
+ const toQuery = []
140
+ for (const pubkey of pubkeyList) {
141
+ if (hasCachedKey(contentKeysByPubkey, pubkey)) {
142
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
143
+ if (cached) out[pubkey] = cached
144
+ continue
145
+ }
146
+ const pending = inFlightByPubkey.get(pubkey)
147
+ if (pending) waiting.push([pubkey, pending])
148
+ else toQuery.push(pubkey)
149
+ }
150
+
151
+ if (toQuery.length) {
152
+ const query = queryContentKeyProofs(toQuery, { _getEvents, _getRelaysByPubkey, cacheMs, negativeCacheMs })
153
+ for (const pubkey of toQuery) inFlightByPubkey.set(pubkey, query)
154
+ try {
155
+ Object.assign(out, await query)
156
+ } finally {
157
+ for (const pubkey of toQuery) {
158
+ if (inFlightByPubkey.get(pubkey) === query) inFlightByPubkey.delete(pubkey)
159
+ }
160
+ }
161
+ }
162
+
163
+ for (const [pubkey, pending] of waiting) {
164
+ const found = await pending.catch(() => null)
165
+ if (found?.[pubkey]) out[pubkey] = found[pubkey]
166
+ else {
167
+ const cached = cloneContentKey(contentKeysByPubkey[pubkey])
168
+ if (cached) out[pubkey] = cached
169
+ }
170
+ }
171
+ return out
172
+ }
@@ -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.9",
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
+ }
@@ -0,0 +1,29 @@
1
+ export const LOOKUP_WARNING_INTERVAL_MS = 30_000
2
+
3
+ // Best-effort discovery can fail on every message while a relay or the
4
+ // network is unavailable. Keep the first failure visible, then debounce
5
+ // repeats and report how many were suppressed on the next log.
6
+ export function createLookupWarningLogger ({
7
+ intervalMs = LOOKUP_WARNING_INTERVAL_MS,
8
+ label = 'private-messenger content-key lookup failed',
9
+ warn = (...args) => console.warn(...args),
10
+ now = Date.now
11
+ } = {}) {
12
+ let warnedAt = null
13
+ let suppressed = 0
14
+ return (error, context = '') => {
15
+ const current = now()
16
+ if (warnedAt !== null && current - warnedAt < intervalMs) {
17
+ suppressed++
18
+ return false
19
+ }
20
+ const count = suppressed
21
+ warnedAt = current
22
+ suppressed = 0
23
+ warn(
24
+ `${label}${context ? ` (${context})` : ''}${count ? ` [${count} repeated failures suppressed]` : ''}`,
25
+ error?.message ?? error
26
+ )
27
+ return true
28
+ }
29
+ }
@@ -9,6 +9,7 @@ import { normalizeRumor, deliveryInfo } from './helpers/rumor.js'
9
9
  import { incompleteFetchError } from './helpers/fetch-error.js'
10
10
  import { readHistory } from './helpers/history.js'
11
11
  import { subscriptionError } from './helpers/subscription-error.js'
12
+ import { createLookupWarningLogger } from './helpers/lookup-warning.js'
12
13
  import * as nip44v3 from '../nip44-v3/index.js'
13
14
  import { relayPool } from '../relay/index.js'
14
15
  import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
@@ -35,6 +36,7 @@ const HEX_SECKEY = /^[0-9a-f]{64}$/i
35
36
  const HEX_PUBKEY = /^[0-9a-f]{64}$/i
36
37
  const encoder = new TextEncoder()
37
38
  const decoder = new TextDecoder()
39
+ const warnLookupFailure = createLookupWarningLogger()
38
40
  const NIP44_V3_SCOPE = ''
39
41
 
40
42
  const sendToRelays = (event, relays) => relayPool.sendEvent(event, relays)
@@ -110,7 +112,19 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
110
112
  const useDoubleDh = typeof senderSigner.nip44EncryptDoubleDH === 'function'
111
113
  const channelPubkey = await privateChannelSigner.getPublicKey()
112
114
  const channelReaderPubkey = privateChannelReaderPubkey || channelPubkey
113
- const receiverContentKeys = useDoubleDh ? await _getIykcProofs(receiverPubkeysWithoutContentKeys(receivers)) : {}
115
+ let receiverContentKeys = {}
116
+ if (useDoubleDh) {
117
+ const receiversWithoutContentKeys = receiverPubkeysWithoutContentKeys(receivers)
118
+ try {
119
+ receiverContentKeys = await _getIykcProofs(receiversWithoutContentKeys) || {}
120
+ } catch (error) {
121
+ // Content-key discovery is best-effort; without a proof the row still
122
+ // works in sender-content mode instead of failing the send. Keep
123
+ // failures visible without logging on every message during an outage.
124
+ warnLookupFailure(error, `recipients: ${receiversWithoutContentKeys.length}`)
125
+ receiverContentKeys = {}
126
+ }
127
+ }
114
128
  const preparedRows = await prepareEnvelopeRows({
115
129
  senderSigner,
116
130
  imkcSigner: useDoubleDh ? imkcSigner : null,
@@ -390,13 +404,18 @@ async function unwrapRecipientEnvelope ({ payloadCiphertext, envelope, receiverS
390
404
  if (envelope.iykcPubkey) {
391
405
  assertValidEnvelopeIykcProof(envelope)
392
406
  }
407
+ // In self-encryption the advertised sender content key is also our own.
408
+ // Rows written without a content-key lookup (useContentKeys: false) carry
409
+ // no iykc pubkey, so hand imkc to the signer as the receiver's own key.
410
+ const ownContentPubkey = envelope.iykcPubkey ||
411
+ (senderPubkey && receiverPubkey && senderPubkey === receiverPubkey ? imkcPubkey : '')
393
412
  messageSeckey = base64ToText(await receiverSigner.nip44DecryptDoubleDH(
394
413
  senderPubkey,
395
414
  ROUTER_KIND,
396
415
  rowScope,
397
416
  envelope.ciphertext,
398
417
  imkcPubkey,
399
- envelope.iykcPubkey || ''
418
+ ownContentPubkey
400
419
  ))
401
420
  } else {
402
421
  if (!receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
@@ -953,12 +972,12 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
953
972
 
954
973
  // Complete interval recovery with bounded pages and one fragment processor.
955
974
  // Existing fetch() retains its one-query/event-array contract.
956
- 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 }) {
957
976
  const authors = privateChannelPubkeyList(options)
958
977
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], ...(authors.length ? { authors } : {}), since: options.since, until: options.until }
959
978
  const processOuterEvent = createProcessor({ ...options, receiverSigner, privateChannelSigner, onError: error => { options.onError?.(error); throw error } })
960
979
  try {
961
- 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 })
962
981
  } finally { processOuterEvent.close() }
963
982
  }
964
983
 
@@ -243,6 +243,16 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
243
243
  const requestedSeeders = new Set()
244
244
  const validSeeders = new Set([file.peerPubkey, ...messenger.recoverySeeders(file.controlChannelPubkey)])
245
245
  let terminalError
246
+ let lastError = null
247
+ const reportError = error => {
248
+ lastError = {
249
+ name: error?.name || 'Error',
250
+ message: error?.message ?? String(error),
251
+ code: error?.code || '',
252
+ at: Date.now()
253
+ }
254
+ onError(error)
255
+ }
246
256
  const fail = error => { terminalError ||= error }
247
257
  async function accept (event, index, { save = true } = {}) {
248
258
  signal.throwIfAborted()
@@ -297,7 +307,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
297
307
  })
298
308
  // Malformed remote records do not poison the ingestion chain. Operational
299
309
  // persistence failures stop the job so completion cannot outrun storage.
300
- ingest = task.catch(error => { if (error instanceof ValidationError) onError(error); else if (!signal.aborted) fail(error) })
310
+ ingest = task.catch(error => { if (error instanceof ValidationError) reportError(error); else if (!signal.aborted) fail(error) })
301
311
  return ingest
302
312
  }
303
313
  try {
@@ -312,7 +322,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
312
322
  if (total !== undefined && persisted.size === total) return
313
323
  const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
314
324
  const relays = await messenger.resolveWatchRelays(parent)
315
- const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError }
325
+ const options = { receiverSigner: messenger.userSigner, privateChannelSigner: file.signer, privateChannelPubkey: file.fileChannelPubkey, receiverPubkey: messenger.userPubkey, relays, receivedChunkIndexedDB: messenger._indexedDB, receivedChunkScope: `${messenger.userPubkey}:file:${file.fileChannelPubkey}`, onEvent: consume, onSeedEvent: seed => saveSeed(file, seed), mode: messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? 'watchtower' : 'leecher', onError: reportError }
316
326
  sub = _transport.subscribe({ ...options, liveOnly: true })
317
327
  const stop = () => { sub?.close() }
318
328
  signal.addEventListener('abort', stop, { once: true })
@@ -323,7 +333,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
323
333
  const history = _transport.fetch({ ...options, signal: AbortSignal.any([signal, historyController.signal]), since: 0 }).catch(error => {
324
334
  // Completion aborts the remaining history read in finally. Only its
325
335
  // own cancellation is expected; genuine history failures stay visible.
326
- if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) onError(error)
336
+ if (!signal.aborted && !(historyController.signal.aborted && error === historyController.signal.reason)) reportError(error)
327
337
  })
328
338
  work.add(history); history.finally(() => work.delete(history))
329
339
  const seeders = [...validSeeders].sort((a, b) => (successes.get(b) || 0) - (successes.get(a) || 0) || Math.random() - 0.5)
@@ -340,9 +350,33 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
340
350
  requestedSeeders.add(peer)
341
351
  requestRoundAt = Date.now() + _hedgeMs
342
352
  // The request is on dm; its compact reply is on the file channel.
343
- await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(onError)
353
+ await messenger.ask({ channelPubkey: file.controlChannelPubkey, receiverPubkey: peer, code: FILE_CHUNKS_REQUEST_CODE, payload: { fileChannelPubkey: file.fileChannelPubkey, missingRanges: rangesFor(missing) } }).catch(reportError)
354
+ }
355
+ if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) {
356
+ const details = {
357
+ operation: 'file-download-stall',
358
+ ownerPubkey: messenger.userPubkey,
359
+ root: file.root,
360
+ peerPubkey: file.peerPubkey,
361
+ total,
362
+ persisted: persisted.size,
363
+ missingRanges: rangesFor(missing),
364
+ missingCount: total === undefined ? null : total - persisted.size,
365
+ seeders: [...validSeeders],
366
+ requestedSeeders: [...requestedSeeders],
367
+ lastProgressAt: new Date(lastProgress).toISOString(),
368
+ lastProgressAgoMs: Date.now() - lastProgress,
369
+ elapsedMs: Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs),
370
+ lastError
371
+ }
372
+ const error = new Error('FILE_DOWNLOAD_STALLED')
373
+ error.code = 'FILE_DOWNLOAD_STALLED'
374
+ error.operation = details.operation
375
+ error.elapsedMs = details.elapsedMs
376
+ error.details = details
377
+ console.warn('private-messenger file download stalled', details)
378
+ throw error
344
379
  }
345
- if (!remaining.length && Date.now() - Math.max(lastProgress, requestRoundAt - _hedgeMs) >= _idleMs) throw new Error('FILE_DOWNLOAD_STALLED')
346
380
  // Start a fresh batch when all positions of the previous batch arrived.
347
381
  if (job.batch?.every(index => persisted.has(index))) { requestedSeeders.clear(); requestRoundAt = Date.now(); job.batch = null }
348
382
  job.batch ||= missing
@@ -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'
@@ -9,11 +9,18 @@ Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and eithe
9
9
  Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
10
10
  persistence; absent file storage uses the library cache. `mode` defaults to
11
11
  `seeder`, `seedersForPeer(peer)` to `[peer]`, and `allowedKinds` to
12
- `[5, 9, 1063, 34601]`. The DM context is `dm`; file contexts are `dm:media:<root>`.
12
+ `[5, 9, 1063, 34601]`. `useContentKeys` defaults to `true`: the coordinator looks
13
+ up recipients' published content-key proofs (kind 18716) while sending and falls
14
+ back to sender-content whenever a proof is missing or the lookup fails. Misses
15
+ are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
16
+ key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
13
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
14
18
 
15
- Methods: `setPeers`, `setAvailable(boolean)`, `enqueue`, `retry`, `cancel`,
16
- `download`, `cancelDownload`, and `close`. Callbacks are `onOutbox`, `onMedia`,
19
+ Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
20
+ `enqueue`, `retry`, `cancel`, `download`, `cancelDownload`, and `close`.
21
+ `prefetchContentKeys` warms the content-key lookup cache for the owner and the
22
+ given peers (default: current peers); it is best-effort and never rejects.
23
+ Callbacks are `onOutbox`, `onMedia`,
17
24
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
18
25
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
19
26
 
@@ -3,6 +3,7 @@ import { createPrivateFileTransfer } from '../file/index.js'
3
3
  import { createPrivateMessenger } from '../index.js'
4
4
  import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
+ import { getIykcProofs } from '../../content-key/index.js'
6
7
  import { onOnline } from '../../network/index.js'
7
8
  import { decodeFileMetadata } from '../../nip94/index.js'
8
9
  import { createChatOutbox } from './helpers/work-storage.js'
@@ -18,8 +19,16 @@ export function wireEvent (value, owner) {
18
19
  // Relay rejection text must not be interpreted as a local signer denial.
19
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 || ''}`)
20
21
 
21
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
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' }) }) {
22
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
+ }
23
32
  const userSigner = messengerSigner(signer)
24
33
  messageStorage ||= createEventStoreMessageStorage({ eventStore })
25
34
  chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
@@ -36,6 +45,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
36
45
  let downloads
37
46
  const downloadIntents = new Map()
38
47
  const downloadEpochs = new Map()
48
+ const activeDownloads = new Map()
39
49
  let downloadWriteTail = Promise.resolve()
40
50
  const writeDownloadIntent = operation => {
41
51
  const work = downloadWriteTail.catch(() => {}).then(operation)
@@ -71,18 +81,18 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
71
81
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
72
82
  channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
73
83
  channels.set(peer, channel)
74
- } catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
84
+ } catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
75
85
  }
76
86
  if (closed || !available || version !== lifecycle) return
77
87
  values.push(channel)
78
88
  }
79
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
89
+ if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
80
90
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
81
91
  await messenger.update({ channels: values })
82
92
  await messenger.resume('signer')
83
93
  if (!fileTransfers) {
84
94
  fileTransfers = FileTransfer({
85
- messenger, onError,
95
+ messenger, onError: reportError,
86
96
  resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
87
97
  storage: chunkStorage,
88
98
  seedStorage: recoveryStorage?.seeds,
@@ -94,20 +104,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
94
104
  onMedia({ ...state, peer })
95
105
  if (state.status === 'complete') {
96
106
  const id = intentId({ peer, root: state.root })
97
- if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
107
+ if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(reportError)
98
108
  }
99
109
  }
100
110
  })
101
111
  downloads = await openDownloads({ owner, signer })
102
112
  for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
103
113
  }
104
- 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
+ }
105
125
  drain(); pump()
106
126
  }
107
127
  function schedule () {
108
128
  const work = configuring.catch(() => {}).then(configure)
109
129
  configuring = work
110
- work.catch(onError)
130
+ work.catch(reportError)
111
131
  return work
112
132
  }
113
133
  async function drain () {
@@ -129,7 +149,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
129
149
  const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
130
150
  if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
131
151
  if (event.kind === 34601) {
132
- try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
152
+ try { decodeIrfsChunk(event) } catch (error) { reportError(error); await ack(); continue }
133
153
  }
134
154
  if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
135
155
  if (!available || closed) { await nack(); break }
@@ -138,7 +158,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
138
158
  await ack()
139
159
  } catch (error) {
140
160
  await nack()
141
- onError(error)
161
+ reportError(error)
142
162
  // Retain the failed record and suspend network ingestion until recovery.
143
163
  await messenger.pause('storage')
144
164
  break
@@ -217,8 +237,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
217
237
  } catch (error) {
218
238
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
219
239
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
220
- await storage.put(entry, { existing: true }).catch(onError)
221
- onError(error)
240
+ await storage.put(entry, { existing: true }).catch(reportError)
241
+ reportError(error)
222
242
  // The failed wire event may be a quote or file chunk. Report the
223
243
  // owning outbox item separately, without mutating the native error.
224
244
  if (error.retryWhenOnline) offlineSends.add(entry.id)
@@ -284,6 +304,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
284
304
  }
285
305
  return {
286
306
  download, cancelDownload,
307
+ // Peers-based so callers can prefetch as soon as a chat route opens, before
308
+ // the shared-key channel exists. When group chats get real membership, add
309
+ // a thin prefetchChannelContentKeys(channelPubkey) wrapper that resolves
310
+ // the channel's receivers and delegates here.
311
+ async prefetchContentKeys (values = [...peers]) {
312
+ if (closed || !useContentKeys) return {}
313
+ const requested = [...new Set([owner, ...(Array.isArray(values) ? values : [values])])]
314
+ .filter(value => typeof value === 'string' && value)
315
+ if (!requested.length) return {}
316
+ try {
317
+ return await _getIykcProofs(requested)
318
+ } catch (error) {
319
+ // Prefetch only warms the lookup cache; callers must keep working with
320
+ // sender-content until a proof is available. Keep the failure visible.
321
+ console.warn('private-messenger content-key prefetch failed', error?.message ?? error)
322
+ return {}
323
+ }
324
+ },
287
325
  async setPeers (values) {
288
326
  peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
289
327
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
@@ -329,14 +367,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
329
367
  },
330
368
  async close () {
331
369
  closed = true; available = false; lifecycle++
332
- stopOnlineRetry(); offlineSends.clear()
370
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
333
371
  for (const controller of sendControllers.values()) controller.abort()
334
372
  await Promise.allSettled([configuring, initialized])
335
373
  await messenger?.pause('closed')
336
374
  if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
337
375
  await messenger?.close()
338
376
  await storage?.close()
339
- await downloadWriteTail.catch(onError)
377
+ await downloadWriteTail.catch(reportError)
340
378
  await downloads?.close()
341
379
  }
342
380
  }