-
Notifications
You must be signed in to change notification settings - Fork 51
FIX: prevent AttributeError in __del__ on partially-initialized Cursor #646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
subrata-ms
wants to merge
3
commits into
main
Choose a base branch
from
subrata-ms/Bug642
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+126
−5
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -402,6 +402,108 @@ def test_cursor_del_unclosed_cursor_cleanup(conn_str): | |
| assert "Exception" not in result.stderr | ||
|
|
||
|
|
||
| def test_cursor_del_half_initialized_cursor_no_errors(): | ||
| """Regression: if ``Cursor.__init__`` raises before reaching | ||
| ``self.closed = False`` (e.g. ``_initialize_cursor`` failure on a bad | ||
| HSTMT alloc), the half-initialized instance eventually hits ``__del__``. | ||
|
|
||
|
Comment on lines
+406
to
+409
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 |
||
| Two bugs used to fire in that path and produce a | ||
| ``PytestUnraisableExceptionWarning`` in CI: | ||
| * ``close()`` did ``if self.closed:`` and raised | ||
| ``AttributeError: 'Cursor' object has no attribute 'closed'``; | ||
| * the ``__del__`` exception handler then did ``sys._is_finalizing()`` | ||
| (typo for ``sys.is_finalizing``) and raised a second AttributeError, | ||
| masking the first. | ||
|
|
||
| This test fabricates a half-initialized Cursor and asserts that GC | ||
| completes cleanly with no unraisable exceptions. | ||
| """ | ||
| import gc | ||
| import warnings | ||
| from mssql_python.cursor import Cursor | ||
|
|
||
| # Simulate the state Cursor.__init__ leaves behind when bypassed | ||
| # entirely via ``Cursor.__new__(Cursor)``: no ``closed`` attribute. | ||
| # (After the structural fix, real ``__init__`` always sets | ||
| # ``closed=False`` as its first statement, so this is the only | ||
| # remaining path that can produce a no-``closed`` instance.) | ||
| class _BogusConn: | ||
| pass | ||
|
|
||
| c = Cursor.__new__(Cursor) | ||
| c._connection = _BogusConn() | ||
| c.hstmt = None | ||
| assert "closed" not in c.__dict__, "test fixture must omit 'closed' attribute" | ||
|
|
||
| # close() must tolerate the missing attribute (BUG A fix). | ||
| c.close() | ||
|
|
||
| # And the __del__ path must not raise either bug A or bug B. | ||
| with warnings.catch_warnings(record=True) as caught: | ||
| warnings.simplefilter("always") | ||
| del c | ||
| gc.collect() | ||
|
|
||
| offenders = [ | ||
| w | ||
| for w in caught | ||
| if "AttributeError" in str(w.message) | ||
| and ("closed" in str(w.message) or "_is_finalizing" in str(w.message)) | ||
| ] | ||
| assert not offenders, f"unexpected unraisable exceptions: {[str(w.message) for w in offenders]}" | ||
|
|
||
|
|
||
| def test_cursor_init_failure_leaves_consistent_state(conn_str, monkeypatch): | ||
| """Structural-fix regression: ``Cursor.__init__`` must set | ||
| ``self.closed = False`` and ``self.hstmt = None`` *before* any statement | ||
| that can raise. If ``_initialize_cursor`` fails (e.g. HSTMT alloc), | ||
| the partially-constructed cursor must still be safely closeable and | ||
| must not leak a server-side handle on the way to the GC. | ||
|
|
||
| Before the fix the partial cursor had no ``closed`` attribute at all, | ||
| causing ``__del__`` -> ``close()`` to raise ``AttributeError`` and | ||
| surface as ``PytestUnraisableExceptionWarning`` in CI. | ||
| """ | ||
| import gc | ||
| import warnings | ||
| from mssql_python import connect | ||
| from mssql_python.cursor import Cursor | ||
|
|
||
| conn = connect(conn_str) | ||
| try: | ||
| boom = RuntimeError("simulated HSTMT allocation failure") | ||
|
|
||
| def _raise(self): | ||
| raise boom | ||
|
|
||
| monkeypatch.setattr(Cursor, "_initialize_cursor", _raise) | ||
|
|
||
| with pytest.raises(RuntimeError, match="simulated HSTMT"): | ||
| conn.cursor() | ||
|
|
||
| # Force GC of any cursor instance __new__'d during the failed | ||
| # construction; no unraisable AttributeError must escape. | ||
| with warnings.catch_warnings(record=True) as caught: | ||
| warnings.simplefilter("always") | ||
| gc.collect() | ||
|
|
||
|
Comment on lines
+481
to
+489
|
||
| offenders = [w for w in caught if "AttributeError" in str(w.message)] | ||
| assert not offenders, ( | ||
| "failed __init__ produced unraisable AttributeError in __del__: " | ||
| f"{[str(w.message) for w in offenders]}" | ||
| ) | ||
|
|
||
| # Connection must still be usable after the failed cursor creation. | ||
| # This implicitly verifies _cursors tracking wasn't corrupted. | ||
| monkeypatch.undo() | ||
| cur = conn.cursor() | ||
| cur.execute("SELECT 1") | ||
| assert cur.fetchone()[0] == 1 | ||
| cur.close() | ||
| finally: | ||
| conn.close() | ||
|
|
||
|
|
||
| def test_cursor_operations_after_close_raise_errors(conn_str): | ||
| """Test that all cursor operations raise appropriate errors after close""" | ||
| conn = connect(conn_str) | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 throughsys.unraisablehook, notwarnings, socatch_warnings(record=True)never sees it andoffendersis always empty. wrappingconn.cursor()instead (as suggested above) doesn't help, same reason.also the dealloc happens during
conn.cursor(), not thegc.collect()wrapped (_cursorsis a WeakSet).the version that breaks (fails on main, passes here):