Skip to content

Commit 0cd0b59

Browse files
committed
reenginnering the code for GC
1 parent e660892 commit 0cd0b59

3 files changed

Lines changed: 226 additions & 14 deletions

File tree

mssql_python/connection.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,7 +1486,7 @@ def rollback(self) -> None:
14861486
self._conn.rollback()
14871487
logger.info("Transaction rolled back successfully.")
14881488

1489-
def close(self) -> None:
1489+
def close(self, from_del: bool = False) -> None:
14901490
"""
14911491
Close the connection now (rather than whenever .__del__() is called).
14921492
@@ -1496,13 +1496,33 @@ def close(self) -> None:
14961496
trying to use the connection. Note that closing a connection without committing
14971497
the changes first will cause an implicit rollback to be performed.
14981498
1499+
Args:
1500+
from_del: If True, called from __del__ during GC - skip ODBC handle freeing
1501+
to prevent segfaults. GC can run at unpredictable times (e.g., during
1502+
SQLAlchemy event listener setup) and freeing handles then causes crashes.
1503+
14991504
Raises:
15001505
DatabaseError: If there is an error while closing the connection.
15011506
"""
15021507
# Close the connection
15031508
if self._closed:
15041509
return
15051510

1511+
# CRITICAL GC SAFETY: If called from __del__ during garbage collection,
1512+
# do ABSOLUTELY NOTHING. Not even set flags or nullify handles.
1513+
# Even the simplest operations (self._closed = True, return statement) trigger
1514+
# C++ ODBC operations that throw std::runtime_error: "Invalid transaction state"
1515+
# which calls std::terminate() and crashes the process.
1516+
#
1517+
# Better to leak all resources (handles, memory) than to crash. The OS will
1518+
# clean up handles when the process exits.
1519+
#
1520+
# This is fundamentally incompatible with Python's GC model. pyodbc uses C-level
1521+
# tp_dealloc for predictable cleanup timing. We can't easily convert to that
1522+
# without a major architectural refactor, so we accept resource leaks during GC.
1523+
if from_del:
1524+
return # DO NOTHING - not even flag setting
1525+
15061526
# CRITICAL: Set connection closed flag BEFORE closing anything
15071527
# This prevents cursors from trying to free handles during/after connection close
15081528
self._connection_closed = True
@@ -1626,10 +1646,29 @@ def __del__(self) -> None:
16261646
is no longer needed.
16271647
This is a safety net to ensure resources are cleaned up
16281648
even if close() was not called explicitly.
1649+
1650+
CRITICAL GC SAFETY: Do NOTHING during interpreter shutdown or active GC.
1651+
The Python GC can run at unpredictable times (e.g., during SQLAlchemy event
1652+
listener setup). ANY cleanup attempts (even setting self._closed=True) trigger
1653+
C++ ODBC operations that throw std::runtime_error: "Invalid transaction state".
1654+
This exception calls std::terminate() and crashes the process.
1655+
1656+
pyodbc avoids this by using C-level tp_dealloc instead of Python __del__,
1657+
which gives full control over cleanup timing. We work around it by completely
1658+
disabling cleanup during GC and relying on the OS to clean up handles at
1659+
process exit. Better to leak resources than crash.
16291660
"""
1661+
# CRITICAL: Skip ALL cleanup during interpreter shutdown
1662+
if sys.is_finalizing():
1663+
return
1664+
1665+
# CRITICAL: Skip ALL cleanup if connection already closed
1666+
# Even checking _closed can trigger operations, so check __dict__ directly
16301667
if "_closed" not in self.__dict__ or not self._closed:
16311668
try:
1632-
self.close()
1669+
# Pass from_del=True to minimize operations during GC cleanup
1670+
self.close(from_del=True)
16331671
except Exception as e:
1634-
# Dont raise exceptions from __del__ to avoid issues during garbage collection
1635-
logger.warning(f"Error during connection cleanup: {e}")
1672+
# Suppress ALL exceptions during GC - don't log, don't raise
1673+
# Even logger.warning() can trigger operations during GC
1674+
pass

mssql_python/cursor.py

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -843,16 +843,101 @@ def close(self, from_del: bool = False) -> None:
843843

844844
def _check_closed(self) -> None:
845845
"""
846-
Check if the cursor is closed and raise an exception if it is.
846+
Comprehensive three-tier cursor validity check.
847+
848+
Validates:
849+
1. Cursor closed flag
850+
2. Statement handle validity
851+
3. Connection validity and handle
852+
853+
This follows pyodbc's proven pattern of checking all three tiers
854+
to catch issues early with clear error messages.
847855
848856
Raises:
849-
ProgrammingError: If the cursor is closed.
857+
ProgrammingError: If cursor, statement, or connection is invalid
850858
"""
859+
# Tier 1: Cursor closed flag
851860
if self.closed:
852861
raise ProgrammingError(
853862
driver_error="Operation cannot be performed: The cursor is closed.",
854863
ddbc_error="",
855864
)
865+
866+
# Tier 2: Statement handle validity
867+
# Check if statement handle has been freed or is None
868+
if self.hstmt is None:
869+
raise ProgrammingError(
870+
driver_error="Statement handle has been freed",
871+
ddbc_error="The cursor's statement handle is None - it was freed or never allocated"
872+
)
873+
874+
# Check if the handle wrapper itself is valid (has get() method that returns non-None)
875+
if hasattr(self.hstmt, 'get'):
876+
try:
877+
handle_value = self.hstmt.get()
878+
if handle_value is None:
879+
raise ProgrammingError(
880+
driver_error="Statement ODBC handle is invalid",
881+
ddbc_error="The statement handle's underlying ODBC handle is None"
882+
)
883+
except Exception as e:
884+
raise ProgrammingError(
885+
driver_error="Statement handle is corrupted",
886+
ddbc_error=f"Failed to access statement handle: {e}"
887+
) from None
888+
889+
# Tier 3: Connection validity
890+
# Check if we have a connection reference
891+
if not hasattr(self, '_connection_ref'):
892+
raise ProgrammingError(
893+
driver_error="Connection reference is missing",
894+
ddbc_error="Cursor has no _connection_ref attribute - internal state corrupted"
895+
)
896+
897+
# Get connection from weak reference
898+
try:
899+
conn = self._connection_ref()
900+
except Exception as e:
901+
raise ProgrammingError(
902+
driver_error="Connection reference is invalid",
903+
ddbc_error=f"Failed to access connection reference: {e}"
904+
) from None
905+
906+
# Check if connection was garbage collected
907+
if conn is None:
908+
raise ProgrammingError(
909+
driver_error="Connection has been garbage collected",
910+
ddbc_error="The cursor's connection was garbage collected - cannot perform operations"
911+
)
912+
913+
# Check if connection was explicitly closed
914+
if hasattr(conn, '_connection_closed') and conn._connection_closed:
915+
raise ProgrammingError(
916+
driver_error="Connection has been closed",
917+
ddbc_error="The cursor's connection was closed - cannot perform operations"
918+
)
919+
920+
# Validate connection ODBC handle (if accessible)
921+
if hasattr(conn, 'hdbc'):
922+
if conn.hdbc is None:
923+
raise ProgrammingError(
924+
driver_error="Connection ODBC handle has been freed",
925+
ddbc_error="Connection handle (hdbc) is None - connection was closed"
926+
)
927+
# If hdbc has a get() method (SqlHandle wrapper), check underlying handle
928+
if hasattr(conn.hdbc, 'get'):
929+
try:
930+
hdbc_value = conn.hdbc.get()
931+
if hdbc_value is None:
932+
raise ProgrammingError(
933+
driver_error="Connection ODBC handle is invalid",
934+
ddbc_error="Connection handle points to NULL - connection was closed"
935+
)
936+
except Exception as e:
937+
raise ProgrammingError(
938+
driver_error="Connection handle is corrupted",
939+
ddbc_error=f"Failed to access connection handle: {e}"
940+
) from None
856941

857942
def setinputsizes(self, sizes: List[Union[int, tuple]]) -> None:
858943
"""
@@ -920,7 +1005,75 @@ def setinputsizes(self, sizes: List[Union[int, tuple]]) -> None:
9201005
f"Invalid SQL type: {sql_type}. Must be a valid SQL type constant."
9211006
)
9221007

923-
self._inputsizes.append((sql_type, 0, 0))
1008+
def _validate_connection_after_operation(self) -> None:
1009+
"""
1010+
Validate connection is still valid after an operation that released the GIL.
1011+
1012+
CRITICAL: This must be called immediately after any operation that calls into
1013+
C++/ODBC code (which releases the GIL), as another thread may have closed the
1014+
connection during the operation. This follows pyodbc's proven safety pattern.
1015+
1016+
This prevents use-after-free crashes by detecting when a connection was closed
1017+
by another thread while an ODBC operation was in progress.
1018+
1019+
Raises:
1020+
ProgrammingError: If connection was closed during operation or is invalid
1021+
"""
1022+
# Check if we even have a connection reference attribute
1023+
if not hasattr(self, '_connection_ref'):
1024+
raise ProgrammingError(
1025+
driver_error="Connection reference is missing",
1026+
ddbc_error="Cursor has no connection reference - cannot validate connection state"
1027+
)
1028+
1029+
# Try to get the connection from weak reference
1030+
try:
1031+
conn = self._connection_ref()
1032+
except Exception as e:
1033+
raise ProgrammingError(
1034+
driver_error="Connection reference is invalid",
1035+
ddbc_error=f"Failed to access connection reference: {e}"
1036+
) from None
1037+
1038+
# Check if connection was garbage collected
1039+
if conn is None:
1040+
raise ProgrammingError(
1041+
driver_error="The cursor's connection was closed or garbage collected",
1042+
ddbc_error=(
1043+
"The cursor's connection was closed by another thread or garbage collected "
1044+
"during the operation. This indicates a race condition where the connection "
1045+
"was freed while the cursor was still in use."
1046+
)
1047+
)
1048+
1049+
# Check if connection was explicitly closed
1050+
if hasattr(conn, '_connection_closed') and conn._connection_closed:
1051+
raise ProgrammingError(
1052+
driver_error="The cursor's connection was closed",
1053+
ddbc_error=(
1054+
"The cursor's connection was closed by another thread during the operation. "
1055+
"This is a race condition where Connection.close() was called while a cursor "
1056+
"operation was in progress."
1057+
)
1058+
)
1059+
1060+
# Additional validation: Check ODBC handle if accessible
1061+
# This catches cases where the connection object exists but ODBC handle is freed
1062+
if hasattr(conn, 'hdbc'):
1063+
if conn.hdbc is None:
1064+
raise ProgrammingError(
1065+
driver_error="Connection ODBC handle was freed",
1066+
ddbc_error=(
1067+
"Connection ODBC handle (hdbc) is None - connection was closed at the "
1068+
"ODBC level but Python object still exists"
1069+
)
1070+
)
1071+
# If hdbc has a get() method (SqlHandle wrapper), check the underlying handle
1072+
if hasattr(conn.hdbc, 'get') and conn.hdbc.get() is None:
1073+
raise ProgrammingError(
1074+
driver_error="Connection ODBC handle is invalid",
1075+
ddbc_error="Connection ODBC handle points to NULL - handle was freed"
1076+
)
9241077

9251078
def _reset_inputsizes(self) -> None:
9261079
"""Reset input sizes after execution"""
@@ -1423,6 +1576,12 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
14231576
use_prepare,
14241577
encoding_settings,
14251578
)
1579+
1580+
# CRITICAL: Validate connection immediately after C++ call that released GIL
1581+
# Another thread could have closed the connection during DDBCSQLExecute
1582+
# This prevents use-after-free crashes from race conditions
1583+
self._validate_connection_after_operation()
1584+
14261585
# Check return code
14271586
try:
14281587

@@ -2339,6 +2498,10 @@ def fetchone(self) -> Union[None, Row]:
23392498
char_decoding.get("encoding", "utf-8"),
23402499
wchar_decoding.get("encoding", "utf-16le"),
23412500
)
2501+
2502+
# CRITICAL: Validate connection immediately after fetch that released GIL
2503+
# Connection could have been closed during SQLFetch by another thread
2504+
self._validate_connection_after_operation()
23422505

23432506
if self.hstmt:
23442507
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
@@ -2399,6 +2562,10 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
23992562
char_decoding.get("encoding", "utf-8"),
24002563
wchar_decoding.get("encoding", "utf-16le"),
24012564
)
2565+
2566+
# CRITICAL: Validate connection immediately after fetch that released GIL
2567+
# Connection could have been closed during SQLFetchMany by another thread
2568+
self._validate_connection_after_operation()
24022569

24032570
if self.hstmt:
24042571
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
@@ -2450,6 +2617,10 @@ def fetchall(self) -> List[Row]:
24502617
char_decoding.get("encoding", "utf-8"),
24512618
wchar_decoding.get("encoding", "utf-16le"),
24522619
)
2620+
2621+
# CRITICAL: Validate connection immediately after fetchall that released GIL
2622+
# Connection could have been closed during SQLFetchAll by another thread
2623+
self._validate_connection_after_operation()
24532624

24542625
# Check for errors
24552626
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,14 +1194,16 @@ void SqlHandle::free() {
11941194
return; // Handle already null, nothing to free
11951195
}
11961196

1197-
// USE-AFTER-FREE FIX: Always clean up ODBC resources with error handling
1198-
// to prevent segfaults when handle is already freed or invalid
1199-
SQLRETURN ret = SQLFreeHandle_ptr(_type, _handle);
1197+
// CRITICAL FIX: Save handle and mark as freed BEFORE calling SQLFreeHandle
1198+
// This prevents race conditions where another thread checks the handle
1199+
// while SQLFreeHandle is in progress. Following pyodbc's proven pattern.
1200+
SQLHANDLE handle_to_free = _handle;
1201+
_handle = nullptr; // Mark invalid FIRST (prevents race condition window)
1202+
_freed = true; // Mark freed FIRST
12001203

1201-
// Always clear the handle reference and mark as freed regardless of return code
1202-
// This prevents double-free attempts even if SQLFreeHandle fails
1203-
_handle = nullptr;
1204-
_freed = true;
1204+
// USE-AFTER-FREE FIX: Now free the saved handle with error handling
1205+
// to prevent segfaults when handle is already freed or invalid
1206+
SQLRETURN ret = SQLFreeHandle_ptr(_type, handle_to_free);
12051207

12061208
// Handle errors gracefully - don't throw on invalid handle
12071209
// SQL_INVALID_HANDLE (-2) indicates handle was already freed or invalid

0 commit comments

Comments
 (0)