libp2r2p 0.11.17 → 0.11.19
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 +28 -2
- package/helpers/abortable-semaphore.js +2 -2
- package/package.json +1 -1
- package/private-channel/index.js +9 -2
- package/private-messenger/helpers/background-signer.js +16 -0
- package/private-messenger/helpers/fallback-race.js +134 -0
- package/private-messenger/helpers/send-routing.js +31 -5
- package/private-messenger/index.js +181 -62
- package/private-messenger/session/README.md +51 -3
- package/private-messenger/session/helpers/foreground-work.js +29 -0
- package/private-messenger/session/helpers/peer-preparation.js +111 -0
- package/private-messenger/session/helpers/signer.js +5 -1
- package/private-messenger/session/index.js +153 -53
- package/relay/helpers/publish.js +5 -1
- package/relay/services/relay-connection.js +31 -18
- package/relay/services/relay-pool.js +11 -4
package/README.md
CHANGED
|
@@ -535,7 +535,8 @@ new channels are watched unless paused. Applications may explicitly watch again.
|
|
|
535
535
|
|
|
536
536
|
Pauses, unwatch and close record the start of the gap, even for channels that
|
|
537
537
|
have never received a message. Rewatch/resume uses live delivery plus historical
|
|
538
|
-
recovery with overlap.
|
|
538
|
+
recovery with overlap. Remote recovery failures never restore the caller's pause
|
|
539
|
+
reason: signer availability belongs exclusively to the account owner.
|
|
539
540
|
Before opening a watch, the messenger persists a pending recovery interval.
|
|
540
541
|
A first watch scans the configured recovery window (seven days by default),
|
|
541
542
|
including messages sent before this identity first opened the channel. Successful
|
|
@@ -546,6 +547,24 @@ This also covers abrupt termination without `close()` and channels with no
|
|
|
546
547
|
messages. Applications still supply signers and desired channels on reopening;
|
|
547
548
|
storage is scoped by origin and user pubkey, not the transient session ID.
|
|
548
549
|
Failed ingestion or incomplete relay fetch reports never complete the gap.
|
|
550
|
+
|
|
551
|
+
`init()` and `createPrivateMessenger()` resolve after local preparation. Likewise,
|
|
552
|
+
`update(options, { waitForBackground = false })` and
|
|
553
|
+
`resume(reason, { waitForBackground = false })` wait for valid local channel
|
|
554
|
+
configuration and mandatory durable writes, allowing sends independently of
|
|
555
|
+
subscriptions, presence and history. Set `waitForBackground: true` to also await
|
|
556
|
+
the initial background tasks associated with that call, not every future retry.
|
|
557
|
+
Both modes share tasks; unchanged configuration does not recreate subscriptions.
|
|
558
|
+
Later errors reach `onError` with the original error as `cause` and
|
|
559
|
+
`operation: 'private-messenger.background'` plus a `phase`. Cancelled or obsolete
|
|
560
|
+
tasks cannot deliver into a new channel generation. Mandatory persistence still
|
|
561
|
+
owns its storage pause; incomplete history remains a pending recovery interval.
|
|
562
|
+
Subscription setup is bounded separately from presence, so a slow announcement
|
|
563
|
+
does not hold the other channels' readers. Sessions prioritize user persistence
|
|
564
|
+
and publication before the next background presence signer call. Signing an outer
|
|
565
|
+
envelope can overlap recovery-seed persistence, but no envelope is yielded for
|
|
566
|
+
publication unless both succeed; only one outer is prepared at a time.
|
|
567
|
+
|
|
549
568
|
`private-channel.fetch` reports incomplete reads as an `AggregateError` with
|
|
550
569
|
stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
|
|
551
570
|
completion statuses and native failure messages, including status-only failures.
|
|
@@ -1056,7 +1075,7 @@ in pairs per recipient when
|
|
|
1056
1075
|
selected relays reject publication or become unreachable while connectivity is
|
|
1057
1076
|
confirmed. The exact encrypted outer events are reused. Optional `fallbackRelays` on
|
|
1058
1077
|
`createPrivateMessenger` and `createPrivateMessageSession` are tried after the
|
|
1059
|
-
primary destinations, including explicit per-call `relays`, channel `sendRelays`
|
|
1078
|
+
primary destinations by default, including explicit per-call `relays`, channel `sendRelays`
|
|
1060
1079
|
and channel/global `relays`, as well as explicit `relayToReceivers` maps.
|
|
1061
1080
|
Retries preserve each encrypted recipient subset; success requires accepted
|
|
1062
1081
|
publication covering every recipient in that subset. Receive routing adds them from the start for live
|
|
@@ -1133,3 +1152,10 @@ and wake pending sends on release rather than repeatedly publishing while paused
|
|
|
1133
1152
|
Custom session factories must expose `readStatus()` and deliver the initial and
|
|
1134
1153
|
subsequent `onStateChanged` snapshots; missing observation is rejected instead of
|
|
1135
1154
|
using polling compatibility. See [session lifecycle](private-messenger/session/README.md).
|
|
1155
|
+
|
|
1156
|
+
Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
|
|
1157
|
+
by default). Automatic NIP-65 sends can start configured fallback relays after
|
|
1158
|
+
the chosen delay while primary publications retain their deadlines. Explicit
|
|
1159
|
+
routes keep sequential fallback; every encrypted recipient must be covered.
|
|
1160
|
+
`RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
|
|
1161
|
+
See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A bounded FIFO for asynchronous work. Acquired leases release explicitly;
|
|
2
2
|
// queued cancellations never consume capacity or keep idle keyed gates alive.
|
|
3
|
-
export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
3
|
+
export function createAbortableSemaphore (capacity, onIdle = () => {}, { maxQueued = 256 } = {}) {
|
|
4
4
|
const queue = []
|
|
5
5
|
let active = 0
|
|
6
6
|
const drain = () => {
|
|
@@ -21,7 +21,7 @@ export function createAbortableSemaphore (capacity, onIdle = () => {}) {
|
|
|
21
21
|
return {
|
|
22
22
|
acquire (signal) {
|
|
23
23
|
signal?.throwIfAborted()
|
|
24
|
-
if (queue.length >=
|
|
24
|
+
if (queue.length >= maxQueued) return Promise.reject(Object.assign(new Error('PRIVATE_CHANNEL_HISTORY_QUEUE_FULL'), { code: 'PRIVATE_CHANNEL_HISTORY_QUEUE_FULL' }))
|
|
25
25
|
return new Promise((resolve, reject) => {
|
|
26
26
|
const job = {
|
|
27
27
|
signal, resolve, abort: () => {
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -158,7 +158,8 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
158
158
|
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
159
159
|
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
160
160
|
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
161
|
-
|
|
161
|
+
const persistSeeds = async () => {
|
|
162
|
+
if (!onPreparedSeed) return
|
|
162
163
|
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
163
164
|
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
164
165
|
for (const rowIndex of rowIndexes) {
|
|
@@ -179,12 +180,18 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
179
180
|
content
|
|
180
181
|
}), routerSeckey)
|
|
181
182
|
const createdAt = nowSeconds()
|
|
182
|
-
const
|
|
183
|
+
const prepareOuter = async () => privateChannelSigner.signEvent({
|
|
183
184
|
kind: PRIVATE_BROADCAST_KIND,
|
|
184
185
|
created_at: createdAt,
|
|
185
186
|
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
186
187
|
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
187
188
|
})
|
|
189
|
+
// These independent steps may run together, but no envelope is yielded
|
|
190
|
+
// until every seed is durable. Settle both before releasing prepared rows.
|
|
191
|
+
const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
|
|
192
|
+
if (persistence.status === 'rejected') throw persistence.reason
|
|
193
|
+
if (prepared.status === 'rejected') throw prepared.reason
|
|
194
|
+
const outer = prepared.value
|
|
188
195
|
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
189
196
|
yield outer
|
|
190
197
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Keep actual permissions and cryptography in the signer. Background presence
|
|
2
|
+
// only yields before its next RPC, without interrupting an operation in flight.
|
|
3
|
+
export function backgroundSigner (signer, wait, signal) {
|
|
4
|
+
if (!signer || !wait) return signer
|
|
5
|
+
return new Proxy(signer, {
|
|
6
|
+
get (target, key) {
|
|
7
|
+
const value = Reflect.get(target, key, target)
|
|
8
|
+
if (typeof value !== 'function') return value
|
|
9
|
+
return async (...args) => {
|
|
10
|
+
await wait(signal)
|
|
11
|
+
signal?.throwIfAborted()
|
|
12
|
+
return value.apply(target, args)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
2
|
+
|
|
3
|
+
// Each invocation owns one already signed outer event and its encrypted subset.
|
|
4
|
+
// Lanes share coverage and the tried set; only started reports outlive the race.
|
|
5
|
+
export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
|
|
6
|
+
const pending = new Set(receivers)
|
|
7
|
+
const summaries = []
|
|
8
|
+
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
9
|
+
const outcome = Promise.withResolvers()
|
|
10
|
+
let primaryRunning = true
|
|
11
|
+
let fallbackRunning = false
|
|
12
|
+
let fallbackScheduled = true
|
|
13
|
+
let done = false
|
|
14
|
+
let blocked = false
|
|
15
|
+
let offline = false
|
|
16
|
+
let timer = null
|
|
17
|
+
let onlineWork
|
|
18
|
+
const current = () => !transportSignal?.aborted && isCurrent()
|
|
19
|
+
const summary = success => Promise.all(summaries).then(reports => ({
|
|
20
|
+
success, total: tried.size,
|
|
21
|
+
fulfilled: reports.reduce((total, report) => total + (report.fulfilled || 0), 0),
|
|
22
|
+
succeededRelays: [...new Set(reports.flatMap(report => report.succeededRelays || []))],
|
|
23
|
+
errors: reports.flatMap(report => report.errors || [])
|
|
24
|
+
}))
|
|
25
|
+
const finish = success => {
|
|
26
|
+
if (done) return
|
|
27
|
+
done = true
|
|
28
|
+
clearTimer(timer); timer = null
|
|
29
|
+
transportSignal?.removeEventListener('abort', interrupted)
|
|
30
|
+
const interruptedWork = !success && (offline || !current())
|
|
31
|
+
outcome.resolve({
|
|
32
|
+
success, total: tried.size, promise: summary(success),
|
|
33
|
+
...(interruptedWork && !signal?.aborted ? { retryWhenAvailable: true, ...(offline ? { retryWhenOnline: true } : {}) } : {})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
const check = () => {
|
|
37
|
+
if (done) return
|
|
38
|
+
if (!pending.size) finish(true)
|
|
39
|
+
else if (!primaryRunning && !fallbackRunning && !fallbackScheduled) finish(false)
|
|
40
|
+
}
|
|
41
|
+
const interrupted = () => finish(false)
|
|
42
|
+
const online = async () => {
|
|
43
|
+
if (done || !current()) return false
|
|
44
|
+
try {
|
|
45
|
+
if (!onlineWork) {
|
|
46
|
+
const work = Promise.resolve().then(() => isOnline({ signal: transportSignal })).finally(() => { if (onlineWork === work) onlineWork = null })
|
|
47
|
+
onlineWork = work
|
|
48
|
+
}
|
|
49
|
+
const connected = await onlineWork
|
|
50
|
+
if (done || !current()) return false
|
|
51
|
+
if (!connected) offline = true
|
|
52
|
+
return connected
|
|
53
|
+
} catch { if (!done && current()) offline = true; return false }
|
|
54
|
+
}
|
|
55
|
+
const launch = async (batch, extra = []) => {
|
|
56
|
+
const relays = [...new Set([...batch.relays, ...extra])].filter(relay => !tried.has(relay))
|
|
57
|
+
if (!relays.length) return
|
|
58
|
+
for (const relay of relays) tried.add(relay)
|
|
59
|
+
const slot = Promise.withResolvers()
|
|
60
|
+
summaries.push(slot.promise)
|
|
61
|
+
let result
|
|
62
|
+
try { result = await sendEvent(event, relays, { signal: transportSignal }) } catch (reason) {
|
|
63
|
+
result = { success: false, promise: Promise.resolve({ fulfilled: 0, errors: [{ reason }] }) }
|
|
64
|
+
}
|
|
65
|
+
const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
|
|
66
|
+
slot.resolve(settled)
|
|
67
|
+
settled.then(async report => {
|
|
68
|
+
const failed = (report.errors || []).filter(item => isReplaceableRelayFailure(item.reason))
|
|
69
|
+
// Late redundant failures can inform later sends after this race wins.
|
|
70
|
+
if (failed.length && (result.success || (done && !pending.size)) && !transportSignal?.aborted && isCurrent()) {
|
|
71
|
+
try { if (await isOnline({ signal: transportSignal }) && isCurrent()) remember(failed) } catch {}
|
|
72
|
+
}
|
|
73
|
+
}).catch(() => {})
|
|
74
|
+
if (result.success) {
|
|
75
|
+
if (!done) for (const receiver of batch.receivers) pending.delete(receiver)
|
|
76
|
+
check()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const report = await settled
|
|
80
|
+
if (done) return
|
|
81
|
+
const errors = report.errors || []
|
|
82
|
+
if (!errors.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) blocked = true
|
|
83
|
+
else if (await online()) remember(errors)
|
|
84
|
+
}
|
|
85
|
+
async function fallback () {
|
|
86
|
+
if (done || fallbackRunning || !fallbackScheduled) return
|
|
87
|
+
clearTimer(timer)
|
|
88
|
+
fallbackScheduled = false
|
|
89
|
+
fallbackRunning = true
|
|
90
|
+
try {
|
|
91
|
+
if (blocked || offline || !pending.size || !current()) return
|
|
92
|
+
let batch = nextFallback(pending)
|
|
93
|
+
if (!batch || !await online()) return
|
|
94
|
+
while (batch) {
|
|
95
|
+
if (done || blocked || offline || !current()) break
|
|
96
|
+
await launch(batch)
|
|
97
|
+
batch = nextFallback(pending)
|
|
98
|
+
}
|
|
99
|
+
} finally { fallbackRunning = false; check() }
|
|
100
|
+
}
|
|
101
|
+
async function primary () {
|
|
102
|
+
try {
|
|
103
|
+
let batch = first || nextPrimary(pending)
|
|
104
|
+
let opening = true
|
|
105
|
+
while (batch) {
|
|
106
|
+
if (done || blocked || offline || !current()) break
|
|
107
|
+
await launch(batch, opening ? mirrors : [])
|
|
108
|
+
opening = false
|
|
109
|
+
batch = nextPrimary(pending)
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
primaryRunning = false
|
|
113
|
+
// Exhaustion before the deadline starts the fallback immediately.
|
|
114
|
+
if (!done && fallbackScheduled) {
|
|
115
|
+
if (!blocked && !offline && current()) await fallback()
|
|
116
|
+
else { clearTimer(timer); fallbackScheduled = false }
|
|
117
|
+
}
|
|
118
|
+
check()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
|
|
122
|
+
transportSignal?.addEventListener('abort', interrupted, { once: true })
|
|
123
|
+
const deadline = now() + delay
|
|
124
|
+
const failed = reason => {
|
|
125
|
+
if (done) return
|
|
126
|
+
done = true; clearTimer(timer); timer = null
|
|
127
|
+
transportSignal?.removeEventListener('abort', interrupted)
|
|
128
|
+
outcome.reject(reason)
|
|
129
|
+
}
|
|
130
|
+
timer = setTimer(() => { fallback().catch(failed) }, Math.max(0, deadline - now()))
|
|
131
|
+
timer?.unref?.()
|
|
132
|
+
primary().catch(failed)
|
|
133
|
+
return outcome.promise
|
|
134
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeRelayUrl } from '../../url/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
|
+
import { publishWithEarlyFallback } from './fallback-race.js'
|
|
3
4
|
import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
|
|
4
5
|
|
|
5
6
|
const EXCLUSION_MS = 5 * 60 * 1000
|
|
@@ -12,6 +13,11 @@ export function normalizeFallbackRelays (value = []) {
|
|
|
12
13
|
return [...new Set(Array.from(value, normalizeRelayUrl))]
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export function normalizeFallbackDelay (value = null) {
|
|
17
|
+
if (value !== null && (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)) throw new ValidationError('INVALID_FALLBACK_DELAY')
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function routeEntries (routes) {
|
|
16
22
|
return routes instanceof Map ? [...routes] : Object.entries(routes || {})
|
|
17
23
|
}
|
|
@@ -26,9 +32,11 @@ function routeGroups (routes) {
|
|
|
26
32
|
return [...groups.values()]
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, now = Date.now }) {
|
|
35
|
+
export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
30
36
|
// Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
|
|
31
37
|
// retain their fanout; configured fallbacks are a separate last-resort stage.
|
|
38
|
+
fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
|
|
39
|
+
const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
|
|
32
40
|
const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
|
|
33
41
|
const primaryRoutes = primaryRelayToReceivers || (primaryRelays
|
|
34
42
|
? new Map(primaryRelays.map(relay => [relay, peers]))
|
|
@@ -61,12 +69,12 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
61
69
|
return routes
|
|
62
70
|
}
|
|
63
71
|
const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
|
|
64
|
-
const current = () => !
|
|
72
|
+
const current = () => !transportSignal?.aborted && isCurrent()
|
|
65
73
|
const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
|
|
66
|
-
const remember = errors => { for (const item of errors) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
74
|
+
const remember = errors => { for (const item of errors) if (candidateSet.has(normalized(item.relay))) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
|
|
67
75
|
const online = async () => {
|
|
68
76
|
if (!current()) return false
|
|
69
|
-
try { return await isOnline({ signal }) && current() } catch { return false }
|
|
77
|
+
try { return await isOnline({ signal: transportSignal }) && current() } catch { return false }
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
async function send (event, initialRelays, context = {}) {
|
|
@@ -77,6 +85,24 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
77
85
|
const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
|
|
78
86
|
const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
|
|
79
87
|
const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
|
|
88
|
+
if (!explicit && fallbackDelayMs !== null && fallbackRelays.length) {
|
|
89
|
+
const tried = new Set()
|
|
90
|
+
const pick = (pending, fallback) => {
|
|
91
|
+
const excluded = [...new Set([...exclusions.keys(), ...tried])]
|
|
92
|
+
const routes = pickRelays([...pending], fallback
|
|
93
|
+
? Object.fromEntries([...pending].map(pubkey => [pubkey, { read: fallbackRelays }]))
|
|
94
|
+
: primaryByPeer, { relayType: 'read', maxPerPubkey: 2, excludeRelaysByPubkey: new Map([...pending].map(pubkey => [pubkey, excluded])), emptyRelaysFallback: [] })
|
|
95
|
+
return routeGroups(routes)[0]
|
|
96
|
+
}
|
|
97
|
+
const result = await publishWithEarlyFallback({
|
|
98
|
+
event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
|
|
99
|
+
nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
|
|
100
|
+
sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
|
|
101
|
+
delay: fallbackDelayMs, now, setTimer, clearTimer
|
|
102
|
+
})
|
|
103
|
+
if (!result.success && current()) exclusions.clear()
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
80
106
|
let batch = first.length ? { receivers, relays: first } : null
|
|
81
107
|
const tried = new Set()
|
|
82
108
|
const errors = []
|
|
@@ -106,7 +132,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
|
|
|
106
132
|
for (const relay of relays) tried.add(normalized(relay))
|
|
107
133
|
// Every relay in this batch serves the same pending recipient subset.
|
|
108
134
|
// One ACK covers that subset, never unrelated members of the ciphertext.
|
|
109
|
-
result = await sendEvent(event, relays)
|
|
135
|
+
result = await sendEvent(event, relays, { signal: transportSignal })
|
|
110
136
|
const settled = Promise.resolve(result.promise)
|
|
111
137
|
summaries.push(settled)
|
|
112
138
|
if (result.success) {
|