From ebcde749109457ae5dda96803c706db4ed8f2b2f Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Fri, 19 Jun 2026 14:47:33 +0200 Subject: [PATCH 1/3] Verify interpreter state before logging into Python Before forwarding a record to Python, check that the interpreter is actually usable: query Py_IsInitialized via the pyo3 FFI and consult an atexit hook that flips a flag once finalization begins. If either says the interpreter is not usable, drop the message instead of crashing or hanging. - Add the `macros` feature to the main pyo3 dependency so the atexit hook can be defined with #[pyfunction]/wrap_pyfunction!. - Relax forbid(unsafe_code) to deny(unsafe_code) so the single Py_IsInitialized FFI call can be allowed locally. --- Cargo.toml | 4 +++- src/lib.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d69d3d1..8aa1b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,9 @@ kv = ["log/kv"] arc-swap = "~1" # It's OK to ask for std on log, because pyo3 needs it too. log = { version = "~0.4.21", default-features = false, features = ["std"] } -pyo3 = { version = ">=0.26,<0.30", default-features = false } +pyo3 = { version = ">=0.26,<0.30", default-features = false, features = [ + "macros", +] } [dev-dependencies] pyo3 = { version = ">=0.26,<0.30", default-features = false, features = [ diff --git a/src/lib.rs b/src/lib.rs index 4df4ab6..9316a17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![forbid(unsafe_code)] +#![deny(unsafe_code)] #![doc( html_root_url = "https://docs.rs/pyo3-log/0.2.1/pyo3-log/", test(attr(deny(warnings))), @@ -184,6 +184,7 @@ use std::cmp; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use arc_swap::ArcSwap; @@ -191,6 +192,52 @@ use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError}; use pyo3::prelude::*; use pyo3::types::PyTuple; +/// Set once the Python interpreter starts shutting down. +/// +/// Once the interpreter begins finalization, calling into it is no longer safe ‒ doing so can lead +/// to crashes or hangs. We register an [`atexit`](https://docs.python.org/3/library/atexit.html) +/// hook that flips this flag so we can stop forwarding log messages in time. +static PYTHON_FINALIZING: AtomicBool = AtomicBool::new(false); + +/// The atexit hook. Marks the interpreter as on its way out. +#[pyfunction] +fn pyo3_log_atexit() { + PYTHON_FINALIZING.store(true, Ordering::SeqCst); +} + +/// Registers the [`pyo3_log_atexit`] hook, idempotently. +/// +/// Multiple loggers (or repeated construction) would otherwise register the hook more than once. +/// The flag here keeps it to a single registration per process. +fn register_atexit(py: Python<'_>) -> PyResult<()> { + static REGISTERED: AtomicBool = AtomicBool::new(false); + if REGISTERED.swap(true, Ordering::SeqCst) { + return Ok(()); + } + + let atexit = py.import("atexit")?; + let hook = wrap_pyfunction!(pyo3_log_atexit, py)?; + atexit.call_method1("register", (hook,))?; + Ok(()) +} + +/// Checks whether it is safe to call into the Python interpreter. +/// +/// Returns `false` if either the interpreter is not initialized (according to the +/// [`Py_IsInitialized`][pyo3::ffi::Py_IsInitialized] FFI call) or our atexit hook has fired, +/// signalling that finalization has begun. In either case, calling into Python is unsafe and the +/// log message must be dropped. +fn interpreter_usable() -> bool { + if PYTHON_FINALIZING.load(Ordering::SeqCst) { + return false; + } + #[allow(unsafe_code)] + // SAFETY: Py_IsInitialized is safe to call at any time, including before initialization and + // after finalization. It only reads an interpreter status flag and takes no arguments. + let initialized = unsafe { pyo3::ffi::Py_IsInitialized() } != 0; + initialized +} + /// A handle into a [`Logger`], able to reset its caches. /// /// This handle can be used to manipulate a [`Logger`] even after it has been installed. It's main @@ -329,6 +376,7 @@ impl Logger { /// It defaults to having a filter for [`Debug`][LevelFilter::Debug]. pub fn new(py: Python<'_>, caching: Caching) -> PyResult { let logging = py.import("logging")?; + register_atexit(py)?; Ok(Self { top_filter: LevelFilter::Debug, filters: HashMap::new(), @@ -591,7 +639,10 @@ impl Log for Logger { fn log(&self, record: &Record) { let cache = self.lookup(record.target()); - if self.enabled_inner(record.metadata(), &cache) { + // Before calling into Python, make sure the interpreter is actually usable. If it isn't + // initialized yet or has started finalizing, calling into it would crash or hang, so we + // silently drop the message instead. + if self.enabled_inner(record.metadata(), &cache) && interpreter_usable() { Python::attach(|py| { // If an exception were triggered before this attempt to log, // store it to the side for now and restore it afterwards. From 645cafe3ce252e9fd3aac2885caf4cf49cb17304 Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Fri, 19 Jun 2026 16:51:42 +0200 Subject: [PATCH 2/3] Put interpreter-state checks behind a feature flag The Py_IsInitialized FFI call and atexit hook are now gated behind the optional `interpreter-state` feature. With the feature off, the crate keeps forbid(unsafe_code) and pulls in no extra pyo3 features; with it on, the checks run and forbid is relaxed to deny(unsafe_code). --- Cargo.toml | 7 ++++--- src/lib.rs | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8aa1b78..1b86f4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,14 +16,15 @@ rust-version = "1.74" [features] kv = ["log/kv"] +# Verify the Python interpreter is alive (initialized, not finalizing) before logging into it. +# Pulls in pyo3's `macros` feature for the atexit hook. +interpreter-state = ["pyo3/macros"] [dependencies] arc-swap = "~1" # It's OK to ask for std on log, because pyo3 needs it too. log = { version = "~0.4.21", default-features = false, features = ["std"] } -pyo3 = { version = ">=0.26,<0.30", default-features = false, features = [ - "macros", -] } +pyo3 = { version = ">=0.26,<0.30", default-features = false } [dev-dependencies] pyo3 = { version = ">=0.26,<0.30", default-features = false, features = [ diff --git a/src/lib.rs b/src/lib.rs index 9316a17..b3ac666 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ -#![deny(unsafe_code)] +#![cfg_attr(not(feature = "interpreter-state"), forbid(unsafe_code))] +#![cfg_attr(feature = "interpreter-state", deny(unsafe_code))] #![doc( html_root_url = "https://docs.rs/pyo3-log/0.2.1/pyo3-log/", test(attr(deny(warnings))), @@ -184,6 +185,7 @@ use std::cmp; use std::collections::HashMap; +#[cfg(feature = "interpreter-state")] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -197,9 +199,11 @@ use pyo3::types::PyTuple; /// Once the interpreter begins finalization, calling into it is no longer safe ‒ doing so can lead /// to crashes or hangs. We register an [`atexit`](https://docs.python.org/3/library/atexit.html) /// hook that flips this flag so we can stop forwarding log messages in time. +#[cfg(feature = "interpreter-state")] static PYTHON_FINALIZING: AtomicBool = AtomicBool::new(false); /// The atexit hook. Marks the interpreter as on its way out. +#[cfg(feature = "interpreter-state")] #[pyfunction] fn pyo3_log_atexit() { PYTHON_FINALIZING.store(true, Ordering::SeqCst); @@ -209,6 +213,7 @@ fn pyo3_log_atexit() { /// /// Multiple loggers (or repeated construction) would otherwise register the hook more than once. /// The flag here keeps it to a single registration per process. +#[cfg(feature = "interpreter-state")] fn register_atexit(py: Python<'_>) -> PyResult<()> { static REGISTERED: AtomicBool = AtomicBool::new(false); if REGISTERED.swap(true, Ordering::SeqCst) { @@ -227,6 +232,7 @@ fn register_atexit(py: Python<'_>) -> PyResult<()> { /// [`Py_IsInitialized`][pyo3::ffi::Py_IsInitialized] FFI call) or our atexit hook has fired, /// signalling that finalization has begun. In either case, calling into Python is unsafe and the /// log message must be dropped. +#[cfg(feature = "interpreter-state")] fn interpreter_usable() -> bool { if PYTHON_FINALIZING.load(Ordering::SeqCst) { return false; @@ -376,6 +382,7 @@ impl Logger { /// It defaults to having a filter for [`Debug`][LevelFilter::Debug]. pub fn new(py: Python<'_>, caching: Caching) -> PyResult { let logging = py.import("logging")?; + #[cfg(feature = "interpreter-state")] register_atexit(py)?; Ok(Self { top_filter: LevelFilter::Debug, @@ -641,8 +648,13 @@ impl Log for Logger { // Before calling into Python, make sure the interpreter is actually usable. If it isn't // initialized yet or has started finalizing, calling into it would crash or hang, so we - // silently drop the message instead. - if self.enabled_inner(record.metadata(), &cache) && interpreter_usable() { + // silently drop the message instead. (Only checked with the `interpreter-state` feature.) + #[cfg(feature = "interpreter-state")] + let usable = interpreter_usable(); + #[cfg(not(feature = "interpreter-state"))] + let usable = true; + + if self.enabled_inner(record.metadata(), &cache) && usable { Python::attach(|py| { // If an exception were triggered before this attempt to log, // store it to the side for now and restore it afterwards. From 4d4f22745b4563a0de9153b3db88c5360e9a4e4d Mon Sep 17 00:00:00 2001 From: Pavel Zwerschke Date: Fri, 19 Jun 2026 16:53:49 +0200 Subject: [PATCH 3/3] Rename feature to dangerous-shutdown-guard The check is racy -- the interpreter can begin finalizing between Py_IsInitialized returning true and the actual call into Python (per maintainer feedback on issue #30). The name and docs now reflect that this is a best-effort mitigation, not a guarantee. --- Cargo.toml | 9 ++++++--- src/lib.rs | 22 +++++++++++----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1b86f4a..99e57d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,12 @@ rust-version = "1.74" [features] kv = ["log/kv"] -# Verify the Python interpreter is alive (initialized, not finalizing) before logging into it. -# Pulls in pyo3's `macros` feature for the atexit hook. -interpreter-state = ["pyo3/macros"] +# Best-effort guard against logging into a Python interpreter that is shutting down. Checks +# Py_IsInitialized and an atexit hook before each log call. This is racy (the interpreter can +# start finalizing between the check and the call) and only reduces, not eliminates, the chance +# of a crash or hang during shutdown -- hence "dangerous". Pulls in pyo3's `macros` feature for +# the atexit hook. +dangerous-shutdown-guard = ["pyo3/macros"] [dependencies] arc-swap = "~1" diff --git a/src/lib.rs b/src/lib.rs index b3ac666..e782b29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ -#![cfg_attr(not(feature = "interpreter-state"), forbid(unsafe_code))] -#![cfg_attr(feature = "interpreter-state", deny(unsafe_code))] +#![cfg_attr(not(feature = "dangerous-shutdown-guard"), forbid(unsafe_code))] +#![cfg_attr(feature = "dangerous-shutdown-guard", deny(unsafe_code))] #![doc( html_root_url = "https://docs.rs/pyo3-log/0.2.1/pyo3-log/", test(attr(deny(warnings))), @@ -185,7 +185,7 @@ use std::cmp; use std::collections::HashMap; -#[cfg(feature = "interpreter-state")] +#[cfg(feature = "dangerous-shutdown-guard")] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -199,11 +199,11 @@ use pyo3::types::PyTuple; /// Once the interpreter begins finalization, calling into it is no longer safe ‒ doing so can lead /// to crashes or hangs. We register an [`atexit`](https://docs.python.org/3/library/atexit.html) /// hook that flips this flag so we can stop forwarding log messages in time. -#[cfg(feature = "interpreter-state")] +#[cfg(feature = "dangerous-shutdown-guard")] static PYTHON_FINALIZING: AtomicBool = AtomicBool::new(false); /// The atexit hook. Marks the interpreter as on its way out. -#[cfg(feature = "interpreter-state")] +#[cfg(feature = "dangerous-shutdown-guard")] #[pyfunction] fn pyo3_log_atexit() { PYTHON_FINALIZING.store(true, Ordering::SeqCst); @@ -213,7 +213,7 @@ fn pyo3_log_atexit() { /// /// Multiple loggers (or repeated construction) would otherwise register the hook more than once. /// The flag here keeps it to a single registration per process. -#[cfg(feature = "interpreter-state")] +#[cfg(feature = "dangerous-shutdown-guard")] fn register_atexit(py: Python<'_>) -> PyResult<()> { static REGISTERED: AtomicBool = AtomicBool::new(false); if REGISTERED.swap(true, Ordering::SeqCst) { @@ -232,7 +232,7 @@ fn register_atexit(py: Python<'_>) -> PyResult<()> { /// [`Py_IsInitialized`][pyo3::ffi::Py_IsInitialized] FFI call) or our atexit hook has fired, /// signalling that finalization has begun. In either case, calling into Python is unsafe and the /// log message must be dropped. -#[cfg(feature = "interpreter-state")] +#[cfg(feature = "dangerous-shutdown-guard")] fn interpreter_usable() -> bool { if PYTHON_FINALIZING.load(Ordering::SeqCst) { return false; @@ -382,7 +382,7 @@ impl Logger { /// It defaults to having a filter for [`Debug`][LevelFilter::Debug]. pub fn new(py: Python<'_>, caching: Caching) -> PyResult { let logging = py.import("logging")?; - #[cfg(feature = "interpreter-state")] + #[cfg(feature = "dangerous-shutdown-guard")] register_atexit(py)?; Ok(Self { top_filter: LevelFilter::Debug, @@ -648,10 +648,10 @@ impl Log for Logger { // Before calling into Python, make sure the interpreter is actually usable. If it isn't // initialized yet or has started finalizing, calling into it would crash or hang, so we - // silently drop the message instead. (Only checked with the `interpreter-state` feature.) - #[cfg(feature = "interpreter-state")] + // silently drop the message instead. (Only checked with the `dangerous-shutdown-guard` feature.) + #[cfg(feature = "dangerous-shutdown-guard")] let usable = interpreter_usable(); - #[cfg(not(feature = "interpreter-state"))] + #[cfg(not(feature = "dangerous-shutdown-guard"))] let usable = true; if self.enabled_inner(record.metadata(), &cache) && usable {