FIX: prevent AttributeError in __del__ on partially-initialized Cursor - #646
FIX: prevent AttributeError in __del__ on partially-initialized Cursor#646subrata-ms wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens mssql_python.cursor.Cursor lifecycle behavior to avoid unraisable exceptions during garbage collection when a Cursor is only partially initialized, and adds regression tests to prevent recurrence of the CI PytestUnraisableExceptionWarning seen in issue #642.
Changes:
- Establishes
Cursorcleanup invariants earlier in__init__(closed=False,hstmt=None) soclose()/__del__can run safely even after initialization failures. - Makes
Cursor.close()tolerant of instances missing theclosedattribute (e.g., objects created viaCursor.__new__). - Fixes
__del__finalization check to usesys.is_finalizing()and guards debug logging during interpreter shutdown; adds lifecycle regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
mssql_python/cursor.py |
Makes cursor initialization/cleanup resilient to partial construction and interpreter shutdown edge cases. |
tests/test_005_connection_cursor_lifecycle.py |
Adds regression tests for half-initialized cursor GC paths and failed cursor construction scenarios. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@subrata-ms generally looks good to me. The copilot comment on the test is interesting. |
bewithgaurav
left a comment
There was a problem hiding this comment.
fix itself lgtm but the tests aren't defending the same as of now
added a comment that should close that gap, copilot's comments are also working taking a look at
| assert "Exception" not in result.stderr | ||
|
|
||
|
|
||
| def test_cursor_del_half_initialized_cursor_no_errors(): |
There was a problem hiding this comment.
this test passes even on main with the unfixed cursor.py (switched to main back in & ran this test - all green), actually it doesn't reinforce corrections done in the PR.
the unraisable from __del__ goes through sys.unraisablehook, not warnings, so catch_warnings(record=True) never sees it and offenders is always empty. wrapping conn.cursor() instead (as suggested above) doesn't help, same reason.
also the dealloc happens during conn.cursor(), not the gc.collect() wrapped (_cursors is a WeakSet).
the version that breaks (fails on main, passes here):
import sys
captured = []
old = sys.unraisablehook
sys.unraisablehook = lambda u: captured.append(u)
try:
with pytest.raises(RuntimeError, match="simulated HSTMT"):
conn.cursor()
gc.collect()
finally:
sys.unraisablehook = old
assert not [u for u in captured if isinstance(u.exc_value, AttributeError)]There was a problem hiding this comment.
I just rechecked the changes
this is valid for test_cursor_init_failure_leaves_consistent_state actually - this is a wrongly pinned comment - could you please make the changes accordingly?
There was a problem hiding this comment.
You're absolutely right , thanks for the careful read. Let me confirm and lay out the fix:
-
catch_warnings(record=True) never sees this.
Unraisable exceptions from del are routed through sys.unraisablehook, not through warnings.warn(). Pytest's builtin plugin later converts those into PytestUnraisableExceptionWarning, but that conversion runs inside pytest's own hook at capture-flush time, outside the warnings.catch_warnings block scope. So caught is empty on both fixed and unfixed cursor.py — the assertion is effectively assert not [], which is why it "passes on main". The test isn't asserting anything about the bug. -
gc.collect() is the wrong sync point.
_cursors is a WeakSet, so the failed Cursor.init never installs a strong reference anywhere. When _initialize_cursor raises, the partial object's refcount hits zero the moment the exception unwinds out of conn.cursor() — del runs synchronously right there, inside the pytest.raises block. By the time gc.collect() runs, there's nothing left to collect. -
The right shape of the test.
Install a sys.unraisablehook, wrap only conn.cursor() (not gc.collect()), and assert on the captured unraisable.exc_value. I verified this locally: on main (pre-fix cursor.py) that test fails with exactly the AttributeError: 'Cursor' object has no attribute 'closed' captured through the hook; on this branch it passes clean. I'll push the updated test in the next revision and drop the warnings/gc machinery entirely. Same treatment for test_cursor_del_half_initialized_cursor_no_errors — the warnings.catch_warnings block there is dead code for the same reason and should either use the unraisable hook or be removed (the c.close() call already exercises Bug A directly and would raise on unfixed code, so that assertion alone is a valid regression guard for Bug A).
Change has been pushed as part of latest commit.
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/cursor.pyLines 3271-3279 3271 # Don't raise an exception in __del__, just log it
3272 # If interpreter is shutting down, we might not have logging set up
3273 import sys
3274
! 3275 if sys and sys.is_finalizing():
3276 # Suppress logging during interpreter shutdown
3277 return
3278 # ``logger`` could be torn down or have its handlers closed
3279 # late in interpreter shutdown; guard so the debug callLines 3277-3285 3277 return
3278 # ``logger`` could be torn down or have its handlers closed
3279 # late in interpreter shutdown; guard so the debug call
3280 # cannot itself raise an unraisable exception.
! 3281 if logger is not None:
3282 logger.debug("Exception during cursor cleanup in __del__: %s", e)
3283
3284 def scroll(
3285 self, value: int, mode: str = "relative"📋 Files Needing Attention📉 Files with overall lowest coverage (click to expand)mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 76.3%
mssql_python.__init__.py: 77.3%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 81.4%
mssql_python.pybind.connection.connection.cpp: 83.7%
mssql_python.connection.py: 84.7%🔗 Quick Links
|
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Unraisable exceptions from Cursor.__del__ flow through sys.unraisablehook, not through the warnings module, so warnings.catch_warnings(record=True) never sees them — the previous test passed on unfixed cursor.py. Also drop the gc.collect() sync point in the __init__-failure test: Connection._cursors is a WeakSet, so a failed Cursor.__init__ never installs a strong ref anywhere and __del__ runs synchronously on the exception unwind out of conn.cursor(). Wrap the hook around that call only. Verified: on main (buggy cursor.py) the half-initialized test now fails with AttributeError at cursor.py:782; on this branch (fixed cursor.py) it passes clean.
Work Item / Issue Reference
Summary
This pull request improves the robustness of the
Cursorclass initialization and cleanup logic, ensuring that partially-initialized or half-constructed cursor objects are handled safely and do not cause unraisable exceptions during garbage collection. It also adds regression tests to prevent recurrence of related bugs.Initialization and cleanup robustness:
__init__method incursor.pynow setsself.closed = Falseandself.hstmt = Noneas the very first statements, before any code that might raise an exception, ensuring that even partially-initializedCursorinstances have a consistent state for cleanup. [1] [2]close()method now safely checks for the existence of theclosedattribute usinggetattr(self, "closed", True), preventingAttributeErrorif the attribute is missing (e.g., in half-initialized objects).__del__method now uses the correctsys.is_finalizing()function (instead of the incorrectsys._is_finalizing()) and guards logging calls to prevent unraisable exceptions during interpreter shutdown.Testing and regression prevention:
test_cursor_del_half_initialized_cursor_no_errorsandtest_cursor_init_failure_leaves_consistent_stateto ensure that half-initialized cursors do not raise unraisable exceptions during garbage collection and that failed initialization leaves the cursor and connection in a consistent, recoverable state.