@@ -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 )
0 commit comments