Skip to content

Support both HTTP.jl 1.x and 2.x - #3608

Merged
pankgeorg merged 1 commit into
mainfrom
aspg/http2
Oct 4, 2026
Merged

pankgeorg merged 1 commit into
mainfrom
aspg/http2

Conversation

@pankgeorg

@pankgeorg pankgeorg commented Sep 1, 2026 •

Copy link
Copy Markdown
Member

Another take on #3565: instead of migrating off HTTP.jl 1.x, support both 1.x and 2.x.

HTTP = "^1.10.17, 2.4"

2.4 is the floor on the 2.x line because 16 advisories were all fixed in 2.4.0.

Why both

HTTP.jl 2 is a big breaking release (transport moved to Reseau, Headers and message bodies became their own types, listen! changed shape), but the surface Pluto actually touches differs in only a handful of places. Supporting both means Pluto doesn't force the version choice on anyone sharing an environment with it, and gives the ecosystem room to move at its own pace.

How

All version-dependent code lives in one new file, src/webserver/HTTPCompat.jl, behind a HTTP_IS_V2 compile-time constant. That's the whole list of differences:

HTTP.jl 1 HTTP.jl 2
listening socket Sockets.TCPServer Reseau TCP.Listener
WebSocket upgrade no Origin check checks by default → opted out
request body Vector{UInt8} BytesBody / EmptyBody
Content-Length we must set it, else chunked HTTP.jl writes it
client hangup Base.IOError Base.SystemError
client read timeout readtimeout read_idle_timeout

Everything else is written once, and run! came out shorter than before:

  • The hand-rolled request/response path is now HTTP.streamhandler(app) — both versions ship it, and it does exactly what Pluto was doing by hand (read body → call handler → assign response → write body).
  • The headers Pluto adds to every response (Content-Length, Referrer-Policy, Server) and the Binder token capture moved into middleware, so they apply on both without touching the stream.
  • on_shutdown is now called by Base.close(::RunningPlutoServer) rather than being a HTTP.jl 1 listen! hook (2.x has no such hook). Ordering is preserved: both versions close the server gracefully, i.e. they wait for open connections to finish — including the WebSockets that on_shutdown is what closes.
  • iswritable(::HTTP.Stream) is gone in 2.x; replaced by a flag set inside the upgrade handler.

On the Origin check

HTTP.jl 2 rejects a WebSocket upgrade whose Origin doesn't match the request's own scheme, host and port. Pluto is routinely served through reverse proxies that terminate TLS and/or rewrite Host (Binder, JupyterHub, JuliaHub), which makes that comparison fail for perfectly legitimate clients. We opt out, matching HTTP.jl 1, which doesn't check the origin at all.

This isn't a security regression: Pluto authenticates an upgrade with its session secret, and the cookie carrying that secret is SameSite=Strict, so it is not sent along with a cross-site handshake.

Tests

New HTTP.jl N.x testset in test/webserver.jl that exercises precisely the parts that differ between the two:

  • response framing — asserts Content-Length is present and Transfer-Encoding is absent (this one fails on 1.x without the shim, which falls back to chunked)
  • reading a request body — POST /notebookupload, the only route that does
  • a real WebSocket round trip — connect → 👋, carrying a cross-site Origin header
  • graceful shutdown — close(server) shuts down the notebooks it was running

Test.yml gains four jobs that pin HTTP.jl 1.x (Pkg.add + Pkg.pin) across Linux/macOS/Windows; the default jobs resolve freely and get the latest 2.x. The testset asserts via PLUTO_TEST_HTTP_VERSION that the pin actually took effect, so a silent resolution drift fails loudly instead of quietly testing 2.x twice.

Also fixes test/Configuration.jl: request(url, method; cookies=true, jar) was a silently ignored keyword on 1.x, so those sub-testsets were sharing HTTP.jl's global cookie jar instead of their own. It's cookiejar=jar now — correct on 1.x, required on 2.x.

Verified locally (Julia 1.12, HTTP 1.11.0 and 2.6.6)

  • webserver.jl 46/46, Dynamic.jl 34/34, Configuration.jl 182/182 — on both.
  • Full Puppeteer E2E suite (11 suites, 264 tests) green in a real browser against a Pluto server on each version.
    • bonds.js › should not rerun bond values when refreshing page is flaky on a cold server — it fails on whichever suite runs first and passes on re-run, identically on both versions.

Note

Unlike #3565 I left test/compiletimes.jl's HTTP.get("http://github.com") alone — following the redirect works fine under 2.6.6, so redirect=false didn't seem needed.

🤖 Generated with Claude Code

https://claude.ai/code/session_018xi94bDdW1Pgg85t4mCqXK

Try this Pull Request!

Open Julia and type:

julia> import Pkg
julia> Pkg.activate(temp=true)
julia> Pkg.add(url="https://github.com/JuliaPluto/Pluto.jl", rev="aspg/http2")
julia> using Pluto

@pankgeorg
pankgeorg requested a review from Pangoraw September 1, 2026 06:42

@Pangoraw Pangoraw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

HTTP.jl 2 is a breaking release: it moves the transport layer to Reseau,
turns `Headers` and message bodies into their own types, checks the `Origin`
header on WebSocket upgrades, and changed the shape of `listen!`. Rather than
migrating away from 1.x (#3565), widen compat to `^1.10.17, 2.4` and adapt.

2.4 is the minimum on the 2.x line because 2.0–2.3 carry the HTTP/2,
WebSocket masking and request handling advisories fixed in 2.4.0.

The version-dependent pieces live in the new `src/webserver/HTTPCompat.jl`,
behind a `HTTP_IS_V2` compile-time constant: the listening socket type, the
WebSocket `Origin` policy, the in-memory request body type, who writes
`Content-Length`, and the exception a client disconnect surfaces as.

Everything else is written once. `run!` now hands the request/response path
to `HTTP.streamhandler`, which both versions provide and which does exactly
what Pluto was doing by hand; the headers Pluto adds to every response and
the Binder `token` capture moved into middleware so they apply on both. The
`on_shutdown` hook is called by `Base.close(::RunningPlutoServer)` instead of
by HTTP.jl, since 2.x has no such hook and both versions close the server
gracefully — i.e. they wait for the open WebSockets that `on_shutdown` closes.

Origin checking is disabled on the upgrade (HTTP.jl 1 does not check at all).
The default policy compares the `Origin` against the request's own scheme,
host and port, which rejects legitimate clients behind the TLS-terminating,
`Host`-rewriting proxies Pluto is regularly served through (Binder,
JupyterHub, JuliaHub). Pluto authenticates an upgrade with its session
secret, whose cookie is `SameSite=Strict` and so is not sent cross-site.

Tests: a new `test/webserver.jl` testset covers response framing and headers,
reading a request body, a WebSocket round trip with a cross-site `Origin`,
and a graceful shutdown; `Test.yml` gains four jobs that pin HTTP.jl 1.x, and
the testset asserts the pin took effect. Also fixes the cookie jar keyword in
`test/Configuration.jl`, which was silently ignored as `jar` and therefore
shared the global jar between the sub-testsets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xi94bDdW1Pgg85t4mCqXK
@pankgeorg
pankgeorg marked this pull request as ready for review October 4, 2026 18:34
@pankgeorg
pankgeorg merged commit 38496f0 into main Oct 4, 2026
16 checks passed
@pankgeorg
pankgeorg deleted the aspg/http2 branch October 4, 2026 21:15
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