From 3ba2d92f9bbabe459836cd025aced00a1f33551e Mon Sep 17 00:00:00 2001 From: niclas Date: Tue, 8 Sep 2026 11:16:25 +0200 Subject: [PATCH 1/2] Prevent hanging on exit after HTTP on Windows hyper resolves host names on tokio's blocking pool, whose threads linger for ten seconds after their last job, and dropping a runtime waits for each of them to acknowledge the shutdown. This used to be a thread local, which is dropped when its thread exits. On Windows the main thread's thread locals are dropped during ExitProcess, after the OS has already terminated every other thread, so a program that had sent a request in its last ten seconds waited forever for a pool thread that no longer existed. Leaking the runtime at exit costs nothing, since the OS reclaims it either way. --- src/http.rs | 83 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/src/http.rs b/src/http.rs index d04d2485..6da262ef 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,4 +1,5 @@ use core::mem::ManuallyDrop; +use std::sync::LazyLock; use crate::roc_platform_abi::*; use crate::{roc_host, roc_u8_list_from_slice}; @@ -15,13 +16,13 @@ type HttpTransportErr = BadBodyOrNetworkErrorOrOtherOrTimeout; type HttpTransportErrPayload = BadBodyOrNetworkErrorOrOtherOrTimeoutPayload; type HttpTransportErrTag = BadBodyOrNetworkErrorOrOtherOrTimeoutTag; -thread_local! { - static TOKIO_RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() +static TOKIO_RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_current_thread() .enable_io() .enable_time() .build() - .expect("failed to build tokio runtime"); -} + .expect("failed to build tokio runtime") +}); // Numeric method tags must match `to_host_method` in platform/InternalHttp.roc. fn as_hyper_method(method: u8, method_ext: &str) -> Option { @@ -194,21 +195,61 @@ pub extern "C" fn hosted_http_send_request(args: HostHttpSendRequestArgs) -> Htt Err(err) => return http_err(http_err_other(&err, roc_host)), }; - TOKIO_RUNTIME.with(|rt| { - if timeout_ms > 0 { - rt.block_on(async { - match tokio::time::timeout( - std::time::Duration::from_millis(timeout_ms), - async_send_request(request, roc_host), - ) - .await - { - Ok(response) => response, - Err(_) => http_err(http_err_timeout()), - } - }) - } else { - rt.block_on(async_send_request(request, roc_host)) - } - }) + if timeout_ms > 0 { + TOKIO_RUNTIME.block_on(async { + match tokio::time::timeout( + std::time::Duration::from_millis(timeout_ms), + async_send_request(request, roc_host), + ) + .await + { + Ok(response) => response, + Err(_) => http_err(http_err_timeout()), + } + }) + } else { + TOKIO_RUNTIME.block_on(async_send_request(request, roc_host)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::thread; + use std::time::Duration; + + // hyper's default resolver runs getaddrinfo on tokio's blocking pool, so a + // request to a host name leaves a pool thread alive for up to ten seconds + // afterwards. Dropping a runtime waits for every pool thread to acknowledge + // the shutdown. A runtime owned by a thread local is dropped when its + // thread exits, and on Windows the main thread's thread locals are dropped + // during ExitProcess, after the OS has already terminated every other + // thread. Nothing was left to acknowledge, so a program that had sent a + // request in its last ten seconds never finished exiting. + // + // A blocking task held until after the join stands in for the terminated + // pool thread. Either way the pool cannot acknowledge, and a thread that + // used the runtime must still be able to exit. + #[test] + fn thread_exit_does_not_wait_for_the_blocking_pool() { + let (release_tx, release_rx) = mpsc::channel::<()>(); + let user = thread::spawn(move || { + TOKIO_RUNTIME.spawn_blocking(move || { + let _ = release_rx.recv(); + }); + }); + + let (exited_tx, exited_rx) = mpsc::channel::<()>(); + thread::spawn(move || { + let _ = user.join(); + let _ = exited_tx.send(()); + }); + let exited = exited_rx.recv_timeout(Duration::from_secs(5)).is_ok(); + drop(release_tx); + assert!( + exited, + "a thread that used the HTTP runtime hung on exit while a blocking task was running" + ); + } } From 5f9db99e6883a44ecf2ea160cc1a8cb0568984e9 Mon Sep 17 00:00:00 2001 From: Anton-4 <17049058+Anton-4@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:05:41 +0200 Subject: [PATCH 2/2] Cover the exit hang end to end, and keep the trap from returning The unit test could not fail for the right reason: with the thread local it replaced, `TOKIO_RUNTIME.spawn_blocking` does not compile, so a revert is a build error rather than a failing test, and an explicit shutdown added later would keep it green. Nothing else could catch it either. hyper-util's connector parses an IP literal before it reaches a resolver (`SocketAddrs::try_parse`), so every example that talks to 127.0.0.1 skips `GaiResolver`, and with it the blocking pool whose shutdown is what hangs. Measured against the test server, an IP literal starts zero blocking-pool threads and a host name starts one. So point examples/http-client.roc at `localhost`. Both of its run cases now reach the pool and then exit immediately, which is the bug, and the connection-refused case does it without a server at all. http-simple.roc stays on the IP literal so the fast path keeps its coverage. The test server binds ::1 as well as 127.0.0.1, because Windows answers `localhost` with ::1 first; ::1 goes up first since scripts/test.py polls 127.0.0.1 for readiness. Move the reasoning to the static it explains, naming the two ways to reintroduce the hang: give the runtime an owner, or shut it down alongside `process_service::shutdown`. The test comment now describes the test. Give listener.rs the same static, since a thread-local runtime is the trap itself, not a thing to document. Nothing there reaches the blocking pool today -- socket2 binds and crate::tcp resolves -- and a static keeps it that way by construction, while letting listeners register with one IO driver. Co-Authored-By: Claude Opus 5 (1M context) --- ci/rust_http_server/src/main.rs | 42 ++++++++++++++++++++++++----- examples/http-client.roc | 16 ++++++----- src/http.rs | 48 ++++++++++++++++++++++++--------- src/listener.rs | 30 ++++++++++++--------- 4 files changed, 98 insertions(+), 38 deletions(-) diff --git a/ci/rust_http_server/src/main.rs b/ci/rust_http_server/src/main.rs index d804c1ad..6bb04b8e 100644 --- a/ci/rust_http_server/src/main.rs +++ b/ci/rust_http_server/src/main.rs @@ -8,7 +8,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioTimer; -use std::net::SocketAddr; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; use tokio::net::TcpListener; type GenericError = Box; @@ -71,13 +71,41 @@ async fn handle_request(req: Request) -> Result, Gen } #[tokio::main] -async fn main() -> Result<(), Box> { - // Address to bind the server to - let addr: SocketAddr = ([127, 0, 0, 1], 9000).into(); +async fn main() -> Result<(), GenericError> { + // `examples/http-client.roc` reaches this server by host name so the platform + // actually resolves it, and Windows answers `localhost` with ::1 ahead of + // 127.0.0.1. Bind both loopback families so either answer connects. IPv6 is + // best effort: hosts without it fall back to the IPv4 listener. + // + // Bind ::1 first. `wait_for_port` in scripts/test.py polls 127.0.0.1 for + // readiness, so the IPv4 listener has to be the last one to come up. + let mut listeners = Vec::new(); + for addr in [ + SocketAddr::from((Ipv6Addr::LOCALHOST, 9000)), + SocketAddr::from((Ipv4Addr::LOCALHOST, 9000)), + ] { + match TcpListener::bind(addr).await { + Ok(listener) => { + println!("Listening on http://{}", addr); + listeners.push(listener); + } + Err(err) if addr.is_ipv6() => println!("Skipping IPv6 loopback: {}", err), + Err(err) => return Err(err.into()), + } + } + + let accepting: Vec<_> = listeners + .into_iter() + .map(|l| tokio::spawn(accept(l))) + .collect(); + for task in accepting { + task.await??; + } + + Ok(()) +} - // Bind to the port and listen for incoming TCP connections - let listener = TcpListener::bind(addr).await?; - println!("Listening on http://{}", addr); +async fn accept(listener: TcpListener) -> Result<(), GenericError> { loop { // When an incoming TCP connection is received grab a TCP stream for // client<->server communication. diff --git a/examples/http-client.roc b/examples/http-client.roc index c6b0f329..804d56c7 100644 --- a/examples/http-client.roc +++ b/examples/http-client.roc @@ -18,22 +18,26 @@ import http.Response # # roc build examples/http-client.roc # ./examples/http-client +# +# The URLs below say `localhost` rather than `127.0.0.1` on purpose. An IP +# literal never reaches a resolver, so only a host name exercises the platform's +# name-resolution path; examples/http-simple.roc covers the IP literal. main! : List(OsStr) => Try({}, _) main! = |_args| run_demo!() run_demo! : () => Try({}, _) run_demo! = || { - utf8 = Http.get_utf8!("http://127.0.0.1:9000/utf8test") ? |err| GetUtf8Failed(err) + utf8 = Http.get_utf8!("http://localhost:9000/utf8test") ? |err| GetUtf8Failed(err) write_line!("I received '${utf8}' from the server.")? - request = Request.from_method(GET).with_uri("http://127.0.0.1:9000/utf8test") + request = Request.from_method(GET).with_uri("http://localhost:9000/utf8test") response = Http.send!(request) ? |err| SendFailed(err) status = U16.to_str(Response.status(response)) decoded : { foo : Str } - decoded = Http.get!("http://127.0.0.1:9000") ? |err| GetJsonFailed(err) + decoded = Http.get!("http://localhost:9000") ? |err| GetJsonFailed(err) - echo_request = Request.from_method(POST).with_uri("http://127.0.0.1:9000/echo-json") + echo_request = Request.from_method(POST).with_uri("http://localhost:9000/echo-json") echo_response = Http.send_json!(echo_request, { foo: "Hello Json!" }) ? |err| SendJsonFailed(err) echoed : { foo : Str } echoed = Http.decode_json_response(echo_response) ? |err| EchoedJsonDecodeFailed(err) @@ -62,7 +66,7 @@ reject_invalid_url! = || { reject_invalid_json! : () => Try({}, _) reject_invalid_json! = || { result : Try({ foo : Str }, _) - result = Http.get!("http://127.0.0.1:9000/invalid-json") + result = Http.get!("http://localhost:9000/invalid-json") match result { Err(JsonErr(_)) => write_line!("invalid JSON was rejected.") @@ -73,7 +77,7 @@ reject_invalid_json! = || { reject_invalid_utf8! : () => Try({}, _) reject_invalid_utf8! = || { - result = Http.get_utf8!("http://127.0.0.1:9000/invalid-utf8") + result = Http.get_utf8!("http://localhost:9000/invalid-utf8") match result { Err(BadBody(_)) => write_line!("invalid UTF-8 was rejected.") diff --git a/src/http.rs b/src/http.rs index 6da262ef..69c35220 100644 --- a/src/http.rs +++ b/src/http.rs @@ -16,6 +16,22 @@ type HttpTransportErr = BadBodyOrNetworkErrorOrOtherOrTimeout; type HttpTransportErrPayload = BadBodyOrNetworkErrorOrOtherOrTimeoutPayload; type HttpTransportErrTag = BadBodyOrNetworkErrorOrOtherOrTimeoutTag; +// Deliberately a `static`, so this runtime is never dropped. +// +// hyper resolves host names with `getaddrinfo` on tokio's blocking pool, whose +// threads linger for ten seconds after their last job, and dropping a runtime +// waits for each of them to acknowledge the shutdown. This used to be a thread +// local, which is dropped when its thread exits. On Windows the main thread's +// thread locals are dropped during `ExitProcess`, after the OS has already +// terminated every other thread, so a program that had sent a request in its +// last ten seconds waited forever for a pool thread that no longer existed. +// Leaking the runtime at exit costs nothing, since the OS reclaims it either way. +// +// So: do not give this runtime an owner that drops it, and do not shut it down +// from `lib.rs` alongside `process_service::shutdown`. Either reintroduces the +// hang. `examples/http-client.roc` guards the exit path by reaching the test +// server by host name; an IP literal skips the resolver, and with it the +// blocking pool, so it cannot reproduce this. static TOKIO_RUNTIME: LazyLock = LazyLock::new(|| { tokio::runtime::Builder::new_current_thread() .enable_io() @@ -219,20 +235,21 @@ mod tests { use std::thread; use std::time::Duration; - // hyper's default resolver runs getaddrinfo on tokio's blocking pool, so a - // request to a host name leaves a pool thread alive for up to ten seconds - // afterwards. Dropping a runtime waits for every pool thread to acknowledge - // the shutdown. A runtime owned by a thread local is dropped when its - // thread exits, and on Windows the main thread's thread locals are dropped - // during ExitProcess, after the OS has already terminated every other - // thread. Nothing was left to acknowledge, so a program that had sent a - // request in its last ten seconds never finished exiting. + // Guards the shape of `TOKIO_RUNTIME`: a thread that used it must be able to + // exit while the blocking pool still holds work. An owned runtime blocks in + // `Drop` until every pool thread acknowledges the shutdown, which is the + // Windows exit hang described above the static. A blocking task held until + // after the join stands in for a pool thread that cannot acknowledge. // - // A blocking task held until after the join stands in for the terminated - // pool thread. Either way the pool cannot acknowledge, and a thread that - // used the runtime must still be able to exit. + // This only covers the declaration; the exit path itself is covered by the + // `http-client` run cases in scripts/test_spec.json, which reach the test + // server by host name and so actually put work on the blocking pool. #[test] fn thread_exit_does_not_wait_for_the_blocking_pool() { + // Generous: the passing case finishes in microseconds. This only has to + // be shorter than a hang, which never ends. + const EXIT_BUDGET: Duration = Duration::from_secs(5); + let (release_tx, release_rx) = mpsc::channel::<()>(); let user = thread::spawn(move || { TOKIO_RUNTIME.spawn_blocking(move || { @@ -241,12 +258,17 @@ mod tests { }); let (exited_tx, exited_rx) = mpsc::channel::<()>(); - thread::spawn(move || { + let watcher = thread::spawn(move || { let _ = user.join(); let _ = exited_tx.send(()); }); - let exited = exited_rx.recv_timeout(Duration::from_secs(5)).is_ok(); + + let exited = exited_rx.recv_timeout(EXIT_BUDGET).is_ok(); + // Release the pool before asserting, so a failure unwinds instead of + // leaving `user` parked in `Drop` and `watcher` joined to it forever. drop(release_tx); + watcher.join().expect("watcher thread panicked"); + assert!( exited, "a thread that used the HTTP runtime hung on exit while a blocking task was running" diff --git a/src/listener.rs b/src/listener.rs index 3a674750..fd886690 100644 --- a/src/listener.rs +++ b/src/listener.rs @@ -1,11 +1,19 @@ //! Deadline-aware listeners using a host-thread reactor. use crate::{roc_host, resources, roc_platform_abi::*}; -use std::{io, mem::ManuallyDrop, net::{TcpListener, TcpStream}, time::Duration}; +use std::{io, mem::ManuallyDrop, net::{TcpListener, TcpStream}, sync::LazyLock, time::Duration}; -thread_local! { - static RUNTIME: tokio::runtime::Runtime = tokio::runtime::Builder::new_current_thread() - .enable_all().build().expect("TCP listener runtime"); -} +// A `static` for the same reason as `TOKIO_RUNTIME` in http.rs: a runtime with an +// owner is dropped when that owner goes away, and dropping a runtime waits for +// tokio's blocking pool. On Windows a thread local's drop runs during +// `ExitProcess`, after every other thread is already gone, so that wait never +// ends. Nothing here reaches the pool today — binding goes through `socket2` and +// name resolution through `crate::tcp` — but a `static` keeps it that way by +// construction rather than by review, and lets listeners created on one thread be +// accepted on another, since they all register with the one IO driver. +static RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all().build().expect("TCP listener runtime") +}); struct Listener { socket: Option } fn listen(host: String, port: u16, timeout_ms: u64) -> io::Result { @@ -20,11 +28,9 @@ fn listen(host: String, port: u16, timeout_ms: u64) -> io::Result { socket.bind(&address.into())?; socket.listen(128)?; socket.set_nonblocking(true)?; - RUNTIME.with(|runtime| { - let _entered = runtime.enter(); - tokio::net::TcpListener::from_std(TcpListener::from(socket)) - .map(|socket| Listener { socket: Some(socket) }) - }) + let _entered = RUNTIME.enter(); + tokio::net::TcpListener::from_std(TcpListener::from(socket)) + .map(|socket| Listener { socket: Some(socket) }) })(); match result { Ok(listener) => return Ok(listener), Err(e) => error = e } } @@ -47,13 +53,13 @@ impl Listener { fn accept(&self, timeout_ms: u64) -> io::Result { crate::tcp::deadline_from_timeout(timeout_ms)?; let socket = self.socket()?; - RUNTIME.with(|runtime| runtime.block_on(async { + RUNTIME.block_on(async { let (stream, _) = tokio::time::timeout(Duration::from_millis(timeout_ms), socket.accept()).await .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??; let stream = stream.into_std()?; stream.set_nonblocking(false)?; Ok(stream) - })) + }) } }