Skip to content

Commit 611ab59

Browse files
committed
fix(http2): stream abstract bodies incrementally
Write each body chunk as soon as body_read! returns it. Finish body-only responses with an empty END_STREAM DATA frame so trailers keep their existing terminal HEADERS behavior. This prevents long-lived SSE responses, including MCP subscription streams, from holding the first event until the next event or EOF. Add a real HTTP/2 regression test and keep the HTTP/1 test synchronized without atomics.
1 parent e162b7d commit 611ab59

3 files changed

Lines changed: 45 additions & 17 deletions

File tree

src/http2_server.jl

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,26 +1158,22 @@ function _write_response_body_h2_server!(
11581158
end
11591159
body isa AbstractBody || throw(ProtocolError("unsupported HTTP/2 response body type $(typeof(body))"))
11601160
buf = Vector{UInt8}(undef, 16 * 1024)
1161-
pending = UInt8[]
1162-
have_pending = false
11631161
try
11641162
while true
11651163
n = body_read!(body::AbstractBody, buf)
11661164
if n == 0
1167-
if have_pending
1168-
_write_data_frames_h2_server!(conn, write_lock, send_state, stream_id, pending; end_stream=end_stream, write_deadline_ns=write_deadline_ns)
1169-
elseif end_stream
1170-
_write_frame_h2_server_threadsafe!(write_lock, conn, DataFrame(stream_id, true, UInt8[]), write_deadline_ns)
1171-
end
1165+
end_stream && _write_frame_h2_server_threadsafe!(write_lock, conn, DataFrame(stream_id, true, UInt8[]), write_deadline_ns)
11721166
return nothing
11731167
end
1174-
current = Vector{UInt8}(undef, n)
1175-
copyto!(current, 1, buf, 1, n)
1176-
if have_pending
1177-
_write_data_frames_h2_server!(conn, write_lock, send_state, stream_id, pending; end_stream=false, write_deadline_ns=write_deadline_ns)
1178-
end
1179-
pending = current
1180-
have_pending = true
1168+
_write_data_frames_h2_server!(
1169+
conn,
1170+
write_lock,
1171+
send_state,
1172+
stream_id,
1173+
@view(buf[1:n]);
1174+
end_stream=false,
1175+
write_deadline_ns=write_deadline_ns,
1176+
)
11811177
end
11821178
finally
11831179
@try_ignore begin

test/http2_server_tests.jl

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,38 @@ end
237237
end
238238
end
239239

240+
@testset "HTTP/2 server SSE events stream incrementally" begin
241+
# The producer only writes the second event after the client receives the
242+
# first. Holding one AbstractBody chunk until the next read or EOF makes the
243+
# producer emit the sentinel instead and fails without a latency assertion.
244+
saw_first = Channel{Nothing}(1)
245+
server = HT.serve!("127.0.0.1", 0; listenany = true) do request
246+
_ = request
247+
return HT.sse_stream(200) do stream
248+
write(stream, HT.SSEEvent("one"))
249+
delivered = timedwait(() -> isready(saw_first), 5.0; pollint = 0.01)
250+
write(stream, HT.SSEEvent(delivered === :ok ? "two" : "first event was not delivered before close"))
251+
end
252+
end
253+
address = HT.server_addr(server)
254+
conn = HT.connect_h2!(address; secure = false)
255+
try
256+
request = HT.Request("GET", "/"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0)
257+
response = HT.h2_roundtrip!(conn, request)
258+
first_buffer = Vector{UInt8}(undef, 64)
259+
first_count = HT.body_read!(response.body, first_buffer)
260+
first_event = String(first_buffer[1:first_count])
261+
first_event == "data: one\n\n" && put!(saw_first, nothing)
262+
remaining = String(_read_all_h2_server(response.body))
263+
@test response.status == 200
264+
@test first_event * remaining == "data: one\n\ndata: two\n\n"
265+
finally
266+
close(conn)
267+
HT.forceclose(server)
268+
_ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001)
269+
end
270+
end
271+
240272
@testset "HTTP/2 server writes unread BytesBody response bytes directly" begin
241273
payload = collect(codeunits("abcdef"))
242274
returned_body = Ref{Union{Nothing,HT.BytesBody}}(nothing)

test/http_server_http1_tests.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,12 @@ end
295295
# callback has observed the first; if the transport buffers until close,
296296
# the producer times out and writes a sentinel instead, failing the test
297297
# deterministically without wall-clock assertions.
298-
saw_first = Threads.Atomic{Bool}(false)
298+
saw_first = Channel{Nothing}(1)
299299
server = HT.serve!("127.0.0.1", 0; listenany = true) do request
300300
_ = request
301301
return HT.sse_stream(200) do stream
302302
write(stream, HT.SSEEvent("one"))
303-
delivered = timedwait(() -> saw_first[], 5.0; pollint = 0.01)
303+
delivered = timedwait(() -> isready(saw_first), 5.0; pollint = 0.01)
304304
write(stream, HT.SSEEvent(delivered === :ok ? "two" : "first event was not delivered before close"))
305305
end
306306
end
@@ -309,7 +309,7 @@ end
309309
events = String[]
310310
response = HT.get("http://$(address)/"; sse_callback = event -> begin
311311
push!(events, event.data)
312-
event.data == "one" && (saw_first[] = true)
312+
event.data == "one" && put!(saw_first, nothing)
313313
end)
314314
@test response.status == 200
315315
@test events == ["one", "two"]

0 commit comments

Comments
 (0)