libp2r2p 0.11.18 → 0.11.20

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
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
535
535
 
536
536
  Pauses, unwatch and close record the start of the gap, even for channels that
537
537
  have never received a message. Rewatch/resume uses live delivery plus historical
538
- recovery with overlap. A failed resume remains retryable under its pause reason.
538
+ recovery with overlap. Remote recovery failures never restore the caller's pause
539
+ reason: signer availability belongs exclusively to the account owner.
539
540
  Before opening a watch, the messenger persists a pending recovery interval.
540
541
  A first watch scans the configured recovery window (seven days by default),
541
542
  including messages sent before this identity first opened the channel. Successful
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
546
547
  messages. Applications still supply signers and desired channels on reopening;
547
548
  storage is scoped by origin and user pubkey, not the transient session ID.
548
549
  Failed ingestion or incomplete relay fetch reports never complete the gap.
550
+
551
+ `init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
552
+ `update(options, { waitForBackground = false })` and
553
+ `resume(reason, { waitForBackground = false })` wait for valid local channel
554
+ configuration and mandatory durable writes, allowing sends independently of
555
+ subscriptions, presence and history. Set `waitForBackground: true` to also await
556
+ the initial background tasks associated with that call, not every future retry.
557
+ Both modes share tasks; unchanged configuration does not recreate subscriptions.
558
+ Later errors reach `onError` with the original error as `cause` and
559
+ `operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
560
+ tasks cannot deliver into a new channel generation. Mandatory persistence still
561
+ owns its storage pause; incomplete history remains a pending recovery interval.
562
+ Subscription setup is bounded separately from presence, so a slow announcement
563
+ does not hold the other channels' readers. Sessions prioritize user persistence
564
+ and publication before the next background presence signer call. Signing an outer
565
+ envelope can overlap recovery-seed persistence, but no envelope is yielded for
566
+ publication unless both succeed; only one outer is prepared at a time.
567
+
549
568
  `private-channel.fetch` reports incomplete reads as an `AggregateError` with
550
569
  stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
551
570
  completion statuses and native failure messages, including status-only failures.
@@ -936,22 +955,26 @@ also admits relay policy refusals (`blocked:`, `restricted:`, `auth-required:`,
936
955
  Local authentication denials, invalid events and unknown errors qualify for
937
956
  neither policy. Predicates classify individual native errors; callers decide
938
957
  how to handle mixed per-relay outcomes and application-specific aggregates.
939
- `parseRelayRetryAdvice(reason, extra, { now = Date.now() })`, exported by
958
+ `parseRelayRetryAdvice(reason, params, { now = Date.now() })`, exported by
940
959
  `libp2r2p/relay`, returns `{ retryAt, retryAfterMs? }` or `null`. It only interprets
941
- leading `rate-limited:` refusals. Optional `retry_at` is a Unix timestamp in
942
- seconds (fractions allowed), converted to milliseconds. Finite positive absolute
943
- advice takes precedence over `retry_after`, including when already expired;
944
- expired advice does not restart a relative wait. Future waits are capped at five
945
- minutes from processing. Missing/invalid absolute advice falls back to finite
946
- positive relative seconds, capped at 300. A valid relative value is retained as
947
- `retryAfterMs` even when the absolute deadline wins. Schedulers wait until
960
+ leading `rate-limited:` refusals. The optional string `params` is relative
961
+ milliseconds (e.g. "2500") or an absolute Unix timestamp in milliseconds
962
+ prefixed with `@` (e.g. "@1791738154332"). Values must be positive safe integers
963
+ in canonical decimal form, without signs, whitespace, fractions or leading zeros.
964
+ Relative values produce both `retryAfterMs` and `retryAt`; absolute values produce
965
+ only `retryAt` and retain an expired deadline without starting a new wait.
966
+ Future waits are capped at five minutes. Missing or invalid params are ignored
967
+ and do not instruct clients to retry immediately. Schedulers wait until
948
968
  max(local backoff deadline, retryAt), never sum the two or renew the deadline on
949
969
  online notifications.
950
970
 
951
- The optional Nostr extension works with older relays that only send
952
- `retry_after` and older consumers that ignore `retry_at`. Neither field supplies
953
- provenance or retry eligibility: `local`, `origin`, `retryable`, category and code
954
- claims in extras are ignored. Native messages, causes and codes remain intact.
971
+ Rate limits apply only to the client message type that triggered the refusal:
972
+ `EVENT`, `AUTH`, `REQ` and `COUNT` have independent connection cooldowns. Responses
973
+ are correlated with pending operations; unknown IDs never create cooldowns.
974
+ CLOSE cleanup remains immediate. The launcher can replace valid relative params
975
+ with `@` timestamps before queueing bridge delivery because its consumers share
976
+ its clock. No legacy timing objects are interpreted. Params never supply provenance
977
+ or retry eligibility; native messages, causes and codes remain intact.
955
978
 
956
979
  ## Binary encodings
957
980
 
@@ -1072,10 +1095,11 @@ user-facing send error.
1072
1095
 
1073
1096
  ### Relay backpressure and bounded synchronization (0.11.7)
1074
1097
 
1075
- Optional `retry_at` Unix seconds or numeric `retry_after` seconds on relay `CLOSED` and rejected `OK`
1076
- responses are preserved as `retryAfterMs` and `retryAt` on native relay errors.
1077
- Only `rate-limited:` responses apply this advice; invalid values are ignored
1078
- and delays are capped at five minutes. New operations on that connection wait
1098
+ Optional decimal `params` milliseconds or `@` Unix milliseconds on relay `CLOSED`
1099
+ and rejected `OK` responses are preserved as normalized `retryAt` (and
1100
+ `retryAfterMs` for relative params) on native relay errors. Only `rate-limited:`
1101
+ responses apply this advice; invalid values are ignored
1102
+ and delays are capped at five minutes. New operations of the refused type wait
1079
1103
  within their existing deadlines; active subscriptions remain live and `CLOSE`
1080
1104
  never waits. Cancelled or timed-out operations cannot be sent later. A rejection
1081
1105
  is still reported to the caller; this does not silently republish failed events.
@@ -1,6 +1,6 @@
1
1
  // A bounded FIFO for asynchronous work. Acquired leases release explicitly;
2
2
  // queued cancellations never consume capacity or keep idle keyed gates alive.
3
- export function createAbortableSemaphore (capacity, onIdle = () => {}) {
3
+ export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
4
4
  const queue = []
5
5
  let active = 0
6
6
  const drain = () => {
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
21
21
  return {
22
22
  acquire (signal) {
23
23
  signal?.throwIfAborted()
24
- if (queue.length >= 256) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
24
+ if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
25
25
  return new Promise((resolve, reject) => {
26
26
  const job = {
27
27
  signal, resolve, abort: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.18",
3
+ "version": "0.11.20",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
158
158
  const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
159
159
  const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
160
160
  const total = preparedChunkCount(context.preparedRows, rowIndexes)
161
- if (onPreparedSeed) {
161
+ const persistSeeds = async () => {
162
+ if (!onPreparedSeed) return
162
163
  const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
163
164
  const payloadRow = readPreparedRow(context.preparedRows, 0)
164
165
  for (const rowIndex of rowIndexes) {
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
179
180
  content
180
181
  }), routerSeckey)
181
182
  const createdAt = nowSeconds()
182
- const outer = await privateChannelSigner.signEvent({
183
+ const prepareOuter = async () => privateChannelSigner.signEvent({
183
184
  kind: PRIVATE_BROADCAST_KIND,
184
185
  created_at: createdAt,
185
186
  tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
186
187
  content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
187
188
  })
189
+ // These independent steps may run together, but no envelope is yielded
190
+ // until every seed is durable. Settle both before releasing prepared rows.
191
+ const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
192
+ if (persistence.status === 'rejected') throw persistence.reason
193
+ if (prepared.status === 'rejected') throw prepared.reason
194
+ const outer = prepared.value
188
195
  if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
189
196
  yield outer
190
197
  }
@@ -0,0 +1,16 @@
1
+ // Keep actual permissions and cryptography in the signer. Background presence
2
+ // only yields before its next RPC, without interrupting an operation in flight.
3
+ export function backgroundSigner (signer, wait, signal) {
4
+ if (!signer || !wait) return signer
5
+ return new Proxy(signer, {
6
+ get (target, key) {
7
+ const value = Reflect.get(target, key, target)
8
+ if (typeof value !== 'function') return value
9
+ return async (...args) => {
10
+ await wait(signal)
11
+ signal?.throwIfAborted()
12
+ return value.apply(target, args)
13
+ }
14
+ }
15
+ })
16
+ }
@@ -48,6 +48,7 @@ import * as privateMessage from '../private-message/index.js'
48
48
  import { isOnline, onOnline } from '../network/index.js'
49
49
  import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
50
  import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
+ import { backgroundSigner } from './helpers/background-signer.js'
51
52
  import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
52
53
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
53
54
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
@@ -243,6 +244,7 @@ export class PrivateMessenger {
243
244
  _pickRelaysForPubkeys = pickRelaysForPubkeys,
244
245
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
245
246
  _isOnline = isOnline,
247
+ _waitForForeground,
246
248
  _onOnline = onOnline,
247
249
  _random = Math.random,
248
250
  _setTimeout = globalThis.setTimeout.bind(globalThis),
@@ -304,6 +306,7 @@ export class PrivateMessenger {
304
306
  this._pickRelaysForPubkeys = _pickRelaysForPubkeys
305
307
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
306
308
  this._isOnline = _isOnline
309
+ this._waitForForeground = _waitForForeground
307
310
  this.sendRelayExclusions = new Map()
308
311
  this.sendRoutingLifetime = new AbortController()
309
312
  this.sendRoutingPause = new AbortController()
@@ -341,7 +344,13 @@ export class PrivateMessenger {
341
344
  this.recoveryRanges = new Map()
342
345
  this.recoveryPriority = new Map()
343
346
  this.recoveryOnline = { at: 0, value: true }
344
- this.resumeWork = null
347
+ this.channelBackground = new Map()
348
+ // At most one queued job exists per configured channel. Keep setup below
349
+ // foreground demand; history has its own independent admission limits.
350
+ this.channelSetupAdmission = createAbortableSemaphore(3, () => {}, { maxQueued: Infinity })
351
+ this.presenceAdmission = createAbortableSemaphore(1, () => {}, { maxQueued: Infinity })
352
+ this.updateTail = Promise.resolve()
353
+ this.pauseRevisions = new Map()
345
354
  this.capacityTimer = null
346
355
  this.capacityCheck = null
347
356
  this.capacityRequiredBytes = 0
@@ -350,6 +359,7 @@ export class PrivateMessenger {
350
359
  this.liveRecoveryTimers = new Map()
351
360
  this.watchRevisionByChannel = new Map()
352
361
  this.presenceTimers = new Map()
362
+ this.presenceJobs = new Map()
353
363
  this.stopRelayListWatcher = null
354
364
  this.relayListWatcherPubkey = ''
355
365
  this.relayListRefreshPromise = null
@@ -435,7 +445,6 @@ export class PrivateMessenger {
435
445
  this.startStorageMaintenance()
436
446
  this.state = { channels: await this.stateStore.load() }
437
447
  await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
438
- await this.pruneStoredSeeds()
439
448
  this.initialized = true
440
449
  await this.refreshAndApplyStoragePolicy()
441
450
  this.broadcastStoragePolicyChange()
@@ -673,7 +682,16 @@ export class PrivateMessenger {
673
682
  })
674
683
  }
675
684
 
676
- async update (options = {}) {
685
+ async update (options = {}, { waitForBackground = false } = {}) {
686
+ if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
687
+ const work = this.updateTail.catch(() => {}).then(() => this.applyUpdate(options))
688
+ this.updateTail = work
689
+ const background = await work
690
+ if (waitForBackground) await Promise.all(background)
691
+ return this
692
+ }
693
+
694
+ async applyUpdate (options) {
677
695
  this.assertOpen()
678
696
  const {
679
697
  userSigner = this.userSigner,
@@ -692,15 +710,17 @@ export class PrivateMessenger {
692
710
  let nextIdentityStorageRetentionSeconds = updatesIdentityPolicy
693
711
  ? normalizeIdentityStorageRetentionSeconds(options.identityStorageRetentionSeconds)
694
712
  : this.identityStorageRetentionSeconds
695
- if (userSigner) {
713
+ if (userSigner && (userSigner !== this.userSigner || !this.userPubkey)) {
696
714
  const userPubkey = await userSigner.getPublicKey?.()
697
715
  if (!userPubkey) throw new ValidationError('USER_SIGNER_REQUIRED')
698
716
  if (this.userPubkey && userPubkey !== this.userPubkey) throw new ValidationError('USER_SIGNER_MISMATCH')
699
717
  this.userSigner = userSigner
700
718
  }
719
+ if (contentKeySigner !== this.contentKeySigner || (!this.contentKeyPubkey && contentKeySigner)) {
720
+ this.contentKeyPubkey = await contentKeySigner?.getPublicKey?.() || ''
721
+ }
701
722
  this.contentKeySigner = contentKeySigner || null
702
723
  this.nymSigner = nymSigner || null
703
- this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
704
724
  const nextChannels = await this.normalizeChannels(channels, { relays, mode })
705
725
  this.assertOpen()
706
726
  const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
@@ -709,9 +729,11 @@ export class PrivateMessenger {
709
729
  return !previous || ['signer', 'readerSigner', 'readerPubkey', 'nymSigner', 'mode', 'usesNip65WatchRelays', 'offlineRecoverySeconds', 'autoDeletionCapability'].some(key => previous[key] !== channel[key]) ||
710
730
  ['relays', 'sendRelays', 'seeders'].some(key => JSON.stringify(previous[key]) !== JSON.stringify(channel[key]))
711
731
  }
712
- 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)
732
+ const changed = nextChannels.filter(channel => readerIdentityChanged || updatesStalePolicy || updatesIdentityPolicy || changedChannel(channel))
733
+ const watchPubkeys = changed.filter(channel => !this.channels.has(channel.pubkey) || this.desiredChannels.has(channel.pubkey)).map(channel => channel.pubkey)
713
734
  const removedPubkeys = [...this.channels.keys()].filter(pubkey => !nextPubkeys.has(pubkey))
714
735
  const updatesStoragePolicy = updatesStalePolicy || updatesIdentityPolicy
736
+ if (!changed.length && !removedPubkeys.length && !updatesStoragePolicy) return [...this.channelBackground.values()].map(job => job.work)
715
737
 
716
738
  if (updatesStoragePolicy) {
717
739
  const currentPolicy = await this.readStoragePolicySnapshot()
@@ -743,18 +765,35 @@ export class PrivateMessenger {
743
765
  if (updatesStoragePolicy) this.broadcastStoragePolicyChange()
744
766
 
745
767
  await this.unwatch(removedPubkeys)
768
+ // Keep an established reader alive until private-message atomically
769
+ // replaces it. Only obsolete setup/presence work is retired here.
770
+ for (const pubkey of watchPubkeys) {
771
+ this.cancelReloadGap(pubkey)
772
+ this.cancelLiveRecovery(pubkey)
773
+ this.channelBackground.get(pubkey)?.controller.abort()
774
+ this.channelBackground.delete(pubkey)
775
+ this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
776
+ this.stopPresencePublisher(pubkey)
777
+ }
746
778
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
747
779
  for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
748
- for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
780
+ for (const channel of changed) this.channels.set(channel.pubkey, channel)
749
781
 
750
- await this.cleanupStaleChannels({ storageSnapshot })
751
- await this.applyRecoveryPolicies(nextChannels)
752
- await this.watch(watchPubkeys)
753
- await this.reconcilePresencePublishers()
782
+ // Incremental registration must not prune persisted channels whose signers
783
+ // are still being prepared. Retention cleanup belongs to maintenance.
784
+ await this.applyRecoveryPolicies(changed, { prune: !this.seedStorage })
785
+ this.assertOpen()
786
+ for (const pubkey of watchPubkeys) {
787
+ this.desiredChannels.add(pubkey)
788
+ this.recordRecoveryWindow(pubkey)
789
+ }
790
+ if (this.pauseReasons.size) this.recordInterruption(watchPubkeys)
791
+ await this.flushStateWrites()
792
+ this.ensureNetworkWatchers()
754
793
  if (this.storagePolicyRevision === storageSnapshot.policyRevision) {
755
794
  this.storagePolicyNeedsApply = false
756
795
  }
757
- return this
796
+ return this.startChannelBackground(watchPubkeys)
758
797
  }
759
798
 
760
799
  async normalizeChannels (channels, defaults) {
@@ -772,7 +811,7 @@ export class PrivateMessenger {
772
811
  if (!signer && !readerSigner) throw new ValidationError('CHANNEL_SIGNER_REQUIRED')
773
812
  const mode = channel.mode || defaults.mode || 'leecher'
774
813
  if (!signer && doesModeStoreRecoverySeeds(mode)) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
775
- const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
814
+ const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || (readerSigner === signer ? pubkey : await readerSigner?.getPublicKey?.()) || pubkey
776
815
  const autoDeletionCapability = channel.autoDeletionCapability === undefined
777
816
  ? undefined
778
817
  : normalizeAutoDeletionCapability(channel.autoDeletionCapability)
@@ -804,8 +843,8 @@ export class PrivateMessenger {
804
843
  return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
805
844
  }
806
845
 
807
- async readRelaysForPubkey (pubkey) {
808
- const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
846
+ async readRelaysForPubkey (pubkey, options) {
847
+ const relaysByPubkey = await this._getRelaysByPubkey([pubkey], options)
809
848
  const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
810
849
  if (readRelays.length) return readRelays
811
850
  return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
@@ -823,8 +862,8 @@ export class PrivateMessenger {
823
862
  }
824
863
  }
825
864
 
826
- async resolveWatchRelays (channel) {
827
- const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey)
865
+ async resolveWatchRelays (channel, options) {
866
+ const primary = !channel.usesNip65WatchRelays && channel.relays.length ? channel.relays : await this.readRelaysForPubkey(this.userPubkey, options)
828
867
  return uniq([...primary, ...this.fallbackRelays].map(normalizeRelayUrl))
829
868
  }
830
869
 
@@ -1124,7 +1163,7 @@ export class PrivateMessenger {
1124
1163
  this.writeState(state)
1125
1164
  }
1126
1165
 
1127
- async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
1166
+ async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true, signal } = {}) {
1128
1167
  this.assertOpen()
1129
1168
  const channelPubkeys = uniq(channels)
1130
1169
  for (const pubkey of channelPubkeys) {
@@ -1148,13 +1187,15 @@ export class PrivateMessenger {
1148
1187
  await this.flushStateWrites()
1149
1188
  let stop; let watchRelays
1150
1189
  try {
1151
- watchRelays = await this.resolveWatchRelays(channel)
1190
+ signal?.throwIfAborted()
1191
+ watchRelays = await this.resolveWatchRelays(channel, { signal })
1152
1192
  this.assertOpen()
1153
1193
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) continue
1154
1194
  stop = await this._privateMessage.watch({
1155
1195
  channels: [pubkey],
1156
1196
  relays: watchRelays,
1157
1197
  receiverSigner: this.userSigner,
1198
+ receiverPubkey: this.userPubkey,
1158
1199
  iykcSigner: this.contentKeySigner,
1159
1200
  privateChannelSigner: channel.signer,
1160
1201
  privateChannelReaderSigner: channel.readerSigner,
@@ -1174,6 +1215,7 @@ export class PrivateMessenger {
1174
1215
  onError: err => this.onError?.(err)
1175
1216
  })
1176
1217
  } catch (error) {
1218
+ if (signal?.aborted) continue
1177
1219
  const transient = isRetryableRelayFailure(error)
1178
1220
  const remote = error?.name !== 'Nip42AuthenticationError' && (error?.category === 'relay' || isReplaceableRelayFailure(error))
1179
1221
  if (!transient && !remote) throw error
@@ -1655,6 +1697,8 @@ export class PrivateMessenger {
1655
1697
  stopWatches (channels = [...this.stopByChannel.keys()]) {
1656
1698
  const closing = []
1657
1699
  for (const pubkey of channels) {
1700
+ this.channelBackground.get(pubkey)?.controller.abort()
1701
+ this.channelBackground.delete(pubkey)
1658
1702
  this.cancelReloadGap(pubkey)
1659
1703
  this.cancelLiveRecovery(pubkey)
1660
1704
  this.liveInterruptions.delete(pubkey)
@@ -1785,6 +1829,7 @@ export class PrivateMessenger {
1785
1829
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1786
1830
  this.assertOpen()
1787
1831
  this.pauseReasons.add(reason)
1832
+ this.pauseRevisions.set(reason, (this.pauseRevisions.get(reason) || 0) + 1)
1788
1833
  this.sendRoutingPause.abort(this.pausedError())
1789
1834
  this.notifyStatus()
1790
1835
  for (const extension of this.extensions) extension.pause?.()
@@ -1793,14 +1838,16 @@ export class PrivateMessenger {
1793
1838
  return Promise.all([this.stopWatches([...this.desiredChannels]), this.flushStateWrites()])
1794
1839
  }
1795
1840
 
1796
- async resume (reason) {
1841
+ async resume (reason, { waitForBackground = false } = {}) {
1797
1842
  if (typeof reason !== 'string' || !reason.trim()) throw new ValidationError('PAUSE_REASON_REQUIRED')
1843
+ if (typeof waitForBackground !== 'boolean') throw new ValidationError('INVALID_WAIT_FOR_BACKGROUND')
1798
1844
  this.assertOpen()
1845
+ const revision = this.pauseRevisions.get(reason)
1799
1846
  // Repair durable state before announcing that publication can resume.
1800
1847
  if (reason === 'storage' && this.pendingStorageWrites.size) await this.retryStorageWrites(this.sendRoutingLifetime.signal)
1801
1848
  await this.flushStateWrites()
1802
1849
  this.assertOpen()
1803
- const wasAutomatic = this.automaticPauses.has(reason)
1850
+ if (revision !== this.pauseRevisions.get(reason)) return
1804
1851
  const removed = this.pauseReasons.delete(reason)
1805
1852
  this.automaticPauses.delete(reason)
1806
1853
  if (!this.pauseReasons.size && this.sendRoutingPause.signal.aborted) this.sendRoutingPause = new AbortController()
@@ -1810,29 +1857,75 @@ export class PrivateMessenger {
1810
1857
  this.capacityTimer = null
1811
1858
  this.capacityRequiredBytes = 0
1812
1859
  }
1813
- if (!removed || this.pauseReasons.size) return
1814
- if (this.resumeWork) await this.resumeWork
1815
- if (this.pauseReasons.size || this.closePromise) return
1816
- const work = (async () => {
1817
- const channels = [...this.desiredChannels]
1860
+ if (this.pauseReasons.size) return
1861
+ const channels = [...this.desiredChannels]
1862
+ if (removed) {
1818
1863
  this.closeOpenOfflineRanges(channels)
1819
- await this.watch(channels, { scheduleReloadGap: false })
1820
- if (this.pauseReasons.size || this.closePromise) return
1821
- await this.reconcilePresencePublishers()
1822
- await this.recoverOfflineRanges(channels)
1823
- for (const pubkey of channels) {
1824
- if (this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1825
- }
1826
- for (const extension of this.extensions) await extension.resume?.()
1827
- })()
1828
- this.resumeWork = work
1829
- try { await work } catch (err) {
1830
- if (!this.closePromise) {
1831
- if (wasAutomatic && (reason === 'network' ? isRetryableRelayFailure(err) : isRecoverableStorageFailure(err))) this.automaticPauses.add(reason)
1832
- await this.applyPause(reason)
1864
+ await this.flushStateWrites()
1865
+ }
1866
+ if (this.pauseReasons.size || this.closePromise) return
1867
+ const jobs = removed ? this.startChannelBackground(channels, { recover: true }) : [...this.channelBackground.values()].map(job => job.work)
1868
+ if (removed) {
1869
+ for (const extension of this.extensions) {
1870
+ const work = Promise.resolve().then(() => { if (!this.pauseReasons.size && !this.closePromise) return extension.resume?.() })
1871
+ work.catch(error => this.onError?.(error))
1872
+ jobs.push(work)
1833
1873
  }
1834
- throw err
1835
- } finally { if (this.resumeWork === work) this.resumeWork = null }
1874
+ }
1875
+ if (waitForBackground) await Promise.all(jobs)
1876
+ }
1877
+
1878
+ startChannelBackground (channels, { recover = false } = {}) {
1879
+ const jobs = []
1880
+ for (const pubkey of channels) {
1881
+ if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) continue
1882
+ let job = this.channelBackground.get(pubkey)
1883
+ if (job) { jobs.push(job.work); continue }
1884
+ const controller = new AbortController()
1885
+ const channel = this.channels.get(pubkey)
1886
+ job = { controller, work: null }
1887
+ this.channelBackground.set(pubkey, job)
1888
+ const active = () => this.initialized && !controller.signal.aborted && !this.closePromise && !this.pauseReasons.size && this.channels.get(pubkey) === channel && this.desiredChannels.has(pubkey)
1889
+ let phase = 'subscribe'
1890
+ let stopAbort
1891
+ const cancelled = new Promise(resolve => {
1892
+ stopAbort = resolve
1893
+ controller.signal.addEventListener('abort', stopAbort, { once: true })
1894
+ })
1895
+ const work = Promise.resolve().then(async () => {
1896
+ if (!this.initialized) await this.initSettledPromise
1897
+ if (!active()) return
1898
+ const release = await this.channelSetupAdmission.acquire(controller.signal)
1899
+ try {
1900
+ if (!active()) return
1901
+ await this.watch([pubkey], { scheduleReloadGap: !recover, signal: controller.signal })
1902
+ } finally { release() }
1903
+ if (!active()) return
1904
+ phase = 'presence'
1905
+ if (doesModeStoreRecoverySeeds(channel.mode)) await this.startPresencePublisher(pubkey)
1906
+ if (!active()) return
1907
+ phase = 'history'
1908
+ if (recover) await this.recoverOfflineRanges([pubkey])
1909
+ if (!active()) return
1910
+ if (recover && this.hasPendingRecovery(pubkey)) this.scheduleOfflineRecovery(pubkey, { startDelayMs: 1000 })
1911
+ }).catch(error => {
1912
+ if (!active()) return
1913
+ const diagnostic = Object.assign(new Error(error?.message || 'PRIVATE_MESSENGER_BACKGROUND_FAILED', { cause: error }), {
1914
+ name: error?.name || 'Error', ...(error?.code ? { code: error.code } : {}),
1915
+ ...(error?.category ? { category: error.category } : {}),
1916
+ operation: 'private-messenger.background', phase
1917
+ })
1918
+ this.onError?.(diagnostic)
1919
+ throw error
1920
+ })
1921
+ job.work = Promise.race([work, cancelled]).finally(() => {
1922
+ controller.signal.removeEventListener('abort', stopAbort)
1923
+ if (this.channelBackground.get(pubkey) === job) this.channelBackground.delete(pubkey)
1924
+ })
1925
+ job.work.catch(() => {})
1926
+ jobs.push(job.work)
1927
+ }
1928
+ return jobs
1836
1929
  }
1837
1930
 
1838
1931
  receive (pubkey, operation, message = {}) {
@@ -2336,16 +2429,16 @@ export class PrivateMessenger {
2336
2429
  })
2337
2430
  }
2338
2431
 
2339
- async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
2432
+ async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
2340
2433
  const channel = this.requireWritableChannel(channelPubkey)
2341
2434
  if (!this.offlineRecoverySecondsFor(channel)) return null
2342
2435
  const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
2343
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
2436
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, signal })
2344
2437
  this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
2345
2438
  return this._privateMessage.yell({
2346
- senderSigner: this.userSigner,
2347
- imkcSigner: this.contentKeySigner,
2348
- privateChannelSigner: channel.signer,
2439
+ senderSigner: backgroundSigner(this.userSigner, this._waitForForeground, signal),
2440
+ imkcSigner: backgroundSigner(this.contentKeySigner, this._waitForForeground, signal),
2441
+ privateChannelSigner: backgroundSigner(channel.signer, this._waitForForeground, signal),
2349
2442
  privateChannelReaderPubkey: channel.readerPubkey,
2350
2443
  receiverPubkeys,
2351
2444
  ...routing,
@@ -2362,22 +2455,40 @@ export class PrivateMessenger {
2362
2455
  async startPresencePublisher (channelPubkey) {
2363
2456
  if (this.pauseReasons.size || !this.desiredChannels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
2364
2457
  if (this.presenceTimers.has(channelPubkey)) return
2365
- try {
2366
- await this.publishSeederPresence(channelPubkey)
2367
- } catch (err) {
2368
- console.warn('private-messenger seeder presence failed', err?.message ?? err)
2458
+ if (this.presenceJobs.has(channelPubkey)) return this.presenceJobs.get(channelPubkey).work
2459
+ const job = { controller: new AbortController(), work: null }
2460
+ const active = () => this.presenceJobs.get(channelPubkey) === job && !this.pauseReasons.size && !this.closePromise && this.desiredChannels.has(channelPubkey)
2461
+ let publishing = false
2462
+ const publish = async () => {
2463
+ if (!active() || publishing) return
2464
+ publishing = true
2465
+ let release
2466
+ try {
2467
+ release = await this.presenceAdmission.acquire(job.controller.signal)
2468
+ job.controller.signal.addEventListener('abort', release, { once: true })
2469
+ if (active()) await this.publishSeederPresence(channelPubkey, { signal: job.controller.signal })
2470
+ } catch (error) {
2471
+ if (active()) this.onError?.(Object.assign(new Error(error?.message, { cause: error }), { operation: 'private-messenger.background', phase: 'presence' }))
2472
+ } finally {
2473
+ if (release) { job.controller.signal.removeEventListener('abort', release); release() }
2474
+ publishing = false
2475
+ }
2369
2476
  }
2370
- if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(channelPubkey)) return
2371
- const timer = this._setInterval(() => {
2372
- return this.publishSeederPresence(channelPubkey).catch(err => {
2373
- console.warn('private-messenger seeder presence failed', err?.message ?? err)
2374
- })
2375
- }, this.seederPresenceIntervalMs)
2376
- timer?.unref?.()
2377
- this.presenceTimers.set(channelPubkey, timer)
2477
+ const work = Promise.resolve().then(async () => {
2478
+ await publish()
2479
+ if (!active()) return
2480
+ const timer = this._setInterval(publish, this.seederPresenceIntervalMs)
2481
+ timer?.unref?.()
2482
+ this.presenceTimers.set(channelPubkey, timer)
2483
+ })
2484
+ job.work = work
2485
+ this.presenceJobs.set(channelPubkey, job)
2486
+ return work
2378
2487
  }
2379
2488
 
2380
2489
  stopPresencePublisher (channelPubkey) {
2490
+ this.presenceJobs.get(channelPubkey)?.controller.abort()
2491
+ this.presenceJobs.delete(channelPubkey)
2381
2492
  const timer = this.presenceTimers.get(channelPubkey)
2382
2493
  if (timer) this._clearInterval(timer)
2383
2494
  this.presenceTimers.delete(channelPubkey)
@@ -2458,7 +2569,7 @@ export class PrivateMessenger {
2458
2569
  return seconds ? seconds * 1000 : DEFAULT_RECEIVED_CHUNK_TTL_MS
2459
2570
  }
2460
2571
 
2461
- async applyRecoveryPolicies (channels) {
2572
+ async applyRecoveryPolicies (channels, { prune = true } = {}) {
2462
2573
  return this.runQueueOperation(async () => {
2463
2574
  const state = this.readState()
2464
2575
  const now = nowSeconds()
@@ -2484,7 +2595,7 @@ export class PrivateMessenger {
2484
2595
  for (const channel of channels) {
2485
2596
  if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
2486
2597
  }
2487
- for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2598
+ if (prune) for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
2488
2599
  })
2489
2600
  }
2490
2601
 
@@ -3070,6 +3181,7 @@ export class PrivateMessenger {
3070
3181
  let unwatchError
3071
3182
  try { await unwatchPromise } catch (err) { unwatchError = err }
3072
3183
  await initSettledPromise
3184
+ await this.updateTail.catch(() => {})
3073
3185
  await Promise.all([...this.extensions].map(extension => extension.close?.()))
3074
3186
  await this.stampActiveChannelActivity()
3075
3187
  await this.queueOperationTail
@@ -15,10 +15,35 @@ back to sender-content whenever a proof is missing or the lookup fails. Misses
15
15
  are cached for 5 minutes (found proofs for 40), so a later prefetch picks up a
16
16
  key published after an earlier miss. Set it to `false` when the signer owns that discovery. The DM context is `dm`; file contexts are `dm:media:<root>`.
17
17
  `createMessengerSigner` adapts this byte signer to the transport's Base64 API.
18
+ Each adapter belongs to an immutable account/channel identity: it shares the
19
+ first successful public-key lookup and retries failed lookups only on a new call.
20
+ Create a new adapter when changing identity. Signing/encryption still uses the
21
+ real signer and its current permissions on every operation.
18
22
 
19
- Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
23
+ Methods: `setPeers`, `setAvailable(boolean)`, `preparePeer(peer)`, `prefetchContentKeys(peers?)`,
20
24
  `prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
21
25
  `cancelDownload`, and `close`.
26
+
27
+ The session creates one messenger for the account, independently of contact
28
+ revisions. `setPeers` and `setAvailable` reconcile local configuration without
29
+ awaiting all contacts or remote recovery. Channel preparation is incremental and
30
+ deduplicated: at most four preparations run, with at most three in the background.
31
+ `preparePeer(peer)` promotes an existing contact and resolves when its channel is
32
+ locally writable. Call it when opening a conversation; enqueue, retry and remote
33
+ download also request preparation automatically. An unprepared conversation does
34
+ not block local saves or sends to a ready contact. Resolved identities are reused.
35
+ The session also defers the next optional presence signer call while user outbox
36
+ writes/publications are active. Existing signer calls, subscriptions and historical
37
+ coverage remain intact; releasing foreground work resumes the waiting presence.
38
+ Successful sends remove the final outbox record directly instead of encrypting a
39
+ redundant final checkpoint; intermediate context/file checkpoints remain durable.
40
+
41
+ Known transient preparation failures retry with bounded offline-aware backoff;
42
+ unknown, permission and validation failures remain isolated to that contact until
43
+ explicit preparation/retry or a real availability transition. Removing a contact,
44
+ locking or closing discards obsolete signer results and cancels its queued work.
45
+ Contact changes never invent a `signer` pause; only `setAvailable` controls that
46
+ reason. Storage, capacity, network and explicit pauses keep their own owners.
22
47
  `prefetchContentKeys` warms the content-key lookup cache for the owner and the
23
48
  given peers (default: current peers); it is best-effort and never rejects.
24
49
  `prioritizeRange` registers an in-memory priority window for a conversation: