Skip to content

Commit f229052

Browse files
authored
FIX: Segmentation Fault in libmsodbcsql-18.5 during SQLFreeHandle() (#415)
<!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#41463](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/41463) <!-- External contributors: GitHub Issue --> > GitHub Issue: #341 ------------------------------------------------------------------- ### Summary <!-- Insert your summary of changes below. Minimum 10 characters required. --> This pull request implements a critical fix for a long-standing use-after-free (segmentation fault) bug that occurred when a database connection was closed while statement handles were still alive. The fix ensures that child statement handles are properly tracked and marked as "implicitly freed" when the parent connection is closed, preventing double-free and use-after-free errors. Additionally, comprehensive regression tests are added to verify the fix under various scenarios. **Critical bug fix for handle management:** * The `Connection` class now tracks all child statement handles in a `_childStatementHandles` vector using `weak_ptr` to avoid circular references and memory leaks. When the connection is closed, all child statement handles are marked as "implicitly freed" before the parent handle is released. This prevents the `SqlHandle` destructor from attempting to free handles that have already been freed by the ODBC driver. [[1]](diffhunk://#diff-c74277ea1f36fff8f6bbe9df3fd722f7d270f3d6a3069ac7a747fe6d4235cf13R64-R67) [[2]](diffhunk://#diff-eca696c13d997f510e5f9b16288ed1deb0ad132768c283eda1518f78edf9b6ecR97-R110) [[3]](diffhunk://#diff-eca696c13d997f510e5f9b16288ed1deb0ad132768c283eda1518f78edf9b6ecL176-R203) * The `SqlHandle` class now includes an `_implicitly_freed` flag and a `markImplicitlyFreed()` method. The `free()` method checks this flag and skips ODBC cleanup if the handle was already implicitly freed, preventing use-after-free errors. [[1]](diffhunk://#diff-85167a2d59779df18704284ab7ce46220c3619408fbf22c631ffdf29f794d635R382-R390) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1R1147-R1150) [[3]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L1172-L1181) * The Python bindings are updated to expose the new `markImplicitlyFreed` method on `SqlHandle`, ensuring that the state can be managed from both C++ and Python layers. **Testing and verification:** * A new test file, `test_016_connection_invalidation_segfault.py`, is added with extensive tests that reproduce the original segfault scenarios, including multiple cursors, uncommitted transactions, prepared statements, and concurrent connection invalidation. These tests confirm that the fix prevents crashes and ensures clean resource management. **Other minor changes:** * Minor whitespace and comment cleanups in unrelated parts of the codebase. [[1]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L2896) [[2]](diffhunk://#diff-dde2297345718ec449a14e7dff91b7bb2342b008ecc071f562233646d71144a1L3618-L3619) This change is critical for stability and correctness when using connection pooling, SQLAlchemy, or any code that may close connections before all cursors are explicitly closed. <!-- ### PR Title Guide > For feature requests FEAT: (short-description) > For non-feature requests like test case updates, config updates , dependency updates etc CHORE: (short-description) > For Fix requests FIX: (short-description) > For doc update requests DOC: (short-description) > For Formatting, indentation, or styling update STYLE: (short-description) > For Refactor, without any feature changes REFACTOR: (short-description) > For release related changes, without any feature changes RELEASE: #<RELEASE_VERSION> (short-description) ### Contribution Guidelines External contributors: - Create a GitHub issue first: https://github.com/microsoft/mssql-python/issues/new - Link the GitHub issue in the "GitHub Issue" section above - Follow the PR title format and provide a meaningful summary mssql-python maintainers: - Create an ADO Work Item following internal processes - Link the ADO Work Item in the "ADO Work Item" section above - Follow the PR title format and provide a meaningful summary -->
1 parent 2354f09 commit f229052

File tree

5 files changed

+447
-17
lines changed

5 files changed

+447
-17
lines changed

mssql_python/pybind/connection/connection.cpp

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,47 @@ void Connection::connect(const py::dict& attrs_before) {
9494
void Connection::disconnect() {
9595
if (_dbcHandle) {
9696
LOG("Disconnecting from database");
97+
98+
// CRITICAL FIX: Mark all child statement handles as implicitly freed
99+
// When we free the DBC handle below, the ODBC driver will automatically free
100+
// all child STMT handles. We need to tell the SqlHandle objects about this
101+
// so they don't try to free the handles again during their destruction.
102+
103+
// THREAD-SAFETY: Lock mutex to safely access _childStatementHandles
104+
// This protects against concurrent allocStatementHandle() calls or GC finalizers
105+
{
106+
std::lock_guard<std::mutex> lock(_childHandlesMutex);
107+
108+
// First compact: remove expired weak_ptrs (they're already destroyed)
109+
size_t originalSize = _childStatementHandles.size();
110+
_childStatementHandles.erase(
111+
std::remove_if(_childStatementHandles.begin(), _childStatementHandles.end(),
112+
[](const std::weak_ptr<SqlHandle>& wp) { return wp.expired(); }),
113+
_childStatementHandles.end());
114+
115+
LOG("Compacted child handles: %zu -> %zu (removed %zu expired)",
116+
originalSize, _childStatementHandles.size(),
117+
originalSize - _childStatementHandles.size());
118+
119+
LOG("Marking %zu child statement handles as implicitly freed",
120+
_childStatementHandles.size());
121+
for (auto& weakHandle : _childStatementHandles) {
122+
if (auto handle = weakHandle.lock()) {
123+
// SAFETY ASSERTION: Only STMT handles should be in this vector
124+
// This is guaranteed by allocStatementHandle() which only creates STMT handles
125+
// If this assertion fails, it indicates a serious bug in handle tracking
126+
if (handle->type() != SQL_HANDLE_STMT) {
127+
LOG_ERROR("CRITICAL: Non-STMT handle (type=%d) found in _childStatementHandles. "
128+
"This will cause a handle leak!", handle->type());
129+
continue; // Skip marking to prevent leak
130+
}
131+
handle->markImplicitlyFreed();
132+
}
133+
}
134+
_childStatementHandles.clear();
135+
_allocationsSinceCompaction = 0;
136+
} // Release lock before potentially slow SQLDisconnect call
137+
97138
SQLRETURN ret = SQLDisconnect_ptr(_dbcHandle->get());
98139
checkError(ret);
99140
// triggers SQLFreeHandle via destructor, if last owner
@@ -173,7 +214,36 @@ SqlHandlePtr Connection::allocStatementHandle() {
173214
SQLHANDLE stmt = nullptr;
174215
SQLRETURN ret = SQLAllocHandle_ptr(SQL_HANDLE_STMT, _dbcHandle->get(), &stmt);
175216
checkError(ret);
176-
return std::make_shared<SqlHandle>(static_cast<SQLSMALLINT>(SQL_HANDLE_STMT), stmt);
217+
auto stmtHandle = std::make_shared<SqlHandle>(static_cast<SQLSMALLINT>(SQL_HANDLE_STMT), stmt);
218+
219+
// THREAD-SAFETY: Lock mutex before modifying _childStatementHandles
220+
// This protects against concurrent disconnect() or allocStatementHandle() calls,
221+
// or GC finalizers running from different threads
222+
{
223+
std::lock_guard<std::mutex> lock(_childHandlesMutex);
224+
225+
// Track this child handle so we can mark it as implicitly freed when connection closes
226+
// Use weak_ptr to avoid circular references and allow normal cleanup
227+
_childStatementHandles.push_back(stmtHandle);
228+
_allocationsSinceCompaction++;
229+
230+
// Compact expired weak_ptrs only periodically to avoid O(n²) overhead
231+
// This keeps allocation fast (O(1) amortized) while preventing unbounded growth
232+
// disconnect() also compacts, so this is just for long-lived connections with many cursors
233+
if (_allocationsSinceCompaction >= COMPACTION_INTERVAL) {
234+
size_t originalSize = _childStatementHandles.size();
235+
_childStatementHandles.erase(
236+
std::remove_if(_childStatementHandles.begin(), _childStatementHandles.end(),
237+
[](const std::weak_ptr<SqlHandle>& wp) { return wp.expired(); }),
238+
_childStatementHandles.end());
239+
_allocationsSinceCompaction = 0;
240+
LOG("Periodic compaction: %zu -> %zu handles (removed %zu expired)",
241+
originalSize, _childStatementHandles.size(),
242+
originalSize - _childStatementHandles.size());
243+
}
244+
} // Release lock
245+
246+
return stmtHandle;
177247
}
178248

179249
SQLRETURN Connection::setAttribute(SQLINTEGER attribute, py::object value) {
@@ -308,7 +378,7 @@ bool Connection::reset() {
308378
disconnect();
309379
return false;
310380
}
311-
381+
312382
// SQL_ATTR_RESET_CONNECTION does NOT reset the transaction isolation level.
313383
// Explicitly reset it to the default (SQL_TXN_READ_COMMITTED) to prevent
314384
// isolation level settings from leaking between pooled connection usages.
@@ -320,7 +390,7 @@ bool Connection::reset() {
320390
disconnect();
321391
return false;
322392
}
323-
393+
324394
updateLastUsed();
325395
return true;
326396
}

mssql_python/pybind/connection/connection.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55
#include "../ddbc_bindings.h"
66
#include <memory>
77
#include <string>
8+
#include <mutex>
89

910
// Represents a single ODBC database connection.
1011
// Manages connection handles.
1112
// Note: This class does NOT implement pooling logic directly.
13+
//
14+
// THREADING MODEL (per DB-API 2.0 threadsafety=1):
15+
// - Connections should NOT be shared between threads in normal usage
16+
// - However, _childStatementHandles is mutex-protected because:
17+
// 1. Python GC/finalizers can run from any thread
18+
// 2. Native code may release GIL during blocking ODBC calls
19+
// 3. Provides safety if user accidentally shares connection
20+
// - All accesses to _childStatementHandles are guarded by _childHandlesMutex
1221

1322
class Connection {
1423
public:
@@ -61,6 +70,22 @@ class Connection {
6170
std::chrono::steady_clock::time_point _lastUsed;
6271
std::wstring wstrStringBuffer; // wstr buffer for string attribute setting
6372
std::string strBytesBuffer; // string buffer for byte attributes setting
73+
74+
// Track child statement handles to mark them as implicitly freed when connection closes
75+
// Uses weak_ptr to avoid circular references and allow normal cleanup
76+
// THREAD-SAFETY: All accesses must be guarded by _childHandlesMutex
77+
std::vector<std::weak_ptr<SqlHandle>> _childStatementHandles;
78+
79+
// Counter for periodic compaction of expired weak_ptrs
80+
// Compact every N allocations to avoid O(n²) overhead in hot path
81+
// THREAD-SAFETY: Protected by _childHandlesMutex
82+
size_t _allocationsSinceCompaction = 0;
83+
static constexpr size_t COMPACTION_INTERVAL = 100;
84+
85+
// Mutex protecting _childStatementHandles and _allocationsSinceCompaction
86+
// Prevents data races between allocStatementHandle() and disconnect(),
87+
// or concurrent GC finalizers running from different threads
88+
mutable std::mutex _childHandlesMutex;
6489
};
6590

6691
class ConnectionHandle {

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,21 @@ SQLSMALLINT SqlHandle::type() const {
11441144
return _type;
11451145
}
11461146

1147+
void SqlHandle::markImplicitlyFreed() {
1148+
// SAFETY: Only STMT handles should be marked as implicitly freed.
1149+
// When a DBC handle is freed, the ODBC driver automatically frees all child STMT handles.
1150+
// Other handle types (ENV, DBC, DESC) are NOT automatically freed by parents.
1151+
// Calling this on wrong handle types will cause silent handle leaks.
1152+
if (_type != SQL_HANDLE_STMT) {
1153+
// Log error but don't throw - we're likely in cleanup/destructor path
1154+
LOG_ERROR("SAFETY VIOLATION: Attempted to mark non-STMT handle as implicitly freed. "
1155+
"Handle type=%d. This will cause handle leak. Only STMT handles are "
1156+
"automatically freed by parent DBC handles.", _type);
1157+
return; // Refuse to mark - let normal free() handle it
1158+
}
1159+
_implicitly_freed = true;
1160+
}
1161+
11471162
/*
11481163
* IMPORTANT: Never log in destructors - it causes segfaults.
11491164
* During program exit, C++ destructors may run AFTER Python shuts down.
@@ -1169,16 +1184,19 @@ void SqlHandle::free() {
11691184
return;
11701185
}
11711186

1172-
// Always clean up ODBC resources, regardless of Python state
1187+
// CRITICAL FIX: Check if handle was already implicitly freed by parent handle
1188+
// When Connection::disconnect() frees the DBC handle, the ODBC driver automatically
1189+
// frees all child STMT handles. We track this state to avoid double-free attempts.
1190+
// This approach avoids calling ODBC functions on potentially-freed handles, which
1191+
// would cause use-after-free errors.
1192+
if (_implicitly_freed) {
1193+
_handle = nullptr; // Just clear the pointer, don't call ODBC functions
1194+
return;
1195+
}
1196+
1197+
// Handle is valid and not implicitly freed, proceed with normal freeing
11731198
SQLFreeHandle_ptr(_type, _handle);
11741199
_handle = nullptr;
1175-
1176-
// Only log if Python is not shutting down (to avoid segfault)
1177-
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
1181-
}
11821200
}
11831201
}
11841202

@@ -2893,7 +2911,6 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p
28932911

28942912
// Cache decimal separator to avoid repeated system calls
28952913

2896-
28972914
for (SQLSMALLINT i = 1; i <= colCount; ++i) {
28982915
SQLWCHAR columnName[256];
28992916
SQLSMALLINT columnNameLen;
@@ -3615,8 +3632,6 @@ SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, py::list& colum
36153632
columnInfos[col].processedColumnSize + 1; // +1 for null terminator
36163633
}
36173634

3618-
3619-
36203635
// Performance: Build function pointer dispatch table (once per batch)
36213636
// This eliminates the switch statement from the hot loop - 10,000 rows × 10
36223637
// cols reduces from 100,000 switch evaluations to just 10 switch
@@ -4033,8 +4048,8 @@ SQLRETURN FetchMany_wrap(SqlHandlePtr StatementHandle, py::list& rows, int fetch
40334048
lobColumns.push_back(i + 1); // 1-based
40344049
}
40354050
}
4036-
4037-
// Initialized to 0 for LOB path counter; overwritten by ODBC in non-LOB path;
4051+
4052+
// Initialized to 0 for LOB path counter; overwritten by ODBC in non-LOB path;
40384053
SQLULEN numRowsFetched = 0;
40394054
// If we have LOBs → fall back to row-by-row fetch + SQLGetData_wrap
40404055
if (!lobColumns.empty()) {
@@ -4066,7 +4081,7 @@ SQLRETURN FetchMany_wrap(SqlHandlePtr StatementHandle, py::list& rows, int fetch
40664081
LOG("FetchMany_wrap: Error when binding columns - SQLRETURN=%d", ret);
40674082
return ret;
40684083
}
4069-
4084+
40704085
SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_ROW_ARRAY_SIZE, (SQLPOINTER)(intptr_t)fetchSize, 0);
40714086
SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_ROWS_FETCHED_PTR, &numRowsFetched, 0);
40724087

mssql_python/pybind/ddbc_bindings.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,24 @@ class SqlHandle {
379379
SQLSMALLINT type() const;
380380
void free();
381381

382+
// Mark this handle as implicitly freed (freed by parent handle)
383+
// This prevents double-free attempts when the ODBC driver automatically
384+
// frees child handles (e.g., STMT handles when DBC handle is freed)
385+
//
386+
// SAFETY CONSTRAINTS:
387+
// - ONLY call this on SQL_HANDLE_STMT handles
388+
// - ONLY call this when the parent DBC handle is about to be freed
389+
// - Calling on other handle types (ENV, DBC, DESC) will cause HANDLE LEAKS
390+
// - The ODBC spec only guarantees automatic freeing of STMT handles by DBC parents
391+
//
392+
// Current usage: Connection::disconnect() marks all tracked STMT handles
393+
// before freeing the DBC handle.
394+
void markImplicitlyFreed();
395+
382396
private:
383397
SQLSMALLINT _type;
384398
SQLHANDLE _handle;
399+
bool _implicitly_freed = false; // Tracks if handle was freed by parent
385400
};
386401
using SqlHandlePtr = std::shared_ptr<SqlHandle>;
387402

0 commit comments

Comments
 (0)