Skip to content

Commit 381128d

Browse files
Andy-Jostclaude
andauthored
cuda.core: make Program.__dealloc__ safe when collected in a reference cycle (#2879)
* cuda.core: guard Program.__dealloc__ against a failed construction Program_init records self._options as its first step, so a Program whose construction failed before that point, for example because options had the wrong type, reaches __dealloc__ with _options still None. _cleanup_debug_source then read self._options._name and raised AttributeError, which surfaced through sys.unraisablehook as "Exception ignored in: Program.__dealloc__". Skip the debug-source cleanup when the options were never recorded; there is nothing to remove in that case. Issue #2876 Co-authored-by: Claude Fable 5.1 <[email protected]> * cuda.core: make Program.__dealloc__ safe when collected in a reference cycle The cyclic collector clears the attributes of every object in a cycle before it frees any of them, so a Program freed that way may find _options already None, or the ProgramOptions already emptied, and _cleanup_debug_source() raised AttributeError reading options._name. Keep the debug source path in its own bytes attribute, which tp_clear leaves alone, and unlink from that alone. Fixes #2876 Co-Authored-By: Claude Fable 5.1 <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
1 parent daaab7a commit 381128d

4 files changed

Lines changed: 72 additions & 7 deletions

File tree

‎cuda_core/cuda/core/_program.pxd‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ cdef class Program:
2121
str _code_type # Normalised code_type ("c++", "ptx", "nvvm")
2222
str _pch_status # PCH creation outcome after compile
2323
bytes _nvrtc_name # Source filepath given to NVRTC; a real path for debug builds
24+
bytes _debug_source # Temp file this Program wrote for cuda-gdb, or None
2425
list _extra_options # NVRTC options Program adds on top of ProgramOptions

‎cuda_core/cuda/core/_program.pyx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,17 @@ cdef class Program:
104104
self._cleanup_debug_source()
105105

106106
def _cleanup_debug_source(self):
107-
# Only a temp file this Program wrote may be removed, and the name having
108-
# moved off options.name is what says one was written. Without that test
109-
# the caller's own file is deleted whenever options.name happens to match
110-
# something on disk, since the unredirected name is just that path.
111-
if self._nvrtc_name is not None and self._nvrtc_name != self._options._name:
112-
self._unlink_debug_source(self._nvrtc_name.decode())
107+
# Only a temp file this Program wrote may be removed, so its path is kept
108+
# in a dedicated attribute rather than derived from _nvrtc_name and
109+
# _options. When a Program dies inside a reference cycle, the cyclic
110+
# collector clears the object attributes of everything in the cycle
111+
# before __dealloc__ runs: _options may already be None, or the
112+
# ProgramOptions may already have lost its fields (#2876). A bytes
113+
# attribute is left alone by that clearing.
114+
path = self._debug_source
115+
if path is not None:
116+
self._debug_source = None
117+
self._unlink_debug_source(path.decode())
113118

114119
def _unlink_debug_source(self, path: str) -> None:
115120
try:
@@ -883,7 +888,7 @@ cdef inline int Program_init(Program self, object code, str code_type, object op
883888
if (options.debug or options.lineinfo) and options.name == "default_program":
884889
debug_path = self._try_materialize_nvrtc_debug_source(code)
885890
if debug_path is not None:
886-
self._nvrtc_name = debug_path.encode()
891+
self._nvrtc_name = self._debug_source = debug_path.encode()
887892
# NVRTC resolves #include "..." against the directory of the name it
888893
# is given, so moving the name into the temp dir would otherwise stop
889894
# every quoted include from resolving where it did before.

‎cuda_core/docs/source/release/1.3.0-notes.rst‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ New features
3434
Fixes and enhancements
3535
----------------------
3636

37+
- A :class:`Program` that is collected as part of a reference cycle no longer
38+
raises ``AttributeError`` from its destructor, and the source file it wrote
39+
for a ``debug`` or ``lineinfo`` build is removed. The cyclic collector
40+
clears object attributes before the destructor runs, and the destructor
41+
read the program options to locate that file.
42+
(`#2876 <https://github.com/NVIDIA/cuda-python/issues/2876>`__)
43+
3744
- ``Graph.__getitem__`` now declares an overload for each node type that has
3845
an executable view, so type checkers and editors see the precise view type:
3946
indexing with a :class:`~graph.KernelNode` yields an

‎cuda_core/tests/test_program.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import gc
6+
import os
57
import re
68
import shutil
79
import subprocess
810
import sys
11+
import tempfile
912
import warnings
1013

1114
import pytest
@@ -406,6 +409,55 @@ def test_program_init_invalid_code_format():
406409
Program(code, "c++")
407410

408411

412+
@pytest.mark.agent_authored(model="claude-fable-5-1")
413+
@pytest.mark.thread_unsafe(reason="replaces the process-global sys.unraisablehook and tempfile.mkstemp")
414+
@pytest.mark.parametrize("cleared_first", ["options", "program"])
415+
def test_program_collected_in_reference_cycle_is_quiet(cleared_first, monkeypatch):
416+
"""A Program collected as part of a reference cycle is destroyed quietly and removes its debug source (#2876).
417+
418+
The cyclic collector clears the attributes of every object in the cycle
419+
before it runs __dealloc__, in allocation order: either the ProgramOptions
420+
loses its fields first, or the Program loses its _options first. Neither
421+
may be consulted in __dealloc__. A failure there is only visible through
422+
sys.unraisablehook, so the hook is replaced to observe it.
423+
"""
424+
written = []
425+
real_mkstemp = tempfile.mkstemp
426+
427+
def recording_mkstemp(*args, **kwargs):
428+
fd, path = real_mkstemp(*args, **kwargs)
429+
written.append(path)
430+
return fd, path
431+
432+
monkeypatch.setattr(tempfile, "mkstemp", recording_mkstemp)
433+
unraisable = []
434+
monkeypatch.setattr(sys, "unraisablehook", unraisable.append)
435+
436+
code = 'extern "C" __global__ void my_kernel() {}'
437+
# arch is passed explicitly so the current device is not queried; debug=True
438+
# makes the Program write its source to a temp file for cuda-gdb.
439+
options = ProgramOptions(arch="sm_90", debug=True)
440+
if cleared_first == "options":
441+
# The cycle is allocated between the options and the Program, so the
442+
# collector clears the ProgramOptions before it frees the Program.
443+
cycle = []
444+
cycle.append(cycle)
445+
program = Program(code, "c++", options)
446+
cycle.append(program)
447+
else:
448+
# The cycle is allocated after the Program, so the collector clears the
449+
# Program's own attributes, including _options, before it frees it.
450+
program = Program(code, "c++", options)
451+
cycle = [program]
452+
cycle.append(cycle)
453+
assert written and all(os.path.exists(path) for path in written)
454+
del options, program, cycle
455+
gc.collect()
456+
457+
assert unraisable == []
458+
assert not any(os.path.exists(path) for path in written)
459+
460+
409461
# arch is passed explicitly so the current device is not queried.
410462
@pytest.mark.agent_authored(model="claude-opus-5")
411463
@pytest.mark.parametrize("name", [None, "my_program"])

0 commit comments

Comments
 (0)