Conversation
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
There was a problem hiding this comment.
💡 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".
| if (Py_IsInitialized() && !PythonIsFinalizing() && PyGILState_Check()) { | ||
| py::gil_scoped_release release; |
There was a problem hiding this comment.
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 👍 / 👎.
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-pollsleep_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_planreleases aroundrunner->run_plan), so no current caller arrives GIL-held. The masking is incidental — a new direct caller or a refactor that drops an outergil_scoped_releasewould turn an ordinary poll into interpreter-wide starvation. This change makes the wait self-contained instead.Changes
SleepWithGILReleasedIfHeldingil_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.wait_fte_querystatus-wait loop and theDrainResultHandlesdrain loop.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).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_testbinding 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 raisessys.setswitchintervalabove 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).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_eventsis not exposed to Python, so a dedicated test would need a second_for_testsubmit 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