Skip to content

New trio.lowlevel.hold_autojump() context manager, and use in to_thread.run_sync() - #3553

Open
Zac-HD wants to merge 2 commits into
python-trio:mainfrom
Zac-HD:claude/peaceful-volta-r6wj0f
Open

Zac-HD wants to merge 2 commits into
python-trio:mainfrom
Zac-HD:claude/peaceful-volta-r6wj0f

Conversation

@Zac-HD

@Zac-HD Zac-HD commented Oct 22, 2026

Copy link
Copy Markdown
Member

I've regularly run into painful situations in tests where I'd like to use the autojump_clock to speed things up, but it doesn't play nicely with work delegated to threads.

This PR proposes a new mechanism to avoid jumping the clock forward while there's work happening in a thread (or in general elsewhere; it could be another async loop too); instead waiting in real time for it to finish. Details are described in the documentation in the diff.

Thoughts?

@njsmith

njsmith commented Oct 22, 2026 via email

Copy link
Copy Markdown
Member

@Zac-HD
Zac-HD force-pushed the claude/peaceful-volta-r6wj0f branch from 1c98666 to f3cf3d2 Compare October 22, 2026 23:40
@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00000%. Comparing base (654ba84) to head (33ba976).

Additional details and impacted files
@@               Coverage Diff                @@
##                 main        #3553    +/-   ##
================================================
  Coverage   100.00000%   100.00000%            
================================================
  Files             128          128            
  Lines           19475        19784   +309     
  Branches         1323         1327     +4     
================================================
+ Hits            19475        19784   +309     
Files with missing lines Coverage Δ
src/trio/_core/__init__.py 100.00000% <ø> (ø)
src/trio/_core/_mock_clock.py 100.00000% <ø> (ø)
src/trio/_core/_run.py 100.00000% <100.00000%> (ø)
src/trio/_core/_tests/test_mock_clock.py 100.00000% <100.00000%> (ø)
src/trio/_subprocess_platform/waitid.py 100.00000% <100.00000%> (ø)
src/trio/_tests/test_subprocess.py 100.00000% <100.00000%> (ø)
src/trio/_tests/test_threads.py 100.00000% <100.00000%> (ø)
src/trio/_threads.py 100.00000% <100.00000%> (ø)
src/trio/_wait_for_object.py 100.00000% <100.00000%> (ø)
src/trio/lowlevel.py 100.00000% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@CoolCat467

This comment was marked as resolved.

A task blocked in to_thread.run_sync looks idle to the run loop: it is
parked in wait_task_rescheduled, and the worker thread only touches the
loop when it finishes. So with a finite autojump_threshold, MockClock
could advance virtual time while a thread was still doing real work,
making concurrent tasks observe spuriously-fired timeouts.

Count the work that is in flight off the event loop in a plain counter
on the Runner, autojump_holds (the same place clock_autojump_threshold
lives, avoiding an import cycle with trio._threads), and don't arm the
autojump while it is nonzero -- mirroring the existing special case for
tasks blocked in wait_all_tasks_blocked. When the check suppresses
arming, the IO wait just sleeps until a thread reports back, so there is
no polling or busy-wait.

Each to_thread.run_sync call owns one unit of the count through a small
private object, _AutojumpThreadHold, which only acts inside the run it
belongs to, so the count is only ever changed on that run's thread.

Deliberate details:

* A thread that has re-entered Trio via from_thread.run is NOT counted
  while the re-entrant call runs: the thread is parked and the real work
  is back on the Trio side, where the run loop can already see it.
  Without this, a virtual-time sleep inside the re-entrant call would
  deadlock. This applies on both dispatch routes -- the waiting task's
  message loop, and the system task that runs the call when the thread
  was started with abandon_on_cancel=True or passes an explicit
  trio_token. The unit is taken back when the re-entrant call returns.

* When a task with abandon_on_cancel=True is cancelled, its unit is
  given up for good even though the abandoned OS thread is still
  running, and a re-entrant call still in flight at that moment will not
  take it back. The clock therefore tracks "threads some task is waiting
  on", which preserves today's cancel-then-jump behavior after
  abandonment.

* A worker thread that re-enters a *different* run (explicit trio_token)
  stays counted in its home run, whose clock cannot see the work being
  done in the other run, and the other run's count is never touched.

* The threads Trio itself uses only to wait for something outside the
  process -- a child exiting (the waitid backend) or a Windows handle --
  are not counted. Where the platform allows, those waits are I/O waits
  that leave the run loop idle, and the clock must not behave
  differently because a platform needs a thread for them. They go
  through a private _to_thread_run_sync(..., hold_autojump=False); the
  public function's signature is unchanged.

The cost: a thread can now deadlock under an autojump clock. If it
blocks, other than through from_thread.run, waiting for a task that
first has to sleep in virtual time (queue.get() on a queue a task fills
after a sleep), the clock waits for the thread and the thread for the
clock, where the clock used to jump and let it finish.

Fixes python-trio#456.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USWcRiSmFFfbTeL9SNRFE1
The autojump clock now waits for to_thread.run_sync threads, but a task
can also be waiting for other work that happens outside the run loop: a
task parked in wait_task_rescheduled until a foreign thread, or another
event loop, wakes it through TrioToken.run_sync_soon. To the run loop
such a task looks idle, so virtual time can jump past its timeouts while
the work is still in progress, and code outside Trio had no way to say
otherwise.

hold_autojump() is a context manager over the same Runner.autojump_holds
count that to_thread.run_sync uses: the autojump is not armed while any
unit is held. It is synchronous, counted (so it nests), does nothing
under a real clock, and does not hold up wait_all_tasks_blocked.

It can only be entered from inside a task; run_sync_soon callbacks
qualify because they run in a system task. The run loop re-checks the
count after every batch of task steps and runs none between arming the
autojump and firing it, so the count cannot rise in between, and no
check is needed at the firing site. The runner is bound on entry and
released on exit, so exiting the block from inside a different run
cannot touch that run's count.

to_thread.run_sync keeps its own private holder on the same count,
because it has to let go of its unit and re-take it around from_thread
re-entry, which a with block cannot express.
@Zac-HD
Zac-HD force-pushed the claude/peaceful-volta-r6wj0f branch from 5f18da3 to 33ba976 Compare October 23, 2026 01:12
@A5rocks

A5rocks commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

It turns out tokio's equivalent of autojump_clock works this way unconditionally (any running delegation-to-thread -> clock stays frozen until complete), and ime it's been a good default, useful and I haven't run into any problems.

Basically I wonder how much we benefit from exposing this. I guess it's good to be general? But I can't really think of other situations where this is useful?

(I think this makes sense for to_thread.run_sync() though!)

@Zac-HD

Zac-HD commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Basically I wonder how much we benefit from exposing this. I guess it's good to be general? But I can't really think of other situations where this is useful?

I'm about 50/50 "might as well be general", and thinking about specific cases like interprocess stuff like https://github.com/goodboy/tractor, interop between Trio and other async loops (eg js/pyodide), etc.

@A5rocks

A5rocks commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Makes sense! I only just realized that the semantics of an operation matter for this (since either an operation is blocked on another task that is alive or some external thing; and we only want to prevent autojump for that external thing).

I'll review the code at some later point but on a high level this makes sense to me.

@njsmith

njsmith commented Oct 23, 2026 via email

Copy link
Copy Markdown
Member

This branch has not been deployed

No deployments
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.

5 participants