cuda.core: reject operations on closed resource objects - #2635
Conversation
|
| 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() |
There was a problem hiding this comment.
This fixes a latent bug. multiprocessing.Queue.put requires its argument to remain valid until received.
0539baa to
31f74c3
Compare
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.
31f74c3 to
84db682
Compare
Replace lifecycle-dependent truthiness with explicit is_closed and is_valid properties while preserving the historical truth value of cuda.core objects.
mdboom
left a comment
There was a problem hiding this comment.
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?
| return tuple(out) | ||
|
|
||
|
|
||
| cdef int Buffer_check_open(Buffer self) except -1: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
I don't think the Cython function can be called from a pure Python module.
There was a problem hiding this comment.
Ah. I missed that this was a .py file. Makes sense.
| 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 |
Centralize open and valid state checks so hot Cython call paths use one consistent implementation.
Generally agree. The latest change consolidates open-state checks in inline functions. Shared Cython checks live in Details:
|
Expect the shared Context checker message so the green-context test matches the standardized validation path.
|
Python's precedent is We don't need to follow it, of course. |
mdboom
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
Minor nit:
| 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 |
There was a problem hiding this comment.
Minor DRY nit:
| 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) |
There was a problem hiding this comment.
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.
cuda.core consistently names Boolean properties as |
Description
closes #2627
Add a consistent closed-state contract across
cuda.coreresource objects so released native handles are rejected before reaching CUDA. Closeable resources now exposeis_closed, whileGraphDefinitionandGraphNodeexposeis_validfor graph-lifetime invalidation. This change also adds tests ensuringbool(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 makesBuffer.set_deallocation_stream()andBuffer.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