Skip to content

feat(upgrade): allow resetting an HTTP/2 upgrade with CONNECT_ERROR - #4210

Open
jeremyjpj0916 wants to merge 2 commits into
hyperium:masterfrom
jeremyjpj0916:feat/upgraded-h2-connect-error-reset
Open

jeremyjpj0916 wants to merge 2 commits into
hyperium:masterfrom
jeremyjpj0916:feat/upgraded-h2-connect-error-reset

Conversation

@jeremyjpj0916

Copy link
Copy Markdown

Closes #4209

Problem

Dropping or shutting down an Upgraded that came from an HTTP/2 CONNECT
always ends the stream with END_STREAM. H2Upgraded holds only the data
channel into UpgradedSendStreamTask, which owns the SendStream, and
H2Upgraded is private, so user code cannot reach SendStream::send_reset.

A proxy whose tunnelled TCP connection fails therefore has no way to follow
RFC 9113 section 8.5, which treats any error in that TCP connection as a
stream error of type CONNECT_ERROR.
The client sees a clean close and cannot tell a failed tunnel from a complete
one.

Change

Add one public method:

impl Upgraded {
    /// Resets an HTTP/2 upgrade's stream with `RST_STREAM(CONNECT_ERROR)`.
    ///
    /// Returns `true` if the reset was requested. A send task that is
    /// already finishing the stream with `END_STREAM` still wins, so `true`
    /// does not guarantee a reset reaches the wire. Returns `false` and does
    /// nothing if this is not an HTTP/2 upgrade, if a reset was already
    /// queued, or if the stream's send side has already finished (for
    /// example after a completed shutdown).
    #[cfg(all(any(feature = "client", feature = "server"), feature = "http2"))]
    pub fn reset_with_connect_error(&mut self) -> bool;
}
  • proto::h2::upgrade::pair creates a second oneshot. H2Upgraded keeps the
    sender (H2Upgraded::reset). UpgradedSendStreamTask polls the receiver
    right after its poll_reset check, before it waits for capacity for a
    buffered write or reads the data channel. On a reason it calls
    send_reset(reason) and ends. On a dropped sender it clears the receiver
    and carries on as before.
  • Because the reset is polled first, a reset immediately followed by a drop
    still sends RST_STREAM, not END_STREAM, and a buffered write waiting for
    capacity does not delay it.
  • When the task ends, it clears the receiver before it sends or drops its
    error. So once a writer sees the task gone (for example a completed
    poll_shutdown), a later reset returns false, whatever the executor does
    with the finished future.
  • Upgraded reaches H2Upgraded with a &mut form of the existing TypeId
    downcast (modeled on std::error::Error::downcast_mut) via a new
    Rewind::get_mut. H2Upgraded becomes pub(crate).

The method takes no error code, because h2::Reason is not part of hyper's
public API and CONNECT_ERROR is the code the RFC assigns to this case. A
variant taking a code is easy to add if preferred.

Cost: one more small allocation per HTTP/2 upgrade (the oneshot) and one
oneshot poll per wake of the send task. Nothing changes for a stream that is
never reset.

An earlier form of this change is carried on Ferrum Edge's main branch as a patch on a vendored
hyper 1.9.0, not yet released.

Tests

In src/proto/h2/upgrade.rs, each test runs a real h2 client and server over
tokio::io::duplex, with the server side wrapped in Upgraded:

  • a reset, then an immediate drop, reaches the client as
    RST_STREAM(CONNECT_ERROR); a second reset returns false;
  • a reset after data has reached the client;
  • a reset while a buffered write waits for flow-control capacity (the client
    grants a zero stream window) reaches the client as
    RST_STREAM(CONNECT_ERROR), with no DATA;
  • a plain drop still sends END_STREAM;
  • after a completed poll_shutdown, the reset returns false and the client
    sees END_STREAM.

In src/upgrade.rs, which also run under Miri:

  • a non-HTTP/2 upgrade returns false and still downcasts;
  • the &mut downcast returns None for the wrong type, and a change made
    through it to the IO inside Upgraded is visible afterwards.

Dropping or shutting down an `Upgraded` that came from an HTTP/2 `CONNECT`
always ends the stream with `END_STREAM`, so a proxy whose tunnelled TCP
connection fails cannot report it as RFC 9113 section 8.5 asks.

Add `Upgraded::reset_with_connect_error`. For an HTTP/2 upgrade it queues
`RST_STREAM(CONNECT_ERROR)` in place of the `END_STREAM` and returns `true`.
It returns `false` for any other upgrade, for a second call, or once the send
side has finished.

`H2Upgraded` sends the reason over a new oneshot to `UpgradedSendStreamTask`,
which polls it right after `poll_reset`, ahead of buffered or queued data, so
a reset followed at once by a drop still sends `RST_STREAM`. `Upgraded` reaches
`H2Upgraded` through a `&mut` form of the existing `TypeId` downcast.

Closes hyperium#4209
`UpgradedSendStreamTask` now clears its reset receiver as soon as it
finishes, before it sends or drops its error. A writer that has seen the
task end therefore always gets `false` from a later reset, whatever the
executor does with the finished future. The field order stays as a
fallback for a task dropped before it finishes.

Add a test that resets while a buffered write waits for flow-control
capacity, and a Miri-runnable test for the success path of
`__hyper_downcast_mut`. Expand that function's SAFETY comment, and
explain why `reset_with_connect_error` derefs through the `Box`.
jeremyjpj0916 added a commit to ferrum-edge/ferrum-edge that referenced this pull request Sep 28, 2026
The vendored hyper CONNECT_ERROR patch is filed as hyperium/hyper#4209 and
hyperium/hyper#4210. Update the patch README, inventory row, lifecycle JSON,
and filed texts, and note the one known difference from the upstream PR.

Add the operator-facing note on what a revoked HBONE tunnel looks like and
how to tell a revocation from a backend failure on the gateway.

This branch has not been deployed

No deployments
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.

Upgraded HTTP/2 CONNECT streams cannot be reset, so a failed tunnel looks like a clean close

1 participant