Skip to content

fix(identify): handle multiple envelopes from go#3423

Merged
tabcat merged 25 commits into
libp2p:mainfrom
wjmelements:wjmelements/fix-close-crash
May 12, 2026
Merged

fix(identify): handle multiple envelopes from go#3423
tabcat merged 25 commits into
libp2p:mainfrom
wjmelements:wjmelements/fix-close-crash

Conversation

@wjmelements

Copy link
Copy Markdown
Contributor

Reviewer @tabcat
This was causing failures with identify when I was doing a quick Helia bitswap with Kubo.
Kubo closes the stream fully immediately after sending the identify response.
The JS identify client calls pb.unwrap().unwrap().close() after pb.read(); this throws StreamStateError because byteStream.unwrap() tries to push the internal read buffer onto a stream whose readStatus is closed.
The error is caught and the stream aborted, discarding a successfully-read message, so consumeIdentifyMessage is never called, and peer:identify never fires.
Consequently, Helia never gets onConnect called for Kubo peers.
(I'm not too familiar with this code so maybe there is a better way to fix this.)

Changes

  • wrap the close() call in its own try/catch so a failure there doesn't discard the already-read message

@wjmelements wjmelements requested a review from a team as a code owner March 27, 2026 10:00
@tabcat

tabcat commented Mar 27, 2026

Copy link
Copy Markdown
Member

Hi @wjmelements do you think you could reproduce this behavior inside of a test?
A stack trace would also be super helpful 🙏

@tabcat

tabcat commented Mar 28, 2026

Copy link
Copy Markdown
Member

byteStream.unwrap() tries to push the internal read buffer onto a stream whose readStatus is closed.v

byteStream will only push the read buffer back to the stream if there are bytes leftover. any idea what those bytes are?

@tabcat tabcat added the need/author-input Needs input from the original author label Mar 29, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@wjmelements

Copy link
Copy Markdown
Contributor Author

I pushed a test that reproduces the same exception I was seeing.

any idea what those bytes are?

It's pb.Identify{SignedPeerRecord: sr}. The relevant function is writeChunkedIdentifyMsg in p2p/protocol/identify/id.go of go-libp2p (my version is v0.48.0 but this has been the behavior for a few years).

To summarize that code, the identify response gets split up if there is a SignedPeerRecord and the message will be larger than legacyIDSize(2 KB); the first message is the identify message without the SignedPeerRecord and the second is another with only the SignedPeerRecord.

@achingbrain

Copy link
Copy Markdown
Member

the identify response gets split up if there is a SignedPeerRecord and the message will be larger than legacyIDSize(2 KB); the first message is the identify message without the SignedPeerRecord and the second is another with only the SignedPeerRecord.

This is not spec-compliant behaviour, the spec clearly says the remote should send an Identify message and close(s) the stream, not one or more Identify messages (or words to that effect). I've opened libp2p/go-libp2p#3483

To move forward here we should probably read multiple identify messages until the stream closes (with an appropriate timeout) and compose the final message from whatever we receive.

We definitely want to prefer signed peer records if they are available.

@tabcat

tabcat commented Apr 3, 2026

Copy link
Copy Markdown
Member

It looks like go iterates over 10 messages with a 5 second timeout for the identify stream. It collects all the messages and merges them before writing to the peer store. All identify responses are dropped if the stream times out.

@wjmelements

Copy link
Copy Markdown
Contributor Author

It looks like go iterates over 10 messages with a 5 second timeout for the identify stream. It collects all the messages and merges them before writing to the peer store. All identify responses are dropped if the stream times out.

Ok, I plan to implement similar behavior for js.

@achingbrain

Copy link
Copy Markdown
Member

There is an update to the Identify spec with message-splitting taking shape here.

Any changes made here should make the js implementation conform to the newly more detailed spec.

…ge has addresses

Assisted-by: Claude:claude-sonnet-4-6
Comment thread packages/protocol-identify/src/utils.ts Outdated
@tabcat tabcat self-requested a review April 30, 2026 14:13
wjmelements and others added 9 commits May 8, 2026 19:41
Reverts buildIdentifyMessages, packItems, and the deferred-SPR path so
identify and identify-push send a single message. Receive-side multi-message
support is retained.

Multi-message send is deferred to a follow-up so older js-libp2p receivers
without the close-error fix in this PR don't regress on identify when this
ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns listenAddrs merge with the existing protocols dedup. Duplicates
across multiple identify messages would otherwise inflate the peerStore
entry. Invisible in practice today (no senders duplicate) but worth
locking in to prevent future drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralises the check used by both _identify and identify-push handlers to
decide whether a read error after at least one successful message means
"remote finished" vs "real failure". Treats UnexpectedEOFError and "remote
write side no longer writable" as equivalent EOF signals so transport-level
resets after success aren't dropped as failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C2: wrap post-read close() in try/catch with trace log. The original bug in
this area was that StreamStateError from byteStream.unwrap() pushing buffered
trailing bytes back to a closed-read stream propagated through the outer
catch and aborted the stream — discarding the successfully-read message.
Swallowing the close error preserves the read result, which is what the
caller actually wanted.

I1: import MAX_IDENTIFY_MESSAGES from consts instead of shadowing it
locally with a literal.

I2: log when the cap is reached without seeing EOF, so silent truncation
surfaces in operator logs.

I4: replace UnexpectedEOFError-only break with isEofLike, which also
treats "remote write side no longer writable" as EOF after at least one
successful message — catches stream-reset-after-success as a success path.

Co-Authored-By: William Morriss <wjmelements@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the changes to identify._identify so the push receive handler
also: imports MAX_IDENTIFY_MESSAGES from consts, uses isEofLike for the
break condition, logs at the cap-reached boundary, and swallows close
errors with a trace log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds T1 (empty stream rejection), T2-cap (cap boundary), T3 (non-EOF
mid-stream), T-reset (reset-after-success returns merged result), and
T-close-swallow (C2 regression: close error after read does not discard
the message). Equivalent push-receive tests added.

Removes "should order public addresses before private in the identify
response" — the assertion no longer guards real behavior since the
in-protocol address sorter was removed in the multi-send revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move @libp2p/peer-record after @libp2p/peer-id to satisfy the existing
import/order eslint rule. Pre-existing — was originally introduced in
aa154eb but not flagged until lint ran on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tabcat and others added 7 commits May 10, 2026 00:21
The post-loop `if (messages.length === 0) throw new InvalidMessageError(...)`
in both _identify and the push handleProtocol was unreachable: the read-loop
catch only breaks on EOF when messages.length > 0, otherwise it re-throws
the underlying UnexpectedEOFError. The empty-stream case is therefore
handled by the propagating EOF, never by the post-loop check.

Removes the dead block in both files. identify-push no longer needs the
InvalidMessageError import.

Behavior is unchanged — empty streams continue to reject with
UnexpectedEOFError, matching the existing test assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PeerStore handles dedup of incoming listenAddrs downstream, so deduping in
the merge step was redundant. Restores the prior plain-concat behavior for
listenAddrs while keeping the Set-based dedup on protocols (string ids).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n close error

Drops the isEofLike helper (and its standalone unit tests) — the check is
inlined at both call sites, using err.name === 'UnexpectedEOFError' (the
codebase's bundle-safe error-discrimination idiom) plus the stream-state
fallback for transport-level resets.

In identify._identify, the close call moves from pb.unwrap().unwrap().close
to stream.close — same effect, simpler chain, matches identify-push.

If close throws, abort the stream so transport-level cleanup runs even
though the read result is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Any error after at least one successful read keeps the messages we got and
breaks the loop. The error name and stream-state checks come out — the
data we've accumulated is already-validated parsed envelopes regardless of
why the next read failed, and applying it is consistent with the spec's
"missing fields ignored" merge semantics.

T3 updates from "rejects on garbage" to "returns the message before
garbage" to match the new behavior. The trailing comment referencing the
removed isEofLike helper is also cleaned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both identify._identify and identify-push.handleProtocol had near-identical
multi-message read + close + abort logic. Extracts the common shape to
utils.ts so the two call sites collapse to a single helper call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ages units

Adds 5 unit tests against readIdentifyMessages in utils.spec.ts: happy path,
empty-stream rejection, cap boundary, lenient-catch on read error after
success, and close-error preserves messages with abort.

Removes the integration-level equivalents from index.spec.ts (5) and
push.spec.ts (2) that were exercising the same helper paths through the
full identify / push handler. Coverage of "helper is wired in correctly"
stays in the existing "should merge multiple identify messages from the
remote" and "should handle multiple push messages and merge them" tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ close-throws

The recent dedup move pulled the helper-behavior tests into utils.spec.ts as
direct readIdentifyMessages units, but in doing so dropped end-to-end
verification that those error/recovery surfaces flow through the public
caller (Identify.identify / IdentifyPush.handleProtocol). A future refactor
of the caller wrapper could regress without any unit failure surfacing it.

Adds 4 short integration assertions (~15 lines each, much smaller than the
originals): empty-stream UnexpectedEOFError propagation and close-error
recovery, on both identify and identify-push code paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tabcat

tabcat commented May 9, 2026

Copy link
Copy Markdown
Member

apologies for the delay. where this pr landed:

receive: handles up to 10 lp-framed identify messages and merges them. lets us interop with go-libp2p which splits the signedPeerRecord into a second message when identify is over 2kb. original close-crash gets fixed by
wrapping the close in try/catch with stream.abort() if close throws. the read-loop catch is lenient, any error after at least one successful read keeps the messages we got and breaks.

send: still single-message. sending multi-message would crash older js-libp2p receivers that still have the close-crash. this pr is what lets them stop crashing so once it's deployed we can turn on multi-send too, depending on how the spec changes solidify.

cross-impl: looked at the 12 active libp2p stacks at libp2p/libp2p. 4kb is the practical outbound ceiling, rust-libp2p and litep2p both cap there. today's identify is comfortably under that without post-quantum keys so this
pr doesn't add an outbound cap, trim, or sort. those become useful when envelopes grow (pq identity, more circuit-relay addresses, etc) and can be added in follow-ups. still need to run some interop tests with other implementations locally.

deferred: multi-message send, outbound 4kb cap + trim, defaultMultiaddrSorter (only meaningful when splitting), partial-diff identify-push. spec pr libp2p/specs#709 still has open questions around envelope sizes for
post-quantum signatures, can update when that settles.

@tabcat tabcat requested a review from achingbrain May 9, 2026 23:38
@tabcat tabcat changed the title fix(identify): swallow StreamStateError on close fix(identify): handle multiple envelopes from go May 10, 2026
@tabcat

tabcat commented May 10, 2026

Copy link
Copy Markdown
Member

tested against:

  • go-v0.46, go-v0.47, go-v0.48
  • rust-v0.53, rust-v0.54, rust-v0.55, rust-v0.56
  • nim-v1.14
  • jvm-v1.2
  • js-v1.x, js-v2.x, js-v3.x
  • js-head ↔ js-head

using libp2p/test-plans. no failures.

@tabcat tabcat merged commit 2572c86 into libp2p:main May 12, 2026
35 checks passed
@tabcat tabcat mentioned this pull request May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

need/author-input Needs input from the original author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants