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.
@@ -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: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
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: recoveryStorage?.seeds,
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 (peer, event, signal) {
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 options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer], signal }
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 storage.put(entry, { existing: true })
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
- // Read/publish one chunk at a time. Progress is durable after each
447
- // accepted chunk; replay after interruption keeps its original identity.
448
- for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
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({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
452
- for (; entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES); entry.chunkIndex++) {
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
- if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
457
- await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner), { signal: controller.signal })
458
- const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
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
- await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
463
- // Let the independent local stage finish newly admitted texts before
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) || (pending.peer !== owner && !peers.has(pending.peer))) continue
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
- for (; entry.index < entry.events.length; entry.index++) {
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.peer, event, controller.signal)
538
+ await publish(entry, event, controller.signal)
484
539
  debug(entry, 'publication-accepted', startedAt)
485
540
  await active()
486
- // The final durable operation is removal. Avoid encrypting a checkpoint
487
- // that would immediately be deleted; earlier context stages still checkpoint.
488
- if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
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)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
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 storage.put(entry, { existing: true }).catch(reportError)
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
- if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
548
- if (stopOnline) return
549
- // A fresh listener receives confirmed connectivity even when a brief outage
550
- // occurred between the app's long-lived monitor checks.
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); emit(); pump()
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) { entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
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.delete(id); localReady.delete(id)
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)