Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,21 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
connection: Database connection object.
timeout: Query timeout in seconds
"""
# Establish the close() invariant *first*, before any statement that
# can raise (notably ``_initialize_cursor`` below). Setting
# ``closed=False`` (not True) up front means that if ``__init__``
# fails partway — even after ``hstmt`` was allocated — a subsequent
# ``close()`` / ``__del__`` will still see a consistent view and
# correctly release whatever was allocated. Pairing it with
# ``hstmt=None`` keeps ``close()`` safe when allocation fails before
# an HSTMT exists.
self.closed: bool = False
self.hstmt: Optional[Any] = None

self._connection: "Connection" = connection # Store as private attribute
self._timeout: int = timeout
self._inputsizes: Optional[List[Union[int, Tuple[Any, ...]]]] = None
# self.connection.autocommit = False
self.hstmt: Optional[Any] = None
self._initialize_cursor()
self.description: Optional[
List[
Expand All @@ -134,7 +144,6 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
1 # Default number of rows to fetch at a time is 1, user can change it
)
self.buffer_length: int = 1024 # Default buffer length for string data
self.closed: bool = False
self._result_set_empty: bool = False # Add this initialization
self.last_executed_stmt: str = "" # Stores the last statement executed by this cursor
self.is_stmt_prepared: List[bool] = [
Expand Down Expand Up @@ -779,7 +788,13 @@ def close(self) -> None:
will be raised if any operation (other than close) is attempted with the cursor.
This is a deviation from pyodbc, which raises an exception if the cursor is already closed.
"""
if self.closed:
# ``closed`` may be missing on the instance only if the object was
# built via ``Cursor.__new__(Cursor)`` (no ``__init__``). Normal
# construction sets ``self.closed = False`` as the first statement
# of ``__init__``, so any partially-initialized instance still has
# the attribute. Treat the no-``__init__`` case as "already closed"
# — there's nothing to release — and let GC reap the object cleanly.
if getattr(self, "closed", True):
# Do nothing - not calling _check_closed() here since we want this to be idempotent
return

Expand Down Expand Up @@ -3257,10 +3272,14 @@ def __del__(self):
# If interpreter is shutting down, we might not have logging set up
import sys

if sys and sys._is_finalizing():
if sys and sys.is_finalizing():
# Suppress logging during interpreter shutdown
return
logger.debug("Exception during cursor cleanup in __del__: %s", e)
# ``logger`` could be torn down or have its handlers closed
# late in interpreter shutdown; guard so the debug call
# cannot itself raise an unraisable exception.
if logger is not None:
logger.debug("Exception during cursor cleanup in __del__: %s", e)

def scroll(
self, value: int, mode: str = "relative"
Expand Down
130 changes: 130 additions & 0 deletions tests/test_005_connection_cursor_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,136 @@ def test_cursor_del_unclosed_cursor_cleanup(conn_str):
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.

"""Regression: ``Cursor.__del__`` / ``close()`` must tolerate Cursor instances
missing the ``closed`` attribute (e.g. objects created via ``Cursor.__new__``),
so GC does not emit unraisable exceptions.

Two bugs used to fire in that path and produce a
``PytestUnraisableExceptionWarning`` in CI:
* Bug A: ``close()`` did ``if self.closed:`` and raised
``AttributeError: 'Cursor' object has no attribute 'closed'``;
* Bug B: the ``__del__`` exception handler then did
``sys._is_finalizing()`` (typo for ``sys.is_finalizing``) and raised
a second AttributeError, masking Bug A.

NOTE: unraisable exceptions from ``__del__`` flow through
``sys.unraisablehook`` — NOT through the ``warnings`` module — so
``warnings.catch_warnings(record=True)`` never sees them. We install
a temporary ``sys.unraisablehook`` to observe them directly.
"""
import gc
from mssql_python.cursor import Cursor

class _BogusConn:
pass

# --- Bug A regression guard: explicit close() must tolerate the
# missing ``closed`` attribute. On unfixed cursor.py this raises
# ``AttributeError`` synchronously and the test fails right here.
c1 = Cursor.__new__(Cursor)
c1._connection = _BogusConn()
c1.hstmt = None
assert "closed" not in c1.__dict__, "test fixture must omit 'closed' attribute"
c1.close()

# --- Bug B regression guard: drive a fresh partial cursor through
# ``__del__`` without calling close() first. On unfixed cursor.py,
# ``__del__`` calls close() -> Bug A AttributeError -> the exception
# handler calls ``sys._is_finalizing()`` -> Bug B AttributeError
# escapes through ``sys.unraisablehook``. On the fixed code path,
# close() succeeds inside __del__ and no unraisable is emitted.
captured = []

def _hook(unraisable):
captured.append(unraisable)

old_hook = sys.unraisablehook
sys.unraisablehook = _hook
try:
c2 = Cursor.__new__(Cursor)
c2._connection = _BogusConn()
c2.hstmt = None
assert "closed" not in c2.__dict__
del c2
gc.collect() # belt-and-suspenders; refcount already reached zero.
finally:
sys.unraisablehook = old_hook

offenders = [
u
for u in captured
if isinstance(u.exc_value, AttributeError)
and ("closed" in str(u.exc_value) or "_is_finalizing" in str(u.exc_value))
]
assert not offenders, (
f"unexpected unraisable AttributeError from Cursor.__del__: "
f"{[str(u.exc_value) for u 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.

NOTE: the failed ``Cursor.__init__`` never installs a strong ref anywhere
(``Connection._cursors`` is a ``WeakSet``), so the partial object's
refcount reaches zero the moment the exception unwinds out of
``conn.cursor()`` — ``__del__`` runs synchronously right there, not on
the next ``gc.collect()``. We therefore wrap ``sys.unraisablehook``
only around the ``conn.cursor()`` call, and observe unraisables through
the hook rather than through ``warnings.catch_warnings`` (which never
sees them).
"""
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)

captured = []

def _hook(unraisable):
captured.append(unraisable)

old_hook = sys.unraisablehook
sys.unraisablehook = _hook
try:
with pytest.raises(RuntimeError, match="simulated HSTMT"):
conn.cursor()
finally:
sys.unraisablehook = old_hook

offenders = [u for u in captured if isinstance(u.exc_value, AttributeError)]
assert not offenders, (
"failed __init__ produced unraisable AttributeError in __del__: "
f"{[str(u.exc_value) for u 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)
Expand Down
Loading