Skip to content

Commit c4f065b

Browse files
committed
address review feedback
1 parent 47dfed4 commit c4f065b

8 files changed

Lines changed: 235 additions & 21 deletions

File tree

‎cuda_core/pytest.ini‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
addopts = --showlocals --durations=20
77
pythonpath = tests
88
norecursedirs = cython
9+
filterwarnings =
10+
# A test left a memory resource alive; see "Release resources at test boundaries" in tests/AGENTS.md.
11+
# conftest.py makes helpers importable, which happens after pytest first
12+
# applies these filters (and warns); the filter is applied again per test.
13+
# The message regex below is pinned to pytest's wording (as of pytest 9.x);
14+
# if a pytest upgrade rewords "Failed to import filter module" this stops
15+
# suppressing the config warning (noisy, not fatal) and must be updated.
16+
ignore:Failed to import filter module.*UnreleasedMemoryResourceWarning:pytest.PytestConfigWarning
17+
error::helpers.memory.UnreleasedMemoryResourceWarning
918
markers =
1019
samples: tests for cuda.core standalone samples
1120
# Keep this authorship marker registry in sync across all pytest config roots.

‎cuda_core/tests/AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ that creates them. The collected items and imported modules are frozen after
6969
collection, so the collect covers only objects created during the run.
7070

7171
A resource that outlives a test without `init_cuda` makes the next `init_cuda`
72-
test emit `UnreleasedMemoryResourceWarning`. Release what you construct, and
72+
test fail with `UnreleasedMemoryResourceWarning` (escalated to an error in
73+
`pytest.ini`). Release what you construct, and
7374
request `init_cuda` in any test that constructs one (see "Tests that touch CUDA
7475
must establish their own context").
7576

‎cuda_core/tests/conftest.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ def _item_uses_init_cuda(item):
145145

146146

147147
def _device_default_memory_resource_count():
148-
"""Number of main-thread Devices that currently cache a default MR."""
148+
"""Number of main-thread Devices that currently cache a default MR.
149+
150+
Reaches into Device thread-local state (``_device._tls``) on purpose; update
151+
this and :func:`_release_device_default_memory_resources` if that moves.
152+
"""
149153
return sum(d._memory_resource is not None for d in getattr(_device._tls, "devices", ()))
150154

151155

@@ -261,8 +265,10 @@ def init_cuda(request):
261265
try:
262266
yield device
263267
finally:
264-
# pytest holds fixture values in item.funcargs until all teardown has
265-
# run; drop them so already torn-down fixtures are not counted below.
268+
# pytest holds fixture values in item.funcargs (a pytest-internal dict)
269+
# until all teardown has run; we deliberately drop them so already
270+
# torn-down fixtures are not counted below. Revisit if pytest changes
271+
# how it retains fixture values.
266272
request.node.funcargs.clear()
267273
# Only a resource in a reference cycle outlives the test frame, so
268274
# collect only if the count differs from its value at setup. A lower
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for the ``init_cuda`` teardown gc.collect() gate and its warnings.
5+
6+
``conftest.py`` snapshots the live ``MemoryResource`` count at ``init_cuda``
7+
setup and, at teardown, collects only when the count differs from that
8+
snapshot. It also warns when a test leaves a resource alive without
9+
``init_cuda`` (``UnreleasedMemoryResourceWarning``, raised at setup) or releases
10+
a resource that existed before it started (``BaselineMemoryResourceReleasedWarning``,
11+
raised at teardown).
12+
13+
These behaviors live in the fixture's setup/teardown, so they cannot be observed
14+
from an ordinary test body. Each test here drives the *real* ``init_cuda``
15+
generator by hand with a minimal fake ``request`` and (where needed) a
16+
``gc.collect`` spy, so the collect decision and both warnings can be asserted
17+
directly. The fixture function and its module globals are reached through
18+
pytest's fixture registry (``FixtureDef.func``) so the monkeypatched globals are
19+
the ones the running code reads. Tests are ``thread_unsafe`` because they patch
20+
process-global state (``gc.collect`` and the conftest module globals) and assert
21+
on the process-global resource count; each cleans up every resource it creates
22+
so the real session's leftover tracking is not perturbed.
23+
"""
24+
25+
import gc
26+
import inspect
27+
28+
import pytest
29+
from helpers.memory import (
30+
BaselineMemoryResourceReleasedWarning,
31+
UnreleasedMemoryResourceWarning,
32+
)
33+
34+
from cuda.core import MemoryResource
35+
from cuda.core._memory._buffer import _live_memory_resource_count
36+
37+
38+
class _LocalMR(MemoryResource):
39+
"""A trivial MemoryResource subclass that holds no CUDA pool.
40+
41+
Construction/destruction only moves the live counter, so it is a clean way
42+
to make the gate's snapshot comparison grow or shrink without allocating.
43+
"""
44+
45+
46+
_THREAD_UNSAFE_MARKER = pytest.mark.thread_unsafe(
47+
reason="patches gc.collect and conftest module globals, and asserts on the process-global resource count"
48+
)
49+
50+
51+
class _FakeOption:
52+
randomly_seed = None
53+
54+
55+
class _FakeConfig:
56+
def __init__(self):
57+
self.option = _FakeOption()
58+
59+
60+
class _FakeNode:
61+
def __init__(self, nodeid):
62+
self.nodeid = nodeid
63+
self.funcargs = {}
64+
65+
66+
class _FakeRequest:
67+
"""Minimal stand-in for the pytest request that ``init_cuda`` reads."""
68+
69+
def __init__(self, nodeid="manual::init_cuda_gate_probe"):
70+
self.node = _FakeNode(nodeid)
71+
self.config = _FakeConfig()
72+
73+
74+
def _init_cuda_func(request):
75+
"""Return the raw ``init_cuda`` generator function from the fixture registry.
76+
77+
``FixtureDef.func`` is the undecorated fixture function, and its
78+
``__globals__`` is the live conftest namespace, so monkeypatching keys there
79+
affects the code these tests drive. Both are pytest internals; the asserts
80+
below fail loudly if a pytest change breaks the assumption.
81+
"""
82+
fixturedefs = request._fixturemanager.getfixturedefs("init_cuda", request.node)
83+
assert fixturedefs, "init_cuda fixture not found in the registry"
84+
func = fixturedefs[-1].func
85+
assert inspect.isgeneratorfunction(func), "init_cuda is expected to be a generator fixture"
86+
return func
87+
88+
89+
def _spy_on_gc_collect(monkeypatch):
90+
"""Patch ``gc.collect`` to record each explicit call; returns the call list.
91+
92+
Automatic generational collections do not route through ``gc.collect``, so
93+
the list counts only the fixture's explicit collects.
94+
"""
95+
calls = []
96+
real_collect = gc.collect
97+
98+
def spy(*args, **kwargs):
99+
calls.append(1)
100+
return real_collect(*args, **kwargs)
101+
102+
monkeypatch.setattr(gc, "collect", spy)
103+
return calls
104+
105+
106+
# --- 2. the collect gate -------------------------------------------------------
107+
108+
109+
@_THREAD_UNSAFE_MARKER
110+
@pytest.mark.agent_authored(model="claude-opus-4.8")
111+
def test_teardown_skips_collect_when_mr_count_unchanged(request, monkeypatch):
112+
func = _init_cuda_func(request)
113+
# Disable the leftover check so only the teardown gate can call gc.collect.
114+
monkeypatch.setitem(func.__globals__, "_mr_expected_live", None)
115+
collects = _spy_on_gc_collect(monkeypatch)
116+
117+
gen = func(_FakeRequest())
118+
next(gen) # setup: records the snapshot
119+
before = len(collects)
120+
# A resource created and freed by refcount leaves the count at the snapshot.
121+
mr = _LocalMR()
122+
del mr
123+
with pytest.raises(StopIteration):
124+
next(gen) # teardown runs the gate
125+
126+
assert len(collects) == before, "gate collected despite an unchanged count"
127+
128+
129+
@_THREAD_UNSAFE_MARKER
130+
@pytest.mark.agent_authored(model="claude-opus-4.8")
131+
def test_teardown_collects_when_mr_count_grows(request, monkeypatch):
132+
func = _init_cuda_func(request)
133+
monkeypatch.setitem(func.__globals__, "_mr_expected_live", None)
134+
collects = _spy_on_gc_collect(monkeypatch)
135+
136+
gen = func(_FakeRequest())
137+
next(gen) # setup: records the snapshot
138+
before = len(collects)
139+
# A resource still alive at teardown lifts the count above the snapshot.
140+
mr = _LocalMR()
141+
try:
142+
with pytest.raises(StopIteration):
143+
next(gen) # teardown runs the gate
144+
assert len(collects) == before + 1, "gate skipped the collect despite a grown count"
145+
finally:
146+
# Restore the baseline so the session's leftover tracking is unperturbed.
147+
del mr
148+
gc.collect()
149+
150+
151+
# --- 3. the two warnings -------------------------------------------------------
152+
153+
154+
@_THREAD_UNSAFE_MARKER
155+
@pytest.mark.agent_authored(model="claude-opus-4.8")
156+
def test_setup_warns_when_resources_left_alive_since_last_init_cuda(request, monkeypatch):
157+
func = _init_cuda_func(request)
158+
g = func.__globals__
159+
# Force the leftover-check precondition: the count at item start exceeds the
160+
# baseline recorded after the previous init_cuda test, i.e. something in
161+
# between left a resource alive. Keep a real resource referenced so the
162+
# "still alive after a collect" count is nonzero.
163+
base = _live_memory_resource_count()
164+
leaked = _LocalMR()
165+
monkeypatch.setitem(g, "_mr_expected_live", base)
166+
monkeypatch.setitem(g, "_mr_live_at_item_start", base + 1)
167+
monkeypatch.setitem(g, "_mr_last_init_cuda_nodeid", "tests/prev::test_prev")
168+
169+
gen = func(_FakeRequest())
170+
try:
171+
with pytest.warns(UnreleasedMemoryResourceWarning, match="left alive by"):
172+
next(gen) # setup emits the leftover warning
173+
with pytest.raises(StopIteration):
174+
next(gen) # finish teardown
175+
finally:
176+
del leaked
177+
gc.collect()
178+
179+
180+
@_THREAD_UNSAFE_MARKER
181+
@pytest.mark.agent_authored(model="claude-opus-4.8")
182+
def test_teardown_warns_on_baseline_release(request, monkeypatch):
183+
func = _init_cuda_func(request)
184+
# Disable the leftover check; this test exercises the teardown-side warning.
185+
monkeypatch.setitem(func.__globals__, "_mr_expected_live", None)
186+
187+
# A resource that exists before setup is part of the snapshot; releasing it
188+
# during the "test" drops the count below the snapshot at teardown.
189+
baseline_mr = _LocalMR()
190+
gen = func(_FakeRequest())
191+
next(gen) # setup snapshots the count, including baseline_mr
192+
del baseline_mr # release the pre-existing resource mid-"test"
193+
194+
with (
195+
pytest.warns(BaselineMemoryResourceReleasedWarning, match="existed before it started"),
196+
pytest.raises(StopIteration),
197+
):
198+
next(gen) # teardown sees a count below the snapshot

‎cuda_core/tests/memory/test_resource_live_count.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ def __init__(self, a, b):
3030
self.b = b
3131

3232

33-
_THREADSAFE_MARKER = pytest.mark.thread_unsafe(reason="asserts on the process-global resource count")
33+
_THREAD_UNSAFE_MARKER = pytest.mark.thread_unsafe(reason="asserts on the process-global resource count")
3434

3535

36-
@_THREADSAFE_MARKER
36+
@_THREAD_UNSAFE_MARKER
3737
@pytest.mark.agent_authored(model="glm-5.2")
3838
def test_new_resource_is_counted_until_freed(init_cuda):
3939
before = _live_memory_resource_count()
@@ -43,7 +43,7 @@ def test_new_resource_is_counted_until_freed(init_cuda):
4343
assert _live_memory_resource_count() == before
4444

4545

46-
@_THREADSAFE_MARKER
46+
@_THREAD_UNSAFE_MARKER
4747
@pytest.mark.agent_authored(model="glm-5.2")
4848
def test_resource_in_cycle_stays_counted_until_collect(init_cuda):
4949
before = _live_memory_resource_count()
@@ -64,7 +64,7 @@ def test_resource_in_cycle_stays_counted_until_collect(init_cuda):
6464
gc.enable()
6565

6666

67-
@_THREADSAFE_MARKER
67+
@_THREAD_UNSAFE_MARKER
6868
@pytest.mark.agent_authored(model="glm-5.2")
6969
def test_subclass_with_init_args_is_counted(init_cuda):
7070
# Guards the no-argument base __cinit__: a Python subclass whose __init__
@@ -77,7 +77,7 @@ def test_subclass_with_init_args_is_counted(init_cuda):
7777
assert _live_memory_resource_count() == before
7878

7979

80-
@_THREADSAFE_MARKER
80+
@_THREAD_UNSAFE_MARKER
8181
@pytest.mark.agent_authored(model="glm-5.2")
8282
def test_cython_subclass_wrapper_is_counted(init_cuda):
8383
# Covers a Cython subclass with its own __cinit__: a non-owning
@@ -90,7 +90,7 @@ def test_cython_subclass_wrapper_is_counted(init_cuda):
9090
assert _live_memory_resource_count() == before
9191

9292

93-
@_THREADSAFE_MARKER
93+
@_THREAD_UNSAFE_MARKER
9494
@pytest.mark.agent_authored(model="glm-5.2")
9595
def test_concurrent_construct_and_drop_returns_to_start(init_cuda):
9696
# On a free-threaded build this guards against a non-atomic counter; on a

‎cuda_core/tests/memory_ipc/test_errors.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,10 @@ def test_main(self, ipc_device, ipc_memory_resource):
162162
finally:
163163
for mr in self._extra_mrs:
164164
mr.close()
165-
# mr-baseline-retention-fix (H1): drop the fixture MR and buffer
166-
# references stored on this class instance, pytest retains
167-
# ``item._instance`` for the session, so the closed (but not
168-
# destroyed) MR objects would otherwise ratchet the baseline.
165+
# Drop the fixture MR and buffer references stored on this class
166+
# instance: pytest retains ``item._instance`` for the session, so the
167+
# closed (but not destroyed) MR objects would otherwise ratchet the
168+
# live-MemoryResource baseline the init_cuda teardown compares against.
169169
self.__dict__.pop("buffer", None)
170170
self.__dict__.pop("mr", None)
171171
self.__dict__.pop("device", None)

‎cuda_core/tests/test_error_handling.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,9 @@ def test_escalated_cudawarning_from_cleanup_is_not_a_crash(mempool_device):
230230
assert len(unraisable) == 1
231231
assert issubclass(unraisable[0].exc_type, CUDAWarning)
232232
assert "restoring the caller's context" in str(unraisable[0].exc_value)
233-
# mr-baseline-retention-fix (H3): the unraisable hook args hold
234-
# exc_traceback whose frame holds ``mr`` (never closed). Clear the
235-
# list and drop the locals so gc can reclaim mr; close the pool too.
233+
# The unraisable hook args hold exc_traceback whose frame holds ``mr``
234+
# (never closed). Clear the list and drop the locals so gc can reclaim mr;
235+
# close the pool too.
236236
unraisable.clear()
237237
mr.close()
238238
del mr, buf

‎cuda_core/tests/test_memory.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,10 +337,10 @@ def fill_env(request, init_cuda):
337337
try:
338338
yield device, mr
339339
finally:
340-
# mr-baseline-retention-fix (H4): drop the fixture's MR
341-
# reference so the (closed-by-dealloc) object does not persist
342-
# in the pytest fixture cache past this teardown; without this the
343-
# baseline ratchets/oscillates with fragile release timing.
340+
# Defensive: drop the fixture-local MR reference promptly so the
341+
# closed-by-dealloc object does not linger in this generator's frame.
342+
# init_cuda's teardown also clears item.funcargs, so the test's own
343+
# reference is released there regardless.
344344
del mr
345345

346346

0 commit comments

Comments
 (0)