libp2r2p 0.11.30 → 0.11.32

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
@@ -1278,3 +1278,15 @@ circuit: explicit bans/refusals open immediately, repeated transport failures
1278
1278
  open after three attempts, and a fully quarantined route reports
1279
1279
  `RELAY_CIRCUIT_OPEN` with `retryAt` before another envelope is prepared. Reads and
1280
1280
  receive routing are unaffected. See [session relay fallback](private-messenger/session/README.md#relay-fallback).
1281
+
1282
+ The 0.11.31 pending-decryption retry groups records by
1283
+ `(receiverPubkey, ownContentPubkey)` for one pass. A FIFO probe may skip the
1284
+ remaining records only when the local signer confirms `CONTENT_KEY_NOT_FOUND`
1285
+ with `hasOwnContentKey: false`. MAC, proof, parsing and operational failures are
1286
+ never generalized to the group, and every deferred record remains persisted.
1287
+
1288
+ The 0.11.32 local recovery-seed queue serializes incoming and outgoing writes
1289
+ within one PrivateMessenger. Duplicate `bySeedKey` races retry up to five times:
1290
+ NYM rows remain idempotent and router rows re-read the winning timestamps before
1291
+ replacement. External event-store seed adapters retain their independent batched
1292
+ writes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.30",
3
+ "version": "0.11.32",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -57,7 +57,7 @@ import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicati
57
57
  import { normalizeRelayUrl } from '../url/index.js'
58
58
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
59
59
  import { migrateMessageQueue } from './services/migrate-message-queue.js'
60
- import { createPendingDecryption } from './services/pending-decryption.js'
60
+ import { createPendingDecryption, pendingContentKeyGroup, pendingMissingContentKeyGroup } from './services/pending-decryption.js'
61
61
  import { createDecryptionScheduler } from './services/decryption-scheduler.js'
62
62
  import { decryptionError, isDeferredDecryptionError } from '../private-channel/helpers/decryption-error.js'
63
63
  import { bytesToBase64 } from '../base64/index.js'
@@ -108,6 +108,7 @@ export {
108
108
  const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
109
109
  const MAX_OFFLINE_RECOVERY_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000)
110
110
  const RECOVERY_STORAGE_BATCH_SIZE = 100
111
+ const SEED_QUEUE_UPSERT_ATTEMPTS = 5
111
112
  const STORAGE_POLICY_BROADCAST_CHANNEL = 'libp2r2p:private-messenger:storage-policy'
112
113
  const DEFAULT_OFFLINE_SKEW_SECONDS = 30
113
114
  const DEFAULT_RELOAD_GAP_DELAY_MS = 500
@@ -374,6 +375,7 @@ export class PrivateMessenger {
374
375
  this.watchRevisionByChannel = new Map()
375
376
  this.presenceTimers = new Map()
376
377
  this.presenceJobs = new Map()
378
+ this.seedQueueOperationTail = Promise.resolve()
377
379
  this.stopRelayListWatcher = null
378
380
  this.relayListWatcherPubkey = ''
379
381
  this.relayListRefreshPromise = null
@@ -686,6 +688,13 @@ export class PrivateMessenger {
686
688
  return this.runQueueOperation(operation)
687
689
  }
688
690
 
691
+ runSeedQueueOperation (operation) {
692
+ if (this.closePromise) return Promise.reject(new Error('PRIVATE_MESSENGER_CLOSED'))
693
+ const run = this.seedQueueOperationTail.then(operation)
694
+ this.seedQueueOperationTail = run.catch(() => {})
695
+ return run
696
+ }
697
+
689
698
  debug (action, detail = {}) {
690
699
  try {
691
700
  this.onDebug?.({ source: 'private-messenger', action, ...detail })
@@ -2140,8 +2149,8 @@ export class PrivateMessenger {
2140
2149
 
2141
2150
  async enqueueSeed (channelPubkey, seed) {
2142
2151
  if (!this.offlineRecoverySecondsFor(channelPubkey)) return
2143
- const receivedAt = nowSeconds()
2144
2152
  if (this.seedStorage) {
2153
+ const receivedAt = nowSeconds()
2145
2154
  const rows = seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length
2146
2155
  ? [{ recordType: NYM_CARRIER_SEED_RECORD_TYPE, carriers: compactSeedNymCarriers(seed.carriers) }]
2147
2156
  : compactSeedRouterRows(seed)
@@ -2155,22 +2164,37 @@ export class PrivateMessenger {
2155
2164
  }
2156
2165
  return
2157
2166
  }
2167
+ return this.runSeedQueueOperation(() => this.enqueueQueueSeed(channelPubkey, seed))
2168
+ }
2169
+
2170
+ async enqueueQueueSeed (channelPubkey, seed) {
2171
+ this.assertOpen()
2172
+ if (!this.channels.has(channelPubkey) || !this.offlineRecoverySecondsFor(channelPubkey)) return
2173
+ const receivedAt = nowSeconds()
2158
2174
  if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
2159
2175
  const carriers = compactSeedNymCarriers(seed.carriers)
2160
2176
  const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
2161
2177
  const key = nymCarrierSeedKey({ channelPubkey, carriers })
2162
2178
  const seedKey = key ? `nym:${key}` : ''
2163
- if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) return
2164
- await this.seedQueue.enqueue({
2165
- type: 'seed',
2166
- recordType: NYM_CARRIER_SEED_RECORD_TYPE,
2167
- channelPubkey,
2168
- receivedAt,
2169
- carriers,
2170
- meta: { channelPubkey: seed.channelPubkey },
2171
- [SEED_KEY]: seedKey || undefined,
2172
- [SEED_TIME]: recordTime
2173
- })
2179
+ for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
2180
+ if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) break
2181
+ try {
2182
+ await this.seedQueue.enqueue({
2183
+ type: 'seed',
2184
+ recordType: NYM_CARRIER_SEED_RECORD_TYPE,
2185
+ channelPubkey,
2186
+ receivedAt,
2187
+ carriers,
2188
+ meta: { channelPubkey: seed.channelPubkey },
2189
+ [SEED_KEY]: seedKey || undefined,
2190
+ [SEED_TIME]: recordTime
2191
+ })
2192
+ break
2193
+ } catch (error) {
2194
+ if (error?.name !== 'ConstraintError') throw error
2195
+ if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
2196
+ }
2197
+ }
2174
2198
  await this.pruneStoredSeeds(channelPubkey)
2175
2199
  return
2176
2200
  }
@@ -2180,21 +2204,29 @@ export class PrivateMessenger {
2180
2204
  const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
2181
2205
  const rowKey = routerSeedRowKey({ ...row, channelPubkey })
2182
2206
  const seedKey = `router:${rowKey}`
2183
- const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
2184
- const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
2185
- const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
2186
- await this.seedQueue.enqueue({
2187
- ...row,
2188
- type: 'seed',
2189
- recordType: ROUTER_SEED_RECORD_TYPE,
2190
- channelPubkey,
2191
- receivedAt,
2192
- firstSeenAt,
2193
- lastSeenAt,
2194
- meta: { channelPubkey: seed.channelPubkey },
2195
- [SEED_KEY]: seedKey,
2196
- [SEED_TIME]: lastSeenAt || rowTime
2197
- })
2207
+ for (let attempt = 0; attempt < SEED_QUEUE_UPSERT_ATTEMPTS; attempt++) {
2208
+ const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
2209
+ const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
2210
+ const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
2211
+ try {
2212
+ await this.seedQueue.enqueue({
2213
+ ...row,
2214
+ type: 'seed',
2215
+ recordType: ROUTER_SEED_RECORD_TYPE,
2216
+ channelPubkey,
2217
+ receivedAt,
2218
+ firstSeenAt,
2219
+ lastSeenAt,
2220
+ meta: { channelPubkey: seed.channelPubkey },
2221
+ [SEED_KEY]: seedKey,
2222
+ [SEED_TIME]: lastSeenAt || rowTime
2223
+ })
2224
+ break
2225
+ } catch (error) {
2226
+ if (error?.name !== 'ConstraintError') throw error
2227
+ if (attempt === SEED_QUEUE_UPSERT_ATTEMPTS - 1) throw error
2228
+ }
2229
+ }
2198
2230
  }
2199
2231
  await this.pruneStoredSeeds(channelPubkey)
2200
2232
  }
@@ -3259,12 +3291,7 @@ export class PrivateMessenger {
3259
3291
  job.started = true
3260
3292
  const force = job.force
3261
3293
  job.force = false
3262
- const groups = new Map()
3263
- for (const record of await this.pendingDecryption.records({ force })) {
3264
- if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3265
- groups.get(record.channelPubkey).push(record)
3266
- }
3267
- const results = await Promise.allSettled([...groups].map(async ([channelPubkey, records]) => {
3294
+ const replay = async (channelPubkey, records, callbacks = {}) => {
3268
3295
  const channel = this.channels.get(channelPubkey)
3269
3296
  if (!channel || !this.desiredChannels.has(channelPubkey)) return
3270
3297
  const current = () => {
@@ -3302,15 +3329,78 @@ export class PrivateMessenger {
3302
3329
  return this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }, { trackActivity: false })
3303
3330
  },
3304
3331
  onSeedEvent: seed => { current(); return this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed, { trackActivity: false }) },
3305
- onRecovered: record => { current(); return this.pendingDecryption.recovered(record) },
3306
- onFailure: (record, error) => { current(); return this.pendingDecryption.failed(record, error) }
3332
+ onRecovered: async record => {
3333
+ current()
3334
+ await callbacks.onRecovered?.(record)
3335
+ return this.pendingDecryption.recovered(record)
3336
+ },
3337
+ onFailure: async (record, error) => {
3338
+ current()
3339
+ if (await callbacks.onFailure?.(record, error) === false) return
3340
+ return this.pendingDecryption.failed(record, error)
3341
+ }
3307
3342
  })
3308
3343
  } catch (error) {
3309
3344
  if (this.channels.get(channelPubkey) === channel && this.desiredChannels.has(channelPubkey)) throw error
3310
3345
  } finally { this.decryptionScheduler.finish(channelPubkey) }
3311
- }))
3346
+ }
3347
+
3348
+ const records = await this.pendingDecryption.records({ force })
3349
+ const probes = new Map()
3350
+ for (const record of records) {
3351
+ const channelPubkey = record.channelPubkey
3352
+ if (!this.channels.has(channelPubkey) || !this.desiredChannels.has(channelPubkey)) continue
3353
+ const missingGroup = pendingMissingContentKeyGroup(record)
3354
+ if (missingGroup && !probes.has(missingGroup)) probes.set(missingGroup, record)
3355
+ }
3356
+
3357
+ const unavailableGroups = new Set()
3358
+ const probed = new Set()
3359
+ let probeError
3360
+ for (const [groupKey, candidate] of probes) {
3361
+ signal.throwIfAborted()
3362
+ const current = await this.pendingDecryption.current(candidate, { force })
3363
+ if (!current || pendingMissingContentKeyGroup(current) !== groupKey) continue
3364
+ probed.add(current.id)
3365
+ let missing = false
3366
+ await replay(current.channelPubkey, [current], {
3367
+ onFailure: async (_record, error) => {
3368
+ const failedGroup = pendingMissingContentKeyGroup({
3369
+ code: error.code,
3370
+ receiverPubkey: error.receiverPubkey,
3371
+ ownContentPubkey: error.ownContentPubkey,
3372
+ hasOwnContentKey: error.hasOwnContentKey
3373
+ })
3374
+ if (failedGroup === groupKey) missing = true
3375
+ if (isDeferredDecryptionError(error)) {
3376
+ await this.pendingDecryption.failed(current, error)
3377
+ return false
3378
+ }
3379
+ probeError ||= error
3380
+ return false
3381
+ }
3382
+ })
3383
+ if (missing) unavailableGroups.add(groupKey)
3384
+ }
3385
+
3386
+ const remaining = []
3387
+ for (const record of records) {
3388
+ if (!this.channels.has(record.channelPubkey) || !this.desiredChannels.has(record.channelPubkey)) continue
3389
+ if (probed.has(record.id)) continue
3390
+ const groupKey = pendingContentKeyGroup(record)
3391
+ if (groupKey && unavailableGroups.has(groupKey)) continue
3392
+ remaining.push(record)
3393
+ }
3394
+ const groups = new Map()
3395
+ for (const record of remaining) {
3396
+ if (!groups.has(record.channelPubkey)) groups.set(record.channelPubkey, [])
3397
+ groups.get(record.channelPubkey).push(record)
3398
+ }
3399
+ const results = await Promise.allSettled([...groups].map(([channelPubkey, channelRecords]) =>
3400
+ replay(channelPubkey, channelRecords)))
3312
3401
  const failed = results.find(result => result.status === 'rejected')
3313
3402
  if (failed) throw failed.reason
3403
+ if (probeError) throw probeError
3314
3404
  job.started = false
3315
3405
  } while (job.again)
3316
3406
  }
@@ -6,6 +6,8 @@ import { isDeferredDecryptionError } from '../../private-channel/helpers/decrypt
6
6
 
7
7
  const encoder = new TextEncoder()
8
8
  const decoder = new TextDecoder()
9
+ const HEX64 = /^[0-9a-f]{64}$/i
10
+ const CONTENT_KEY_FIELDS = ['receiverPubkey', 'ownContentPubkey', 'peerContentPubkey']
9
11
  export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
10
12
 
11
13
  function storedBytes (item) {
@@ -30,6 +32,34 @@ function packetSnapshot (packet) {
30
32
  return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
31
33
  }
32
34
 
35
+ function errorMetadata (error) {
36
+ const metadata = {}
37
+ for (const key of CONTENT_KEY_FIELDS) {
38
+ const value = error?.[key]
39
+ if (typeof value === 'string' && HEX64.test(value)) metadata[key] = value.toLowerCase()
40
+ }
41
+ if (typeof error?.hasOwnContentKey === 'boolean') metadata.hasOwnContentKey = error.hasOwnContentKey
42
+ return metadata
43
+ }
44
+
45
+ function replaceErrorMetadata (item, error) {
46
+ const next = { ...item, code: error?.code }
47
+ for (const key of [...CONTENT_KEY_FIELDS, 'hasOwnContentKey']) delete next[key]
48
+ return Object.assign(next, errorMetadata(error))
49
+ }
50
+
51
+ export function pendingContentKeyGroup (item) {
52
+ const receiver = item?.receiverPubkey
53
+ const own = item?.ownContentPubkey
54
+ if (typeof receiver !== 'string' || typeof own !== 'string' || !HEX64.test(receiver) || !HEX64.test(own)) return ''
55
+ return `${receiver.toLowerCase()}:${own.toLowerCase()}`
56
+ }
57
+
58
+ export function pendingMissingContentKeyGroup (item) {
59
+ if (item?.code !== 'CONTENT_KEY_NOT_FOUND' || item?.hasOwnContentKey !== false) return ''
60
+ return pendingContentKeyGroup(item)
61
+ }
62
+
33
63
  // Stable across live, history and synthesized recovery wrappers.
34
64
  function packetId (record) {
35
65
  const rows = record.format === 'routerRow_v1'
@@ -72,8 +102,19 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
72
102
  record = packetSnapshot(record)
73
103
  return serial(async () => {
74
104
  const id = packetId(record)
75
- if (await queue.someBy('byId', id)) return true
76
- const item = { id, channelPubkey: record.channelPubkey, packet: structuredClone(record), code: error.code, macAttempts: error.code === 'INVALID_MAC' ? 1 : 0 }
105
+ const previous = await queue.getBy('byId', id)
106
+ if (previous) {
107
+ const updated = replaceErrorMetadata(previous, error)
108
+ if (JSON.stringify(updated) !== JSON.stringify(previous)) await queue.putBy('byId', updated, { existingOnly: true })
109
+ return true
110
+ }
111
+ const item = {
112
+ id,
113
+ channelPubkey: record.channelPubkey,
114
+ packet: structuredClone(record),
115
+ macAttempts: error.code === 'INVALID_MAC' ? 1 : 0,
116
+ ...replaceErrorMetadata({}, error)
117
+ }
77
118
  const requiredBytes = storedBytes(item)
78
119
  if (requiredBytes > maxBytes) {
79
120
  notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
@@ -115,7 +156,10 @@ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = P
115
156
  if (!isDeferredDecryptionError(error)) throw error
116
157
  const current = await queue.getBy('byId', item.id)
117
158
  if (!current) return
118
- const updated = { ...current, code: error.code, macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0) }
159
+ const updated = {
160
+ ...replaceErrorMetadata(current, error),
161
+ macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0)
162
+ }
119
163
  await queue.putBy('byId', updated, { existingOnly: true })
120
164
  if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
121
165
  })
@@ -9,6 +9,13 @@ the account messenger while available. Call after committed content-key changes;
9
9
  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
+ Within one pass, records whose last failure proved the same
13
+ `(receiverPubkey, ownContentPubkey)` is missing share one FIFO probe across all
14
+ channels. An unchanged `CONTENT_KEY_NOT_FOUND` skips only that group's remaining
15
+ records for the pass; none are removed or suspended. If the probe advances past
16
+ the key lookup, including with `INVALID_MAC`, or fails with another error, all
17
+ other records in the group are still attempted. Missing-key suppression never
18
+ survives into another pass.
12
19
 
13
20
  Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
14
21
  hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases