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 +15 -4
- package/helpers/ranges.js +31 -0
- package/package.json +1 -1
- package/private-channel/helpers/history.js +47 -3
- package/private-channel/index.js +2 -2
- package/private-messenger/helpers/recovery-retry.js +16 -0
- package/private-messenger/index.js +322 -44
- package/private-messenger/session/index.js +31 -12
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
|
-
|
|
536
|
-
|
|
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.
|
|
543
|
-
|
|
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,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
|
|
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(
|
|
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
|
+
}
|
package/private-channel/index.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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)
|
|
2397
|
+
if (!channel || !current?.offlineRanges?.length) {
|
|
2398
|
+
this.dropRecoveryRanges(pubkey)
|
|
2399
|
+
continue
|
|
2400
|
+
}
|
|
2164
2401
|
const recoverySeconds = this.offlineRecoverySecondsFor(channel)
|
|
2165
|
-
if (!recoverySeconds)
|
|
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
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
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);
|
|
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(
|
|
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())
|
|
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(
|
|
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) {
|
|
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
|
-
|
|
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(
|
|
222
|
-
|
|
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(
|
|
377
|
+
await downloadWriteTail.catch(reportError)
|
|
359
378
|
await downloads?.close()
|
|
360
379
|
}
|
|
361
380
|
}
|