Skip to content

Commit 3f9bf09

Browse files
krynjuclaude
andauthored
fix(client): discard truncated JSON document on mid-stream EOF (#97)
JSONChunkReader's `_read_json_chunk` brace-counts one JSON document off the response BufferStream, but its `while !eof(io)` loop also terminates on end-of-stream. When a streaming response closed mid-document (a dropped or cancelled connection), the partial bytes were handed to the JSON parser and threw `Unexpected end of input`, surfacing as a logged "exception reading chunk" error in the streaming consumer. Track completeness in the object/array and string branches: only the balanced close (`depth == 0`, or the terminating quote) marks the document complete. If the loop exits via EOF while incomplete, return empty so `iterate` treats it as a clean end of stream instead of parsing a truncated document. Complete documents are unaffected. Updates the existing jsonchunk4 test (it asserted the old throw-on-truncation behavior) and adds `_read_json_chunk` unit tests for truncated object / nested object / array / string, and complete-then-truncated. Bumps to 0.2.4. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ce0218 commit 3f9bf09

3 files changed

Lines changed: 62 additions & 4 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
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.3"
7+
version = "0.2.4"
88

99
[deps]
1010
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"

src/client/chunk_readers.jl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function _read_json_chunk(io::IO)
2929
depth = 0
3030
in_string = false
3131
escaped = false
32+
complete = false
3233

3334
while !eof(io)
3435
byte = read(io, UInt8)
@@ -52,12 +53,22 @@ function _read_json_chunk(io::IO)
5253
depth += 1
5354
elseif byte == close_byte
5455
depth -= 1
55-
depth == 0 && break
56+
if depth == 0
57+
complete = true
58+
break
59+
end
5660
end
5761
end
5862
end
63+
# The stream ended before the structure was balanced: the bytes read are a
64+
# truncated document (a mid-stream connection close / cancelled response).
65+
# Discard them instead of handing a partial document to the JSON parser,
66+
# which would throw "Unexpected end of input". Returning empty makes
67+
# `iterate` treat this as a clean end of stream.
68+
complete || return UInt8[]
5969
elseif first_byte == UInt8('"')
6070
escaped = false
71+
complete = false
6172
read(io, UInt8) # consume opening quote
6273
write(out, UInt8('"'))
6374
while !eof(io)
@@ -68,9 +79,12 @@ function _read_json_chunk(io::IO)
6879
elseif byte == UInt8('\\')
6980
escaped = true
7081
elseif byte == UInt8('"')
82+
complete = true
7183
break
7284
end
7385
end
86+
# Truncated string (stream closed before the closing quote): discard.
87+
complete || return UInt8[]
7488
else
7589
# number / true / false / null: read until delimiter
7690
while !eof(io)

test/chunkreader_tests.jl

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ function jsonchunk3()
116116
end
117117

118118
function jsonchunk4()
119+
# A truncated trailing document (the stream closed mid-object, e.g. a dropped
120+
# or cancelled streaming response) is discarded rather than handed to the JSON
121+
# parser, so iteration ends cleanly instead of throwing "Unexpected end of
122+
# input". The complete documents read before the truncation are still yielded.
119123
buff = Base.BufferStream()
120124
reader = JSONChunkReader(buff)
121125
results = String[]
@@ -126,10 +130,11 @@ function jsonchunk4()
126130
end
127131

128132
write(buff, "\n\n{\"hello\": \"world\"}\n\n")
129-
write(buff, "{\"hello\": \"world\"\n")
133+
write(buff, "{\"hello\": \"world\"\n") # truncated: no closing brace before EOF
130134
close(buff)
131-
@test_throws TaskFailedException wait(readertask)
135+
wait(readertask) # no throw
132136
@test length(results) == 1
137+
@test JSON.parse(results[1])["hello"] == "world"
133138
end
134139

135140
function rfc7464chunk1()
@@ -278,6 +283,40 @@ function read_json_chunk_brackets_in_string()
278283
@test eof(io)
279284
end
280285

286+
function read_json_chunk_truncated_object()
287+
# stream closed before the closing brace (e.g. dropped connection mid-object):
288+
# the partial document is discarded rather than parsed.
289+
io = IOBuffer("{\"statuses\":")
290+
@test isempty(_read_json_chunk(io))
291+
@test eof(io)
292+
end
293+
294+
function read_json_chunk_truncated_nested_object()
295+
io = IOBuffer("{\"a\": {\"b\": 1")
296+
@test isempty(_read_json_chunk(io))
297+
@test eof(io)
298+
end
299+
300+
function read_json_chunk_truncated_array()
301+
io = IOBuffer("[1, 2")
302+
@test isempty(_read_json_chunk(io))
303+
@test eof(io)
304+
end
305+
306+
function read_json_chunk_truncated_string()
307+
io = IOBuffer("\"dev/termina")
308+
@test isempty(_read_json_chunk(io))
309+
@test eof(io)
310+
end
311+
312+
function read_json_chunk_complete_then_truncated()
313+
# a complete document is returned; the following truncated one is discarded.
314+
io = IOBuffer("{\"a\":1}{\"b\":")
315+
@test String(_read_json_chunk(io)) == "{\"a\":1}"
316+
@test isempty(_read_json_chunk(io))
317+
@test eof(io)
318+
end
319+
281320
function runtests()
282321
linechunk1()
283322
linechunk2()
@@ -303,6 +342,11 @@ function runtests()
303342
read_json_chunk_stops_at_boundary()
304343
read_json_chunk_braces_in_string()
305344
read_json_chunk_brackets_in_string()
345+
read_json_chunk_truncated_object()
346+
read_json_chunk_truncated_nested_object()
347+
read_json_chunk_truncated_array()
348+
read_json_chunk_truncated_string()
349+
read_json_chunk_complete_then_truncated()
306350
end
307351

308352
end # module ChunkReaderTests

0 commit comments

Comments
 (0)