[Core] Fix async actor crash on CPython 3.14 fiber-stack recursion guard - #63284
[Core] Fix async actor crash on CPython 3.14 fiber-stack recursion guard#63284elliot-barn wants to merge 5 commits into
Conversation
On cp3.14, every Ray async actor (any @ray.remote class with an `async def`
method) crashes the worker process before any user code runs:
Fatal Python error: _Py_CheckRecursiveCall: Unrecoverable stack overflow
Current thread 0x... [ray::<ActorName>] (most recent call first):
<no Python frame>
cp3.14's _Py_ReachedRecursionLimit compares the current frame address to
c_stack_soft_limit on _PyThreadStateImpl. That limit is anchored to the OS
thread's pthread stack at PyThreadState bind time. Ray's async-actor
dispatcher (FiberState) runs every task on a 256 KiB boost::fibers
fixedsize_stack -- a memory region distinct from the pthread stack -- so the
very first Python call inside the fiber trips the guard.
Fix: install a hook on FiberState that runs inside every fiber before the
user callback. The hook rewrites c_stack_top / c_stack_soft_limit /
c_stack_hard_limit to bracket the current fiber-stack frame with a 192 KiB
budget out of the 256 KiB fiber stack. The hook uses opaque function-pointer
plumbing in fiber.h (no Python.h dependency, so pure-C++ targets compile
unchanged) and a cp3.14-gated implementation installed at module init from
_raylet.pyx. On Python < 3.14 the installer compiles to a no-op stub and the
hook stays null -- behavior is identical to upstream.
The hook leaks gilstate_counter by +1 once per fiber-runner OS thread so the
PyThreadState we wrote to isn't torn down by PyGILState_Release at counter==0
(which would let the user callback's own PyGILState_Ensure allocate a fresh
tstate and overwrite our c_stack_* via _Py_InitializeRecursionLimits). The
GIL itself stays releasable through Ray's existing `with nogil:` block around
YieldCurrentFiber, which calls PyEval_SaveThread directly and bypasses
gilstate_counter -- so AsyncIO Thread can still acquire the GIL during awaits.
Verified by the hello_world_py314 release test and the new
FiberStateTest.FiberPreCallbackFiresBeforeUserCallbackAndIsOptional gtest.
Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to re-anchor CPython 3.14 recursion limits when executing tasks on fibers, preventing fatal stack overflow errors caused by mismatched stack boundaries. The implementation adds a pre-callback hook to the FiberState class and a Cython-based handler that adjusts internal CPython thread state fields. Feedback highlights several critical areas for improvement: the use of non-portable GCC intrinsics that will break Windows builds, the fragility of hardcoded memory offsets for internal CPython structures, a potentially insufficient conditional check for re-anchoring, and a data race on the static callback member that should be addressed using atomics.
| PyThreadState *tstate = PyThreadState_Get(); | ||
| uintptr_t *c_stack = | ||
| (uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t)); | ||
| uintptr_t here = (uintptr_t)__builtin_frame_address(0); |
There was a problem hiding this comment.
__builtin_frame_address is a GCC/Clang intrinsic and is not supported by the MSVC compiler used on Windows. This will break the Ray build on Windows for Python 3.14. Consider using a more portable way to obtain a stack address, such as taking the address of a local variable (e.g., uintptr_t here = (uintptr_t)&here;), or using an #ifdef block to provide an MSVC-compatible intrinsic like _AddressOfReturnAddress().
| PyGILState_STATE g = PyGILState_Ensure(); | ||
| PyThreadState *tstate = PyThreadState_Get(); | ||
| uintptr_t *c_stack = | ||
| (uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t)); |
There was a problem hiding this comment.
The offset calculation for c_stack fields is extremely fragile as it relies on the internal memory layout of _PyThreadStateImpl in CPython 3.14. While the comment notes this, using sizeof(PyThreadState) is risky because PyThreadState is intended to be an opaque handle in the public API. If it becomes an incomplete type in future releases, this will fail to compile. Additionally, compiler-specific padding could invalidate these hardcoded offsets. A more robust approach would be to use a small C++ helper that includes the internal headers if Py_BUILD_CORE is defined, or at least add a static assertion to verify the expected layout.
| uintptr_t *c_stack = | ||
| (uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t)); | ||
| uintptr_t here = (uintptr_t)__builtin_frame_address(0); | ||
| if (here <= c_stack[1] /* c_stack_soft_limit */) { |
There was a problem hiding this comment.
The conditional check if (here <= c_stack[1]) might be insufficient. If the fiber stack is allocated at a significantly higher memory address than the original pthread stack, here will be greater than c_stack_soft_limit, and the re-anchoring will be skipped. This would leave the Python interpreter with an incorrect (and likely much larger) recursion budget, potentially leading to a physical stack overflow of the 256 KiB fiber stack before the Python recursion guard trips. It is safer to always re-anchor the limits when entering a new fiber to ensure they match the fiber's actual stack boundaries.
| private: | ||
| static constexpr size_t kStackSize = 1024 * 256; | ||
|
|
||
| inline static FiberPreCallback fiber_pre_callback_ = nullptr; |
There was a problem hiding this comment.
Accessing fiber_pre_callback_ from multiple threads (the thread calling SetFiberPreCallback and the fiber runner thread) without synchronization is a data race. Although it is typically set once during module initialization, the test suite resets it to nullptr at the end of tests, which could lead to undefined behavior if a fiber is still running or being dispatched. Consider making this member a std::atomic<FiberPreCallback> to ensure thread-safe access and proper memory visibility across threads. Note that this will require including <atomic>.
Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
| PyGILState_STATE g = PyGILState_Ensure(); | ||
| PyThreadState *tstate = PyThreadState_Get(); | ||
| uintptr_t *c_stack = | ||
| (uintptr_t *)((char *)tstate + sizeof(PyThreadState) + sizeof(Py_ssize_t)); |
There was a problem hiding this comment.
Wrong struct offset misses _PyInterpreterFrame base_frame field
High Severity
The offset calculation sizeof(PyThreadState) + sizeof(Py_ssize_t) to reach c_stack_top is incorrect. The actual CPython 3.14 _PyThreadStateImpl layout has _PyInterpreterFrame base_frame between PyThreadState base and Py_ssize_t refcount, but the code omits sizeof(_PyInterpreterFrame) from the offset. This causes c_stack to point into base_frame instead of the c_stack_* fields. Reads from c_stack[1] get a garbage value (not c_stack_soft_limit), and writes to c_stack[0..2] corrupt base_frame rather than updating the recursion limits, so the fix either silently corrupts memory or fails to re-anchor (leaving the original crash).
Additional Locations (1)
Reviewed by Cursor Bugbot for commit fac4017. Configure here.
|
This pull request has been automatically marked as stale because it has not had You can always ask for help on our discussion forum or Ray's public slack channel. If you'd like to keep this open, just leave any comment, and the stale label will be removed. |
| c_stack[0] = here; // c_stack_top | ||
| c_stack[2] = here - budget; // c_stack_hard_limit | ||
| c_stack[1] = here - (budget - margin); // c_stack_soft_limit | ||
| } |
There was a problem hiding this comment.
Shared stack limits across fibers
High Severity
On CPython 3.14 the fiber pre-hook rewrites one PyThreadState’s c_stack_* only when here <= c_stack_soft_limit, so shallow entry on later tasks often skips re-anchoring. It also runs once per task before the callback, not when a fiber resumes after YieldCurrentFiber. With async actors’ high default max_concurrency, overlapping fibers can share stale limits and hit fatal stack overflow again after awaits or on later tasks.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4054e97. Configure here.
| /// extension at module init; pure-C++ targets keep it null. A plain C | ||
| /// function pointer keeps Python.h out of this header. | ||
| using FiberPreCallback = void (*)(); | ||
| static void SetFiberPreCallback(FiberPreCallback cb) { fiber_pre_callback_ = cb; } |
There was a problem hiding this comment.
Missing Doxygen on SetFiberPreCallback
Low Severity
The new static SetFiberPreCallback declaration is not documented with a Doxygen block that includes @param for its callback argument, as required for new C++ API in this project.
Triggered by project rule: Bugbot Rules
Reviewed by Cursor Bugbot for commit 4054e97. Configure here.
|
I tried this on CPU with 12 threads, this actually makes the GIL lock all the other threads other than the main after loading neural networks into the RAM |
Bring the cp3.14 async-actor fiber-stack fix up to date with master. The core fix (fiber.h, _raylet.pyx) merged cleanly; master's unrelated Event -> FiberEvent/StdEvent refactor in fiber.h merged in alongside it. The only conflict was in fiber_state_test.cc, where master and this branch each appended new tests at the same location; resolved by keeping all of them (FiberPreCallbackFiresBeforeUserCallbackAndIsOptional plus master's DrainsInFlightFibersBeforeStopping and DestructorStopsAndJoins). Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit de17005. Configure here.
| } else { | ||
| gil_leaked = true; | ||
| (void)g; | ||
| } |
There was a problem hiding this comment.
First hook leaves GIL held
High Severity
On each fiber-runner OS thread, the first RayReanchorPyRecursionLimitsOnCurrentFiber call runs PyGILState_Ensure but skips PyGILState_Release to leak gilstate_counter. That pairing leaves the GIL acquired on that thread after the pre-callback, which can block other worker threads from running Python and matches reported GIL contention under multi-threaded workloads.
Reviewed by Cursor Bugbot for commit de17005. Configure here.
|
Closing in favor of #64772 |
…protection to fiber stacks (#64772) # Description On Python 3.14 + Linux, every async-actor task permanently leaks ~518 KiB of live malloc (the per-task `asyncio.Task`, `concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack `Packer`s with 256 KiB internal buffers). Closes #63290 ### Root cause **1. CPython 3.14 changed how it avoids stack overflow when freeing objects.** Freeing one object can recursively free many others (a dict frees its values, which free their contents, …), and each level is a nested C call. To keep that from overflowing the C stack, CPython has long had a safety mechanism (the "trashcan"): when it decides it's too deep, it doesn't free the object right away. Instead it parks the object on a per-thread *delete-later* list and drains the list once there's stack headroom again. Up to 3.13, "too deep" was a simple recursion counter. In 3.14 it's decided by comparing the actual machine **stack pointer** against the stack bounds CPython recorded for the thread when it attached (from pthreads, on Linux). **2. Ray async actors don't run task code on the thread's normal stack.** Each task executes on a small 256 KiB boost fiber stack allocated elsewhere in memory. The problem is that CPython still thinks the thread runs on its original pthread stack. So while a task runs on a fiber, every "am I near the stack limit?" check compares the fiber's stack pointer against the *pthread* stack's bounds. On Linux, fiber stacks happen to be allocated at lower addresses than the pthread stack, so CPython concludes the stack is hopelessly overflowed and parks **every** object freed during the task (including return-value serialization and end-of-task cleanup) on the delete-later list. That list is only ever drained by a later free on the same thread state at a healthy stack margin, which never happens here as the Ray thread only runs on fibers and Ray creates a fresh Python thread state per task and destroys it at task end. This means that CPython destroys a thread state **without draining its delete-later list** and the parked objects are orphaned permanently. That's the leak. Why the confusing symptoms: - `boost::make_fcontext` in the issue's flamegraphs just marks *where* the leaked allocations were made (on a fiber stack); the fiber stacks themselves are freed correctly. - macOS is unaffected only by luck: fiber stacks there land at *higher* addresses than the pthread stack, so the check passes. - 3.13 and earlier are unaffected because their trashcan uses the counter, not the stack pointer. ### Fix CPython 3.14.2 added an official API for exactly this situation: `PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets an embedder tell CPython "this thread is currently executing on *this* stack." We call it with the fiber's stack bounds: - at async-actor task entry in `task_execution_handler`, and - whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers share the thread state, so each must re-register its own stack). With the bounds correct, the near-limit check returns to normal behavior: objects are freed immediately, and the rare genuinely-deep free is parked and then properly drained. Implementation notes: the symbol is looked up via `dlsym`, so `_raylet` still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a more severe, since-fixed stack-check bug anyway, python/cpython#141944). No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are derived from the current stack pointer minus a conservative allowance for stack already used, so the protection errs toward triggering slightly early rather than missing an overflow. Side benefit: fibers gain real C-stack overflow protection (RecursionError) on 3.14, which they currently lack entirely (`boost::fibers::fixedsize_stack` has no guard pages). Also makes `FiberState::kStackSize` public so the anchoring uses the real fiber stack size. ## Related issue number Closes #63290. Supersedes #63284 (same diagnosis direction, but hand-rolled `_PyThreadStateImpl` offsets, a deliberate `gilstate_counter` leak that freezes non-main threads, and a crash premise that CPython 3.14.2 already fixed upstream). ## Checks - Verified with a locally built cp314 Linux (aarch64, python:3.14.6 docker) wheel: - refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks) - `__del__` deferral probe: dealloc during return serialization on the fiber **deferred → immediate** - live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3 KiB/task** across async call → dict/bytes, async generator, sync generator on async actor - reporter-shaped streaming workload (400 tasks, 10 concurrent sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped regions 0 → 0 - async-actor smoke: correctness (echo, state, async generators, recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s) - throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs baseline, same container image): fixed 5624–6076 tasks/s vs unpatched 4551–4825 tasks/s meaning no regression (the unpatched build is slower while leaking) - baseline (unpatched) wheel from the same tree reproduces the bug: +4.00 refs/task, fiber dealloc deferred=True note: fable did a majority of the heavy lifting in this investigation with prompting on what to check next and validate the solution --------- Signed-off-by: Mark Towers <mark@anyscale.com> Signed-off-by: myan <myan@anyscale.com> Co-authored-by: Mark Towers <mark@anyscale.com> Co-authored-by: myan <myan@anyscale.com> Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
#65177) …protection to fiber stacks (#64772) # Description On Python 3.14 + Linux, every async-actor task permanently leaks ~518 KiB of live malloc (the per-task `asyncio.Task`, `concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack `Packer`s with 256 KiB internal buffers). Closes #63290 ### Root cause **1. CPython 3.14 changed how it avoids stack overflow when freeing objects.** Freeing one object can recursively free many others (a dict frees its values, which free their contents, …), and each level is a nested C call. To keep that from overflowing the C stack, CPython has long had a safety mechanism (the "trashcan"): when it decides it's too deep, it doesn't free the object right away. Instead it parks the object on a per-thread *delete-later* list and drains the list once there's stack headroom again. Up to 3.13, "too deep" was a simple recursion counter. In 3.14 it's decided by comparing the actual machine **stack pointer** against the stack bounds CPython recorded for the thread when it attached (from pthreads, on Linux). **2. Ray async actors don't run task code on the thread's normal stack.** Each task executes on a small 256 KiB boost fiber stack allocated elsewhere in memory. The problem is that CPython still thinks the thread runs on its original pthread stack. So while a task runs on a fiber, every "am I near the stack limit?" check compares the fiber's stack pointer against the *pthread* stack's bounds. On Linux, fiber stacks happen to be allocated at lower addresses than the pthread stack, so CPython concludes the stack is hopelessly overflowed and parks **every** object freed during the task (including return-value serialization and end-of-task cleanup) on the delete-later list. That list is only ever drained by a later free on the same thread state at a healthy stack margin, which never happens here as the Ray thread only runs on fibers and Ray creates a fresh Python thread state per task and destroys it at task end. This means that CPython destroys a thread state **without draining its delete-later list** and the parked objects are orphaned permanently. That's the leak. Why the confusing symptoms: - `boost::make_fcontext` in the issue's flamegraphs just marks *where* the leaked allocations were made (on a fiber stack); the fiber stacks themselves are freed correctly. - macOS is unaffected only by luck: fiber stacks there land at *higher* addresses than the pthread stack, so the check passes. - 3.13 and earlier are unaffected because their trashcan uses the counter, not the stack pointer. ### Fix CPython 3.14.2 added an official API for exactly this situation: `PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets an embedder tell CPython "this thread is currently executing on *this* stack." We call it with the fiber's stack bounds: - at async-actor task entry in `task_execution_handler`, and - whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers share the thread state, so each must re-register its own stack). With the bounds correct, the near-limit check returns to normal behavior: objects are freed immediately, and the rare genuinely-deep free is parked and then properly drained. Implementation notes: the symbol is looked up via `dlsym`, so `_raylet` still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a more severe, since-fixed stack-check bug anyway, python/cpython#141944). No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are derived from the current stack pointer minus a conservative allowance for stack already used, so the protection errs toward triggering slightly early rather than missing an overflow. Side benefit: fibers gain real C-stack overflow protection (RecursionError) on 3.14, which they currently lack entirely (`boost::fibers::fixedsize_stack` has no guard pages). Also makes `FiberState::kStackSize` public so the anchoring uses the real fiber stack size. ## Related issue number Closes #63290. Supersedes #63284 (same diagnosis direction, but hand-rolled `_PyThreadStateImpl` offsets, a deliberate `gilstate_counter` leak that freezes non-main threads, and a crash premise that CPython 3.14.2 already fixed upstream). ## Checks - Verified with a locally built cp314 Linux (aarch64, python:3.14.6 docker) wheel: - refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks) - `__del__` deferral probe: dealloc during return serialization on the fiber **deferred → immediate** - live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3 KiB/task** across async call → dict/bytes, async generator, sync generator on async actor - reporter-shaped streaming workload (400 tasks, 10 concurrent sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped regions 0 → 0 - async-actor smoke: correctness (echo, state, async generators, recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s) - throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs baseline, same container image): fixed 5624–6076 tasks/s vs unpatched 4551–4825 tasks/s meaning no regression (the unpatched build is slower while leaking) - baseline (unpatched) wheel from the same tree reproduces the bug: +4.00 refs/task, fiber dealloc deferred=True note: fable did a majority of the heavy lifting in this investigation with prompting on what to check next and validate the solution --------- (cherry picked from commit 35591ba) Signed-off-by: Mark Towers <mark@anyscale.com> Signed-off-by: myan <myan@anyscale.com> Signed-off-by: elliot-barn <elliot.barnwell@anyscale.com> Co-authored-by: Mark Towers <mark.m.towers@gmail.com> Co-authored-by: Mark Towers <mark@anyscale.com> Co-authored-by: myan <myan@anyscale.com> Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
…protection to fiber stacks (ray-project#64772) # Description On Python 3.14 + Linux, every async-actor task permanently leaks ~518 KiB of live malloc (the per-task `asyncio.Task`, `concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack `Packer`s with 256 KiB internal buffers). Closes ray-project#63290 ### Root cause **1. CPython 3.14 changed how it avoids stack overflow when freeing objects.** Freeing one object can recursively free many others (a dict frees its values, which free their contents, …), and each level is a nested C call. To keep that from overflowing the C stack, CPython has long had a safety mechanism (the "trashcan"): when it decides it's too deep, it doesn't free the object right away. Instead it parks the object on a per-thread *delete-later* list and drains the list once there's stack headroom again. Up to 3.13, "too deep" was a simple recursion counter. In 3.14 it's decided by comparing the actual machine **stack pointer** against the stack bounds CPython recorded for the thread when it attached (from pthreads, on Linux). **2. Ray async actors don't run task code on the thread's normal stack.** Each task executes on a small 256 KiB boost fiber stack allocated elsewhere in memory. The problem is that CPython still thinks the thread runs on its original pthread stack. So while a task runs on a fiber, every "am I near the stack limit?" check compares the fiber's stack pointer against the *pthread* stack's bounds. On Linux, fiber stacks happen to be allocated at lower addresses than the pthread stack, so CPython concludes the stack is hopelessly overflowed and parks **every** object freed during the task (including return-value serialization and end-of-task cleanup) on the delete-later list. That list is only ever drained by a later free on the same thread state at a healthy stack margin, which never happens here as the Ray thread only runs on fibers and Ray creates a fresh Python thread state per task and destroys it at task end. This means that CPython destroys a thread state **without draining its delete-later list** and the parked objects are orphaned permanently. That's the leak. Why the confusing symptoms: - `boost::make_fcontext` in the issue's flamegraphs just marks *where* the leaked allocations were made (on a fiber stack); the fiber stacks themselves are freed correctly. - macOS is unaffected only by luck: fiber stacks there land at *higher* addresses than the pthread stack, so the check passes. - 3.13 and earlier are unaffected because their trashcan uses the counter, not the stack pointer. ### Fix CPython 3.14.2 added an official API for exactly this situation: `PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets an embedder tell CPython "this thread is currently executing on *this* stack." We call it with the fiber's stack bounds: - at async-actor task entry in `task_execution_handler`, and - whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers share the thread state, so each must re-register its own stack). With the bounds correct, the near-limit check returns to normal behavior: objects are freed immediately, and the rare genuinely-deep free is parked and then properly drained. Implementation notes: the symbol is looked up via `dlsym`, so `_raylet` still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a more severe, since-fixed stack-check bug anyway, python/cpython#141944). No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are derived from the current stack pointer minus a conservative allowance for stack already used, so the protection errs toward triggering slightly early rather than missing an overflow. Side benefit: fibers gain real C-stack overflow protection (RecursionError) on 3.14, which they currently lack entirely (`boost::fibers::fixedsize_stack` has no guard pages). Also makes `FiberState::kStackSize` public so the anchoring uses the real fiber stack size. ## Related issue number Closes ray-project#63290. Supersedes ray-project#63284 (same diagnosis direction, but hand-rolled `_PyThreadStateImpl` offsets, a deliberate `gilstate_counter` leak that freezes non-main threads, and a crash premise that CPython 3.14.2 already fixed upstream). ## Checks - Verified with a locally built cp314 Linux (aarch64, python:3.14.6 docker) wheel: - refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks) - `__del__` deferral probe: dealloc during return serialization on the fiber **deferred → immediate** - live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3 KiB/task** across async call → dict/bytes, async generator, sync generator on async actor - reporter-shaped streaming workload (400 tasks, 10 concurrent sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped regions 0 → 0 - async-actor smoke: correctness (echo, state, async generators, recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s) - throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs baseline, same container image): fixed 5624–6076 tasks/s vs unpatched 4551–4825 tasks/s meaning no regression (the unpatched build is slower while leaking) - baseline (unpatched) wheel from the same tree reproduces the bug: +4.00 refs/task, fiber dealloc deferred=True note: fable did a majority of the heavy lifting in this investigation with prompting on what to check next and validate the solution --------- Signed-off-by: Mark Towers <mark@anyscale.com> Signed-off-by: myan <myan@anyscale.com> Co-authored-by: Mark Towers <mark@anyscale.com> Co-authored-by: myan <myan@anyscale.com> Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com>
…protection to fiber stacks (ray-project#64772) # Description On Python 3.14 + Linux, every async-actor task permanently leaks ~518 KiB of live malloc (the per-task `asyncio.Task`, `concurrent.futures.Future`, Cython coroutine + scopes, and two msgpack `Packer`s with 256 KiB internal buffers). Closes ray-project#63290 ### Root cause **1. CPython 3.14 changed how it avoids stack overflow when freeing objects.** Freeing one object can recursively free many others (a dict frees its values, which free their contents, …), and each level is a nested C call. To keep that from overflowing the C stack, CPython has long had a safety mechanism (the "trashcan"): when it decides it's too deep, it doesn't free the object right away. Instead it parks the object on a per-thread *delete-later* list and drains the list once there's stack headroom again. Up to 3.13, "too deep" was a simple recursion counter. In 3.14 it's decided by comparing the actual machine **stack pointer** against the stack bounds CPython recorded for the thread when it attached (from pthreads, on Linux). **2. Ray async actors don't run task code on the thread's normal stack.** Each task executes on a small 256 KiB boost fiber stack allocated elsewhere in memory. The problem is that CPython still thinks the thread runs on its original pthread stack. So while a task runs on a fiber, every "am I near the stack limit?" check compares the fiber's stack pointer against the *pthread* stack's bounds. On Linux, fiber stacks happen to be allocated at lower addresses than the pthread stack, so CPython concludes the stack is hopelessly overflowed and parks **every** object freed during the task (including return-value serialization and end-of-task cleanup) on the delete-later list. That list is only ever drained by a later free on the same thread state at a healthy stack margin, which never happens here as the Ray thread only runs on fibers and Ray creates a fresh Python thread state per task and destroys it at task end. This means that CPython destroys a thread state **without draining its delete-later list** and the parked objects are orphaned permanently. That's the leak. Why the confusing symptoms: - `boost::make_fcontext` in the issue's flamegraphs just marks *where* the leaked allocations were made (on a fiber stack); the fiber stacks themselves are freed correctly. - macOS is unaffected only by luck: fiber stacks there land at *higher* addresses than the pthread stack, so the check passes. - 3.13 and earlier are unaffected because their trashcan uses the counter, not the stack pointer. ### Fix CPython 3.14.2 added an official API for exactly this situation: `PyUnstable_ThreadState_SetStackProtection` (python/cpython#141661) lets an embedder tell CPython "this thread is currently executing on *this* stack." We call it with the fiber's stack bounds: - at async-actor task entry in `task_execution_handler`, and - whenever a fiber resumes after `YieldCurrentFiber` (concurrent fibers share the thread state, so each must re-register its own stack). With the bounds correct, the near-limit check returns to normal behavior: objects are freed immediately, and the rare genuinely-deep free is parked and then properly drained. Implementation notes: the symbol is looked up via `dlsym`, so `_raylet` still imports on 3.14.0/3.14.1 (fix skipped there; those releases have a more severe, since-fixed stack-check bug anyway, python/cpython#141944). No-op below 3.14 (preprocessor-gated) and on Windows. Stack bounds are derived from the current stack pointer minus a conservative allowance for stack already used, so the protection errs toward triggering slightly early rather than missing an overflow. Side benefit: fibers gain real C-stack overflow protection (RecursionError) on 3.14, which they currently lack entirely (`boost::fibers::fixedsize_stack` has no guard pages). Also makes `FiberState::kStackSize` public so the anchoring uses the real fiber stack size. ## Related issue number Closes ray-project#63290. Supersedes ray-project#63284 (same diagnosis direction, but hand-rolled `_PyThreadStateImpl` offsets, a deliberate `gilstate_counter` leak that freezes non-main threads, and a crash premise that CPython 3.14.2 already fixed upstream). ## Checks - Verified with a locally built cp314 Linux (aarch64, python:3.14.6 docker) wheel: - refcount probe: **+4.00 refs/task → 0.00/task** (100 tasks) - `__del__` deferral probe: dealloc during return serialization on the fiber **deferred → immediate** - live-malloc probe (`mallinfo2`, 300 tasks/shape): **~518 KiB/task → ~3 KiB/task** across async call → dict/bytes, async generator, sync generator on async actor - reporter-shaped streaming workload (400 tasks, 10 concurrent sessions): live-malloc delta **0.2 MB total**, fiber-sized mapped regions 0 → 0 - async-actor smoke: correctness (echo, state, async generators, recursion), concurrency (20 overlapping 0.5 s sleeps in 0.51 s) - throughput A/B (500 sequential echo tasks, 3 runs fixed / 2 runs baseline, same container image): fixed 5624–6076 tasks/s vs unpatched 4551–4825 tasks/s meaning no regression (the unpatched build is slower while leaking) - baseline (unpatched) wheel from the same tree reproduces the bug: +4.00 refs/task, fiber dealloc deferred=True note: fable did a majority of the heavy lifting in this investigation with prompting on what to check next and validate the solution --------- Signed-off-by: Mark Towers <mark@anyscale.com> Signed-off-by: myan <myan@anyscale.com> Co-authored-by: Mark Towers <mark@anyscale.com> Co-authored-by: myan <myan@anyscale.com> Co-authored-by: Mengjin Yan <mengjinyan3@gmail.com> Signed-off-by: 400Ping <jiekaichang@apache.org>


Prerequisite to #63237
On cp3.14, every Ray async actor (any @ray.remote class with an
async defmethod) crashes the worker process before any user code runs:cp3.14's _Py_ReachedRecursionLimit compares the current frame address to c_stack_soft_limit on _PyThreadStateImpl. That limit is anchored to the OS thread's pthread stack at PyThreadState bind time. Ray's async-actor dispatcher (FiberState) runs every task on a 256 KiB boost::fibers fixedsize_stack -- a memory region distinct from the pthread stack -- so the very first Python call inside the fiber trips the guard.
Fix: install a hook on FiberState that runs inside every fiber before the user callback. The hook rewrites c_stack_top / c_stack_soft_limit / c_stack_hard_limit to bracket the current fiber-stack frame with a 192 KiB budget out of the 256 KiB fiber stack. The hook uses opaque function-pointer plumbing in fiber.h (no Python.h dependency, so pure-C++ targets compile unchanged) and a cp3.14-gated implementation installed at module init from _raylet.pyx. On Python < 3.14 the installer compiles to a no-op stub and the hook stays null -- behavior is identical to upstream.
The hook leaks gilstate_counter by +1 once per fiber-runner OS thread so the PyThreadState we wrote to isn't torn down by PyGILState_Release at counter==0 (which would let the user callback's own PyGILState_Ensure allocate a fresh tstate and overwrite our c_stack_* via _Py_InitializeRecursionLimits). The GIL itself stays releasable through Ray's existing
with nogil:block around YieldCurrentFiber, which calls PyEval_SaveThread directly and bypasses gilstate_counter -- so AsyncIO Thread can still acquire the GIL during awaits.Verified by the hello_world_py314 release test and the new FiberStateTest.FiberPreCallbackFiresBeforeUserCallbackAndIsOptional gtest.
Description
Related issues
Additional information