Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
196d625
update strom dependency which fixes disable remote fingerprint verifi…
timwu20 Oct 7, 2025
b875bbd
change str0m to 0.11.1
timwu20 Oct 20, 2025
925caa9
fix lint
timwu20 Oct 21, 2025
4200246
set and listen on ChannelBufferedAmountLow after stream closure
timwu20 Oct 20, 2025
85fae21
fix: multistream-select negotiation for outbound webrtc substreams
haikoschol Oct 27, 2025
2ec539b
close data channel immediately when substream is closed
haikoschol Nov 12, 2025
4e4a2d3
remove setting of buffered amount low threshold in str0m
haikoschol Nov 12, 2025
7947bf6
remove special case of receiving None instead of a SubstreamEvent
haikoschol Nov 12, 2025
3765d52
move handling of trailing newline to webrtc_listener_negotiate()
haikoschol Nov 12, 2025
fdedc4f
cover all negotiation responses included in tests
haikoschol Nov 13, 2025
1cd945b
Merge branch 'master' into haiko-webrtc-outbound-multistream-nego-fix
haikoschol Nov 13, 2025
6dcb15d
address review feedback
haikoschol Nov 13, 2025
9621e3c
extract loop to drain_trailing_protocols()
haikoschol Nov 13, 2025
daa8f71
fmt
haikoschol Nov 19, 2025
afd44c8
Merge branch 'master' into haiko-webrtc-outbound-multistream-nego-fix
haikoschol Nov 19, 2025
99f6547
consider truncated multistream messages invalid data
haikoschol Nov 19, 2025
c9a0140
Merge branch 'master' into haiko-webrtc-outbound-multistream-nego-fix
haikoschol Nov 25, 2025
90f0c86
Apply suggestions from code review
haikoschol Nov 26, 2025
c0a44c0
avoid Message::Protocols in webrtc_listener_negotiate()
haikoschol Nov 26, 2025
c27930a
remove handling of Event::ChannelBufferedAmountLow
haikoschol Dec 2, 2025
4ed539e
Merge branch 'master' into haiko-webrtc-outbound-multistream-nego-fix
haikoschol Dec 2, 2025
12801fd
Merge branch 'master' into haiko-webrtc-outbound-multistream-nego-fix
haikoschol Dec 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/target
.idea

34 changes: 15 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ rcgen = { version = "0.14.5", optional = true }
# End of Quic related dependencies.

# WebRTC related dependencies. WebRTC is an experimental feature flag. The dependencies must be updated.
str0m = { version = "0.9.0", optional = true }
str0m = { version = "0.11.1", optional = true }
# End of WebRTC related dependencies.

# Fuzzing related dependencies.
Expand Down
163 changes: 114 additions & 49 deletions src/multistream_select/dialer_select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@

use crate::{
codec::unsigned_varint::UnsignedVarint,
error::{self, Error, ParseError},
error::{self, Error, ParseError, SubstreamError},
multistream_select::{
protocol::{
webrtc_encode_multistream_message, HeaderLine, Message, MessageIO, Protocol,
ProtocolError,
ProtocolError, PROTO_MULTISTREAM_1_0
},
Negotiated, NegotiationError, Version,
},
Expand Down Expand Up @@ -357,24 +357,95 @@ impl WebRtcDialerState {
&mut self,
payload: Vec<u8>,
) -> Result<HandshakeResult, crate::error::NegotiationError> {
let Message::Protocols(protocols) =
Message::decode(payload.into()).map_err(|_| ParseError::InvalidData)?
else {
return Err(crate::error::NegotiationError::MultistreamSelectError(
NegotiationError::Failed,
));
// All multistream-select messages are length-prefixed. Since this code path is not using
// multistream_select::protocol::MessageIO, we need to decode and remove the length here.
let mut remaining: &[u8] = &payload;
let (len, tail) = unsigned_varint::decode::usize(remaining).
map_err(|error| {
tracing::debug!(
target: LOG_TARGET,
?error,
message = ?payload,
"Failed to decode length-prefix in multistream message");
error::NegotiationError::ParseError(ParseError::InvalidData)
})?;

let payload = tail[..len].to_vec();

let message = Message::decode(payload.into());

tracing::trace!(
target: LOG_TARGET,
?message,
"Decoded message while registering response",
);

let mut protocols = match message {
Ok(Message::Header(HeaderLine::V1)) => {
vec![PROTO_MULTISTREAM_1_0]
}
Ok(Message::Protocol(protocol)) => vec![protocol],
Ok(Message::Protocols(protocols)) => protocols,
Ok(Message::NotAvailable) => {
return match &self.state {
HandshakeState::WaitingProtocol =>
Err(error::NegotiationError::MultistreamSelectError(
NegotiationError::Failed
)),
_ => Err(error::NegotiationError::StateMismatch),
}
}
Ok(Message::ListProtocols) => return Err(error::NegotiationError::StateMismatch),
Err(_) => return Err(error::NegotiationError::ParseError(ParseError::InvalidData)),
};

remaining = &tail[len..];

loop {
if remaining.is_empty() {
break;
}

let (len, tail) = unsigned_varint::decode::usize(remaining).
map_err(|error| {
tracing::debug!(
target: LOG_TARGET,
?error,
message = ?remaining,
"Failed to decode length-prefix in multistream message");
error::NegotiationError::ParseError(ParseError::InvalidData)
})?;

if len > tail.len() {
break;
}

let payload = tail[..len].to_vec();

match Message::decode(payload.into()) {
Ok(Message::Protocol(protocol)) => protocols.push(protocol),
Err(error) => {
tracing::debug!(
target: LOG_TARGET,
?error,
message = ?tail[..len],
"Failed to decode multistream message",
);
return Err(error::NegotiationError::ParseError(ParseError::InvalidData))
},
_ => return Err(error::NegotiationError::StateMismatch),
}

remaining = &tail[len..];
}

let mut protocol_iter = protocols.into_iter();
loop {
match (&self.state, protocol_iter.next()) {
(HandshakeState::WaitingResponse, None) =>
return Err(crate::error::NegotiationError::StateMismatch),
(HandshakeState::WaitingResponse, Some(protocol)) => {
let header = Protocol::try_from(&b"/multistream/1.0.0"[..])
.expect("valid multitstream-select header");

if protocol == header {
if protocol == PROTO_MULTISTREAM_1_0 {
self.state = HandshakeState::WaitingProtocol;
} else {
return Err(crate::error::NegotiationError::MultistreamSelectError(
Expand Down Expand Up @@ -410,7 +481,9 @@ mod tests {
use super::*;
use crate::multistream_select::listener_select_proto;
use std::time::Duration;
use bytes::BufMut;
use tokio::net::{TcpListener, TcpStream};
use crate::multistream_select::protocol::MSG_MULTISTREAM_1_0;

#[tokio::test]
async fn select_proto_basic() {
Expand Down Expand Up @@ -755,23 +828,18 @@ mod tests {
fn propose() {
let (mut dialer_state, message) =
WebRtcDialerState::propose(ProtocolName::from("/13371338/proto/1"), vec![]).unwrap();
let message = bytes::BytesMut::from(&message[..]).freeze();

let Message::Protocols(protocols) = Message::decode(message).unwrap() else {
panic!("invalid message type");
};
let mut bytes = BytesMut::with_capacity(32);
bytes.put_u8(MSG_MULTISTREAM_1_0.len() as u8);
let _ = Message::Header(HeaderLine::V1).encode(&mut bytes).unwrap();

assert_eq!(protocols.len(), 2);
assert_eq!(
protocols[0],
Protocol::try_from(&b"/multistream/1.0.0"[..])
.expect("valid multitstream-select header")
);
assert_eq!(
protocols[1],
Protocol::try_from(&b"/13371338/proto/1"[..])
.expect("valid multitstream-select header")
);
let proto = Protocol::try_from(&b"/13371338/proto/1"[..]).expect("valid protocol name");
bytes.put_u8((proto.as_ref().len() + 1) as u8); // + 1 for \n
let _ = Message::Protocol(proto).encode(&mut bytes).unwrap();

let expected_message = bytes.freeze().to_vec();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dq: This slightly changes the meaning of the messages.

Before we were expecting:

[Protocols(/multistream/1.0.0), Protocol(/13371338/proto/1)]

Now we are expecting:

// \n added
[Header(/multistream/1.0.0 /\n),

// \n counted but not checked
Protocol(/13371338/proto/1 [])

Hmm, one of those representation can't be spec compliant right?

Also are we ignoring malformated messages:

  • we decode the frame size correctly
  • but we don't check then if the last character is \n or not sufficeint bytes provided?

Copy link
Contributor Author

@haikoschol haikoschol Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the test ensures that the wire format that WebRtcDialerState::propose() produces is what we expect. Before it actually ensured that whatever propose() produces can be decoded with Message::decode(). So I did change the semantics of the test after all. But I would argue that the new meaning is more useful. We could also add decoding and assert we get the expected strings back, but due to the changes made, this can no longer be done by just calling Message::decode().


assert_eq!(message, expected_message);
}

#[test]
Expand All @@ -781,41 +849,38 @@ mod tests {
vec![ProtocolName::from("/sup/proto/1")],
)
.unwrap();
let message = bytes::BytesMut::from(&message[..]).freeze();

let Message::Protocols(protocols) = Message::decode(message).unwrap() else {
panic!("invalid message type");
};
let mut bytes = BytesMut::with_capacity(32);
bytes.put_u8(MSG_MULTISTREAM_1_0.len() as u8);
let _ = Message::Header(HeaderLine::V1).encode(&mut bytes).unwrap();

assert_eq!(protocols.len(), 3);
assert_eq!(
protocols[0],
Protocol::try_from(&b"/multistream/1.0.0"[..])
.expect("valid multitstream-select header")
);
assert_eq!(
protocols[1],
Protocol::try_from(&b"/13371338/proto/1"[..])
.expect("valid multitstream-select header")
);
assert_eq!(
protocols[2],
Protocol::try_from(&b"/sup/proto/1"[..]).expect("valid multitstream-select header")
);
let proto1 = Protocol::try_from(&b"/13371338/proto/1"[..]).expect("valid protocol name");
bytes.put_u8((proto1.as_ref().len() + 1) as u8); // + 1 for \n
let _ = Message::Protocol(proto1).encode(&mut bytes).unwrap();

let proto2 = Protocol::try_from(&b"/sup/proto/1"[..]).expect("valid protocol name");
bytes.put_u8((proto2.as_ref().len() + 1) as u8); // + 1 for \n
let _ = Message::Protocol(proto2).encode(&mut bytes).unwrap();

let expected_message = bytes.freeze().to_vec();

assert_eq!(message, expected_message);
}

#[test]
fn register_response_invalid_message() {
// send only header line
fn register_response_header_only() {
let mut bytes = BytesMut::with_capacity(32);
bytes.put_u8(MSG_MULTISTREAM_1_0.len() as u8);

let message = Message::Header(HeaderLine::V1);
message.encode(&mut bytes).map_err(|_| Error::InvalidData).unwrap();

let (mut dialer_state, _message) =
WebRtcDialerState::propose(ProtocolName::from("/13371338/proto/1"), vec![]).unwrap();

match dialer_state.register_response(bytes.freeze().to_vec()) {
Err(error::NegotiationError::MultistreamSelectError(NegotiationError::Failed)) => {}
Ok(HandshakeResult::NotReady) => {},
Err(err) => panic!("unexpected error: {:?}", err),
event => panic!("invalid event: {event:?}"),
}
}
Expand Down
22 changes: 10 additions & 12 deletions src/multistream_select/listener_select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,14 @@ pub fn webrtc_listener_negotiate<'a>(
supported_protocols: &'a mut impl Iterator<Item = &'a ProtocolName>,
payload: Bytes,
) -> crate::Result<ListenerSelectResult> {
let payload = if payload.len() > 2 && payload[0..payload.len() - 2] != b"\n\n"[..] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way around this without allocation? As the comment in Message::decode says, the well-formed message is terminated with a newline. Also, why are we checking for two newlines, but are adding one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be avoided by passing a BytesMut to the function. While looking into this, I noticed that there are (failing) tests in this module that I didn't run when working on this PR and then I noticed that the tests are not run in GH Actions either. Is this intentional? (cc @lexnv)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, why are we checking for two newlines, but are adding one?

This is quite messy because we are misusing Message::Protocols to parse what is actually two distinct multistream-select messages that are often sent inside one WebRtcMessage. I wanted to keep the changes in this PR minimal, knowing that we need to rewrite the listener negotiation anyway to support the "back-and-forth" (as you pointed out in #75).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a way to avoid appending a newline and using Message::Protocols to decode, by reusing drain_trailing_protocols(). I've implemented this in c0a44c0 .

I have not retested this with libp2p and smoldot. Will do that tomorrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not retested this with libp2p and smoldot. Will do that tomorrow.

litep2p-perf with a libp2p dialer

works (d/l up to 512KiB, u/l up to 16MiB)

litep2p-perf with a smoldot dialer

litep2p crashes apparently during the noise handshake:

2025-11-27T12:45:32.307986Z DEBUG litep2p::webrtc: received non-stun message source=127.0.0.1:63971

thread 'main' (15158228) panicked at /Users/haiko/code/polkadot/litep2p/src/transport/webrtc/opening.rs:432:70:
to succeed: ParseError(InvalidData)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
2025-11-27T12:45:32.308961Z  WARN litep2p::transport-service: transport service closed
2025-11-27T12:45:32.309016Z  WARN litep2p::transport-service: transport service closed
2025-11-27T12:45:32.309022Z  WARN litep2p::transport-service: transport service closed

This makes absolutely no sense to me yet. I know that I've tested this before and I haven't made any changes to the smoldot side since. I've tried a few previous commits for litep2p but always had the same result. Need to keep investigating...

webrtc-poc with a libp2p dialer

what works:

  • noise handshake
  • substream opening and multistream-select negotiation from libp2p to litep2p
  • ping from libp2p to litep2p

what doesn't work:

  • substream opening and multistream-select negotiation from litep2p to libp2p

I thought I had tested this before, but I'm not sure. Maybe I just focused on smoldot. My previous comment here doesn't mention this pairing either. I've also tried previous commits on this PR branch without success. Need to keep investigating here as well.

webrtc-poc with a smoldot dialer

works

Copy link
Contributor Author

@haikoschol haikoschol Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

webrtc-poc with a libp2p dialer

what works:

  • noise handshake
  • substream opening and multistream-select negotiation from libp2p to litep2p
  • ping from libp2p to litep2p

what doesn't work:

  • substream opening and multistream-select negotiation from litep2p to libp2p

I figured out what is going on here and opened #494. The relevant insight in the context of this review is that the faulty behavior is not caused by a regression introduced in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

litep2p-perf with a smoldot dialer

litep2p crashes apparently during the noise handshake:

2025-11-27T12:45:32.307986Z DEBUG litep2p::webrtc: received non-stun message source=127.0.0.1:63971

thread 'main' (15158228) panicked at /Users/haiko/code/polkadot/litep2p/src/transport/webrtc/opening.rs:432:70:
to succeed: ParseError(InvalidData)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
2025-11-27T12:45:32.308961Z  WARN litep2p::transport-service: transport service closed
2025-11-27T12:45:32.309016Z  WARN litep2p::transport-service: transport service closed
2025-11-27T12:45:32.309022Z  WARN litep2p::transport-service: transport service closed

This makes absolutely no sense to me yet. I know that I've tested this before and I haven't made any changes to the smoldot side since.

I've looked into this issue again and figured out what is going on. I hadn't actually tested this specific combination before (litep2p listener, smoldot dialer, perf behavior). The combinations I tested were:

  • libp2p listener, smoldot dialer, perf behavior
  • litep2p listener, smoldot dialer, ping in both directions

I have two branches in the ChainSafe smoldot fork; one for the perf behavior (haiko-webrtc-perf) and one for ping (haiko-libp2p-webrtc). When I tested the second combination listed above (and found the issue this PR is addressing) I ran into this crash in the noise handshake on the branch haiko-libp2p-webrtc and fixed it (or rather avoided it) in this commit. Making the same change on the other branch also avoids this crash.

What happens is that without this fix, smoldot sends a two byte long message (i.e. an incomplete WebRTC protobuf frame), which fails to decode here and panics due to an .expect() here.

In short, this crash is not a regression caused by the changes in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmitry-markin can you please go through my comments above and resolve this thread if you are happy with the change I made in c0a44c0?

let mut buf = BytesMut::from(payload);
buf.extend_from_slice(b"\n");
buf.freeze()
} else {
payload
};

let Message::Protocols(protocols) = Message::decode(payload).map_err(|_| Error::InvalidData)?
else {
return Err(Error::NegotiationError(
Expand Down Expand Up @@ -466,12 +474,7 @@ mod tests {
message.encode(&mut bytes).map_err(|_| Error::InvalidData).unwrap();

match webrtc_listener_negotiate(&mut local_protocols.iter(), bytes.freeze()) {
Err(error) => assert!(std::matches!(
error,
Error::NegotiationError(error::NegotiationError::MultistreamSelectError(
NegotiationError::Failed
))
)),
Err(error) => assert!(std::matches!(error, Error::InvalidData)),
event => panic!("invalid event: {event:?}"),
}
}
Expand All @@ -495,12 +498,7 @@ mod tests {
message.encode(&mut bytes).map_err(|_| Error::InvalidData).unwrap();

match webrtc_listener_negotiate(&mut local_protocols.iter(), bytes.freeze()) {
Err(error) => assert!(std::matches!(
error,
Error::NegotiationError(error::NegotiationError::MultistreamSelectError(
NegotiationError::Failed
))
)),
Err(error) => assert!(std::matches!(error, Error::InvalidData)),
event => panic!("invalid event: {event:?}"),
}
}
Expand Down
7 changes: 3 additions & 4 deletions src/multistream_select/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub const MSG_MULTISTREAM_1_0: &[u8] = b"/multistream/1.0.0\n";
const MSG_PROTOCOL_NA: &[u8] = b"na\n";
/// The encoded form of a multistream-select 'ls' message.
const MSG_LS: &[u8] = b"ls\n";
/// A Protocol instance for the `/multistream/1.0.0` header line.
pub const PROTO_MULTISTREAM_1_0: Protocol = Protocol(Bytes::from_static(MSG_MULTISTREAM_1_0));
/// Logging target.
const LOG_TARGET: &str = "litep2p::multistream-select";

Expand Down Expand Up @@ -230,7 +232,7 @@ impl Message {
///
/// # Note
///
/// This is implementation is not compliant with the multistream-select protocol spec.
/// This implementation may not be compliant with the multistream-select protocol spec.
/// The only purpose of this was to get the `multistream-select` protocol working with smoldot.
pub fn webrtc_encode_multistream_message(
messages: impl IntoIterator<Item = Message>,
Expand All @@ -249,9 +251,6 @@ pub fn webrtc_encode_multistream_message(
header.append(&mut proto_bytes);
}

// For the `Message::Protocols` to be interpreted correctly, it must be followed by a newline.
header.push(b'\n');

Ok(BytesMut::from(&header[..]))
}

Expand Down
Loading