Skip to content

Commit 3515533

Browse files
authored
Run HTTP server tasks on the interactive thread pool (#1344)
* fix(server): use interactive thread pool * fix(server): keep interactive spawn trim-safe Preserve the existing task error behavior while selecting the interactive pool. Wrapping server tasks in errormonitor pulls Base error-display I/O into JuliaC strict trim compilation on Julia 1.13 and produces unresolved dynamic calls.
1 parent e5cf7a5 commit 3515533

13 files changed

Lines changed: 159 additions & 7 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Added `HTTP.peeraddr(::HTTP.Stream)`, returning the remote (client) `SocketAddr` of a server stream for both plain-TCP and TLS connections and both HTTP/1 and HTTP/2. This is the supported way to obtain the client IP (for rate limiting, audit logging, and per-client policy) without reaching into transport internals, and restores the capability `Sockets.getpeername(::HTTP.Stream)` provided in HTTP.jl 1.x.
1313

1414
### Fixed
15+
- Restored HTTP and WebSocket server task scheduling to Julia's `:interactive`
16+
thread pool so default-pool compute work cannot starve server and health-check
17+
tasks when an interactive thread is configured. ([#1342])
1518
- Percent-decode `userinfo` before building the `Basic` auth header (RFC 3986); fixes wrong credentials for request URLs and proxies containing percent-encoded characters.
1619

1720
## [v2.0.0] - 2026-04-27
@@ -822,3 +825,4 @@ See changes for 0.9.15: this release is equivalent to 0.9.15 with [#752] reverte
822825
[#1119]: https://github.com/JuliaWeb/HTTP.jl/issues/1119
823826
[#1126]: https://github.com/JuliaWeb/HTTP.jl/issues/1126
824827
[#1127]: https://github.com/JuliaWeb/HTTP.jl/issues/1127
828+
[#1342]: https://github.com/JuliaWeb/HTTP.jl/issues/1342

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ Each callback receives an `HTTP.SSEEvent` with the parsed `data`, `event`,
119119

120120
Use `HTTP.serve!` for request/response handlers:
121121

122+
> HTTP.jl schedules server tasks on Julia's `:interactive` thread pool so they
123+
> can remain responsive when the default pool is busy. Start production servers
124+
> with at least one interactive thread, for example
125+
> `julia --threads=4,1 server.jl`. Without an interactive thread, Julia falls
126+
> back to the default pool and non-yielding compute tasks can delay HTTP work,
127+
> including health checks. See the
128+
> [server guide][server-guide-url] for configuration and handler guidance.
129+
122130
```julia
123131
using HTTP
124132

@@ -174,3 +182,4 @@ HTTP.WebSockets.forceclose(server)
174182

175183
[issues-url]: https://github.com/JuliaWeb/HTTP.jl/issues
176184
[migration-guide-url]: https://juliaweb.github.io/HTTP.jl/dev/guides/migration-1x/
185+
[server-guide-url]: https://juliaweb.github.io/HTTP.jl/dev/guides/server/

docs/src/guides/server.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,43 @@ CurrentModule = HTTP
88
handlers. The right choice depends on how much control you need over read/write
99
sequencing.
1010

11+
## Interactive Thread Pool
12+
13+
HTTP.jl schedules its server tasks on Julia's `:interactive` thread pool. This
14+
includes listener, connection, request-handler, HTTP/2 stream, server-side SSE,
15+
and WebSocket server tasks. Keeping this work separate from the `:default` pool
16+
allows the server to accept and handle requests, including health checks, while
17+
the default pool runs compute-intensive tasks that may not yield.
18+
19+
Configure at least one interactive thread when starting a production server.
20+
For example, this command creates four default worker threads and one
21+
interactive thread:
22+
23+
```sh
24+
julia --threads=4,1 --project=. server.jl
25+
```
26+
27+
The equivalent environment setting is `JULIA_NUM_THREADS=4,1`. Check the live
28+
configuration with `Threads.nthreads(:interactive)`, which should return at
29+
least `1`.
30+
31+
If no interactive thread exists, Julia runs tasks requested for `:interactive`
32+
on the default pool. The server still starts, but it loses isolation from
33+
default-pool work. Non-yielding compute tasks can then delay all HTTP work and
34+
make health checks appear unresponsive.
35+
36+
Interactive tasks should remain responsive. Do not run long, non-yielding
37+
compute kernels directly in a server handler. Move that work to the default
38+
pool and wait for it from the handler so the interactive task can yield:
39+
40+
```julia
41+
result = fetch(Threads.@spawn :default expensive_work())
42+
```
43+
44+
A non-yielding handler can still monopolize the interactive pool. The separate
45+
pool protects HTTP work from compute tasks assigned to `:default`; it cannot
46+
make non-yielding handler code cooperative.
47+
1148
## Request Handlers
1249

1350
Use `HTTP.serve!` or `HTTP.serve` when your application naturally maps

src/HTTP.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ using URIs
2626

2727
const VERSION = v"2.0.0"
2828

29+
macro _spawn_interactive(ex)
30+
return esc(:(Threads.@spawn :interactive $ex))
31+
end
32+
2933
export WebSockets
3034
export escape
3135

src/http2_server.jl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1593,7 +1593,10 @@ function _dispatch_h2_stream!(
15931593
_fail_h2_server_stream!(server, tracked, conn, write_lock, states_lock, states, send_state, state, _H2_ERROR_PROTOCOL)
15941594
return nothing
15951595
end
1596-
Threads.@spawn _handle_h2_stream!(server, tracked, conn, write_lock, send_state, states_lock, states, state.stream_id, state, decoded_headers::Vector{HeaderField})
1596+
@_spawn_interactive _handle_h2_stream!(
1597+
server, tracked, conn, write_lock, send_state, states_lock, states,
1598+
state.stream_id, state, decoded_headers::Vector{HeaderField},
1599+
)
15971600
return nothing
15981601
end
15991602

src/http_handlers.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import ..canceled
3333
import ..body_close!
3434
import ..get_request_context
3535
import .._request_with_context
36+
import ..@_spawn_interactive
3637
import ..@try_ignore
3738

3839
"""
@@ -429,7 +430,7 @@ function (middleware::_HandlerTimeoutMiddleware)(req::Request)
429430
derived_ctx = _timeout_child_context(get_request_context(req), middleware.timeout_ns)
430431
timed_req = _request_with_context(req, derived_ctx)
431432
result = Channel{Tuple{Bool,Any}}(1)
432-
Threads.@spawn begin
433+
@_spawn_interactive begin
433434
try
434435
put!(result, (true, middleware.handler(timed_req)))
435436
catch err

src/http_server.jl

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ The handle owns the listener, background task, active-connection set, and
6363
timeout configuration. Keep it around for lifecycle operations such as
6464
[`port`](@ref), `wait(server)`, `close(server)`, or [`forceclose`](@ref).
6565
66+
HTTP.jl schedules server tasks on Julia's `:interactive` thread pool. Start
67+
Julia with at least one interactive thread, such as `--threads=4,1`, to keep
68+
server and health-check work isolated from non-yielding tasks on the default
69+
pool. Without an interactive thread, Julia falls back to the default pool.
70+
6671
Timeout fields are stored in nanoseconds. Use the convenience `listen!` and
6772
`serve!` keywords to configure request-read, header-read, response-write, and
6873
idle deadlines without constructing a `Server` manually.
@@ -1247,7 +1252,7 @@ function _serve_listener!(server::Server, listener::Union{TCP.Listener,TLS.Liste
12471252
end
12481253
tracked = _ServerConn(conn, ReentrantLock(), nothing, _ConnState.NEW, floor(Int64, time()))
12491254
_track_conn!(server, tracked)
1250-
Threads.@spawn _serve_conn!(server, tracked)
1255+
@_spawn_interactive _serve_conn!(server, tracked)
12511256
end
12521257
return nothing
12531258
end
@@ -1271,7 +1276,7 @@ function _start_server_task!(f::F, server::Server)::Server where {F}
12711276
state == _ServerState.CLOSED && throw(ProtocolError("closed servers cannot be restarted"))
12721277
state == _ServerState.RUNNING && throw(ProtocolError("server is already running"))
12731278
ready = Threads.Event(true)
1274-
task = Threads.@spawn begin
1279+
task = @_spawn_interactive begin
12751280
try
12761281
f(ready)
12771282
catch
@@ -1364,6 +1369,9 @@ request and writing the response. Timeout keywords ending in `_ns` are
13641369
nanoseconds; `read_timeout`, `read_header_timeout`, `write_timeout`, and
13651370
`idle_timeout` accept seconds. The older `readtimeout` keyword is accepted as a
13661371
seconds-valued migration alias for `read_timeout`.
1372+
1373+
Server tasks use Julia's `:interactive` thread pool. Configure at least one
1374+
interactive thread; see [`Server`](@ref) and the [Server Guide](@ref).
13671375
"""
13681376
function listen!(
13691377
handler::F, host::AbstractString="127.0.0.1", port_num::Integer=8080;
@@ -1528,6 +1536,9 @@ Timeout keywords ending in `_ns` are nanoseconds; the older `readtimeout`
15281536
keyword is accepted as a seconds-valued migration alias for `read_timeout`.
15291537
Ordinary request handlers buffer request bodies before dispatch; `max_body_bytes`
15301538
caps that buffering, and `0` restores the legacy unbounded behavior.
1539+
1540+
Server tasks use Julia's `:interactive` thread pool. Configure at least one
1541+
interactive thread; see [`Server`](@ref) and the [Server Guide](@ref).
15311542
"""
15321543
function serve!(
15331544
handler::F,

src/http_sse.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ end
239239

240240
function sse_stream(response::Response, f::Function; max_len::Integer=_DEFAULT_SSE_STREAM_MAX_LEN)::SSEStream
241241
stream = sse_stream(response; max_len=max_len)
242-
Threads.@spawn begin
242+
@_spawn_interactive begin
243243
try
244244
f(stream)
245245
catch err

src/http_websockets.jl

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import .._is_transport_timeout
9292
import .._wrap_transport_timeout
9393
import ..Stream
9494
import .._clear_deadlines!
95+
import ..@_spawn_interactive
9596

9697
include("http_websocket_pmce.jl")
9798
include("http_websocket_codec.jl")
@@ -1744,7 +1745,7 @@ function serve!(server::Server, listener, ready::Threads.Event)::Server
17441745
err isa EOFError && return server
17451746
rethrow(err)
17461747
end
1747-
Threads.@spawn _serve_ws_conn!(server, conn)
1748+
@_spawn_interactive _serve_ws_conn!(server, conn)
17481749
end
17491750
return server
17501751
end
@@ -1846,6 +1847,11 @@ read the actual address afterwards with [`server_addr`](@ref). Pass
18461847
([RFC 7692](https://www.rfc-editor.org/rfc/rfc7692)); it is negotiated per
18471848
connection and clients must also opt in. `maxframesize` defaults to 16 MiB
18481849
and bounds incoming frame/message buffering.
1850+
1851+
WebSocket server tasks use Julia's `:interactive` thread pool. Start Julia with
1852+
at least one interactive thread, such as `--threads=4,1`, to isolate server work
1853+
from non-yielding tasks on the default pool. Without an interactive thread,
1854+
Julia falls back to the default pool.
18491855
"""
18501856
function listen!(
18511857
handler::Function,
@@ -1874,7 +1880,7 @@ function listen!(
18741880
compress=compress,
18751881
)
18761882
ready = Threads.Event(true)
1877-
server.serve_task = Threads.@spawn begin
1883+
server.serve_task = @_spawn_interactive begin
18781884
try
18791885
_listen_ws(server, ready)
18801886
catch

test/http2_server_tests.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,9 @@ end
203203
end
204204

205205
@testset "HTTP/2 server request handling" begin
206+
handler_pools = Channel{Symbol}(2)
206207
server = HT.serve!("127.0.0.1", 0; listenany = true) do request
208+
put!(handler_pools, Threads.threadpool())
207209
payload = collect(codeunits("h2:" * request.target))
208210
return HT.Response(200, HT.BytesBody(payload); content_length = length(payload), proto_major = 2, proto_minor = 0)
209211
end
@@ -218,6 +220,9 @@ end
218220
@test res2.status == 200
219221
@test String(_read_all_h2_server(res1.body)) == "h2:/one"
220222
@test String(_read_all_h2_server(res2.body)) == "h2:/two"
223+
expected_pool = Threads.nthreads(:interactive) > 0 ? :interactive : :default
224+
@test take!(handler_pools) == expected_pool
225+
@test take!(handler_pools) == expected_pool
221226
finally
222227
close(conn)
223228
HT.forceclose(server)

0 commit comments

Comments
 (0)