libp2r2p 0.11.22 → 0.11.24

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.
@@ -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
@@ -59,7 +72,7 @@ The coordinator resolves the peer's channel and queues the request until the
59
72
  channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
60
73
  covered, and are dropped with recovery state. They are process-local and reset
61
74
  on reload.
62
- Callbacks are `onOutbox`, `onMedia`,
75
+ Callbacks are optional `onDebug`, `onOutbox`, `onMedia`,
63
76
  `onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
64
77
  account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
65
78
 
@@ -213,3 +226,26 @@ closing shared sockets or cancelling another subscriber for the same event ID.
213
226
  Operations already transmitted cannot be recalled from relays. A messenger pause
214
227
  cancels its outstanding publication work and leaves the outbox pending; resume
215
228
  creates a fresh publication lifetime, without treating the pause as a user abort.
229
+
230
+ ## Independent local persistence and publication
231
+
232
+ One encrypted outbox feeds two independent serial processors. Local persistence
233
+ never waits for peer preparation or an in-flight publication. Remote entries
234
+ become publishable only after their local progress checkpoint commits; failed
235
+ checkpoints are retried even if the copies have already committed. Text may
236
+ continue passing uploads between chunks after its local stage is ready.
237
+
238
+ Self entries emit `status: 'saved'` as soon as local persistence succeeds. They
239
+ do not write a redundant localSaved checkpoint before removal. Remote entries
240
+ emit that status after the first valid relay ACK for every required publication,
241
+ without waiting for redundant relays. Outbox cleanup runs independently; failures
242
+ retain saved status and retry only removal with the session backoff, never
243
+ `onSendError`. Consumers must accept saved entries while removal is pending.
244
+ A crash before removal replays the original event identity into idempotent storage.
245
+ Account shutdown fences late admissions and waits for both processors and cleanup.
246
+
247
+ `onDebug(record)` is disabled by default. Records carry fixed stage names, public
248
+ event/peer identifiers and elapsed milliseconds for admission, local processing,
249
+ channel preparation, publication acceptance and cleanup. Callback failures never
250
+ change delivery. Connect only in development; never log events or crypto buffers.
251
+ Custom outbox factories also receive this optional callback for storage timings.
@@ -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 = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
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')) {
@@ -64,6 +66,14 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
64
66
  const entries = new Map()
65
67
  const cancelled = new Set()
66
68
  const sendControllers = new Map()
69
+ const localReady = new Set()
70
+ const localIdleWaiters = new Set()
71
+ const cleanups = new Map()
72
+ const queuedAt = new WeakMap()
73
+ const debug = (entry, stage, startedAt) => {
74
+ if (!onDebug) return
75
+ try { onDebug({ stage, eventId: entry.id, peer: entry.peer, at: Date.now(), ...(startedAt != null ? { elapsedMs: Date.now() - startedAt } : {}) }) } catch {}
76
+ }
67
77
  const offlineSends = new Set()
68
78
  const pausedSends = new Set()
69
79
  let messengerStatus
@@ -121,15 +131,22 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
121
131
  let messengerWork
122
132
  let fileSetupWork
123
133
  let sending
134
+ let enqueuing = 0
135
+ let localSaving = false
136
+ let relocal = false
124
137
  let repump
125
138
  let draining
126
139
  let redrain = false
127
140
  const idleWaiters = new Set()
128
- const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
141
+ const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
129
142
  const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
130
143
  const ready = () => (initialized ??= (async () => {
131
- storage = await openOutbox({ owner, signer })
132
- for (const entry of await storage.list()) entries.set(entry.id, entry)
144
+ storage = await openOutbox({ owner, signer, onDebug })
145
+ for (const entry of await storage.list()) {
146
+ entries.set(entry.id, entry)
147
+ if (entry.localSaved.every(Boolean)) localReady.add(entry.id)
148
+ queuedAt.set(entry, Date.now())
149
+ }
133
150
  emit()
134
151
  })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
135
152
  async function createMessenger () {
@@ -139,7 +156,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
156
  let candidate
140
157
  try {
141
158
  candidate = await Messenger({
142
- fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
159
+ fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
143
160
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
144
161
  onStateChanged: state => {
145
162
  if (closed || messengerBinding !== binding) return
@@ -152,6 +169,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
152
169
  if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
153
170
  const status = messengerStatusSnapshot(candidate.readStatus())
154
171
  messenger = candidate
172
+ messenger.setPendingDecryptionReady?.(pendingDecryptionReady)
173
+ messenger.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
155
174
  stateChanged(status)
156
175
  } catch (error) {
157
176
  messengerBinding = null
@@ -163,13 +182,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
163
182
 
164
183
  const preparation = createPeerPreparation({
165
184
  prepare: async (peer, signal) => {
185
+ const startedAt = Date.now()
166
186
  const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
167
187
  const pubkey = await scoped.getPublicKey()
168
188
  signal.throwIfAborted()
169
189
  const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
170
190
  channels.set(peer, channel)
191
+ if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
171
192
  await schedule()
172
193
  signal.throwIfAborted()
194
+ if (onDebug) { try { onDebug({ stage: 'channel-ready', peer, elapsedMs: Date.now() - startedAt }) } catch {} }
173
195
  return channel
174
196
  },
175
197
  onReady: () => { drain(); pump() }, onError: reportError,
@@ -360,7 +382,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
360
382
  // Published is relay acceptance, never a peer receipt.
361
383
  await assertMessagePublished(report, event)
362
384
  }
363
- async function sendEntry (entry) {
385
+ async function cleanupEntry (entry) {
386
+ if (cleanups.has(entry.id)) return cleanups.get(entry.id)
387
+ const startedAt = Date.now()
388
+ const work = Promise.resolve().then(() => storage.remove(entry.id)).then(() => {
389
+ entries.delete(entry.id); localReady.delete(entry.id)
390
+ debug(entry, 'outbox-cleanup', startedAt)
391
+ emit()
392
+ }).catch(error => {
393
+ // Delivery has already succeeded. Cleanup must never become a send error.
394
+ entry.failed = true; entry.retryable = true
395
+ reportError(error)
396
+ if (!closed && available) scheduleSendRetry()
397
+ }).finally(() => { cleanups.delete(entry.id); if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry(); settled() })
398
+ cleanups.set(entry.id, work)
399
+ return work
400
+ }
401
+ async function sendEntry (entry, localOnly = false) {
364
402
  const releaseForeground = foreground.enter()
365
403
  entry.status = 'pending'; emit()
366
404
  const controller = new AbortController()
@@ -370,19 +408,32 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
370
408
  if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
371
409
  if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
372
410
  }
373
- // Personal copies must commit even if publishing attachment bytes
374
- // fails offline. Remote progress remains independently retryable.
375
- for (let index = 0; index < entry.events.length; index++) {
376
- await active()
377
- const event = entry.events[index]
378
- if (!entry.localSaved[index]) {
411
+ if (localOnly) {
412
+ debug(entry, 'local-start', queuedAt.get(entry))
413
+ const startedAt = Date.now()
414
+ for (let index = 0; index < entry.events.length; index++) {
415
+ await active()
416
+ if (entry.localSaved[index]) continue
417
+ const event = entry.events[index]
379
418
  const hearsay = !event.sig && event.pubkey !== owner
380
419
  const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
381
420
  if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
382
421
  if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
383
422
  entry.localSaved[index] = true
423
+ }
424
+ debug(entry, 'local-stored', startedAt)
425
+ await active()
426
+ if (entry.peer === owner) {
427
+ entry.status = 'saved'; emit()
428
+ cleanupEntry(entry)
429
+ } else {
430
+ // Retry this checkpoint even when a preceding attempt saved all copies
431
+ // but failed to persist progress. Only then admit remote publication.
384
432
  await storage.put(entry, { existing: true })
433
+ await active()
434
+ localReady.add(entry.id)
385
435
  }
436
+ return
386
437
  }
387
438
  if (entry.peer !== owner && !preparation.get(entry.peer)) {
388
439
  if (preparation.error(entry.peer)) throw preparation.error(entry.peer)
@@ -409,11 +460,17 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
409
460
  emit()
410
461
  await active()
411
462
  await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
463
+ // Let the independent local stage finish newly admitted texts before
464
+ // selecting publications at this chunk boundary. It never awaits us.
465
+ if ([...entries.values()].some(pending => !pending.files.length && pending.peer !== owner && !pending.failed && !localReady.has(pending.id))) {
466
+ await pumpLocal()
467
+ if (localSaving) await new Promise(resolve => localIdleWaiters.add(resolve))
468
+ }
412
469
  // Newly queued text and deletion controls can pass an in-flight file.
413
470
  let admitted = 0
414
471
  for (const pending of entries.values()) {
415
472
  if (closed || !available || admitted >= 8) break
416
- if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
473
+ if (!localReady.has(pending.id) || pending.peer === owner || pending.status === 'saved' || pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
417
474
  admitted++
418
475
  await sendEntry(pending)
419
476
  }
@@ -422,15 +479,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
422
479
  for (; entry.index < entry.events.length; entry.index++) {
423
480
  await active()
424
481
  const event = entry.events[entry.index]
425
- if (entry.peer !== owner) await publish(entry.peer, event, controller.signal)
482
+ const startedAt = Date.now()
483
+ await publish(entry.peer, event, controller.signal)
484
+ debug(entry, 'publication-accepted', startedAt)
426
485
  await active()
427
486
  // The final durable operation is removal. Avoid encrypting a checkpoint
428
487
  // that would immediately be deleted; earlier context stages still checkpoint.
429
488
  if (entry.index + 1 < entry.events.length) await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
430
489
  }
431
- await storage.remove(entry.id)
432
- entries.delete(entry.id)
433
- emit()
490
+ entry.status = 'saved'; emit()
491
+ cleanupEntry(entry)
434
492
  } catch (cause) {
435
493
  // Pauses can surface at any stage (text publication, file authorization
436
494
  // or chunk delivery). Normalize all of them into the retryable path.
@@ -478,6 +536,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
478
536
  for (const entry of entries.values()) {
479
537
  if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
480
538
  entry.failed = false
539
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
481
540
  retried = true
482
541
  }
483
542
  if (retried) pump()
@@ -500,10 +559,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
500
559
  return schedule()
501
560
  })
502
561
  }
503
- async function pump () {
562
+ async function pumpLocal () {
504
563
  if (!available || closed || !storage) return
505
- // A retry that lands while a pump is already running must not be skipped
506
- // just because that pass already walked past the entry.
564
+ if (localSaving) { relocal = true; return }
565
+ localSaving = true
566
+ try {
567
+ do {
568
+ relocal = false
569
+ for (const entry of entries.values()) {
570
+ if (closed || !available) break
571
+ if (entry.failed || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) continue
572
+ if (entry.status === 'saved') { cleanupEntry(entry); continue }
573
+ if (localReady.has(entry.id) && entry.peer !== owner) continue
574
+ await sendEntry(entry, true)
575
+ pumpRemote()
576
+ }
577
+ } while (relocal)
578
+ } finally { localSaving = false; for (const resolve of localIdleWaiters) resolve(); localIdleWaiters.clear(); settled() }
579
+ }
580
+ async function pumpRemote () {
581
+ if (!available || closed || !storage || !messenger) return
507
582
  if (sending) { repump = true; return }
508
583
  sending = true
509
584
  try {
@@ -511,22 +586,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
511
586
  repump = false
512
587
  for (const entry of entries.values()) {
513
588
  if (closed || !available) break
514
- if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
515
- if (entry.peer !== owner && entry.localSaved.every(Boolean) && !preparation.get(entry.peer) && !preparation.error(entry.peer)) {
589
+ if (entry.peer === owner || entry.status === 'saved' || !localReady.has(entry.id) || !peers.has(entry.peer) || entry.failed || cancelled.has(entry.id)) continue
590
+ if (!preparation.get(entry.peer) && !preparation.error(entry.peer)) {
516
591
  preparation.request(entry.peer).catch(() => {})
517
592
  continue
518
593
  }
519
- if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
594
+ if (entry.files.length && !fileTransfers) continue
520
595
  await sendEntry(entry)
521
596
  }
522
597
  } while (repump)
523
598
  } finally {
524
599
  sending = false
525
600
  watchOfflineSends()
526
- if (![...entries.values()].some(pendingRetryable)) resetSendRetry()
601
+ if (![...entries.values()].some(pendingRetryable) && !cleanups.size) resetSendRetry()
527
602
  settled()
528
603
  }
529
604
  }
605
+ async function pump () { await Promise.all([pumpLocal(), pumpRemote()]) }
530
606
  const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
531
607
  async function download (file, options = {}) {
532
608
  if (file.peer === owner) return file
@@ -558,6 +634,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
558
634
  await writeDownloadIntent(() => downloads?.remove(id))
559
635
  }
560
636
  return {
637
+ setPendingDecryptionReady (ready) {
638
+ if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
639
+ pendingDecryptionReady = ready
640
+ if (!closed) messenger?.setPendingDecryptionReady?.(ready)
641
+ },
642
+ setPendingDecryptionPriority (peer) {
643
+ if (peer !== null && !/^[0-9a-f]{64}$/.test(peer || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
644
+ pendingDecryptionPeer = peer === owner ? null : peer
645
+ if (!closed) messenger?.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
646
+ },
561
647
  async retryPendingDecryptions (options) {
562
648
  if (closed || !available) return
563
649
  await ensureMessenger()
@@ -605,6 +691,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
605
691
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
606
692
  peers.clear(); for (const peer of next) peers.add(peer)
607
693
  preparation.reconcile(peers)
694
+ if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
695
+ pendingDecryptionPeer = null
696
+ messenger?.setPendingDecryptionPriority?.(null)
697
+ }
608
698
  for (const peer of channels.keys()) {
609
699
  if (!peers.has(peer)) {
610
700
  channels.delete(peer); pendingPriorityRanges.delete(peer)
@@ -629,6 +719,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
629
719
  inboxRecovery.stop(); await messenger?.pause('signer'); return
630
720
  }
631
721
  await ready()
722
+ pumpLocal()
632
723
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
633
724
  if (storageBlocked) inboxRecovery.start({ immediate: true })
634
725
  if (!changed && messenger && !configurationFailed) { preparation.setAvailable(true); pump(); return configuring }
@@ -637,6 +728,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
637
728
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
638
729
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
639
730
  const releaseForeground = foreground.enter()
731
+ enqueuing++
640
732
  try {
641
733
  await ready()
642
734
  const main = wireEvent(event, owner)
@@ -644,11 +736,19 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
644
736
  const events = [...context.map(value => wireEvent(value, owner)), main]
645
737
  const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
646
738
  const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
739
+ const startedAt = Date.now()
740
+ debug(entry, 'outbox-start')
647
741
  await storage.put(entry)
742
+ debug(entry, 'outbox-stored', startedAt)
743
+ queuedAt.set(entry, Date.now())
744
+ if (cancelled.has(id)) { await storage.remove(id); throw new Error('CHAT_UNAVAILABLE') }
745
+ // An accepted durable intent survives shutdown/locking for replay. Only
746
+ // explicit cancellation removes it; no late callback or work is admitted.
747
+ if (closed) return id
648
748
  entries.set(id, entry); emit(); pump()
649
- if (peer !== owner) preparePeer(peer).catch(() => {})
749
+ if (available && peer !== owner) preparePeer(peer).catch(() => {})
650
750
  return id
651
- } finally { releaseForeground() }
751
+ } finally { releaseForeground(); enqueuing--; settled() }
652
752
  },
653
753
  async retry (id) {
654
754
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
@@ -662,7 +762,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
662
762
  sendControllers.get(id)?.abort()
663
763
  offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
664
764
  await ready()
665
- entries.delete(id)
765
+ entries.delete(id); localReady.delete(id)
666
766
  await storage.remove(id)
667
767
  emit()
668
768
  },
@@ -674,7 +774,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
674
774
  for (const controller of sendControllers.values()) controller.abort()
675
775
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])
676
776
  await messenger?.pause('closed')
677
- if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
777
+ if (sending || localSaving || enqueuing || draining || cleanups.size) await new Promise(resolve => idleWaiters.add(resolve))
678
778
  await messenger?.close()
679
779
  await storage?.close()
680
780
  await downloadWriteTail.catch(reportError)