Skip to content

Commit 4c7b25a

Browse files
pankgeorgclaude
andcommitted
Migrate to HTTP.jl 2.0 (require 2.1)
HTTP.jl 2.0 is a breaking rewrite on the Reseau transport layer. This adapts Pluto's web server to the new API and drops pre-2.0 compatibility. - Require `HTTP = "2.1"`. The mixed HTTP+WebSocket server helper `HTTP.WebSockets.upgrade(f, stream)` is not in 2.0.0 (added in 2.1.0), and Pluto serves HTTP and WebSockets on one port, so 2.0.0 cannot be supported. - WebServer.jl: `listen!` no longer takes `server=`/`on_shutdown`/`stream`/ `verbose`, so bind the `TCP.Listener` ourselves (keeping the port-hint search) and pass it to `listen!`. The graceful `close(::HTTP.Server)` waits for active WebSocket connections, so client shutdown now runs from `RunningPlutoServer` before the server is closed. - The server `Stream` exposes request metadata via `http.message`; rebuild the request with its body for the handlers, then assign `http.response` and write the body bytes (mirrors HTTP's own stream handler). A client disconnect now surfaces as `SystemError` (Reseau), so it is swallowed alongside `IOError`. - `HTTP.WebSocket`/`HTTP.send` move under `HTTP.WebSockets`. Keep 1.x behavior of not checking the WebSocket `Origin` (it breaks proxied setups; the secret is the real auth). - `auth_middleware`: `Headers` is no longer a plain vector, so replace the `filter!` with `setheader`, which already de-duplicates. - `readtimeout` -> `read_idle_timeout`. - test/Configuration.jl: the cookie jar keyword is `cookiejar`, not `jar`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 35008a3 commit 4c7b25a

6 files changed

Lines changed: 76 additions & 38 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Downloads = "1"
4646
ExpressionExplorer = "0.5, 0.6, 1"
4747
FileWatching = "1"
4848
GracefulPkg = "2"
49-
HTTP = "^1.10.17, 2.1"
49+
HTTP = "2.1"
5050
HypertextLiteral = "0.7, 0.8, 0.9, 1"
5151
InteractiveUtils = "1"
5252
LRUCache = "1.6.2"

src/webserver/Authentication.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function auth_middleware(handler)
101101
if !required || is_authenticated(session, request)
102102
response = handler(request)
103103
if !required
104-
filter!(p -> p[1] != "Access-Control-Allow-Origin", response.headers)
104+
# setheader replaces any existing values for this key.
105105
HTTP.setheader(response, "Access-Control-Allow-Origin" => "*")
106106
end
107107
if required || HTTP.URI(request.target).path ("", "/")

src/webserver/PutUpdates.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ end
6767
# https://github.com/JuliaWeb/HTTP.jl/issues/382
6868
const flushtoken = Token()
6969

70-
function send_message(stream::HTTP.WebSocket, msg)
71-
HTTP.send(stream, serialize_message(msg))
70+
function send_message(stream::HTTP.WebSockets.WebSocket, msg)
71+
HTTP.WebSockets.send(stream, serialize_message(msg))
7272
end
7373
function send_message(stream::IO, msg)
7474
write(stream, serialize_message(msg))
7575
end
7676

77-
function is_stream_open(stream::HTTP.WebSocket)
77+
function is_stream_open(stream::HTTP.WebSockets.WebSocket)
7878
!HTTP.WebSockets.isclosed(stream)
7979
end
8080
function is_stream_open(io::IO)

src/webserver/Router.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ function http_router_for(session::ServerSession)
197197
uri = HTTP.URI(request.target)
198198
query = HTTP.queryparams(uri)
199199

200-
save_path = SessionActions.save_upload(request.body; filename_base=get(query, "name", nothing))
200+
save_path = SessionActions.save_upload(String(request.body); filename_base=get(query, "name", nothing))
201201

202202
try_launch_notebook_response(
203203
SessionActions.open,

src/webserver/WebServer.jl

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ function swallow_exception(f, exception_type::Type{T}) where {T}
3131
end
3232
end
3333

34+
"""
35+
The raw bytes of an in-memory response body. Pluto's handlers build responses
36+
from strings or byte vectors, so depending on the constructor the body ends up
37+
as an `HTTP.BytesBody`, a `Vector{UInt8}`, a `String`, or `HTTP.EmptyBody`.
38+
"""
39+
response_body_bytes(body::HTTP.BytesBody) = body.data
40+
response_body_bytes(body::AbstractVector{UInt8}) = body
41+
response_body_bytes(body::AbstractString) = codeunits(body)
42+
response_body_bytes(::HTTP.EmptyBody) = UInt8[]
43+
3444
"""
3545
Pluto.run()
3646
@@ -67,26 +77,40 @@ const is_first_run = Ref(true)
6777

6878
"Return a port and serversocket to use while taking into account the `favourite_port`."
6979
function port_serversocket(hostIP::Sockets.IPAddr, favourite_port, port_hint)
70-
local port, serversocket
80+
listen_on(port) = HTTP.TCP.listen("tcp", HTTP.HostResolvers.join_host_port(string(hostIP), Int(port)))
7181
if favourite_port === nothing
72-
port, serversocket = Sockets.listenany(hostIP, UInt16(port_hint))
82+
port = UInt16(port_hint)
83+
while true
84+
serversocket = try
85+
listen_on(port)
86+
catch e
87+
port == typemax(UInt16) && rethrow()
88+
port += UInt16(1)
89+
continue
90+
end
91+
return port, serversocket
92+
end
7393
else
7494
port = UInt16(favourite_port)
75-
try
76-
serversocket = Sockets.listen(hostIP, port)
95+
serversocket = try
96+
listen_on(port)
7797
catch e
7898
error("Cannot listen on port $port. It may already be in use, or you may not have sufficient permissions. Use Pluto.run() to automatically select an available port.")
7999
end
100+
return port, serversocket
80101
end
81-
return port, serversocket
82102
end
83103

84104
struct RunningPlutoServer
85105
http_server
106+
on_shutdown::Function
86107
initial_registry_update_task::Task
87108
end
88109

89110
function Base.close(ssc::RunningPlutoServer)
111+
# Close client connections and shut down notebooks first: the graceful
112+
# `close(::HTTP.Server)` waits for active (WebSocket) connections to finish.
113+
ssc.on_shutdown()
90114
close(ssc.http_server)
91115
wait(ssc.http_server)
92116
wait(ssc.initial_registry_update_task)
@@ -101,10 +125,11 @@ function Base.wait(ssc::RunningPlutoServer)
101125
catch e
102126
println()
103127
println()
104-
Base.close(ssc)
105128
(e isa InterruptException) || rethrow(e)
129+
finally
130+
Base.close(ssc)
106131
end
107-
132+
108133
nothing
109134
end
110135

@@ -149,20 +174,18 @@ function run!(session::ServerSession)
149174
local port, serversocket = port_serversocket(hostIP, favourite_port, port_hint)
150175

151176
on_shutdown() = @sync begin
152-
# Triggered by HTTP.jl
153177
@info("\nClosing Pluto... Restart Julia for a fresh session. \n\nHave a nice day! 🎈\n\n")
154-
# TODO: put do_work tokens back
155-
@async swallow_exception(() -> close(serversocket), Base.IOError)
178+
# TODO: put do_work tokens back
156179
for client in values(session.connected_clients)
157-
@async swallow_exception(() -> close(client.stream), Base.IOError)
180+
@async swallow_exception(() -> close(client.stream), Exception)
158181
end
159182
empty!(session.connected_clients)
160183
for nb in values(session.notebooks)
161184
@asynclog SessionActions.shutdown(session, nb; keep_in_session=false, async=false, verbose=false)
162185
end
163186
end
164187

165-
server = HTTP.listen!(hostIP, port; stream=true, server=serversocket, on_shutdown, verbose=-1) do http::HTTP.Stream
188+
server = HTTP.listen!(serversocket) do http::HTTP.Stream
166189
# the if statement below asks if the current request is a "websocket upgrade" request: the start of a websocket connection.
167190
if HTTP.WebSockets.isupgrade(http.message)
168191
secret_required = let
@@ -182,7 +205,8 @@ function run!(session::ServerSession)
182205
if !secret_required || is_authenticated(session, http.message)
183206
try
184207
# "upgrade" means accept and start the websocket connection that the client requested
185-
HTTP.WebSockets.upgrade(http) do clientstream
208+
# Origin checking is disabled (like HTTP.jl 1.x) because it breaks proxied setups (Binder, JupyterHub); Pluto's own secret provides the authentication.
209+
HTTP.WebSockets.upgrade(http; check_origin=(request, origin) -> true) do clientstream
186210
if HTTP.WebSockets.isclosed(clientstream)
187211
return
188212
end
@@ -248,7 +272,7 @@ function run!(session::ServerSession)
248272
end
249273
finally
250274
# if we never wrote a response, then do it now
251-
if isopen(http) && !iswritable(http)
275+
if isopen(http)
252276
finish()
253277
end
254278
end
@@ -258,9 +282,19 @@ function run!(session::ServerSession)
258282
else
259283
# then it's a regular HTTP request, not a WS upgrade
260284

261-
request::HTTP.Request = http.message
262-
request.body = read(http)
263-
# HTTP.closeread(http)
285+
# `http.message` only carries the request metadata; rebuild a request that includes the body for the handlers.
286+
request::HTTP.Request = let m = http.message
287+
HTTP.Request(
288+
m.method,
289+
m.target;
290+
headers=m.headers,
291+
body=read(http),
292+
host=m.host,
293+
proto_major=Int(m.proto_major),
294+
proto_minor=Int(m.proto_minor),
295+
close=m.close,
296+
)
297+
end
264298

265299
# If a "token" url parameter is passed in from binder, then we store it to add to every URL (so that you can share the URL to collaborate).
266300
let
@@ -271,23 +305,27 @@ function run!(session::ServerSession)
271305
end
272306

273307
###
274-
response_body = app(request)
308+
response::HTTP.Response = app(request)
275309
###
276310

277-
request.response::HTTP.Response = response_body
278-
request.response.request = request
311+
response.request = request
279312
try
280-
HTTP.setheader(http, "Content-Length" => string(length(request.response.body)))
313+
# The stream writes the status, headers and Content-Length from its `response` when `startwrite` is called.
314+
http.response = response
281315
# https://github.com/fonsp/Pluto.jl/pull/722
282316
HTTP.setheader(http, "Referrer-Policy" => "same-origin")
283317
# https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#:~:text=is%202%20minutes.-,14.38%20Server
284318
HTTP.setheader(http, "Server" => "Pluto.jl/$(PLUTO_VERSION_STR[2:end]) Julia/$(JULIA_VERSION_STR[2:end])")
285319
HTTP.startwrite(http)
286-
write(http, request.response.body)
320+
write(http, response_body_bytes(response.body))
321+
# Finish the response here (rather than letting the server loop do
322+
# it) so that a client that disconnects mid-response is handled by
323+
# the catch below instead of aborting the connection in the loop.
324+
HTTP.closewrite(http)
287325
catch e
288-
if isa(e, Base.IOError) || isa(e, ArgumentError)
289-
# @warn "Attempted to write to a closed stream at $(request.target)"
290-
else
326+
# The client hung up before we finished writing; nothing to do.
327+
# Reseau surfaces a disconnect as SystemError (e.g. broken pipe).
328+
if !(e isa Base.IOError || e isa ArgumentError || e isa Base.SystemError)
291329
rethrow(e)
292330
end
293331
end
@@ -296,7 +334,7 @@ function run!(session::ServerSession)
296334

297335
server_running() =
298336
try
299-
HTTP.get("http://$(hostIP):$(port)$(session.options.server.base_url)ping"; status_exception=false, retry=false, connect_timeout=10, readtimeout=10).status == 200
337+
HTTP.get("http://$(hostIP):$(port)$(session.options.server.base_url)ping"; status_exception=false, retry=false, connect_timeout=10, read_idle_timeout=10).status == 200
300338
catch
301339
false
302340
end
@@ -325,7 +363,7 @@ function run!(session::ServerSession)
325363
will_update && println(" Updating registry done ✓")
326364
end
327365

328-
return RunningPlutoServer(server, initial_registry_update_task)
366+
return RunningPlutoServer(server, on_shutdown, initial_registry_update_task)
329367
end
330368
precompile(run, (ServerSession, HTTP.Handlers.Router{Symbol("##001")}))
331369

test/Configuration.jl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,20 @@ end
162162
# Effectful paths should not work without a secret.
163163
@testset "simple & effect w/o auth 1 $suffix $method" for (suffix, method) in effect_routes
164164
url = local_url(suffix)
165-
r = request(url, method; cookies=true, jar)
165+
r = request(url, method; cookies=true, cookiejar=jar)
166166
@test r.status == 403
167167
@test !shares_secret(r)
168168
end
169169

170170
# With this config, the / path should work and share the secret, even when requested without a secret.
171-
r = request(local_url(""), "GET"; cookies=true, jar)
171+
r = request(local_url(""), "GET"; cookies=true, cookiejar=jar)
172172
@test r.status == 200
173173
@test shares_secret(r)
174174

175175
# Now, the other effectful paths should work bc of the secret.
176176
@testset "simple w/o auth 2 $suffix $method" for (suffix, method) in simple_routes
177177
url = local_url(suffix)
178-
r = request(url, method; cookies=true, jar)
178+
r = request(url, method; cookies=true, cookiejar=jar)
179179
@test r.status 200:299 # 2xx is OK
180180
@test shares_secret(r)
181181
end
@@ -185,13 +185,13 @@ end
185185

186186
jar = HTTP.Cookies.CookieJar()
187187

188-
@test shares_secret(request(local_url("") |> withsecret, "GET"; cookies=true, jar))
188+
@test shares_secret(request(local_url("") |> withsecret, "GET"; cookies=true, cookiejar=jar))
189189

190190

191191
@testset "simple w/ auth $suffix $method" for (suffix, method) in simple_routes
192192
# should work because of cookie
193193
url = local_url(suffix)
194-
r = request(url, method; cookies=true, jar)
194+
r = request(url, method; cookies=true, cookiejar=jar)
195195
@test r.status 200:299 # 2xx is OK
196196
@test shares_secret(r) # see reasoning in of https://github.com/fonsp/Pluto.jl/commit/20515dd46678a49ca90e042fcfa3eab1e5c8e162
197197

0 commit comments

Comments
 (0)