libp2r2p 0.11.16 → 0.11.18

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.
@@ -5,11 +5,13 @@ import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/ind
5
5
  import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
6
  import { getIykcProofs } from '../../content-key/index.js'
7
7
  import { onOnline } from '../../network/index.js'
8
+ import { ValidationError } from '../../error/index.js'
8
9
  import { decodeFileMetadata } from '../../nip94/index.js'
9
10
  import { createChatOutbox } from './helpers/work-storage.js'
10
11
  import { messengerSigner } from './helpers/signer.js'
11
12
  import { assertMessagePublished } from '../helpers/publication.js'
12
- import { normalizeFallbackRelays } from '../helpers/send-routing.js'
13
+ import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
14
+ import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
13
15
 
14
16
  export function wireEvent (value, owner) {
15
17
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -27,12 +29,21 @@ const SEND_RETRY_MAX_MS = 30000
27
29
  const unavailableError = (error, signal) => Object.assign(new Error(error?.message || 'CHAT_UNAVAILABLE'), {
28
30
  code: error?.code,
29
31
  cause: error,
32
+ ...(Array.isArray(error?.pauseReasons) ? { pauseReasons: [...error.pauseReasons] } : {}),
30
33
  retryWhenAvailable: !signal?.aborted,
31
34
  retryWhenOnline: false
32
35
  })
33
36
 
34
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
37
+ function messengerStatusSnapshot (value) {
38
+ if (!value || typeof value.closed !== 'boolean' || typeof value.paused !== 'boolean' ||
39
+ !Array.isArray(value.pauseReasons) || value.pauseReasons.some(reason => typeof reason !== 'string' || !reason.trim()) ||
40
+ value.paused !== Boolean(value.pauseReasons.length)) throw new ValidationError('INVALID_MESSENGER_STATUS')
41
+ return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
42
+ }
43
+
44
+ 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, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
35
45
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
46
+ fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
36
47
  const reportedErrors = new WeakSet()
37
48
  const reportError = error => {
38
49
  if (error && (typeof error === 'object' || typeof error === 'function')) {
@@ -51,6 +62,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
51
62
  const cancelled = new Set()
52
63
  const sendControllers = new Map()
53
64
  const offlineSends = new Set()
65
+ const pausedSends = new Set()
66
+ let messengerStatus
67
+ let messengerBinding
68
+ let storageBlocked = false
69
+ const inboxRecovery = createPauseRecovery({
70
+ attempt: async signal => { signal.throwIfAborted(); await drain(); if (storageBlocked) throw inboxError },
71
+ retryable: isRecoverableStorageFailure, onError: reportError,
72
+ setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random
73
+ })
74
+ let inboxError
75
+ const stateChanged = state => {
76
+ if (closed) return
77
+ messengerStatus = messengerStatusSnapshot(state)
78
+ if (!available || state.closed) return
79
+ if (state.paused) {
80
+ for (const entry of entries.values()) {
81
+ if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
82
+ }
83
+ if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
84
+ return
85
+ }
86
+ clearSendRetry()
87
+ for (const id of pausedSends) { const entry = entries.get(id); if (entry) entry.failed = false }
88
+ pausedSends.clear()
89
+ // The callback can run inside construction/resume, before configure settles.
90
+ queueMicrotask(() => pump())
91
+ }
54
92
  let stopOnline
55
93
  let sendRetryTimer
56
94
  let sendRetryDelay = SEND_RETRY_MIN_MS
@@ -70,12 +108,15 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
70
108
  let storage
71
109
  let initialized
72
110
  let closed = false
111
+ let closeWork
73
112
  let available = false
74
113
  let lifecycle = 0
75
114
  let configuring = Promise.resolve()
115
+ let configurationFailed = false
76
116
  let sending
77
117
  let repump
78
118
  let draining
119
+ let redrain = false
79
120
  const idleWaiters = new Set()
80
121
  const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
81
122
  const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
@@ -84,6 +125,35 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
84
125
  for (const entry of await storage.list()) entries.set(entry.id, entry)
85
126
  emit()
86
127
  })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
128
+ async function createMessenger () {
129
+ const binding = {}
130
+ messengerBinding = binding
131
+ let notified = false
132
+ let candidate
133
+ try {
134
+ candidate = await Messenger({
135
+ fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys,
136
+ onMessageQueued: () => { if (messengerBinding === binding) return drain() },
137
+ onStateChanged: state => {
138
+ if (closed || messengerBinding !== binding) return
139
+ stateChanged(state)
140
+ notified = true
141
+ },
142
+ onError: reportError
143
+ })
144
+ if (closed) { messengerBinding = null; await candidate?.close?.(); return }
145
+ if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
146
+ const status = messengerStatusSnapshot(candidate.readStatus())
147
+ messenger = candidate
148
+ stateChanged(status)
149
+ } catch (error) {
150
+ messengerBinding = null
151
+ messengerStatus = undefined
152
+ try { await candidate?.close?.() } catch (cleanupError) { reportError(cleanupError) }
153
+ throw error
154
+ }
155
+ }
156
+
87
157
  async function configure () {
88
158
  if (closed || !available) return
89
159
  await ready()
@@ -102,7 +172,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
102
172
  if (closed || !available || version !== lifecycle) return
103
173
  values.push(channel)
104
174
  }
105
- if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
175
+ if (!messenger) await createMessenger()
176
+ if (!messenger) return
106
177
  if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
107
178
  await messenger.update({ channels: values })
108
179
  if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
@@ -116,6 +187,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
116
187
  }
117
188
  }
118
189
  await messenger.resume('signer')
190
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
119
191
  if (!fileTransfers) {
120
192
  fileTransfers = FileTransfer({
121
193
  messenger, onError: reportError,
@@ -153,13 +225,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
225
  function schedule () {
154
226
  const work = configuring.catch(() => {}).then(configure)
155
227
  configuring = work
156
- work.catch(reportError)
228
+ work.then(() => { if (configuring === work) configurationFailed = false }, error => { if (configuring === work) configurationFailed = true; reportError(error) })
157
229
  return work
158
230
  }
159
231
  async function drain () {
160
- if (draining || closed || !available || !messenger) return
232
+ if (draining) { redrain = true; return }
233
+ if (closed || !available || !messenger) return
161
234
  draining = true
162
235
  const deferred = []
236
+ let persisted = false
237
+ let passFailed = false
163
238
  try {
164
239
  while (true) {
165
240
  if (closed || !available) break
@@ -181,16 +256,33 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
181
256
  if (!available || closed) { await nack(); break }
182
257
  const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
183
258
  if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
184
- await ack()
259
+ if (!available || closed) { await nack(); break }
260
+ if (await ack() === false) throw new Error('INBOX_ACK_FAILED')
261
+ persisted = true
185
262
  } catch (error) {
186
263
  await nack()
187
264
  reportError(error)
188
265
  // Retain the failed record and suspend network ingestion until recovery.
189
- await messenger.pause('storage')
266
+ passFailed = true
267
+ storageBlocked = true
268
+ inboxError = error
269
+ await messenger.pause('session-storage')
270
+ if (isRecoverableStorageFailure(error)) inboxRecovery.start()
190
271
  break
191
272
  }
192
273
  }
193
- } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
274
+ if (storageBlocked && persisted && !passFailed) {
275
+ // A retry can save one record and fail the next. Only release after the
276
+ // whole currently admissible pass completes without another failure.
277
+ storageBlocked = false; inboxError = null
278
+ await messenger.resume('session-storage')
279
+ }
280
+ } finally {
281
+ await Promise.all(deferred.map(nack => nack()))
282
+ draining = false; settled()
283
+ const again = redrain; redrain = false
284
+ if (again && !storageBlocked && !closed && available) queueMicrotask(() => drain())
285
+ }
194
286
  }
195
287
  async function publish (peer, event, signal) {
196
288
  if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
@@ -229,6 +321,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
229
321
  await storage.put(entry, { existing: true })
230
322
  }
231
323
  }
324
+ if (entry.peer !== owner && messengerStatus?.paused) {
325
+ throw unavailableError(Object.assign(new Error('PRIVATE_MESSENGER_PAUSED'), { pauseReasons: [...messengerStatus.pauseReasons] }), controller.signal)
326
+ }
232
327
  // Read/publish one chunk at a time. Progress is durable after each
233
328
  // accepted chunk; replay after interruption keeps its original identity.
234
329
  for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
@@ -273,20 +368,20 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
273
368
  if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else {
274
369
  entry.status = error.retryWhenAvailable ? 'pending' : 'error'; entry.failed = true; entry.retryable = retryable(error)
275
370
  await storage.put(entry, { existing: true }).catch(reportError)
371
+ if (entry.peer !== owner && error.retryWhenAvailable && messenger.readStatus().paused) pausedSends.add(entry.id)
276
372
  reportError(error)
277
373
  // The failed wire event may be a quote or file chunk. Report the
278
374
  // owning outbox item separately, without mutating the native error.
279
375
  if (error.retryWhenOnline) offlineSends.add(entry.id)
280
- if (error.retryWhenAvailable) scheduleSendRetry()
376
+ if (error.retryWhenAvailable && !pausedSends.has(entry.id)) scheduleSendRetry()
281
377
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
282
378
  }
283
379
  emit()
284
380
  } finally { sendControllers.delete(entry.id) }
285
381
  }
286
382
  function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
287
- // Entries that failed only because the messenger was paused/unavailable do
288
- // not receive a connectivity transition, so they own a bounded backoff timer
289
- // instead of waiting for the next account-state change.
383
+ // Transient availability failures without an active messenger pause retain
384
+ // bounded retries. Observed pauses wait exclusively for their release.
290
385
  function clearSendRetry () {
291
386
  if (sendRetryTimer) _clearTimeout(sendRetryTimer)
292
387
  sendRetryTimer = undefined
@@ -305,7 +400,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
305
400
  if (closed || !available) return
306
401
  let retried = false
307
402
  for (const entry of entries.values()) {
308
- if (!pendingRetryable(entry)) continue
403
+ if (!pendingRetryable(entry) || pausedSends.has(entry.id)) continue
309
404
  entry.failed = false
310
405
  retried = true
311
406
  }
@@ -341,9 +436,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
341
436
  for (const entry of entries.values()) {
342
437
  if (closed || !available) break
343
438
  if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
439
+ if (entry.peer !== owner && entry.files.length && !fileTransfers) continue
344
440
  await sendEntry(entry)
345
441
  }
346
- } while (repump && !closed && available)
442
+ } while (repump)
347
443
  } finally {
348
444
  sending = false
349
445
  watchOfflineSends()
@@ -416,21 +512,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
416
512
  return true
417
513
  },
418
514
  async setPeers (values) {
419
- peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
515
+ const next = new Set([...values].filter(peer => peer !== owner))
516
+ if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
517
+ peers.clear(); for (const peer of next) peers.add(peer)
420
518
  for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
421
519
  lifecycle++
422
520
  return schedule()
423
521
  },
424
522
  async setAvailable (value) {
523
+ const changed = available !== (value === true)
425
524
  available = value === true
525
+ if (!changed && !available) return
426
526
  stopOnlineRetry(); offlineSends.clear()
427
527
  if (available) resetSendRetry()
428
528
  else clearSendRetry()
429
- lifecycle++
430
- if (!available) { await messenger?.pause('signer'); return }
529
+ if (changed) lifecycle++
530
+ if (!available) { inboxRecovery.stop(); await messenger?.pause('signer'); return }
431
531
  await ready()
432
532
  for (const entry of entries.values()) if (pendingRetryable(entry)) entry.failed = false
433
- await messenger?.resume('storage')
533
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
534
+ if (!changed && messenger && !configurationFailed) return pump()
434
535
  return schedule()
435
536
  },
436
537
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
@@ -446,7 +547,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
446
547
  return id
447
548
  },
448
549
  async retry (id) {
449
- offlineSends.delete(id); watchOfflineSends()
550
+ offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
551
+ if (storageBlocked) inboxRecovery.start({ immediate: true })
450
552
  await ready()
451
553
  const entry = entries.get(id)
452
554
  if (entry) { entry.failed = false; resetSendRetry(); if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
@@ -454,24 +556,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
454
556
  async cancel (id) {
455
557
  cancelled.add(id)
456
558
  sendControllers.get(id)?.abort()
457
- offlineSends.delete(id); watchOfflineSends()
559
+ offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
458
560
  await ready()
459
561
  entries.delete(id)
460
562
  await storage.remove(id)
461
563
  emit()
462
564
  },
463
- async close () {
464
- closed = true; available = false; lifecycle++
465
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
466
- clearSendRetry()
467
- for (const controller of sendControllers.values()) controller.abort()
468
- await Promise.allSettled([configuring, initialized])
469
- await messenger?.pause('closed')
470
- if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
471
- await messenger?.close()
472
- await storage?.close()
473
- await downloadWriteTail.catch(reportError)
474
- await downloads?.close()
565
+ close () {
566
+ return (closeWork ??= (async () => {
567
+ closed = true; available = false; lifecycle++
568
+ stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
569
+ clearSendRetry(); inboxRecovery.stop(); pausedSends.clear()
570
+ for (const controller of sendControllers.values()) controller.abort()
571
+ await Promise.allSettled([configuring, initialized])
572
+ await messenger?.pause('closed')
573
+ if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
574
+ await messenger?.close()
575
+ await storage?.close()
576
+ await downloadWriteTail.catch(reportError)
577
+ await downloads?.close()
578
+ })())
475
579
  }
476
580
  }
477
581
  }
@@ -76,7 +76,11 @@ export function createPublishSettlements (promises, timeout, { onSettled } = {})
76
76
  })
77
77
  }
78
78
 
79
- return { promise, timeout: timeoutPending }
79
+ const cancel = reason => {
80
+ if (isFinished) return
81
+ for (let index = 0; index < settlements.length; index++) settle(index, { status: 'rejected', outcome: 'failed', reason })
82
+ }
83
+ return { promise, timeout: timeoutPending, cancel }
80
84
  }
81
85
 
82
86
  // Turns ordered relay settlements into a stable, caller-facing report. Failed
@@ -186,23 +186,33 @@ export class RelayConnection {
186
186
  }
187
187
 
188
188
  #sendEventOperation (type, event, map, timeoutCode, signal) {
189
- if (signal?.aborted) return Promise.reject(signal.reason || relayTimeoutError(timeoutCode))
190
- if (map.has(event.id)) return map.get(event.id).promise
189
+ if (signal?.aborted) return Promise.reject(signal.reason)
190
+ let pending = map.get(event.id)
191
+ const created = !pending
192
+ if (created) {
193
+ const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
194
+ pending = { timer, waiters: new Set(), cancelSend: null }
195
+ map.set(event.id, pending)
196
+ }
191
197
  const deferred = Promise.withResolvers()
192
- const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
193
- const onAbort = () => this.#settleEvent(map, event.id, signal.reason || relayTimeoutError(timeoutCode))
194
- const stopAbort = () => signal?.removeEventListener('abort', onAbort)
195
- const pending = { ...deferred, timer, promise: deferred.promise, cancelSend: null, stopAbort }
196
- map.set(event.id, pending)
198
+ const onAbort = () => {
199
+ pending.waiters.delete(waiter)
200
+ signal.removeEventListener('abort', onAbort)
201
+ deferred.reject(signal.reason)
202
+ if (!pending.waiters.size && map.get(event.id) === pending) {
203
+ map.delete(event.id)
204
+ pending.cancelSend?.()
205
+ clearTimeout(pending.timer)
206
+ }
207
+ }
208
+ const waiter = { ...deferred, stopAbort: () => signal?.removeEventListener('abort', onAbort) }
209
+ pending.waiters.add(waiter)
197
210
  signal?.addEventListener('abort', onAbort, { once: true })
198
- pending.cancelSend = this.#dispatchWork(() => {
199
- // Once transmitted, keep the existing ACK/report semantics. Before that,
200
- // an operation-wide deadline must cancel the deferred publication.
201
- stopAbort()
202
- this.send(JSON.stringify([type, event]))
203
- }, error => {
204
- this.#settleEvent(map, event.id, error)
205
- })
211
+ if (created) {
212
+ pending.cancelSend = this.#dispatchWork(() => {
213
+ this.send(JSON.stringify([type, event]))
214
+ }, error => this.#settleEvent(map, event.id, error))
215
+ }
206
216
  return deferred.promise
207
217
  }
208
218
 
@@ -225,10 +235,13 @@ export class RelayConnection {
225
235
  if (!pending) return
226
236
  map.delete(id)
227
237
  pending.cancelSend?.()
228
- pending.stopAbort?.()
229
238
  clearTimeout(pending.timer)
230
- if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
231
- else pending.resolve(value)
239
+ for (const waiter of pending.waiters) {
240
+ waiter.stopAbort()
241
+ if (reason) waiter.reject(errorFrom(reason, 'OPERATION_REJECTED'))
242
+ else waiter.resolve(value)
243
+ }
244
+ pending.waiters.clear()
232
245
  }
233
246
 
234
247
  #settleCount (id, payload, reason) {
@@ -1204,8 +1204,10 @@ export class RelayPool {
1204
1204
  timeout = SEND_TIMEOUT_MS,
1205
1205
  timeoutUntilFirstFulfillment = null,
1206
1206
  getAuthEvent,
1207
- onRelayResult
1207
+ onRelayResult,
1208
+ signal
1208
1209
  } = {}) {
1210
+ if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
1209
1211
  const urls = normalizedRelayUrls(relays)
1210
1212
  if (!urls.length) {
1211
1213
  const promise = Promise.resolve(publishSummary([], urls, {
@@ -1236,7 +1238,11 @@ export class RelayPool {
1236
1238
  // Resolves after every relay settles (or reaches the operation timeout) as
1237
1239
  // { success, total, fulfilled, succeededRelays, errors },
1238
1240
  // where errors contains { relay, reason } entries for failed relays.
1241
+ const cancel = () => settlement.cancel(signal.reason)
1242
+ if (signal?.aborted) cancel()
1243
+ else signal?.addEventListener('abort', cancel, { once: true })
1239
1244
  const promise = settlement.promise
1245
+ .finally(() => signal?.removeEventListener('abort', cancel))
1240
1246
  .then(settlements => publishSummary(settlements, urls, {
1241
1247
  includeSucceededRelays: true
1242
1248
  }))
@@ -1245,10 +1251,11 @@ export class RelayPool {
1245
1251
  const deferred = sendDeferreds[index]
1246
1252
  ;(async () => {
1247
1253
  try {
1254
+ const operationSignal = sendControllers[index].signal
1255
+ if (operationSignal.aborted) throw operationSignal.reason
1248
1256
  const relay = await this.#getRelay(url)
1249
- const signal = sendControllers[index].signal
1250
- if (signal.aborted) throw signal.reason
1251
- return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
1257
+ if (operationSignal.aborted) throw operationSignal.reason
1258
+ return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
1252
1259
  } catch (err) {
1253
1260
  const reason = err instanceof Error ? err : new Error(String(err))
1254
1261
  if (reason instanceof Nip42AuthenticationError) throw reason