Skip to content

Commit b733335

Browse files
committed
base code fix
1 parent 590af57 commit b733335

4 files changed

Lines changed: 102 additions & 73 deletions

File tree

mssql_python/pybind/connection/connection.cpp

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@ Connection::Connection(const std::wstring& conn_str, bool use_pool)
5151
}
5252

5353
Connection::~Connection() {
54-
disconnect(); // fallback if user forgets to disconnect
54+
try {
55+
disconnect(); // fallback if user forgets to disconnect
56+
} catch (...) {
57+
// Never throw from a destructor — doing so during stack unwinding
58+
// causes std::terminate(). Log and swallow.
59+
LOG_ERROR("Exception suppressed in ~Connection destructor");
60+
}
5561
}
5662

5763
// Allocates connection handle
@@ -99,23 +105,22 @@ void Connection::disconnect() {
99105
// When we free the DBC handle below, the ODBC driver will automatically free
100106
// all child STMT handles. We need to tell the SqlHandle objects about this
101107
// so they don't try to free the handles again during their destruction.
102-
108+
103109
// THREAD-SAFETY: Lock mutex to safely access _childStatementHandles
104110
// This protects against concurrent allocStatementHandle() calls or GC finalizers
105111
{
106112
std::lock_guard<std::mutex> lock(_childHandlesMutex);
107-
113+
108114
// First compact: remove expired weak_ptrs (they're already destroyed)
109115
size_t originalSize = _childStatementHandles.size();
110116
_childStatementHandles.erase(
111117
std::remove_if(_childStatementHandles.begin(), _childStatementHandles.end(),
112118
[](const std::weak_ptr<SqlHandle>& wp) { return wp.expired(); }),
113119
_childStatementHandles.end());
114-
115-
LOG("Compacted child handles: %zu -> %zu (removed %zu expired)",
116-
originalSize, _childStatementHandles.size(),
117-
originalSize - _childStatementHandles.size());
118-
120+
121+
LOG("Compacted child handles: %zu -> %zu (removed %zu expired)", originalSize,
122+
_childStatementHandles.size(), originalSize - _childStatementHandles.size());
123+
119124
LOG("Marking %zu child statement handles as implicitly freed",
120125
_childStatementHandles.size());
121126
for (auto& weakHandle : _childStatementHandles) {
@@ -124,8 +129,10 @@ void Connection::disconnect() {
124129
// This is guaranteed by allocStatementHandle() which only creates STMT handles
125130
// If this assertion fails, it indicates a serious bug in handle tracking
126131
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());
132+
LOG_ERROR(
133+
"CRITICAL: Non-STMT handle (type=%d) found in _childStatementHandles. "
134+
"This will cause a handle leak!",
135+
handle->type());
129136
continue; // Skip marking to prevent leak
130137
}
131138
handle->markImplicitlyFreed();
@@ -136,8 +143,13 @@ void Connection::disconnect() {
136143
} // Release lock before potentially slow SQLDisconnect call
137144

138145
SQLRETURN ret = SQLDisconnect_ptr(_dbcHandle->get());
139-
checkError(ret);
140-
// triggers SQLFreeHandle via destructor, if last owner
146+
if (!SQL_SUCCEEDED(ret)) {
147+
// Log the error but do NOT throw — disconnect must be safe to call
148+
// from destructors, reset() failure paths, and pool cleanup.
149+
// Throwing here during stack unwinding causes std::terminate().
150+
LOG_ERROR("SQLDisconnect failed (ret=%d), forcing handle cleanup", ret);
151+
}
152+
// Always free the handle regardless of SQLDisconnect result
141153
_dbcHandle.reset();
142154
} else {
143155
LOG("No connection handle to disconnect");
@@ -221,7 +233,7 @@ SqlHandlePtr Connection::allocStatementHandle() {
221233
// or GC finalizers running from different threads
222234
{
223235
std::lock_guard<std::mutex> lock(_childHandlesMutex);
224-
236+
225237
// Track this child handle so we can mark it as implicitly freed when connection closes
226238
// Use weak_ptr to avoid circular references and allow normal cleanup
227239
_childStatementHandles.push_back(stmtHandle);
@@ -237,9 +249,8 @@ SqlHandlePtr Connection::allocStatementHandle() {
237249
[](const std::weak_ptr<SqlHandle>& wp) { return wp.expired(); }),
238250
_childStatementHandles.end());
239251
_allocationsSinceCompaction = 0;
240-
LOG("Periodic compaction: %zu -> %zu handles (removed %zu expired)",
241-
originalSize, _childStatementHandles.size(),
242-
originalSize - _childStatementHandles.size());
252+
LOG("Periodic compaction: %zu -> %zu handles (removed %zu expired)", originalSize,
253+
_childStatementHandles.size(), originalSize - _childStatementHandles.size());
243254
}
244255
} // Release lock
245256

mssql_python/pybind/connection/connection_pool.cpp

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,26 @@ std::shared_ptr<Connection> ConnectionPool::acquire(const std::wstring& connStr,
2121
auto now = std::chrono::steady_clock::now();
2222
size_t before = _pool.size();
2323

24+
LOG("ConnectionPool::acquire: pool_size=%zu, max_size=%zu, idle_timeout=%d", before,
25+
_max_size, _idle_timeout_secs);
26+
2427
// Phase 1: Remove stale connections, collect for later disconnect
25-
_pool.erase(std::remove_if(_pool.begin(), _pool.end(),
26-
[&](const std::shared_ptr<Connection>& conn) {
27-
auto idle_time =
28-
std::chrono::duration_cast<std::chrono::seconds>(
29-
now - conn->lastUsed())
30-
.count();
31-
if (idle_time > _idle_timeout_secs) {
32-
to_disconnect.push_back(conn);
33-
return true;
34-
}
35-
return false;
36-
}),
37-
_pool.end());
28+
_pool.erase(
29+
std::remove_if(
30+
_pool.begin(), _pool.end(),
31+
[&](const std::shared_ptr<Connection>& conn) {
32+
auto idle_time =
33+
std::chrono::duration_cast<std::chrono::seconds>(now - conn->lastUsed())
34+
.count();
35+
LOG("ConnectionPool::acquire: checking conn idle_time=%lld vs timeout=%d",
36+
(long long)idle_time, _idle_timeout_secs);
37+
if (idle_time > _idle_timeout_secs) {
38+
to_disconnect.push_back(conn);
39+
return true;
40+
}
41+
return false;
42+
}),
43+
_pool.end());
3844

3945
size_t pruned = before - _pool.size();
4046
_current_size = (_current_size >= pruned) ? (_current_size - pruned) : 0;

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4514,10 +4514,8 @@ SQLLEN SQLRowCount_wrap(SqlHandlePtr StatementHandle) {
45144514
return rowCount;
45154515
}
45164516

4517-
static std::once_flag pooling_init_flag;
45184517
void enable_pooling(int maxSize, int idleTimeout) {
4519-
std::call_once(pooling_init_flag,
4520-
[&]() { ConnectionPoolManager::getInstance().configure(maxSize, idleTimeout); });
4518+
ConnectionPoolManager::getInstance().configure(maxSize, idleTimeout);
45214519
}
45224520

45234521
// Thread-safe decimal separator setting

tests/test_009_pooling.py

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -278,81 +278,95 @@ def try_overflow():
278278
c.close()
279279

280280

281-
@pytest.mark.skip("Flaky test - idle timeout behavior needs investigation")
282281
def test_pool_idle_timeout_removes_connections(conn_str):
283282
"""Test that idle_timeout removes connections from the pool after the timeout."""
284283
pooling(max_size=2, idle_timeout=1)
285284
conn1 = connect(conn_str)
286-
spid_list = []
287285
cursor1 = conn1.cursor()
288-
cursor1.execute("SELECT @@SPID")
289-
spid1 = cursor1.fetchone()[0]
290-
spid_list.append(spid1)
286+
# Use connection_id (a GUID unique per physical connection) instead of @@SPID,
287+
# because SQL Server can reassign the same SPID to a new connection.
288+
cursor1.execute("SELECT connection_id FROM sys.dm_exec_connections WHERE session_id = @@SPID")
289+
conn_id1 = cursor1.fetchone()[0]
291290
conn1.close()
292291

293-
# Wait for longer than idle_timeout
294-
time.sleep(3)
292+
# Wait well beyond the idle_timeout to account for slow CI and integer-second granularity
293+
time.sleep(5)
295294

296-
# Get a new connection, which should not reuse the previous SPID
295+
# Get a new connection — the idle one should have been evicted during acquire()
297296
conn2 = connect(conn_str)
298297
cursor2 = conn2.cursor()
299-
cursor2.execute("SELECT @@SPID")
300-
spid2 = cursor2.fetchone()[0]
301-
spid_list.append(spid2)
298+
cursor2.execute("SELECT connection_id FROM sys.dm_exec_connections WHERE session_id = @@SPID")
299+
conn_id2 = cursor2.fetchone()[0]
302300
conn2.close()
303301

304-
assert spid1 != spid2, "Idle timeout did not remove connection from pool"
302+
assert (
303+
conn_id1 != conn_id2
304+
), "Idle timeout did not remove connection from pool — same connection_id reused"
305305

306306

307307
# =============================================================================
308308
# Error Handling and Recovery Tests
309309
# =============================================================================
310310

311311

312-
@pytest.mark.skip(
313-
"Test causes fatal crash - forcibly closing underlying connection leads to undefined behavior"
314-
)
315312
def test_pool_removes_invalid_connections(conn_str):
316-
"""Test that the pool removes connections that become invalid (simulate by closing underlying connection)."""
313+
"""Test that the pool removes connections that become invalid and recovers gracefully.
314+
315+
This test simulates a connection being returned to the pool in a dirty state
316+
(with an open transaction) by calling _conn.close() directly, bypassing the
317+
normal Python close() which does a rollback. The pool's acquire() should detect
318+
the bad connection during reset(), discard it, and create a fresh one.
319+
"""
317320
pooling(max_size=1, idle_timeout=30)
318321
conn = connect(conn_str)
319322
cursor = conn.cursor()
320323
cursor.execute("SELECT 1")
321-
# Simulate invalidation by forcibly closing the connection at the driver level
322-
try:
323-
# Try to access a private attribute or method to forcibly close the underlying connection
324-
# This is implementation-specific; if not possible, skip
325-
if hasattr(conn, "_conn") and hasattr(conn._conn, "close"):
326-
conn._conn.close()
327-
else:
328-
pytest.skip("Cannot forcibly close underlying connection for this driver")
329-
except Exception:
330-
pass
331-
# Safely close the connection, ignoring errors due to forced invalidation
324+
cursor.fetchone()
325+
326+
# Record the connection_id of the original connection
327+
cursor.execute("SELECT connection_id FROM sys.dm_exec_connections WHERE session_id = @@SPID")
328+
original_conn_id = cursor.fetchone()[0]
329+
330+
# Force-return the connection to the pool WITHOUT rollback.
331+
# This leaves the pooled connection in a dirty state (open implicit transaction)
332+
# which will cause reset() to fail on next acquire().
333+
conn._conn.close()
334+
335+
# Python close() will fail since the underlying handle is already gone
332336
try:
333337
conn.close()
334-
except RuntimeError as e:
335-
if "not initialized" not in str(e):
336-
raise
337-
# Now, get a new connection from the pool and ensure it works
338+
except RuntimeError:
339+
pass
340+
341+
# Now get a new connection the pool should discard the dirty one and create fresh
338342
new_conn = connect(conn_str)
339343
new_cursor = new_conn.cursor()
340-
try:
341-
new_cursor.execute("SELECT 1")
342-
result = new_cursor.fetchone()
343-
assert result is not None and result[0] == 1, "Pool did not remove invalid connection"
344-
finally:
345-
new_conn.close()
344+
new_cursor.execute("SELECT 1")
345+
result = new_cursor.fetchone()
346+
assert result is not None and result[0] == 1, "Pool did not recover from invalid connection"
347+
348+
# Verify it's a different physical connection
349+
new_cursor.execute(
350+
"SELECT connection_id FROM sys.dm_exec_connections WHERE session_id = @@SPID"
351+
)
352+
new_conn_id = new_cursor.fetchone()[0]
353+
assert (
354+
original_conn_id != new_conn_id
355+
), "Expected a new physical connection after pool discarded the dirty one"
356+
357+
new_conn.close()
346358

347359

348360
def test_pool_recovery_after_failed_connection(conn_str):
349361
"""Test that the pool recovers after a failed connection attempt."""
350362
pooling(max_size=1, idle_timeout=30)
351363
# First, try to connect with a bad password (should fail)
352-
if "Pwd=" in conn_str:
353-
bad_conn_str = conn_str.replace("Pwd=", "Pwd=wrongpassword")
354-
elif "Password=" in conn_str:
355-
bad_conn_str = conn_str.replace("Password=", "Password=wrongpassword")
364+
import re
365+
366+
pwd_match = re.search(r"(Pwd|Password)=", conn_str, re.IGNORECASE)
367+
if pwd_match:
368+
key = pwd_match.group(0) # e.g. "PWD=" or "Pwd=" or "Password="
369+
bad_conn_str = conn_str.replace(key, key + "wrongpassword")
356370
else:
357371
pytest.skip("No password found in connection string to modify")
358372
with pytest.raises(Exception):

0 commit comments

Comments
 (0)