Skip to content

FIX: prevent AttributeError in __del__ on partially-initialized Cursor - #646

Merged
subrata-ms merged 10 commits into
mainfrom
subrata-ms/Bug642
Jul 29, 2026
Merged

FIX: prevent AttributeError in __del__ on partially-initialized Cursor#646
subrata-ms merged 10 commits into
mainfrom
subrata-ms/Bug642

Conversation

@subrata-ms

@subrata-ms subrata-ms commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

AB#45943

GitHub Issue: #642


Summary

This pull request improves the robustness of the Cursor class 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:

  • The __init__ method in cursor.py now sets self.closed = False and self.hstmt = None as the very first statements, before any code that might raise an exception, ensuring that even partially-initialized Cursor instances have a consistent state for cleanup. [1] [2]
  • The close() method now safely checks for the existence of the closed attribute using getattr(self, "closed", True), preventing AttributeError if the attribute is missing (e.g., in half-initialized objects).
  • The __del__ method now uses the correct sys.is_finalizing() function (instead of the incorrect sys._is_finalizing()) and guards logging calls to prevent unraisable exceptions during interpreter shutdown.

Testing and regression prevention:

  • Adds test_cursor_del_half_initialized_cursor_no_errors and test_cursor_init_failure_leaves_consistent_state to 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.

@github-actions github-actions Bot added the pr-size: medium Moderate update size label Jun 25, 2026
@subrata-ms
subrata-ms marked this pull request as ready for review June 25, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Cursor cleanup invariants earlier in __init__ (closed=False, hstmt=None) so close()/__del__ can run safely even after initialization failures.
  • Makes Cursor.close() tolerant of instances missing the closed attribute (e.g., objects created via Cursor.__new__).
  • Fixes __del__ finalization check to use sys.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.

Comment thread tests/test_005_connection_cursor_lifecycle.py Outdated
Comment thread tests/test_005_connection_cursor_lifecycle.py Outdated
@saurabh500

Copy link
Copy Markdown
Contributor

@subrata-ms generally looks good to me. The copilot comment on the test is interesting.

saurabh500
saurabh500 previously approved these changes Jun 27, 2026

@bewithgaurav bewithgaurav left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread tests/test_005_connection_cursor_lifecycle.py
Comment thread tests/test_005_connection_cursor_lifecycle.py Outdated
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

60%


🎯 Overall Coverage

81%


📈 Total Lines Covered: 7065 out of 8677
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/cursor.py (60.0%): Missing lines 3275,3281

Summary

  • Total: 5 lines
  • Missing: 2 lines
  • Coverage: 60%

mssql_python/cursor.py

Lines 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 call

Lines 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

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

subrata-ms and others added 4 commits July 8, 2026 14:29
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
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.

@bewithgaurav bewithgaurav left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the core fix is correct, just need a minor correction on tests

Comment thread tests/test_005_connection_cursor_lifecycle.py Outdated
Binding the raised RuntimeError to an enclosing-frame local ('boom = ...')
keeps the exception's __traceback__ alive, which in turn keeps the
partial Cursor's frame alive past the sys.unraisablehook restore. __del__
then fires AFTER the hook is unwound, the hook window closes with no
unraisables captured, and the assertion silently becomes a no-op — same
dead-assertion pattern as the old catch_warnings, just reached via a
different mechanism. The test passed on unfixed cursor.py as a result.

Fix: construct the RuntimeError inline inside _raise so no long-lived
reference to it exists. Once pytest.raises exits, the exception (and its
traceback, and the partial cursor frame) become unreachable and __del__
fires synchronously inside the hook window. Verified: fails on main,
passes on this branch.

Docstring note added so nobody reintroduces the enclosing-frame local.
@subrata-ms
subrata-ms merged commit dbbcd02 into main Jul 29, 2026
29 checks passed
@subrata-ms
subrata-ms deleted the subrata-ms/Bug642 branch July 29, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: medium Moderate update size

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants