Skip to content

Commit 4573cd8

Browse files
rayegunclaude
andauthored
Fix HTTP/2 client deadlock when the server responds before consuming the request body (#1326)
A body writer parked in _reserve_send_window! only woke on WINDOW_UPDATE, connection error, or connection close. A peer that completed its response (END_STREAM), reset the stream, or a canceled request left it waiting for window credit that would never arrive. - Track send-closed streams in a state_lock-guarded set on H2Connection; mark on END_STREAM/RST_STREAM/cancel and notify window_condition. - _reserve_send_window! returns 0 for a send-closed stream; the writer abandons the upload, sends RST_STREAM(NO_ERROR) for a benign early response, and the roundtrip returns the peer's response. - RST_STREAM(NO_ERROR) after a complete response is benign per RFC 9113 §8.1; other resets surface as H2StreamResetError without failing the shared connection. - Emit content-length on h2 requests when the body size is known, matching the HTTP/1.1 wire path. Fixes #1325 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 768f863 commit 4573cd8

2 files changed

Lines changed: 321 additions & 13 deletions

File tree

src/http2_client.jl

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ mutable struct H2Connection
148148
conn_send_window::Int64
149149
initial_stream_send_window::Int64
150150
stream_send_window::Dict{UInt32,Int64}
151+
# Streams whose send side is closed: the peer completed its response
152+
# (END_STREAM) or reset the stream, or the request was canceled. Guarded by
153+
# `state_lock` and checked by `_reserve_send_window!` so a body writer
154+
# parked on flow control aborts instead of waiting for WINDOW_UPDATE
155+
# frames the peer will never send (RFC 9113 §8.1 permits a server to
156+
# respond before the request body completes).
157+
send_closed_streams::Set{UInt32}
151158
peer_max_concurrent_streams::Int
152159
peer_max_header_list_size::Int
153160
peer_goaway_last_stream_id::UInt32
@@ -258,10 +265,43 @@ end
258265
@inline function _h2_send_window_ready_locked(conn::H2Connection, stream_id::UInt32)::Bool
259266
(@atomic :acquire conn.closed) && return true
260267
conn.conn_error !== nothing && return true
261-
stream_window = get(() -> Int64(0), conn.stream_send_window, stream_id)
268+
# A send-closed or unregistered stream will never receive more window
269+
# credit; report ready so the waiter re-checks and aborts.
270+
stream_id in conn.send_closed_streams && return true
271+
haskey(conn.stream_send_window, stream_id) || return true
272+
stream_window = conn.stream_send_window[stream_id]
262273
return stream_window > 0 && conn.conn_send_window > 0
263274
end
264275

276+
"""
277+
_mark_h2_send_closed!(conn, stream_id)
278+
279+
Record that no more request DATA may be sent on `stream_id` (the peer ended or
280+
reset the stream, or the request was canceled) and wake any body writer parked
281+
in `_reserve_send_window!` waiting for flow-control credit.
282+
"""
283+
function _mark_h2_send_closed!(conn::H2Connection, stream_id::UInt32)
284+
lock(conn.state_lock)
285+
try
286+
if haskey(conn.streams, stream_id)
287+
push!(conn.send_closed_streams, stream_id)
288+
end
289+
notify(conn.window_condition; all=true)
290+
finally
291+
unlock(conn.state_lock)
292+
end
293+
return nothing
294+
end
295+
296+
@inline function _h2_send_closed(conn::H2Connection, stream_id::UInt32)::Bool
297+
lock(conn.state_lock)
298+
try
299+
return stream_id in conn.send_closed_streams || !haskey(conn.stream_send_window, stream_id)
300+
finally
301+
unlock(conn.state_lock)
302+
end
303+
end
304+
265305
function _wait_h2_send_window_locked!(conn::H2Connection, stream_id::UInt32, deadline_ns::Int64)::Nothing
266306
if deadline_ns == 0
267307
wait(conn.window_condition)
@@ -286,13 +326,18 @@ function _wait_h2_send_window_locked!(conn::H2Connection, stream_id::UInt32, dea
286326
return nothing
287327
end
288328

329+
# Returns the number of DATA payload bytes the caller may send (> 0), or 0 when
330+
# the stream's send side closed (peer response complete, stream reset, request
331+
# canceled, or stream unregistered) and the caller must stop sending.
289332
function _reserve_send_window!(conn::H2Connection, stream_id::UInt32, wanted::Int, deadline_ns::Int64)::Int
290333
wanted > 0 || throw(ArgumentError("wanted send window must be > 0"))
291334
lock(conn.state_lock)
292335
try
293336
while true
294337
(@atomic :acquire conn.closed) && throw(ProtocolError("HTTP/2 connection is closed"))
295338
conn.conn_error === nothing || throw(conn.conn_error::ProtocolError)
339+
stream_id in conn.send_closed_streams && return 0
340+
haskey(conn.stream_send_window, stream_id) || return 0
296341
stream_window = get(() -> Int64(0), conn.stream_send_window, stream_id)
297342
if stream_window <= 0 || conn.conn_send_window <= 0
298343
# Both the connection-level and per-stream windows must allow
@@ -310,21 +355,24 @@ function _reserve_send_window!(conn::H2Connection, stream_id::UInt32, wanted::In
310355
end
311356
end
312357

313-
function _write_data_frames_h2!(conn::H2Connection, stream_id::UInt32, request::Request, data::Vector{UInt8}, end_stream::Bool)
314-
isempty(data) && return nothing
358+
# Returns `true` when all of `data` was written, `false` when the stream's send
359+
# side closed mid-body and the remaining payload must be abandoned.
360+
function _write_data_frames_h2!(conn::H2Connection, stream_id::UInt32, request::Request, data::Vector{UInt8}, end_stream::Bool)::Bool
361+
isempty(data) && return true
315362
offset = 1
316363
total_len = length(data)
317364
while offset <= total_len
318365
remaining = total_len - offset + 1
319366
write_deadline_ns = _request_write_deadline_ns(request)
320367
chunk_len = _reserve_send_window!(conn, stream_id, remaining, write_deadline_ns)
368+
chunk_len == 0 && return false
321369
chunk = Vector{UInt8}(undef, chunk_len)
322370
copyto!(chunk, 1, data, offset, chunk_len)
323371
final_chunk = (offset + chunk_len - 1) == total_len
324372
_write_frame_h2_threadsafe!(conn, DataFrame(stream_id, end_stream && final_chunk, chunk), write_deadline_ns)
325373
offset += chunk_len
326374
end
327-
return nothing
375+
return true
328376
end
329377

330378
function _apply_peer_settings!(conn::H2Connection, settings::Vector{Pair{UInt16,UInt32}})
@@ -646,6 +694,7 @@ function _unregister_stream!(conn::H2Connection, stream_id::UInt32)
646694
try
647695
pop!(conn.streams, stream_id, nothing)
648696
pop!(conn.stream_send_window, stream_id, nothing)
697+
delete!(conn.send_closed_streams, stream_id)
649698
notify(conn.stream_condition; all=true)
650699
finally
651700
unlock(conn.state_lock)
@@ -871,7 +920,21 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
871920
rst = frame::RSTStreamFrame
872921
state = _stream_state(conn, rst.stream_id)
873922
state === nothing && return nothing
874-
_set_stream_error!(state::H2StreamState, H2StreamResetError(rst.error_code))
923+
# RFC 9113 §8.1: a server MAY send RST_STREAM with NO_ERROR after a
924+
# complete response to ask the client to abort the request body;
925+
# "clients MUST NOT discard responses as a result". Only surface the
926+
# reset as a stream error when it does not follow a complete response.
927+
benign = false
928+
lock((state::H2StreamState).lock)
929+
try
930+
benign = rst.error_code == UInt32(0) && (state::H2StreamState).stream_done
931+
finally
932+
unlock((state::H2StreamState).lock)
933+
end
934+
benign || _set_stream_error!(state::H2StreamState, H2StreamResetError(rst.error_code))
935+
# Either way the peer will grant no more send window: wake any body
936+
# writer parked on flow control so it stops uploading.
937+
_mark_h2_send_closed!(conn, rst.stream_id)
875938
return nothing
876939
elseif frame isa WindowUpdateFrame
877940
update = frame::WindowUpdateFrame
@@ -900,6 +963,10 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
900963
_consume_orphan_header_fragment!(conn, headers.stream_id, headers.header_block_fragment, headers.end_headers)
901964
return nothing
902965
end
966+
# A response bearing END_STREAM means the peer is done with the stream
967+
# and will grant no more send window; wake any body writer parked on
968+
# flow control so the roundtrip can return this early response.
969+
headers.end_stream && _mark_h2_send_closed!(conn, headers.stream_id)
903970
_handle_stream_header_fragment!(conn, state::H2StreamState, headers.header_block_fragment, headers.end_headers, headers.end_stream)
904971
# Track an open header block so a missing END_HEADERS forces CONTINUATION
905972
# (RFC 7540 6.10) and the trailing frames still reach this stream even if
@@ -938,6 +1005,9 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
9381005
data = frame::DataFrame
9391006
state = _stream_state(conn, data.stream_id)
9401007
state === nothing && return nothing
1008+
# Mark before `_handle_stream_data!`, which can block while the
1009+
# buffered body is drained.
1010+
data.end_stream && _mark_h2_send_closed!(conn, data.stream_id)
9411011
_handle_stream_data!(state::H2StreamState, data)
9421012
return nothing
9431013
end
@@ -1114,6 +1184,7 @@ function _connect_h2_from_tcp!(
11141184
Int64(65_535),
11151185
Int64(65_535),
11161186
Dict{UInt32,Int64}(),
1187+
Set{UInt32}(),
11171188
typemax(Int),
11181189
0,
11191190
typemax(UInt32),
@@ -1226,8 +1297,13 @@ function Base.close(conn::H2Connection)
12261297
return nothing
12271298
end
12281299

1229-
function _write_request_body_h2!(conn::H2Connection, stream_id::UInt32, request::Request)
1230-
request.body isa EmptyBody && return nothing
1300+
# Returns `true` when the full body (and END_STREAM) was sent, `false` when the
1301+
# upload was abandoned because the stream's send side closed first — e.g. the
1302+
# peer answered with a complete response before reading the whole request body
1303+
# (RFC 9113 §8.1), reset the stream, or the request was canceled. The caller
1304+
# decides how to close out the abandoned stream.
1305+
function _write_request_body_h2!(conn::H2Connection, stream_id::UInt32, request::Request)::Bool
1306+
request.body isa EmptyBody && return true
12311307
buf = Vector{UInt8}(undef, 16 * 1024)
12321308
pending = UInt8[]
12331309
have_pending = false
@@ -1236,16 +1312,16 @@ function _write_request_body_h2!(conn::H2Connection, stream_id::UInt32, request:
12361312
n = body_read!(request.body, buf)
12371313
if n == 0
12381314
if have_pending
1239-
_write_data_frames_h2!(conn, stream_id, request, pending, true)
1240-
else
1241-
_write_frame_h2_threadsafe!(conn, DataFrame(stream_id, true, UInt8[]), _request_write_deadline_ns(request))
1315+
return _write_data_frames_h2!(conn, stream_id, request, pending, true)
12421316
end
1243-
return nothing
1317+
_h2_send_closed(conn, stream_id) && return false
1318+
_write_frame_h2_threadsafe!(conn, DataFrame(stream_id, true, UInt8[]), _request_write_deadline_ns(request))
1319+
return true
12441320
end
12451321
current = Vector{UInt8}(undef, n)
12461322
copyto!(current, 1, buf, 1, n)
12471323
if have_pending
1248-
_write_data_frames_h2!(conn, stream_id, request, pending, false)
1324+
_write_data_frames_h2!(conn, stream_id, request, pending, false) || return false
12491325
end
12501326
pending = current
12511327
have_pending = true
@@ -1302,6 +1378,14 @@ function _request_headers_for_h2(address::String, request::Request, secure::Bool
13021378
push!(fields, HeaderField(lowered, normalized, false))
13031379
end
13041380
end
1381+
# HTTP/2 carries no Transfer-Encoding; DATA framing alone delimits the
1382+
# body. Still advertise `content-length` when the body size is known
1383+
# (RFC 9113 §8.1.1), matching the HTTP/1.1 client: intermediaries that
1384+
# translate to an HTTP/1.1 origin otherwise forward the body without a
1385+
# declared length, and origins commonly treat that as an empty body.
1386+
if request.content_length >= 0 && !(request.body isa EmptyBody) && !hasheader(request.headers, "content-length")
1387+
push!(fields, HeaderField("content-length", string(request.content_length), false))
1388+
end
13051389
return fields
13061390
end
13071391

@@ -1606,6 +1690,9 @@ function _h2_roundtrip_incoming!(
16061690
cancel_cb = let conn = conn, stream_state = stream_state, request_ctx = request_ctx
16071691
() -> begin
16081692
_set_stream_error!(stream_state, _canceled_error(request_ctx))
1693+
# Stop a body writer parked on flow control; cancellation means no
1694+
# more request DATA may be sent on this stream.
1695+
_mark_h2_send_closed!(conn, stream_state.stream_id)
16091696
@try_ignore _write_frame_h2_threadsafe!(conn, RSTStreamFrame(stream_state.stream_id, UInt32(0x8)))
16101697
lock(conn.state_lock)
16111698
try
@@ -1625,7 +1712,19 @@ function _h2_roundtrip_incoming!(
16251712
end
16261713
try
16271714
end_stream = request.body isa EmptyBody
1628-
end_stream || _write_request_body_h2!(conn, stream_state.stream_id, request)
1715+
if !end_stream && !_write_request_body_h2!(conn, stream_state.stream_id, request)
1716+
# The upload was abandoned because the peer finished with the
1717+
# stream first. When that was a benign early complete response
1718+
# (no stream error), actively cancel our half of the stream
1719+
# with NO_ERROR so the peer can release its stream state; a
1720+
# peer-initiated reset or cancellation needs no reply.
1721+
send_rst = lock(stream_state.lock) do
1722+
stream_state.stream_error === nothing && !stream_state.conn_errored
1723+
end
1724+
if send_rst
1725+
@try_ignore _write_frame_h2_threadsafe!(conn, RSTStreamFrame(stream_state.stream_id, UInt32(0x0)))
1726+
end
1727+
end
16291728
catch err
16301729
_fail_h2_connection!(conn, ProtocolError("HTTP/2 write failed", err::Exception))
16311730
rethrow()

0 commit comments

Comments
 (0)