Skip to content

cuda.core: reject operations on closed resource objects - #2635

Open
Andy-Jost wants to merge 8 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/closed-resource-validation
Open

cuda.core: reject operations on closed resource objects#2635
Andy-Jost wants to merge 8 commits into
NVIDIA:mainfrom
Andy-Jost:ajost/closed-resource-validation

Conversation

@Andy-Jost

@Andy-Jost Andy-Jost commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

closes #2627

Add a consistent closed-state contract across cuda.core resource objects so released native handles are rejected before reaching CUDA. Closeable resources now expose is_closed, while GraphDefinition and GraphNode expose is_valid for graph-lifetime invalidation. This change also adds tests ensuring bool(obj) returns true for closed and invalid objects, to retain backwards compatibility.

Active methods validate their own state and accepted resource arguments. Stream_accept() now rejects closed streams and graph builders, which also makes Buffer.set_deallocation_stream() and Buffer.close(stream=...) reject a closed stream without replacing the buffer's saved deallocation recipe. Closing CUDA default-stream tokens remains a no-op.

The same validation covers events, buffers, memory pools, IPC handles, contexts, compiler resources, graphs, arrays, textures, surfaces, and graphics resources. Tests cover named lifecycle state, backward-compatible truthiness, idempotent cleanup, safe inspection, cross-object validation, graph invalidation, and deallocation-stream failure atomicity.

Checklist

  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@Andy-Jost Andy-Jost added this to the cuda.core 1.2.0 milestone Aug 14, 2026
@Andy-Jost Andy-Jost added bug Something isn't working P0 High priority - Must do! cuda.core Everything related to the cuda.core module labels Aug 14, 2026
@Andy-Jost Andy-Jost self-assigned this Aug 14, 2026
@github-actions

Copy link
Copy Markdown

Comment on lines 133 to +138
q_bc.put(buffer)
buffer.close()

# Wait for C to receive before exiting.
# Queue serialization runs in a feeder thread. Keep the buffer open
# until C has received it and the parent releases this process.
event_b.wait(timeout=CHILD_TIMEOUT_SEC)
buffer.close()

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.

This fixes a latent bug. multiprocessing.Queue.put requires its argument to remain valid until received.

Add consistent liveness checks so closed handles cannot reach CUDA as valid resources, including graph and cross-object operations.
The allocation-handle constructor is intentionally unsupported on Windows, so limit its close-state test to supported platforms.
@Andy-Jost
Andy-Jost force-pushed the ajost/closed-resource-validation branch from 31f74c3 to 84db682 Compare August 14, 2026 22:51
Replace lifecycle-dependent truthiness with explicit is_closed and is_valid properties while preserving the historical truth value of cuda.core objects.

@mdboom mdboom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment -- move the helper functions to inline implementations in the .pxd. Then I see a mix of calling these helper functions and doing an if closed: raise(...). Is there a reason for that difference? If not, maybe consistently use the helper functions?

Comment thread cuda_core/cuda/core/_memory/_buffer.pyx Outdated
return tuple(out)


cdef int Buffer_check_open(Buffer self) except -1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's called on basically every path, inlining should help. Since this is used from other modules, move the implementation to the .pxd and add the inline keyword.

) except? -1

cdef int MP_raise_release_threshold(_MemPool self) except? -1
cdef int MP_check_open(_MemPool self) except -1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move the implementation here and inline?


def _get_int_attr(buf: Buffer, attribute: Any) -> int:
if buf.is_closed:
raise RuntimeError("Buffer has been closed")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Buffer_check_open(buf)?

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.

I don't think the Cython function can be called from a pure Python module.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. I missed that this was a .py file. Makes sense.

Comment on lines +366 to +369
cdef int MP_check_open(_MemPool self) except -1:
if not self._h_pool:
raise RuntimeError(f"{self.__class__.__name__} has been closed")
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move to .pxd and inline.

@Andy-Jost
Andy-Jost requested a review from mdboom August 18, 2026 16:51
Centralize open and valid state checks so hot Cython call paths use one consistent implementation.
@Andy-Jost

Copy link
Copy Markdown
Contributor Author

General comment -- move the helper functions to inline implementations in the .pxd. Then I see a mix of calling these helper functions and doing an if closed: raise(...). Is there a reason for that difference? If not, maybe consistently use the helper functions?

Generally agree. The latest change consolidates open-state checks in inline functions. Shared Cython checks live in .pxd files, and module-local checks live in .pyx files. The remaining exceptions involve pure Python modules or circular Cython dependencies.

Details:

  • _managed_buffer.py is pure Python, so it cannot call the cdef checker from _buffer.pxd. It now uses a local Python _check_open() helper.
  • _virtual_memory_resource.py is also pure Python. It has only one check, so it uses buf.is_closed directly.
  • _stream.pyx cannot directly cimport GB_check_open without creating a circular dependency. Stream_accept() instead accesses arg.stream, whose property calls the local inline checker.

Expect the shared Context checker message so the green-context test matches the standardized validation path.
@juenglin

Copy link
Copy Markdown
Contributor

Python's precedent is closed.

>>> open("README.md", "r").closed
False

We don't need to follow it, of course.

@mdboom mdboom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks much better from the human-review standpoint.

Claude flagged a few things that seem like legitimate issues, but I don't have the full context to evaluate them.

reference and allows the Python owner to be GC'd.
"""
if self._h_stream and Stream_is_default_token(self):
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is legit, but I don't fully understand how these special Stream singletons work.

From Claude:

Stream.close() early-returns for any stream whose raw handle equals CU_STREAM_LEGACY/CU_STREAM_PER_THREAD, because Stream_is_default_token() compares raw handle values, not identity against the two default-stream singletons. Stream.from_handle(1) / Stream.from_handle(2) — legitimate user-created borrowed wrappers — become permanently un-closeable: the owner reference is never released and is_closed stays False forever, contradicting the method's own docstring. Fix: guard on self is LEGACY_DEFAULT_STREAM or self is PER_THREAD_DEFAULT_STREAM, not on handle value. The new test (test_raw_null_stream_is_live_until_closed) uses from_handle(0), which happens to sidestep this exact case.

asynchronously. Must be passed explicitly; pass
``device.default_stream`` to use the default stream.
"""
cdef Stream s = Stream_accept(stream)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think whether Claude is right about this depends on whether double-calling cuMemFreeAsync is an error.

From Claude:

_MemPool.deallocate() (backing DeviceMemoryResource/PinnedMemoryResource/ManagedMemoryResource) never got an MP_check_open(self) call, unlike every sibling entry point in the same file (allocate, attributes, MP_raise_release_threshold, __reduce__, peer_accessible_by). After mr.close(), calling mr.deallocate(ptr, size, stream=...) sails straight through to cuMemFreeAsync against a destroyed pool — exactly the failure class this PR is meant to close everywhere else.

# Unpickling performs a live CUDA IPC import from descriptor bytes in the
# pickle stream. Only deserialize Buffers from a trusted principal.
# Must not serialize the parent's stream!
Buffer_check_open(self)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one may or may not be legit. From Claude:

Adding Buffer_check_open(self) to __reduce__ breaks the queue.put(buffer); buffer.close() pattern: multiprocessing.Queue.put() serializes on a background feeder thread, so a race lets close() win and __reduce__ raises inside the feeder thread, where multiprocessing logs-and-discards it — the consumer just hangs with no error at the put() call site. The PR's own test (test_send_buffers.py:134) had to be reordered to work around this. This needs to be called out in the release notes (currently only mention rejecting closed resources, not this pickling/queue-handoff hazard).

cdef inline void check_owner_mutable(self) except *:
if as_cu(self._h_graph) == NULL:
raise RuntimeError("GraphDefinition is no longer valid")
if as_cu(self._h_node) == NULL:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Claude:

check_owner_mutable() treats any NULL _h_node as "destroyed," but GraphDefinition._entry (the virtual entry node) legitimately has _h_node == NULL by design — GN_check_valid correctly exempts it via _is_entry, but _AdjacencySetCore never captures that flag for the owner node. So graph_def._entry.succ.add(node) incorrectly raises "GraphNode has been destroyed" for a node that's actually valid. Exposure is limited (private _entry, not routed through this path by the public API today), but it's a real inconsistency in the exact contract this PR establishes, and untested.

return self._h_fd.get() == NULL or as_intptr(self._h_fd) < 0

def __int__(self) -> int:
if not self._h_fd or as_intptr(self._h_fd) < 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit:

Suggested change
if not self._h_fd or as_intptr(self._h_fd) < 0:
if not self.is_closed:

@property
def is_closed(self) -> bool:
"""Whether this allocation handle has been closed."""
return self._h_fd.get() == NULL or as_intptr(self._h_fd) < 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor DRY nit:

Suggested change
return self._h_fd.get() == NULL or as_intptr(self._h_fd) < 0
return IPCAllocationHandle_check_open(self)

(<_AdjacencySetCore>self._core).check_owner_mutable()
if not isinstance(value, GraphNode):
return
(<_AdjacencySetCore>self._core).check_mutation(value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude points out that this causes a diversion from the Python MutableSet.discard convention -- that an invalid value should just return and never raise. Therefore this check maybe belongs right before the remove_edge call.

@Andy-Jost

Copy link
Copy Markdown
Contributor Author

Python's precedent is closed.

>>> open("README.md", "r").closed
False

We don't need to follow it, of course.

cuda.core consistently names Boolean properties as is_*.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cuda.core Everything related to the cuda.core module P0 High priority - Must do!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cuda.core: reject operations on closed resource objects

3 participants