Skip to content
Draft
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
73 changes: 66 additions & 7 deletions mssql_python/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ def __init__(
# TODO: Think and implement scenarios for multi-threaded access
# to cursors
self._cursors = weakref.WeakSet()

# Track if connection has been closed to prevent cursors from
# trying to free handles after connection is closed (prevents segfault)
self._connection_closed = False

# Initialize output converters dictionary and its lock for thread safety
self._output_converters = {}
Expand Down Expand Up @@ -1482,7 +1486,7 @@ def rollback(self) -> None:
self._conn.rollback()
logger.info("Transaction rolled back successfully.")

def close(self) -> None:
def close(self, from_del: bool = False) -> None:
"""
Close the connection now (rather than whenever .__del__() is called).

Expand All @@ -1492,19 +1496,64 @@ def close(self) -> None:
trying to use the connection. Note that closing a connection without committing
the changes first will cause an implicit rollback to be performed.

Args:
from_del: If True, called from __del__ during GC - skip ODBC handle freeing
to prevent segfaults. GC can run at unpredictable times (e.g., during
SQLAlchemy event listener setup) and freeing handles then causes crashes.

Raises:
DatabaseError: If there is an error while closing the connection.
"""
# Close the connection
if self._closed:
return

# CRITICAL GC SAFETY: If called from __del__ during garbage collection,
# do ABSOLUTELY NOTHING. Not even set flags or nullify handles.
# Even the simplest operations (self._closed = True, return statement) trigger
# C++ ODBC operations that throw std::runtime_error: "Invalid transaction state"
# which calls std::terminate() and crashes the process.
#
# CRITICAL: Do NOT set self._conn = None! The C++ ConnectionHandle destructor
# calls rollback/disconnect which throws exceptions during GC. Keep the reference
# alive to prevent C++ destructor from running during GC.
#
# Better to leak all resources (handles, memory) than to crash. The OS will
# clean up handles when the process exits.
#
# This is fundamentally incompatible with Python's GC model. pyodbc uses C-level
# tp_dealloc for predictable cleanup timing. We can't easily convert to that
# without a major architectural refactor, so we accept resource leaks during GC.
if from_del:
return # DO NOTHING - not even flag setting, not even _conn nullification

# CRITICAL: Set connection closed flag BEFORE closing anything
# This prevents cursors from trying to free handles during/after connection close
self._connection_closed = True

# Close all cursors first, but don't let one failure stop the others
if hasattr(self, "_cursors"):
# Convert to list to avoid modification during iteration
cursors_to_close = list(self._cursors)
close_errors = []

# First pass: Invalidate cursor handles to prevent use-after-free
for cursor in cursors_to_close:
try:
# CRITICAL: Mark cursor as invalidated BEFORE clearing handle
# This tells cursor.close() to skip SQLFreeHandle entirely
if hasattr(cursor, '_invalidated'):
cursor._invalidated = True
# Mark handles as freed before closing connection
# This prevents cursors from trying to free already-freed handles
if hasattr(cursor, '_handle_freed'):
cursor._handle_freed = True
if hasattr(cursor, 'hstmt'):
cursor.hstmt = None
except Exception as e: # pylint: disable=broad-exception-caught
logger.warning("Error invalidating cursor handle: %s", e)

# Second pass: Close cursors
for cursor in cursors_to_close:
try:
if not cursor.closed:
Expand Down Expand Up @@ -1601,10 +1650,20 @@ def __del__(self) -> None:
is no longer needed.
This is a safety net to ensure resources are cleaned up
even if close() was not called explicitly.

CRITICAL GC SAFETY: Do ABSOLUTELY NOTHING during GC cleanup.
ANY operation (even calling close(from_del=True)) can trigger C++ ODBC
operations that throw uncatchable exceptions during garbage collection.

The C++ ODBC driver throws std::runtime_error: "Invalid transaction state"
when connections are cleaned up during GC, especially during SQLAlchemy
event listener setup. These exceptions bypass Python exception handling and
call std::terminate(), crashing the process.

pyodbc avoids this by using C-level tp_dealloc. We work around it by doing
NOTHING and letting the OS clean up ODBC handles at process exit. Better
to leak resources than crash.
"""
if "_closed" not in self.__dict__ or not self._closed:
try:
self.close()
except Exception as e:
# Dont raise exceptions from __del__ to avoid issues during garbage collection
logger.warning(f"Error during connection cleanup: {e}")
# DO ABSOLUTELY NOTHING - not even sys.is_finalizing() check
# Even the simplest operations can trigger C++ ODBC calls during GC
pass
Loading
Loading