stop actor event loops before Python exits (#4757) - #4757
Open
shayne-fletcher wants to merge 1 commit into
Open
Conversation
Contributor
|
@shayne-fletcher has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117568016. |
shayne-fletcher
force-pushed
the
export-D117568016
branch
from
August 26, 2026 20:26
9288ae9 to
1c50ae3
Compare
Contributor
Author
|
Fixes #4749 |
shayne-fletcher
added a commit
to shayne-fletcher/monarch-1
that referenced
this pull request
Aug 26, 2026
Summary: GitHub issue: meta-pytorch#4749 GitHub pull request: meta-pytorch#4757 each `PythonActor` instance has its own asyncio event loop running on a dedicated daemon OS thread, created through Python's `threading.Thread`. today Python can begin exiting while one of those threads is still alive, which can crash the whole process. specifically, CPython can force an actor thread to exit while it still has Rust/PyO3 calls on its stack. that forced unwind aborts the process with `FATAL: exception not rethrown`. cpuhrsch's core dumps establish this exact failure path, and D117279357 identified and field-tested the key mitigation: stop actor event loops before Python finalizes. this diff builds on that idea and refines how the loops are found and stopped: - each actor-loop thread is marked with its loop before it starts, allowing shutdown to use Python's existing list of live threads instead of adding another global registry or lock; - the existing `shutdown_tokio_runtime` handler performs the work, so no second production `atexit` handler is added; - shutdown asks every marked loop to stop, releases the GIL so the threads can run, and polls Python's live-thread list until the shared deadline. this keeps shutdown bounded even on Python 3.14, where `Thread.is_alive()` can itself block; - every loop shares one 250 ms deadline instead of receiving its own timeout. the actual bound is 250 ms plus one final scan. the guard is deliberately best effort. it covers loops visible while it is scanning, but it can miss a loop created after it has scanned and found none. together with this fix, a deterministic subprocess test is added that follows the real shutdown path and reproduces precisely the observed failure: without the markers, Python 3.12 aborts during shutdown; with them, every actor loop stops before exit on Python 3.12 and GIL-enabled Python 3.14. focused Rust tests cover multiple loops and the deadline path. this is a temporary mitigation. it stops leftover actor loops just before Python exits, but it does not fix why they are still running. today actor cleanup can return before its loop thread ends, and the root client can report `Stopped` without running that cleanup. the pytokio-removal work will provide the lifecycle fix. once every actor owns and waits for its loop thread on every shutdown path, this mitigation and its marker can be removed. the Jobs API creates a simple process tree: it provisions worker host processes with `run_worker_loop_forever()`, and those worker hosts launch actor processes through `bootstrap_main`. a worker host hands control to Rust and ends with `process::exit(0)`, so it never runs Python finalization. the client and actor processes return through Python and do run `Py_FinalizeEx`; those are the processes this mitigation protects. Differential Revision: D117568016
shayne-fletcher
force-pushed
the
export-D117568016
branch
from
August 26, 2026 22:45
1c50ae3 to
a78f71f
Compare
Summary: GitHub issue: meta-pytorch#4749 GitHub pull request: meta-pytorch#4757 each `PythonActor` instance has its own asyncio event loop running on a dedicated daemon OS thread, created through Python's `threading.Thread`. today Python can begin exiting while one of those threads is still alive, which can crash the whole process. specifically, CPython can force an actor thread to exit while it still has Rust/PyO3 calls on its stack. that forced unwind aborts the process with `FATAL: exception not rethrown`. cpuhrsch's core dumps establish this exact failure path, and D117279357 identified and field-tested the key mitigation: stop actor event loops before Python finalizes. this diff builds on that idea and refines how the loops are found and stopped: - each actor-loop thread is marked with its loop before it starts, allowing shutdown to use Python's existing list of live threads instead of adding another global registry or lock; - the existing `shutdown_tokio_runtime` handler performs the work, so no second production `atexit` handler is added; - shutdown asks every marked loop to stop, releases the GIL so the threads can run, and polls Python's live-thread list until the shared deadline. this keeps shutdown bounded even on Python 3.14, where `Thread.is_alive()` can itself block; - every loop shares one 250 ms deadline instead of receiving its own timeout. the actual bound is 250 ms plus one final scan. the guard is deliberately best effort. it covers loops visible while it is scanning, but it can miss a loop created after it has scanned and found none. together with this fix, a deterministic subprocess test is added that follows the real shutdown path and reproduces precisely the observed failure: without the markers, Python 3.12 aborts during shutdown; with them, every actor loop stops before exit on Python 3.12 and GIL-enabled Python 3.14. focused Rust tests cover multiple loops and the deadline path. this is a temporary mitigation. it stops leftover actor loops just before Python exits, but it does not fix why they are still running. today actor cleanup can return before its loop thread ends, and the root client can report `Stopped` without running that cleanup. the pytokio-removal work will provide the lifecycle fix. once every actor owns and waits for its loop thread on every shutdown path, this mitigation and its marker can be removed. the Jobs API creates a simple process tree: it provisions worker host processes with `run_worker_loop_forever()`, and those worker hosts launch actor processes through `bootstrap_main`. a worker host hands control to Rust and ends with `process::exit(0)`, so it never runs Python finalization. the client and actor processes return through Python and do run `Py_FinalizeEx`; those are the processes this mitigation protects. Differential Revision: D117568016
shayne-fletcher
force-pushed
the
export-D117568016
branch
from
August 26, 2026 22:52
a78f71f to
1332809
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Aug 27, 2026
Summary: GitHub issue: #4749 GitHub pull request: #4757 each `PythonActor` has a Python-created OS thread running its asyncio event loop. actor shutdown can report success before that thread has stopped. if Python then finalizes the process, CPython can interrupt the thread while Rust/PyO3 frames remain on its stack, aborting the process with `FATAL: exception not rethrown`. this is not Tokio failing to clean up Python threads. this diff adds a bounded exit-time safety net: before `Py_FinalizeEx`, it finds those actor-loop threads and asks their loops to stop. it uses `shutdown_tokio_runtime` only because that is Monarch's existing Python-exit hook; Tokio did not create or own these threads. this is the first of two pieces. this diff mitigates the process-exit crash. follow-up lifecycle work, planned with pytokio removal, will make each actor stop and wait for its loop thread during normal shutdown. once that exists, this safety net can be removed. cpuhrsch's core dumps establish this exact failure path, and D117279357 identified and field-tested the key mitigation: stop actor event loops before Python finalizes. this diff builds on that idea and refines how the loops are found and stopped: - each actor-loop thread is marked with its loop before it starts, allowing shutdown to use Python's existing list of live threads instead of adding another global registry or lock; - the existing `shutdown_tokio_runtime` handler performs the work, so no second production `atexit` handler is added; - shutdown asks every marked loop to stop, releases the GIL so the threads can run, and polls Python's live-thread list until the shared deadline. this keeps shutdown bounded even on Python 3.14, where `Thread.is_alive()` can itself block; - every loop shares one 250 ms deadline instead of receiving its own timeout. the actual bound is 250 ms plus one final scan. the guard is deliberately best effort. it covers loops visible while it is scanning, but it can miss a loop created after it has scanned and found none. together with this fix, a deterministic subprocess test is added that follows the real shutdown path and reproduces precisely the observed failure: without the markers, Python 3.12 aborts during shutdown; with them, every actor loop stops before exit on Python 3.12 and GIL-enabled Python 3.14. focused Rust tests cover multiple loops and the deadline path. this is a temporary mitigation. it stops leftover actor loops just before Python exits, but it does not fix why they are still running. today actor cleanup can return before its loop thread ends, and the root client can report `Stopped` without running that cleanup. the pytokio-removal work will provide the lifecycle fix. once every actor owns and waits for its loop thread on every shutdown path, this mitigation and its marker can be removed. the Jobs API creates a simple process tree: it provisions worker host processes with `run_worker_loop_forever()`, and those worker hosts launch actor processes through `bootstrap_main`. a worker host hands control to Rust and ends with `process::exit(0)`, so it never runs Python finalization. the client and actor processes return through Python and do run `Py_FinalizeEx`; those are the processes this mitigation protects. Reviewed By: samlurye Differential Revision: D117568016 fbshipit-source-id: 00c62a8c474a465171be4bfc9155c3117fe46166
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
GitHub issue: #4749
GitHub pull request: #4757
each
PythonActorinstance has its own asyncio event loop running on a dedicated daemon OS thread, created through Python'sthreading.Thread. today Python can begin exiting while one of those threads is still alive, which can crash the whole process. specifically, CPython can force an actor thread to exit while it still has Rust/PyO3 calls on its stack. that forced unwind aborts the process withFATAL: exception not rethrown.cpuhrsch's core dumps establish this exact failure path, and D117279357 identified and field-tested the key mitigation: stop actor event loops before Python finalizes. this diff builds on that idea and refines how the loops are found and stopped:
shutdown_tokio_runtimehandler performs the work, so no second productionatexithandler is added;Thread.is_alive()can itself block;the guard is deliberately best effort. it covers loops visible while it is scanning, but it can miss a loop created after it has scanned and found none.
together with this fix, a deterministic subprocess test is added that follows the real shutdown path and reproduces precisely the observed failure: without the markers, Python 3.12 aborts during shutdown; with them, every actor loop stops before exit on Python 3.12 and GIL-enabled Python 3.14. focused Rust tests cover multiple loops and the deadline path.
this is a temporary mitigation. it stops leftover actor loops just before Python exits, but it does not fix why they are still running. today actor cleanup can return before its loop thread ends, and the root client can report
Stoppedwithout running that cleanup. the pytokio-removal work will provide the lifecycle fix. once every actor owns and waits for its loop thread on every shutdown path, this mitigation and its marker can be removed.the Jobs API creates a simple process tree: it provisions worker host processes with
run_worker_loop_forever(), and those worker hosts launch actor processes throughbootstrap_main. a worker host hands control to Rust and ends withprocess::exit(0), so it never runs Python finalization. the client and actor processes return through Python and do runPy_FinalizeEx; those are the processes this mitigation protects.Differential Revision: D117568016