From a983c30a462394242ecd9449006e6c155b36aef3 Mon Sep 17 00:00:00 2001 From: Alexander Beedie Date: Fri, 14 Aug 2026 12:28:15 +0400 Subject: [PATCH] Don't touch Python state from destructors that outlive the interpreter --- .../pyconnection/pyconnection.hpp | 5 ++ src/python_import_cache.cpp | 8 +++ tests/fast/test_module.py | 59 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/include/duckdb_python/pyconnection/pyconnection.hpp b/src/include/duckdb_python/pyconnection/pyconnection.hpp index 638b0a4b..0341a8de 100644 --- a/src/include/duckdb_python/pyconnection/pyconnection.hpp +++ b/src/include/duckdb_python/pyconnection/pyconnection.hpp @@ -44,6 +44,11 @@ struct DefaultConnectionHolder { DefaultConnectionHolder() { } ~DefaultConnectionHolder() { + if (connection && (!nb::is_alive() || !PyGILState_Check())) { + // Can't free Python objects here; static destruction outlives the interpreter, and + // nb::is_alive() reports true when Py_Finalize is skipped (hence the GIL check). + new std::shared_ptr(std::move(connection)); // NOLINT: deliberate leak + } } public: diff --git a/src/python_import_cache.cpp b/src/python_import_cache.cpp index f3d1a04b..a41eabc1 100644 --- a/src/python_import_cache.cpp +++ b/src/python_import_cache.cpp @@ -79,6 +79,14 @@ nb::handle PythonImportCacheItem::Load(PythonImportCache &cache, nb::handle sour //===--------------------------------------------------------------------===// PythonImportCache::~PythonImportCache() { + if (!nb::is_alive()) { + // Process-global state, so this can run from static destruction: acquiring the GIL there + // is fatal and dropping the references without it is undefined behaviour, so leak them. + for (auto &object : owned_objects) { + object.release(); + } + return; + } try { nb::gil_scoped_acquire acquire; owned_objects.clear(); diff --git a/tests/fast/test_module.py b/tests/fast/test_module.py index 7cfd2041..ee18b1f2 100644 --- a/tests/fast/test_module.py +++ b/tests/fast/test_module.py @@ -1,3 +1,6 @@ +import subprocess +import sys + import duckdb @@ -10,3 +13,59 @@ def test_threadsafety(self): def test_apilevel(self): assert duckdb.apilevel == "2.0" + + +class TestModuleShutdown: + """Module state is static, so its members can be destroyed *after* the interpreter is gone. + + Deleting '_clean_default_connection' forces that: nothing then releases the import cache + or default connection while the interpreter is alive, so both are torn down from static + destruction, where neither may touch the GIL. + """ + + def test_module_state_freed_after_finalize(self): + code = """\ +import _duckdb +import duckdb + +del _duckdb._clean_default_connection +del duckdb._clean_default_connection + +assert duckdb.sql("select 42").fetchall() == [(42,)] +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, f"exit={result.returncode}\n{result.stderr}" + + def test_import_cache_released_on_a_normal_exit(self): + # Passing a duckdb.Value caches the 'duckdb' module itself, so an + # uncleared cache keeps it alive past nanobind's leak check. + code = """\ +import duckdb + +value = duckdb.Value('{"duck": 42}', duckdb.type("JSON")) +assert duckdb.execute("select typeof($1)", [value]).fetchone() == ("JSON",) +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, f"exit={result.returncode}\n{result.stderr}" + assert "leaked" not in result.stderr, result.stderr + + def test_no_crash_when_process_exits_without_finalize(self): + # nanobind stays 'alive' if Py_Finalize never runs as its cleanup is a Py_AtExit hook. + code = """\ +import ctypes +import sys + +import duckdb + +assert duckdb.sql("select 42").fetchall() == [(42,)] + +# ctypes drops the GIL and never returns, so static destruction runs without it. +if sys.platform == "win32": + ctypes.windll.kernel32.ExitProcess(0) +else: + ctypes.CDLL(None).exit(0) + +raise AssertionError("unreachable") +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, f"exit={result.returncode}\n{result.stderr}"