Logs
2026-04-25T20:20:11+08:00 INFO client mode
2026-04-25T20:20:11+08:00 INFO connected to server {"udpEnabled": true, "tx": 4750000, "count": 1}
2026-04-25T20:20:11+08:00 INFO HTTP proxy server listening {"addr": "0.0.0.0:49998"}
(Server logs are uneventful — no errors. The client is healthy; the issue is purely in how the client writes the CONNECT 200 response back to the local browser/CLI.)
Summary
When a client speaks HTTP CONNECT to http.listen on hysteria2 client, the HTTP/1.1 200 OK\r\n\r\n reply is emitted on the wire as two TCP segments (17 bytes HTTP/1.1 200 OK\r\n then 2 bytes \r\n). For framing-sensitive downstream HTTP CONNECT parsers — most notably Bun's x86_64 native build (used by Anthropic's Claude Code CLI, anthropics/claude-code#50252) — the 2 trailing bytes are consumed as TLS handshake data, the TLS record framing is permanently misaligned, and the HTTPS/2 fetch hangs until external timeout.
Bun-aarch64 is not affected; Bun-x86_64 is. The bug is in Bun's parser, but the trigger is hy2's split write, and we control that side.
This is the same class of issue as #1109 / #1110 (ffmpeg compatibility): a real-world client doesn't tolerate the exact byte boundaries that Go's net/http.Response.Write produces, even though the wire bytes are technically valid.
Root cause (verified in source)
app/internal/http/server.go::sendSimpleResponse (current master, line ~283) ends with:
where conn is the raw *net.TCPConn accepted from the local listener.
Go stdlib net/http.Response.Write writes the response in pieces:
response.go:259 → fmt.Fprintf(w, "HTTP/%d.%d %03d %s\r\n", ...) (one Write, 17 bytes for "HTTP/1.1 200 OK\r\n")
response.go:322 → io.WriteString(w, "\r\n") (one Write, 2 bytes)
For a CONNECT 200 with ContentLength=-1 and an empty Header, no other bytes are written between these two. Because Go sets TCP_NODELAY=true on *net.TCPConn by default (net/tcpsock.go:222), each application-level Write becomes its own TCP segment.
Reproduce
A 50-line Python proxy reproduces the exact wire pattern with no hy2 involved:
# normal mode → 1 sendall ("HTTP/1.1 200 OK\r\n\r\n") → 5/5 OK in Claude Code
# split mode → 2 sendalls ("HTTP/1.1 200 OK\r\n" then "\r\n") → 4/5 timeout
Full reproducer + cross-architecture data is in anthropics/claude-code#50252 (comment) and anthropics/claude-code#50252 (comment).
Verified fix (12-line patch on master)
Buffer the simple response into a bytes.Buffer and emit it in one conn.Write. bytes is already imported. Wire bytes are unchanged; only the syscall boundary moves.
// app/internal/http/server.go
func sendSimpleResponse(conn net.Conn, req *http.Request, statusCode int) error {
resp := &http.Response{ /* unchanged */ }
resp.ContentLength = -1
resp.Close = false
resp.Uncompressed = true
var buf bytes.Buffer
if err := resp.Write(&buf); err != nil {
return err
}
_, err := conn.Write(buf.Bytes())
return err
}
// same change applied to sendProxyAuthRequired
End-to-end results on Linux x86_64 (Ubuntu 24.04, kernel 6.8.0, hysteria v2.8.1 + this patch, Claude Code 2.1.119):
| Build |
Trials |
Latency |
| v2.8.1 stock |
1/5 OK, 4/5 TIMEOUT at 45s |
timeout |
| v2.8.1 + patch |
5/5 OK |
9–15s |
tcpdump -i lo confirms the 200 response is now a single 19-byte TCP segment (previously 17 + 2).
Versions
- hysteria: v2.8.1 (also reproduces on v2.7.1 and master HEAD;
app/internal/http/server.go::sendSimpleResponse is byte-identical across all three)
- OS: Ubuntu 24.04.3, kernel 6.8.0-107-generic
- Affected downstream client: Bun x86_64 native build, as used by Claude Code 2.1.119 (Anthropic). Bun aarch64 is not affected.
PR coming next, modeled on #1110.
Logs
(Server logs are uneventful — no errors. The client is healthy; the issue is purely in how the client writes the CONNECT 200 response back to the local browser/CLI.)
Summary
When a client speaks HTTP CONNECT to
http.listenon hysteria2 client, theHTTP/1.1 200 OK\r\n\r\nreply is emitted on the wire as two TCP segments (17 bytesHTTP/1.1 200 OK\r\nthen 2 bytes\r\n). For framing-sensitive downstream HTTP CONNECT parsers — most notably Bun's x86_64 native build (used by Anthropic's Claude Code CLI, anthropics/claude-code#50252) — the 2 trailing bytes are consumed as TLS handshake data, the TLS record framing is permanently misaligned, and the HTTPS/2 fetch hangs until external timeout.Bun-aarch64 is not affected; Bun-x86_64 is. The bug is in Bun's parser, but the trigger is hy2's split write, and we control that side.
This is the same class of issue as #1109 / #1110 (ffmpeg compatibility): a real-world client doesn't tolerate the exact byte boundaries that Go's
net/http.Response.Writeproduces, even though the wire bytes are technically valid.Root cause (verified in source)
app/internal/http/server.go::sendSimpleResponse(currentmaster, line ~283) ends with:where
connis the raw*net.TCPConnaccepted from the local listener.Go stdlib
net/http.Response.Writewrites the response in pieces:response.go:259→fmt.Fprintf(w, "HTTP/%d.%d %03d %s\r\n", ...)(one Write, 17 bytes for "HTTP/1.1 200 OK\r\n")response.go:322→io.WriteString(w, "\r\n")(one Write, 2 bytes)For a CONNECT 200 with
ContentLength=-1and an emptyHeader, no other bytes are written between these two. Because Go setsTCP_NODELAY=trueon*net.TCPConnby default (net/tcpsock.go:222), each application-level Write becomes its own TCP segment.Reproduce
A 50-line Python proxy reproduces the exact wire pattern with no hy2 involved:
Full reproducer + cross-architecture data is in anthropics/claude-code#50252 (comment) and anthropics/claude-code#50252 (comment).
Verified fix (12-line patch on master)
Buffer the simple response into a
bytes.Bufferand emit it in oneconn.Write.bytesis already imported. Wire bytes are unchanged; only the syscall boundary moves.End-to-end results on Linux x86_64 (Ubuntu 24.04, kernel 6.8.0, hysteria v2.8.1 + this patch, Claude Code 2.1.119):
tcpdump -i loconfirms the 200 response is now a single 19-byte TCP segment (previously 17 + 2).Versions
app/internal/http/server.go::sendSimpleResponseis byte-identical across all three)PR coming next, modeled on #1110.