From e4d658a75327e16f37c62741e106239f34c6822e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 27 Oct 2026 18:44:43 +0000 Subject: [PATCH 1/5] fix(dial): skip body read after a failed 101 handshake When the server answered 101 but a header check failed, Dial read up to 1024 bytes from the response body before returning. After a 101 the body is the upgraded connection, so a silent server made Dial wait for the 3 s timer. Close it instead and return an empty body; other statuses still return the first 1024 bytes. --- dial.go | 8 +++++++ dial_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/dial.go b/dial.go index f5e4544b..90832c4d 100644 --- a/dial.go +++ b/dial.go @@ -148,6 +148,14 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) ( resp.Body = nil defer func() { if err != nil { + if resp.StatusCode == http.StatusSwitchingProtocols { + // The body is the upgraded connection, not an HTTP body. + // Reading it waits for data the server may never send. + respBody.Close() + resp.Body = http.NoBody + return + } + // We read a bit of the body for easier debugging. r := io.LimitReader(respBody, 1024) diff --git a/dial_test.go b/dial_test.go index 492ac6b3..8b33109d 100644 --- a/dial_test.go +++ b/dial_test.go @@ -416,3 +416,62 @@ func TestDialViaProxy(t *testing.T) { assertEcho(t, ctx, c) assertClose(t, c) } + +func TestDialFailedHandshakeBody(t *testing.T) { + t.Parallel() + + t.Run("switchingProtocols", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, brw, err := http.NewResponseController(w).Hijack() + if err != nil { + t.Errorf("failed to hijack: %v", err) + return + } + defer conn.Close() + brw.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: xd\r\n\r\n") + brw.Flush() + // Send nothing more and wait for the client to close. + io.Copy(io.Discard, conn) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + start := time.Now() + _, resp, err := websocket.Dial(ctx, s.URL, nil) + elapsed := time.Since(start) + assert.Contains(t, err, "invalid Sec-WebSocket-Accept") + assert.Equal(t, "StatusCode", http.StatusSwitchingProtocols, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", "", string(b)) + if elapsed > time.Second { + t.Fatalf("Dial took %v, want under 1s", elapsed) + } + }) + + t.Run("forbidden", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden by test", http.StatusForbidden) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Error(t, err) + assert.Equal(t, "StatusCode", http.StatusForbidden, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", "forbidden by test\n", string(b)) + }) +} From 5b96150ec026a16e744a640f73366b7fa8ecc85c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:38:45 +0000 Subject: [PATCH 2/5] test(dial): send bytes after the 101 in TestDialFailedHandshakeBody With a silent server, an empty body also results from reading the upgraded connection with a short timer. Bytes after the 101 make that variant fail on the body check. --- dial_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dial_test.go b/dial_test.go index 8b33109d..2d1c94d4 100644 --- a/dial_test.go +++ b/dial_test.go @@ -433,9 +433,10 @@ func TestDialFailedHandshakeBody(t *testing.T) { brw.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + "Connection: Upgrade\r\n" + "Upgrade: websocket\r\n" + - "Sec-WebSocket-Accept: xd\r\n\r\n") + "Sec-WebSocket-Accept: xd\r\n\r\n" + + "after upgrade") brw.Flush() - // Send nothing more and wait for the client to close. + // Keep the connection open, so reading it would stall. io.Copy(io.Discard, conn) })) defer s.Close() @@ -451,6 +452,8 @@ func TestDialFailedHandshakeBody(t *testing.T) { b, err := io.ReadAll(resp.Body) assert.Success(t, err) assert.Equal(t, "body", "", string(b)) + // Reading the body would take the 3s of Dial's read timer, and 1s + // leaves margin for slow CI. if elapsed > time.Second { t.Fatalf("Dial took %v, want under 1s", elapsed) } From d967226f65e35dc6e5b5fc7d17e8d0f01f810c8f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:38:45 +0000 Subject: [PATCH 3/5] docs(dial): say how long reading the upgraded connection stalls The read is bounded by the 3 s timer, so "waits for data the server may never send" overstated it. --- dial.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 90832c4d..02916baa 100644 --- a/dial.go +++ b/dial.go @@ -149,8 +149,9 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) ( defer func() { if err != nil { if resp.StatusCode == http.StatusSwitchingProtocols { - // The body is the upgraded connection, not an HTTP body. - // Reading it waits for data the server may never send. + // The body is the upgraded connection, not an HTTP body, and + // reading it stalls until the timer below fires if the server + // sends nothing. respBody.Close() resp.Body = http.NoBody return From 05b39f3e1b5f9876853e175505779d0b8064f69d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:43:16 +0000 Subject: [PATCH 4/5] docs(dial): name every way the 101 body read can end The read also stalls when the server sends fewer than 1024 bytes and keeps the connection open, not only when it sends nothing. --- dial.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index 02916baa..e8272dfb 100644 --- a/dial.go +++ b/dial.go @@ -150,8 +150,8 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) ( if err != nil { if resp.StatusCode == http.StatusSwitchingProtocols { // The body is the upgraded connection, not an HTTP body, and - // reading it stalls until the timer below fires if the server - // sends nothing. + // reading it stalls until the timer below fires unless the + // server closes it or sends 1024 bytes. respBody.Close() resp.Body = http.NoBody return From 1abc0687ffb7bf268a833bf3eb471c3c2565362e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 19:54:17 +0000 Subject: [PATCH 5/5] docs(dial): say that resp.Body is empty after a failed 101 The Dial doc said the body on error holds up to 1024 bytes, so a caller debugging a failed 101 could not tell an empty body from a silent server. --- dial.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dial.go b/dial.go index e8272dfb..371a7d8e 100644 --- a/dial.go +++ b/dial.go @@ -109,8 +109,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // The response is the WebSocket handshake response from the server. // You never need to close resp.Body yourself. // -// If an error occurs, the returned response may be non nil. -// However, you can only read the first 1024 bytes of the body. +// If an error occurs, the returned response may be non nil. After a 101 +// response, resp.Body is empty, because its bytes belong to the upgraded +// connection. For any other status, resp.Body holds at most the first +// 1024 bytes of the body. // // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes.