Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/include/duckdb_python/pyconnection/pyconnection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<DuckDBPyConnection>(std::move(connection)); // NOLINT: deliberate leak
}
}

public:
Expand Down
8 changes: 8 additions & 0 deletions src/python_import_cache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions tests/fast/test_module.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import subprocess
import sys

import duckdb


Expand All @@ -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}"