@origintrail-official/dkg-core 10.0.19 → 10.0.21

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.
Files changed (102) hide show
  1. package/dist/absolute-rfc3987-iri.d.ts +8 -0
  2. package/dist/absolute-rfc3987-iri.d.ts.map +1 -0
  3. package/dist/absolute-rfc3987-iri.js +8 -0
  4. package/dist/absolute-rfc3987-iri.js.map +1 -0
  5. package/dist/author-catalog-directory.d.ts.map +1 -1
  6. package/dist/author-catalog-directory.js +68 -24
  7. package/dist/author-catalog-directory.js.map +1 -1
  8. package/dist/bounded-denial-logger.d.ts +10 -0
  9. package/dist/bounded-denial-logger.d.ts.map +1 -1
  10. package/dist/bounded-denial-logger.js +58 -14
  11. package/dist/bounded-denial-logger.js.map +1 -1
  12. package/dist/bounded-gzip.d.ts +20 -0
  13. package/dist/bounded-gzip.d.ts.map +1 -0
  14. package/dist/bounded-gzip.js +156 -0
  15. package/dist/bounded-gzip.js.map +1 -0
  16. package/dist/constants.d.ts +8 -0
  17. package/dist/constants.d.ts.map +1 -1
  18. package/dist/constants.js +8 -0
  19. package/dist/constants.js.map +1 -1
  20. package/dist/context-graph-on-chain-binding.d.ts +8 -1
  21. package/dist/context-graph-on-chain-binding.d.ts.map +1 -1
  22. package/dist/context-graph-on-chain-binding.js +18 -3
  23. package/dist/context-graph-on-chain-binding.js.map +1 -1
  24. package/dist/crypto/swm-sender-key.d.ts.map +1 -1
  25. package/dist/crypto/swm-sender-key.js +13 -5
  26. package/dist/crypto/swm-sender-key.js.map +1 -1
  27. package/dist/daemon-request-deadlines.d.ts +60 -0
  28. package/dist/daemon-request-deadlines.d.ts.map +1 -0
  29. package/dist/daemon-request-deadlines.js +86 -0
  30. package/dist/daemon-request-deadlines.js.map +1 -0
  31. package/dist/errors.d.ts +21 -0
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +30 -0
  34. package/dist/errors.js.map +1 -1
  35. package/dist/event-bus.d.ts +1 -0
  36. package/dist/event-bus.d.ts.map +1 -1
  37. package/dist/event-bus.js +1 -0
  38. package/dist/event-bus.js.map +1 -1
  39. package/dist/experimental-exact-batch-stream.d.ts +77 -0
  40. package/dist/experimental-exact-batch-stream.d.ts.map +1 -0
  41. package/dist/experimental-exact-batch-stream.js +198 -0
  42. package/dist/experimental-exact-batch-stream.js.map +1 -0
  43. package/dist/experimental-exact-batch-wire.d.ts +41 -0
  44. package/dist/experimental-exact-batch-wire.d.ts.map +1 -0
  45. package/dist/experimental-exact-batch-wire.js +116 -0
  46. package/dist/experimental-exact-batch-wire.js.map +1 -0
  47. package/dist/index.d.ts +9 -3
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +9 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/network/address-policy.d.ts +5 -0
  52. package/dist/network/address-policy.d.ts.map +1 -1
  53. package/dist/network/address-policy.js +24 -5
  54. package/dist/network/address-policy.js.map +1 -1
  55. package/dist/network/index.d.ts +1 -1
  56. package/dist/network/index.d.ts.map +1 -1
  57. package/dist/network/index.js.map +1 -1
  58. package/dist/network/libp2p-network.d.ts +2 -1
  59. package/dist/network/libp2p-network.d.ts.map +1 -1
  60. package/dist/network/libp2p-network.js +4 -1
  61. package/dist/network/libp2p-network.js.map +1 -1
  62. package/dist/network/libp2p-peer-connect.d.ts +10 -4
  63. package/dist/network/libp2p-peer-connect.d.ts.map +1 -1
  64. package/dist/network/libp2p-peer-connect.js +73 -9
  65. package/dist/network/libp2p-peer-connect.js.map +1 -1
  66. package/dist/network/network.d.ts +19 -0
  67. package/dist/network/network.d.ts.map +1 -1
  68. package/dist/network/network.js.map +1 -1
  69. package/dist/network/peer-resolver.d.ts +11 -0
  70. package/dist/network/peer-resolver.d.ts.map +1 -1
  71. package/dist/network/peer-resolver.js +90 -15
  72. package/dist/network/peer-resolver.js.map +1 -1
  73. package/dist/proto/swm-sender-key.d.ts +1 -1
  74. package/dist/proto/swm-sender-key.d.ts.map +1 -1
  75. package/dist/proto/swm-sender-key.js +1 -0
  76. package/dist/proto/swm-sender-key.js.map +1 -1
  77. package/dist/protocol-outbox.d.ts +2 -2
  78. package/dist/protocol-outbox.js +2 -2
  79. package/dist/protocol-router.d.ts +42 -0
  80. package/dist/protocol-router.d.ts.map +1 -1
  81. package/dist/protocol-router.js +280 -165
  82. package/dist/protocol-router.js.map +1 -1
  83. package/dist/request-abort-lifecycle.d.ts +35 -0
  84. package/dist/request-abort-lifecycle.d.ts.map +1 -0
  85. package/dist/request-abort-lifecycle.js +54 -0
  86. package/dist/request-abort-lifecycle.js.map +1 -0
  87. package/dist/sparql-safe.d.ts +8 -0
  88. package/dist/sparql-safe.d.ts.map +1 -1
  89. package/dist/sparql-safe.js +13 -2
  90. package/dist/sparql-safe.js.map +1 -1
  91. package/dist/sparql-terms.d.ts +36 -0
  92. package/dist/sparql-terms.d.ts.map +1 -0
  93. package/dist/sparql-terms.js +98 -0
  94. package/dist/sparql-terms.js.map +1 -0
  95. package/dist/sync-wire-objects.d.ts.map +1 -1
  96. package/dist/sync-wire-objects.js +9 -4
  97. package/dist/sync-wire-objects.js.map +1 -1
  98. package/dist/telemetry-api.d.ts +15 -4
  99. package/dist/telemetry-api.d.ts.map +1 -1
  100. package/dist/telemetry-api.js +13 -6
  101. package/dist/telemetry-api.js.map +1 -1
  102. package/package.json +7 -2
@@ -1,4 +1,5 @@
1
1
  import { LibP2PNetwork } from './network/libp2p-network.js';
2
+ import { startRequestAbortLifecycle } from './request-abort-lifecycle.js';
2
3
  import { MessageStreamPool, POOLED_MESSAGE_PROTOCOL, } from './message-stream-pool.js';
3
4
  import { withSpan, getMetrics } from './telemetry-api.js';
4
5
  /** Default max bytes readAll will buffer before aborting (10 MB). */
@@ -452,8 +453,8 @@ export class ProtocolRouter {
452
453
  * probe opens and immediately aborts the stream before any payload bytes.
453
454
  */
454
455
  async probeProtocol(peerIdStr, protocolId, timeoutMs = 3_000) {
455
- const deadline = AbortSignal.timeout(timeoutMs);
456
- const signal = composeAbortSignals(deadline, this.node.stopSignal) ?? deadline;
456
+ const lifecycle = startRequestAbortLifecycle(timeoutMs, [this.node.stopSignal]);
457
+ const signal = lifecycle.signal;
457
458
  try {
458
459
  await this.requirePeerAccepted(peerIdStr, protocolId, 'outbound', { signal, timeoutMs });
459
460
  // Match send()'s resolver contract before opening a stream. For the
@@ -475,10 +476,237 @@ export class ProtocolRouter {
475
476
  throw error;
476
477
  return isProtocolUnsupportedError(error) ? 'unsupported' : 'unavailable';
477
478
  }
479
+ finally {
480
+ lifecycle.release();
481
+ }
482
+ }
483
+ /**
484
+ * Own one dedicated duplex stream. Only negotiation may change routes;
485
+ * after callback entry no authenticated request is retried or replayed.
486
+ */
487
+ async withDuplexStream(peerIdStr, protocolId, options, exchange) {
488
+ this.validateDuplexStreamOptions(options);
489
+ const lifecycle = startRequestAbortLifecycle(options.timeoutMs, [options.signal, this.node.stopSignal]);
490
+ const signal = lifecycle.signal;
491
+ let stream;
492
+ const abortOwned = () => { if (stream)
493
+ abortStream(stream, signal.reason); };
494
+ signal.addEventListener('abort', abortOwned, { once: true });
495
+ try {
496
+ const openStream = await this.createAdmittedPeerStreamOperation(peerIdStr, protocolId, signal, options.timeoutMs);
497
+ ({ stream } = await openStream({
498
+ resolverTimeoutMs: options.timeoutMs,
499
+ afterDialFailure: 'reuse-limited',
500
+ cancelledResolution: 'throw',
501
+ }));
502
+ this.boundDuplexStreamBuffers(stream, options.maxReadBufferBytes);
503
+ const result = await exchange(stream, signal);
504
+ if (signal.aborted)
505
+ throw asAbortError(signal.reason);
506
+ await this.closeDuplexStream(stream, signal);
507
+ return result;
508
+ }
509
+ finally {
510
+ if (stream && stream.status !== 'closed')
511
+ abortStream(stream, new Error('duplex stream scope ended'));
512
+ signal.removeEventListener('abort', abortOwned);
513
+ lifecycle.release();
514
+ }
515
+ }
516
+ /** Read a bounded request, then retain its continuation for duplex work. */
517
+ registerDuplexStream(protocolId, readRequest, handler, options) {
518
+ this.validateDuplexStreamOptions(options);
519
+ if (!Number.isSafeInteger(options.maxRequestBytes) || options.maxRequestBytes <= 0
520
+ || options.maxRequestBytes > this.maxReadBytes)
521
+ throw new RangeError('Invalid duplex request limit');
522
+ this.node.libp2p.handle(protocolId, async (stream, connection) => {
523
+ const peerId = connection.remotePeer.toString();
524
+ const closed = new AbortController();
525
+ const onClose = () => closed.abort(new Error('duplex peer stream closed'));
526
+ const lifecycle = startRequestAbortLifecycle(options.timeoutMs, [options.signal, this.node.stopSignal, closed.signal]);
527
+ const signal = lifecycle.signal;
528
+ const onAbort = () => abortStream(stream, signal.reason);
529
+ let request;
530
+ stream.addEventListener('close', onClose, { once: true });
531
+ signal.addEventListener('abort', onAbort, { once: true });
532
+ try {
533
+ if (signal.aborted)
534
+ throw asAbortError(signal.reason);
535
+ this.rejectKnownRejectedInboundPeer(peerId, protocolId);
536
+ this.boundDuplexStreamBuffers(stream, options.maxReadBufferBytes);
537
+ request = await readRequest(stream, signal);
538
+ if (request.requestData.byteLength > options.maxRequestBytes)
539
+ throw new RangeError('Duplex request byte limit exceeded');
540
+ await this.requirePeerAccepted(peerId, protocolId, 'inbound', { signal, timeoutMs: options.timeoutMs });
541
+ if (signal.aborted)
542
+ throw asAbortError(signal.reason);
543
+ await handler({ ...request, peerId, stream, signal });
544
+ if (signal.aborted)
545
+ throw asAbortError(signal.reason);
546
+ stream.removeEventListener('close', onClose);
547
+ await this.closeDuplexStream(stream, signal);
548
+ }
549
+ catch (error) {
550
+ // Do not log authenticated request bytes, metadata, URLs or tokens.
551
+ abortStream(stream, error instanceof Error ? error : new Error('duplex stream failed'));
552
+ }
553
+ finally {
554
+ stream.removeEventListener('close', onClose);
555
+ signal.removeEventListener('abort', onAbort);
556
+ if (stream.status !== 'closed')
557
+ abortStream(stream, new Error('duplex stream scope ended'));
558
+ await request?.dispose?.().catch(() => undefined);
559
+ lifecycle.release();
560
+ }
561
+ }, { runOnLimitedConnection: true });
562
+ }
563
+ validateDuplexStreamOptions(options) {
564
+ if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0
565
+ || !Number.isSafeInteger(options.maxReadBufferBytes) || options.maxReadBufferBytes <= 0
566
+ || options.maxReadBufferBytes > this.maxReadBytes)
567
+ throw new RangeError('Invalid duplex stream limits');
568
+ }
569
+ boundDuplexStreamBuffers(stream, limit) {
570
+ stream.maxReadBufferLength = Math.min(stream.maxReadBufferLength || limit, limit);
571
+ stream.maxWriteBufferLength = Math.min(stream.maxWriteBufferLength || limit, limit);
572
+ }
573
+ async closeDuplexStream(stream, signal) {
574
+ // close() flushes and half-closes only the writable end. Wait for the
575
+ // peer's half-close too: resetting immediately could discard final bytes
576
+ // already handed to the transport but not yet consumed by the peer.
577
+ let onClose;
578
+ let onAbort;
579
+ const settled = new Promise((resolve, reject) => {
580
+ onClose = () => stream.status === 'closed' ? resolve() : reject(new Error('Duplex stream closed before settlement'));
581
+ onAbort = () => reject(asAbortError(signal.reason));
582
+ stream.addEventListener('close', onClose, { once: true });
583
+ signal.addEventListener('abort', onAbort, { once: true });
584
+ });
585
+ // The close operation may fail before the close-event promise is awaited.
586
+ void settled.catch(() => undefined);
587
+ try {
588
+ if (signal.aborted)
589
+ throw asAbortError(signal.reason);
590
+ await stream.close({ signal });
591
+ if (stream.status !== 'closed')
592
+ await settled;
593
+ }
594
+ finally {
595
+ stream.removeEventListener('close', onClose);
596
+ signal.removeEventListener('abort', onAbort);
597
+ }
598
+ }
599
+ /**
600
+ * Admission is checked once for the operation, before any transport work.
601
+ * Each open uses a fresh connection snapshot and resolver watch. The caller
602
+ * owns payload retries and chooses when a failed direct dial permits relay
603
+ * reuse; this operation never sends or replays application bytes.
604
+ */
605
+ async createAdmittedPeerStreamOperation(peerIdStr, protocolId, signal, timeoutMs) {
606
+ if (signal.aborted)
607
+ throw asAbortError(signal.reason);
608
+ await this.requirePeerAccepted(peerIdStr, protocolId, 'outbound', { signal, timeoutMs });
609
+ if (signal.aborted)
610
+ throw asAbortError(signal.reason);
611
+ // Keep parsing lazy: pooled sends may settle without a one-shot open.
612
+ let peerId;
613
+ return async (options) => {
614
+ if (signal.aborted)
615
+ throw asAbortError(signal.reason);
616
+ peerId ??= (await import('@libp2p/peer-id')).peerIdFromString(peerIdStr);
617
+ const targetPeer = peerId;
618
+ const libp2p = this.node.libp2p;
619
+ // Raw walks heal keyed-lookup misses. Remember every observed connection
620
+ // so the resolver yields only to a connection this pass has not tried.
621
+ const considered = new WeakSet();
622
+ const getConnections = () => {
623
+ const connections = rawGetConnectionsFor(libp2p, targetPeer);
624
+ for (const connection of connections)
625
+ considered.add(connection);
626
+ return connections;
627
+ };
628
+ const reuseOptions = {
629
+ peerHasDirectAddrs: async () => {
630
+ const peer = await libp2p.peerStore.get(targetPeer);
631
+ return (peer.addresses ?? []).some(({ multiaddr }) => {
632
+ const address = multiaddr?.toString?.() ?? '';
633
+ return Boolean(address) && !address.includes('/p2p-circuit');
634
+ });
635
+ },
636
+ allowLimitedWithDirectAddrs: options.allowLimitedWithDirectAddrs,
637
+ excludeConnections: options.excludeConnections,
638
+ };
639
+ let reused = await tryReuseExistingConnection(getConnections, protocolId, signal, reuseOptions);
640
+ if (!reused && this.peerResolver && !signal.aborted) {
641
+ // Only resolution follows the connection event signal. Negotiation and
642
+ // payload work keep the caller's original cancellation/deadline signal.
643
+ const connected = watchForNewPeerConnection(libp2p, targetPeer, considered);
644
+ const resolution = composeAbortSignalsScoped(signal, connected?.signal);
645
+ try {
646
+ if (!connected?.signal.aborted) {
647
+ await this.peerResolver.resolve(peerIdStr, {
648
+ signal: resolution.signal ?? signal,
649
+ perStepTimeoutMs: options.resolverTimeoutMs,
650
+ }).catch(() => undefined);
651
+ }
652
+ }
653
+ finally {
654
+ resolution.dispose();
655
+ connected?.dispose();
656
+ }
657
+ if (connected?.signal.aborted) {
658
+ reused = await tryReuseExistingConnection(getConnections, protocolId, signal, reuseOptions);
659
+ }
660
+ }
661
+ if (signal.aborted && (reused || options.cancelledResolution === 'throw')) {
662
+ if (reused)
663
+ abortStream(reused.stream, signal.reason);
664
+ throw asAbortError(signal.reason);
665
+ }
666
+ const dialStartedAt = Date.now();
667
+ let stream;
668
+ if (reused)
669
+ stream = reused.stream;
670
+ else {
671
+ options.onNormalDial?.();
672
+ try {
673
+ stream = await this.network.dialProtocol(peerIdStr, protocolId, { signal });
674
+ }
675
+ catch (error) {
676
+ if (options.afterDialFailure === 'throw')
677
+ throw error;
678
+ if (signal.aborted)
679
+ throw asAbortError(signal.reason);
680
+ // No application bytes have been sent. A duplex scope may use an
681
+ // existing relay after the normal dial actually failed.
682
+ reused = await tryReuseExistingConnection(getConnections, protocolId, signal, { ...reuseOptions, allowLimitedWithDirectAddrs: true });
683
+ if (!reused)
684
+ throw error;
685
+ stream = reused.stream;
686
+ }
687
+ }
688
+ if (signal.aborted) {
689
+ abortStream(stream, signal.reason);
690
+ throw asAbortError(signal.reason);
691
+ }
692
+ return { stream, connection: reused?.connection ?? null, dialDurationMs: Date.now() - dialStartedAt };
693
+ };
478
694
  }
479
695
  async sendInner(peerIdStr, protocolId, data, timeoutMsOrOpts = DEFAULT_SEND_TIMEOUT_MS) {
480
696
  const opts = typeof timeoutMsOrOpts === 'number' ? { timeoutMs: timeoutMsOrOpts } : timeoutMsOrOpts;
481
697
  const timeoutMs = opts.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
698
+ // One deadline for the whole send, following the caller's signal and
699
+ // node.stopSignal. Once the send settles nothing of it stays attached to
700
+ // either (#2812).
701
+ const lifecycle = startRequestAbortLifecycle(timeoutMs, [opts.signal, this.node.stopSignal]);
702
+ try {
703
+ return await this.sendWithinLifecycle(peerIdStr, protocolId, data, opts, timeoutMs, lifecycle);
704
+ }
705
+ finally {
706
+ lifecycle.release();
707
+ }
708
+ }
709
+ async sendWithinLifecycle(peerIdStr, protocolId, data, opts, timeoutMs, lifecycle) {
482
710
  const singleUsePayload = opts.payloadReuse === 'single-use';
483
711
  const maxAttempts = singleUsePayload ? 1 : 3;
484
712
  const parallelPaths = Math.max(1, Math.floor(opts.parallelPaths ?? 1));
@@ -487,16 +715,10 @@ export class ProtocolRouter {
487
715
  }
488
716
  const maxReadBytes = resolveSendMaxReadBytes(opts.maxReadBytes, this.maxReadBytes);
489
717
  const overallStartedAt = Date.now();
490
- const overallDeadline = AbortSignal.timeout(timeoutMs);
718
+ const overallDeadline = lifecycle.deadline;
491
719
  const stopSignal = this.node.stopSignal;
492
- const budgetSignal = composeAbortSignals(overallDeadline, opts.signal) ?? overallDeadline;
493
- const overallSignal = composeAbortSignals(budgetSignal, stopSignal) ?? budgetSignal;
494
- if (overallSignal.aborted)
495
- throw asAbortError(overallSignal.reason);
496
- await this.requirePeerAccepted(peerIdStr, protocolId, 'outbound', {
497
- signal: overallSignal,
498
- timeoutMs,
499
- });
720
+ const overallSignal = lifecycle.signal;
721
+ const openStream = await this.createAdmittedPeerStreamOperation(peerIdStr, protocolId, overallSignal, timeoutMs);
500
722
  // Pooled overlay short-circuit. When `enablePooling(protocolId)`
501
723
  // has been called for this logical protocol AND the peer hasn't
502
724
  // been pinned to the one-shot wire variant by a prior fallback,
@@ -625,11 +847,11 @@ export class ProtocolRouter {
625
847
  if (remainingMs === 0) {
626
848
  throw new Error(`send timeout: pooled fallback exhausted the ${timeoutMs}ms budget before one-shot attempt`);
627
849
  }
628
- const libp2p = this.node.libp2p;
629
- const { peerIdFromString } = await import('@libp2p/peer-id');
630
- const peerId = peerIdFromString(peerIdStr);
631
850
  const startedAt = overallStartedAt;
632
851
  if (parallelPaths > 1) {
852
+ const libp2p = this.node.libp2p;
853
+ const { peerIdFromString } = await import('@libp2p/peer-id');
854
+ const peerId = peerIdFromString(peerIdStr);
633
855
  // Reuse the overall budget rather than starting a fresh one
634
856
  // — see comment above. Codex PR #560 round 4.
635
857
  const multipathSignalWithStop = overallSignal;
@@ -706,8 +928,9 @@ export class ProtocolRouter {
706
928
  lastErr = new Error('send timeout elapsed');
707
929
  throw lastErr;
708
930
  }
709
- const attemptDeadline = AbortSignal.timeout(remaining);
710
- const attemptSignal = composeAbortSignals(attemptDeadline, overallSignal) ?? attemptDeadline;
931
+ // The request deadline already ends where this attempt's remaining
932
+ // budget does, so every attempt shares the request signal.
933
+ const attemptSignal = overallSignal;
711
934
  if (attemptSignal.aborted)
712
935
  throw asAbortError(attemptSignal.reason);
713
936
  // Track which connection (if any) the fast path picked this
@@ -717,153 +940,19 @@ export class ProtocolRouter {
717
940
  let pickedConnection = null;
718
941
  let attemptedNormalDial = false;
719
942
  try {
720
- // RFC 07 §3.2: re-prime the libp2p peerStore on EVERY attempt.
721
- //
722
- // Was originally a single pre-loop call (PR #497) but that left
723
- // a real cold-dial gap surfaced during the two-laptop debug
724
- // session that produced PR #517 + the MessageOutbox PR: if the
725
- // first resolver call landed during a transient routing-table
726
- // miss (peer's K-bucket entry expired, no live gossip source,
727
- // DHT walk happened to time out), all 3 dialProtocol attempts
728
- // hit the same empty peerStore in the next ~1.5s and the loop
729
- // gave up with `'no valid addresses for peer'` before any DHT
730
- // advertisement / `connection:open` event from the recipient
731
- // could populate addresses for a later attempt.
732
- //
733
- // Re-running per-attempt is cheap on the warm path (the
734
- // resolver's step 1 is a sub-millisecond live-connection check
735
- // that short-circuits when we already have the peer connected
736
- // or its addresses cached in the peerStore) and only pays the
737
- // DHT-walk / registry-lookup cost on the cold path — which is
738
- // exactly the case we want to keep paying for, because that's
739
- // where address staleness actually hurts. The resolver itself
740
- // never throws (returns empty array on miss); the dial below
741
- // surfaces a real transport error if the peer is genuinely
742
- // unreachable.
743
- //
744
- // Codex review feedback on PR #497: pass the same AbortSignal +
745
- // remaining time budget to the resolver so a caller's
746
- // `timeoutMs` bounds the entire send (resolver + dial + read).
747
- // First try the existing-connection fast path: if we already
748
- // have at least one open connection to this peer (of ANY
749
- // direction, direct OR circuit-relay-limited), open the new
750
- // stream on that connection directly via `newStream` instead
751
- // of going through `dialProtocol`. This sidesteps the entire
752
- // address-resolution + peerStore lookup path that returns
753
- // "no valid addresses for peer" when libp2p's peerStore is
754
- // empty or stale for the peer — the "Window D" failure class
755
- // identified in the May 2026 Miles↔Lex 6h soak postmortem,
756
- // where 31 inbound circuit `connection:open` events from peer
757
- // P over 4 minutes failed to heal any of 20 opportunistic
758
- // outbound flushes because each one went through dialProtocol
759
- // and lost the peerStore race.
760
- //
761
- // Crucially: when peerStore is empty for P (the Window D
762
- // shape), the connection-manager-auto-dial side effect of
763
- // `peerStore.merge` documented at
764
- // docs/archive/UPSTREAM_ISSUE_DRAFT.md does NOT fire — there
765
- // are no direct addresses to upgrade to. So this fast path
766
- // does not introduce the mid-stream-negotiation race the doc
767
- // warns about; it benefits exactly the case where the doc's
768
- // race cannot apply.
769
- //
770
- // Failure here (connection died between `getConnections` and
771
- // `newStream`, peer dropped protocol support, etc.) falls
772
- // through to the dialProtocol path below WITHIN THE SAME
773
- // ATTEMPT, so we don't waste a retry slot on the fast-path
774
- // miss.
775
- // CRITICAL: feed the fast path from a RAW `getConnections()`
776
- // walk filtered by `remotePeer`, NOT from the peerId-keyed
777
- // `libp2p.getConnections(peerId)` lookup.
778
- //
779
- // The PR #533 diagnostic surface (`PeerDiagnostics`) is
780
- // explicitly built around the divergence
781
- // `rawConnectionCount > getConnectionsReturnsForPeer` as the
782
- // smoking-gun Window D signature: libp2p's peerId-keyed
783
- // lookup can return `[]` even when a raw walk over all
784
- // connections shows one or more open connections whose
785
- // `remotePeer` matches the target peerId. Using the keyed
786
- // lookup here would make the fast path miss the EXACT case
787
- // it was built to heal — `tryReuseExistingConnection` would
788
- // get an empty candidate list, fall through to the resolver
789
- // + dialProtocol path, and still fail with "no valid
790
- // addresses for peer". Walk raw, filter ourselves, and
791
- // accept that we pay the O(N) cost over the whole
792
- // connection table per send — N is the total open-conn
793
- // count of the node, typically <100, so the cost is a few
794
- // microseconds and the correctness win is large.
795
- //
796
- // Every connection the walk hands the fast path is recorded, so
797
- // the resolver watch below can tell a connection that opened
798
- // after this pass from one the pass already skipped or failed on.
799
- const consideredConnections = new WeakSet();
800
- const getPeerConnections = () => {
801
- const connections = rawGetConnectionsFor(libp2p, peerId);
802
- for (const connection of connections)
803
- consideredConnections.add(connection);
804
- return connections;
805
- };
806
- const fastPathOptions = {
807
- // Probe peerStore for non-circuit (direct) addresses; the
808
- // fast path uses this to gate whether reusing a LIMITED
809
- // (circuit-relay-v2) connection is safe or whether
810
- // libp2p's CM is about to auto-upgrade and prune it
811
- // mid-stream. See the JSDoc inside
812
- // `tryReuseExistingConnection` + the PR #537 CI
813
- // postmortem for the DCUtR upgrade race detail.
814
- peerHasDirectAddrs: async () => {
815
- const peer = await libp2p.peerStore.get(peerId);
816
- const addrs = peer.addresses ?? [];
817
- for (const a of addrs) {
818
- const ma = a.multiaddr?.toString?.() ?? '';
819
- if (ma && !ma.includes('/p2p-circuit'))
820
- return true;
821
- }
822
- return false;
823
- },
943
+ // Re-prime on each cold retry, while warm reuse skips resolution.
944
+ // A failed normal dial unlocks limited reuse on the next attempt;
945
+ // unlike a duplex scope, send() keeps its existing retry/backoff gate.
946
+ const opened = await openStream({
947
+ resolverTimeoutMs: remaining,
824
948
  allowLimitedWithDirectAddrs: normalDialFailed,
825
949
  excludeConnections: triedConnections,
826
- };
827
- let fastResult = await tryReuseExistingConnection(getPeerConnections, protocolId, attemptSignal, fastPathOptions);
828
- if (this.peerResolver && !fastResult) {
829
- // One resolver step can spend the whole remaining budget:
830
- // kad-dht parks a findPeer until its signal aborts while the
831
- // routing table is empty. A connection to the peer that opens
832
- // meanwhile (the peer dialing us, or a concurrent dial) makes
833
- // further resolution pointless, just as the resolver's own
834
- // live-connection step would, so it ends the resolver early and
835
- // the fast path runs again over it. Only the resolver's signal
836
- // is cut short; the rest of the attempt keeps `attemptSignal`.
837
- const peerConnected = watchForNewPeerConnection(libp2p, peerId, consideredConnections);
838
- try {
839
- if (!peerConnected?.signal.aborted) {
840
- await this.peerResolver
841
- .resolve(peerIdStr, {
842
- signal: composeAbortSignals(attemptSignal, peerConnected?.signal) ?? attemptSignal,
843
- perStepTimeoutMs: remaining,
844
- })
845
- .catch(() => undefined);
846
- }
847
- }
848
- finally {
849
- peerConnected?.dispose();
850
- }
851
- if (peerConnected?.signal.aborted) {
852
- fastResult = await tryReuseExistingConnection(getPeerConnections, protocolId, attemptSignal, fastPathOptions);
853
- }
854
- }
855
- const fastStream = fastResult?.stream ?? null;
856
- pickedConnection = fastResult?.connection ?? null;
857
- const dialStartedAt = Date.now();
858
- let stream;
859
- if (fastStream) {
860
- stream = fastStream;
861
- }
862
- else {
863
- attemptedNormalDial = true;
864
- stream = await this.network.dialProtocol(peerIdStr, protocolId, { signal: attemptSignal });
865
- }
866
- const dialDurationMs = Date.now() - dialStartedAt;
950
+ afterDialFailure: 'throw',
951
+ cancelledResolution: 'dial-with-signal',
952
+ onNormalDial: () => { attemptedNormalDial = true; },
953
+ });
954
+ const { stream, dialDurationMs } = opened;
955
+ pickedConnection = opened.connection;
867
956
  if (stream.writeStatus === 'closed' || stream.writeStatus === 'closing') {
868
957
  stream.abort(new Error('stream closed before send'));
869
958
  throw new Error('stream returned in closed state');
@@ -916,7 +1005,10 @@ export class ProtocolRouter {
916
1005
  // during the backoff, we throw immediately rather than
917
1006
  // continuing into another attempt that's already over budget.
918
1007
  await new Promise((resolve, reject) => {
919
- const t = setTimeout(resolve, backoff);
1008
+ const t = setTimeout(() => {
1009
+ overallSignal.removeEventListener('abort', onAbort);
1010
+ resolve();
1011
+ }, backoff);
920
1012
  const onAbort = () => {
921
1013
  clearTimeout(t);
922
1014
  if (stopSignal?.aborted) {
@@ -1220,7 +1312,21 @@ export async function raceMultiPath(args) {
1220
1312
  const picked = candidates.slice(0, parallelPaths);
1221
1313
  const streams = picked.map(() => null);
1222
1314
  let winnerIdx = -1;
1315
+ // One controller per path, following the shared signal, so a loser is
1316
+ // cancelled as soon as the winner settles even while it is still waiting
1317
+ // for `newStream`, instead of running on until the shared signal aborts.
1318
+ const pathControllers = picked.map(() => new AbortController());
1319
+ const forwardShared = () => {
1320
+ for (const controller of pathControllers) {
1321
+ if (!controller.signal.aborted)
1322
+ controller.abort(signal.reason);
1323
+ }
1324
+ };
1325
+ signal.addEventListener('abort', forwardShared, { once: true });
1223
1326
  const abortPath = (idx, reason) => {
1327
+ const controller = pathControllers[idx];
1328
+ if (!controller.signal.aborted)
1329
+ controller.abort(reason);
1224
1330
  const s = streams[idx];
1225
1331
  if (!s || s.aborted)
1226
1332
  return;
@@ -1240,9 +1346,10 @@ export async function raceMultiPath(args) {
1240
1346
  }
1241
1347
  };
1242
1348
  const attempts = picked.map(async (conn, idx) => {
1349
+ const pathSignal = pathControllers[idx].signal;
1243
1350
  const stream = await conn.newStream(protocolId, {
1244
1351
  runOnLimitedConnection: true,
1245
- signal,
1352
+ signal: pathSignal,
1246
1353
  });
1247
1354
  streams[idx] = { stream, aborted: false };
1248
1355
  if (winnerIdx !== -1 && winnerIdx !== idx) {
@@ -1259,8 +1366,8 @@ export async function raceMultiPath(args) {
1259
1366
  throw new Error('multipath: stream returned in closed state');
1260
1367
  }
1261
1368
  stream.send(data);
1262
- await stream.close({ signal });
1263
- return await readAllWithSignal(stream, maxReadBytes, signal);
1369
+ await stream.close({ signal: pathSignal });
1370
+ return await readAllWithSignal(stream, maxReadBytes, pathSignal);
1264
1371
  });
1265
1372
  let winnerResponse = null;
1266
1373
  try {
@@ -1308,6 +1415,9 @@ export async function raceMultiPath(args) {
1308
1415
  throw asAbortError(signal.reason);
1309
1416
  return null;
1310
1417
  }
1418
+ finally {
1419
+ signal.removeEventListener('abort', forwardShared);
1420
+ }
1311
1421
  return { response: winnerResponse, attemptedPaths: picked.length };
1312
1422
  }
1313
1423
  /**
@@ -1373,6 +1483,11 @@ export async function readAllWithSignal(stream, maxBytes, signal) {
1373
1483
  * Uses Node's `AbortSignal.any` when available (Node 20.3+, current
1374
1484
  * production target). Falls back to a manual composer for older Node
1375
1485
  * — keeps tests / older sandboxes working.
1486
+ *
1487
+ * Not for per-request use against a long-lived signal: when an input is an
1488
+ * `AbortSignal.timeout` signal, Node keeps the result, and its entry on the
1489
+ * other input, alive until that input is collected (#2812). The router's
1490
+ * own requests use `startRequestAbortLifecycle` instead.
1376
1491
  */
1377
1492
  export function composeAbortSignals(primary, secondary) {
1378
1493
  if (!primary)