Skip to content

fix(kad-dht): apply the timeout signal when pinging old contacts - #3671

Merged
tabcat merged 1 commit into
libp2p:mainfrom
tabcat:fix/kad-dht-ping-timeout
Oct 17, 2026
Merged

tabcat merged 1 commit into
libp2p:mainfrom
tabcat:fix/kad-dht-ping-timeout

Conversation

@tabcat

@tabcat tabcat commented Oct 17, 2026

Copy link
Copy Markdown
Member

Description

pingOldContacts() builds the abort signal it needs and then does not use it.

const signal = this.pingOldContactTimeout.getTimeoutSignal()
const signals = anySignal([
  signal,
  this.shutdownController.signal,
  options?.signal
])
setMaxListeners(Infinity, signal, signals)

try {
  return await this.pingContact(oldContact, options)   // `options`, not `signals`

signals is only ever .clear()ed in the finally. The options passed instead are the queue job's own, whose signal aborts only once every caller waiting on that job has aborted, so pingOldContactTimeout never applies.

Outbound ping has no timeout of its own either. @libp2p/ping bounds the pong wait solely with raceSignal(finished.promise, options.signal), and the AbortSignal.timeout in that package is inside handlePing, the inbound handler. So a contact that accepts the ping stream and never sends a pong wedges the job for the lifetime of the node.

Three consequences:

  • _add() awaits for await (const toEvict of this.ping(toPing, options)), so the routing table insert that triggered the ping never completes.
  • The job keeps one of the 20 pingOldContactQueue slots, and pingOldContactQueue.find(peerId) keeps returning it, so every later ping of that peer joins the wedged job instead of starting its own.
  • Ping.ping() only aborts its stream when the signal fires, so the ping stream stays open until the underlying connection dies.

Found on a DHT server node after 13 days of uptime, with 13 of the 20 slots held by pings that had never timed out.

With the signal applied, a timed out ping rejects inside pingContact(), which returns false, so the unresponsive contact is evicted and the replacement is added.

The test stubs a contact whose ping only settles on abort, then asserts that the ping ran, the contact was evicted, the new peer took its place, and the queue slot was released. Asserting only that the insert returned would be vacuous, since every path that skips the ping also returns.

Notes & open questions

The catch in pingOldContacts does not fire on a timeout, which looks odd beside this change. It cannot fire at all: pingContact() wraps its whole body and returns false for any error, so ping_old_contact_error is never incremented. Pre-existing, and I would rather remove the dead branch separately than widen this PR.

Other things in the same area, left out deliberately:

  • PING_OLD_CONTACT_TIMEOUT and PING_NEW_CONTACT_TIMEOUT (both 2000) are exported and never read, so the effective bound is AdaptiveTimeout's own 5s rising to 60s. Wiring them up would change behaviour.
  • verifyNewContact() passes signals to join() and add() but hands the queue's options to pingContact(), the same shape fixed here, so its timeout reaches the ping only via Job.onAbort. It also starts the clock before queue.add(), so queue wait counts against the ping timeout.
  • network.ts calls getTimeoutSignal(options) in two places, which records a caller abort as a timeout failure and inflates the adaptive timeout.

Change checklist

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if necessary (this includes comments as well)
  • I have added tests that prove my fix is effective or that my feature works

pingOldContacts() composed an abort signal from the ping timeout, the
shutdown controller and the caller's signal, then passed the job's own
options to pingContact() instead of that signal, so the composed signal
was only ever cleared, never used.

The job's signal aborts only once every caller waiting on the job has
aborted, and outbound ping has no timeout of its own, so a contact that
accepts the ping stream and never sends a pong wedged the job forever.
That also blocked the bucket's add, which awaits the ping generator,
held a slot in pingOldContactQueue for the lifetime of the node, and
left the ping stream open until the connection died.

The test asserts the outcome rather than just that the add returned,
because every way of not reaching the ping at all also resolves it.
@tabcat
tabcat marked this pull request as ready for review October 17, 2026 08:02
@tabcat
tabcat requested a review from a team as a code owner October 17, 2026 08:02
@tabcat
tabcat merged commit 2d4c93e into libp2p:main Oct 17, 2026
60 of 62 checks passed
@tabcat tabcat mentioned this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant