Skip to content

Commit 8e9b80a

Browse files
authored
Surface HTTP/2 response resets after buffered data (#1343)
* fix(http2): surface partial response resets Preserve buffered response DATA after RST_STREAM, then raise the terminal stream error instead of reporting clean EOF. Close and unregister the body when the error surfaces. Cover partial bodies across small and compaction-boundary payload sizes. * fix(http2): preserve completed response after reset Ignore a late stream reset after END_STREAM completed the response normally. Add a synchronized wire-level regression that proves a non-NO_ERROR reset cannot discard the buffered complete body. * fix(http2): keep completed streams after conn error A connection failure caused by another multiplexed stream must not invalidate a response that already received END_STREAM. Remove the impossible incomplete-response branch and cover the completed-stream ownership case with a raw two-stream peer. * test(http2): verify GOAWAY error details Assert the terminal GOAWAY error retains the peer last-stream identifier and renders its user-facing message. This pins the typed error contract used by response lifecycle failures.
1 parent 90c2359 commit 8e9b80a

2 files changed

Lines changed: 228 additions & 5 deletions

File tree

src/http2_client.jl

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -977,12 +977,13 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
977977
state === nothing && return nothing
978978
# RFC 9113 §8.1: a server MAY send RST_STREAM with NO_ERROR after a
979979
# complete response to ask the client to abort the request body;
980-
# "clients MUST NOT discard responses as a result". Only surface the
981-
# reset as a stream error when it does not follow a complete response.
980+
# clients must not discard the response. A reset with another code can
981+
# also race with the final response frame. Once END_STREAM completed
982+
# the response normally, keep it regardless of the later reset code.
982983
benign = false
983984
lock((state::H2StreamState).lock)
984985
try
985-
benign = rst.error_code == UInt32(0) && (state::H2StreamState).stream_done
986+
benign = (state::H2StreamState).stream_done && !_stream_failed(state::H2StreamState)
986987
finally
987988
unlock((state::H2StreamState).lock)
988989
end
@@ -1598,6 +1599,7 @@ function body_read!(body::H2Body, dst::Vector{UInt8})::Int
15981599
nread = 0
15991600
done = false
16001601
too_many = false
1602+
terminal_error::Union{Nothing,Exception} = nothing
16011603
wait_deadline_ns = Int64(0)
16021604
lock(body.state.lock)
16031605
try
@@ -1618,8 +1620,16 @@ function body_read!(body::H2Body, dst::Vector{UInt8})::Int
16181620
notify(body.state.condition)
16191621
end
16201622
elseif body.state.stream_done
1621-
_publish_h2_response_trailers!(body.state)
1622-
done = true
1623+
# DATA buffered before a stream reset is still readable, but
1624+
# once it is drained the terminal stream error must surface.
1625+
# Otherwise a partial response without Content-Length looks
1626+
# like a successful clean EOF.
1627+
if body.state.stream_error !== nothing
1628+
terminal_error = body.state.stream_error
1629+
else
1630+
_publish_h2_response_trailers!(body.state)
1631+
done = true
1632+
end
16231633
else
16241634
_throw_stream_error(body.conn, body.state)
16251635
deadline_ns = _request_read_deadline_ns(body.request)
@@ -1633,6 +1643,12 @@ function body_read!(body::H2Body, dst::Vector{UInt8})::Int
16331643
unlock(body.state.lock)
16341644
end
16351645
wait_deadline_ns == 0 || (_wait_h2_body_progress!(body.state, wait_deadline_ns); continue)
1646+
if terminal_error !== nothing
1647+
@atomic :release body.closed = true
1648+
_clear_h2_cancel_callback!(body)
1649+
_unregister_stream!(body.conn, body.stream_id)
1650+
throw(terminal_error::Exception)
1651+
end
16361652
if too_many
16371653
@atomic :release body.closed = true
16381654
@try_ignore _write_frame_h2_threadsafe!(body.conn, RSTStreamFrame(body.stream_id, UInt32(0x1)))

test/http2_client_tests.jl

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,210 @@ end
269269
end
270270
end
271271

272+
@testset "HTTP/2 client surfaces a reset after buffered response DATA" begin
273+
for payload in ("x", "partial", repeat("z", 4097))
274+
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
275+
laddr = NC.addr(listener)::NC.SocketAddrV4
276+
address = ND.join_host_port("127.0.0.1", Int(laddr.port))
277+
allow_reset = Channel{Nothing}(1)
278+
finish = Channel{Nothing}(1)
279+
server_task = errormonitor(Threads.@spawn begin
280+
accepted_conn = NC.accept(listener)
281+
reader = HT._ConnReader(accepted_conn)
282+
server_encoder = HT.Encoder()
283+
server_decoder = HT.Decoder()
284+
try
285+
_ = _read_exact_h2_tcp!(accepted_conn, length(HT._H2_PREFACE))
286+
_ = HT.read_frame!(reader)
287+
_write_frame_to_conn!(accepted_conn, HT.SettingsFrame(false, Pair{UInt16, UInt32}[]))
288+
_ = HT.read_frame!(reader)
289+
headers_frame = _read_next_headers_frame!(reader)
290+
hf = headers_frame::HT.HeadersFrame
291+
_ = HT.decode_header_block(server_decoder, hf.header_block_fragment)
292+
encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)])
293+
_write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, false, true, encoded))
294+
_write_frame_to_conn!(accepted_conn, HT.DataFrame(hf.stream_id, false, collect(codeunits(payload))))
295+
take!(allow_reset)
296+
_write_frame_to_conn!(accepted_conn, HT.RSTStreamFrame(hf.stream_id, UInt32(0x8)))
297+
take!(finish)
298+
finally
299+
HTTP.@try_ignore NC.close(accepted_conn)
300+
end
301+
return nothing
302+
end)
303+
h2_conn = HT.connect_h2!(address; secure = false)
304+
try
305+
request = HT.Request("GET", "/partial-reset"; host = address, body = HT.EmptyBody(), content_length = 0)
306+
response = HT.h2_roundtrip!(h2_conn, request)
307+
put!(allow_reset, nothing)
308+
state = (response.body::HT.H2Body).state
309+
reset_received = timedwait(() -> begin
310+
lock(state.lock)
311+
try
312+
return state.stream_error !== nothing
313+
finally
314+
unlock(state.lock)
315+
end
316+
end, 5.0; pollint = 0.001)
317+
@test reset_received != :timed_out
318+
319+
buf = Vector{UInt8}(undef, length(payload))
320+
@test HT.body_read!(response.body, buf) == length(payload)
321+
@test String(copy(buf)) == payload
322+
err = try
323+
HT.body_read!(response.body, buf)
324+
nothing
325+
catch e
326+
e
327+
end
328+
@test err isa HT.H2StreamResetError
329+
@test (err::HT.H2StreamResetError).error_code == UInt32(0x8)
330+
@test HT.body_closed(response.body)
331+
@test HT._stream_state(h2_conn, (response.body::HT.H2Body).stream_id) === nothing
332+
@test isempty(HT.get_request_context(request).cancel_callbacks)
333+
@test HT._h2_conn_reusable(h2_conn)
334+
finally
335+
isready(allow_reset) || put!(allow_reset, nothing)
336+
put!(finish, nothing)
337+
_wait_task_h2!(server_task)
338+
close(h2_conn)
339+
HTTP.@try_ignore NC.close(listener)
340+
end
341+
end
342+
end
343+
344+
@testset "HTTP/2 client keeps a complete response after a late reset" begin
345+
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
346+
laddr = NC.addr(listener)::NC.SocketAddrV4
347+
address = ND.join_host_port("127.0.0.1", Int(laddr.port))
348+
reset_processed = Channel{Nothing}(1)
349+
finish = Channel{Nothing}(1)
350+
server_task = errormonitor(Threads.@spawn begin
351+
accepted_conn = NC.accept(listener)
352+
reader = HT._ConnReader(accepted_conn)
353+
server_encoder = HT.Encoder()
354+
server_decoder = HT.Decoder()
355+
try
356+
_ = _read_exact_h2_tcp!(accepted_conn, length(HT._H2_PREFACE))
357+
_ = HT.read_frame!(reader)
358+
_write_frame_to_conn!(accepted_conn, HT.SettingsFrame(false, Pair{UInt16, UInt32}[]))
359+
_ = HT.read_frame!(reader)
360+
headers_frame = _read_next_headers_frame!(reader)
361+
hf = headers_frame::HT.HeadersFrame
362+
_ = HT.decode_header_block(server_decoder, hf.header_block_fragment)
363+
encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)])
364+
_write_frame_to_conn!(accepted_conn, HT.HeadersFrame(hf.stream_id, false, true, encoded))
365+
_write_frame_to_conn!(accepted_conn, HT.DataFrame(hf.stream_id, true, collect(codeunits("complete"))))
366+
_write_frame_to_conn!(accepted_conn, HT.RSTStreamFrame(hf.stream_id, UInt32(0x8)))
367+
ping_data = ntuple(UInt8, 8)
368+
_write_frame_to_conn!(accepted_conn, HT.PingFrame(false, ping_data))
369+
while true
370+
frame = HT.read_frame!(reader)
371+
frame isa HT.PingFrame && (frame::HT.PingFrame).ack && break
372+
end
373+
put!(reset_processed, nothing)
374+
take!(finish)
375+
finally
376+
HTTP.@try_ignore NC.close(accepted_conn)
377+
end
378+
return nothing
379+
end)
380+
h2_conn = HT.connect_h2!(address; secure = false)
381+
try
382+
request = HT.Request("GET", "/complete-late-reset"; host = address, body = HT.EmptyBody(), content_length = 0)
383+
response = HT.h2_roundtrip!(h2_conn, request)
384+
reset_seen = timedwait(() -> isready(reset_processed), 5.0; pollint = 0.001)
385+
@test reset_seen != :timed_out
386+
reset_seen == :timed_out && error("client did not process the late reset")
387+
take!(reset_processed)
388+
state = (response.body::HT.H2Body).state
389+
response_complete = timedwait(() -> begin
390+
lock(state.lock)
391+
try
392+
return state.stream_done
393+
finally
394+
unlock(state.lock)
395+
end
396+
end, 5.0; pollint = 0.001)
397+
@test response_complete != :timed_out
398+
@test String(_read_all_h2_body(response.body)) == "complete"
399+
@test HT.body_closed(response.body)
400+
@test HT._stream_state(h2_conn, (response.body::HT.H2Body).stream_id) === nothing
401+
@test isempty(HT.get_request_context(request).cancel_callbacks)
402+
@test HT._h2_conn_reusable(h2_conn)
403+
finally
404+
put!(finish, nothing)
405+
_wait_task_h2!(server_task)
406+
close(h2_conn)
407+
HTTP.@try_ignore NC.close(listener)
408+
end
409+
end
410+
411+
@testset "HTTP/2 client keeps a complete response when another stream fails with the connection" begin
412+
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
413+
laddr = NC.addr(listener)::NC.SocketAddrV4
414+
address = ND.join_host_port("127.0.0.1", Int(laddr.port))
415+
allow_close = Channel{Nothing}(1)
416+
server_task = errormonitor(Threads.@spawn begin
417+
accepted_conn = NC.accept(listener)
418+
reader = HT._ConnReader(accepted_conn)
419+
server_encoder = HT.Encoder()
420+
server_decoder = HT.Decoder()
421+
try
422+
_ = _read_exact_h2_tcp!(accepted_conn, length(HT._H2_PREFACE))
423+
_ = HT.read_frame!(reader)
424+
_write_frame_to_conn!(accepted_conn, HT.SettingsFrame(false, Pair{UInt16, UInt32}[]))
425+
_ = HT.read_frame!(reader)
426+
first_headers_frame = _read_next_headers_frame!(reader)
427+
first_hf = first_headers_frame::HT.HeadersFrame
428+
_ = HT.decode_header_block(server_decoder, first_hf.header_block_fragment)
429+
encoded = HT.encode_header_block(server_encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)])
430+
_write_frame_to_conn!(accepted_conn, HT.HeadersFrame(first_hf.stream_id, false, true, encoded))
431+
_write_frame_to_conn!(accepted_conn, HT.DataFrame(first_hf.stream_id, true, collect(codeunits("complete"))))
432+
second_headers_frame = _read_next_headers_frame!(reader)
433+
second_hf = second_headers_frame::HT.HeadersFrame
434+
_ = HT.decode_header_block(server_decoder, second_hf.header_block_fragment)
435+
_write_frame_to_conn!(accepted_conn, HT.HeadersFrame(second_hf.stream_id, false, true, encoded))
436+
take!(allow_close)
437+
finally
438+
HTTP.@try_ignore NC.close(accepted_conn)
439+
end
440+
return nothing
441+
end)
442+
h2_conn = HT.connect_h2!(address; secure = false)
443+
try
444+
complete_request = HT.Request("GET", "/complete"; host = address, body = HT.EmptyBody(), content_length = 0)
445+
complete_response = HT.h2_roundtrip!(h2_conn, complete_request)
446+
incomplete_request = HT.Request("GET", "/incomplete"; host = address, body = HT.EmptyBody(), content_length = 0)
447+
incomplete_response = HT.h2_roundtrip!(h2_conn, incomplete_request)
448+
put!(allow_close, nothing)
449+
_wait_task_h2!(server_task)
450+
complete_state = (complete_response.body::HT.H2Body).state
451+
incomplete_state = (incomplete_response.body::HT.H2Body).state
452+
failure_seen = timedwait(() -> begin
453+
complete_failed = lock(complete_state.lock) do
454+
complete_state.conn_errored
455+
end
456+
return lock(incomplete_state.lock) do
457+
complete_failed && incomplete_state.conn_errored
458+
end
459+
end, 5.0; pollint = 0.001)
460+
@test failure_seen != :timed_out
461+
@test String(_read_all_h2_body(complete_response.body)) == "complete"
462+
@test_throws HT.ProtocolError HT.body_read!(incomplete_response.body, Vector{UInt8}(undef, 1))
463+
HT.body_close!(incomplete_response.body)
464+
@test HT.body_closed(complete_response.body)
465+
@test HT._stream_state(h2_conn, (complete_response.body::HT.H2Body).stream_id) === nothing
466+
@test isempty(HT.get_request_context(complete_request).cancel_callbacks)
467+
@test !HT._h2_conn_reusable(h2_conn)
468+
finally
469+
isready(allow_close) || put!(allow_close, nothing)
470+
_wait_task_h2!(server_task)
471+
close(h2_conn)
472+
HTTP.@try_ignore NC.close(listener)
473+
end
474+
end
475+
272476
@testset "HTTP/2 client requires initial SETTINGS before other frames" begin
273477
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
274478
laddr = NC.addr(listener)::NC.SocketAddrV4
@@ -1740,6 +1944,9 @@ end
17401944
@test String(_read_all_h2_body(response.body)) == "ok"
17411945
failure = only(failures)::TaskFailedException
17421946
@test failure.task.exception isa HT.H2GoAwayError
1947+
goaway_error = failure.task.exception::HT.H2GoAwayError
1948+
@test goaway_error.last_stream_id == UInt32(1)
1949+
@test sprint(showerror, goaway_error) == "HTTP/2 stream rejected by GOAWAY"
17431950
_wait_task_h2!(server_task)
17441951
finally
17451952
close(h2_conn)

0 commit comments

Comments
 (0)