|
| 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 |
0 commit comments