Skip to content

Commit 312e2b6

Browse files
authored
fix(client): :http streaming stalls small chunks until 8KB buffer fills (#102)
The :http backend's streaming read loop fills a fixed 8KB buffer with `readbytes!(io, buf)`. On HTTP.jl 2.x, `readbytes!` on a response body blocks until the whole buffer is filled (or the body ends), so a streamed chunk smaller than 8KB is not forwarded to the consumer until enough later data accumulates. On a quiet long-lived stream — e.g. a Kubernetes watch, where a single event is a few KB and the next event may be minutes away — every event was delivered one flush behind the action that caused it, effectively an indefinite stall. Read with `readavailable` instead: `eof(io)` blocks until data is available, and `readavailable` then returns whatever has arrived without further blocking, on both HTTP.jl 1.x and 2.x (the same pattern HTTP.jl's own `download` uses). The `readbytes!` loop was itself a workaround for `read(io, n)` being unavailable on 2.x streams. Found with a watch reaction-time probe (Kuber.jl) against a live k3s cluster: with the :http backend, MODIFIED/DELETED watch events took 30s+ — delivered only when later events flushed the buffer — while `curl -N` through the same `kubectl proxy` delivered every event in under 100ms, and the :downloads backend reacted in ~5-10ms. With the fix, :http reacts in milliseconds as well. Adds a regression test: a raw-TCP chunked HTTP/1.1 server sends one small chunk, stalls, then sends another; the first chunk must reach the consumer during the stall. Unfixed code fails this on HTTP.jl 2.x (the first chunk arrives only together with the second); HTTP.jl 1.x and the :downloads backend already delivered promptly and keep passing.
1 parent 0270461 commit 312e2b6

4 files changed

Lines changed: 127 additions & 8 deletions

File tree

Project.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"]
44
license = "MIT"
55
desc = "OpenAPI server and client helper for Julia"
66
authors = ["JuliaHub Inc."]
7-
version = "0.2.6"
7+
version = "0.2.7"
88

99
[deps]
1010
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
@@ -35,9 +35,10 @@ p7zip_jll = "17"
3535
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
3636
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
3737
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
38+
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
3839
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
3940
TimeZones = "f269a46b-ccf7-5d73-abea-4c690281aa53"
4041
URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
4142

4243
[targets]
43-
test = ["Test", "Random", "URIs", "HTTP", "Dates", "TimeZones"]
44+
test = ["Test", "Random", "URIs", "HTTP", "Dates", "TimeZones", "Sockets"]

src/client/httplibs/juliaweb_http.jl

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,17 @@ function _http_streaming_request(ctx, method, url, headers, body, timeout, bytes
317317
write(io, body)
318318
captured_response[] = http_response = startread(io)
319319
try
320-
# `read(io, n)` is unavailable on 2.0 streams; read into a reusable
321-
# buffer with `readbytes!`, which works on both 1.x and 2.x.
322-
buf = Vector{UInt8}(undef, 8192) # 8KB chunks
320+
# `readavailable`, not `readbytes!`: on both 1.x and 2.x streams
321+
# `readbytes!(io, buf)` blocks until the whole buffer is filled
322+
# (or the body ends), so a response chunk smaller than the buffer
323+
# (e.g. a single Kubernetes watch event) is not forwarded until
324+
# enough later data accumulates — an indefinite stall on quiet
325+
# streams. `eof` blocks until data is available; `readavailable`
326+
# then returns whatever has arrived without further blocking.
323327
while !eof(io)
324-
n = readbytes!(io, buf)
325-
n == 0 && break
326-
bytesread[] += write(output, view(buf, 1:n))
328+
bytes = readavailable(io)
329+
isempty(bytes) && continue
330+
bytesread[] += write(output, bytes)
327331
end
328332
finally
329333
close(output)

test/runtests.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ using Test, HTTP
33
import OpenAPI
44

55
include("chunkreader_tests.jl")
6+
include("streaming_latency_tests.jl")
67
include("testutils.jl")
78
include("modelgen/testmodelgen.jl")
89
include("client/runtests.jl")
@@ -19,6 +20,9 @@ include("deep_object/deep_client.jl")
1920
@testset "Chunk Readers" begin
2021
ChunkReaderTests.runtests()
2122
end
23+
@testset "Streaming Latency" begin
24+
StreamingLatencyTests.runtests()
25+
end
2226
@testset "Petstore Client" begin
2327
try
2428
if run_tests_with_servers && !openapi_generator_env

test/streaming_latency_tests.jl

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
module StreamingLatencyTests
2+
3+
using Test
4+
using Sockets
5+
6+
import OpenAPI
7+
import OpenAPI.Clients: Client, Ctx, exec, LineChunkReader
8+
9+
# Streamed chunks must be forwarded to the consumer as soon as they arrive,
10+
# not held back until an internal read buffer fills. Regression test for the
11+
# :http backend stalling small chunks: `readbytes!(io, buf)` blocks until the
12+
# whole 8KB buffer is filled, so a chunk smaller than that (e.g. a Kubernetes
13+
# watch event) was only delivered once enough later data accumulated — on a
14+
# quiet stream, effectively never.
15+
#
16+
# The server is a minimal raw-TCP chunked HTTP/1.1 responder so that the test
17+
# controls exactly when bytes hit the wire, independent of HTTP.jl's server
18+
# API (which differs between 1.x and 2.x). Two endpoints:
19+
# /quick - both chunks back to back (used to warm up/compile the client path)
20+
# /stall - first chunk immediately, second only after `delay` seconds
21+
22+
# JSON string literals: in the streaming path the client parses each chunk as
23+
# JSON (no response object to take a content-type from), so a quoted string
24+
# round-trips to a `String` return value.
25+
const CHUNK1 = "\"tick-1\"\n"
26+
const CHUNK2 = "\"tick-2\"\n"
27+
28+
function write_chunk(sock, data)
29+
write(sock, string(sizeof(data), base=16), "\r\n", data, "\r\n")
30+
flush(sock)
31+
end
32+
33+
function handle_connection(sock, delay)
34+
try
35+
request_line = readline(sock)
36+
path = split(request_line, ' ')[2]
37+
while true
38+
line = readline(sock)
39+
(isempty(line) || line == "\r") && break
40+
end
41+
# `connection: close` so the client does not try to reuse the socket,
42+
# which this bare-bones server closes after each response
43+
write(sock, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ntransfer-encoding: chunked\r\n\r\n")
44+
flush(sock)
45+
write_chunk(sock, CHUNK1)
46+
endswith(path, "/stall") && sleep(delay)
47+
write_chunk(sock, CHUNK2)
48+
write(sock, "0\r\n\r\n")
49+
flush(sock)
50+
catch
51+
# client went away; nothing to do
52+
finally
53+
close(sock)
54+
end
55+
end
56+
57+
function start_server(delay)
58+
listener = Sockets.listen(Sockets.localhost, 0)
59+
_, port = Sockets.getsockname(listener)
60+
@async begin
61+
while true
62+
sock = try
63+
accept(listener)
64+
catch
65+
break # listener closed
66+
end
67+
@async handle_connection(sock, delay)
68+
end
69+
end
70+
listener, Int(port)
71+
end
72+
73+
# Run a streaming GET and return (first chunk, seconds until it arrived,
74+
# remaining chunks).
75+
function first_chunk_latency(httplib, port, path)
76+
client = Client("http://127.0.0.1:$port"; httplib=httplib)
77+
return_types = Dict{Regex,Type}(Regex("^200\$") => String)
78+
ctx = Ctx(client, "GET", return_types, path, String[]; chunk_reader_type=LineChunkReader)
79+
events = Channel{Any}(64)
80+
t0 = time()
81+
task = @async exec(ctx, events)
82+
first = take!(events)
83+
latency = time() - t0
84+
remaining = collect(events)
85+
wait(task)
86+
(first, latency, remaining)
87+
end
88+
89+
function runtests()
90+
delay = 4.0
91+
listener, port = start_server(delay)
92+
try
93+
@testset "first chunk not delayed until buffer fills ($httplib)" for httplib in values(OpenAPI.Clients.HTTPLib)
94+
# warm up: compile the full streaming request path so the timing
95+
# below measures I/O behavior, not JIT
96+
first_chunk_latency(httplib, port, "/quick")
97+
98+
first, latency, remaining = first_chunk_latency(httplib, port, "/stall")
99+
@test first == "tick-1"
100+
# the first chunk must arrive while the server is still stalling
101+
# before the second chunk, not when the response completes
102+
@test latency < delay / 2
103+
@test remaining == ["tick-2"]
104+
end
105+
finally
106+
close(listener)
107+
end
108+
end
109+
110+
end # module StreamingLatencyTests

0 commit comments

Comments
 (0)