Skip to content

[fix](ray) Release the caller's GIL across Python-backend poll sleeps - #537

Open
caomaocao wants to merge 1 commit into
AstroVela:mainfrom
caomaocao:fix/poll-loops-release-caller-gil
Open

caomaocao wants to merge 1 commit into
AstroVela:mainfrom
caomaocao:fix/poll-loops-release-caller-gil

Conversation

@caomaocao

Copy link
Copy Markdown
Contributor

What & why

Closes #456.

The Python-backend FTE worker manager polls a Python backend in a loop, sleeping between polls. PythonGILWrapper (PyGILState_Ensure/PyGILState_Release) restores — it does not release — a GIL the caller already held. So a wait entered while holding the GIL keeps the GIL across every inter-poll sleep_for, which can starve the Python backend thread that must make progress for the poll to ever observe it.

This is latent today: every pybind entry point releases the GIL before reaching the wait (this file's run_plan releases around runner->run_plan), so no current caller arrives GIL-held. The masking is incidental — a new direct caller or a refactor that drops an outer gil_scoped_release would turn an ordinary poll into interpreter-wide starvation. This change makes the wait self-contained instead.

Changes

  • New shared helper SleepWithGILReleasedIfHeld in gil_wrapper.hpp — sleeps while releasing the GIL only when the thread actually holds it, guarded by the most defensive form already used in this tree (Py_IsInitialized() && !PythonIsFinalizing() && PyGILState_Check()); a plain sleep otherwise.
  • Both Python-backend poll-loop sleeps now route through it: the wait_fte_query status-wait loop and the DrainResultHandles drain loop.
  • De-duplication: the existing Ray poll-loop guard in worker_manager.cpp (which already open-coded this exact release-if-held pattern) now calls the shared helper. Behavior-preserving; the condition is widened from GIL-held-only to also short-circuit during finalization (strictly safer, unreachable-in-practice difference).
  • Per-poll Python callbacks still acquire the GIL exactly as before via PythonGILWrapper; only the waiting portion changes.

No public API, SQL, or backend-protocol surface changes — the only new symbols are a test-only pybind method/binding.

Testing

A new test-only _wait_fte_query_gil_held_for_test binding enters the wait without releasing the GIL (the inverse of the existing _wait_fte_query_scoped_for_test), against a fake Python backend. The regression test raises sys.setswitchinterval above the wait's total duration, so CPython's voluntary GIL hand-offs during the status callbacks cannot fire: a concurrent Python thread then advances only if the inter-poll sleep explicitly releases the GIL. Unfixed ⇒ the spinner's progress is exactly zero; fixed ⇒ it advances. A second test covers the success path (completes once the backend reports finished).

CI is the gate. The native extension cannot be built on the author's machine, so these tests were not run locally — please rely on CI. The test is designed to fail deterministically (a threshold-free progress > 0 under a suppressed switch interval, not a timing race) so a red result unambiguously means the guard is missing.

Known gap (deferred follow-up)

The drain-loop sleep routes through the same one-line helper but is not independently exercised by a test — reaching it requires stored result handles, and submit_fte_task_events is not exposed to Python, so a dedicated test would need a second _for_test submit binding plus fake pending-then-ready result-handle machinery that can't be validated on the local (unbuildable) native extension. Both sleeps are the identical helper call, proven by the status-loop discriminator; independent drain-loop coverage is left as a follow-up. Happy to add it in this PR if a reviewer prefers.

Also intentionally out of scope: the sibling unguarded sleep on the non-Python-backend Ray status path (worker_manager.cpp, the FTE status wait loop) — same bug class, but not one of the two sites named in #456. Flagged for a separate follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxNrziiFp5FDEJpGUPX9NK

The Python-backend FTE worker manager polls a Python backend in a loop,
sleeping between polls. PythonGILWrapper (PyGILState_Ensure/Release)
restores — it does not release — a GIL the caller already held, so a wait
entered GIL-held kept the GIL across every inter-poll sleep and could
starve the Python backend thread that must make progress. It is latent
today only because every pybind entry point releases the GIL before
calling in.

Add a shared SleepWithGILReleasedIfHeld helper (guarded by initialized +
!finalizing + GIL-held) and route the two Python-backend poll-loop sleeps
(wait_fte_query status loop and DrainResultHandles) through it. The
existing Ray poll-loop guard is refactored onto the same helper,
de-duplicating it with no behavior change.

Regression test: a new test-only _wait_fte_query_gil_held_for_test binding
enters the wait GIL-held against a fake backend; with sys.setswitchinterval
raised above the wait's duration, a concurrent Python thread advances only
if the sleep explicitly releases the GIL — deterministically catching a
regression of the status-loop guard. The drain-loop sleep shares the same
helper; exercising it independently needs result-handle submission test
infrastructure and is deferred as a follow-up.

Refs AstroVela#456

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxNrziiFp5FDEJpGUPX9NK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 697d944aad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +95 to +96
if (Py_IsInitialized() && !PythonIsFinalizing() && PyGILState_Check()) {
py::gil_scoped_release release;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard GIL release with attached-thread-state check

In a Python 3.14 process that has ever created a subinterpreter, CPython documents that PyGILState_Check() always returns 1, even when this thread is inside an outer py::gil_scoped_release; the normal wait_fte_query path reaches these sleeps with the GIL already released, so this branch can construct another gil_scoped_release and call PyEval_SaveThread without an attached thread state, aborting the process instead of sleeping. Use an actual attached-thread-state check such as PyThreadState_GetUnchecked() where available, or pass down whether the caller held the GIL, before releasing.

Useful? React with 👍 / 👎.

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.

[P2][Pybind] Python-backend polling loops can sleep while retaining the caller's GIL

1 participant