libp2r2p 0.11.16 → 0.11.17

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 CHANGED
@@ -1126,3 +1126,10 @@ are unchanged; newer live arrivals cannot erase a pending interval.
1126
1126
  count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
1127
1127
  serialized volume, not heap use. Diagnostics never retain event contents or
1128
1128
  filters. Existing 1,000-item / 8-MiB live limits are unchanged.
1129
+
1130
+ Private messenger status (`readStatus`, `onStateChanged`) exposes pause reasons
1131
+ without events or credentials. Sessions recover owned network/storage failures
1132
+ and wake pending sends on release rather than repeatedly publishing while paused.
1133
+ Custom session factories must expose `readStatus()` and deliver the initial and
1134
+ subsequent `onStateChanged` snapshots; missing observation is rejected instead of
1135
+ using polling compatibility. See [session lifecycle](private-messenger/session/README.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.16",
3
+ "version": "0.11.17",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -0,0 +1,80 @@
1
+ // Local persistence is not a relay failure. Retry operational storage failures,
2
+ // but leave validation/permission failures to their owner or an explicit retry.
3
+ export const isRecoverableStorageFailure = error => error != null && typeof error === 'object' && error?.name !== 'ValidationError' && error?.category !== 'validation' &&
4
+ !['SecurityError', 'NotAllowedError', 'TypeError', 'RangeError', 'SyntaxError', 'DataError', 'DataCloneError'].includes(error?.name) &&
5
+ !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|NOT_IN_PERSONA/.test(`${error?.code || ''} ${error?.name || ''} ${error?.message || ''}`)
6
+
7
+ // One cancellable job per cause. Waiting offline does not spend a backoff step.
8
+ export function createPauseRecovery ({ attempt, online, onOnline, retryable, onError, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout, random = Math.random }) {
9
+ let current
10
+ function stop () {
11
+ const job = current
12
+ current = null
13
+ if (!job) return
14
+ job.controller.abort()
15
+ if (job.timer != null) clearTimer(job.timer)
16
+ job.stopOnline?.()
17
+ }
18
+ function start ({ immediate = false, retryAt = 0 } = {}) {
19
+ const advice = Number.isFinite(retryAt) && retryAt > 0 ? retryAt : 0
20
+ if (current) {
21
+ if (advice > current.retryAt) { current.retryAt = advice; current.wake?.() }
22
+ return immediate ? current.retry?.() : current.work
23
+ }
24
+ const job = { controller: new AbortController(), delay: 1000, retryAt: advice, timer: null, nextAt: 0, wake: null, retry: null, stopOnline: null, work: null }
25
+ current = job
26
+ const active = () => current === job && !job.controller.signal.aborted
27
+ const schedule = delay => {
28
+ if (!active()) return
29
+ job.nextAt = Math.max(now() + delay, job.retryAt)
30
+ job.timer = setTimer(() => { job.timer = null; return run() }, Math.max(job.nextAt - now(), 0))
31
+ job.timer?.unref?.()
32
+ }
33
+ const run = () => {
34
+ if (!active() || job.work) return job.work
35
+ const work = (async () => {
36
+ if (online && !await online({ signal: job.controller.signal })) {
37
+ if (!active()) return
38
+ job.stopOnline = onOnline(() => {
39
+ if (!active()) return
40
+ job.stopOnline?.(); job.stopOnline = null
41
+ schedule(0)
42
+ })
43
+ return
44
+ }
45
+ if (!active()) return
46
+ try {
47
+ await attempt(job.controller.signal)
48
+ if (active()) stop()
49
+ } catch (error) {
50
+ if (!active()) return
51
+ onError(error)
52
+ if (!retryable(error)) { stop(); return }
53
+ job.retryAt = Math.max(job.retryAt, Number.isFinite(error?.retryAt) ? error.retryAt : 0)
54
+ schedule(Math.min(30000, job.delay * (0.8 + random() * 0.4)))
55
+ job.delay = Math.min(30000, job.delay * 2)
56
+ }
57
+ })().catch(error => { if (active()) { onError(error); stop() } }).finally(() => { if (job.work === work) job.work = null })
58
+ job.work = work
59
+ return work
60
+ }
61
+ job.wake = () => {
62
+ if (!active() || job.work) return job.work
63
+ if (job.timer != null) clearTimer(job.timer)
64
+ job.timer = null
65
+ if (job.nextAt > now()) { schedule(job.nextAt - now()); return }
66
+ return run()
67
+ }
68
+ job.retry = () => {
69
+ if (!active() || job.work) return job.work
70
+ if (job.timer != null) clearTimer(job.timer)
71
+ job.timer = null
72
+ if (job.retryAt > now()) { schedule(job.retryAt - now()); return }
73
+ return run()
74
+ }
75
+ if (immediate) return run()
76
+ schedule(1000)
77
+ job.delay = 2000
78
+ }
79
+ return { start, stop, wake: () => current?.wake?.() }
80
+ }
@@ -45,7 +45,9 @@
45
45
  import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
46
46
  import { mergeRanges, subtractRanges, intersectRanges } from '../helpers/ranges.js'
47
47
  import * as privateMessage from '../private-message/index.js'
48
- import { isOnline } from '../network/index.js'
48
+ import { isOnline, onOnline } from '../network/index.js'
49
+ import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
+ import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
49
51
  import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
50
52
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
51
53
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
@@ -231,6 +233,7 @@ export class PrivateMessenger {
231
233
  useContentKeys = true,
232
234
  onContentKeyChange,
233
235
  onMessageQueued,
236
+ onStateChanged,
234
237
  onDebug,
235
238
  onError = defaultOnError,
236
239
  _privateMessage = privateMessage,
@@ -239,6 +242,8 @@ export class PrivateMessenger {
239
242
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
240
243
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
241
244
  _isOnline = isOnline,
245
+ _onOnline = onOnline,
246
+ _random = Math.random,
242
247
  _setTimeout = globalThis.setTimeout.bind(globalThis),
243
248
  _clearTimeout = globalThis.clearTimeout.bind(globalThis),
244
249
  _setInterval = globalThis.setInterval.bind(globalThis),
@@ -247,6 +252,7 @@ export class PrivateMessenger {
247
252
  _storageClearInterval = globalThis.clearInterval.bind(globalThis),
248
253
  _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
249
254
  } = {}) {
255
+ if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
250
256
  this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
251
257
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
252
258
  this.staleChannelSeconds = normalizeStaleChannelSeconds(staleChannelSeconds)
@@ -277,6 +283,16 @@ export class PrivateMessenger {
277
283
  this._indexedDB = _indexedDB
278
284
  this.useContentKeys = useContentKeys
279
285
  this.onContentKeyChange = onContentKeyChange
286
+ this.onStateChanged = onStateChanged
287
+ this.statusFingerprint = null
288
+ this.automaticPauses = new Set()
289
+ this.pendingStorageWrites = new Map()
290
+ this.pauseRecoveries = new Map()
291
+ this.watchRecoveries = new Map()
292
+ this.pauseOnlineWork = null
293
+ this.pauseOnlineController = null
294
+ this._onOnline = _onOnline
295
+ this._random = _random
280
296
  this.onMessageQueued = onMessageQueued
281
297
  this.onDebug = onDebug
282
298
  this.onError = onError
@@ -351,6 +367,7 @@ export class PrivateMessenger {
351
367
  this.closePromise = null
352
368
  this.initSettledPromise = null
353
369
  this.initialized = false
370
+ this.notifyStatus()
354
371
  }
355
372
 
356
373
  async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
@@ -663,6 +680,7 @@ export class PrivateMessenger {
663
680
  relays = [],
664
681
  mode = 'leecher'
665
682
  } = options
683
+ const readerIdentityChanged = userSigner !== this.userSigner || contentKeySigner !== this.contentKeySigner || nymSigner !== this.nymSigner
666
684
  const updatesStalePolicy = Object.hasOwn(options, 'staleChannelSeconds')
667
685
  const updatesIdentityPolicy = Object.hasOwn(options, 'identityStorageRetentionSeconds')
668
686
  let nextStaleChannelSeconds = updatesStalePolicy
@@ -683,7 +701,12 @@ export class PrivateMessenger {
683
701
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
684
702
  this.assertOpen()
685
703
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
686
- const watchPubkeys = [...nextPubkeys].filter(pubkey => !this.channels.has(pubkey) || this.desiredChannels.has(pubkey))
704
+ const changedChannel = channel => {
705
+ const previous = this.channels.get(channel.pubkey)
706
+ return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
707
+ ['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
708
+ }
709
+ const watchPubkeys = nextChannels.filter(channel => !this.channels.has(channel.pubkey) || (this.desiredChannels.has(channel.pubkey) && (readerIdentityChanged || Object.hasOwn(options, 'staleChannelSeconds') || Object.hasOwn(options, 'identityStorageRetentionSeconds') || changedChannel(channel) || !this.stopByChannel.has(channel.pubkey)))).map(channel => channel.pubkey)
687
710
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
688
711
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
689
712
 
@@ -1118,31 +1141,53 @@ export class PrivateMessenger {
1118
1141
  // Neither a crash nor a newly received message may erase the initial scan.
1119
1142
  this.recordRecoveryWindow(pubkey)
1120
1143
  await this.flushStateWrites()
1121
- const watchRelays = await this.resolveWatchRelays(channel)
1122
- this.assertOpen()
1123
- if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1124
- const stop = await this._privateMessage.watch({
1125
- channels: [pubkey],
1126
- relays: watchRelays,
1127
- receiverSigner: this.userSigner,
1128
- iykcSigner: this.contentKeySigner,
1129
- privateChannelSigner: channel.signer,
1130
- privateChannelReaderSigner: channel.readerSigner,
1131
- privateChannelReaderPubkey: channel.readerPubkey,
1132
- mode: channel.mode,
1133
- onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
1134
- onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
1135
- onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
1136
- onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
1137
- onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
1138
- onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1139
- onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1140
- onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1141
- receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1142
- receivedChunkIndexedDB: this._indexedDB,
1143
- onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1144
- onError: err => this.onError?.(err)
1145
- })
1144
+ let stop; let watchRelays
1145
+ try {
1146
+ watchRelays = await this.resolveWatchRelays(channel)
1147
+ this.assertOpen()
1148
+ if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1149
+ stop = await this._privateMessage.watch({
1150
+ channels: [pubkey],
1151
+ relays: watchRelays,
1152
+ receiverSigner: this.userSigner,
1153
+ iykcSigner: this.contentKeySigner,
1154
+ privateChannelSigner: channel.signer,
1155
+ privateChannelReaderSigner: channel.readerSigner,
1156
+ privateChannelReaderPubkey: channel.readerPubkey,
1157
+ mode: channel.mode,
1158
+ onAsk: message => this.receive(pubkey, () => this.handleAsk(pubkey, message), message),
1159
+ onReply: message => this.receive(pubkey, () => this.handleReply(pubkey, message), message),
1160
+ onTell: message => this.receive(pubkey, () => this.handleTell(pubkey, message), message),
1161
+ onYell: message => this.receive(pubkey, () => this.handleYell(pubkey, message), message),
1162
+ onNym: message => this.receive(pubkey, () => this.handleNym(pubkey, message), message),
1163
+ onMessage: message => this.receive(pubkey, () => this.handleMessage(pubkey, message), message),
1164
+ onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1165
+ onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1166
+ receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1167
+ receivedChunkIndexedDB: this._indexedDB,
1168
+ onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
1169
+ onError: err => this.onError?.(err)
1170
+ })
1171
+ } catch (error) {
1172
+ const transient = isRetryableRelayFailure(error)
1173
+ const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
1174
+ if (!transient && !remote) throw error
1175
+ this.onError?.(error)
1176
+ if (!transient) continue
1177
+ if (!this.watchRecoveries.has(pubkey)) {
1178
+ const task = this.recoveryTask(async signal => {
1179
+ signal.throwIfAborted()
1180
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
1181
+ await this.watch([pubkey])
1182
+ if (!this.stopByChannel.has(pubkey)) throw error
1183
+ }, { network: true, retryable: isRetryableRelayFailure })
1184
+ this.watchRecoveries.set(pubkey, task)
1185
+ }
1186
+ this.watchRecoveries.get(pubkey).start({ retryAt: error.retryAt })
1187
+ continue
1188
+ }
1189
+ this.watchRecoveries.get(pubkey)?.stop()
1190
+ this.watchRecoveries.delete(pubkey)
1146
1191
  if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
1147
1192
  await stop?.()
1148
1193
  this.assertOpen()
@@ -1182,6 +1227,8 @@ export class PrivateMessenger {
1182
1227
  if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
1183
1228
  const start = Math.min(interrupted.get(relay) ?? Infinity, since)
1184
1229
  interrupted.set(relay, start)
1230
+ this.watchRecoveries.get(pubkey)?.stop()
1231
+ this.watchRecoveries.delete(pubkey)
1185
1232
  this.cancelReloadGap(pubkey)
1186
1233
  for (const controller of this.recoveryControllers) {
1187
1234
  if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
@@ -1620,6 +1667,8 @@ export class PrivateMessenger {
1620
1667
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1621
1668
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1622
1669
  for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1670
+ for (const [key, pending] of this.pendingStorageWrites) if (pubkeys.includes(pending.record.channelPubkey)) this.pendingStorageWrites.delete(key)
1671
+ this.syncPauseRecovery()
1623
1672
  for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
1624
1673
  this.recordInterruption(pubkeys)
1625
1674
  for (const controller of this.recoveryControllers) {
@@ -1628,10 +1677,110 @@ export class PrivateMessenger {
1628
1677
  return Promise.all([this.stopWatches(pubkeys), this.flushStateWrites()])
1629
1678
  }
1630
1679
 
1680
+ readStatus () {
1681
+ return { closed: Boolean(this.closePromise), paused: this.pauseReasons.size > 0, pauseReasons: [...this.pauseReasons].sort() }
1682
+ }
1683
+
1684
+ notifyStatus () {
1685
+ const status = this.readStatus()
1686
+ const fingerprint = JSON.stringify(status)
1687
+ if (fingerprint !== this.statusFingerprint) {
1688
+ this.statusFingerprint = fingerprint
1689
+ try { this.onStateChanged?.(status) } catch (error) { this.onError?.(error) }
1690
+ }
1691
+ this.syncPauseRecovery()
1692
+ }
1693
+
1694
+ pausedError () {
1695
+ return Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: this.readStatus().pauseReasons })
1696
+ }
1697
+
1698
+ recoveryTask (attempt, { network = false, retryable = isRecoverableStorageFailure } = {}) {
1699
+ return createPauseRecovery({
1700
+ attempt, retryable, onError: error => this.onError?.(error),
1701
+ ...(network ? { online: () => this.checkPauseOnline(), onOnline: this._onOnline } : {}),
1702
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout, random: this._random
1703
+ })
1704
+ }
1705
+
1706
+ checkPauseOnline () {
1707
+ if (!this.pauseOnlineWork) {
1708
+ const controller = new AbortController()
1709
+ this.pauseOnlineController = controller
1710
+ const work = Promise.resolve().then(() => this._isOnline({ signal: controller.signal })).finally(() => {
1711
+ if (this.pauseOnlineWork === work) { this.pauseOnlineWork = null; this.pauseOnlineController = null }
1712
+ })
1713
+ this.pauseOnlineWork = work
1714
+ }
1715
+ return this.pauseOnlineWork
1716
+ }
1717
+
1718
+ syncPauseRecovery () {
1719
+ if (!this.pauseRecoveries) return
1720
+ const blocked = this.closePromise || !this.desiredChannels.size ||
1721
+ [...this.pauseReasons].some(reason => !['network', 'storage', 'capacity', 'session-storage'].includes(reason))
1722
+ for (const [reason, task] of this.pauseRecoveries) {
1723
+ if (blocked || !this.automaticPauses.has(reason) || !this.pauseReasons.has(reason)) task.stop()
1724
+ }
1725
+ if (blocked) {
1726
+ this.pauseOnlineController?.abort()
1727
+ this.pauseOnlineWork = null; this.pauseOnlineController = null
1728
+ return
1729
+ }
1730
+ for (const reason of this.automaticPauses) {
1731
+ if (!this.pauseReasons.has(reason)) continue
1732
+ let task = this.pauseRecoveries.get(reason)
1733
+ if (!task) {
1734
+ task = this.recoveryTask(async signal => {
1735
+ if (reason === 'storage') await this.retryStorageWrites(signal)
1736
+ await this.flushStateWrites()
1737
+ signal.throwIfAborted()
1738
+ await this.resume(reason)
1739
+ }, {
1740
+ network: reason === 'network', retryable: error => {
1741
+ const retry = reason === 'network' ? isRetryableRelayFailure(error) : isRecoverableStorageFailure(error)
1742
+ if (!retry) this.automaticPauses.delete(reason)
1743
+ return retry
1744
+ }
1745
+ })
1746
+ this.pauseRecoveries.set(reason, task)
1747
+ }
1748
+ task.start()
1749
+ }
1750
+ }
1751
+
1752
+ async retryStorageWrites (signal) {
1753
+ for (const [key, pending] of this.pendingStorageWrites) {
1754
+ signal.throwIfAborted()
1755
+ const pubkey = pending.record.channelPubkey
1756
+ if (!this.channels.has(pubkey) || !this.desiredChannels.has(pubkey)) { this.pendingStorageWrites.delete(key); continue }
1757
+ try {
1758
+ if (!pending.dedupeKey || !await this.queue.someBy('byChannelTypeEventId', pending.dedupeKey)) await this.queue.enqueue(pending.record)
1759
+ } catch (error) { if (error?.name !== 'ConstraintError') throw error }
1760
+ signal.throwIfAborted()
1761
+ this.pendingStorageWrites.delete(key)
1762
+ this.markSeen(pubkey, pending.at || nowSeconds())
1763
+ this.wakeDeliveries()
1764
+ this.onMessageQueued?.()
1765
+ }
1766
+ }
1767
+
1768
+ pauseInternally (reason) {
1769
+ if (!this.pauseReasons.has(reason) || this.automaticPauses.has(reason)) this.automaticPauses.add(reason)
1770
+ return this.applyPause(reason)
1771
+ }
1772
+
1631
1773
  pause (reason) {
1774
+ // An explicit caller takes ownership, even if this cause already exists.
1775
+ this.automaticPauses.delete(reason)
1776
+ return this.applyPause(reason)
1777
+ }
1778
+
1779
+ applyPause (reason) {
1632
1780
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1633
1781
  this.assertOpen()
1634
1782
  this.pauseReasons.add(reason)
1783
+ this.notifyStatus()
1635
1784
  for (const extension of this.extensions) extension.pause?.()
1636
1785
  this.recordInterruption(this.desiredChannels)
1637
1786
  for (const controller of this.recoveryControllers) controller.abort()
@@ -1641,7 +1790,14 @@ export class PrivateMessenger {
1641
1790
  async resume (reason) {
1642
1791
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1643
1792
  this.assertOpen()
1793
+ // Repair durable state before announcing that publication can resume.
1794
+ if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
1795
+ await this.flushStateWrites()
1796
+ this.assertOpen()
1797
+ const wasAutomatic = this.automaticPauses.has(reason)
1644
1798
  const removed = this.pauseReasons.delete(reason)
1799
+ this.automaticPauses.delete(reason)
1800
+ this.notifyStatus()
1645
1801
  if (reason === 'capacity' && removed) {
1646
1802
  clearInterval(this.capacityTimer)
1647
1803
  this.capacityTimer = null
@@ -1664,7 +1820,10 @@ export class PrivateMessenger {
1664
1820
  })()
1665
1821
  this.resumeWork = work
1666
1822
  try { await work } catch (err) {
1667
- if (!this.closePromise) await this.pause(reason)
1823
+ if (!this.closePromise) {
1824
+ if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
1825
+ await this.applyPause(reason)
1826
+ }
1668
1827
  throw err
1669
1828
  } finally { if (this.resumeWork === work) this.resumeWork = null }
1670
1829
  }
@@ -1677,7 +1836,7 @@ export class PrivateMessenger {
1677
1836
  return this.queueIncoming(async () => {
1678
1837
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey)) {
1679
1838
  this.recordInterruption([pubkey], messageTime(message))
1680
- throw new Error('PRIVATE_MESSENGER_PAUSED')
1839
+ throw this.pausedError()
1681
1840
  }
1682
1841
  return operation()
1683
1842
  })
@@ -1790,19 +1949,20 @@ export class PrivateMessenger {
1790
1949
  this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
1791
1950
  return
1792
1951
  }
1952
+ const record = {
1953
+ type,
1954
+ channelPubkey,
1955
+ receivedAt: nowSeconds(),
1956
+ event: message.event,
1957
+ ...info,
1958
+ payload: message.payload,
1959
+ question: message.question || null,
1960
+ questionId: message.questionId || null,
1961
+ outer: message.outer || null,
1962
+ meta: message.meta || null
1963
+ }
1793
1964
  try {
1794
- await this.queue.enqueue({
1795
- type,
1796
- channelPubkey,
1797
- receivedAt: nowSeconds(),
1798
- event: message.event,
1799
- ...info,
1800
- payload: message.payload,
1801
- question: message.question || null,
1802
- questionId: message.questionId || null,
1803
- outer: message.outer || null,
1804
- meta: message.meta || null
1805
- })
1965
+ await this.queue.enqueue(record)
1806
1966
  } catch (err) {
1807
1967
  // The unique index closes the small cross-instance race after `someBy`.
1808
1968
  if (dedupeKey && err?.name === 'ConstraintError') {
@@ -1813,7 +1973,8 @@ export class PrivateMessenger {
1813
1973
  this.recordInterruption([channelPubkey], message.outer?.created_at || message.event?.created_at || nowSeconds())
1814
1974
  // Do not await stop from inside its own transport callback.
1815
1975
  const atCapacity = err.message === 'QUEUE_CAPACITY_EXCEEDED'
1816
- this.pause(atCapacity ? 'capacity' : 'storage').catch(error => this.onError?.(error))
1976
+ if (!atCapacity && isRecoverableStorageFailure(err)) this.pendingStorageWrites.set(JSON.stringify(dedupeKey || [channelPubkey, type]), { record, dedupeKey, at: message.outer?.created_at || message.event?.created_at })
1977
+ ;(atCapacity ? this.pause('capacity') : isRecoverableStorageFailure(err) ? this.pauseInternally('storage') : this.pause('storage')).catch(error => this.onError?.(error))
1817
1978
  if (atCapacity) {
1818
1979
  this.capacityRequiredBytes = Math.max(this.capacityRequiredBytes, err.requiredBytes || 0)
1819
1980
  if (!this.capacityTimer) {
@@ -2247,7 +2408,7 @@ export class PrivateMessenger {
2247
2408
 
2248
2409
  requireWritableChannel (pubkey) {
2249
2410
  this.assertOpen()
2250
- if (this.pauseReasons.size) throw new Error('PRIVATE_MESSENGER_PAUSED')
2411
+ if (this.pauseReasons.size) throw this.pausedError()
2251
2412
  const channel = this.requireChannel(pubkey)
2252
2413
  if (!channel.signer) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
2253
2414
  return channel
@@ -2373,12 +2534,12 @@ export class PrivateMessenger {
2373
2534
  ensureNetworkWatchers () {
2374
2535
  if (typeof window === 'undefined') return
2375
2536
  if (!this.stopOffline) {
2376
- const offline = () => { this.pause('network').catch(err => this.onError?.(err)) }
2537
+ const offline = () => { this.pauseInternally('network').catch(err => this.onError?.(err)) }
2377
2538
  window.addEventListener('offline', offline)
2378
2539
  this.stopOffline = () => window.removeEventListener('offline', offline)
2379
2540
  }
2380
2541
  if (!this.stopOnline) {
2381
- const online = () => { this.resume('network').catch(err => this.onError?.(err)) }
2542
+ const online = () => { this.syncPauseRecovery(); this.pauseRecoveries.get('network')?.wake() }
2382
2543
  window.addEventListener('online', online)
2383
2544
  this.stopOnline = () => window.removeEventListener('online', online)
2384
2545
  }
@@ -2718,7 +2879,7 @@ export class PrivateMessenger {
2718
2879
  controller.channelPubkey = pubkey
2719
2880
  this.recoveryControllers.add(controller)
2720
2881
  try {
2721
- if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw new Error('PRIVATE_MESSENGER_PAUSED')
2882
+ if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) throw this.pausedError()
2722
2883
  const fetchRelays = await this.resolveWatchRelays(channel)
2723
2884
  controller.signal.throwIfAborted()
2724
2885
  const record = this.recoveryRangeRecord(pubkey, range)
@@ -2875,6 +3036,9 @@ export class PrivateMessenger {
2875
3036
  const initSettledPromise = this.initSettledPromise
2876
3037
  this.sendRoutingLifetime.abort()
2877
3038
  this.sendRelayExclusions.clear()
3039
+ this.pendingStorageWrites.clear()
3040
+ for (const task of this.pauseRecoveries.values()) task.stop()
3041
+ for (const task of this.watchRecoveries.values()) task.stop()
2878
3042
  let unwatchPromise
2879
3043
  try {
2880
3044
  unwatchPromise = Promise.resolve(this.unwatch())
@@ -2932,6 +3096,7 @@ export class PrivateMessenger {
2932
3096
  this.recoveryPriority.clear()
2933
3097
  if (unwatchError) throw unwatchError
2934
3098
  })()
3099
+ this.notifyStatus()
2935
3100
  return this.closePromise
2936
3101
  }
2937
3102
  }
@@ -40,13 +40,41 @@ or cancelled/closed sends. Apps own route visibility and user-intent filtering;
40
40
  automatic retries may also fail. Callbacks must not throw. Errors are not copied
41
41
  into persisted outbox records.
42
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.
43
+ Sends that only failed because the messenger was paused stay pending without
44
+ `onSendError`. `PrivateMessenger.readStatus()` and optional `onStateChanged`
45
+ provide isolated `{ closed, paused, pauseReasons }` snapshots, initially and on
46
+ actual transitions. Paused errors retain their `pauseReasons` and native cause.
47
+ The session parks remote sends and wakes them immediately on release; local
48
+ personal copies and self-chat remain writable.
49
+
50
+ A custom `Messenger(options)` factory must provide synchronous `readStatus()`
51
+ and honor `options.onStateChanged`: deliver an initial snapshot before the factory
52
+ resolves and notify each effective state change. Snapshots contain boolean
53
+ `closed`/`paused` and a `pauseReasons` string array; `paused` reflects whether that
54
+ array is nonempty. The session copies snapshots and rejects missing observation
55
+ with `MESSENGER_STATE_CONTRACT_REQUIRED`, or malformed state with
56
+ `INVALID_MESSENGER_STATUS` (`ValidationError`). Rejected instances are closed and
57
+ obsolete callbacks are ignored. Wrappers can forward the complete options to
58
+ `createPrivateMessenger(options)` unchanged.
59
+
60
+ Observed pauses never fall back to polling sends. Other transient availability
61
+ failures without an active pause retain bounded 1..30s send retries; a pause
62
+ superseding such a retry parks it until the state notification releases it.
63
+
64
+ Network pauses observed internally use shared `isOnline`/`onOnline` monitoring,
65
+ including recovery when no second native online event arrives. Offline waiting
66
+ spends no backoff. Retryable watch failures recover by channel without globally
67
+ pausing publication. Background historical recovery does not gate text sends.
68
+ Internal storage pauses repair pending state writes before release; explicit
69
+ external pauses remain owned by their caller. Operational recovery retries use
70
+ 1, 2, 4... seconds, capped at 30 seconds with 20% jitter; permanent relay and
71
+ local validation/auth/permission failures await explicit recovery.
72
+
73
+ A failed session inbox save uses the separate `session-storage` reason. The
74
+ session retries the actual reserved record and releases only after save and ACK.
75
+ No message eviction or false historical coverage is introduced. Account
76
+ unavailability stops its recovery timers; availability or explicit retry can
77
+ reevaluate a local failure without clearing another owner's storage pause.
50
78
 
51
79
  `enqueue({ peer, event, context, requiredFiles, deletion })` preserves prepared
52
80
  identities. Local message commits and remote stages have independent checkpoints.
@@ -5,11 +5,13 @@ import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/ind
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
6
  import { getIykcProofs } from '../../content-key/index.js'
7
7
  import { onOnline } from '../../network/index.js'
8
+ import { ValidationError } from '../../error/index.js'
8
9
  import { decodeFileMetadata } from '../../nip94/index.js'
9
10
  import { createChatOutbox } from './helpers/work-storage.js'
10
11
  import { messengerSigner } from './helpers/signer.js'
11
12
  import { assertMessagePublished } from '../helpers/publication.js'
12
13
  import { normalizeFallbackRelays } from '../helpers/send-routing.js'
14
+ import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
13
15
 
14
16
  export function wireEvent (value, owner) {
15
17
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -27,10 +29,18 @@ const SEND_RETRY_MAX_MS = 30000
27
29
  const unavailableError = (error, signal) => Object.assign(new Error(error?.message || 'CHAT_UNAVAILABLE'), {
28
30
  code: error?.code,
29
31
  cause: error,
32
+ ...(Array.isArray(error?.pauseReasons) ? { pauseReasons: [...error.pauseReasons] } : {}),
30
33
  retryWhenAvailable: !signal?.aborted,
31
34
  retryWhenOnline: false
32
35
  })
33
36
 
37
+ function messengerStatusSnapshot (value) {
38
+ if (!value || typeof value.closed !== 'boolean' || typeof value.paused !== 'boolean' ||
39
+ !Array.isArray(value.pauseReasons) || value.pauseReasons.some(reason => typeof reason !== 'string' || !reason.trim()) ||
40
+ value.paused !== Boolean(value.pauseReasons.length)) throw new ValidationError('INVALID_MESSENGER_STATUS')
41
+ return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
42
+ }
43
+
34
44
  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' }) }) {
35
45
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
36
46
  const reportedErrors = new WeakSet()
@@ -51,6 +61,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
51
61
  const cancelled = new Set()
52
62
  const sendControllers = new Map()
53
63
  const offlineSends = new Set()
64
+ const pausedSends = new Set()
65
+ let messengerStatus
66
+ let messengerBinding
67
+ let storageBlocked = false
68
+ const inboxRecovery = createPauseRecovery({
69
+ attempt: async signal => { signal.throwIfAborted(); await drain(); if (storageBlocked) throw inboxError },
70
+ retryable: isRecoverableStorageFailure, onError: reportError,
71
+ setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
72
+ })
73
+ let inboxError
74
+ const stateChanged = state => {
75
+ if (closed) return
76
+ messengerStatus = messengerStatusSnapshot(state)
77
+ if (!available || state.closed) return
78
+ if (state.paused) {
79
+ for (const entry of entries.values()) {
80
+ if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
81
+ }
82
+ if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
83
+ return
84
+ }
85
+ clearSendRetry()
86
+ for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
87
+ pausedSends.clear()
88
+ // The callback can run inside construction/resume, before configure settles.
89
+ queueMicrotask(() => pump())
90
+ }
54
91
  let stopOnline
55
92
  let sendRetryTimer
56
93
  let sendRetryDelay = SEND_RETRY_MIN_MS
@@ -70,12 +107,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
70
107
  let storage
71
108
  let initialized
72
109
  let closed = false
110
+ let closeWork
73
111
  let available = false
74
112
  let lifecycle = 0
75
113
  let configuring = Promise.resolve()
114
+ let configurationFailed = false
76
115
  let sending
77
116
  let repump
78
117
  let draining
118
+ let redrain = false
79
119
  const idleWaiters = new Set()
80
120
  const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
81
121
  const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
@@ -84,6 +124,35 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
84
124
  for (const entry of await storage.list()) entries.set(entry.id, entry)
85
125
  emit()
86
126
  })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
127
+ async function createMessenger () {
128
+ const binding = {}
129
+ messengerBinding = binding
130
+ let notified = false
131
+ let candidate
132
+ try {
133
+ candidate = await Messenger({
134
+ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
135
+ onMessageQueued: () => { if (messengerBinding === binding) return drain() },
136
+ onStateChanged: state => {
137
+ if (closed || messengerBinding !== binding) return
138
+ stateChanged(state)
139
+ notified = true
140
+ },
141
+ onError: reportError
142
+ })
143
+ if (closed) { messengerBinding = null; await candidate?.close?.(); return }
144
+ if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
145
+ const status = messengerStatusSnapshot(candidate.readStatus())
146
+ messenger = candidate
147
+ stateChanged(status)
148
+ } catch (error) {
149
+ messengerBinding = null
150
+ messengerStatus = undefined
151
+ try { await candidate?.close?.() } catch (cleanupError) { reportError(cleanupError) }
152
+ throw error
153
+ }
154
+ }
155
+
87
156
  async function configure () {
88
157
  if (closed || !available) return
89
158
  await ready()
@@ -102,7 +171,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
102
171
  if (closed || !available || version !== lifecycle) return
103
172
  values.push(channel)
104
173
  }
105
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
174
+ if (!messenger) await createMessenger()
175
+ if (!messenger) return
106
176
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
107
177
  await messenger.update({ channels: values })
108
178
  if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
@@ -116,6 +186,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
116
186
  }
117
187
  }
118
188
  await messenger.resume('signer')
189
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
119
190
  if (!fileTransfers) {
120
191
  fileTransfers = FileTransfer({
121
192
  messenger, onError: reportError,
@@ -153,13 +224,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
224
  function schedule () {
154
225
  const work = configuring.catch(() => {}).then(configure)
155
226
  configuring = work
156
- work.catch(reportError)
227
+ work.then(() => { if (configuring === work) configurationFailed = false }, error => { if (configuring === work) configurationFailed = true; reportError(error) })
157
228
  return work
158
229
  }
159
230
  async function drain () {
160
- if (draining || closed || !available || !messenger) return
231
+ if (draining) { redrain = true; return }
232
+ if (closed || !available || !messenger) return
161
233
  draining = true
162
234
  const deferred = []
235
+ let persisted = false
236
+ let passFailed = false
163
237
  try {
164
238
  while (true) {
165
239
  if (closed || !available) break
@@ -181,16 +255,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
181
255
  if (!available || closed) { await nack(); break }
182
256
  const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
183
257
  if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
184
- await ack()
258
+ if (!available || closed) { await nack(); break }
259
+ if (await ack() === false) throw new Error('INBOX_ACK_FAILED')
260
+ persisted = true
185
261
  } catch (error) {
186
262
  await nack()
187
263
  reportError(error)
188
264
  // Retain the failed record and suspend network ingestion until recovery.
189
- await messenger.pause('storage')
265
+ passFailed = true
266
+ storageBlocked = true
267
+ inboxError = error
268
+ await messenger.pause('session-storage')
269
+ if (isRecoverableStorageFailure(error)) inboxRecovery.start()
190
270
  break
191
271
  }
192
272
  }
193
- } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
273
+ if (storageBlocked && persisted && !passFailed) {
274
+ // A retry can save one record and fail the next. Only release after the
275
+ // whole currently admissible pass completes without another failure.
276
+ storageBlocked = false; inboxError = null
277
+ await messenger.resume('session-storage')
278
+ }
279
+ } finally {
280
+ await Promise.all(deferred.map(nack => nack()))
281
+ draining = false; settled()
282
+ const again = redrain; redrain = false
283
+ if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
284
+ }
194
285
  }
195
286
  async function publish (peer, event, signal) {
196
287
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
@@ -229,6 +320,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
229
320
  await storage.put(entry, { existing: true })
230
321
  }
231
322
  }
323
+ if (entry.peer !== owner && messengerStatus?.paused) {
324
+ throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
325
+ }
232
326
  // Read/publish one chunk at a time. Progress is durable after each
233
327
  // accepted chunk; replay after interruption keeps its original identity.
234
328
  for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
@@ -273,20 +367,20 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
273
367
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
274
368
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
275
369
  await storage.put(entry, { existing: true }).catch(reportError)
370
+ if (entry.peer !== owner && error.retryWhenAvailable && messenger.readStatus().paused) pausedSends.add(entry.id)
276
371
  reportError(error)
277
372
  // The failed wire event may be a quote or file chunk. Report the
278
373
  // owning outbox item separately, without mutating the native error.
279
374
  if (error.retryWhenOnline) offlineSends.add(entry.id)
280
- if (error.retryWhenAvailable) scheduleSendRetry()
375
+ if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
281
376
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
282
377
  }
283
378
  emit()
284
379
  } finally { sendControllers.delete(entry.id) }
285
380
  }
286
381
  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.
382
+ // Transient availability failures without an active messenger pause retain
383
+ // bounded retries. Observed pauses wait exclusively for their release.
290
384
  function clearSendRetry () {
291
385
  if (sendRetryTimer) _clearTimeout(sendRetryTimer)
292
386
  sendRetryTimer = undefined
@@ -305,7 +399,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
305
399
  if (closed || !available) return
306
400
  let retried = false
307
401
  for (const entry of entries.values()) {
308
- if (!pendingRetryable(entry)) continue
402
+ if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
309
403
  entry.failed = false
310
404
  retried = true
311
405
  }
@@ -341,9 +435,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
341
435
  for (const entry of entries.values()) {
342
436
  if (closed || !available) break
343
437
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
438
+ if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
344
439
  await sendEntry(entry)
345
440
  }
346
- } while (repump && !closed && available)
441
+ } while (repump)
347
442
  } finally {
348
443
  sending = false
349
444
  watchOfflineSends()
@@ -416,21 +511,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
416
511
  return true
417
512
  },
418
513
  async setPeers (values) {
419
- peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
514
+ const next = new Set([...values].filter(peer => peer !== owner))
515
+ if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
516
+ peers.clear(); for (const peer of next) peers.add(peer)
420
517
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
421
518
  lifecycle++
422
519
  return schedule()
423
520
  },
424
521
  async setAvailable (value) {
522
+ const changed = available !== (value === true)
425
523
  available = value === true
524
+ if (!changed && !available) return
426
525
  stopOnlineRetry(); offlineSends.clear()
427
526
  if (available) resetSendRetry()
428
527
  else clearSendRetry()
429
- lifecycle++
430
- if (!available) { await messenger?.pause('signer'); return }
528
+ if (changed) lifecycle++
529
+ if (!available) { inboxRecovery.stop(); await messenger?.pause('signer'); return }
431
530
  await ready()
432
531
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
433
- await messenger?.resume('storage')
532
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
533
+ if (!changed && messenger && !configurationFailed) return pump()
434
534
  return schedule()
435
535
  },
436
536
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
@@ -446,7 +546,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
446
546
  return id
447
547
  },
448
548
  async retry (id) {
449
- offlineSends.delete(id); watchOfflineSends()
549
+ offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
550
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
450
551
  await ready()
451
552
  const entry = entries.get(id)
452
553
  if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
@@ -454,24 +555,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
454
555
  async cancel (id) {
455
556
  cancelled.add(id)
456
557
  sendControllers.get(id)?.abort()
457
- offlineSends.delete(id); watchOfflineSends()
558
+ offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
458
559
  await ready()
459
560
  entries.delete(id)
460
561
  await storage.remove(id)
461
562
  emit()
462
563
  },
463
- async close () {
464
- closed = true; available = false; lifecycle++
465
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
466
- clearSendRetry()
467
- for (const controller of sendControllers.values()) controller.abort()
468
- await Promise.allSettled([configuring, initialized])
469
- await messenger?.pause('closed')
470
- if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
471
- await messenger?.close()
472
- await storage?.close()
473
- await downloadWriteTail.catch(reportError)
474
- await downloads?.close()
564
+ close () {
565
+ return (closeWork ??= (async () => {
566
+ closed = true; available = false; lifecycle++
567
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
568
+ clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
569
+ for (const controller of sendControllers.values()) controller.abort()
570
+ await Promise.allSettled([configuring, initialized])
571
+ await messenger?.pause('closed')
572
+ if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
573
+ await messenger?.close()
574
+ await storage?.close()
575
+ await downloadWriteTail.catch(reportError)
576
+ await downloads?.close()
577
+ })())
475
578
  }
476
579
  }
477
580
  }