Skip to content

Abort :http streaming read when the consumer closes the channel#98

Merged
tanmaykm merged 1 commit into
mainfrom
tan/http-streaming-abort-on-close
Jul 2, 2026
Merged

Abort :http streaming read when the consumer closes the channel#98
tanmaykm merged 1 commit into
mainfrom
tan/http-streaming-abort-on-close

Conversation

@tanmaykm

@tanmaykm tanmaykm commented Jul 2, 2026

Copy link
Copy Markdown
Member

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_request runs two tasks under @sync: one reads the socket into a buffer stream, the other turns chunks into objects and put!s them onto stream_to. Closing stream_to ends the chunk-reader task, but nothing touches the connection io — so the read task stays blocked in eof(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 :downloads backend does not have this problem: it already has a third watcher task that interrupts the download when stream_to closes. The :http backend was missing the equivalent.

Fix

Add an abort-on-close watcher task to _http_streaming_request, mirroring :downloads:

  • Capture the connection io into a Ref when the request opens.
  • A watcher waits on stream_to; when it closes, it close(io) and schedule(read_task, InterruptException()).
  • 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 unchanged.

Why the InterruptException and not just close(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 an InterruptException on the read task reliably unblocks it — the same fallback :downloads uses 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") via exec (the existing resp === nothing path), which is_request_interrupted already recognizes — so consumers that already catch is_request_interrupted / is_longpoll_timeout need no changes.

Scope

Shared :http code 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:

  • Watch that detects its condition and calls close(stream): returns in ~2–3s (previously hung to the read-idle timeout).
  • Watch with a timeout timer that closes the stream: returns/errors at the caller's timeout (15s), not the read-idle timeout.
  • Long-lived watch stopped by an external flag closing the stream: stops within the poll granularity.

All three previously hung. The genuine read-idle-timeout path (fires while stream_to is 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.

@tanmaykm tanmaykm requested a review from a team July 2, 2026 15:41
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.
@tanmaykm tanmaykm force-pushed the tan/http-streaming-abort-on-close branch from 4d197b2 to a89bb8a Compare July 2, 2026 16:06
@tanmaykm tanmaykm merged commit 7bc9734 into main Jul 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants