Abort :http streaming read when the consumer closes the channel#98
Merged
Conversation
The :http backend's streaming request had no way to abort an in-flight
socket read when the consumer closes `stream_to`. Closing the channel
stopped the chunk-reader task but left the read task blocked in
`eof(io)`/`readbytes!(io)` on the socket, so a streaming request whose
consumer stops early (e.g. a Kubernetes watch stopped via `close(stream)`
after the awaited event arrives, or a timer firing) would hang until the
read-idle timeout instead of returning promptly.
Add an abort-on-close watcher task, mirroring the :downloads backend:
capture the connection `io`, and when `stream_to` closes, close `io` and
schedule an `InterruptException` on the read task. `close(io)` alone does
not wake an HTTP/2 body read parked on the flow-control timer
(`_wait_h2_body_progress!` -> `timedwait`), so the scheduled interrupt is
what reliably unblocks it (the same fallback :downloads uses for
non-interruptible downloads). The read task swallows the abort (channel
closed, or our InterruptException) and returns normally; a genuine
network error or read-idle timeout arrives while `stream_to` is still
open, so it still propagates. The interrupt surfaces to callers as
`InvocationException("request was interrupted")` via `exec`, which
`is_request_interrupted` already recognizes.
Affects both HTTP.jl 1.x and 2.x (shared :http code path). Validated
against a live Kubernetes watch on HTTP 1.11 and 2.5: consumer-initiated
stop now returns in ~2-3s instead of hanging.
4d197b2 to
a89bb8a
Compare
krynju
approved these changes
Jul 2, 2026
tanmaykm
added a commit
that referenced
this pull request
Jul 3, 2026
* Default the :http backend to HTTP/1.1 on HTTP.jl 2.x HTTP.jl 2.x defaults to prefer_http2=true and its :auto ALPN silently upgrades any capable TLS server to HTTP/2. The streaming abort model added in #98 (interrupt the read task + close the stream when the consumer closes the channel) assumes one request per connection, as in HTTP/1.1. Over a reused HTTP/2 connection each aborted watch/streaming cycle leaves per-stream state behind, and after a few cycles the shared connection read loop wedges (observed as a hung Kubernetes watch: the socket in CLOSE-WAIT with the h2 read loop parked in read_frame!). Pin the transport to HTTP/1.1 (protocol=:h1) for both the plain and streaming request paths, guarded by the existing _HTTP_V2 check (1.x has no protocol keyword). The choice is overridable per client via the :http_protocol option (:auto or :h2) for callers who want the 2.x default. * update patch version
krynju
added a commit
that referenced
this pull request
Jul 3, 2026
The watcher added in #98 waited with `wait(stream_to); yield()` in a loop. `wait(::Channel)` returns as soon as data is *available*, so whenever an event sits in the channel before the consumer takes it, the loop spins hot — measured at 100% of a core in system time on a k8s watch stream. Poll `isopen` with a 250ms sleep instead; the watcher only exists to abort the read when the consumer walks away, so the added abort latency is irrelevant. Bumps to 0.2.6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tanmaykm
pushed a commit
that referenced
this pull request
Jul 3, 2026
…ng (#101) The watcher added in #98 waited with `wait(stream_to); yield()` in a loop. `wait(::Channel)` returns as soon as data is *available*, so whenever an event sits in the channel before the consumer takes it, the loop spins hot — measured at 100% of a core in system time on a k8s watch stream. Poll `isopen` with a 250ms sleep instead; the watcher only exists to abort the read when the consumer walks away, so the added abort latency is irrelevant. Bumps to 0.2.6. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The
:http(HTTP.jl) streaming backend cannot abort an in-flight socket read when the consumer closes the event channel (stream_to)._http_streaming_requestruns two tasks under@sync: one reads the socket into a buffer stream, the other turns chunks into objects andput!s them ontostream_to. Closingstream_toends the chunk-reader task, but nothing touches the connectionio— so the read task stays blocked ineof(io)/readbytes!(io)until the read-idle timeout.As a result, a streaming request whose consumer stops early hangs instead of returning. This is the normal control flow for a Kubernetes watch: the consumer reads events until the awaited one arrives (or a timer fires), then calls
close(stream)to stop — and then the whole call blocks up to the read-idle timeout (e.g. 10 min) rather than returning in a couple of seconds.The
:downloadsbackend does not have this problem: it already has a third watcher task that interrupts the download whenstream_tocloses. The:httpbackend was missing the equivalent.Fix
Add an abort-on-close watcher task to
_http_streaming_request, mirroring:downloads:iointo aRefwhen the request opens.stream_to; when it closes, itclose(io)andschedule(read_task, InterruptException()).InterruptException) and returns normally. A genuine network error or read-idle timeout arrives whilestream_tois still open, so it still propagates unchanged.Why the
InterruptExceptionand not justclose(io): on HTTP/2, closing the stream does not wake a body read parked on the flow-control timer (_wait_h2_body_progress!→timedwait); the read stays blocked. Scheduling anInterruptExceptionon the read task reliably unblocks it — the same fallback:downloadsuses for non-interruptible downloads.close(io)is kept as a best-effort first step (and is sufficient on HTTP/1.1).The interrupt surfaces to callers as
InvocationException("request was interrupted")viaexec(the existingresp === nothingpath), whichis_request_interruptedalready recognizes — so consumers that already catchis_request_interrupted/is_longpoll_timeoutneed no changes.Scope
Shared
:httpcode path — affects both HTTP.jl 1.x and 2.x. No public API change; non-streaming requests and normal streaming delivery are untouched.Validation
Reproduced the original hang and verified the fix against a live Kubernetes watch (Kuber.jl → OpenAPI) on both HTTP 1.11.0 and HTTP 2.5.1, Julia 1.12:
close(stream): returns in ~2–3s (previously hung to the read-idle timeout).All three previously hung. The genuine read-idle-timeout path (fires while
stream_tois still open) still throws as before, so existing reconnect/retry logic is preserved.Note
A patch release would be needed to consume this downstream (e.g. in Kuber.jl-based watch code). Left the version bump to maintainers.