libp2r2p 0.11.24 → 0.11.26
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 +13 -4
- package/network/README.md +31 -0
- package/network/index.js +55 -13
- package/package.json +1 -1
- package/private-channel/index.js +75 -67
- package/private-message/index.js +26 -7
- package/private-messenger/file/index.js +5 -5
- package/private-messenger/helpers/fallback-race.js +3 -2
- package/private-messenger/helpers/pause-recovery.js +4 -1
- package/private-messenger/helpers/send-routing.js +3 -2
- package/private-messenger/index.js +29 -10
- package/private-messenger/session/README.md +36 -2
- package/private-messenger/session/helpers/publication-journal.js +137 -0
- package/private-messenger/session/index.js +154 -48
|
@@ -14,6 +14,7 @@ import { createChatOutbox } from './helpers/work-storage.js'
|
|
|
14
14
|
import { messengerSigner } from './helpers/signer.js'
|
|
15
15
|
import { assertMessagePublished } from '../helpers/publication.js'
|
|
16
16
|
import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
|
|
17
|
+
import { createPublicationJournal, withPendingSeeds } from './helpers/publication-journal.js'
|
|
17
18
|
import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
|
|
18
19
|
|
|
19
20
|
export function wireEvent (value, owner) {
|
|
@@ -44,7 +45,7 @@ function messengerStatusSnapshot (value) {
|
|
|
44
45
|
return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
48
|
+
export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, _now = () => performance.now(), openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
|
|
48
49
|
fallbackRelays = normalizeFallbackRelays(fallbackRelays)
|
|
49
50
|
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
50
51
|
if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
|
|
@@ -65,18 +66,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
65
66
|
const channels = new Map()
|
|
66
67
|
const entries = new Map()
|
|
67
68
|
const cancelled = new Set()
|
|
69
|
+
const attemptStarts = new Map()
|
|
70
|
+
const networkUrgency = new Map()
|
|
68
71
|
const sendControllers = new Map()
|
|
69
72
|
const localReady = new Set()
|
|
70
73
|
const localIdleWaiters = new Set()
|
|
71
74
|
const cleanups = new Map()
|
|
72
75
|
const queuedAt = new WeakMap()
|
|
73
|
-
const debug = (entry, stage, startedAt) => {
|
|
76
|
+
const debug = (entry, stage, startedAt, coordinates = {}) => {
|
|
74
77
|
if (!onDebug) return
|
|
75
|
-
try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
|
|
78
|
+
try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...coordinates, ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
|
|
76
79
|
}
|
|
77
80
|
const offlineSends = new Set()
|
|
78
81
|
const pausedSends = new Set()
|
|
79
82
|
let messengerStatus
|
|
83
|
+
let networkPauseStartedAt
|
|
84
|
+
let networkConfirmed = false
|
|
80
85
|
let messengerBinding
|
|
81
86
|
let storageBlocked = false
|
|
82
87
|
const inboxRecovery = createPauseRecovery({
|
|
@@ -87,22 +92,31 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
87
92
|
let inboxError
|
|
88
93
|
const stateChanged = state => {
|
|
89
94
|
if (closed) return
|
|
95
|
+
const wasNetworkPaused = messengerStatus?.pauseReasons.includes('network')
|
|
90
96
|
messengerStatus = messengerStatusSnapshot(state)
|
|
97
|
+
if (!wasNetworkPaused && state.pauseReasons.includes('network')) { networkPauseStartedAt = _now(); networkConfirmed = false }
|
|
98
|
+
if (wasNetworkPaused && !state.pauseReasons.includes('network')) {
|
|
99
|
+
for (const entry of entries.values()) if (entry.peer !== owner && entry.status !== 'saved') debug(entry, 'network-pause-released', undefined, { elapsedMs: _now() - networkPauseStartedAt })
|
|
100
|
+
networkPauseStartedAt = undefined; networkConfirmed = false
|
|
101
|
+
}
|
|
91
102
|
if (!available || state.closed) return
|
|
92
103
|
if (state.paused) {
|
|
93
104
|
for (const entry of entries.values()) {
|
|
94
105
|
if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
|
|
95
106
|
}
|
|
96
107
|
if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
|
|
108
|
+
watchOfflineSends()
|
|
97
109
|
return
|
|
98
110
|
}
|
|
99
111
|
clearSendRetry()
|
|
100
|
-
for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
|
|
112
|
+
for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
|
|
101
113
|
pausedSends.clear()
|
|
114
|
+
watchOfflineSends()
|
|
102
115
|
// The callback can run inside construction/resume, before configure settles.
|
|
103
116
|
queueMicrotask(() => pump())
|
|
104
117
|
}
|
|
105
118
|
let stopOnline
|
|
119
|
+
let requestedUrgency = 0
|
|
106
120
|
let sendRetryTimer
|
|
107
121
|
let sendRetryDelay = SEND_RETRY_MIN_MS
|
|
108
122
|
let messenger
|
|
@@ -139,11 +153,31 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
139
153
|
let redrain = false
|
|
140
154
|
const idleWaiters = new Set()
|
|
141
155
|
const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
|
|
142
|
-
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
|
|
156
|
+
const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion && !entry.cancelled))
|
|
157
|
+
const journal = createPublicationJournal({
|
|
158
|
+
entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
|
|
159
|
+
archive: async (entry, rows) => {
|
|
160
|
+
if (recoveryStorage?.seeds) { for (const row of rows) await recoveryStorage.seeds.put(row) } else if (entry.prepared.target.file) {
|
|
161
|
+
for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
|
|
162
|
+
} else {
|
|
163
|
+
for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
changed: entry => {
|
|
167
|
+
watchOfflineSends()
|
|
168
|
+
if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
|
|
169
|
+
else if (!closed && available) pumpRemote()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
const seedStorage = recoveryStorage?.seeds ? withPendingSeeds(recoveryStorage.seeds, () => journal.pendingRows()) : undefined
|
|
143
173
|
const ready = () => (initialized ??= (async () => {
|
|
144
174
|
storage = await openOutbox({ owner, signer, onDebug })
|
|
145
175
|
for (const entry of await storage.list()) {
|
|
176
|
+
// Old encrypted entries retain progress and enter this same pipeline.
|
|
177
|
+
if (entry.cancelled) cancelled.add(entry.id)
|
|
146
178
|
entries.set(entry.id, entry)
|
|
179
|
+
journal.adopt(entry)
|
|
180
|
+
attemptStarts.set(entry.id, performance.now())
|
|
147
181
|
if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
|
|
148
182
|
queuedAt.set(entry, Date.now())
|
|
149
183
|
}
|
|
@@ -156,7 +190,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
156
190
|
let candidate
|
|
157
191
|
try {
|
|
158
192
|
candidate = await Messenger({
|
|
159
|
-
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage:
|
|
193
|
+
fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage, _pendingSeedRecords: () => journal.pendingRows(), userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
|
|
160
194
|
onMessageQueued: () => { if (messengerBinding === binding) return drain() },
|
|
161
195
|
onStateChanged: state => {
|
|
162
196
|
if (closed || messengerBinding !== binding) return
|
|
@@ -236,7 +270,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
236
270
|
fileSetupWork = setupFiles().catch(error => { fileSetupWork = null; if (!closed) reportError(error) })
|
|
237
271
|
}
|
|
238
272
|
fileSetupWork.then(() => { if (!closed && available) restoreDownloads() }).catch(reportError)
|
|
239
|
-
drain(); pump()
|
|
273
|
+
journal.wake(); drain(); pump()
|
|
240
274
|
}
|
|
241
275
|
async function setupFiles () {
|
|
242
276
|
if (!fileTransfers) {
|
|
@@ -244,7 +278,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
244
278
|
messenger, onError: reportError,
|
|
245
279
|
resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
|
|
246
280
|
storage: chunkStorage,
|
|
247
|
-
seedStorage
|
|
281
|
+
seedStorage,
|
|
248
282
|
authorizationStorage: recoveryStorage?.authorizations
|
|
249
283
|
})
|
|
250
284
|
fileTransfers.observe(state => {
|
|
@@ -368,10 +402,24 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
368
402
|
if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
|
|
369
403
|
}
|
|
370
404
|
}
|
|
371
|
-
async function publish (
|
|
405
|
+
async function publish (entry, event, signal) {
|
|
406
|
+
const peer = entry.peer
|
|
372
407
|
if (preparation.error(peer)) throw preparation.error(peer)
|
|
373
408
|
if (!available || !peers.has(peer) || !channels.has(peer)) throw unavailableError(new Error('CHAT_UNAVAILABLE'), signal)
|
|
374
|
-
const
|
|
409
|
+
const channelPubkey = channels.get(peer)?.pubkey
|
|
410
|
+
const retention = mode === 'seeder' || mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(channelPubkey) ?? 0 : 0
|
|
411
|
+
const preparationStartedAt = Date.now()
|
|
412
|
+
const options = {
|
|
413
|
+
channelPubkey, receiverPubkeys: [peer], signal, attemptStartedAt: attemptStarts.get(entry.id),
|
|
414
|
+
onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
|
|
415
|
+
preparedPublication: entry.prepared?.publication,
|
|
416
|
+
onPreparedSeed: async seed => {
|
|
417
|
+
debug(entry, 'envelope-prepared', preparationStartedAt, { publicationId: seed.publication.events[0].id })
|
|
418
|
+
await journal.stage(entry, seed, { index: entry.index }, retention)
|
|
419
|
+
signal.throwIfAborted()
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
debug(entry, 'envelope-preparation')
|
|
375
423
|
let report
|
|
376
424
|
try {
|
|
377
425
|
report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
|
|
@@ -383,10 +431,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
383
431
|
await assertMessagePublished(report, event)
|
|
384
432
|
}
|
|
385
433
|
async function cleanupEntry (entry) {
|
|
434
|
+
if (entry.prepared) { journal.wake(); return }
|
|
386
435
|
if (cleanups.has(entry.id)) return cleanups.get(entry.id)
|
|
387
436
|
const startedAt = Date.now()
|
|
388
|
-
const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
|
|
389
|
-
entries.delete(entry.id); localReady.delete(entry.id)
|
|
437
|
+
const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
|
|
438
|
+
entries.delete(entry.id); localReady.delete(entry.id); attemptStarts.delete(entry.id)
|
|
390
439
|
debug(entry, 'outbox-cleanup', startedAt)
|
|
391
440
|
emit()
|
|
392
441
|
}).catch(error => {
|
|
@@ -399,6 +448,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
399
448
|
return work
|
|
400
449
|
}
|
|
401
450
|
async function sendEntry (entry, localOnly = false) {
|
|
451
|
+
if (entry.status === 'saved') return
|
|
402
452
|
const releaseForeground = foreground.enter()
|
|
403
453
|
entry.status = 'pending'; emit()
|
|
404
454
|
const controller = new AbortController()
|
|
@@ -429,7 +479,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
429
479
|
} else {
|
|
430
480
|
// Retry this checkpoint even when a preceding attempt saved all copies
|
|
431
481
|
// but failed to persist progress. Only then admit remote publication.
|
|
432
|
-
await
|
|
482
|
+
await journal.write(entry)
|
|
433
483
|
await active()
|
|
434
484
|
localReady.add(entry.id)
|
|
435
485
|
}
|
|
@@ -443,49 +493,55 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
443
493
|
if (entry.peer !== owner && messengerStatus?.paused) {
|
|
444
494
|
throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
|
|
445
495
|
}
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
for (; entry.
|
|
496
|
+
if (entry.prepared?.accepted) { journal.wake(); return }
|
|
497
|
+
// One prepared unit per entry. Archive waits yield this entry, not the pump.
|
|
498
|
+
for (; entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
|
|
449
499
|
const file = entry.files[entry.fileIndex]
|
|
500
|
+
const descriptor = { controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }
|
|
450
501
|
await active()
|
|
451
|
-
await fileTransfers.authorizeSeeding(
|
|
452
|
-
|
|
502
|
+
await fileTransfers.authorizeSeeding(descriptor, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
|
|
503
|
+
while (entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES)) {
|
|
504
|
+
const chunkStartedAt = performance.now()
|
|
453
505
|
await active()
|
|
454
506
|
const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
|
|
455
507
|
if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
508
|
+
const recover = mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(descriptor.controlChannelPubkey) ?? 0 : 0
|
|
509
|
+
const target = { file: descriptor, fileIndex: entry.fileIndex, chunkIndex: entry.chunkIndex }
|
|
510
|
+
await fileTransfers.publishChunk(descriptor, wireEvent(chunk, owner), {
|
|
511
|
+
signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublication: entry.prepared?.publication,
|
|
512
|
+
onPreparedSeed: async seed => { await journal.stage(entry, seed, target, recover); controller.signal.throwIfAborted() }
|
|
513
|
+
})
|
|
514
|
+
entry.chunkIndex++
|
|
515
|
+
const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, entry.chunkIndex * IRFS_CHUNK_BYTES)
|
|
459
516
|
entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
|
|
460
517
|
emit()
|
|
461
518
|
await active()
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// selecting publications at this chunk boundary. It never awaits us.
|
|
519
|
+
if (entry.prepared) { journal.accept(entry); return }
|
|
520
|
+
await journal.write(entry)
|
|
465
521
|
if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
|
|
466
522
|
await pumpLocal()
|
|
467
523
|
if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
|
|
468
524
|
}
|
|
469
|
-
// Newly queued text and deletion controls can pass an in-flight file.
|
|
470
525
|
let admitted = 0
|
|
471
526
|
for (const pending of entries.values()) {
|
|
472
527
|
if (closed || !available || admitted >= 8) break
|
|
473
|
-
if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) ||
|
|
528
|
+
if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || pending.prepared?.accepted || !peers.has(pending.peer)) continue
|
|
474
529
|
admitted++
|
|
475
530
|
await sendEntry(pending)
|
|
476
531
|
}
|
|
477
532
|
}
|
|
478
533
|
}
|
|
479
|
-
|
|
534
|
+
while (entry.index < entry.events.length) {
|
|
480
535
|
await active()
|
|
481
536
|
const event = entry.events[entry.index]
|
|
482
537
|
const startedAt = Date.now()
|
|
483
|
-
await publish(entry
|
|
538
|
+
await publish(entry, event, controller.signal)
|
|
484
539
|
debug(entry, 'publication-accepted', startedAt)
|
|
485
540
|
await active()
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (entry.
|
|
541
|
+
entry.index++
|
|
542
|
+
if (entry.index === entry.events.length) { entry.status = 'saved'; emit() }
|
|
543
|
+
if (entry.prepared) { journal.accept(entry); return }
|
|
544
|
+
if (entry.index < entry.events.length) await journal.write(entry)
|
|
489
545
|
}
|
|
490
546
|
entry.status = 'saved'; emit()
|
|
491
547
|
cleanupEntry(entry)
|
|
@@ -493,9 +549,12 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
493
549
|
// Pauses can surface at any stage (text publication, file authorization
|
|
494
550
|
// or chunk delivery). Normalize all of them into the retryable path.
|
|
495
551
|
const error = cause?.retryWhenAvailable ? cause : (isUnavailableSend(cause) ? unavailableError(cause, controller.signal) : cause)
|
|
496
|
-
if (cancelled.has(entry.id)) {
|
|
552
|
+
if (cancelled.has(entry.id)) {
|
|
553
|
+
entry.cancelled = true
|
|
554
|
+
if (entry.prepared) { await journal.write(entry); journal.wake() } else { await storage.remove(entry.id); entries.delete(entry.id) }
|
|
555
|
+
} else {
|
|
497
556
|
entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
|
|
498
|
-
await
|
|
557
|
+
await journal.write(entry).catch(reportError)
|
|
499
558
|
if (entry.peer !== owner && error.retryWhenAvailable && messenger?.readStatus().paused) pausedSends.add(entry.id)
|
|
500
559
|
else if (isUnavailableSend(cause) && cause.pauseReasons?.length && available && !closed) {
|
|
501
560
|
// Release can race persistence of the failed attempt. Do not lose
|
|
@@ -513,7 +572,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
513
572
|
emit()
|
|
514
573
|
} finally { sendControllers.delete(entry.id); releaseForeground() }
|
|
515
574
|
}
|
|
516
|
-
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
|
|
575
|
+
function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined; requestedUrgency = 0 }
|
|
517
576
|
// Transient availability failures without an active messenger pause retain
|
|
518
577
|
// bounded retries. Observed pauses wait exclusively for their release.
|
|
519
578
|
function clearSendRetry () {
|
|
@@ -536,6 +595,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
536
595
|
for (const entry of entries.values()) {
|
|
537
596
|
if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
|
|
538
597
|
entry.failed = false
|
|
598
|
+
attemptStarts.set(entry.id, performance.now())
|
|
539
599
|
if (entry.status === 'saved') { cleanupEntry(entry); continue }
|
|
540
600
|
retried = true
|
|
541
601
|
}
|
|
@@ -544,21 +604,47 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
544
604
|
sendRetryTimer.unref?.()
|
|
545
605
|
}
|
|
546
606
|
function watchOfflineSends () {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
607
|
+
const waiting = [...entries.values()].filter(entry => entry.peer !== owner && peers.has(entry.peer) && entry.status !== 'saved' && !entry.cancelled && !cancelled.has(entry.id) && !entry.prepared?.accepted && (!entry.failed || pendingRetryable(entry)) &&
|
|
608
|
+
(offlineSends.has(entry.id) || (!networkConfirmed && messengerStatus?.pauseReasons.includes('network'))))
|
|
609
|
+
for (const id of networkUrgency.keys()) {
|
|
610
|
+
const entry = entries.get(id)
|
|
611
|
+
if (!entry || entry.status === 'saved' || entry.cancelled || cancelled.has(id)) networkUrgency.delete(id)
|
|
612
|
+
}
|
|
613
|
+
if (closed || !available || !waiting.length) { stopOnlineRetry(); return }
|
|
614
|
+
const deadline = Math.max(0, ...waiting.map(entry => networkUrgency.get(entry.id) ?? 0))
|
|
615
|
+
if (stopOnline && deadline === requestedUrgency) return
|
|
616
|
+
const previous = stopOnline
|
|
617
|
+
requestedUrgency = deadline
|
|
618
|
+
const startedAt = _now()
|
|
619
|
+
// Replacing a subscription retains the shared loop and its backoff. This is
|
|
620
|
+
// only done when a new user action extends the window, never per chunk/retry.
|
|
551
621
|
stopOnline = _onOnline(() => {
|
|
552
622
|
stopOnlineRetry()
|
|
553
623
|
if (closed || !available) return
|
|
624
|
+
networkConfirmed = true
|
|
625
|
+
for (const entry of waiting) debug(entry, 'network-confirmed', undefined, { elapsedMs: _now() - startedAt })
|
|
554
626
|
for (const id of offlineSends) {
|
|
555
627
|
const entry = entries.get(id)
|
|
556
|
-
if (entry?.retryable) entry.failed = false
|
|
628
|
+
if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
|
|
557
629
|
}
|
|
558
630
|
offlineSends.clear()
|
|
631
|
+
// The messenger's automatic recovery owns its network pause; the same
|
|
632
|
+
// monitor also notifies that recovery job. Never resume explicit pauses.
|
|
559
633
|
return schedule()
|
|
634
|
+
}, {
|
|
635
|
+
expediteForMs: Math.max(0, deadline - _now()),
|
|
636
|
+
...(onDebug
|
|
637
|
+
? {
|
|
638
|
+
onDebug: measure => {
|
|
639
|
+
const entry = waiting[0]
|
|
640
|
+
debug(entry, measure.stage, undefined, measure)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
: {})
|
|
560
644
|
})
|
|
645
|
+
previous?.()
|
|
561
646
|
}
|
|
647
|
+
|
|
562
648
|
async function pumpLocal () {
|
|
563
649
|
if (!available || closed || !storage) return
|
|
564
650
|
if (localSaving) { relocal = true; return }
|
|
@@ -586,6 +672,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
586
672
|
repump = false
|
|
587
673
|
for (const entry of entries.values()) {
|
|
588
674
|
if (closed || !available) break
|
|
675
|
+
if (entry.prepared?.accepted) { journal.wake(); continue }
|
|
589
676
|
if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
|
|
590
677
|
if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
|
|
591
678
|
preparation.request(entry.peer).catch(() => {})
|
|
@@ -690,6 +777,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
690
777
|
const next = new Set([...values].filter(peer => peer !== owner))
|
|
691
778
|
if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
|
|
692
779
|
peers.clear(); for (const peer of next) peers.add(peer)
|
|
780
|
+
watchOfflineSends()
|
|
693
781
|
preparation.reconcile(peers)
|
|
694
782
|
if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
|
|
695
783
|
pendingDecryptionPeer = null
|
|
@@ -709,24 +797,30 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
709
797
|
const changed = available !== (value === true)
|
|
710
798
|
available = value === true
|
|
711
799
|
if (!changed && !available) return
|
|
712
|
-
stopOnlineRetry(); offlineSends.clear()
|
|
800
|
+
if (changed || !available) { stopOnlineRetry(); offlineSends.clear() }
|
|
801
|
+
if (!available) { networkUrgency.clear(); networkConfirmed = false }
|
|
713
802
|
if (available) resetSendRetry()
|
|
714
803
|
else clearSendRetry()
|
|
715
804
|
if (changed) lifecycle++
|
|
716
805
|
if (!available) {
|
|
806
|
+
journal.setAvailable(false)
|
|
717
807
|
preparation.setAvailable(false)
|
|
718
808
|
for (const peer of channels.keys()) if (!preparation.get(peer)) channels.delete(peer)
|
|
719
809
|
inboxRecovery.stop(); await messenger?.pause('signer'); return
|
|
720
810
|
}
|
|
721
811
|
await ready()
|
|
812
|
+
journal.setAvailable(true)
|
|
722
813
|
pumpLocal()
|
|
723
|
-
for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
|
|
814
|
+
for (const entry of entries.values()) if (pendingRetryable(entry)) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
|
|
724
815
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
725
816
|
if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
|
|
726
817
|
return schedule()
|
|
727
818
|
},
|
|
728
|
-
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
|
|
819
|
+
async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
|
|
729
820
|
if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
|
|
821
|
+
if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
|
|
822
|
+
const urgencyUntil = _now() + 30000
|
|
823
|
+
const urgencyLifecycle = lifecycle
|
|
730
824
|
const releaseForeground = foreground.enter()
|
|
731
825
|
enqueuing++
|
|
732
826
|
try {
|
|
@@ -741,40 +835,52 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
741
835
|
await storage.put(entry)
|
|
742
836
|
debug(entry, 'outbox-stored', startedAt)
|
|
743
837
|
queuedAt.set(entry, Date.now())
|
|
838
|
+
if (attemptStartedAt !== undefined) attemptStarts.set(id, attemptStartedAt)
|
|
744
839
|
if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
|
|
745
840
|
// An accepted durable intent survives shutdown/locking for replay. Only
|
|
746
841
|
// explicit cancellation removes it; no late callback or work is admitted.
|
|
747
842
|
if (closed) return id
|
|
748
|
-
entries.set(id, entry)
|
|
843
|
+
entries.set(id, entry)
|
|
844
|
+
if (peer !== owner && available && lifecycle === urgencyLifecycle) networkUrgency.set(id, urgencyUntil)
|
|
845
|
+
watchOfflineSends(); emit(); pump()
|
|
749
846
|
if (available && peer !== owner) preparePeer(peer).catch(() => {})
|
|
750
847
|
return id
|
|
751
848
|
} finally { releaseForeground(); enqueuing--; settled() }
|
|
752
849
|
},
|
|
753
850
|
async retry (id) {
|
|
754
|
-
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
755
851
|
if (storageBlocked) inboxRecovery.start({ immediate: true })
|
|
756
852
|
await ready()
|
|
757
853
|
const entry = entries.get(id)
|
|
758
|
-
if (entry)
|
|
854
|
+
if (!entry) return
|
|
855
|
+
if (entry.peer !== owner && entry.status !== 'saved' && available) networkUrgency.set(id, _now() + 30000)
|
|
856
|
+
// Renew while the old wait is still subscribed, preserving the monitor's
|
|
857
|
+
// generation/backoff even if this session is its only consumer.
|
|
858
|
+
watchOfflineSends()
|
|
859
|
+
offlineSends.delete(id); pausedSends.delete(id)
|
|
860
|
+
attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry()
|
|
861
|
+
if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {})
|
|
862
|
+
return pump()
|
|
759
863
|
},
|
|
760
864
|
async cancel (id) {
|
|
761
865
|
cancelled.add(id)
|
|
762
866
|
sendControllers.get(id)?.abort()
|
|
763
867
|
offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
|
|
764
868
|
await ready()
|
|
765
|
-
entries.
|
|
766
|
-
await storage.remove(id)
|
|
869
|
+
const entry = entries.get(id)
|
|
870
|
+
if (entry?.prepared) { entry.cancelled = true; await journal.write(entry); journal.wake() } else { entries.delete(id); localReady.delete(id); await storage.remove(id) }
|
|
767
871
|
emit()
|
|
768
872
|
},
|
|
769
873
|
close () {
|
|
770
874
|
return (closeWork ??= (async () => {
|
|
771
875
|
closed = true; available = false; lifecycle++
|
|
772
|
-
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
876
|
+
stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
773
877
|
clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
|
|
774
878
|
for (const controller of sendControllers.values()) controller.abort()
|
|
775
879
|
await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
|
|
776
880
|
await messenger?.pause('closed')
|
|
777
881
|
if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
|
|
882
|
+
await journal.close()
|
|
883
|
+
if (cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
|
|
778
884
|
await messenger?.close()
|
|
779
885
|
await storage?.close()
|
|
780
886
|
await downloadWriteTail.catch(reportError)
|