Skip to content

Prevent hanging on Windows when exiting within 10s of a HTTP request - #493

Merged
Anton-4 merged 3 commits into
roc-lang:mainfrom
niclas-ahden:windows-http-exit-hang
Sep 11, 2026
Merged

Anton-4 merged 3 commits into
roc-lang:mainfrom
niclas-ahden:windows-http-exit-hang

Conversation

@niclas-ahden

Copy link
Copy Markdown
Contributor

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.

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.
@Anton-4 Anton-4 self-assigned this Sep 11, 2026
Anton-4 and others added 2 commits September 11, 2026 17:05
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) <noreply@anthropic.com>

@Anton-4 Anton-4 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @niclas-ahden!

@Anton-4
Anton-4 merged commit a494e74 into roc-lang:main Sep 11, 2026
34 of 35 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