Skip to content

Add spawn_in_current_thread option to run_process/open_process#3462

Closed
Vansh-Sharma27 wants to merge 1 commit into
python-trio:mainfrom
Vansh-Sharma27:fix/run-process-spawn-in-current-thread
Closed

Add spawn_in_current_thread option to run_process/open_process#3462
Vansh-Sharma27 wants to merge 1 commit into
python-trio:mainfrom
Vansh-Sharma27:fix/run-process-spawn-in-current-thread

Conversation

@Vansh-Sharma27

Copy link
Copy Markdown

Fixes #3360

Root cause

run_process/open_process call subprocess.Popen() via
trio.to_thread.run_sync. Trio's to_thread.run_sync doesn't spin up
a fresh OS thread per call — it pulls an existing worker thread out of
THREAD_CACHE (src/trio/_core/_thread_cache.py) if one is idle, and
only falls back to creating a new one if the cache is empty
(ThreadCache.start_thread_soon, which does self._idle_workers.popitem()
before deciding to spawn). That cache is what makes to_thread.run_sync
cheap to call repeatedly, but it also means the thread that ends up
calling Popen() for any given run_process call is whatever thread
happened to finish its previous job and get parked in the cache. It is
not the thread that called run_process, and it's not a fresh thread
either.

Python-level state (contextvars, etc.) gets propagated to that worker
thread correctly, which is why this is easy to miss. But OS-level
per-thread state is a kernel-level property of the specific thread,
not something Python copies around: setns(2) to move a thread into a
different Linux network namespace, thread-directed capabilities, and
CPU affinity (pthreads(7)) all stick to whichever OS thread you set
them on. If you set one of those up on your own thread and then call
run_process, the child is actually forked from a leftover cached
worker thread that never had your namespace/capabilities/affinity
applied to it in the first place, so it silently inherits whatever
that worker thread's state happens to be instead. That's exactly the
network namespace problem the issue reporter (tik-stbuehler) ran into.

The issue thread also discusses spawning a dedicated thread per call
with clone(2) so it can inherit the caller's namespace, but
tik-stbuehler pushed back on that in the comments since it throws away
the whole point of having a thread cache (or requires a per-trio-loop
thread-local pool, which is a much bigger structural change and was
posted as a workaround, not a proposed fix). The issue itself asks for
two more modest things instead: document the behavior, and add a flag
to spawn from the current thread. This PR does both.

Fix

Added spawn_in_current_thread: bool = False to both run_process
and open_process. When True, Popen() is called synchronously on
the calling thread instead of being handed off to
trio.to_thread.run_sync, so the child reliably gets the calling
thread's OS-level state because it really is forked from that thread.
This obviously blocks the calling thread (and the event loop, if
called from Trio's main task) for as long as Popen() takes, which is
normally fast but is still a real synchronous blocking call, so it's
opt-in and the default behavior/performance is completely unchanged.

Also added the .. note:: docs paragraph the issue asked for on both
run_process and open_process (and on the TYPE_CHECKING stubs
used for Windows / the Unix overloads, so docs stay consistent across
the type-stub split this module already has).

Testing

The namespace scenario from the issue needs root/CAP_SYS_ADMIN and
real network namespace setup, which isn't practical to run in CI. CPU
affinity is gated by the exact same bug mechanism (it's also per-thread
OS state that doesn't follow a thread cache hand-off) but is settable
by an unprivileged process via os.sched_setaffinity, so I used that
as a stand-in to get an actual reproduction instead of just testing
that the option exists:

  • test_spawn_in_current_thread_affinity (Linux-only, skipped on
    macOS/Windows since sched_getaffinity/sched_setaffinity aren't
    available there): runs one run_process call first to warm up
    THREAD_CACHE with a worker thread while affinity is unrestricted,
    then restricts the calling thread's affinity to a single CPU and
    checks that a child spawned the default way still reports the old,
    unrestricted mask (because it came from the stale cached worker
    thread), while a child spawned with spawn_in_current_thread=True
    reports the restricted mask. This fails without the fix and passes
    with it.
  • test_spawn_in_current_thread_equivalence: cross-platform sanity
    check that run_process/open_process behave the same with the
    flag on vs. off (stdin/stdout/return code), since the flag should
    only change which thread calls Popen(), nothing about the
    resulting process.
  • Added the matching Unpack[...] kwarg to the Windows/Unix
    TYPE_CHECKING overloads and a type_tests/subprocesses.py entry so
    the new kwarg type-checks on every overload shape.
  • Added newsfragments/3360.feature.rst.

Ran the project's full check suite locally before opening this:
pytest src (825 passed, 71 skipped, 2 xfailed — all pre-existing,
unrelated to this change), mypy --platform linux/darwin/win32,
pyright on both type_tests dirs, check_type_completeness.py,
gen_exports.py --test, and pre-commit run --all-files. All green.

run_process and open_process spawn the child by calling Popen() inside
trio.to_thread.run_sync, which actually runs on a cached worker thread,
not the thread that called run_process. Python-level state like
context vars gets copied over fine, but OS-level per-thread state
(network namespace from setns, capabilities, CPU affinity) does not,
so the child can end up inheriting whatever the worker thread's state
happens to be instead of the caller's. That's the bug in python-trio#3360.

Added an opt-in spawn_in_current_thread=True kwarg that calls Popen
directly on the calling thread instead of dispatching to a worker
thread, so the child actually gets the calling thread's OS state. This
briefly blocks the event loop while Popen runs, so it's off by
default and existing behavior is unchanged.

Added a regression test that uses sched_getaffinity/sched_setaffinity
to actually reproduce the bug on Linux (skipped elsewhere since those
calls aren't available on macOS/Windows), plus an equivalence test
checking run_process/open_process behave the same with the flag on or
off other than which thread spawns the child.
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.93939% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.98972%. Comparing base (865b7fc) to head (06c61c7).

Files with missing lines Patch % Lines
src/trio/_tests/test_subprocess.py 92.85714% 1 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (93.93939%) is below the target coverage (100.00000%). You can increase the patch coverage or adjust the target coverage.
❌ Your project check has failed because the head coverage (99.98972%) is below the target coverage (100.00000%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@                 Coverage Diff                  @@
##                 main       #3462         +/-   ##
====================================================
- Coverage   100.00000%   99.98972%   -0.01028%     
====================================================
  Files             128         128                 
  Lines           19417       19448         +31     
  Branches         1317        1320          +3     
====================================================
+ Hits            19417       19446         +29     
- Misses              0           1          +1     
- Partials            0           1          +1     
Files with missing lines Coverage Δ
src/trio/_subprocess.py 100.00000% <100.00000%> (ø)
src/trio/_tests/test_subprocess.py 99.56897% <92.85714%> (-0.43104%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@A5rocks

A5rocks commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Blocking the event loop for this is unacceptable, sorry!

@A5rocks A5rocks closed this Jun 30, 2026
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.

run_process ignores thread context and spawns from threadpool

2 participants