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.

@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
@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.

@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.

Comment thread cuda_core/cuda/core/_stream.pyx Outdated
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.

@Andy-Jost Andy-Jost Aug 20, 2026

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.

In thinking about this, I realized it’s probably better to remove the exception that prevents closing the default stream singletons. LEGACY_DEFAULT_STREAM and PER_THREAD_DEFAULT_STREAM are process-wide, so closing them may break the program, but they are still ordinary Stream objects. There is no justification for adding special semantics just because they happen to be global.

This would be like preventing users from closing sys.stdout. Going down that path is what led to this subtle problem in the first place. It’s better to let the global remain an ordinary object, even if it can be misused.

To clarify, LEGACY_DEFAULT_STREAM.close() only detaches the Python object from its handle. It does not instruct CUDA to destroy the default stream. This exception only protected the Python layer.

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.

@Andy-Jost Andy-Jost Aug 20, 2026

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.

_MemPool.close() releases the Python object's memory pool handle but does not necessarily destroy the pool. Device pointer handles structurally embed an independent reference that ensures the pool remains live. I think the best option in this case is to leave deallocate unguarded and interpret close to mean future allocations are disallowed. Frees performed against a closed pool would remain valid. Otherwise, closing a pool would mean leaking any outstanding allocations that used Buffer.from_handle(..., mr=pool).

I think a case could be made that pools ought not be closable. In a language like Python, explicit close usually doesn't make sense. Exceptions are made when resources need to be relinquished accurately, though. I'm not sure that applies to CUDA memory pools.

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 updated the _MemPool.close docstring to clarify this.

# 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).

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.

The pattern queue.put(buffer); buffer.close() was a latent race in the test that I discovered incidentally. That may explain why the test was marked flaky.

Because mp.Queue sends objects asynchronously, subsequent mutations (including close) must be ordered after the object is sent. I think the right thing in this case it to fix the test without publishing a release note.

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.

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 believe this is unreachable and should be ignored. The virtual entry node is private and requesting its successors (or predecessors) is meaningless. Since there's no valid path to something like graph_def._entry.succ we should assume it is not used and avoid an unnecessary check.

Comment thread cuda_core/cuda/core/_memory/_ipc.pyx Outdated
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:

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 noticed the negative-value check was superfluous, so I simply inlined the null-check in the few places where it was needed.

Comment thread cuda_core/cuda/core/_memory/_ipc.pyx Outdated
@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)

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.

Yeah, it's annoying to have two levels of inline helper for the "is it open" check and "raise if not open" check. I don't think this suggestion can be applied because it would raise. Fortunately, I was able to simplify it by removing the negative-value check.

(<_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.

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.

fixed

@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_*.

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.
Replace lifecycle-dependent truthiness with explicit is_closed and is_valid properties while preserving the historical truth value of cuda.core objects.
Centralize open and valid state checks so hot Cython call paths use one consistent implementation.
Expect the shared Context checker message so the green-context test matches the standardized validation path.
Keep generated type information aligned with the rebased lifecycle APIs.
@Andy-Jost
Andy-Jost force-pushed the ajost/closed-resource-validation branch from 868142e to 34b1cd1 Compare August 20, 2026 23:38
@Andy-Jost
Andy-Jost requested a review from mdboom August 20, 2026 23:39
@Andy-Jost
Andy-Jost force-pushed the ajost/closed-resource-validation branch from 34b1cd1 to f8eae47 Compare August 20, 2026 23:57
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