fix(adapt): an informational status goes out when it is written, so coder/websocket's 101 reaches the client

Claude-Session: https://claude.ai/code/session_01AGuBEqCjuman4E9FdKEgeV
This commit is contained in:
2026-10-03 00:41:37 -07:00
parent 0075bc119d
commit 247435d564
2 changed files with 64 additions and 7 deletions
+20 -7
View File
@@ -168,7 +168,7 @@ func serveUpgrade(c *Ctx, h http.Handler, req *http.Request) {
hdr: make(http.Header),
}
h.ServeHTTP(w, req)
if !w.taken {
if !w.taken && !w.switched {
w.answer()
}
})
@@ -179,17 +179,30 @@ func serveUpgrade(c *Ctx, h http.Handler, req *http.Request) {
// — an unauthorized socket is refused before it opens — has its status, headers
// and body written as an ordinary HTTP/1.1 reply, and the connection closes.
type upgradeWriter struct {
conn net.Conn
brw *bufio.ReadWriter
hdr http.Header
status int
body bytes.Buffer
taken bool
conn net.Conn
brw *bufio.ReadWriter
hdr http.Header
status int
body bytes.Buffer
taken bool
switched bool // a 101 went out: the reply is the handler's from here
}
func (w *upgradeWriter) Header() http.Header { return w.hdr }
// WriteHeader records a final status and SENDS an informational one, as net/http
// does: coder/websocket writes its 101 with WriteHeader and only then hijacks, so
// a 101 that waited for the handler to return would never reach the client and
// the handshake would hang with the socket open.
func (w *upgradeWriter) WriteHeader(code int) {
if code >= 100 && code < 200 && !w.taken {
fmt.Fprintf(w.brw, "HTTP/1.1 %d %s\r\n", code, http.StatusText(code))
_ = w.hdr.Write(w.brw)
_, _ = w.brw.WriteString("\r\n")
_ = w.brw.Flush()
w.switched = w.switched || code == http.StatusSwitchingProtocols
return
}
if w.status == 0 {
w.status = code
}
+44
View File
@@ -204,3 +204,47 @@ func TestAdaptNetHTTP_AnUpgradeTakesTheConnection(t *testing.T) {
t.Fatalf("the refusal lost its body: %q", rest)
}
}
// THE 101 GOES OUT WHEN IT IS WRITTEN. coder/websocket sets its headers, calls
// WriteHeader(101) and only then hijacks, relying on net/http to have sent an
// informational status at once. Held until the handler returned, the client never
// saw the switch and the handshake hung — the talk socket's second failure.
func TestAdaptNetHTTP_ASwitchWrittenBeforeTheHijackIsSent(t *testing.T) {
base := streamApp(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Upgrade", "echo")
w.Header().Set("Connection", "Upgrade")
w.WriteHeader(http.StatusSwitchingProtocols)
conn, brw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
defer conn.Close()
line, _ := brw.ReadString('\n')
_, _ = brw.WriteString("echo " + line)
_ = brw.Flush()
})
conn, err := net.Dial("tcp", strings.TrimPrefix(base, "http://"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
fmt.Fprint(conn, "GET /legacy/socket HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: echo\r\n\r\n")
rd := bufio.NewReader(conn)
status, err := rd.ReadString('\n')
if err != nil || !strings.HasPrefix(status, "HTTP/1.1 101") {
t.Fatalf("the switch answered %q (%v), want a 101 before any frame", status, err)
}
head := map[string]bool{}
for line, _ := rd.ReadString('\n'); line != "\r\n" && line != ""; line, _ = rd.ReadString('\n') {
head[strings.ToLower(strings.TrimSpace(line))] = true
}
if !head["upgrade: echo"] {
t.Fatalf("the 101 lost the handler's headers: %v", head)
}
fmt.Fprint(conn, "ping\n")
if got, _ := rd.ReadString('\n'); got != "echo ping\n" {
t.Fatalf("after the switch the connection answered %q", got)
}
}