@@ -94,6 +94,47 @@ void Connection::connect(const py::dict& attrs_before) {
9494void 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
179249SQLRETURN 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}
0 commit comments