-
Notifications
You must be signed in to change notification settings - Fork 120
websocket: connection attempts on the input connector now honor context cancellation #483
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| // Copyright 2026 Redpanda Data, Inc. | ||
|
|
||
| package io | ||
|
|
||
| import ( | ||
| "context" | ||
| "net" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| "github.com/gorilla/websocket" | ||
| ) | ||
|
|
||
| // hiddenDeadlineContext strips ctx.Deadline() while preserving cancellation. | ||
| // | ||
| // Gorilla sets a socket deadline matching ctx.Deadline(). Because the runtime's | ||
| // socket deadline timer and Go's context timer run independently, a socket read | ||
| // can time out (returning a generic "i/o timeout") slightly before ctx.Err() is set. | ||
| // | ||
| // Concealing the deadline prevents Gorilla from setting this competing socket timer. | ||
| // This ensures our context.AfterFunc watcher exclusively controls socket interruption | ||
| // and deterministically returns context errors. | ||
| type hiddenDeadlineContext struct { | ||
| context.Context | ||
| } | ||
|
|
||
| func (hiddenDeadlineContext) Deadline() (time.Time, bool) { | ||
| return time.Time{}, false | ||
| } | ||
|
|
||
| // dialContext is dialer.DialContext plus cancellation of the HTTP upgrade exchange. | ||
| // | ||
| // Gorilla applies the context to the TCP and TLS handshakes only. | ||
| // It then performs the websocket upgrade on a bare connection, which does not respond to context cancellation. | ||
| // A peer that accepts TCP and then stays silent blocks the dial until the handshake timeout. | ||
| func dialContext(ctx context.Context, dialer websocket.Dialer, urlStr string, headers http.Header) (*websocket.Conn, *http.Response, error) { | ||
| netDialer := &net.Dialer{} | ||
| var stop func() bool | ||
|
|
||
| // 1. Intercept network connection creation to attach a context watcher (context.AfterFunc) that closes the conn once ctx is done. | ||
| dialer.NetDialContext = func(dialCtx context.Context, network, addr string) (net.Conn, error) { | ||
| conn, err := netDialer.DialContext(dialCtx, network, addr) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| // Watch ctx, not dialCtx: gorilla wraps dialCtx with HandshakeTimeout and | ||
| // cancels it as DialContext returns, which would fire this on success. | ||
| stop = context.AfterFunc(ctx, func() { _ = conn.Close() }) | ||
| return conn, nil | ||
| } | ||
|
|
||
| // 2. Perform dial with hidden deadline context. | ||
| // The context we give gorilla keeps the cancellation of ctx (stopping TCP/TLS | ||
| // handshakes), but conceals ctx.Deadline() so gorilla does not set a competing | ||
| // socket timer that could race with ctx cancellation. | ||
| client, res, err := dialer.DialContext(hiddenDeadlineContext{ctx}, urlStr, headers) | ||
|
|
||
| // 3. Clean up and prioritize context error returns. | ||
| if stop != nil { | ||
| _ = stop() | ||
| } | ||
| if ctxErr := ctx.Err(); ctxErr != nil { | ||
| if client != nil { | ||
| _ = client.Close() | ||
| } | ||
| return nil, res, ctxErr | ||
| } | ||
| return client, res, err | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| // Copyright 2026 Redpanda Data, Inc. | ||
|
|
||
| package io | ||
|
|
||
| import ( | ||
| "net" | ||
| "sync" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // newHangingListener returns the address of a listener that accepts TCP | ||
| // connections but never answers the handshake, so a dial there stays blocked | ||
| // until the dialer times out. The returned channel reports each accept, which | ||
| // tells the caller that a handshake is in flight. | ||
| func newHangingListener(t *testing.T) (net.Addr, <-chan struct{}) { | ||
| t.Helper() | ||
|
|
||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| require.NoError(t, err) | ||
|
|
||
| accepted := make(chan struct{}, 1) | ||
| var ( | ||
| mut sync.Mutex | ||
| conns []net.Conn | ||
| ) | ||
|
|
||
| go func() { | ||
| for { | ||
| conn, err := lis.Accept() | ||
| if err != nil { | ||
| return | ||
| } | ||
| mut.Lock() | ||
| conns = append(conns, conn) | ||
| mut.Unlock() | ||
| select { | ||
| case accepted <- struct{}{}: | ||
| default: | ||
| } | ||
| } | ||
| }() | ||
|
|
||
| // Close the accepted connections as well as the listener, so that an | ||
| // abandoned dial fails instead of running to the handshake timeout. | ||
| t.Cleanup(func() { | ||
| _ = lis.Close() | ||
| mut.Lock() | ||
| defer mut.Unlock() | ||
| for _, conn := range conns { | ||
| _ = conn.Close() | ||
| } | ||
| }) | ||
|
|
||
| return lis.Addr(), accepted | ||
| } | ||
|
|
||
| // newUnreachableAddr returns the address of a closed port. A dial there fails | ||
| // immediately, so a context error proves no dial was attempted. | ||
| func newUnreachableAddr(t *testing.T) net.Addr { | ||
| t.Helper() | ||
|
|
||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| require.NoError(t, err) | ||
| addr := lis.Addr() | ||
| require.NoError(t, lis.Close()) | ||
|
|
||
| return addr | ||
| } | ||
|
|
||
| // awaitWithin runs fn in the background and returns its error, or fails the test | ||
| // if fn has not returned within d. | ||
| func awaitWithin(t *testing.T, d time.Duration, what string, fn func() error) error { | ||
| t.Helper() | ||
|
|
||
| done := make(chan error, 1) | ||
| go func() { | ||
| done <- fn() | ||
| }() | ||
|
|
||
| select { | ||
| case err := <-done: | ||
| return err | ||
| case <-time.After(d): | ||
| t.Fatalf("%v stayed blocked for %v", what, d) | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| // requireAccepted fails the test if the listener reported no accept within d. It | ||
| // proves that the dial reached the handshake, so a test that expects the context | ||
| // to become mid-handshake cannot pass through an earlier failure instead. | ||
| func requireAccepted(t *testing.T, d time.Duration, accepted <-chan struct{}) { | ||
| t.Helper() | ||
|
|
||
| select { | ||
| case <-accepted: | ||
| case <-time.After(d): | ||
| t.Fatal("the listener accepted no connection, so the dial never reached the handshake") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The branching was redundant and has been simplified