Skip to content

Run test_multi_process in a fresh subprocess to avoid leaked-thread fork warning#3624

Merged
bdarnell merged 1 commit into
tornadoweb:masterfrom
mokashang:fix/test-multi-process-thread-leak
May 21, 2026
Merged

Run test_multi_process in a fresh subprocess to avoid leaked-thread fork warning#3624
bdarnell merged 1 commit into
tornadoweb:masterfrom
mokashang:fix/test-multi-process-thread-leak

Conversation

@mokashang

Copy link
Copy Markdown
Contributor

Fixes #3623.

fork_processes() ultimately calls os.fork(), which on Python 3.12+ raises DeprecationWarning: This process (pid=...) is multi-threaded, use of fork() may lead to deadlocks in the child if there is more than one live thread. tornado/test/runtests.py configures DeprecationWarnings emitted from tornado.* as errors, so any thread that survives an earlier test in the suite will make test_multi_process fail. The user in #3623 hit this with python3 -m tornado.test on Python 3.15.0b1 in the Fedora rpm build, but couldn't reproduce it under tox — which matches the maintainer's hypothesis that the difference is which tests get loaded and what global state they leave behind (the asyncio DNS resolver's thread pool being the most likely suspect).

This PR takes the route suggested in @bdarnell's comment: move the fork-and-serve body of test_multi_process into a small script that runs via python -c in a fresh interpreter, following the pattern in autoreload_test. The outer test method just launches the subprocess (subprocess.run with timeout=30 as a backstop on top of the existing signal.alarm(5)) and asserts a clean exit. Since the subprocess starts single-threaded, whatever the rest of the test suite did to its parent doesn't matter anymore.

The script body itself is the same logic that used to live in the test method: ExpectLog is still active around the fork, the three controlled restarts via /exit=2, /exit=3, /exit=4 are preserved, and task_id() is still asserted in each branch (using bare assert since we're outside a TestCase). PYTHONPATH is propagated to the subprocess so the source tree under test is importable.

The tearDown / get_app helpers and the asyncio, logging, and HTTP-related top-level imports were only used by the old in-process version of the test and are removed. SubprocessTest is untouched.

Testing

  • Full suite: python3 -m tornado.test — 1234 tests, 53 skipped (optional deps), all pass.
  • Targeted: python3 -m tornado.test tornado.test.process_test.ProcessTest.test_multi_process -v — pass.
  • Failure mode: ran a small driver that starts two daemon threads, then invokes ProcessTest('test_multi_process') directly. On master this would trip the multi-threaded-fork DeprecationWarning under warnings-as-errors; with this change it passes because the fork happens inside a fresh subprocess.
  • Lint: python3 -m flake8 tornado/test/process_test.py clean; python3 -m black --check tornado/test/process_test.py clean.

I don't have a Python 3.15.0b1 build handy to verify the exact failure-and-fix on the platform from the issue, but the mechanism is the same on 3.12+, and the isolation removes the dependency on the parent's thread state altogether.

When `python3 -m tornado.test` is run in an environment where some test
earlier in the suite has left a thread running, the `os.fork()` inside
`fork_processes()` triggers `DeprecationWarning: This process (pid=...)
is multi-threaded, use of fork() may lead to deadlocks in the child` on
Python 3.12+. The test suite turns DeprecationWarnings from tornado
into errors, so `test_multi_process` then fails. This has been observed
in the Fedora rpm build of tornado on Python 3.15.0b1 (tornadoweb#3623), where it
does not reproduce under tox.

Rather than chase down every thread leak across the suite, isolate
`test_multi_process` so it always starts from a single-threaded state.
The actual fork-and-serve logic is moved into a script string that is
executed via `python -c` in a fresh interpreter, following the pattern
established in autoreload_test for tests that need a clean process. The
outer test method just launches the subprocess and asserts a clean
exit. PYTHONPATH is propagated so the source tree under test is
importable. The script keeps the existing `signal.alarm(5)` timers and
`subprocess.run(timeout=30)` is added as a backstop in case the script
hangs in a way the alarms don't catch.

Tested locally on macOS / Python 3.13 with the full suite plus a
deliberately leaked thread before the test to confirm the new isolation
holds. The `tearDown` / `get_app` helpers and the `asyncio`, `logging`,
and HTTP-related top-level imports are no longer needed and are
removed.

Fixes tornadoweb#3623
@bdarnell

Copy link
Copy Markdown
Member

Thanks!

@bdarnell bdarnell merged commit ecc40ae into tornadoweb:master May 21, 2026
16 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.

heads up: test_multi_process emits a DeprecationWarning from Python 3.15.0b1

2 participants