Skip to content

fix(client/http): coalesce simple proxy response into a single Write#1554

Open
liuwuyu118 wants to merge 1 commit into
apernet:masterfrom
liuwuyu118:fix/coalesce-http-connect-response
Open

fix(client/http): coalesce simple proxy response into a single Write#1554
liuwuyu118 wants to merge 1 commit into
apernet:masterfrom
liuwuyu118:fix/coalesce-http-connect-response

Conversation

@liuwuyu118

Copy link
Copy Markdown

Closes #1553.

What

Make sendSimpleResponse and sendProxyAuthRequired serialize the response into a bytes.Buffer and emit it with a single conn.Write, instead of letting Go stdlib net/http.Response.Write issue separate writes for the status line and the trailing CRLF.

The wire bytes are unchanged. Only the application-level syscall boundary moves.

Why

For a CONNECT 200 with ContentLength=-1 and an empty Header, http.Response.Write emits two underlying Write calls (status line + final CRLF). Because Go sets TCP_NODELAY=true on *net.TCPConn by default, the bytes go on the wire as two TCP segments (HTTP/1.1 200 OK\r\n + \r\n).

Some real-world HTTP CONNECT clients begin TLS handshake the moment they see the status line + first CRLF and don't tolerate the trailing 2 bytes arriving in a separate segment. The most visible current example is Bun's x86_64 native build, which ships in Anthropic's Claude Code CLI: when the response is split, Bun consumes the trailing \r\n as TLS data, the TLS record framing is permanently misaligned, and the HTTPS/2 fetch hangs until external timeout. Bun aarch64 is not affected. Full repro and cross-architecture data is in #1553 and in anthropics/claude-code#50252.

This is the same class of fix as #1110 (ffmpeg compatibility): keep the wire bytes identical, just adjust how hy2 hands them to the kernel so a single TCP segment is produced.

Verification

End-to-end on Linux x86_64 (Ubuntu 24.04.3, kernel 6.8.0-107, hysteria v2.8.1 + this patch, Claude Code 2.1.119 behind hy2):

Build Trials Latency
v2.8.1 stock 1/5 OK, 4/5 TIMEOUT at 45s timeout
v2.8.1 + this PR 5/5 OK 9–15s

tcpdump -i lo on the local hy2 listener:

  • before: Flags [P.], length 17 then Flags [P.], length 2
  • after: Flags [P.], length 19 (single segment, exactly HTTP/1.1 200 OK\r\n\r\n)

The same change is applied to sendProxyAuthRequired so 407 responses with the Proxy-Authenticate header are also emitted as a single segment.

Compatibility

bytes is already imported in this file; no new dependencies. The buffered response is at most a few hundred bytes (a CONNECT 200 is exactly 19 bytes), so the extra allocation is negligible.

No behavior change for compliant clients: the wire bytes — including byte order, headers, and content — are byte-for-byte identical.

Go's net/http.Response.Write emits the status line and the trailing CRLF
as separate Write calls. For headerless responses (CONNECT 200, 502, etc.)
this produces ≥2 underlying syscalls, and because *net.TCPConn defaults to
TCP_NODELAY=true, the bytes go onto the wire as separate TCP segments.

This breaks framing-sensitive HTTP/CONNECT clients that begin TLS handshake
immediately after seeing the status line + first CRLF without waiting for
the full \r\n\r\n. A concrete example is Bun's x86_64 native build (used in
Anthropic's Claude Code CLI 2.1.119): when the CONNECT 200 arrives in two
TCP segments ("HTTP/1.1 200 OK\r\n" then "\r\n"), the trailing two bytes
get consumed as TLS data, the TLS record framing is permanently misaligned,
and the HTTPS/2 stream hangs until external timeout. A 50-line Python
reproducer demonstrates 4/5 timeouts vs 5/5 OK depending only on whether
the proxy issues 1 or 2 sendalls for the response.

Same class of fix as apernet#1110 (ffmpeg compatibility): keep the wire bytes
identical, just buffer the small response and emit it in one conn.Write
so the kernel produces a single segment.

Verified end-to-end on Linux x86_64 (Ubuntu 24.04, kernel 6.8.0,
hysteria v2.8.1 + this patch) with Claude Code 2.1.119 behind hy2:
- before patch: 1/5 OK, 4/5 timeout
- after  patch: 5/5 OK, 9-15s each
- tcpdump confirms the response goes out as a single 19-byte segment
  (previously two segments of 17 + 2 bytes)

The same buffering is applied to sendProxyAuthRequired so that 407
responses with the Proxy-Authenticate header are also emitted as a
single segment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client/http: CONNECT 200 reply is split across 2 TCP segments, breaking framing-sensitive clients (e.g. Bun x86_64 / Claude Code)

1 participant