Skip to content

Conversation

@kixelated
Copy link
Collaborator

UNTESTED

old: connect_with_subprotocol(url, protocol)
new: connect(ConnectRequest { url, protocol })

This will future-proof things a bit more because we can add new stuff to ConnectRequest and ConnectResponse.

@kixelated
Copy link
Collaborator Author

@gnunicorn does this seem reasonable? Im fine making a breaking API change for this.

Also subprotocols -> protocols.

@gnunicorn
Copy link
Contributor

oh, I didn't see you were working on this here. I've implemented it all on the original branch earlier today...

well, if we go for connect(ConnectRequest { url, protocol }) then any change to the request is still a breaking change in the future though. If you want to a less future-breaking variant, may I suggest to have a ConnectRequestBuilder().url(url).build()?-pattern? Then protocol and other things that are added later can just be optional fields with sensible defaults.

But I am fine with either of these ways.

@kixelated
Copy link
Collaborator Author

kixelated commented Jan 16, 2026

oh, I didn't see you were working on this here. I've implemented it all on the original branch earlier today...

oof, I should have asked.

well, if we go for connect(ConnectRequest { url, protocol }) then any change to the request is still a breaking change in the future though. If you want to a less future-breaking variant, may I suggest to have a ConnectRequestBuilder().url(url).build()?-pattern? Then protocol and other things that are added later can just be optional fields with sensible defaults.

I'm generally fine with breaking changes when the fix is "obvious", like new struct fields. But yep we could add a Builder, or turn ConnectRequest into a builder. There's also From<Url> for ConnectRequest for the existing users.

@kixelated kixelated marked this pull request as ready for review January 17, 2026 01:08
@kixelated
Copy link
Collaborator Author

@gnunicorn what about this #[non_exhaustive] approach? We should be able to add new fields without a semver bump.

@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

Walkthrough

Adds the sfv dependency and a new protocol_negotiation module using RFC 8941 structured fields. ConnectRequest and ConnectResponse gain protocol fields, constructors and builder methods; encoding/decoding use the sfv helpers and now return structured-field errors. New ConnectError variants handle invalid/structured-field errors. Connection APIs were generalized to accept Into<ConnectRequest>/Into<ConnectResponse>. A ConnectComplete type models completed CONNECT exchanges. Sessions, server, and client code were updated to store and expose the negotiated protocol via new protocol() accessors.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: sub-protocol negotiation support and breaking API changes throughout the codebase.
Description check ✅ Passed The description explains the key API change (from connect_with_subprotocol to connect with ConnectRequest) and its rationale for future extensibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web-transport-quiche/src/h3/connect.rs (2)

76-104: Missing protocol validation in client open() compared to quinn implementation.

The quinn implementation (see web-transport-quinn/src/connect.rs lines 99-132) validates that the server's selected protocol is in the client's requested protocols list:

if let Some(protocol) = &response.protocol {
    if !request.protocols.contains(protocol) {
        return Err(ConnectError::ProtocolMismatch(protocol.clone()));
    }
}

This quiche implementation lacks this validation, which could allow a server to respond with a protocol the client never requested.

🔧 Suggested fix

You'll need to add a ProtocolMismatch variant to ConnectError and add the validation:

+    #[error("protocol mismatch: {0}")]
+    ProtocolMismatch(String),

Then in open():

         // Throw an error if we didn't get a 200 OK.
         if response.status != http::StatusCode::OK {
             return Err(ConnectError::Status(response.status));
         }

+        // Validate that the server's protocol was in our request.
+        if let Some(protocol) = &response.protocol {
+            if !request.protocols.contains(protocol) {
+                return Err(ConnectError::ProtocolMismatch(protocol.clone()));
+            }
+        }
+
         Ok(Self {

62-71: Add protocol validation to the server's respond() method for consistency with quinn.

The quinn implementation (web-transport-quinn/src/connect.rs lines 62-65) validates that the server's selected protocol is in the client's request before sending. The quiche implementation doesn't perform this validation. Although the quiche Connect struct does store the request, the respond() method skips the validation check that quinn performs.

🤖 Fix all issues with AI agents
In `@web-transport-quinn/examples/README.md`:
- Around line 17-40: The README mentions non-existent examples multiproto-server
and multiproto-client; either remove the "Multiprotocol negotiation" section or
add implementations named multiproto-server and multiproto-client that mirror
echo-server.rs and echo-client.rs but implement WebTransport subprotocol
negotiation (supporting "echo/0" and "ping/0") and use the same port and CLI
flags referenced in the README; ensure example file names match the cargo run
commands and update the README commands if you choose different names.

In `@web-transport-quinn/src/connect.rs`:
- Around line 29-37: Change the visibility of the Connect struct fields from pub
to pub(crate) to restrict access to the crate internals: update the fields
request, send, and recv in the Connect struct so they are declared as pub(crate)
instead of pub, leaving the struct declaration itself unchanged to preserve
internal use within the crate.

In `@web-transport-quinn/src/server.rs`:
- Line 105: Fix the typo in the doc comment above the server constructor that
mentions "manually constructed Endpoint": change "Manaully" to "Manually" in the
comment describing creation of a new server with a manually constructed Endpoint
(the docstring referencing Endpoint/new server).

In `@web-transport-quinn/src/session.rs`:
- Around line 48-52: Fix the typo in the documentation comment above the struct
fields: change "The request send by the client." to "The request sent by the
client." for the request field associated with ConnectRequest (and while there,
verify the response comment above the response field associated with
ConnectResponse is phrased correctly); update the comment that documents the
request field named request (type ConnectRequest) accordingly.
🧹 Nitpick comments (6)
web-transport/src/quinn.rs (1)

76-78: Consider plumbing protocol selection into the response.
If sub-protocol negotiation is expected at this layer, you may want to respond with a ConnectResponse that includes the selected protocol rather than always returning OK.

web-transport-proto/src/connect.rs (1)

144-149: Consider preserving error details in protocol decoding.

The .map_err(|_| ConnectError::InvalidProtocol) discards the underlying error from decode_list. Since decode_list already returns ConnectError (including StructuredFieldError), you could simplify this and preserve error information:

♻️ Suggested simplification
         let protocols = headers
             .get(protocol_negotiation::AVAILABLE_NAME)
             .map(protocol_negotiation::decode_list)
             .transpose()
-            .map_err(|_| ConnectError::InvalidProtocol)?
+            ?
             .unwrap_or_default();
web-transport-quiche/src/h3/request.rs (1)

43-47: Consider documenting the asymmetry between respond and close.

respond() accepts a generic ConnectResponse while close() only accepts http::StatusCode. This is fine since close represents rejection (non-200), but a brief doc comment clarifying this would help users understand when to use each method.

web-transport-quiche/src/h3/connect.rs (1)

1-1: Inconsistent import style.

Line 1 imports from crate::proto, but line 91 uses web_transport_proto::ConnectResponse::read directly. Consider using ConnectResponse::read since ConnectResponse is already imported.

♻️ Suggested fix
-        let response = web_transport_proto::ConnectResponse::read(&mut recv).await?;
+        let response = ConnectResponse::read(&mut recv).await?;
web-transport-quinn/src/session.rs (1)

103-117: Consider accepting only the recv stream instead of the full ConnectComplete.

The run_closed method only uses connect.recv. Consider taking quinn::RecvStream directly to make the dependency clearer. However, this is a minor clarity improvement.

♻️ Optional refactor
-    async fn run_closed(&mut self, mut connect: ConnectComplete) -> (u32, String) {
+    async fn run_closed(&mut self, mut recv: quinn::RecvStream) -> (u32, String) {
         loop {
-            match web_transport_proto::Capsule::read(&mut connect.recv).await {
+            match web_transport_proto::Capsule::read(&mut recv).await {

And at the call site:

-            let (code, reason) = this2.run_closed(connect).await;
+            let (code, reason) = this2.run_closed(connect.recv).await;
web-transport-quinn/src/server.rs (1)

168-184: Consider validating status code in reject().

The reject() method accepts any http::StatusCode, but passing StatusCode::OK would be semantically incorrect for a rejection. Consider adding validation or documenting that the caller must use a non-2xx status.

💡 Option 1: Add validation
     /// Reject the session with the given status code.
     pub async fn reject(self, status: http::StatusCode) -> Result<(), ServerError> {
+        debug_assert!(!status.is_success(), "reject() should use a non-success status code");
         self.connect.respond(ConnectResponse::new(status)).await?;
         Ok(())
     }

Alternatively, document the expectation in the docstring.

- Fix typo "send" -> "sent" in session.rs comment
- Fix typo "Manaully" -> "Manually" in server.rs docstring
- Change Connect struct fields from pub to pub(crate)
- Remove non-existent multiproto examples from README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@gnunicorn
Copy link
Contributor

oof, I should have asked.

no, worries. it's your crate/work and there was radio-silence from me due to other work.

@gnunicorn what about this #[non_exhaustive] approach? We should be able to add new fields without a semver bump.

totally your call. I always find non_exhaustive structs/enums kinda annoying if you match or destruct them (which we do here) - then you end up adding .. and that can be a footgun later for your own code as you missed places that need updating, or have to read the fields manually. Neither are nice. I tend towards the builder pattern for exactly that reason: externally it is a stable API, internally for matches and destructing the compiling will yell if you change stuff.

do you want me to review your code?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@web-transport-quinn/src/server.rs`:
- Around line 168-178: The respond method currently returns a Session even for
non-200 ConnectResponse values; update respond (in impl for the type with method
respond) to inspect the ConnectResponse.status (from the response.into()) and if
it is not 200 OK, return an appropriate ServerError (or call the existing reject
flow) instead of proceeding to create Session via self.connect.respond; only
call self.connect.respond and construct Session::new(self.conn, self.settings,
connect) when status == 200 to ensure server and client agree on session
establishment.

Comment on lines +168 to 178
/// Reply to the session with the given status code.
///
/// If you want to reject the session, use a non-200 status code.
pub async fn respond(
self,
response: impl Into<ConnectResponse>,
) -> Result<Session, ServerError> {
let response = response.into();
let connect = self.connect.respond(response).await?;
Ok(Session::new(self.conn, self.settings, connect))
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid returning Session for non‑200 responses.
respond() currently returns a Session even when response.status is non‑OK (the doc comment even suggests using non‑200 to reject). That creates a server/client state mismatch because the client path rejects non‑200 and won’t establish a session. Please enforce 200 OK here (or delegate to reject) and return an error otherwise.

Proposed fix
-    /// Reply to the session with the given status code.
-    ///
-    /// If you want to reject the session, use a non-200 status code.
+    /// Reply with 200 OK to accept the session.
+    ///
+    /// Use `reject` for non-200 responses.
     pub async fn respond(
         self,
         response: impl Into<ConnectResponse>,
     ) -> Result<Session, ServerError> {
         let response = response.into();
+        if response.status != http::StatusCode::OK {
+            // Send the rejection but don't create a Session on the server.
+            self.connect.respond(response).await?;
+            return Err(crate::ConnectError::ErrorStatus(response.status).into());
+        }
         let connect = self.connect.respond(response).await?;
         Ok(Session::new(self.conn, self.settings, connect))
     }
🤖 Prompt for AI Agents
In `@web-transport-quinn/src/server.rs` around lines 168 - 178, The respond method
currently returns a Session even for non-200 ConnectResponse values; update
respond (in impl for the type with method respond) to inspect the
ConnectResponse.status (from the response.into()) and if it is not 200 OK,
return an appropriate ServerError (or call the existing reject flow) instead of
proceeding to create Session via self.connect.respond; only call
self.connect.respond and construct Session::new(self.conn, self.settings,
connect) when status == 200 to ensure server and client agree on session
establishment.

@kixelated
Copy link
Collaborator Author

totally your call. I always find non_exhaustive structs/enums kinda annoying if you match or destruct them (which we do here) - then you end up adding .. and that can be a footgun later for your own code as you missed places that need updating, or have to read the fields manually. Neither are nice. I tend towards the builder pattern for exactly that reason: externally it is a stable API, internally for matches and destructing the compiling will yell if you change stuff.

I hadn't thought about that before. I generally don't use destructing, but yeah that makes sense.

I think #[non_exhaustive] makes sense here because we both reading and writing the ConnectRequest and ConnectResponse depending on the mode. The public API can use Deref to access individual fields like connect.url instead of using getters/setters for marginally better ergonomics. And we still support the builder pattern via with_xxx helpers so you can chain setting together.

If we were constructing only, then yeah the builder pattern only makes more sense. That way you would get destructing support for crate internals.

Anyway, I'll merge this when I actually test it in a web browser. Thanks for the contribution and figuring this stuff out!

@kixelated kixelated enabled auto-merge (squash) January 23, 2026 02:48
@kixelated
Copy link
Collaborator Author

Thanks again @gnunicorn. It would be cool to support arbitrary headers if/when Chrome adds support.

@kixelated kixelated merged commit dfc1b83 into main Jan 23, 2026
1 check passed
@kixelated kixelated deleted the ben-protocols-3 branch January 23, 2026 02:52
@github-actions github-actions bot mentioned this pull request Jan 23, 2026
@github-actions github-actions bot mentioned this pull request Jan 24, 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.

3 participants