finalize the Python interpreter on shutdown - #50
Open
jvantuyl wants to merge 1 commit into
Open
Conversation
When a normal Python interpreter exits, it runs a shutdown sequence called "finalization". Pythonx never ran it, so the Python shutdown sequence never happened, which causes subtle problems. Examples include, but are certainly not limited to: - files created with `tempfile` are not deleted - `shelve` changes not persisted - `logging` doesn't emit all logged messages - `readline` history is not written - `asyncio` thread pools don't drain or log some warnings - generator context managers don't run their exit hooks - suspended `async with` statements don't run their exit hooks - unclosed files and sockets linger until the BEAM VM exits - leaked `multiprocessing` semaphores That last one was triggered in the wild by `tqdm` when using `pytorch` in a Livebook, and led me down the rabbit hole that resulted in this code. ## Usage In normal applications where OTP is running things, you don't have to do anything. A new `GenServer` (`Pythonx.Finalizer`) triggers finalization when the application is stopped as part of a normal shutdown (i.e. `System.stop/0`). It logs a warning if `Py_FinalizeEx` returns non-zero. Its `shutdown` is `:infinity`: `Py_FinalizeEx` can take arbitrarily long (a loaded ML framework easily exceeds the 5s default), and giving up early would leave the NIF running on a dirty scheduler while the VM halts underneath it. In environments without a supervision tree (scripts, Mix tasks), call `Pythonx.finalize/0` manually. See its documentation for details. ## Options Two new compile-time config options: - `finalization`: enables or disables *automatic* finalization; manual finalization is always available (boolean; default: `true`) - `binaries`: how strings and bytes are decoded. `:fast` returns zero-copy references into Python memory, which can crash if accessed after finalization; `:safe` always copies (default: `:fast`, the existing behavior) ## Implementation ### OTP Supervision `Pythonx.Finalizer` is the last child in the app supervision tree, so it is stopped first and the `Janitor` is available throughout finalization. Initialization could live there too, but it comes in several flavors with their own side effects, so I left it alone. ### Thread Handling & State CPython binds the thread that runs `Py_InitializeEx` to `runtime->main_tstate` and requires `Py_FinalizeEx` to run on that same thread. When called from any other thread, CPython 3.13 does not swap the main thread state in, frees the calling thread's state as a "non-main" one, and then dereferences it while flushing std files. NIFs run on whichever dirty scheduler picks them up, so on any machine with more than one dirty CPU scheduler, finalizing after evals had spread across schedulers segfaulted deterministically. To address this, Pythonx now owns one native thread (`pythonx-main`) for exactly those two calls. `init` starts it and runs `Py_InitializeEx` there, keeping that thread state aside; `finalize` runs `PyEval_RestoreThread` + `Py_FinalizeEx` on it and joins it, so each re-initialization gets a fresh main thread. Everything that needs the NIF `env` stays on the dirty schedulers, which get their own thread states as before. Python sees this thread as `threading.main_thread()`; evals show up as `Dummy-N` threads. The `MainThread` object is intentionally leaked: if the process exits without finalizing (`finalization: false`, `System.halt/1`), destroying a joinable `std::thread` during static destruction would call `std::terminate` and abort the VM on its way out. ### Reinitializing Finalize + re-init works for the interpreter and pure-Python code, including the standard library (`threading`, `logging`, `atexit`), with CPython's documented per-cycle leaks. C extensions with process-global state are not guaranteed to survive, because CPython never unloads their shared libraries: `numpy` 2.1.2 raises a clean `RuntimeError` on second import, and `torch` 2.14.0 segfaults inside its module init. This is documented on `Pythonx.finalize/0`. Every `PyObjectResource` is stamped with an `init_generation`, a `steady_clock` timestamp set each time the interpreter is initialized. Using or cleaning up a resource from a previous generation fails with a clear error instead of following a dangling pointer. A timestamp rather than a counter makes ordering obvious and junk values recognizable. The public `Pythonx.finalize/0` refuses to run while the application is running, since finalization is the `Finalizer`'s job. `Pythonx.__finalize__/0` (undocumented) bypasses the guard for the `Finalizer` and tests. ### Concurrency Handling Three coordinating structures work together: - `init_mutex` (existing, prevents racing initialization) - `PyGILGuard` (existing, guards Python data structure access) - `active_threads_mutex` (new, guards an `active_threads` counter) We cannot take the `PyGILGuard` during finalization, since the GIL is torn down while we hold it. Instead, `active_threads` counts NIFs currently running Python code and `finalize` waits for it to reach zero, mimicking CPython's own wait for threads. Four operations could race with finalization: - rogue Python code running somewhere unsupervised - attempts to run new Python code - garbage collection of a `PyObjectResource` triggering a `decref` - `send_tagged_object` during finalization `ensure_initialized` now returns an `ActiveThreadGuard` by value and every NIF holds it in a local for its whole duration. The increment happens under `init_mutex`, which `finalize` holds throughout, so new code either sees an initialized interpreter and is counted before finalization can start, or blocks until finalization is done and then raises on an uninitialized interpreter. ### The Janitor Process During Finalization The finalize NIF sends `:finalizing` to the `Janitor` before waiting for in-flight threads and `:finalized` after `Py_FinalizeEx`, both while still holding `init_mutex`. Between them, `decref` messages are ignored: nothing is running, and `Py_FinalizeEx` reclaims the objects regardless of refcount. Since the BEAM delivers local messages in order, `:finalized` arrives before any `decref` from a newly initialized interpreter. I/O is still forwarded the whole time, since finalizers may print. In-flight `decref` calls block on the GIL, so `janitor_decref` also holds an `ActiveThreadGuard` and `finalize` waits for them too. Finalization runs Python code, which may call `pythonx.send_tagged_object`. Anything it references is about to be reclaimed, so it raises a `RuntimeError` during finalization instead of sending. ### Binaries & Python-allocated Memory `:fast` decoding returns resource binaries that reference Python memory directly. After finalization those references are freed memory. The `binaries` option selects copying (`:safe`) instead. Only `bytes` and `str` are affected; no other type is zero-copy. `PyObjectResource` instances are counted, and the `Finalizer` logs a warning if any survive finalization while `binaries` is `:fast`. `:fast` stays the default because it is the existing behavior. # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Tue Sep 1 06:25:03 2026 -0700 # # On branch main # Your branch and 'origin/main' have diverged, # and have 1 and 1 different commits each, respectively. # # Changes to be committed: # modified: Makefile # modified: README.md # new file: c_src/main_thread.hpp # modified: c_src/python.cpp # modified: c_src/python.hpp # modified: c_src/pythonx.cpp # modified: lib/pythonx.ex # modified: lib/pythonx/application.ex # new file: lib/pythonx/finalize_error.ex # new file: lib/pythonx/finalizer.ex # modified: lib/pythonx/janitor.ex # modified: lib/pythonx/nif.ex # modified: mix.exs # new file: test/pythonx/finalize_test.exs #
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.
Okay, so this is way bigger than I expected it to be. Sorry about that. I tried splitting it into a smaller chunks but I couldn't figure out a way to do this more incrementally.
I left out some stuff that supports using this with ExUnit sanely, so let me know if you want to see that.
No worries if you don't accept it. This was born in a fit of
ragehyperfocus after spending a week tracking down a really subtle issue that it fixes. I wrote it, so I figured I'd at least send it your way.Finalization
When a normal Python interpreter exits, it runs a shutdown sequence called "finalization". Pythonx never ran it, so the Python shutdown sequence never happened, which causes subtle problems. Examples include, but are certainly not limited to:
tempfileare not deletedshelvechanges not persistedloggingdoesn't emit all logged messagesreadlinehistory is not writtenasynciothread pools don't drain or log some warningsasync withstatements don't run their exit hooksmultiprocessingsemaphoresThat last one was triggered in the wild by
tqdmwhen usingpytorchin a Livebook, and led me down the rabbit hole that resulted in this code.Usage
In normal applications where OTP is running things, you don't have to do anything. A new
GenServer(Pythonx.Finalizer) triggers finalization when the application is stopped as part of a normal shutdown (i.e.System.stop/0).It logs a warning if
Py_FinalizeExreturns non-zero. Itsshutdownis:infinity:Py_FinalizeExcan take arbitrarily long (a loaded ML framework easily exceeds the 5s default), and giving up early would leave the NIF running on a dirty scheduler while the VM halts underneath it.In environments without a supervision tree (scripts, Mix tasks), call
Pythonx.finalize/0manually. See its documentation for details.Options
Two new compile-time config options:
finalization: enables or disables automatic finalization; manual finalization is always available (boolean; default:true)binaries: how strings and bytes are decoded.:fastreturns zero-copy references into Python memory, which can crash if accessed after finalization;:safealways copies (default::fast, the existing behavior)Implementation
OTP Supervision
Pythonx.Finalizeris the last child in the app supervision tree, so it is stopped first and theJanitoris available throughout finalization. Initialization could live there too, but it comes in several flavors with their own side effects, so I left it alone.Thread Handling & State
CPython binds the thread that runs
Py_InitializeExtoruntime->main_tstateand requiresPy_FinalizeExto run on that same thread. When called from any other thread, CPython 3.13 does not swap the main thread state in, frees the calling thread's state as a "non-main" one, and then dereferences it while flushing std files.NIFs run on whichever dirty scheduler picks them up, so on any machine with more than one dirty CPU scheduler, finalizing after evals had spread across schedulers segfaulted deterministically.
To address this, Pythonx now owns one native thread (
pythonx-main) for exactly those two calls.initstarts it and runsPy_InitializeExthere, keeping that thread state aside;finalizerunsPyEval_RestoreThread+Py_FinalizeExon it and joins it, so each re-initialization gets a fresh main thread.Everything that needs the NIF
envstays on the dirty schedulers, which get their own thread states as before. Python sees this thread asthreading.main_thread(); evals show up asDummy-Nthreads.The
MainThreadobject is intentionally leaked: if the process exits without finalizing (finalization: false,System.halt/1), destroying a joinablestd::threadduring static destruction would callstd::terminateand abort the VM on its way out.Reinitializing
Finalize + re-init works for the interpreter and pure-Python code, including the standard library (
threading,logging,atexit), with CPython's documented per-cycle leaks. C extensions with process-global state are not guaranteed to survive, because CPython never unloads their shared libraries:numpy2.1.2 raises a cleanRuntimeErroron second import, andtorch2.14.0 segfaults inside its module init. This is documented onPythonx.finalize/0.Every
PyObjectResourceis stamped with aninit_generation, asteady_clocktimestamp set each time the interpreter is initialized. Using or cleaning up a resource from a previous generation fails with a clear error instead of following a dangling pointer. A timestamp rather than a counter makes ordering obvious and junk values recognizable.The public
Pythonx.finalize/0refuses to run while the application is running, since finalization is theFinalizer's job.Pythonx.__finalize__/0(undocumented) bypasses the guard for theFinalizerand tests.Concurrency Handling
Three coordinating structures work together:
init_mutex(existing, prevents racing initialization)PyGILGuard(existing, guards Python data structure access)active_threads_mutex(new, guards anactive_threadscounter)We cannot take the
PyGILGuardduring finalization, since the GIL is torn down while we hold it. Instead,active_threadscounts NIFs currently running Python code andfinalizewaits for it to reach zero, mimicking CPython's own wait for threads.Four operations could race with finalization:
PyObjectResourcetriggering adecrefsend_tagged_objectduring finalizationensure_initializednow returns anActiveThreadGuardby value and every NIF holds it in a local for its whole duration. The increment happens underinit_mutex, whichfinalizeholds throughout, so new code either sees an initialized interpreter and is counted before finalization can start, or blocks until finalization is done and then raises on an uninitialized interpreter.The Janitor Process During Finalization
The finalize NIF sends
:finalizingto theJanitorbefore waiting for in-flight threads and:finalizedafterPy_FinalizeEx, both while still holdinginit_mutex. Between them,decrefmessages are ignored: nothing is running, andPy_FinalizeExreclaims the objects regardless of refcount. Since the BEAM delivers local messages in order,:finalizedarrives before anydecreffrom a newly initialized interpreter. I/O is still forwarded the whole time, since finalizers may print.In-flight
decrefcalls block on the GIL, sojanitor_decrefalso holds anActiveThreadGuardandfinalizewaits for them too.Finalization runs Python code, which may call
pythonx.send_tagged_object. Anything it references is about to be reclaimed, so it raises aRuntimeErrorduring finalization instead of sending.Binaries & Python-allocated Memory
:fastdecoding returns resource binaries that reference Python memory directly. After finalization those references are freed memory. Thebinariesoption selects copying (:safe) instead. Onlybytesandstrare affected; no other type is zero-copy.PyObjectResourceinstances are counted, and theFinalizerlogs a warning if any survive finalization whilebinariesis:fast.:faststays the default because it is the existing behavior.