libp2r2p 0.11.22 → 0.11.23

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
@@ -264,7 +264,36 @@ close. Missing-key retries have no polling timer. MAC failures permit three tota
264
264
  attempts, persisted across reloads; exhausted packets remain until FIFO eviction.
265
265
  `retryPendingDecryptions({ force: true })` explicitly retries exhausted packets.
266
266
  Success enters the normal acknowledged inbox before removing the pending item.
267
- The session coordinator forwards this API. Private-channel consumers may opt in
267
+ The session coordinator forwards this API.
268
+
269
+ Pending recovery waits for 250 ms without channel setup, live/history processing
270
+ or publication activity. Session foreground persistence/publication also delays
271
+ it. After at most five seconds of eligible waiting, a busy channel may advance
272
+ one packet, then receives a fresh deadline. Idle channels take batches of up to
273
+ four records, yielding a task between every record. A busy channel does not hold
274
+ up other channels; overdue channels precede the preferred foreground channel.
275
+ Only requested passes run: a missing key never starts a polling loop. Timer
276
+ throttling or an in-flight signer/storage call can extend actual elapsed time.
277
+
278
+ `pendingDecryptionReady` defaults to `true` on messenger/session construction.
279
+ Apps may pass `false` and call `setPendingDecryptionReady(true)` after their
280
+ initial interface is presented; `false` holds only pending recovery, not live
281
+ reception, publication or channel setup. The idle/deadline windows start after
282
+ release. `setPendingDecryptionPriority(pubkey | null)` selects a channel on the
283
+ messenger or a peer on the session; self-chat/`null` clear the session preference.
284
+ The session retains a peer preference until preparation finishes and clears it
285
+ on peer removal. Keep startup release and account lifecycle fencing in the app.
286
+
287
+ `retryPendingDecryptions()` resolves after the requested scheduled pass (including
288
+ an aggregated follow-up). `force: true` only overrides the MAC attempt ceiling;
289
+ it does not bypass availability or admission. Requests before the first snapshot
290
+ share that pass; requests during it aggregate into one following pass. Queue
291
+ reads wait for initial release. Each packet is revalidated under the identity
292
+ Web Lock when available; waits hold neither that lock nor the normal inbox/IDB
293
+ queues. Fragment assembly survives scheduling boundaries. Pause/close cancel
294
+ waits; channel removal fences its queued and in-flight results.
295
+
296
+ Private-channel consumers may opt in
268
297
  with `onDecryptionFailure({ error, record })`, returning true only after durable
269
298
  handling, and replay records with `replayDeferredDecryptions`. Without the hook,
270
299
  standalone readers retain their existing error behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.22",
3
+ "version": "0.11.23",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -717,7 +717,7 @@ function createProcessor ({
717
717
  onNymEvent,
718
718
  onSeedEvent,
719
719
  onContentKeyUsage,
720
- onDecryptionFailure, isDecryptionPending,
720
+ onDecryptionFailure, isDecryptionPending, _onProcessing,
721
721
  onError,
722
722
  receivedChunkScope = '',
723
723
  receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
@@ -741,7 +741,14 @@ function createProcessor ({
741
741
  storeReady.catch(() => {})
742
742
 
743
743
  async function processOuterEvent (outer) {
744
- await storeReady
744
+ const release = _onProcessing?.(outer.pubkey)
745
+ try {
746
+ await storeReady
747
+ return await processReadyOuterEvent(outer)
748
+ } finally { release?.() }
749
+ }
750
+
751
+ async function processReadyOuterEvent (outer) {
745
752
  let groupKey = ''
746
753
  let routerDecrypted = false
747
754
  try {
@@ -982,7 +989,7 @@ function shouldIgnoreGroupError (err) {
982
989
 
983
990
  // Replay ciphertext only. Keep one fragment processor for the entire batch;
984
991
  // incomplete outer groups remain pending until a later key/channel transition.
985
- export async function replayDeferredDecryptions ({ records, onRecovered, onFailure, ...options }) {
992
+ export async function replayDeferredDecryptions ({ records, onRecovered, onFailure, _runRecord = (_record, work) => work(), ...options }) {
986
993
  if (!Array.isArray(records) || records.some(record => typeof record?.id !== 'string' ||
987
994
  typeof record.channelPubkey !== 'string' || !['outer_v1', 'routerRow_v1'].includes(record.packet?.format))) throw new ValidationError('INVALID_DEFERRED_RECORDS')
988
995
  if (typeof onRecovered !== 'function' || typeof onFailure !== 'function') throw new ValidationError('DEFERRED_CALLBACKS_REQUIRED')
@@ -1017,44 +1024,48 @@ export async function replayDeferredDecryptions ({ records, onRecovered, onFailu
1017
1024
  })
1018
1025
  try {
1019
1026
  for (const record of records) {
1020
- options.signal?.throwIfAborted()
1021
- current = record
1022
- chunked = false
1023
- try {
1024
- if (record.packet.format === 'outer_v1') {
1025
- await processor(record.packet.outer)
1026
- if (!chunked) await onRecovered(record)
1027
- } else {
1028
- const event = await unwrapRouterEvent({
1029
- receiverSigner: options.receiverSigner, receiverPubkey: options.receiverPubkey,
1030
- channelPubkey: record.channelPubkey, router: record.packet.router
1031
- })
1032
- options.signal?.throwIfAborted()
1033
- if (event) {
1034
- await options.onEvent?.(event, record.packet.outer, {
1035
- router: record.packet.router, channelPubkey: record.channelPubkey,
1036
- ...deliveryInfo(event, readSenderTag(record.packet.router))
1027
+ await _runRecord(record, async () => {
1028
+ options.signal?.throwIfAborted()
1029
+ current = record
1030
+ chunked = false
1031
+ try {
1032
+ if (record.packet.format === 'outer_v1') {
1033
+ await processor(record.packet.outer)
1034
+ if (!chunked) await onRecovered(record)
1035
+ } else {
1036
+ const event = await unwrapRouterEvent({
1037
+ receiverSigner: options.receiverSigner, receiverPubkey: options.receiverPubkey,
1038
+ channelPubkey: record.channelPubkey, router: record.packet.router
1037
1039
  })
1040
+ options.signal?.throwIfAborted()
1041
+ if (event) {
1042
+ await options.onEvent?.(event, record.packet.outer, {
1043
+ router: record.packet.router, channelPubkey: record.channelPubkey,
1044
+ ...deliveryInfo(event, readSenderTag(record.packet.router))
1045
+ })
1046
+ }
1047
+ await onRecovered(record)
1038
1048
  }
1039
- await onRecovered(record)
1049
+ } catch (error) {
1050
+ options.signal?.throwIfAborted()
1051
+ await onFailure(record, decryptionError(error, { channelPubkey: record.channelPubkey, eventId: record.packet.outer?.id }))
1040
1052
  }
1041
- } catch (error) {
1042
- options.signal?.throwIfAborted()
1043
- await onFailure(record, decryptionError(error, { channelPubkey: record.channelPubkey, eventId: record.packet.outer?.id }))
1044
- }
1053
+ for (const key of completed) {
1054
+ for (const id of groups.get(key) || []) {
1055
+ const recovered = records.find(record => record.id === id)
1056
+ if (recovered) await onRecovered(recovered)
1057
+ }
1058
+ groups.delete(key)
1059
+ }
1060
+ completed.clear()
1061
+ })
1045
1062
  // Let live ingestion and foreground signer work run between records.
1046
1063
  await new Promise(resolve => setTimeout(resolve, 0))
1047
1064
  }
1048
- for (const key of completed) {
1049
- for (const id of groups.get(key) || []) {
1050
- const record = records.find(record => record.id === id)
1051
- if (record) await onRecovered(record)
1052
- }
1053
- }
1054
1065
  } finally { processor.close() }
1055
1066
  }
1056
1067
 
1057
- export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
1068
+ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
1058
1069
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
1059
1070
  const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
1060
1071
  const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
@@ -1073,7 +1084,7 @@ export async function fetch ({ signal, receiverSigner, iykcSigner, privateChanne
1073
1084
  const elapsedMs = Math.round(performance.now() - startedAt)
1074
1085
  const events = result.map(({ event }) => event)
1075
1086
  events.sort((a, b) => a.created_at - b.created_at)
1076
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1087
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1077
1088
  try {
1078
1089
  for (const event of events) {
1079
1090
  signal?.throwIfAborted()
@@ -1103,7 +1114,7 @@ export async function fetchHistory ({ receiverSigner, privateChannelSigner = rec
1103
1114
  } finally { processOuterEvent.close() }
1104
1115
  }
1105
1116
 
1106
- export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
1117
+ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkScope = '', receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
1107
1118
  if (!relays?.length) throw new ValidationError('NO_RELAYS')
1108
1119
  if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new ValidationError('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
1109
1120
  if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new ValidationError('PRIVATE_CHANNEL_READER_REQUIRED')
@@ -1112,7 +1123,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
1112
1123
  const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
1113
1124
  if (authors.length) filter.authors = authors
1114
1125
  if (limit != null) filter.limit = limit
1115
- const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1126
+ const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onError, receivedChunkScope, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
1116
1127
  const controller = new AbortController()
1117
1128
  const events = liveOnly
1118
1129
  ? _liveEventsGenerator(filter, relays, {
@@ -477,6 +477,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
477
477
  return dispatchSeedEvent(seed)
478
478
  },
479
479
  onContentKeyUsage: dispatchContentKeyUsage,
480
+ _onProcessing: pubkey => watchCallbacks(pubkey)._onProcessing?.(pubkey),
480
481
  isDecryptionPending: record => watchCallbacks(record.channelPubkey).isDecryptionPending?.(record),
481
482
  onDecryptionFailure: failure => watchCallbacks(failure.record.channelPubkey).onDecryptionFailure?.(failure),
482
483
  onError: err => reportSubscriptionError(channels, err)
@@ -524,7 +525,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
524
525
  onSeed,
525
526
  onChunk,
526
527
  onContentKeyUsage,
527
- onDecryptionFailure, isDecryptionPending,
528
+ onDecryptionFailure, isDecryptionPending, _onProcessing,
528
529
  onSubscriptionState,
529
530
  onError,
530
531
  receivedChunkTtlMs,
@@ -545,7 +546,7 @@ export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearT
545
546
  const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
546
547
  if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
547
548
  identity = ownPubkey
548
- const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, onSubscriptionState, onError }
549
+ const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onDecryptionFailure, isDecryptionPending, _onProcessing, onSubscriptionState, onError }
549
550
 
550
551
  for (const channel of channelList) {
551
552
  if (watchRequests.get(channel) !== request) continue
@@ -56,6 +56,7 @@ import { normalizeRelayUrl } from '../url/index.js'
56
56
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
57
57
  import { migrateMessageQueue } from './services/migrate-message-queue.js'
58
58
  import { createPendingDecryption } from './services/pending-decryption.js'
59
+ import { createDecryptionScheduler } from './services/decryption-scheduler.js'
59
60
  import { decryptionError, isDeferredDecryptionError } from '../private-channel/helpers/decryption-error.js'
60
61
  import { bytesToBase64 } from '../base64/index.js'
61
62
  import { ValidationError } from '../error/index.js'
@@ -236,6 +237,7 @@ export class PrivateMessenger {
236
237
  autoDeletionCapability = true,
237
238
  _indexedDB = globalThis.indexedDB,
238
239
  useContentKeys = true,
240
+ pendingDecryptionReady = true,
239
241
  onContentKeyChange,
240
242
  onMessageQueued,
241
243
  onStateChanged,
@@ -248,6 +250,8 @@ export class PrivateMessenger {
248
250
  _subscribeRelayListUpdates = subscribeRelayListUpdates,
249
251
  _isOnline = isOnline,
250
252
  _waitForForeground,
253
+ _observeForeground,
254
+ _now = Date.now,
251
255
  _onOnline = onOnline,
252
256
  _random = Math.random,
253
257
  _setTimeout = globalThis.setTimeout.bind(globalThis),
@@ -259,6 +263,7 @@ export class PrivateMessenger {
259
263
  _BroadcastChannel = _indexedDB === globalThis.indexedDB ? globalThis.BroadcastChannel : undefined
260
264
  } = {}) {
261
265
  if (onStateChanged != null && typeof onStateChanged !== 'function') throw new ValidationError('INVALID_ON_STATE_CHANGED')
266
+ if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
262
267
  this.fallbackRelays = normalizeFallbackRelays(fallbackRelays)
263
268
  this.fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
264
269
  this.offlineRecoverySeconds = normalizeOfflineRecoverySeconds(offlineRecoverySeconds)
@@ -383,6 +388,9 @@ export class PrivateMessenger {
383
388
  this.closePromise = null
384
389
  this.initSettledPromise = null
385
390
  this.initialized = false
391
+ this.pendingDecryptionReady = pendingDecryptionReady
392
+ this.decryptionScheduler = createDecryptionScheduler({ ready: pendingDecryptionReady, now: _now, setTimer: _setTimeout, clearTimer: _clearTimeout })
393
+ this.stopDecryptionForeground = _observeForeground?.(active => this.decryptionScheduler.foreground(active))
386
394
  this.notifyStatus()
387
395
  }
388
396
 
@@ -1222,6 +1230,7 @@ export class PrivateMessenger {
1222
1230
  onSeed: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
1223
1231
  onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
1224
1232
  onDecryptionFailure: failure => this.deferDecryption(failure),
1233
+ _onProcessing: pubkey => this.decryptionScheduler.activity(pubkey),
1225
1234
  isDecryptionPending: record => this.pendingDecryption.has(record),
1226
1235
  receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
1227
1236
  receivedChunkIndexedDB: this._indexedDB,
@@ -1734,7 +1743,10 @@ export class PrivateMessenger {
1734
1743
  unwatch (channels) {
1735
1744
  const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
1736
1745
  for (const extension of this.extensions) extension.unwatch?.(pubkeys)
1737
- for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
1746
+ for (const pubkey of pubkeys) {
1747
+ this.desiredChannels.delete(pubkey)
1748
+ this.decryptionScheduler.remove(pubkey)
1749
+ }
1738
1750
  for (const [key, pending] of this.pendingStorageWrites) if (pubkeys.includes(pending.record.channelPubkey)) this.pendingStorageWrites.delete(key)
1739
1751
  this.syncPauseRecovery()
1740
1752
  for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
@@ -1904,6 +1916,7 @@ export class PrivateMessenger {
1904
1916
  if (job) { jobs.push(job.work); continue }
1905
1917
  const controller = new AbortController()
1906
1918
  const channel = this.channels.get(pubkey)
1919
+ const releaseActivity = this.decryptionScheduler.activity(pubkey)
1907
1920
  job = { controller, work: null }
1908
1921
  this.channelBackground.set(pubkey, job)
1909
1922
  const active = () => this.initialized && !controller.signal.aborted && !this.closePromise && !this.pauseReasons.size && this.channels.get(pubkey) === channel && this.desiredChannels.has(pubkey)
@@ -1940,6 +1953,7 @@ export class PrivateMessenger {
1940
1953
  throw error
1941
1954
  })
1942
1955
  job.work = Promise.race([work, cancelled]).finally(() => {
1956
+ releaseActivity()
1943
1957
  controller.signal.removeEventListener('abort', stopAbort)
1944
1958
  if (this.channelBackground.get(pubkey) === job) this.channelBackground.delete(pubkey)
1945
1959
  })
@@ -1949,18 +1963,19 @@ export class PrivateMessenger {
1949
1963
  return jobs
1950
1964
  }
1951
1965
 
1952
- receive (pubkey, operation, message = {}) {
1966
+ receive (pubkey, operation, message = {}, { trackActivity = true } = {}) {
1953
1967
  if (this.closePromise) {
1954
1968
  if (this.storageActive) this.recordInterruption([pubkey], messageTime(message))
1955
1969
  return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
1956
1970
  }
1971
+ const releaseActivity = trackActivity ? this.decryptionScheduler.activity(pubkey) : () => {}
1957
1972
  return this.queueIncoming(async () => {
1958
1973
  if (this.pauseReasons.size || !this.desiredChannels.has(pubkey)) {
1959
1974
  this.recordInterruption([pubkey], messageTime(message))
1960
1975
  throw this.pausedError()
1961
1976
  }
1962
1977
  return operation()
1963
- })
1978
+ }).finally(releaseActivity)
1964
1979
  }
1965
1980
 
1966
1981
  nip65WatchChannelPubkeys () {
@@ -2274,180 +2289,204 @@ export class PrivateMessenger {
2274
2289
 
2275
2290
  async ask ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
2276
2291
  const channel = this.requireWritableChannel(channelPubkey)
2277
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
2278
- this.debugSend('ask', channelPubkey, { code, receiverPubkey })
2279
- return this._privateMessage.ask({
2280
- senderSigner: this.userSigner,
2281
- imkcSigner: this.contentKeySigner,
2282
- privateChannelSigner: channel.signer,
2283
- privateChannelReaderPubkey: channel.readerPubkey,
2284
- receiverPubkey,
2285
- ...routing,
2286
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2287
- temporaryStorageArea: this.temporaryStorageArea,
2288
- deletionPubkey,
2289
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2290
- message,
2291
- code,
2292
- payload,
2293
- error,
2294
- content,
2295
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2296
- _getIykcProofs: this.contentKeyLookup()
2297
- })
2292
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2293
+ try {
2294
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
2295
+ this.debugSend('ask', channelPubkey, { code, receiverPubkey })
2296
+ return await this._privateMessage.ask({
2297
+ senderSigner: this.userSigner,
2298
+ imkcSigner: this.contentKeySigner,
2299
+ privateChannelSigner: channel.signer,
2300
+ privateChannelReaderPubkey: channel.readerPubkey,
2301
+ receiverPubkey,
2302
+ ...routing,
2303
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2304
+ temporaryStorageArea: this.temporaryStorageArea,
2305
+ deletionPubkey,
2306
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2307
+ message,
2308
+ code,
2309
+ payload,
2310
+ error,
2311
+ content,
2312
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2313
+ _getIykcProofs: this.contentKeyLookup()
2314
+ })
2315
+ } finally { releaseActivity() }
2298
2316
  }
2299
2317
 
2300
2318
  async reply ({ channelPubkey = this.defaultChannelPubkey(), question, receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
2301
2319
  const channel = this.requireWritableChannel(channelPubkey)
2302
- const resolvedReceiverPubkey = receiverPubkey || question?.pubkey || ''
2303
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [resolvedReceiverPubkey], relays, relayToReceivers })
2304
- this.debugSend('reply', channelPubkey, { code, receiverPubkey: receiverPubkey || question?.pubkey || '' })
2305
- return this._privateMessage.reply({
2306
- senderSigner: this.userSigner,
2307
- imkcSigner: this.contentKeySigner,
2308
- privateChannelSigner: channel.signer,
2309
- privateChannelReaderPubkey: channel.readerPubkey,
2310
- question,
2311
- receiverPubkey,
2312
- ...routing,
2313
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2314
- temporaryStorageArea: this.temporaryStorageArea,
2315
- deletionPubkey,
2316
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2317
- message,
2318
- code,
2319
- payload,
2320
- error,
2321
- content,
2322
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2323
- _getIykcProofs: this.contentKeyLookup()
2324
- })
2320
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2321
+ try {
2322
+ const resolvedReceiverPubkey = receiverPubkey || question?.pubkey || ''
2323
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [resolvedReceiverPubkey], relays, relayToReceivers })
2324
+ this.debugSend('reply', channelPubkey, { code, receiverPubkey: receiverPubkey || question?.pubkey || '' })
2325
+ return await this._privateMessage.reply({
2326
+ senderSigner: this.userSigner,
2327
+ imkcSigner: this.contentKeySigner,
2328
+ privateChannelSigner: channel.signer,
2329
+ privateChannelReaderPubkey: channel.readerPubkey,
2330
+ question,
2331
+ receiverPubkey,
2332
+ ...routing,
2333
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2334
+ temporaryStorageArea: this.temporaryStorageArea,
2335
+ deletionPubkey,
2336
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2337
+ message,
2338
+ code,
2339
+ payload,
2340
+ error,
2341
+ content,
2342
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2343
+ _getIykcProofs: this.contentKeyLookup()
2344
+ })
2345
+ } finally { releaseActivity() }
2325
2346
  }
2326
2347
 
2327
2348
  async tell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
2328
2349
  const channel = this.requireWritableChannel(channelPubkey)
2329
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
2330
- this.debugSend('tell', channelPubkey, { code, receiverPubkey })
2331
- return this._privateMessage.tell({
2332
- senderSigner: this.userSigner,
2333
- imkcSigner: this.contentKeySigner,
2334
- privateChannelSigner: channel.signer,
2335
- privateChannelReaderPubkey: channel.readerPubkey,
2336
- receiverPubkey,
2337
- ...routing,
2338
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2339
- temporaryStorageArea: this.temporaryStorageArea,
2340
- deletionPubkey,
2341
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2342
- message,
2343
- code,
2344
- payload,
2345
- error,
2346
- content,
2347
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2348
- _getIykcProofs: this.contentKeyLookup()
2349
- })
2350
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2351
+ try {
2352
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
2353
+ this.debugSend('tell', channelPubkey, { code, receiverPubkey })
2354
+ return await this._privateMessage.tell({
2355
+ senderSigner: this.userSigner,
2356
+ imkcSigner: this.contentKeySigner,
2357
+ privateChannelSigner: channel.signer,
2358
+ privateChannelReaderPubkey: channel.readerPubkey,
2359
+ receiverPubkey,
2360
+ ...routing,
2361
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2362
+ temporaryStorageArea: this.temporaryStorageArea,
2363
+ deletionPubkey,
2364
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2365
+ message,
2366
+ code,
2367
+ payload,
2368
+ error,
2369
+ content,
2370
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2371
+ _getIykcProofs: this.contentKeyLookup()
2372
+ })
2373
+ } finally { releaseActivity() }
2350
2374
  }
2351
2375
 
2352
2376
  async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
2353
2377
  const channel = this.requireWritableChannel(channelPubkey)
2354
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2355
- this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
2356
- return this._privateMessage.yell({
2357
- senderSigner: this.userSigner,
2358
- imkcSigner: this.contentKeySigner,
2359
- privateChannelSigner: channel.signer,
2360
- privateChannelReaderPubkey: channel.readerPubkey,
2361
- receiverPubkeys,
2362
- ...routing,
2363
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2364
- temporaryStorageArea: this.temporaryStorageArea,
2365
- deletionPubkey,
2366
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2367
- message,
2368
- code,
2369
- payload,
2370
- error,
2371
- content,
2372
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2373
- _getIykcProofs: this.contentKeyLookup()
2374
- })
2378
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2379
+ try {
2380
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2381
+ this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
2382
+ return await this._privateMessage.yell({
2383
+ senderSigner: this.userSigner,
2384
+ imkcSigner: this.contentKeySigner,
2385
+ privateChannelSigner: channel.signer,
2386
+ privateChannelReaderPubkey: channel.readerPubkey,
2387
+ receiverPubkeys,
2388
+ ...routing,
2389
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2390
+ temporaryStorageArea: this.temporaryStorageArea,
2391
+ deletionPubkey,
2392
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2393
+ message,
2394
+ code,
2395
+ payload,
2396
+ error,
2397
+ content,
2398
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2399
+ _getIykcProofs: this.contentKeyLookup()
2400
+ })
2401
+ } finally { releaseActivity() }
2375
2402
  }
2376
2403
 
2377
2404
  async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal }) {
2378
2405
  const channel = this.requireWritableChannel(channelPubkey)
2379
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2380
- this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
2381
- return this._privateMessage.broadcastRumor({
2382
- senderSigner: this.userSigner,
2383
- imkcSigner: this.contentKeySigner,
2384
- privateChannelSigner: channel.signer,
2385
- privateChannelReaderPubkey: channel.readerPubkey,
2386
- receiverPubkeys,
2387
- ...routing,
2388
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2389
- temporaryStorageArea: this.temporaryStorageArea,
2390
- deletionPubkey,
2391
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2392
- rumor,
2393
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2394
- _getIykcProofs: this.contentKeyLookup()
2395
- })
2406
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2407
+ try {
2408
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2409
+ this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
2410
+ return await this._privateMessage.broadcastRumor({
2411
+ senderSigner: this.userSigner,
2412
+ imkcSigner: this.contentKeySigner,
2413
+ privateChannelSigner: channel.signer,
2414
+ privateChannelReaderPubkey: channel.readerPubkey,
2415
+ receiverPubkeys,
2416
+ ...routing,
2417
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2418
+ temporaryStorageArea: this.temporaryStorageArea,
2419
+ deletionPubkey,
2420
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2421
+ rumor,
2422
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2423
+ _getIykcProofs: this.contentKeyLookup()
2424
+ })
2425
+ } finally { releaseActivity() }
2396
2426
  }
2397
2427
 
2398
2428
  async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal }) {
2399
2429
  const channel = this.requireWritableChannel(channelPubkey)
2400
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2401
- this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
2402
- return this._privateMessage.broadcastEvent({
2403
- senderSigner: this.userSigner,
2404
- imkcSigner: this.contentKeySigner,
2405
- privateChannelSigner: channel.signer,
2406
- privateChannelReaderPubkey: channel.readerPubkey,
2407
- receiverPubkeys,
2408
- ...routing,
2409
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2410
- temporaryStorageArea: this.temporaryStorageArea,
2411
- deletionPubkey,
2412
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2413
- event,
2414
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2415
- _getIykcProofs: this.contentKeyLookup()
2416
- })
2430
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2431
+ try {
2432
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal })
2433
+ this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
2434
+ return await this._privateMessage.broadcastEvent({
2435
+ senderSigner: this.userSigner,
2436
+ imkcSigner: this.contentKeySigner,
2437
+ privateChannelSigner: channel.signer,
2438
+ privateChannelReaderPubkey: channel.readerPubkey,
2439
+ receiverPubkeys,
2440
+ ...routing,
2441
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2442
+ temporaryStorageArea: this.temporaryStorageArea,
2443
+ deletionPubkey,
2444
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2445
+ event,
2446
+ onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2447
+ _getIykcProofs: this.contentKeyLookup()
2448
+ })
2449
+ } finally { releaseActivity() }
2417
2450
  }
2418
2451
 
2419
2452
  async broadcastNymRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, nymSigner, deletionPubkey }) {
2420
2453
  const channel = this.requireWritableChannel(channelPubkey)
2421
- const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
2422
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2423
- this.debugSend('broadcastNymRumor', channelPubkey, { receiverPubkeys })
2424
- return this._privateMessage.broadcastNymRumor({
2425
- nymSigner: resolvedNymSigner,
2426
- privateChannelSigner: channel.signer,
2427
- privateChannelReaderPubkey: channel.readerPubkey,
2428
- ...routing,
2429
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2430
- deletionPubkey,
2431
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2432
- rumor
2433
- })
2454
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2455
+ try {
2456
+ const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
2457
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2458
+ this.debugSend('broadcastNymRumor', channelPubkey, { receiverPubkeys })
2459
+ return await this._privateMessage.broadcastNymRumor({
2460
+ nymSigner: resolvedNymSigner,
2461
+ privateChannelSigner: channel.signer,
2462
+ privateChannelReaderPubkey: channel.readerPubkey,
2463
+ ...routing,
2464
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2465
+ deletionPubkey,
2466
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2467
+ rumor
2468
+ })
2469
+ } finally { releaseActivity() }
2434
2470
  }
2435
2471
 
2436
2472
  async broadcastNymEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, nymSigner, deletionPubkey }) {
2437
2473
  const channel = this.requireWritableChannel(channelPubkey)
2438
- const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
2439
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2440
- this.debugSend('broadcastNymEvent', channelPubkey, { receiverPubkeys })
2441
- return this._privateMessage.broadcastNymEvent({
2442
- nymSigner: resolvedNymSigner,
2443
- privateChannelSigner: channel.signer,
2444
- privateChannelReaderPubkey: channel.readerPubkey,
2445
- ...routing,
2446
- expirationSeconds: this.eventExpirationSecondsFor(channel),
2447
- deletionPubkey,
2448
- autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2449
- event
2450
- })
2474
+ const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2475
+ try {
2476
+ const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
2477
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2478
+ this.debugSend('broadcastNymEvent', channelPubkey, { receiverPubkeys })
2479
+ return await this._privateMessage.broadcastNymEvent({
2480
+ nymSigner: resolvedNymSigner,
2481
+ privateChannelSigner: channel.signer,
2482
+ privateChannelReaderPubkey: channel.readerPubkey,
2483
+ ...routing,
2484
+ expirationSeconds: this.eventExpirationSecondsFor(channel),
2485
+ deletionPubkey,
2486
+ autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2487
+ event
2488
+ })
2489
+ } finally { releaseActivity() }
2451
2490
  }
2452
2491
 
2453
2492
  async publishSeederPresence (channelPubkey = this.defaultChannelPubkey(), { signal } = {}) {
@@ -2881,6 +2920,7 @@ export class PrivateMessenger {
2881
2920
  onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
2882
2921
  onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
2883
2922
  onDecryptionFailure: failure => this.deferDecryption(failure),
2923
+ _onProcessing: pubkey => this.decryptionScheduler.activity(pubkey),
2884
2924
  isDecryptionPending: record => this.pendingDecryption.has(record),
2885
2925
  onError: err => { throw err }
2886
2926
  }
@@ -3146,22 +3186,36 @@ export class PrivateMessenger {
3146
3186
  }
3147
3187
  }
3148
3188
 
3189
+ setPendingDecryptionReady (ready) {
3190
+ if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
3191
+ this.pendingDecryptionReady = ready
3192
+ this.decryptionScheduler.setReady(ready)
3193
+ }
3194
+
3195
+ setPendingDecryptionPriority (pubkey) {
3196
+ if (pubkey !== null && !/^[0-9a-f]{64}$/.test(pubkey || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
3197
+ this.decryptionScheduler.priority(pubkey)
3198
+ }
3199
+
3149
3200
  retryPendingDecryptions ({ force = false } = {}) {
3150
3201
  if (typeof force !== 'boolean') throw new ValidationError('INVALID_FORCE')
3151
3202
  if (this.closePromise || this.pauseReasons.size || !this.initialized) return Promise.resolve()
3152
3203
  if (this.decryptionRetry && !this.decryptionRetry.controller.signal.aborted) {
3153
- this.decryptionRetry.again = true
3204
+ // Before a pass reads its snapshot, new requests belong to that pass.
3205
+ if (this.decryptionRetry.started) this.decryptionRetry.again = true
3154
3206
  this.decryptionRetry.force ||= force
3155
3207
  return this.decryptionRetry.work
3156
3208
  }
3157
- const job = { controller: new AbortController(), again: false, force, work: null }
3209
+ const job = { controller: new AbortController(), again: false, force, started: false, work: null }
3158
3210
  const previous = this.decryptionRetry?.work
3159
3211
  this.decryptionRetry = job
3160
3212
  const { signal } = job.controller
3161
3213
  const run = async () => {
3162
3214
  do {
3215
+ await this.decryptionScheduler.waitReady(signal)
3163
3216
  signal.throwIfAborted()
3164
3217
  job.again = false
3218
+ job.started = true
3165
3219
  const force = job.force
3166
3220
  job.force = false
3167
3221
  const groups = new Map()
@@ -3169,35 +3223,59 @@ export class PrivateMessenger {
3169
3223
  if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3170
3224
  groups.get(record.channelPubkey).push(record)
3171
3225
  }
3172
- for (const [channelPubkey, records] of groups) {
3173
- signal.throwIfAborted()
3226
+ const results = await Promise.allSettled([...groups].map(async ([channelPubkey, records]) => {
3174
3227
  const channel = this.channels.get(channelPubkey)
3175
- if (!channel || !this.desiredChannels.has(channelPubkey)) continue
3176
- const current = () => { signal.throwIfAborted(); if (this.channels.get(channelPubkey) !== channel) throw new Error('PRIVATE_CHANNEL_CHANGED') }
3177
- await this._privateChannel.replayDeferredDecryptions({
3178
- records, signal, receiverSigner: this.userSigner, receiverPubkey: this.userPubkey,
3179
- privateChannelSigner: channel.signer, privateChannelReaderSigner: channel.readerSigner,
3180
- privateChannelReaderPubkey: channel.readerPubkey, mode: channel.mode,
3181
- receivedChunkScope: `${this.prefix}:pending`, receivedChunkIndexedDB: this._indexedDB,
3182
- onEvent: (event, outer, meta) => {
3183
- current()
3184
- return this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer })
3185
- },
3186
- onNymEvent: (event, outer, meta) => {
3187
- current()
3188
- return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer })
3189
- },
3190
- onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed) },
3191
- onRecovered: record => { current(); return this.pendingDecryption.recovered(record) },
3192
- onFailure: (record, error) => { current(); return this.pendingDecryption.failed(record, error) }
3193
- })
3194
- }
3228
+ if (!channel || !this.desiredChannels.has(channelPubkey)) return
3229
+ const current = () => {
3230
+ signal.throwIfAborted()
3231
+ if (this.pauseReasons.size || this.channels.get(channelPubkey) !== channel || !this.desiredChannels.has(channelPubkey)) throw new Error('PRIVATE_CHANNEL_CHANGED')
3232
+ }
3233
+ try {
3234
+ await this._privateChannel.replayDeferredDecryptions({
3235
+ records, signal, receiverSigner: this.userSigner, receiverPubkey: this.userPubkey,
3236
+ privateChannelSigner: channel.signer, privateChannelReaderSigner: channel.readerSigner,
3237
+ privateChannelReaderPubkey: channel.readerPubkey, mode: channel.mode,
3238
+ receivedChunkScope: `${this.prefix}:pending`, receivedChunkIndexedDB: this._indexedDB,
3239
+ _runRecord: async (record, process) => {
3240
+ let done = false
3241
+ while (!done) {
3242
+ current()
3243
+ done = await this.decryptionScheduler.run(channelPubkey, () => {
3244
+ const attempt = async () => {
3245
+ current()
3246
+ if (!this.pendingDecryptionReady) return false
3247
+ if (await this.pendingDecryption.current(record, { force })) await process()
3248
+ return true
3249
+ }
3250
+ const locks = globalThis.navigator?.locks
3251
+ return locks?.request ? locks.request(`${this.prefix}:pending-decryption:retry`, { signal }, attempt) : attempt()
3252
+ }, signal)
3253
+ }
3254
+ },
3255
+ onEvent: (event, outer, meta) => {
3256
+ current()
3257
+ return this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }, { trackActivity: false })
3258
+ },
3259
+ onNymEvent: (event, outer, meta) => {
3260
+ current()
3261
+ return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }, { trackActivity: false })
3262
+ },
3263
+ onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed, { trackActivity: false }) },
3264
+ onRecovered: record => { current(); return this.pendingDecryption.recovered(record) },
3265
+ onFailure: (record, error) => { current(); return this.pendingDecryption.failed(record, error) }
3266
+ })
3267
+ } catch (error) {
3268
+ if (this.channels.get(channelPubkey) === channel && this.desiredChannels.has(channelPubkey)) throw error
3269
+ } finally { this.decryptionScheduler.finish(channelPubkey) }
3270
+ }))
3271
+ const failed = results.find(result => result.status === 'rejected')
3272
+ if (failed) throw failed.reason
3273
+ job.started = false
3195
3274
  } while (job.again)
3196
3275
  }
3197
- job.work = Promise.resolve(previous).catch(() => {}).then(() => {
3198
- const locks = globalThis.navigator?.locks
3199
- return locks?.request ? locks.request(`${this.prefix}:pending-decryption:retry`, { signal }, run) : run()
3200
- }).catch(error => { if (!signal.aborted) throw error }).finally(() => {
3276
+ job.work = Promise.resolve(previous).catch(() => {}).then(run).catch(error => {
3277
+ if (!signal.aborted) throw error
3278
+ }).finally(() => {
3201
3279
  if (this.decryptionRetry === job) this.decryptionRetry = null
3202
3280
  })
3203
3281
  return job.work
@@ -3256,6 +3334,8 @@ export class PrivateMessenger {
3256
3334
 
3257
3335
  close () {
3258
3336
  this.decryptionRetry?.controller.abort(new Error('PRIVATE_MESSENGER_CLOSED'))
3337
+ this.decryptionScheduler.close()
3338
+ this.stopDecryptionForeground?.()
3259
3339
  if (this.closePromise) return this.closePromise
3260
3340
  const initSettledPromise = this.initSettledPromise
3261
3341
  this.sendRoutingLifetime.abort()
@@ -0,0 +1,135 @@
1
+ // Admission only: never wait while holding an inbox operation, IDB transaction
2
+ // or the cross-instance retry lock. A busy channel cannot stall another one.
3
+ export function createDecryptionScheduler ({ ready = true, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
4
+ const channels = new Map()
5
+ const readyWaiters = new Set()
6
+ let timer
7
+ let running = false
8
+ let closed
9
+ let foreground = false
10
+ let foregroundAt = now()
11
+ let priority = null
12
+ let previous
13
+ let batch = 0
14
+ const stateFor = pubkey => {
15
+ if (!channels.has(pubkey)) channels.set(pubkey, { active: 0, at: now(), deadline: null, jobs: [] })
16
+ return channels.get(pubkey)
17
+ }
18
+ const quietAt = state => state.active || foreground ? Infinity : Math.max(state.at, foregroundAt) + 250
19
+ const schedule = () => {
20
+ if (timer !== undefined) clearTimer(timer)
21
+ timer = undefined
22
+ if (closed || !ready || running) return
23
+ const pending = [...channels.values()].filter(state => state.jobs.length)
24
+ if (!pending.length) return
25
+ const at = Math.min(...pending.map(state => Math.min(quietAt(state), state.deadline)))
26
+ timer = setTimer(pump, Math.max(0, at - now()))
27
+ }
28
+ async function pump () {
29
+ timer = undefined
30
+ if (closed || !ready || running) return
31
+ const time = now()
32
+ const eligible = [...channels].filter(([, state]) => state.jobs.length && Math.min(quietAt(state), state.deadline) <= time)
33
+ eligible.sort(([a, x], [b, y]) => {
34
+ const overdue = Number(y.deadline <= time) - Number(x.deadline <= time)
35
+ if (overdue) return overdue
36
+ if (x.deadline <= time && x.deadline !== y.deadline) return x.deadline - y.deadline
37
+ if (a === previous && batch >= 4) return 1
38
+ if (b === previous && batch >= 4) return -1
39
+ return Number(b === priority) - Number(a === priority)
40
+ })
41
+ if (!eligible.length) { schedule(); return }
42
+ const [pubkey, state] = eligible[0]
43
+ const job = state.jobs.shift()
44
+ running = true
45
+ if (previous === pubkey) batch++
46
+ else { previous = pubkey; batch = 1 }
47
+ try {
48
+ job.signal?.throwIfAborted()
49
+ job.resolve(await job.work())
50
+ } catch (error) { job.reject(error) } finally {
51
+ job.signal?.removeEventListener('abort', job.abort)
52
+ state.deadline = now() + 5000
53
+ running = false
54
+ // Always use a new task, including between records in the same batch.
55
+ schedule()
56
+ }
57
+ }
58
+ const rejectJobs = (state, error) => {
59
+ for (const job of state.jobs.splice(0)) {
60
+ job.signal?.removeEventListener('abort', job.abort)
61
+ job.reject(error)
62
+ }
63
+ }
64
+ return {
65
+ activity (pubkey) {
66
+ if (closed) return () => {}
67
+ const state = stateFor(pubkey)
68
+ state.active++; state.at = now(); schedule()
69
+ let released = false
70
+ return () => {
71
+ if (released) return
72
+ released = true
73
+ state.active--; state.at = now(); schedule()
74
+ }
75
+ },
76
+ foreground (active) { foreground = active; foregroundAt = now(); schedule() },
77
+ priority (pubkey) { priority = pubkey; schedule() },
78
+ setReady (value) {
79
+ if (ready === value || closed) return
80
+ ready = value
81
+ if (ready) {
82
+ for (const state of channels.values()) { state.at = now(); state.deadline = state.jobs.length ? now() + 5000 : null }
83
+ for (const wake of [...readyWaiters]) wake()
84
+ }
85
+ schedule()
86
+ },
87
+ async waitReady (signal) {
88
+ while (true) {
89
+ signal?.throwIfAborted()
90
+ if (closed) throw closed
91
+ if (ready) return
92
+ await new Promise((resolve, reject) => {
93
+ const finish = () => {
94
+ readyWaiters.delete(finish); signal?.removeEventListener('abort', finish)
95
+ if (closed || signal?.aborted) reject(closed || signal.reason)
96
+ else resolve()
97
+ }
98
+ readyWaiters.add(finish); signal?.addEventListener('abort', finish, { once: true })
99
+ })
100
+ }
101
+ },
102
+ run (pubkey, work, signal) {
103
+ signal?.throwIfAborted()
104
+ if (closed) return Promise.reject(closed)
105
+ const state = stateFor(pubkey)
106
+ state.deadline ??= now() + 5000
107
+ return new Promise((resolve, reject) => {
108
+ const job = { work, resolve, reject, signal }
109
+ job.abort = () => {
110
+ const index = state.jobs.indexOf(job)
111
+ if (index < 0) return
112
+ state.jobs.splice(index, 1); reject(signal.reason); schedule()
113
+ }
114
+ signal?.addEventListener('abort', job.abort, { once: true })
115
+ state.jobs.push(job); schedule()
116
+ })
117
+ },
118
+ remove (pubkey) {
119
+ const state = channels.get(pubkey)
120
+ if (state) rejectJobs(state, new Error('PRIVATE_CHANNEL_CHANGED'))
121
+ channels.delete(pubkey); schedule()
122
+ },
123
+ finish (pubkey) {
124
+ const state = channels.get(pubkey)
125
+ if (state && !state.jobs.length) state.deadline = null
126
+ },
127
+ close () {
128
+ closed ||= new Error('PRIVATE_MESSENGER_CLOSED')
129
+ if (timer !== undefined) clearTimer(timer)
130
+ for (const state of channels.values()) rejectJobs(state, closed)
131
+ channels.clear()
132
+ for (const wake of [...readyWaiters]) wake()
133
+ }
134
+ }
135
+ }
@@ -98,6 +98,12 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
98
98
  for await (const item of queue.storedItems()) if (force || item.macAttempts < 3) records.push(item)
99
99
  return records
100
100
  },
101
+ current (item, { force = false } = {}) {
102
+ return serial(async () => {
103
+ const stored = await queue.getBy('byId', item.id)
104
+ return stored && (force || stored.macAttempts < 3) ? stored : null
105
+ })
106
+ },
101
107
  recovered (item) {
102
108
  return serial(async () => {
103
109
  await queue.removeBy('byId', item.id)
@@ -10,6 +10,19 @@ ordinary availability recovery already requests a pass. Ciphertext stays in the
10
10
  separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
11
11
  cleared. This does not retry application storage/permission failures as crypto.
12
12
 
13
+ Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
14
+ hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
15
+ them after initial presentation. This never pauses live reception, sends or
16
+ channel preparation. Recovery waits for 250 ms of channel inactivity; a five-second
17
+ maximum admits one packet during sustained activity. It yields between records,
18
+ with at most four consecutive preferred records before another eligible channel.
19
+ `setPendingDecryptionPriority(peer | null)` retains a peer preference through
20
+ preparation; self-chat/`null` or removal clear it. Deadlines prevent preference
21
+ from starving other channels. Account unavailability/close still cancel recovery;
22
+ `force` bypasses only the MAC attempt limit. Awaiting the retry API awaits its
23
+ scheduled pass, so do not await it on the interface's critical startup path.
24
+
25
+
13
26
  Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
14
27
  `eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
15
28
  Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
@@ -3,15 +3,19 @@
3
3
  export function createForegroundWork () {
4
4
  let active = 0
5
5
  const waiters = new Set()
6
+ const observers = new Set()
7
+ const notify = () => { for (const listener of observers) listener(active > 0) }
6
8
  const lifetime = new AbortController()
7
9
  return {
8
10
  enter () {
9
11
  active++
12
+ notify()
10
13
  let released = false
11
14
  return () => {
12
15
  if (released) return
13
16
  released = true
14
17
  if (--active === 0) for (const wake of [...waiters]) wake()
18
+ notify()
15
19
  }
16
20
  },
17
21
  wait (signal) {
@@ -24,6 +28,7 @@ export function createForegroundWork () {
24
28
  combined.addEventListener('abort', finish, { once: true })
25
29
  })
26
30
  },
27
- close () { lifetime.abort(); waiters.clear() }
31
+ observe (listener) { observers.add(listener); listener(active > 0); return () => observers.delete(listener) },
32
+ close () { lifetime.abort(); waiters.clear(); observers.clear() }
28
33
  }
29
34
  }
@@ -44,9 +44,11 @@ function messengerStatusSnapshot (value) {
44
44
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
45
45
  }
46
46
 
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, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, 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' }) }) {
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 = () => {}, 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
48
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
49
49
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
50
+ if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
51
+ let pendingDecryptionPeer = null
50
52
  const reportedErrors = new WeakSet()
51
53
  const reportError = error => {
52
54
  if (error && (typeof error === 'object' || typeof error === 'function')) {
@@ -139,7 +141,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
141
  let candidate
140
142
  try {
141
143
  candidate = await Messenger({
142
- fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
144
+ fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
143
145
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
144
146
  onStateChanged: state => {
145
147
  if (closed || messengerBinding !== binding) return
@@ -152,6 +154,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
152
154
  if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
153
155
  const status = messengerStatusSnapshot(candidate.readStatus())
154
156
  messenger = candidate
157
+ messenger.setPendingDecryptionReady?.(pendingDecryptionReady)
158
+ messenger.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
155
159
  stateChanged(status)
156
160
  } catch (error) {
157
161
  messengerBinding = null
@@ -168,6 +172,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
168
172
  signal.throwIfAborted()
169
173
  const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
170
174
  channels.set(peer, channel)
175
+ if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
171
176
  await schedule()
172
177
  signal.throwIfAborted()
173
178
  return channel
@@ -558,6 +563,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
558
563
  await writeDownloadIntent(() => downloads?.remove(id))
559
564
  }
560
565
  return {
566
+ setPendingDecryptionReady (ready) {
567
+ if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
568
+ pendingDecryptionReady = ready
569
+ if (!closed) messenger?.setPendingDecryptionReady?.(ready)
570
+ },
571
+ setPendingDecryptionPriority (peer) {
572
+ if (peer !== null && !/^[0-9a-f]{64}$/.test(peer || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
573
+ pendingDecryptionPeer = peer === owner ? null : peer
574
+ if (!closed) messenger?.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
575
+ },
561
576
  async retryPendingDecryptions (options) {
562
577
  if (closed || !available) return
563
578
  await ensureMessenger()
@@ -605,6 +620,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
605
620
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
606
621
  peers.clear(); for (const peer of next) peers.add(peer)
607
622
  preparation.reconcile(peers)
623
+ if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
624
+ pendingDecryptionPeer = null
625
+ messenger?.setPendingDecryptionPriority?.(null)
626
+ }
608
627
  for (const peer of channels.keys()) {
609
628
  if (!peers.has(peer)) {
610
629
  channels.delete(peer); pendingPriorityRanges.delete(peer)