libp2r2p 0.11.10 → 0.11.12
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 +31 -4
- package/helpers/ranges.js +47 -0
- package/package.json +1 -1
- package/private-channel/helpers/history.js +54 -4
- package/private-channel/index.js +2 -2
- package/private-messenger/helpers/recovery-retry.js +16 -0
- package/private-messenger/index.js +613 -48
- package/private-messenger/session/README.md +9 -1
- package/private-messenger/session/index.js +59 -12
package/README.md
CHANGED
|
@@ -532,15 +532,42 @@ 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.
|
|
544
|
+
|
|
545
|
+
`prioritizeRange(channelPubkey, { since, until, type })` registers an
|
|
546
|
+
application-directed priority window for the next recovery pass. `unread-page`
|
|
547
|
+
requires `since` (a read anchor); `tail` derives a bounded window from the
|
|
548
|
+
newest persisted range using `priorityTailSeconds` (six hours by default).
|
|
549
|
+
Priority subranges are fetched before older history; relays are ordered by
|
|
550
|
+
recent EOSE, consecutive failures and latency; and the first priority page may
|
|
551
|
+
hedge up to `priorityHedgeRelays` (two) relays with `priorityHedgeDelayMs`
|
|
552
|
+
(500 ms), aborting the loser. When relays do not cover the window, seeders with
|
|
553
|
+
recent presence (`announcedAt`/`lastActiveAt` within `seederOnlineSeconds`) are
|
|
554
|
+
asked only for that subrange; a successful ask counts as coverage. Priority
|
|
555
|
+
probes never consume the relay tier budget, and priorities/statistics are
|
|
556
|
+
process-local: they expire after `priorityTtlMs` (ten minutes) or once covered,
|
|
557
|
+
and clear with channel/recovery state. Presence payloads and persisted ranges
|
|
558
|
+
are unchanged.
|
|
559
|
+
|
|
537
560
|
`private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
|
|
538
561
|
used by messenger recovery. It accepts the signers, processor/fragment options,
|
|
539
562
|
callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
|
|
540
563
|
(defaults: 0 and the start time of the call). It returns only
|
|
541
564
|
`{ oldestCreatedAt, receivedEventCount, relays }`; an empty scan has a null oldest
|
|
542
|
-
time.
|
|
543
|
-
|
|
565
|
+
time. Passing `partial: true` (used by messenger recovery) returns per-relay
|
|
566
|
+
`covered`/`pending`/`events` plus `pendingByRelay`, `anyEose`, `anyEoseWithEvents`
|
|
567
|
+
and `allFailed` instead of throwing on partial failure, and `resume` restricts
|
|
568
|
+
each relay to the given pending subranges. No complete-history event array is
|
|
569
|
+
retained. `fetch` keeps its existing single-query array contract and optional
|
|
570
|
+
limit.
|
|
544
571
|
|
|
545
572
|
History queries each relay independently with limit 16. A response with 16 events
|
|
546
573
|
or status `satisfied` is saturated, even if it already reports EOSE or contains
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
32
|
+
|
|
33
|
+
export function intersectRanges (left = [], right = []) {
|
|
34
|
+
const a = mergeRanges(left)
|
|
35
|
+
const b = mergeRanges(right)
|
|
36
|
+
const out = []
|
|
37
|
+
let i = 0
|
|
38
|
+
let j = 0
|
|
39
|
+
while (i < a.length && j < b.length) {
|
|
40
|
+
const start = Math.max(a[i].start, b[j].start)
|
|
41
|
+
const end = Math.min(a[i].end, b[j].end)
|
|
42
|
+
if (start <= end) out.push({ start, end })
|
|
43
|
+
if (a[i].end < b[j].end) i++
|
|
44
|
+
else j++
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,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,11 @@ 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
|
+
let relayElapsedMs = 0
|
|
50
|
+
const intervals = resumeIntervalsFor({ resume, relay, since, until })
|
|
51
|
+
.map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
|
|
52
|
+
const covered = []
|
|
48
53
|
let outcome = { relay, status: 'eose' }
|
|
49
54
|
while (intervals.length) {
|
|
50
55
|
signal?.throwIfAborted()
|
|
@@ -77,14 +82,20 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
77
82
|
signal?.throwIfAborted()
|
|
78
83
|
errors.push({ relay, reason: error })
|
|
79
84
|
outcome = { relay, status: 'error', error }
|
|
85
|
+
intervals.push(interval)
|
|
80
86
|
break
|
|
81
|
-
} finally {
|
|
87
|
+
} finally {
|
|
88
|
+
const took = performance.now() - started
|
|
89
|
+
elapsedMs += took
|
|
90
|
+
relayElapsedMs += took
|
|
91
|
+
}
|
|
82
92
|
signal?.throwIfAborted()
|
|
83
93
|
const status = page.relays?.find(item => item.relay === relay)
|
|
84
94
|
if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
|
|
85
95
|
await process(page.result)
|
|
86
96
|
errors.push(...(page.errors ?? []))
|
|
87
97
|
outcome = status ?? { relay, status: 'error' }
|
|
98
|
+
intervals.push(interval)
|
|
88
99
|
break
|
|
89
100
|
}
|
|
90
101
|
if (status.status === 'satisfied' || page.result.length >= interval.limit) {
|
|
@@ -98,10 +109,12 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
98
109
|
const error = pageLimitError()
|
|
99
110
|
errors.push({ relay, reason: error })
|
|
100
111
|
outcome = { relay, status: 'error', error }
|
|
112
|
+
intervals.push(interval)
|
|
101
113
|
break
|
|
102
114
|
}
|
|
103
115
|
} else {
|
|
104
116
|
await process(page.result)
|
|
117
|
+
covered.push({ start: interval.since, end: interval.until })
|
|
105
118
|
}
|
|
106
119
|
signal?.throwIfAborted()
|
|
107
120
|
} finally {
|
|
@@ -109,9 +122,35 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
109
122
|
releaseRelay?.()
|
|
110
123
|
}
|
|
111
124
|
}
|
|
112
|
-
report.push(
|
|
125
|
+
report.push({
|
|
126
|
+
...outcome,
|
|
127
|
+
...(partial
|
|
128
|
+
? {
|
|
129
|
+
covered: mergeRanges(covered),
|
|
130
|
+
pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
|
|
131
|
+
events: receivedEventCount - relayStartCount,
|
|
132
|
+
elapsedMs: Math.round(relayElapsedMs)
|
|
133
|
+
}
|
|
134
|
+
: {})
|
|
135
|
+
})
|
|
113
136
|
}
|
|
114
137
|
signal?.throwIfAborted()
|
|
138
|
+
if (partial) {
|
|
139
|
+
const pendingByRelay = {}
|
|
140
|
+
for (const entry of report) {
|
|
141
|
+
if (entry.pending?.length) pendingByRelay[entry.relay] = entry.pending
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
oldestCreatedAt,
|
|
145
|
+
receivedEventCount,
|
|
146
|
+
elapsedMs: Math.round(elapsedMs),
|
|
147
|
+
relays: report,
|
|
148
|
+
pendingByRelay,
|
|
149
|
+
anyEose: report.some(entry => entry.status === 'eose'),
|
|
150
|
+
anyEoseWithEvents: report.some(entry => entry.status === 'eose' && (entry.events ?? 0) > 0),
|
|
151
|
+
allFailed: report.every(entry => entry.status !== 'eose')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
115
154
|
if (errors.length || report.some(item => item.status !== 'eose')) {
|
|
116
155
|
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
156
|
error.operation = 'private-channel.fetchHistory'
|
|
@@ -119,3 +158,14 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
119
158
|
}
|
|
120
159
|
return { oldestCreatedAt, receivedEventCount, relays: report }
|
|
121
160
|
}
|
|
161
|
+
|
|
162
|
+
function resumeIntervalsFor ({ resume, relay, since, until }) {
|
|
163
|
+
const pending = resume && Object.prototype.hasOwnProperty.call(resume, relay) ? resume[relay] : null
|
|
164
|
+
const intervals = Array.isArray(pending) ? pending : [{ start: since, end: until }]
|
|
165
|
+
return mergeRanges(intervals
|
|
166
|
+
.map(interval => ({
|
|
167
|
+
start: Math.max(since, Math.floor(Number(interval?.start))),
|
|
168
|
+
end: Math.min(until, Math.floor(Number(interval?.end)))
|
|
169
|
+
}))
|
|
170
|
+
.filter(interval => Number.isSafeInteger(interval.start) && Number.isSafeInteger(interval.end) && interval.end >= interval.start))
|
|
171
|
+
}
|
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, intersectRanges } from '../helpers/ranges.js'
|
|
47
47
|
import * as privateMessage from '../private-message/index.js'
|
|
48
48
|
import { isOnline } from '../network/index.js'
|
|
49
49
|
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
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,17 @@ 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
|
+
const DEFAULT_PRIORITY_TAIL_SECONDS = 6 * 3600
|
|
134
|
+
const DEFAULT_PRIORITY_TTL_MS = 10 * 60 * 1000
|
|
135
|
+
const DEFAULT_PRIORITY_HEDGE_DELAY_MS = 500
|
|
136
|
+
const DEFAULT_PRIORITY_HEDGE_RELAYS = 2
|
|
137
|
+
|
|
125
138
|
function textToBase64 (text) {
|
|
126
139
|
return bytesToBase64(encoder.encode(text))
|
|
127
140
|
}
|
|
@@ -204,6 +217,10 @@ export class PrivateMessenger {
|
|
|
204
217
|
reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
|
|
205
218
|
seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
|
|
206
219
|
seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
|
|
220
|
+
priorityTailSeconds = DEFAULT_PRIORITY_TAIL_SECONDS,
|
|
221
|
+
priorityTtlMs = DEFAULT_PRIORITY_TTL_MS,
|
|
222
|
+
priorityHedgeDelayMs = DEFAULT_PRIORITY_HEDGE_DELAY_MS,
|
|
223
|
+
priorityHedgeRelays = DEFAULT_PRIORITY_HEDGE_RELAYS,
|
|
207
224
|
maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
|
|
208
225
|
messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
|
|
209
226
|
seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
|
|
@@ -238,6 +255,19 @@ export class PrivateMessenger {
|
|
|
238
255
|
this.reloadGapDelayMs = reloadGapDelayMs
|
|
239
256
|
this.seederPresenceIntervalMs = seederPresenceIntervalMs
|
|
240
257
|
this.seederOnlineSeconds = seederOnlineSeconds
|
|
258
|
+
this.priorityTailSeconds = Number.isFinite(priorityTailSeconds) && priorityTailSeconds >= 0
|
|
259
|
+
? Math.floor(priorityTailSeconds)
|
|
260
|
+
: DEFAULT_PRIORITY_TAIL_SECONDS
|
|
261
|
+
this.priorityTtlMs = Number.isFinite(priorityTtlMs) && priorityTtlMs > 0
|
|
262
|
+
? Math.floor(priorityTtlMs)
|
|
263
|
+
: DEFAULT_PRIORITY_TTL_MS
|
|
264
|
+
this.priorityHedgeDelayMs = Number.isFinite(priorityHedgeDelayMs) && priorityHedgeDelayMs >= 0
|
|
265
|
+
? Math.floor(priorityHedgeDelayMs)
|
|
266
|
+
: DEFAULT_PRIORITY_HEDGE_DELAY_MS
|
|
267
|
+
const hedgeRelays = Math.floor(Number(priorityHedgeRelays))
|
|
268
|
+
this.priorityHedgeRelays = Number.isSafeInteger(hedgeRelays) && hedgeRelays >= 1
|
|
269
|
+
? Math.min(hedgeRelays, 3)
|
|
270
|
+
: DEFAULT_PRIORITY_HEDGE_RELAYS
|
|
241
271
|
this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
|
|
242
272
|
this.messageQueueMaxBytes = messageQueueMaxBytes
|
|
243
273
|
this.seedQueueMaxBytes = seedQueueMaxBytes
|
|
@@ -289,6 +319,9 @@ export class PrivateMessenger {
|
|
|
289
319
|
this.recoveries = new Map()
|
|
290
320
|
this.recoveryAdmission = createAbortableSemaphore(2)
|
|
291
321
|
this.recoveryControllers = new Set()
|
|
322
|
+
this.recoveryRanges = new Map()
|
|
323
|
+
this.recoveryPriority = new Map()
|
|
324
|
+
this.recoveryOnline = { at: 0, value: true }
|
|
292
325
|
this.resumeWork = null
|
|
293
326
|
this.capacityTimer = null
|
|
294
327
|
this.capacityCheck = null
|
|
@@ -664,6 +697,7 @@ export class PrivateMessenger {
|
|
|
664
697
|
|
|
665
698
|
if (removedPubkeys.length) {
|
|
666
699
|
await this.stampChannelActivity(removedPubkeys)
|
|
700
|
+
for (const pubkey of removedPubkeys) this.dropRecoveryState(pubkey)
|
|
667
701
|
}
|
|
668
702
|
|
|
669
703
|
const storageSnapshot = await activatePrivateMessengerStorage({
|
|
@@ -1174,7 +1208,347 @@ export class PrivateMessenger {
|
|
|
1174
1208
|
this.liveRecoveryTimers.delete(pubkey)
|
|
1175
1209
|
}
|
|
1176
1210
|
|
|
1211
|
+
async checkRecoveryOnline () {
|
|
1212
|
+
const now = Date.now()
|
|
1213
|
+
if (now - this.recoveryOnline.at < RECOVERY_ONLINE_CACHE_MS) return this.recoveryOnline.value
|
|
1214
|
+
let value = true
|
|
1215
|
+
try {
|
|
1216
|
+
if (globalThis.navigator?.onLine === false) value = false
|
|
1217
|
+
else if (this._isOnline !== isOnline || typeof globalThis.document !== 'undefined') {
|
|
1218
|
+
value = (await this._isOnline()) !== false
|
|
1219
|
+
}
|
|
1220
|
+
} catch { value = false }
|
|
1221
|
+
this.recoveryOnline = { at: Date.now(), value }
|
|
1222
|
+
return value
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
recoveryRangeKey (channelPubkey, range) {
|
|
1226
|
+
return `${channelPubkey}:${range.start}:${range.end}`
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
recoveryRangeRecord (channelPubkey, range) {
|
|
1230
|
+
const key = this.recoveryRangeKey(channelPubkey, range)
|
|
1231
|
+
let record = this.recoveryRanges.get(key)
|
|
1232
|
+
if (!record) {
|
|
1233
|
+
record = {
|
|
1234
|
+
key,
|
|
1235
|
+
channelPubkey,
|
|
1236
|
+
start: range.start,
|
|
1237
|
+
end: range.end,
|
|
1238
|
+
covered: [],
|
|
1239
|
+
claimed: [],
|
|
1240
|
+
oldestCreatedAt: null,
|
|
1241
|
+
relays: new Map(),
|
|
1242
|
+
tier: '',
|
|
1243
|
+
everEose: false,
|
|
1244
|
+
everEoseWithEvents: false,
|
|
1245
|
+
attempts: 0,
|
|
1246
|
+
lastErrors: []
|
|
1247
|
+
}
|
|
1248
|
+
this.recoveryRanges.set(key, record)
|
|
1249
|
+
}
|
|
1250
|
+
return record
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
reconcileRecoveryRelays (record, relays) {
|
|
1254
|
+
const current = new Set(relays)
|
|
1255
|
+
for (const relay of [...record.relays.keys()]) {
|
|
1256
|
+
if (!current.has(relay)) record.relays.delete(relay)
|
|
1257
|
+
}
|
|
1258
|
+
for (const relay of current) {
|
|
1259
|
+
if (record.relays.has(relay)) continue
|
|
1260
|
+
record.relays.set(relay, {
|
|
1261
|
+
pending: [{ start: record.start, end: record.end }],
|
|
1262
|
+
covered: [],
|
|
1263
|
+
attempts: 0,
|
|
1264
|
+
dead: false,
|
|
1265
|
+
eose: false,
|
|
1266
|
+
events: 0,
|
|
1267
|
+
error: null,
|
|
1268
|
+
consecutiveFailures: 0,
|
|
1269
|
+
latencyMs: null,
|
|
1270
|
+
lastEoseAt: 0,
|
|
1271
|
+
lastErrorAt: 0
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
orderedRecoveryRelays (record) {
|
|
1277
|
+
return [...record.relays.entries()]
|
|
1278
|
+
.filter(([, state]) => !state.dead && !state.eose && state.pending?.length)
|
|
1279
|
+
.sort((a, b) => {
|
|
1280
|
+
const failures = (a[1].consecutiveFailures || 0) - (b[1].consecutiveFailures || 0)
|
|
1281
|
+
if (failures) return failures
|
|
1282
|
+
const latency = (a[1].latencyMs ?? Infinity) - (b[1].latencyMs ?? Infinity)
|
|
1283
|
+
if (latency) return latency
|
|
1284
|
+
return (b[1].lastEoseAt || 0) - (a[1].lastEoseAt || 0)
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
recoveryRelayPlan (record) {
|
|
1289
|
+
const plan = {}
|
|
1290
|
+
const ordered = this.orderedRecoveryRelays(record)
|
|
1291
|
+
for (const [relay, state] of ordered) {
|
|
1292
|
+
plan[relay] = state.pending
|
|
1293
|
+
}
|
|
1294
|
+
return ordered.length ? plan : null
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
hasActiveRecoveryRelays (channelPubkey) {
|
|
1298
|
+
for (const record of this.recoveryRanges.values()) {
|
|
1299
|
+
if (record.channelPubkey !== channelPubkey) continue
|
|
1300
|
+
if (this.recoveryRecordActive(record)) return true
|
|
1301
|
+
}
|
|
1302
|
+
return false
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
recoveryRecordActive (record) {
|
|
1306
|
+
for (const state of record.relays.values()) {
|
|
1307
|
+
if (!state.dead && !state.eose && state.pending?.length) return true
|
|
1308
|
+
}
|
|
1309
|
+
return false
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
hasPendingRecovery (channelPubkey) {
|
|
1313
|
+
return (this.readState().channels[channelPubkey]?.offlineRanges || []).length > 0
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
dropRecoveryRanges (channelPubkey) {
|
|
1317
|
+
for (const [key, record] of this.recoveryRanges) {
|
|
1318
|
+
if (record.channelPubkey === channelPubkey) this.recoveryRanges.delete(key)
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
pruneRecoveryRanges (channelPubkey, ranges) {
|
|
1323
|
+
const keep = new Set((ranges || []).map(range => this.recoveryRangeKey(channelPubkey, range)))
|
|
1324
|
+
for (const [key, record] of this.recoveryRanges) {
|
|
1325
|
+
if (record.channelPubkey === channelPubkey && !keep.has(key)) this.recoveryRanges.delete(key)
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
dropRecoveryState (channelPubkey) {
|
|
1330
|
+
this.dropRecoveryRanges(channelPubkey)
|
|
1331
|
+
this.recoveryPriority.delete(channelPubkey)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
recoveryPriorities (channelPubkey) {
|
|
1335
|
+
const entries = this.recoveryPriority.get(channelPubkey) || []
|
|
1336
|
+
if (!entries.length) return []
|
|
1337
|
+
const now = Date.now()
|
|
1338
|
+
const active = entries.filter(entry => entry.expiresAt > now)
|
|
1339
|
+
if (active.length !== entries.length) {
|
|
1340
|
+
if (active.length) this.recoveryPriority.set(channelPubkey, active)
|
|
1341
|
+
else this.recoveryPriority.delete(channelPubkey)
|
|
1342
|
+
}
|
|
1343
|
+
return active
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async prioritizeRange (channelPubkey, { since, until, type = 'unread-page', ttlMs } = {}) {
|
|
1347
|
+
if (typeof channelPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(channelPubkey)) throw new ValidationError('INVALID_CHANNEL_PUBKEY')
|
|
1348
|
+
if (type !== 'unread-page' && type !== 'tail') throw new ValidationError('INVALID_PRIORITY_TYPE')
|
|
1349
|
+
if (since !== undefined && !Number.isSafeInteger(since)) throw new ValidationError('INVALID_PRIORITY_RANGE')
|
|
1350
|
+
if (until !== undefined && !Number.isSafeInteger(until)) throw new ValidationError('INVALID_PRIORITY_RANGE')
|
|
1351
|
+
if (type === 'unread-page' && since === undefined) throw new ValidationError('PRIORITY_SINCE_REQUIRED')
|
|
1352
|
+
|
|
1353
|
+
const now = nowSeconds()
|
|
1354
|
+
const ranges = (this.readState().channels[channelPubkey]?.offlineRanges || []).slice()
|
|
1355
|
+
if (!ranges.length) return false
|
|
1356
|
+
|
|
1357
|
+
let start = since
|
|
1358
|
+
let end = until
|
|
1359
|
+
if (type === 'unread-page') {
|
|
1360
|
+
const range = ranges.filter(candidate => candidate.end >= start).sort((a, b) => a.start - b.start)[0]
|
|
1361
|
+
if (!range) return false
|
|
1362
|
+
start = Math.max(start, range.start)
|
|
1363
|
+
if (end === undefined) end = Math.min(range.end, now)
|
|
1364
|
+
} else {
|
|
1365
|
+
const range = ranges.sort((a, b) => b.end - a.end)[0]
|
|
1366
|
+
if (end === undefined) end = Math.min(range.end, now)
|
|
1367
|
+
if (start === undefined) start = Math.max(range.start, end - this.priorityTailSeconds)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
|
|
1371
|
+
if (recoverySeconds) {
|
|
1372
|
+
start = Math.max(start, now - recoverySeconds)
|
|
1373
|
+
end = Math.min(end, now)
|
|
1374
|
+
}
|
|
1375
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return false
|
|
1376
|
+
|
|
1377
|
+
const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : this.priorityTtlMs
|
|
1378
|
+
const entries = this.recoveryPriority.get(channelPubkey) || []
|
|
1379
|
+
const createdAt = Date.now()
|
|
1380
|
+
if (!entries.some(entry => entry.start === start && entry.end === end && entry.type === type)) {
|
|
1381
|
+
entries.push({ start, end, type, createdAt, expiresAt: createdAt + ttl })
|
|
1382
|
+
this.recoveryPriority.set(channelPubkey, entries)
|
|
1383
|
+
}
|
|
1384
|
+
return true
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
updateRecoveryRecord (record, history, { completeRelays = new Set((history.relays || []).map(entry => entry.relay)) } = {}) {
|
|
1388
|
+
record.covered = mergeRanges([
|
|
1389
|
+
...record.covered,
|
|
1390
|
+
...(history.relays || []).flatMap(entry => entry.covered || [])
|
|
1391
|
+
])
|
|
1392
|
+
if (history.oldestCreatedAt != null) {
|
|
1393
|
+
record.oldestCreatedAt = record.oldestCreatedAt == null
|
|
1394
|
+
? history.oldestCreatedAt
|
|
1395
|
+
: Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
|
|
1396
|
+
}
|
|
1397
|
+
const byRelay = new Map((history.relays || []).map(entry => [entry.relay, entry]))
|
|
1398
|
+
for (const [relay, state] of record.relays) {
|
|
1399
|
+
const entry = byRelay.get(relay)
|
|
1400
|
+
if (!entry) continue
|
|
1401
|
+
const relayErrors = [...(entry.errors || [])]
|
|
1402
|
+
if (entry.error) relayErrors.push(entry.error)
|
|
1403
|
+
state.error = relayErrors[0] || null
|
|
1404
|
+
const done = entry.status === 'eose' && !(entry.pending?.length) && completeRelays.has(relay)
|
|
1405
|
+
state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
|
|
1406
|
+
state.eose = done
|
|
1407
|
+
state.pending = done
|
|
1408
|
+
? []
|
|
1409
|
+
: subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
|
|
1410
|
+
state.events += entry.events || 0
|
|
1411
|
+
if (Number.isFinite(entry.elapsedMs)) {
|
|
1412
|
+
state.latencyMs = state.latencyMs == null
|
|
1413
|
+
? entry.elapsedMs
|
|
1414
|
+
: Math.round(state.latencyMs * 0.7 + entry.elapsedMs * 0.3)
|
|
1415
|
+
}
|
|
1416
|
+
if (done) {
|
|
1417
|
+
state.attempts = 0
|
|
1418
|
+
state.consecutiveFailures = 0
|
|
1419
|
+
state.lastEoseAt = nowSeconds()
|
|
1420
|
+
} else if (entry.status === 'eose') {
|
|
1421
|
+
state.consecutiveFailures = 0
|
|
1422
|
+
state.lastEoseAt = nowSeconds()
|
|
1423
|
+
} else {
|
|
1424
|
+
state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
|
|
1425
|
+
state.lastErrorAt = nowSeconds()
|
|
1426
|
+
}
|
|
1427
|
+
if (entry.status === 'eose') {
|
|
1428
|
+
record.everEose = true
|
|
1429
|
+
if (state.events > 0) record.everEoseWithEvents = true
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
record.tier = record.everEoseWithEvents ? 'partial' : record.everEose ? 'emptyEose' : 'allFailed'
|
|
1433
|
+
record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
applyRecoveryCoverage (record, ranges) {
|
|
1437
|
+
if (!ranges?.length) return
|
|
1438
|
+
record.claimed = mergeRanges([...record.claimed, ...ranges])
|
|
1439
|
+
record.covered = mergeRanges([...record.covered, ...ranges])
|
|
1440
|
+
for (const state of record.relays.values()) {
|
|
1441
|
+
if (state.eose) continue
|
|
1442
|
+
state.pending = subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
activeRecoverySeeders (channelPubkey) {
|
|
1447
|
+
if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
|
|
1448
|
+
const channel = this.channels.get(channelPubkey)
|
|
1449
|
+
if (!channel?.signer) return []
|
|
1450
|
+
const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
|
|
1451
|
+
const cutoff = nowSeconds() - this.seederOnlineSeconds
|
|
1452
|
+
const candidates = new Set([...(channel.seeders || []), ...Object.keys(activity)])
|
|
1453
|
+
return [...candidates].filter(seeder => {
|
|
1454
|
+
if (!seeder || seeder === this.userPubkey) return false
|
|
1455
|
+
const entry = activity[seeder]
|
|
1456
|
+
const recent = Math.max(entry?.announcedAt || 0, entry?.lastActiveAt || 0)
|
|
1457
|
+
return recent >= cutoff
|
|
1458
|
+
})
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
prunePriorityEntries (channelPubkey) {
|
|
1462
|
+
const entries = this.recoveryPriorities(channelPubkey)
|
|
1463
|
+
if (!entries.length) return
|
|
1464
|
+
const records = [...this.recoveryRanges.values()].filter(record => record.channelPubkey === channelPubkey)
|
|
1465
|
+
const next = entries.filter(entry => {
|
|
1466
|
+
let intersects = false
|
|
1467
|
+
for (const record of records) {
|
|
1468
|
+
const parts = intersectRanges(
|
|
1469
|
+
[{ start: entry.start, end: entry.end }],
|
|
1470
|
+
[{ start: record.start, end: record.end }]
|
|
1471
|
+
)
|
|
1472
|
+
if (!parts.length) continue
|
|
1473
|
+
intersects = true
|
|
1474
|
+
if (subtractRanges(parts[0], record.covered).length) return true
|
|
1475
|
+
}
|
|
1476
|
+
return !intersects
|
|
1477
|
+
})
|
|
1478
|
+
if (next.length) this.recoveryPriority.set(channelPubkey, next)
|
|
1479
|
+
else this.recoveryPriority.delete(channelPubkey)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
applyRecoveryRetryBudget (record) {
|
|
1483
|
+
const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
|
|
1484
|
+
for (const state of record.relays.values()) {
|
|
1485
|
+
if (state.eose || state.dead || !state.pending?.length) continue
|
|
1486
|
+
state.attempts++
|
|
1487
|
+
if (state.attempts >= limit || isPermanentRecoveryError(state.error)) state.dead = true
|
|
1488
|
+
}
|
|
1489
|
+
record.attempts++
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
recoveryIncompleteError (record, history, range) {
|
|
1493
|
+
const errors = []
|
|
1494
|
+
for (const entry of history.relays || []) {
|
|
1495
|
+
for (const reason of entry.errors || []) errors.push({ relay: entry.relay, reason })
|
|
1496
|
+
if (entry.error) errors.push({ relay: entry.relay, reason: entry.error })
|
|
1497
|
+
}
|
|
1498
|
+
const error = incompleteFetchError({
|
|
1499
|
+
errors,
|
|
1500
|
+
report: (history.relays || []).map(entry => ({
|
|
1501
|
+
relay: entry.relay,
|
|
1502
|
+
status: entry.status,
|
|
1503
|
+
...(entry.error ? { error: entry.error } : {})
|
|
1504
|
+
})),
|
|
1505
|
+
receivedEventCount: history.receivedEventCount || 0,
|
|
1506
|
+
elapsedMs: history.elapsedMs || 0,
|
|
1507
|
+
request: {
|
|
1508
|
+
relays: [...record.relays.keys()],
|
|
1509
|
+
channelPubkeys: [record.channelPubkey],
|
|
1510
|
+
receiverPubkey: this.userPubkey,
|
|
1511
|
+
since: range.start,
|
|
1512
|
+
until: range.end,
|
|
1513
|
+
limit: 16,
|
|
1514
|
+
timeoutMs: 5000
|
|
1515
|
+
}
|
|
1516
|
+
})
|
|
1517
|
+
error.operation = 'private-channel.fetchHistory'
|
|
1518
|
+
return error
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
isPartialRecoveryHistory (history) {
|
|
1522
|
+
return Boolean(history) && !Array.isArray(history) &&
|
|
1523
|
+
Array.isArray(history.relays) &&
|
|
1524
|
+
history.pendingByRelay !== undefined &&
|
|
1525
|
+
typeof history.anyEose === 'boolean'
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async finalizeRecoveryRange ({ channelPubkey, range, record }) {
|
|
1529
|
+
if (!await this.checkRecoveryOnline()) return { complete: false, failures: [], offline: true }
|
|
1530
|
+
const uncovered = subtractRanges({ start: range.start, end: range.end }, record.covered)
|
|
1531
|
+
const oldest = record.oldestCreatedAt
|
|
1532
|
+
const leftEdge = oldest == null
|
|
1533
|
+
? [{ start: range.start, end: range.end }]
|
|
1534
|
+
: oldest > range.start
|
|
1535
|
+
? [{ start: range.start, end: Math.min(oldest, range.end) }]
|
|
1536
|
+
: []
|
|
1537
|
+
const seedRanges = mergeRanges([...uncovered, ...leftEdge])
|
|
1538
|
+
const failures = []
|
|
1539
|
+
for (const seedRange of seedRanges) {
|
|
1540
|
+
const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, seedRange.start, seedRange.end)
|
|
1541
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
1542
|
+
}
|
|
1543
|
+
record.lastErrors = failures
|
|
1544
|
+
return { complete: failures.length === 0, failures }
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1177
1547
|
scheduleLiveRecovery (pubkey) {
|
|
1548
|
+
return this.scheduleOfflineRecovery(pubkey)
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
scheduleOfflineRecovery (pubkey, { startDelayMs = 0 } = {}) {
|
|
1178
1552
|
const current = this.liveRecoveryTimers.get(pubkey)
|
|
1179
1553
|
if (current) { current.again = true; return }
|
|
1180
1554
|
const work = { delay: 1000, timer: null, again: false }
|
|
@@ -1199,7 +1573,11 @@ export class PrivateMessenger {
|
|
|
1199
1573
|
}
|
|
1200
1574
|
if (!isCurrent()) return
|
|
1201
1575
|
const pending = this.readState().channels[pubkey]?.offlineRanges?.length
|
|
1202
|
-
|
|
1576
|
+
let retry = null
|
|
1577
|
+
if (pending) {
|
|
1578
|
+
retry = recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4)))
|
|
1579
|
+
if (retry === null && this.hasActiveRecoveryRelays(pubkey)) retry = Math.min(30000, work.delay)
|
|
1580
|
+
}
|
|
1203
1581
|
if (work.again || retry !== null) {
|
|
1204
1582
|
schedule(work.again ? 0 : retry)
|
|
1205
1583
|
work.delay = Math.min(30000, work.delay * 2)
|
|
@@ -1207,7 +1585,7 @@ export class PrivateMessenger {
|
|
|
1207
1585
|
}, delay)
|
|
1208
1586
|
work.timer?.unref?.()
|
|
1209
1587
|
}
|
|
1210
|
-
schedule(
|
|
1588
|
+
schedule(startDelayMs)
|
|
1211
1589
|
}
|
|
1212
1590
|
|
|
1213
1591
|
recordInterruption (channels, at = nowSeconds()) {
|
|
@@ -1242,6 +1620,7 @@ export class PrivateMessenger {
|
|
|
1242
1620
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1243
1621
|
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1244
1622
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1623
|
+
for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
|
|
1245
1624
|
this.recordInterruption(pubkeys)
|
|
1246
1625
|
for (const controller of this.recoveryControllers) {
|
|
1247
1626
|
if (pubkeys.includes(controller.channelPubkey)) controller.abort()
|
|
@@ -1278,6 +1657,9 @@ export class PrivateMessenger {
|
|
|
1278
1657
|
if (this.pauseReasons.size || this.closePromise) return
|
|
1279
1658
|
await this.reconcilePresencePublishers()
|
|
1280
1659
|
await this.recoverOfflineRanges(channels)
|
|
1660
|
+
for (const pubkey of channels) {
|
|
1661
|
+
if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1662
|
+
}
|
|
1281
1663
|
for (const extension of this.extensions) await extension.resume?.()
|
|
1282
1664
|
})()
|
|
1283
1665
|
this.resumeWork = work
|
|
@@ -1348,6 +1730,9 @@ export class PrivateMessenger {
|
|
|
1348
1730
|
}
|
|
1349
1731
|
await this.watch(channelPubkeys, { scheduleReloadGap: false })
|
|
1350
1732
|
await this.recoverOfflineRanges(channelPubkeys)
|
|
1733
|
+
for (const pubkey of channelPubkeys) {
|
|
1734
|
+
if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
1735
|
+
}
|
|
1351
1736
|
}
|
|
1352
1737
|
|
|
1353
1738
|
async handleAsk (channelPubkey, message) {
|
|
@@ -1928,6 +2313,9 @@ export class PrivateMessenger {
|
|
|
1928
2313
|
}
|
|
1929
2314
|
this.writeState(state)
|
|
1930
2315
|
await this.flushStateWrites()
|
|
2316
|
+
for (const channel of channels) {
|
|
2317
|
+
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
|
|
2318
|
+
}
|
|
1931
2319
|
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
1932
2320
|
})
|
|
1933
2321
|
}
|
|
@@ -1968,6 +2356,9 @@ export class PrivateMessenger {
|
|
|
1968
2356
|
if ((this.watchRevisionByChannel.get(pubkey) || 0) !== revision) return
|
|
1969
2357
|
if (start != null) this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
|
|
1970
2358
|
try { await this.recoverOfflineRanges([pubkey]) } catch (err) { this.onError?.(err) }
|
|
2359
|
+
if (!this.closePromise && !this.pauseReasons.size && this.desiredChannels.has(pubkey) && this.hasPendingRecovery(pubkey)) {
|
|
2360
|
+
this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
|
|
2361
|
+
}
|
|
1971
2362
|
}, this.reloadGapDelayMs)
|
|
1972
2363
|
this.reloadGapTimers.set(pubkey, { timer, token, revision })
|
|
1973
2364
|
}
|
|
@@ -1998,15 +2389,15 @@ export class PrivateMessenger {
|
|
|
1998
2389
|
return asks
|
|
1999
2390
|
}
|
|
2000
2391
|
|
|
2001
|
-
async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
|
|
2392
|
+
async #askSeedersForMissingRangeAttempt (channelPubkey, since, until, { seeders } = {}) {
|
|
2002
2393
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
|
|
2003
2394
|
if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
|
|
2004
|
-
const
|
|
2005
|
-
if (!
|
|
2395
|
+
const candidates = seeders ?? this.recoverySeeders(channelPubkey)
|
|
2396
|
+
if (!candidates.length || until < since) return { asks: [], failures: [] }
|
|
2006
2397
|
|
|
2007
2398
|
const asks = []
|
|
2008
2399
|
const failures = []
|
|
2009
|
-
for (const seeder of
|
|
2400
|
+
for (const seeder of candidates) {
|
|
2010
2401
|
try {
|
|
2011
2402
|
const ask = await this.ask({
|
|
2012
2403
|
channelPubkey,
|
|
@@ -2152,24 +2543,176 @@ export class PrivateMessenger {
|
|
|
2152
2543
|
return failures
|
|
2153
2544
|
}
|
|
2154
2545
|
|
|
2546
|
+
recoveryFetchOptions ({ channelPubkey, channel, relays, since, until, signal, partial = false, resume = null }) {
|
|
2547
|
+
return {
|
|
2548
|
+
_acquirePage: pageSignal => this.recoveryAdmission.acquire(pageSignal),
|
|
2549
|
+
signal,
|
|
2550
|
+
receivedChunkScope: this.storageLeaseId,
|
|
2551
|
+
receiverSigner: this.userSigner,
|
|
2552
|
+
iykcSigner: this.contentKeySigner,
|
|
2553
|
+
privateChannelSigner: channel.signer,
|
|
2554
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
2555
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2556
|
+
privateChannelPubkeys: [channelPubkey],
|
|
2557
|
+
receiverPubkey: this.userPubkey,
|
|
2558
|
+
relays,
|
|
2559
|
+
since: Math.max(0, since),
|
|
2560
|
+
until,
|
|
2561
|
+
partial,
|
|
2562
|
+
resume,
|
|
2563
|
+
mode: channel.mode,
|
|
2564
|
+
modeByPubkey: { [channelPubkey]: channel.mode },
|
|
2565
|
+
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
2566
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
2567
|
+
onEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2568
|
+
onNymEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2569
|
+
onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
|
|
2570
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
|
|
2571
|
+
onError: err => { throw err }
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
async hedgedPriorityFetch ({ channelPubkey, channel, candidates, since, until }) {
|
|
2576
|
+
const launched = []
|
|
2577
|
+
let hedgeTimer = null
|
|
2578
|
+
let secondStarted = false
|
|
2579
|
+
const launch = candidate => {
|
|
2580
|
+
const controller = new AbortController()
|
|
2581
|
+
const promise = this._privateChannel
|
|
2582
|
+
.fetchHistory(this.recoveryFetchOptions({
|
|
2583
|
+
channelPubkey,
|
|
2584
|
+
channel,
|
|
2585
|
+
relays: [candidate.relay],
|
|
2586
|
+
since,
|
|
2587
|
+
until,
|
|
2588
|
+
signal: controller.signal,
|
|
2589
|
+
partial: true,
|
|
2590
|
+
resume: { [candidate.relay]: candidate.pending }
|
|
2591
|
+
}))
|
|
2592
|
+
.then(
|
|
2593
|
+
history => ({ ok: true, relay: candidate.relay, controller, history }),
|
|
2594
|
+
error => ({ ok: false, relay: candidate.relay, controller, error })
|
|
2595
|
+
)
|
|
2596
|
+
promise.catch(() => {})
|
|
2597
|
+
launched.push({ promise, controller })
|
|
2598
|
+
return promise
|
|
2599
|
+
}
|
|
2600
|
+
try {
|
|
2601
|
+
const first = launch(candidates[0])
|
|
2602
|
+
const hedge = candidates.length > 1
|
|
2603
|
+
? new Promise(resolve => {
|
|
2604
|
+
hedgeTimer = this._setTimeout(() => {
|
|
2605
|
+
secondStarted = true
|
|
2606
|
+
resolve(launch(candidates[1]))
|
|
2607
|
+
}, this.priorityHedgeDelayMs)
|
|
2608
|
+
})
|
|
2609
|
+
: null
|
|
2610
|
+
let settled = hedge ? await Promise.race([first, hedge]) : await first
|
|
2611
|
+
if (!settled.ok) {
|
|
2612
|
+
if (secondStarted && launched.length > 1) settled = await launched[launched.length - 1].promise
|
|
2613
|
+
else if (candidates.length > 1) settled = await launch(candidates[1])
|
|
2614
|
+
}
|
|
2615
|
+
if (!settled.ok) return null
|
|
2616
|
+
for (const entry of launched) {
|
|
2617
|
+
if (entry.controller === settled.controller) continue
|
|
2618
|
+
try { entry.controller.abort() } catch { /* aborted */ }
|
|
2619
|
+
}
|
|
2620
|
+
return settled
|
|
2621
|
+
} finally {
|
|
2622
|
+
if (hedgeTimer) this._clearTimeout(hedgeTimer)
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
async askPrioritySeeders (channelPubkey, intervals) {
|
|
2627
|
+
if (!intervals.length) return []
|
|
2628
|
+
if (!await this.checkRecoveryOnline()) return []
|
|
2629
|
+
const seeders = this.activeRecoverySeeders(channelPubkey)
|
|
2630
|
+
if (!seeders.length) return []
|
|
2631
|
+
const failures = []
|
|
2632
|
+
const claimed = []
|
|
2633
|
+
for (const interval of mergeRanges(intervals)) {
|
|
2634
|
+
const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, interval.start, interval.end, { seeders })
|
|
2635
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
2636
|
+
if (!attempt.failures.length) claimed.push(interval)
|
|
2637
|
+
}
|
|
2638
|
+
if (claimed.length) {
|
|
2639
|
+
for (const record of this.recoveryRanges.values()) {
|
|
2640
|
+
if (record.channelPubkey !== channelPubkey) continue
|
|
2641
|
+
const applied = intersectRanges(claimed, [{ start: record.start, end: record.end }])
|
|
2642
|
+
if (applied.length) this.applyRecoveryCoverage(record, applied)
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
return failures
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
async runPriorityLane (channelPubkey, channel, ranges) {
|
|
2649
|
+
const priorities = this.recoveryPriorities(channelPubkey)
|
|
2650
|
+
if (!priorities.length) return
|
|
2651
|
+
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2652
|
+
for (const range of ranges) {
|
|
2653
|
+
const record = this.recoveryRangeRecord(channelPubkey, range)
|
|
2654
|
+
this.reconcileRecoveryRelays(record, fetchRelays)
|
|
2655
|
+
const priorityIntervals = intersectRanges(
|
|
2656
|
+
priorities.map(entry => ({ start: entry.start, end: entry.end })),
|
|
2657
|
+
[{ start: range.start, end: range.end }]
|
|
2658
|
+
)
|
|
2659
|
+
if (!priorityIntervals.length) continue
|
|
2660
|
+
const pending = subtractRanges({ start: range.start, end: range.end }, record.covered)
|
|
2661
|
+
const wanted = intersectRanges(priorityIntervals, pending)
|
|
2662
|
+
if (!wanted.length) continue
|
|
2663
|
+
const candidates = this.orderedRecoveryRelays(record)
|
|
2664
|
+
.map(([relay, state]) => ({ relay, pending: intersectRanges(state.pending, wanted) }))
|
|
2665
|
+
.filter(candidate => candidate.pending.length)
|
|
2666
|
+
.slice(0, this.priorityHedgeRelays)
|
|
2667
|
+
if (candidates.length) {
|
|
2668
|
+
const winner = await this.hedgedPriorityFetch({
|
|
2669
|
+
channelPubkey,
|
|
2670
|
+
channel,
|
|
2671
|
+
candidates,
|
|
2672
|
+
since: wanted[0].start,
|
|
2673
|
+
until: wanted[wanted.length - 1].end
|
|
2674
|
+
})
|
|
2675
|
+
if (winner?.history) {
|
|
2676
|
+
if (this.isPartialRecoveryHistory(winner.history)) this.updateRecoveryRecord(record, winner.history, { completeRelays: new Set() })
|
|
2677
|
+
else this.applyRecoveryCoverage(record, wanted)
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const uncovered = intersectRanges(wanted, subtractRanges({ start: range.start, end: range.end }, record.covered))
|
|
2681
|
+
if (uncovered.length) await this.askPrioritySeeders(channelPubkey, uncovered)
|
|
2682
|
+
}
|
|
2683
|
+
this.prunePriorityEntries(channelPubkey)
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2155
2686
|
async recoverChannelRanges (channels) {
|
|
2156
2687
|
const failures = []
|
|
2157
2688
|
const state = this.readState()
|
|
2158
2689
|
const now = nowSeconds()
|
|
2690
|
+
const online = await this.checkRecoveryOnline()
|
|
2159
2691
|
|
|
2160
2692
|
for (const pubkey of uniq(channels)) {
|
|
2161
2693
|
const channel = this.channels.get(pubkey)
|
|
2162
2694
|
const current = state.channels[pubkey]
|
|
2163
|
-
if (!channel || !current?.offlineRanges?.length)
|
|
2695
|
+
if (!channel || !current?.offlineRanges?.length) {
|
|
2696
|
+
this.dropRecoveryState(pubkey)
|
|
2697
|
+
continue
|
|
2698
|
+
}
|
|
2164
2699
|
const recoverySeconds = this.offlineRecoverySecondsFor(channel)
|
|
2165
|
-
if (!recoverySeconds)
|
|
2700
|
+
if (!recoverySeconds) {
|
|
2701
|
+
this.dropRecoveryState(pubkey)
|
|
2702
|
+
continue
|
|
2703
|
+
}
|
|
2166
2704
|
const minStart = now - recoverySeconds
|
|
2167
2705
|
const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
|
|
2168
2706
|
|
|
2707
|
+
if (online) {
|
|
2708
|
+
try { await this.runPriorityLane(pubkey, channel, current.offlineRanges) } catch (error) { this.onError?.(error) }
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2169
2711
|
const remaining = []
|
|
2170
2712
|
let recoveredThrough = current.recoveredThrough || 0
|
|
2171
2713
|
for (const range of current.offlineRanges) {
|
|
2172
2714
|
if (range.end < minStart) continue
|
|
2715
|
+
if (!online) { remaining.push(range); continue }
|
|
2173
2716
|
const watchRevision = this.watchRevisionByChannel.get(pubkey) || 0
|
|
2174
2717
|
const controller = new AbortController()
|
|
2175
2718
|
controller.channelPubkey = pubkey
|
|
@@ -2178,38 +2721,65 @@ export class PrivateMessenger {
|
|
|
2178
2721
|
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
|
|
2179
2722
|
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2180
2723
|
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
|
-
|
|
2724
|
+
const record = this.recoveryRangeRecord(pubkey, range)
|
|
2725
|
+
this.reconcileRecoveryRelays(record, fetchRelays)
|
|
2726
|
+
const plan = this.recoveryRelayPlan(record)
|
|
2727
|
+
if (plan) {
|
|
2728
|
+
const history = await this._privateChannel.fetchHistory(this.recoveryFetchOptions({
|
|
2729
|
+
channelPubkey: pubkey,
|
|
2730
|
+
channel,
|
|
2731
|
+
relays: Object.keys(plan),
|
|
2732
|
+
since: range.start,
|
|
2733
|
+
until: range.end,
|
|
2734
|
+
signal: controller.signal,
|
|
2735
|
+
partial: true,
|
|
2736
|
+
resume: plan
|
|
2737
|
+
}))
|
|
2738
|
+
controller.signal.throwIfAborted()
|
|
2739
|
+
if (this.isPartialRecoveryHistory(history)) {
|
|
2740
|
+
this.updateRecoveryRecord(record, history)
|
|
2741
|
+
this.applyRecoveryRetryBudget(record)
|
|
2742
|
+
if ((history.relays || []).some(entry => entry.status !== 'eose' || entry.pending?.length)) {
|
|
2743
|
+
const error = this.recoveryIncompleteError(record, history, range)
|
|
2744
|
+
this.onError?.(error)
|
|
2745
|
+
failures.push(error)
|
|
2746
|
+
}
|
|
2747
|
+
} else {
|
|
2748
|
+
// Legacy fetch seam (tests/custom channels): resolves only when
|
|
2749
|
+
// the whole range was read, so treat it as fully relay-covered.
|
|
2750
|
+
record.covered = mergeRanges([...record.covered, { start: range.start, end: range.end }])
|
|
2751
|
+
if (history?.oldestCreatedAt != null) {
|
|
2752
|
+
record.oldestCreatedAt = record.oldestCreatedAt == null
|
|
2753
|
+
? history.oldestCreatedAt
|
|
2754
|
+
: Math.min(record.oldestCreatedAt, history.oldestCreatedAt)
|
|
2755
|
+
}
|
|
2756
|
+
for (const state of record.relays.values()) {
|
|
2757
|
+
state.eose = true
|
|
2758
|
+
state.pending = []
|
|
2759
|
+
}
|
|
2760
|
+
record.tier = 'partial'
|
|
2761
|
+
record.lastErrors = []
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2207
2764
|
const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2208
2765
|
(this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2766
|
+
if (lifecycleChanged) {
|
|
2767
|
+
remaining.push(range)
|
|
2768
|
+
continue
|
|
2769
|
+
}
|
|
2770
|
+
if (this.recoveryRecordActive(record)) {
|
|
2771
|
+
remaining.push(range)
|
|
2772
|
+
continue
|
|
2773
|
+
}
|
|
2774
|
+
const result = await this.finalizeRecoveryRange({ channelPubkey: pubkey, range, record })
|
|
2775
|
+
failures.push(...result.failures)
|
|
2776
|
+
if (result.complete) recoveredThrough = Math.max(recoveredThrough, range.end)
|
|
2777
|
+
else remaining.push(range)
|
|
2212
2778
|
} catch (err) {
|
|
2779
|
+
const record = this.recoveryRanges.get(this.recoveryRangeKey(pubkey, range))
|
|
2780
|
+
if (record && isPermanentRecoveryError(err)) {
|
|
2781
|
+
for (const state of record.relays.values()) state.dead = true
|
|
2782
|
+
}
|
|
2213
2783
|
if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
|
|
2214
2784
|
failures.push(err)
|
|
2215
2785
|
remaining.push(range)
|
|
@@ -2223,6 +2793,7 @@ export class PrivateMessenger {
|
|
|
2223
2793
|
recoveredThrough: Math.max(fresh.channels[pubkey]?.recoveredThrough || 0, recoveredThrough),
|
|
2224
2794
|
offlineRanges: mergeRanges(concurrentRanges.concat(remaining))
|
|
2225
2795
|
}
|
|
2796
|
+
this.pruneRecoveryRanges(pubkey, remaining)
|
|
2226
2797
|
this.writeState(fresh)
|
|
2227
2798
|
await this.flushStateWrites()
|
|
2228
2799
|
}
|
|
@@ -2232,6 +2803,7 @@ export class PrivateMessenger {
|
|
|
2232
2803
|
async clearChannel (pubkey) {
|
|
2233
2804
|
await this.unwatch(pubkey)
|
|
2234
2805
|
await this._privateMessage.clearChannelState?.(pubkey)
|
|
2806
|
+
this.dropRecoveryState(pubkey)
|
|
2235
2807
|
return this.runQueueOperation(async () => {
|
|
2236
2808
|
this.channels.delete(pubkey)
|
|
2237
2809
|
this.removeChannelState(pubkey)
|
|
@@ -2265,6 +2837,7 @@ export class PrivateMessenger {
|
|
|
2265
2837
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
2266
2838
|
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
|
|
2267
2839
|
}
|
|
2840
|
+
for (const pubkey of stalePubkeys) this.dropRecoveryState(pubkey)
|
|
2268
2841
|
this.state = state
|
|
2269
2842
|
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
2270
2843
|
})
|
|
@@ -2355,22 +2928,14 @@ export class PrivateMessenger {
|
|
|
2355
2928
|
temporaryStorageArea: this.temporaryStorageArea
|
|
2356
2929
|
})
|
|
2357
2930
|
}
|
|
2931
|
+
this.recoveryRanges.clear()
|
|
2932
|
+
this.recoveryPriority.clear()
|
|
2358
2933
|
if (unwatchError) throw unwatchError
|
|
2359
2934
|
})()
|
|
2360
2935
|
return this.closePromise
|
|
2361
2936
|
}
|
|
2362
2937
|
}
|
|
2363
2938
|
|
|
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
2939
|
function eventType (event) {
|
|
2375
2940
|
if (event.kind === privateMessage.ASK_KIND) return 'ask'
|
|
2376
2941
|
if (event.kind === privateMessage.REPLY_KIND) return 'reply'
|
|
@@ -17,9 +17,17 @@ key published after an earlier miss. Set it to `false` when the signer owns that
|
|
|
17
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
18
18
|
|
|
19
19
|
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
20
|
-
`enqueue`, `retry`, `cancel`, `download`,
|
|
20
|
+
`prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
|
|
21
|
+
`cancelDownload`, and `close`.
|
|
21
22
|
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
22
23
|
given peers (default: current peers); it is best-effort and never rejects.
|
|
24
|
+
`prioritizeRange` registers an in-memory priority window for a conversation:
|
|
25
|
+
`type: 'unread-page'` requires `since` (the read anchor), while `type: 'tail'`
|
|
26
|
+
defaults to the newest persisted range minus `priorityTailSeconds` (6 hours).
|
|
27
|
+
The coordinator resolves the peer's channel and queues the request until the
|
|
28
|
+
channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
|
|
29
|
+
covered, and are dropped with recovery state. They are process-local and reset
|
|
30
|
+
on reload.
|
|
23
31
|
Callbacks are `onOutbox`, `onMedia`,
|
|
24
32
|
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
25
33
|
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
@@ -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,8 @@ 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()
|
|
49
|
+
const pendingPriorityRanges = new Map()
|
|
40
50
|
let downloadWriteTail = Promise.resolve()
|
|
41
51
|
const writeDownloadIntent = operation => {
|
|
42
52
|
const work = downloadWriteTail.catch(() => {}).then(operation)
|
|
@@ -72,18 +82,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
72
82
|
const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
|
|
73
83
|
channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
|
|
74
84
|
channels.set(peer, channel)
|
|
75
|
-
} catch (error) { if (!retryable(error)) deniedPeers.add(peer);
|
|
85
|
+
} catch (error) { if (!retryable(error)) deniedPeers.add(peer); reportError(error); continue }
|
|
76
86
|
}
|
|
77
87
|
if (closed || !available || version !== lifecycle) return
|
|
78
88
|
values.push(channel)
|
|
79
89
|
}
|
|
80
|
-
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError })
|
|
90
|
+
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
|
|
81
91
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
82
92
|
await messenger.update({ channels: values })
|
|
93
|
+
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
94
|
+
for (const [peer, list] of [...pendingPriorityRanges]) {
|
|
95
|
+
const channel = channels.get(peer)
|
|
96
|
+
if (!channel) continue
|
|
97
|
+
for (const request of list) {
|
|
98
|
+
try { await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error) }
|
|
99
|
+
}
|
|
100
|
+
pendingPriorityRanges.delete(peer)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
83
103
|
await messenger.resume('signer')
|
|
84
104
|
if (!fileTransfers) {
|
|
85
105
|
fileTransfers = FileTransfer({
|
|
86
|
-
messenger, onError,
|
|
106
|
+
messenger, onError: reportError,
|
|
87
107
|
resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
|
|
88
108
|
storage: chunkStorage,
|
|
89
109
|
seedStorage: recoveryStorage?.seeds,
|
|
@@ -95,20 +115,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
95
115
|
onMedia({ ...state, peer })
|
|
96
116
|
if (state.status === 'complete') {
|
|
97
117
|
const id = intentId({ peer, root: state.root })
|
|
98
|
-
if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(
|
|
118
|
+
if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(reportError)
|
|
99
119
|
}
|
|
100
120
|
}
|
|
101
121
|
})
|
|
102
122
|
downloads = await openDownloads({ owner, signer })
|
|
103
123
|
for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
|
|
104
124
|
}
|
|
105
|
-
for (const entry of downloadIntents.values())
|
|
125
|
+
for (const entry of downloadIntents.values()) {
|
|
126
|
+
if (!peers.has(entry.file.peer) || activeDownloads.has(entry.id)) continue
|
|
127
|
+
const work = download(entry.file, { manual: true })
|
|
128
|
+
.catch(error => {
|
|
129
|
+
// A failed intent stays queued for the next configure; report each
|
|
130
|
+
// real attempt once even if configure runs while it is in flight.
|
|
131
|
+
if (downloadIntents.has(entry.id)) reportError(error)
|
|
132
|
+
})
|
|
133
|
+
.finally(() => { if (activeDownloads.get(entry.id) === work) activeDownloads.delete(entry.id) })
|
|
134
|
+
activeDownloads.set(entry.id, work)
|
|
135
|
+
}
|
|
106
136
|
drain(); pump()
|
|
107
137
|
}
|
|
108
138
|
function schedule () {
|
|
109
139
|
const work = configuring.catch(() => {}).then(configure)
|
|
110
140
|
configuring = work
|
|
111
|
-
work.catch(
|
|
141
|
+
work.catch(reportError)
|
|
112
142
|
return work
|
|
113
143
|
}
|
|
114
144
|
async function drain () {
|
|
@@ -130,7 +160,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
130
160
|
const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
|
|
131
161
|
if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
|
|
132
162
|
if (event.kind === 34601) {
|
|
133
|
-
try { decodeIrfsChunk(event) } catch (error) {
|
|
163
|
+
try { decodeIrfsChunk(event) } catch (error) { reportError(error); await ack(); continue }
|
|
134
164
|
}
|
|
135
165
|
if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
|
|
136
166
|
if (!available || closed) { await nack(); break }
|
|
@@ -139,7 +169,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
139
169
|
await ack()
|
|
140
170
|
} catch (error) {
|
|
141
171
|
await nack()
|
|
142
|
-
|
|
172
|
+
reportError(error)
|
|
143
173
|
// Retain the failed record and suspend network ingestion until recovery.
|
|
144
174
|
await messenger.pause('storage')
|
|
145
175
|
break
|
|
@@ -218,8 +248,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
218
248
|
} catch (error) {
|
|
219
249
|
if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
|
|
220
250
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
221
|
-
await storage.put(entry, { existing: true }).catch(
|
|
222
|
-
|
|
251
|
+
await storage.put(entry, { existing: true }).catch(reportError)
|
|
252
|
+
reportError(error)
|
|
223
253
|
// The failed wire event may be a quote or file chunk. Report the
|
|
224
254
|
// owning outbox item separately, without mutating the native error.
|
|
225
255
|
if (error.retryWhenOnline) offlineSends.add(entry.id)
|
|
@@ -303,6 +333,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
303
333
|
return {}
|
|
304
334
|
}
|
|
305
335
|
},
|
|
336
|
+
async prioritizeRange (peer, options = {}) {
|
|
337
|
+
if (!/^[0-9a-f]{64}$/.test(peer || '')) throw new TypeError('INVALID_PRIORITY_PEER')
|
|
338
|
+
const { since, until, type = 'unread-page' } = options || {}
|
|
339
|
+
if (type !== 'unread-page' && type !== 'tail') throw new TypeError('INVALID_PRIORITY_TYPE')
|
|
340
|
+
if (since !== undefined && !Number.isSafeInteger(since)) throw new TypeError('INVALID_PRIORITY_RANGE')
|
|
341
|
+
if (until !== undefined && !Number.isSafeInteger(until)) throw new TypeError('INVALID_PRIORITY_RANGE')
|
|
342
|
+
if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
|
|
343
|
+
const request = { since, until, type }
|
|
344
|
+
const channel = channels.get(peer)
|
|
345
|
+
if (channel && typeof messenger?.prioritizeRange === 'function') {
|
|
346
|
+
try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
|
|
347
|
+
}
|
|
348
|
+
const list = pendingPriorityRanges.get(peer) || []
|
|
349
|
+
list.push(request)
|
|
350
|
+
pendingPriorityRanges.set(peer, list)
|
|
351
|
+
return true
|
|
352
|
+
},
|
|
306
353
|
async setPeers (values) {
|
|
307
354
|
peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
|
|
308
355
|
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|
|
@@ -348,14 +395,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
348
395
|
},
|
|
349
396
|
async close () {
|
|
350
397
|
closed = true; available = false; lifecycle++
|
|
351
|
-
stopOnlineRetry(); offlineSends.clear()
|
|
398
|
+
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
352
399
|
for (const controller of sendControllers.values()) controller.abort()
|
|
353
400
|
await Promise.allSettled([configuring, initialized])
|
|
354
401
|
await messenger?.pause('closed')
|
|
355
402
|
if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
|
|
356
403
|
await messenger?.close()
|
|
357
404
|
await storage?.close()
|
|
358
|
-
await downloadWriteTail.catch(
|
|
405
|
+
await downloadWriteTail.catch(reportError)
|
|
359
406
|
await downloads?.close()
|
|
360
407
|
}
|
|
361
408
|
}
|