Skip to content

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

Open
subrata-ms wants to merge 9 commits into
mainfrom
subrata-ms/Bug642
Open

FIX: prevent AttributeError in __del__ on partially-initialized Cursor#646
subrata-ms wants to merge 9 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

assert "Exception" not in result.stderr


def test_cursor_del_half_initialized_cursor_no_errors():

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.

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)]

@bewithgaurav bewithgaurav Jul 15, 2026

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.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're absolutely right , thanks for the careful read. Let me confirm and lay out the fix:

  1. 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.

  2. 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.

  3. 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.

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 2 commits July 8, 2026 01:54
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
subrata-ms and others added 4 commits July 8, 2026 14:29
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.
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.

4 participants