libp2r2p 0.11.7 → 0.11.8
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 +36 -0
- package/package.json +1 -1
- package/private-channel/helpers/subscription-error.js +1 -1
- package/private-channel/index.js +11 -3
- package/private-message/index.js +126 -10
- package/private-messenger/helpers/recovery-retry.js +26 -0
- package/private-messenger/index.js +85 -3
- package/relay/helpers/live-buffer-error.js +15 -0
- package/relay/services/relay-pool.js +15 -9
package/README.md
CHANGED
|
@@ -985,3 +985,39 @@ publication failures retain the payload/index for retry. Outbound channel
|
|
|
985
985
|
fragmentation streams from prepared encrypted rows, cleans partial writes and
|
|
986
986
|
removes failed new keys from the temporary-storage registry. It does not raise
|
|
987
987
|
the browser's Web Storage quota or delete unrelated application data.
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
### Private subscription recovery (0.11.8)
|
|
991
|
+
|
|
992
|
+
`privateChannel.subscribe()` returns `{ ready, done, close }`. `done` always
|
|
993
|
+
resolves to `{ status: 'closed' | 'ended' | 'failed', error? }` after processing
|
|
994
|
+
stops. Explicit cancellation is `closed`; a naturally exhausted stream is
|
|
995
|
+
`ended`; a terminal error is `failed` and still reaches `onError`.
|
|
996
|
+
|
|
997
|
+
`private-message.watch()` accepts an optional asynchronous
|
|
998
|
+
`onSubscriptionState({ state, relay, since, until?, error? })` callback.
|
|
999
|
+
`interrupted` identifies the conservative lower recovery boundary; after a
|
|
1000
|
+
buffer overflow, every affected channel must finish this callback before its
|
|
1001
|
+
shared relay subscription reopens. `ready` follows successful initial relay
|
|
1002
|
+
readiness on the replacement. Callbacks and completions are generation-fenced.
|
|
1003
|
+
Equal watch settings cannot keep a dead subscription registered as active.
|
|
1004
|
+
|
|
1005
|
+
Overflow recovery keeps one timer per relay with exponential 1–30 second
|
|
1006
|
+
backoff and 20% jitter, reset after a stable minute. Other relays remain live.
|
|
1007
|
+
Removal, pause and close cancel pending work. Fragment storage stays scoped to
|
|
1008
|
+
the same session across replacement subscriptions. Permanent refusals remain
|
|
1009
|
+
reported and do not trigger this automatic retry loop.
|
|
1010
|
+
|
|
1011
|
+
PrivateMessenger persists each interrupted interval before reopening, then
|
|
1012
|
+
uses paged `fetchHistory` to repair the gap while listening for new messages.
|
|
1013
|
+
Transient history failures retry with bounded backoff and honor `retryAt`;
|
|
1014
|
+
dense-page limits, permanent refusals and unknown failures stay pending without
|
|
1015
|
+
an automatic loop. Disabled historical recovery restores only live input.
|
|
1016
|
+
Existing persisted ranges, checkpoints, recovery windows and ack/nack semantics
|
|
1017
|
+
are unchanged; newer live arrivals cannot erase a pending interval.
|
|
1018
|
+
|
|
1019
|
+
`RELAY_LIVE_BUFFER_FULL` retains `relay`, `phase`, `recoverySince` and a bounded
|
|
1020
|
+
`buffer` diagnostic: stage (`delivery`, `reconnect`, `history-wait`), queued
|
|
1021
|
+
count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
|
|
1022
|
+
serialized volume, not heap use. Diagnostics never retain event contents or
|
|
1023
|
+
filters. Existing 1,000-item / 8-MiB live limits are unchanged.
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ export function subscriptionError (reason, relay) {
|
|
|
5
5
|
const error = reason instanceof AggregateError
|
|
6
6
|
? new AggregateError(reason.errors, message, { cause: reason })
|
|
7
7
|
: new Error(message, { cause: reason })
|
|
8
|
-
for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean']) {
|
|
8
|
+
for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean', 'phase', 'buffer', 'recoverySince']) {
|
|
9
9
|
if (reason?.[key] !== undefined) error[key] = reason[key]
|
|
10
10
|
}
|
|
11
11
|
error.operation = 'private-channel.subscribe'
|
package/private-channel/index.js
CHANGED
|
@@ -984,6 +984,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
984
984
|
})
|
|
985
985
|
|
|
986
986
|
async function consumeEvents () {
|
|
987
|
+
let result = { status: 'ended' }
|
|
987
988
|
try {
|
|
988
989
|
for await (const item of events) {
|
|
989
990
|
if (controller.signal.aborted) continue
|
|
@@ -991,19 +992,26 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
991
992
|
else if (item.type === 'event') await processOuterEvent(item.event)
|
|
992
993
|
}
|
|
993
994
|
} catch (error) {
|
|
994
|
-
if (!controller.signal.aborted
|
|
995
|
+
if (!controller.signal.aborted) {
|
|
996
|
+
const diagnostic = subscriptionError(error, error?.relay)
|
|
997
|
+
result = { status: 'failed', error: diagnostic }
|
|
998
|
+
// Consumer error callbacks must not turn `done` into an unhandled rejection.
|
|
999
|
+
try { onError?.(diagnostic) } catch {}
|
|
1000
|
+
}
|
|
995
1001
|
} finally {
|
|
996
1002
|
processOuterEvent.close()
|
|
997
1003
|
}
|
|
1004
|
+
return controller.signal.aborted ? { status: 'closed' } : result
|
|
998
1005
|
}
|
|
999
1006
|
|
|
1000
|
-
const
|
|
1007
|
+
const done = consumeEvents()
|
|
1001
1008
|
|
|
1002
1009
|
return {
|
|
1003
1010
|
ready: events.ready || Promise.resolve(),
|
|
1011
|
+
done,
|
|
1004
1012
|
close () {
|
|
1005
1013
|
controller.abort()
|
|
1006
|
-
return
|
|
1014
|
+
return done
|
|
1007
1015
|
}
|
|
1008
1016
|
}
|
|
1009
1017
|
}
|
package/private-message/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
1
2
|
import { isValidEvent } from '../event/index.js'
|
|
2
3
|
import { ValidationError } from '../error/index.js'
|
|
3
4
|
import { generateKeypair } from '../key/index.js'
|
|
@@ -144,9 +145,10 @@ async function ownPrivateChannelPubkey (signer) {
|
|
|
144
145
|
|
|
145
146
|
// A session owns its callbacks and relay reads. Other consumers, including
|
|
146
147
|
// the other side of the same shared channel, cannot overwrite or stop them.
|
|
147
|
-
export function createPrivateMessageSession () {
|
|
148
|
+
export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearTimeout = clearTimeout, _now = Date.now, _random = Math.random } = {}) {
|
|
148
149
|
const watchesByChannel = new Map()
|
|
149
150
|
const subsByRelay = new Map()
|
|
151
|
+
const relayRecoveries = new Map()
|
|
150
152
|
let nextWatchRevision = 1
|
|
151
153
|
let requestSequence = 0
|
|
152
154
|
const watchRequests = new Map()
|
|
@@ -301,11 +303,113 @@ export function createPrivateMessageSession () {
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
function
|
|
306
|
+
function reportSubscriptionError (channels, error) {
|
|
307
|
+
try { watchCallbacks([...channels][0]).onError?.(error) } catch {}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cancelRelayRecovery (relay) {
|
|
311
|
+
const recovery = relayRecoveries.get(relay)
|
|
312
|
+
if (recovery?.timer != null) _clearTimeout(recovery.timer)
|
|
313
|
+
relayRecoveries.delete(relay)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function notifyInterruption (relay, recovery) {
|
|
317
|
+
const channels = desiredRelayState().get(relay) || []
|
|
318
|
+
// Each consumer must persist its gap before this shared subscription restarts.
|
|
319
|
+
// Keep acknowledgments on partial failure; new/replaced watches participate too.
|
|
320
|
+
const outcomes = await Promise.allSettled([...channels].map(async channel => {
|
|
321
|
+
const watch = watchesByChannel.get(channel)
|
|
322
|
+
if (recovery.notified.get(channel) === watch) return
|
|
323
|
+
await watch.callbacks.onSubscriptionState?.({ state: 'interrupted', relay, error: recovery.error, since: recovery.since })
|
|
324
|
+
recovery.notified.set(channel, watch)
|
|
325
|
+
}))
|
|
326
|
+
const failed = outcomes.find(result => result.status === 'rejected')
|
|
327
|
+
if (failed) throw failed.reason
|
|
328
|
+
if (relayRecoveries.get(relay) === recovery && [...(desiredRelayState().get(relay) || [])].some(channel => recovery.notified.get(channel) !== watchesByChannel.get(channel))) {
|
|
329
|
+
await notifyInterruption(relay, recovery)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function scheduleRelayRecovery (relay, recovery) {
|
|
334
|
+
if (relayRecoveries.get(relay) !== recovery || recovery.timer || !desiredRelayState().has(relay)) return
|
|
335
|
+
const delay = Math.min(30000, recovery.delay * (0.8 + _random() * 0.4))
|
|
336
|
+
recovery.delay = Math.min(30000, recovery.delay * 2)
|
|
337
|
+
recovery.timer = _setTimeout(async () => {
|
|
338
|
+
recovery.timer = null
|
|
339
|
+
try {
|
|
340
|
+
await notifyInterruption(relay, recovery)
|
|
341
|
+
if (relayRecoveries.get(relay) !== recovery || !desiredRelayState().has(relay)) return
|
|
342
|
+
await rebuildSubscriptions({ retryRelay: relay })
|
|
343
|
+
} catch (error) {
|
|
344
|
+
reportSubscriptionError(desiredRelayState().get(relay) || [], error)
|
|
345
|
+
scheduleRelayRecovery(relay, recovery)
|
|
346
|
+
}
|
|
347
|
+
}, delay)
|
|
348
|
+
recovery.timer?.unref?.()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function subscriptionEnded (relay, entry, result) {
|
|
352
|
+
if (subsByRelay.get(relay) !== entry) return
|
|
353
|
+
subsByRelay.delete(relay)
|
|
354
|
+
if (result.status === 'closed' || !desiredRelayState().has(relay)) return
|
|
355
|
+
const error = result.error || Object.assign(new Error('PRIVATE_CHANNEL_SUBSCRIPTION_ENDED'), { code: 'PRIVATE_CHANNEL_SUBSCRIPTION_ENDED', relay })
|
|
356
|
+
const previous = relayRecoveries.get(relay)
|
|
357
|
+
const recovery = {
|
|
358
|
+
error,
|
|
359
|
+
since: Math.min(entry.since, error.recoverySince ?? entry.since, previous?.since ?? Infinity),
|
|
360
|
+
delay: entry.readyAt !== undefined && _now() - entry.readyAt >= 60000 ? 1000 : (previous?.delay || entry.retryDelay || 1000),
|
|
361
|
+
notified: new Map(), timer: null
|
|
362
|
+
}
|
|
363
|
+
if (/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError' ||
|
|
364
|
+
(error.code !== 'RELAY_LIVE_BUFFER_FULL' && !previous)) {
|
|
365
|
+
cancelRelayRecovery(relay)
|
|
366
|
+
notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
cancelRelayRecovery(relay)
|
|
370
|
+
relayRecoveries.set(relay, recovery)
|
|
371
|
+
// Start persistence immediately, without overlapping a later retry callback.
|
|
372
|
+
recovery.preparing = notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
|
|
373
|
+
recovery.preparing.finally(() => scheduleRelayRecovery(relay, recovery))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function subscriptionReady (relay, entry, ready) {
|
|
377
|
+
if (subsByRelay.get(relay) !== entry) return
|
|
378
|
+
const recovery = relayRecoveries.get(relay)
|
|
379
|
+
if (!ready?.relays?.includes(relay)) {
|
|
380
|
+
if (recovery) {
|
|
381
|
+
const error = ready?.errors?.[0]?.reason || Object.assign(new Error('RELAY_LIVE_NOT_READY'), { code: 'RELAY_LIVE_NOT_READY', relay })
|
|
382
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
383
|
+
await entry.sub.close()
|
|
384
|
+
}
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
entry.readyAt = _now()
|
|
388
|
+
if (!recovery) return
|
|
389
|
+
try {
|
|
390
|
+
const until = Math.floor(_now() / 1000)
|
|
391
|
+
await Promise.all([...entry.channels].map(async channel => {
|
|
392
|
+
if (subsByRelay.get(relay) !== entry || !watchesByChannel.has(channel)) return
|
|
393
|
+
await watchCallbacks(channel).onSubscriptionState?.({ state: 'ready', relay, since: recovery.since, until })
|
|
394
|
+
}))
|
|
395
|
+
if (subsByRelay.get(relay) === entry && relayRecoveries.get(relay) === recovery) {
|
|
396
|
+
entry.retryDelay = recovery.delay
|
|
397
|
+
cancelRelayRecovery(relay)
|
|
398
|
+
await rebuildSubscriptions()
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
reportSubscriptionError(entry.channels, error)
|
|
402
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
403
|
+
await entry.sub.close()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true, retryRelay } = {}) {
|
|
305
408
|
subscribe = _subscribe === privateChannel.subscribe ? subscribe : _subscribe
|
|
306
409
|
_subscribe = subscribe
|
|
307
410
|
const desired = desiredRelayState()
|
|
308
411
|
const closing = []
|
|
412
|
+
for (const relay of relayRecoveries.keys()) if (!desired.has(relay)) cancelRelayRecovery(relay)
|
|
309
413
|
|
|
310
414
|
for (const [relay, current] of subsByRelay) {
|
|
311
415
|
const nextChannels = desired.get(relay)
|
|
@@ -321,6 +425,8 @@ export function createPrivateMessageSession () {
|
|
|
321
425
|
const current = subsByRelay.get(relay)
|
|
322
426
|
if (doesSubscriptionMatch(current, channels)) continue
|
|
323
427
|
|
|
428
|
+
if (relayRecoveries.has(relay) && retryRelay !== relay) continue
|
|
429
|
+
const since = nowSeconds()
|
|
324
430
|
const channelList = [...channels]
|
|
325
431
|
const firstWatch = watchesByChannel.get(channelList[0])
|
|
326
432
|
const sub = _subscribe({
|
|
@@ -345,7 +451,7 @@ export function createPrivateMessageSession () {
|
|
|
345
451
|
ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
|
|
346
452
|
ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
|
|
347
453
|
limit: 0,
|
|
348
|
-
since
|
|
454
|
+
since,
|
|
349
455
|
liveOnly: true,
|
|
350
456
|
onChunk: handleChunk,
|
|
351
457
|
onEvent: (event, outer, meta) => {
|
|
@@ -358,14 +464,24 @@ export function createPrivateMessageSession () {
|
|
|
358
464
|
return dispatchSeedEvent(seed)
|
|
359
465
|
},
|
|
360
466
|
onContentKeyUsage: dispatchContentKeyUsage,
|
|
361
|
-
onError: err =>
|
|
467
|
+
onError: err => reportSubscriptionError(channels, err)
|
|
362
468
|
})
|
|
363
469
|
|
|
364
|
-
|
|
470
|
+
const entry = {
|
|
471
|
+
since,
|
|
365
472
|
channels: new Set(channels),
|
|
366
473
|
revisions: watchRevisionsForChannels(channelList),
|
|
367
474
|
sub
|
|
368
|
-
}
|
|
475
|
+
}
|
|
476
|
+
subsByRelay.set(relay, entry)
|
|
477
|
+
if (sub.done) sub.done.then(result => subscriptionEnded(relay, entry, result)).catch(error => reportSubscriptionError(channels, error))
|
|
478
|
+
if (sub.ready) {
|
|
479
|
+
sub.ready.then(ready => subscriptionReady(relay, entry, ready)).catch(error => {
|
|
480
|
+
reportSubscriptionError(channels, error)
|
|
481
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
482
|
+
Promise.resolve(sub.close()).catch(() => {})
|
|
483
|
+
})
|
|
484
|
+
}
|
|
369
485
|
if (current) {
|
|
370
486
|
const close = closeSubscription(current.sub, gracefulClose)
|
|
371
487
|
if (close) closing.push(close)
|
|
@@ -393,6 +509,7 @@ export function createPrivateMessageSession () {
|
|
|
393
509
|
onSeed,
|
|
394
510
|
onChunk,
|
|
395
511
|
onContentKeyUsage,
|
|
512
|
+
onSubscriptionState,
|
|
396
513
|
onError,
|
|
397
514
|
receivedChunkTtlMs,
|
|
398
515
|
receivedChunkMaxBytes,
|
|
@@ -403,6 +520,7 @@ export function createPrivateMessageSession () {
|
|
|
403
520
|
_subscribe = privateChannel.subscribe
|
|
404
521
|
}) {
|
|
405
522
|
if (!relays?.length) throw new ValidationError('NO_RELAYS')
|
|
523
|
+
relays = uniq(relays.map(normalizeRelayUrl))
|
|
406
524
|
const request = ++requestSequence
|
|
407
525
|
const channelList = uniq(channels?.length ? channels : [await ownPrivateChannelPubkey(privateChannelSigner)])
|
|
408
526
|
for (const channel of channelList) {
|
|
@@ -411,9 +529,8 @@ export function createPrivateMessageSession () {
|
|
|
411
529
|
const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
|
|
412
530
|
if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
|
|
413
531
|
identity = ownPubkey
|
|
414
|
-
const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onError }
|
|
532
|
+
const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
|
|
415
533
|
|
|
416
|
-
let changed = false
|
|
417
534
|
for (const channel of channelList) {
|
|
418
535
|
if (watchRequests.get(channel) !== request) continue
|
|
419
536
|
const next = {
|
|
@@ -457,10 +574,9 @@ export function createPrivateMessageSession () {
|
|
|
457
574
|
continue
|
|
458
575
|
}
|
|
459
576
|
watchesByChannel.set(channel, next)
|
|
460
|
-
changed = true
|
|
461
577
|
}
|
|
462
578
|
|
|
463
|
-
|
|
579
|
+
await rebuildSubscriptions({ _subscribe })
|
|
464
580
|
return () => {
|
|
465
581
|
const owned = channelList.filter(channel => watchRequests.get(channel) === request)
|
|
466
582
|
return unwatch(owned)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Retry operational interruptions, never dense-page limits or permanent refusals.
|
|
2
|
+
export function recoveryRetryDelay (errors, delay) {
|
|
3
|
+
let retry = false
|
|
4
|
+
let blocked = false
|
|
5
|
+
let retryAt = 0
|
|
6
|
+
const seen = new Set()
|
|
7
|
+
function visit (error) {
|
|
8
|
+
if (!error || seen.has(error)) return
|
|
9
|
+
seen.add(error)
|
|
10
|
+
if (error.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || error.name === 'ValidationError' ||
|
|
11
|
+
/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '')) blocked = true
|
|
12
|
+
if (['transport', 'timeout'].includes(error.category) ||
|
|
13
|
+
['QuotaExceededError', 'InvalidStateError', 'UnknownError'].includes(error.name) ||
|
|
14
|
+
/^(rate-limited:|error:)/.test(error.message || '') ||
|
|
15
|
+
['RELAY_LIVE_BUFFER_FULL', 'RELAY_LIVE_NOT_READY'].includes(error.code)) retry = true
|
|
16
|
+
if (Number.isFinite(error.retryAt)) retryAt = Math.max(retryAt, Math.min(Date.now() + 300000, error.retryAt))
|
|
17
|
+
for (const child of error.errors || []) visit(child)
|
|
18
|
+
for (const entry of error.relays || []) {
|
|
19
|
+
if (['timeout', 'cutoff', 'closed'].includes(entry.status)) retry = true
|
|
20
|
+
visit(entry.error)
|
|
21
|
+
}
|
|
22
|
+
visit(error.cause)
|
|
23
|
+
}
|
|
24
|
+
errors.forEach(visit)
|
|
25
|
+
return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
|
|
26
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recoveryRetryDelay } from './helpers/recovery-retry.js'
|
|
1
2
|
// Expected use:
|
|
2
3
|
// const messenger = await createPrivateMessenger({
|
|
3
4
|
// userSigner,
|
|
@@ -293,6 +294,8 @@ export class PrivateMessenger {
|
|
|
293
294
|
this.capacityCheck = null
|
|
294
295
|
this.capacityRequiredBytes = 0
|
|
295
296
|
this.reloadGapTimers = new Map()
|
|
297
|
+
this.liveInterruptions = new Map()
|
|
298
|
+
this.liveRecoveryTimers = new Map()
|
|
296
299
|
this.watchRevisionByChannel = new Map()
|
|
297
300
|
this.presenceTimers = new Map()
|
|
298
301
|
this.stopRelayListWatcher = null
|
|
@@ -1103,6 +1106,7 @@ export class PrivateMessenger {
|
|
|
1103
1106
|
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
1104
1107
|
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
1105
1108
|
receivedChunkIndexedDB: this._indexedDB,
|
|
1109
|
+
onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
|
|
1106
1110
|
onError: err => this.onError?.(err)
|
|
1107
1111
|
})
|
|
1108
1112
|
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
|
|
@@ -1136,6 +1140,76 @@ export class PrivateMessenger {
|
|
|
1136
1140
|
return this
|
|
1137
1141
|
}
|
|
1138
1142
|
|
|
1143
|
+
async handleSubscriptionState (pubkey, { state, relay, since, until }) {
|
|
1144
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
|
|
1145
|
+
if (!this.offlineRecoverySecondsFor(pubkey)) return
|
|
1146
|
+
let interrupted = this.liveInterruptions.get(pubkey)
|
|
1147
|
+
if (state === 'interrupted') {
|
|
1148
|
+
if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
|
|
1149
|
+
const start = Math.min(interrupted.get(relay) ?? Infinity, since)
|
|
1150
|
+
interrupted.set(relay, start)
|
|
1151
|
+
this.cancelReloadGap(pubkey)
|
|
1152
|
+
for (const controller of this.recoveryControllers) {
|
|
1153
|
+
if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
|
|
1154
|
+
}
|
|
1155
|
+
this.recordInterruption([pubkey], start)
|
|
1156
|
+
this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
|
|
1157
|
+
// This rejection prevents the session from reopening the shared relay.
|
|
1158
|
+
await this.flushStateWrites()
|
|
1159
|
+
} else if (state === 'ready' && interrupted?.has(relay)) {
|
|
1160
|
+
const start = interrupted.get(relay)
|
|
1161
|
+
this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), until)
|
|
1162
|
+
if (interrupted.size === 1) this.closeOpenOfflineRanges([pubkey])
|
|
1163
|
+
await this.flushStateWrites()
|
|
1164
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || this.liveInterruptions.get(pubkey) !== interrupted) return
|
|
1165
|
+
interrupted.delete(relay)
|
|
1166
|
+
if (!interrupted.size) this.liveInterruptions.delete(pubkey)
|
|
1167
|
+
this.scheduleLiveRecovery(pubkey)
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
cancelLiveRecovery (pubkey) {
|
|
1172
|
+
const work = this.liveRecoveryTimers.get(pubkey)
|
|
1173
|
+
if (work?.timer != null) this._clearTimeout(work.timer)
|
|
1174
|
+
this.liveRecoveryTimers.delete(pubkey)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
scheduleLiveRecovery (pubkey) {
|
|
1178
|
+
const current = this.liveRecoveryTimers.get(pubkey)
|
|
1179
|
+
if (current) { current.again = true; return }
|
|
1180
|
+
const work = { delay: 1000, timer: null, again: false }
|
|
1181
|
+
this.liveRecoveryTimers.set(pubkey, work)
|
|
1182
|
+
const isCurrent = () => this.liveRecoveryTimers.get(pubkey) === work && !this.closePromise &&
|
|
1183
|
+
!this.pauseReasons.size && this.desiredChannels.has(pubkey)
|
|
1184
|
+
const schedule = delay => {
|
|
1185
|
+
work.timer = this._setTimeout(async () => {
|
|
1186
|
+
work.timer = null
|
|
1187
|
+
if (!isCurrent()) return
|
|
1188
|
+
work.again = false
|
|
1189
|
+
let errors
|
|
1190
|
+
try {
|
|
1191
|
+
// A ready notification may overtake cancellation of the previous scan.
|
|
1192
|
+
// Let that scan retain its range, then start a fresh recovery attempt.
|
|
1193
|
+
await this.recoveries.get(pubkey)
|
|
1194
|
+
if (!isCurrent()) return
|
|
1195
|
+
errors = await this.recoverOfflineRanges([pubkey])
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
this.onError?.(error)
|
|
1198
|
+
errors = [error]
|
|
1199
|
+
}
|
|
1200
|
+
if (!isCurrent()) return
|
|
1201
|
+
const pending = this.readState().channels[pubkey]?.offlineRanges?.length
|
|
1202
|
+
const retry = pending ? recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4))) : null
|
|
1203
|
+
if (work.again || retry !== null) {
|
|
1204
|
+
schedule(work.again ? 0 : retry)
|
|
1205
|
+
work.delay = Math.min(30000, work.delay * 2)
|
|
1206
|
+
} else this.liveRecoveryTimers.delete(pubkey)
|
|
1207
|
+
}, delay)
|
|
1208
|
+
work.timer?.unref?.()
|
|
1209
|
+
}
|
|
1210
|
+
schedule(0)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1139
1213
|
recordInterruption (channels, at = nowSeconds()) {
|
|
1140
1214
|
const state = this.readState()
|
|
1141
1215
|
for (const pubkey of channels) {
|
|
@@ -1152,6 +1226,8 @@ export class PrivateMessenger {
|
|
|
1152
1226
|
const closing = []
|
|
1153
1227
|
for (const pubkey of channels) {
|
|
1154
1228
|
this.cancelReloadGap(pubkey)
|
|
1229
|
+
this.cancelLiveRecovery(pubkey)
|
|
1230
|
+
this.liveInterruptions.delete(pubkey)
|
|
1155
1231
|
this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
|
|
1156
1232
|
const close = this.stopByChannel.get(pubkey)?.()
|
|
1157
1233
|
if (close?.then) closing.push(close)
|
|
@@ -2063,6 +2139,7 @@ export class PrivateMessenger {
|
|
|
2063
2139
|
}
|
|
2064
2140
|
|
|
2065
2141
|
async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
|
|
2142
|
+
const failures = []
|
|
2066
2143
|
for (const pubkey of uniq(channels)) {
|
|
2067
2144
|
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) continue
|
|
2068
2145
|
let work = this.recoveries.get(pubkey)
|
|
@@ -2070,11 +2147,13 @@ export class PrivateMessenger {
|
|
|
2070
2147
|
work = this.recoverChannelRanges([pubkey])
|
|
2071
2148
|
this.recoveries.set(pubkey, work)
|
|
2072
2149
|
}
|
|
2073
|
-
try { await work } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
|
|
2150
|
+
try { failures.push(...await work) } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
|
|
2074
2151
|
}
|
|
2152
|
+
return failures
|
|
2075
2153
|
}
|
|
2076
2154
|
|
|
2077
2155
|
async recoverChannelRanges (channels) {
|
|
2156
|
+
const failures = []
|
|
2078
2157
|
const state = this.readState()
|
|
2079
2158
|
const now = nowSeconds()
|
|
2080
2159
|
|
|
@@ -2125,12 +2204,14 @@ export class PrivateMessenger {
|
|
|
2125
2204
|
})
|
|
2126
2205
|
controller.signal.throwIfAborted()
|
|
2127
2206
|
const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
|
|
2128
|
-
const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2207
|
+
const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2129
2208
|
(this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
|
|
2209
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
2130
2210
|
if (lifecycleChanged || attempt.failures.length) remaining.push(range)
|
|
2131
2211
|
else recoveredThrough = Math.max(recoveredThrough, range.end)
|
|
2132
2212
|
} catch (err) {
|
|
2133
|
-
this.onError?.(err)
|
|
2213
|
+
if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
|
|
2214
|
+
failures.push(err)
|
|
2134
2215
|
remaining.push(range)
|
|
2135
2216
|
} finally { this.recoveryControllers.delete(controller) }
|
|
2136
2217
|
}
|
|
@@ -2145,6 +2226,7 @@ export class PrivateMessenger {
|
|
|
2145
2226
|
this.writeState(fresh)
|
|
2146
2227
|
await this.flushStateWrites()
|
|
2147
2228
|
}
|
|
2229
|
+
return failures
|
|
2148
2230
|
}
|
|
2149
2231
|
|
|
2150
2232
|
async clearChannel (pubkey) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Bounded diagnostics only: never attach queued payloads or subscription filters.
|
|
2
|
+
export function liveBufferError ({ relay, stage, queue, queuedBytes, incoming, incomingBytes, maxEvents, maxBytes, since }) {
|
|
3
|
+
const now = Date.now()
|
|
4
|
+
const boundaries = [since, incoming?.event?.created_at, ...queue.map(entry => entry.item?.event?.created_at)]
|
|
5
|
+
.filter(value => Number.isFinite(value) && value >= 0)
|
|
6
|
+
return Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), {
|
|
7
|
+
code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer',
|
|
8
|
+
recoverySince: Math.min(Math.floor(now / 1000), ...boundaries),
|
|
9
|
+
buffer: {
|
|
10
|
+
stage, queuedEvents: queue.length, queuedBytes, incomingBytes,
|
|
11
|
+
limits: { events: maxEvents, bytes: maxBytes },
|
|
12
|
+
oldestQueuedMs: queue.length ? Math.max(0, now - queue[0].receivedAt) : 0
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { liveBufferError } from '../helpers/live-buffer-error.js'
|
|
1
2
|
import { ValidationError } from '../../error/index.js'
|
|
2
3
|
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
3
4
|
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
@@ -651,18 +652,23 @@ export class RelayPool {
|
|
|
651
652
|
const seenIds = new Set()
|
|
652
653
|
|
|
653
654
|
let untilTimer = null
|
|
654
|
-
const overflow =
|
|
655
|
-
|
|
655
|
+
const overflow = (item, bytes, stage = 'delivery', entries = queue, volume = queuedBytes) => {
|
|
656
|
+
if (isDone) return
|
|
657
|
+
failure = liveBufferError({
|
|
658
|
+
relay: item.relay, stage, queue: entries, queuedBytes: volume, incoming: item, incomingBytes: bytes,
|
|
659
|
+
maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes,
|
|
660
|
+
since: Math.min(sinceFloor, ...pendingGaps.values(), ...queue.map(entry => entry.item?.event?.created_at).filter(Number.isFinite))
|
|
661
|
+
})
|
|
656
662
|
teardown()
|
|
657
663
|
}
|
|
658
664
|
const enqueue = item => {
|
|
659
665
|
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
660
666
|
if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
|
|
661
|
-
overflow(item
|
|
667
|
+
overflow(item, bytes)
|
|
662
668
|
return
|
|
663
669
|
}
|
|
664
670
|
queuedBytes += bytes
|
|
665
|
-
queue.push({ item, bytes })
|
|
671
|
+
queue.push({ item, bytes, receivedAt: Date.now() })
|
|
666
672
|
p.resolve()
|
|
667
673
|
p = Promise.withResolvers()
|
|
668
674
|
}
|
|
@@ -863,11 +869,11 @@ export class RelayPool {
|
|
|
863
869
|
if (liveBuffer) {
|
|
864
870
|
const bytes = encoder.encode(JSON.stringify(event)).byteLength
|
|
865
871
|
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
866
|
-
overflow(url)
|
|
872
|
+
overflow({ event, relay: url }, bytes, 'reconnect', liveBuffer, bufferedBytes)
|
|
867
873
|
return
|
|
868
874
|
}
|
|
869
875
|
bufferedBytes += bytes
|
|
870
|
-
liveBuffer.push(event)
|
|
876
|
+
liveBuffer.push({ item: { event, relay: url }, receivedAt: Date.now() })
|
|
871
877
|
} else pushEvent(event, url)
|
|
872
878
|
},
|
|
873
879
|
onclose: error => {
|
|
@@ -906,7 +912,7 @@ export class RelayPool {
|
|
|
906
912
|
}).finally(() => {
|
|
907
913
|
const buf = liveBuffer
|
|
908
914
|
liveBuffer = null
|
|
909
|
-
for (const
|
|
915
|
+
for (const entry of buf) pushEvent(entry.item.event, url, true)
|
|
910
916
|
gapTasks.delete(task)
|
|
911
917
|
scheduleProgress(attempt)
|
|
912
918
|
p.resolve()
|
|
@@ -1052,9 +1058,9 @@ export class RelayPool {
|
|
|
1052
1058
|
else {
|
|
1053
1059
|
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
1054
1060
|
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
1055
|
-
throw
|
|
1061
|
+
throw liveBufferError({ relay: item.relay, stage: 'history-wait', queue: liveBuffer, queuedBytes: bufferedBytes, incoming: item, incomingBytes: bytes, maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes, since: filter.since ?? 0 })
|
|
1056
1062
|
}
|
|
1057
|
-
liveBuffer.push({ item, bytes })
|
|
1063
|
+
liveBuffer.push({ item, bytes, receivedAt: Date.now() })
|
|
1058
1064
|
bufferedBytes += bytes
|
|
1059
1065
|
}
|
|
1060
1066
|
liveWake.resolve()
|