Skip to content

stop actor event loops before Python exits (#4757) - #4757

Open
shayne-fletcher wants to merge 1 commit into
meta-pytorch:mainfrom
shayne-fletcher:export-D117568016
Open

stop actor event loops before Python exits (#4757)#4757
shayne-fletcher wants to merge 1 commit into
meta-pytorch:mainfrom
shayne-fletcher:export-D117568016

Conversation

@shayne-fletcher

@shayne-fletcher shayne-fletcher commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary:

GitHub issue: #4749
GitHub pull request: #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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 26, 2026
@meta-codesync

meta-codesync Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@shayne-fletcher has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117568016.

@shayne-fletcher

Copy link
Copy Markdown
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
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
@meta-codesync meta-codesync Bot changed the title stop actor event loops before Python exits stop actor event loops before Python exits (#4757) Aug 26, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant