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