Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ streams on the affected connection, while ordinary stream cancellation must
preserve healthy siblings. See [timeout semantics and configuration](DEPLOYMENT.md)
for partial-progress and cleanup boundaries. This setting is not in v1.0.1.

Failed H2 opens cancel their request immediately. An authenticated error or
canceled handoff can leave unread response data whose cleanup needs that same
shared write lock. Cleanup is client-owned and joined by client shutdown; it
does not extend the failed caller's wait or retire usable sibling streams.
The pending cleanup retains its opening reservation and continuation ticket
slot until the response closes, so the per-connection authentication window
also limits detached continuation cleanup. An invalid authentication proof
instead retires the physical connection before closing its response body.

There is no `autocar/2` ALPN or AutoCAR binary stream header on these paths.
The web ALPNs are `h2`, `h3`, and `http/1.1`. Native and web transports remain
separate modes and are not wire-compatible.
Expand Down
56 changes: 38 additions & 18 deletions internal/tunnel/web_h2_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ type WebH2Client struct {
sessions map[*webH2ClientSession]struct{}
dial *webH2DialAttempt
selection *webH2SelectionCohort
// Additions are made under mu before the closed gate. Close joins both
// unpublished physical setup and cleanup detached from sessions.
// Additions are made under mu before the closed gate. Close joins physical
// setup, opening reservations and cleanup detached from failed responses.
workers sync.WaitGroup
closeDone chan struct{}
closeErr error
Expand Down Expand Up @@ -103,7 +103,8 @@ type webH2ClientSession struct {
authReady chan struct{}
auth *webSessionClientAuth
// opening protects a selected session while an authentication ticket waits
// for replay-window capacity, before RoundTrip owns an HTTP/2 stream.
// for replay-window capacity and until an unsuccessful response is closed.
// Each reservation also owns a worker so client Close joins its cleanup.
opening int
// active counts returned net.Conns until full close, including deadline
// cancellation. Half-closes keep the opposite direction's ownership.
Expand Down Expand Up @@ -308,17 +309,38 @@ func (c *WebH2Client) dialStreamOnce(
return nil, err, false
}
session := reservation.session
defer c.releaseSessionReservation(session)
var (
bearer string
authClaims webAuthClaims
exchange webSessionExchange
)
finish := func() {
reservation.auth.complete(exchange)
c.releaseSessionReservation(session)
}
cleanupTransferred := false
defer func() {
if !cleanupTransferred {
finish()
}
}()
// Closing an unread response may wait on the shared HTTP/2 write lock
// even after request cancellation. Transfer the already-registered opening
// reservation, rather than delaying this caller or closing usable siblings.
// Keep its short-ticket slot until cleanup completes: the authentication
// replay window then also bounds pending continuation cleanup per session.
failResponse := func(body io.ReadCloser) {
fail()
cleanupTransferred = true
go func() {
defer finish()
_ = body.Close()
}()
}
if reservation.bootstrap {
bearer, authClaims, err = c.auth.authorization(binding, c.claims)
} else {
bearer, exchange, err = reservation.auth.authorization(requestCtx, binding)
defer reservation.auth.complete(exchange)
}
if err != nil {
fail()
Expand Down Expand Up @@ -375,9 +397,9 @@ func (c *WebH2Client) dialStreamOnce(
response.StatusCode,
)
if !ok || !c.completeSessionBootstrap(session, sessionAuth) {
_ = response.Body.Close()
fail()
c.failSessionAuthentication(session)
_ = response.Body.Close()
return nil, errors.New("tunnel: web-cover HTTP/2 server authentication failed"), false
}
} else if !reservation.auth.verifyResponseProof(
Expand All @@ -386,44 +408,39 @@ func (c *WebH2Client) dialStreamOnce(
exchange,
response.StatusCode,
) {
_ = response.Body.Close()
fail()
c.failSessionAuthentication(session)
_ = response.Body.Close()
return nil, errors.New("tunnel: web-cover HTTP/2 server authentication failed"), false
}
if response.StatusCode != http.StatusOK {
_ = response.Body.Close()
fail()
failResponse(response.Body)
return nil, &WebConnectError{Transport: webAuthTransportH2, StatusCode: response.StatusCode}, false
}
// Stop the establishment-only cancellation link before handing ownership
// to the caller. If cancellation won the race with the response, fail the
// dial instead of returning an already-canceled stream.
if !stopOpenTimeout() {
_ = response.Body.Close()
fail()
failResponse(response.Body)
return nil, context.DeadlineExceeded, false
}
stopDial()
if err := contextError(ctx); err != nil {
_ = response.Body.Close()
fail()
failResponse(response.Body)
return nil, err, false
}

c.mu.Lock()
if c.closed {
c.mu.Unlock()
_ = response.Body.Close()
fail()
failResponse(response.Body)
return nil, net.ErrClosed, false
}
// Detaching the opening watcher is not the ownership handoff. A caller
// canceled while queued on mu still owns only this request, not its session.
if err := contextError(ctx); err != nil {
c.mu.Unlock()
_ = response.Body.Close()
fail()
failResponse(response.Body)
return nil, err, false
}
c.selected = true
Expand Down Expand Up @@ -546,6 +563,7 @@ func (c *WebH2Client) reserveSession(ctx context.Context) (webH2SessionReservati
// not when a background worker publishes a cold connection.
session.authState = webH2ClientAuthBootstrapping
session.opening++
c.workers.Add(1)
c.mu.Unlock()
<-c.dialGate
return webH2SessionReservation{session: session, bootstrap: true}, nil
Expand All @@ -554,6 +572,7 @@ func (c *WebH2Client) reserveSession(ctx context.Context) (webH2SessionReservati
case webH2ClientAuthReady:
if canTake {
session.opening++
c.workers.Add(1)
auth := session.auth
c.mu.Unlock()
<-c.dialGate
Expand Down Expand Up @@ -651,6 +670,7 @@ func (c *WebH2Client) runSessionDial(attempt *webH2DialAttempt) {
}

func (c *WebH2Client) releaseSessionReservation(session *webH2ClientSession) {
defer c.workers.Done()
c.mu.Lock()
session.opening--
retired := c.cleanupIdleSessionsLocked()
Expand Down Expand Up @@ -896,7 +916,7 @@ func closeWebH2Session(session *webH2ClientSession) error {
}

// Close prevents future dials, cancels active streams, and closes every pooled
// HTTP/2 connection. It joins owned setup and detached cleanup; concurrent
// HTTP/2 connection. It joins owned setup, pending opens and detached cleanup; concurrent
// callers observe the same completed closure and error.
func (c *WebH2Client) Close() error {
c.mu.Lock()
Expand Down
Loading
Loading