Skip to content

Commit e660892

Browse files
committed
segfault fix
1 parent 7fe5431 commit e660892

5 files changed

Lines changed: 669 additions & 30 deletions

File tree

mssql_python/connection.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ def __init__(
287287
# TODO: Think and implement scenarios for multi-threaded access
288288
# to cursors
289289
self._cursors = weakref.WeakSet()
290+
291+
# Track if connection has been closed to prevent cursors from
292+
# trying to free handles after connection is closed (prevents segfault)
293+
self._connection_closed = False
290294

291295
# Initialize output converters dictionary and its lock for thread safety
292296
self._output_converters = {}
@@ -1499,12 +1503,33 @@ def close(self) -> None:
14991503
if self._closed:
15001504
return
15011505

1506+
# CRITICAL: Set connection closed flag BEFORE closing anything
1507+
# This prevents cursors from trying to free handles during/after connection close
1508+
self._connection_closed = True
1509+
15021510
# Close all cursors first, but don't let one failure stop the others
15031511
if hasattr(self, "_cursors"):
15041512
# Convert to list to avoid modification during iteration
15051513
cursors_to_close = list(self._cursors)
15061514
close_errors = []
15071515

1516+
# First pass: Invalidate cursor handles to prevent use-after-free
1517+
for cursor in cursors_to_close:
1518+
try:
1519+
# CRITICAL: Mark cursor as invalidated BEFORE clearing handle
1520+
# This tells cursor.close() to skip SQLFreeHandle entirely
1521+
if hasattr(cursor, '_invalidated'):
1522+
cursor._invalidated = True
1523+
# Mark handles as freed before closing connection
1524+
# This prevents cursors from trying to free already-freed handles
1525+
if hasattr(cursor, '_handle_freed'):
1526+
cursor._handle_freed = True
1527+
if hasattr(cursor, 'hstmt'):
1528+
cursor.hstmt = None
1529+
except Exception as e: # pylint: disable=broad-exception-caught
1530+
logger.warning("Error invalidating cursor handle: %s", e)
1531+
1532+
# Second pass: Close cursors
15081533
for cursor in cursors_to_close:
15091534
try:
15101535
if not cursor.closed:

mssql_python/cursor.py

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import uuid
1616
import datetime
1717
import warnings
18+
import weakref
19+
import sys
1820
from typing import List, Union, Any, Optional, Tuple, Sequence, TYPE_CHECKING, Iterable
1921
from mssql_python.constants import ConstantsDDBC as ddbc_sql_const, SQLTypes
2022
from mssql_python.helpers import check_error
@@ -98,6 +100,13 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
98100
self._inputsizes: Optional[List[Union[int, Tuple[Any, ...]]]] = None
99101
# self.connection.autocommit = False
100102
self.hstmt: Optional[Any] = None
103+
self._handle_freed: bool = False # Track if statement handle has been freed
104+
self._invalidated: bool = False # Track if cursor was invalidated by connection close
105+
106+
# Store weak reference to connection to check if it's closed
107+
# This prevents segfault when trying to free handles after connection close
108+
self._connection_ref: Any = weakref.ref(connection)
109+
101110
self._initialize_cursor()
102111
self.description: Optional[
103112
List[
@@ -728,14 +737,17 @@ def _reset_cursor(self) -> None:
728737
# Reinitialize the statement handle
729738
self._initialize_cursor()
730739

731-
def close(self) -> None:
740+
def close(self, from_del: bool = False) -> None:
732741
"""
733742
Close the connection now (rather than whenever .__del__() is called).
734743
Idempotent: subsequent calls have no effect and will be no-ops.
735744
736745
The cursor will be unusable from this point forward; an InterfaceError
737746
will be raised if any operation (other than close) is attempted with the cursor.
738747
This is a deviation from pyodbc, which raises an exception if the cursor is already closed.
748+
749+
Args:
750+
from_del: Internal flag - when True, handle freeing is skipped to prevent segfaults during GC.
739751
"""
740752
if self.closed:
741753
# Do nothing - not calling _check_closed() here since we want this to be idempotent
@@ -751,10 +763,81 @@ def close(self) -> None:
751763
except Exception as e: # pylint: disable=broad-exception-caught
752764
logger.warning("Error removing cursor from connection tracking: %s", e)
753765

754-
if self.hstmt:
755-
self.hstmt.free()
756-
self.hstmt = None
757-
logger.debug("SQLFreeHandle succeeded")
766+
# Free statement handle with protection against double-free
767+
# CRITICAL SAFETY #0: If called from __del__, do NOTHING - just return
768+
# During GC, ANY object access can trigger segfaults due to partially destroyed state
769+
# Better to leak resources than crash - OS cleans up at process exit
770+
if from_del:
771+
return
772+
773+
if self.hstmt and not self._handle_freed:
774+
775+
# CRITICAL SAFETY #1: Check if cursor was explicitly invalidated by connection
776+
# This happens when connection.close() invalidates all cursors BEFORE freeing connection handle
777+
if hasattr(self, '_invalidated') and self._invalidated:
778+
self.hstmt = None
779+
self._handle_freed = True
780+
return
781+
782+
# CRITICAL SAFETY #2: Check if parent connection is closed via _connection attribute
783+
# This is an additional safety check in case weak reference approach fails
784+
try:
785+
if hasattr(self, '_connection') and self._connection:
786+
if hasattr(self._connection, '_closed') and self._connection._closed:
787+
# Connection is closed, skip freeing handle
788+
self.hstmt = None
789+
self._handle_freed = True
790+
return
791+
if hasattr(self._connection, '_connection_closed') and self._connection._connection_closed:
792+
# Connection closed flag set, skip freeing handle
793+
self.hstmt = None
794+
self._handle_freed = True
795+
return
796+
except (AttributeError, ReferenceError):
797+
# Connection might be in invalid state, skip freeing to be safe
798+
self.hstmt = None
799+
self._handle_freed = True
800+
return
801+
802+
# CRITICAL SAFETY #3: Skip handle freeing during interpreter shutdown
803+
# During shutdown, ODBC resources may already be freed, causing segfault
804+
if sys.is_finalizing():
805+
self.hstmt = None
806+
self._handle_freed = True
807+
return
808+
809+
# Check if parent connection was closed or garbage collected
810+
# First check if we even have a connection reference attribute
811+
if not hasattr(self, '_connection_ref'):
812+
# Old cursor without weak ref - conservatively skip free
813+
self.hstmt = None
814+
self._handle_freed = True
815+
return
816+
817+
# Try to get connection from weak reference
818+
try:
819+
conn = self._connection_ref()
820+
except Exception:
821+
# Weak ref might be invalid during cleanup - skip free to be safe
822+
self.hstmt = None
823+
self._handle_freed = True
824+
return
825+
826+
# Skip free if connection is closed or garbage collected
827+
if conn is None or (hasattr(conn, '_connection_closed') and conn._connection_closed):
828+
# Connection closed/GC'd - ODBC driver already freed handles
829+
self.hstmt = None
830+
self._handle_freed = True
831+
else:
832+
# Connection is still open - safe to free statement handle
833+
try:
834+
self.hstmt.free()
835+
self._handle_freed = True
836+
except Exception:
837+
# Silently handle errors during cleanup
838+
pass
839+
finally:
840+
self.hstmt = None
758841
self._clear_rownumber()
759842
self.closed = True
760843

@@ -2760,7 +2843,8 @@ def __del__(self):
27602843
"""
27612844
if "closed" not in self.__dict__ or not self.closed:
27622845
try:
2763-
self.close()
2846+
# Pass from_del=True to skip handle freeing (prevents segfaults during GC)
2847+
self.close(from_del=True)
27642848
except Exception as e: # pylint: disable=broad-exception-caught
27652849
# Don't raise an exception in __del__, just log it
27662850
# If interpreter is shutting down, we might not have logging set up

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,7 @@ void DriverLoader::loadDriver() {
11281128
}
11291129

11301130
// SqlHandle definition
1131-
SqlHandle::SqlHandle(SQLSMALLINT type, SQLHANDLE rawHandle) : _type(type), _handle(rawHandle) {}
1131+
SqlHandle::SqlHandle(SQLSMALLINT type, SQLHANDLE rawHandle) : _type(type), _handle(rawHandle), _freed(false) {}
11321132

11331133
SqlHandle::~SqlHandle() {
11341134
if (_handle) {
@@ -1152,32 +1152,65 @@ SQLSMALLINT SqlHandle::type() const {
11521152
* If you need destruction logs, use explicit close() methods instead.
11531153
*/
11541154
void SqlHandle::free() {
1155-
if (_handle && SQLFreeHandle_ptr) {
1156-
// Check if Python is shutting down using centralized helper function
1157-
bool pythonShuttingDown = is_python_finalizing();
1158-
1159-
// RESOURCE LEAK MITIGATION:
1160-
// When handles are skipped during shutdown, they are not freed, which could
1161-
// cause resource leaks. However, this is mitigated by:
1162-
// 1. Python-side atexit cleanup (in __init__.py) that explicitly closes all
1163-
// connections before shutdown, ensuring handles are freed in correct order
1164-
// 2. OS-level cleanup at process termination recovers any remaining resources
1165-
// 3. This tradeoff prioritizes crash prevention over resource cleanup, which
1166-
// is appropriate since we're already in shutdown sequence
1167-
if (pythonShuttingDown && (_type == SQL_HANDLE_STMT || _type == SQL_HANDLE_DBC)) {
1168-
_handle = nullptr; // Mark as freed to prevent double-free attempts
1169-
return;
1170-
}
1155+
// Early return if handle was already explicitly freed
1156+
if (_freed) {
1157+
return; // Already freed via explicit free() call
1158+
}
1159+
1160+
// Early return if handle is nullptr
1161+
if (!_handle) {
1162+
_freed = true;
1163+
return; // Already freed, nothing to do
1164+
}
1165+
1166+
// Early return if SQLFreeHandle function is not loaded
1167+
if (!SQLFreeHandle_ptr) {
1168+
_handle = nullptr; // Mark as freed to prevent future attempts
1169+
_freed = true;
1170+
return;
1171+
}
11711172

1172-
// Always clean up ODBC resources, regardless of Python state
1173-
SQLFreeHandle_ptr(_type, _handle);
1174-
_handle = nullptr;
1173+
// Check if Python is shutting down using centralized helper function
1174+
bool pythonShuttingDown = is_python_finalizing();
1175+
1176+
// RESOURCE LEAK MITIGATION:
1177+
// When handles are skipped during shutdown, they are not freed, which could
1178+
// cause resource leaks. However, this is mitigated by:
1179+
// 1. Python-side atexit cleanup (in __init__.py) that explicitly closes all
1180+
// connections before shutdown, ensuring handles are freed in correct order
1181+
// 2. OS-level cleanup at process termination recovers any remaining resources
1182+
// 3. This tradeoff prioritizes crash prevention over resource cleanup, which
1183+
// is appropriate since we're already in shutdown sequence
1184+
if (pythonShuttingDown && (_type == SQL_HANDLE_STMT || _type == SQL_HANDLE_DBC)) {
1185+
_handle = nullptr; // Mark as freed to prevent double-free attempts
1186+
_freed = true;
1187+
return;
1188+
}
11751189

1176-
// Only log if Python is not shutting down (to avoid segfault)
1190+
// Additional safety: Check if handle is SQL_NULL_HANDLE before freeing
1191+
// This can happen if connection was closed and ODBC driver already freed statement handles
1192+
if (_handle == SQL_NULL_HANDLE) {
1193+
_freed = true;
1194+
return; // Handle already null, nothing to free
1195+
}
1196+
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);
1200+
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;
1205+
1206+
// Handle errors gracefully - don't throw on invalid handle
1207+
// SQL_INVALID_HANDLE (-2) indicates handle was already freed or invalid
1208+
// This is expected during connection invalidation scenarios
1209+
if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO && ret != SQL_INVALID_HANDLE) {
1210+
// Only log non-critical errors if Python is not shutting down
11771211
if (!pythonShuttingDown) {
1178-
// Don't log during destruction - even in normal cases it can be
1179-
// problematic If logging is needed, use explicit close() methods
1180-
// instead
1212+
// Log error but don't throw exception - prevents crashes during cleanup
1213+
// If logging is needed, use explicit close() methods instead of destructor
11811214
}
11821215
}
11831216
}

mssql_python/pybind/ddbc_bindings.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ class SqlHandle {
382382
private:
383383
SQLSMALLINT _type;
384384
SQLHANDLE _handle;
385+
bool _freed; // Track if handle has been explicitly freed
385386
};
386387
using SqlHandlePtr = std::shared_ptr<SqlHandle>;
387388

0 commit comments

Comments
 (0)