onnx_runtime_memory_api/deferred.rs
1//! Owning release: lifecycle vocabulary, structured outcomes, and the
2//! provider/context-owned deferred release queue.
3//!
4//! Phase 3 issued binding identity, allocation generations, and lifetime pins.
5//! This module adds the piece that identity alone cannot express: *who owns the
6//! final physical release, and what is true after it partially fails*.
7//!
8//! # The three-step contract
9//!
10//! 1. **Prepare.** [`crate::MemoryBinding::prepare_release`] matches the binding
11//! identity *and* the allocation generation, removes the live record exactly
12//! once under the per-mechanism lock, and returns an owned
13//! [`PreparedAllocationRelease`]. Because the record is removed under that
14//! lock, two racing final releases cannot both proceed, and a stale handle
15//! whose virtual address was reused cannot match a newer generation.
16//! 2. **Queue (optional).** The prepared request is handed to a
17//! [`DeferredReleaseQueue`] owned by the provider context. Every queue call
18//! happens after all registry and mechanism locks are dropped.
19//! 3. **Execute.** [`PreparedAllocationRelease::execute`] calls the pinned
20//! allocator with no lock held and returns an
21//! [`AllocationReleaseOutcome`].
22//!
23//! # Fail-safe rules
24//!
25//! * A prepared request that is *abandoned* (dropped without `execute`)
26//! quarantines its ownership. It never frees, never blocks, and never loses
27//! metadata.
28//! * Enqueue failure returns the exact request to the caller inside
29//! [`DeferredEnqueueError`]; dropping that error quarantines the request
30//! rather than losing it.
31//! * Device loss never calls the allocator. Queued requests finish as
32//! device-lost quarantine while keeping their allocator/authority/context
33//! pins.
34//! * No `Err`/`Failed` shape may imply "nothing changed" after the device was
35//! mutated. Any partial mutation is [`AllocationReleaseOutcome::Quarantined`]
36//! and carries accounting plus residual facts.
37
38use std::fmt::Debug;
39use std::ptr::NonNull;
40use std::sync::Arc;
41
42use crate::binding::{MechanismOperation, ReleaseGate};
43use crate::{
44 AllocationIdentity, AuthorityIdentity, BindingIdentity, DeviceAllocator, DeviceKey,
45 MemoryBinding, ProviderContextIdentity,
46};
47
48/// Where one allocation's ownership currently sits.
49///
50/// This is the shared vocabulary used by owning handles, prepared requests,
51/// mechanism snapshots, and release outcomes. It deliberately distinguishes
52/// "the bytes are gone" from "we still own something we could not give back".
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub enum AllocationReleaseState {
55 /// Live and mapped. The owner may still read, write, view, or release it.
56 Live,
57 /// Final ownership was handed to a deferred queue. The live record is
58 /// already gone, so no further allocation-level operation can match it, but
59 /// the physical bytes are not released yet.
60 Queued,
61 /// The allocator unmapped part of the allocation and stopped. Residual
62 /// ownership is still held by the runtime and must not be reused.
63 PartiallyUnmapped,
64 /// Released back to the allocator (which may have pooled rather than freed
65 /// the bytes). This is the only success terminal state.
66 Released,
67 /// The device or provider context was lost. No allocator call may be made,
68 /// and reclamation happens only at confirmed context/process termination.
69 DeviceLost,
70 /// Ownership is retained deliberately because releasing it would be unsafe
71 /// or dishonest. Quarantined ownership stays observable and blocks unsafe
72 /// mechanism removal.
73 Quarantined,
74}
75
76impl AllocationReleaseState {
77 /// Whether the runtime still owns physical bytes in this state.
78 pub const fn retains_ownership(self) -> bool {
79 matches!(
80 self,
81 Self::Live
82 | Self::Queued
83 | Self::PartiallyUnmapped
84 | Self::DeviceLost
85 | Self::Quarantined
86 )
87 }
88
89 /// Whether an allocator callback may still be made from this state.
90 ///
91 /// Device loss is `false` by construction: the allocator is never called
92 /// after loss, in any phase.
93 pub const fn permits_allocator_call(self) -> bool {
94 matches!(self, Self::Live | Self::Queued)
95 }
96
97 /// Whether no further transition is expected without external teardown.
98 pub const fn is_terminal(self) -> bool {
99 matches!(
100 self,
101 Self::Released | Self::DeviceLost | Self::Quarantined | Self::PartiallyUnmapped
102 )
103 }
104
105 pub const fn name(self) -> &'static str {
106 match self {
107 Self::Live => "live",
108 Self::Queued => "queued",
109 Self::PartiallyUnmapped => "partially unmapped",
110 Self::Released => "released",
111 Self::DeviceLost => "device lost",
112 Self::Quarantined => "quarantined",
113 }
114 }
115}
116
117impl std::fmt::Display for AllocationReleaseState {
118 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 formatter.write_str(self.name())
120 }
121}
122
123/// Byte accounting for one release attempt.
124///
125/// `unmapped_bytes` is the mapped-attribution refund observed by the allocator.
126/// **Zero is a valid complete result**: eager allocators have no mapped
127/// attribution, and a virtual allocation with nothing committed unmaps nothing.
128/// Zero is never an error proxy; failure is expressed by the outcome variant.
129#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
130pub struct ReleaseAccounting {
131 /// The whole-allocation size the release was prepared for.
132 pub allocation_bytes: u64,
133 /// Bytes whose global mapping reference transitioned to unmapped.
134 pub unmapped_bytes: u64,
135}
136
137impl ReleaseAccounting {
138 pub const fn new(allocation_bytes: u64, unmapped_bytes: u64) -> Self {
139 Self {
140 allocation_bytes,
141 unmapped_bytes,
142 }
143 }
144
145 /// Accounting for an eager allocator with no mapped attribution.
146 pub const fn eager(allocation_bytes: u64) -> Self {
147 Self::new(allocation_bytes, 0)
148 }
149}
150
151/// Why ownership was retained instead of released.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
153pub enum QuarantineReason {
154 /// A prepared request was dropped without being executed.
155 AbandonedRequest,
156 /// An owning handle was dropped without an explicit release.
157 OwnerDropped,
158 /// The deferred queue refused final ownership.
159 EnqueueRejected(DeferredEnqueueRejection),
160 /// The device or provider context was lost, so the allocator must not be
161 /// called.
162 DeviceLost,
163 /// The mechanism was already terminated when release was attempted.
164 MechanismTerminated,
165 /// The allocator mutated part of the allocation and then stopped.
166 PartialRelease,
167 /// The allocator refused *after* preparation while promising it had not
168 /// mutated anything. The live record is already gone, so live ownership
169 /// cannot be restored without losing the owner; the conservative answer is
170 /// quarantine.
171 AllocatorRefused,
172 /// A mechanism lock was poisoned, so ownership could not be settled safely.
173 StatePoisoned,
174}
175
176impl QuarantineReason {
177 pub const fn name(self) -> &'static str {
178 match self {
179 Self::AbandonedRequest => "a prepared release request was abandoned",
180 Self::OwnerDropped => "an owning allocation was dropped without explicit release",
181 Self::EnqueueRejected(_) => "the deferred release queue refused the request",
182 Self::DeviceLost => "the device or provider context was lost",
183 Self::MechanismTerminated => "the mechanism was already terminated",
184 Self::PartialRelease => "the allocator released only part of the allocation",
185 Self::AllocatorRefused => "the allocator refused after the record was retired",
186 Self::StatePoisoned => "mechanism state was poisoned",
187 }
188 }
189}
190
191impl std::fmt::Display for QuarantineReason {
192 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 formatter.write_str(self.name())?;
194 if let Self::EnqueueRejected(rejection) = self {
195 write!(formatter, " ({})", rejection.name())?;
196 }
197 Ok(())
198 }
199}
200
201/// What the runtime still owns after a non-complete release.
202#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
203pub struct ResidualOwnership {
204 /// The state the residual ownership is parked in.
205 pub state: AllocationReleaseState,
206 pub reason: QuarantineReason,
207 /// Bytes still owned by the runtime. For a fully unreleased allocation this
208 /// equals the allocation size.
209 pub retained_bytes: u64,
210 /// The address the residual ownership refers to. Kept so a manager can
211 /// reconcile against provider-side records; it is never dereferenced here.
212 pub address: usize,
213 pub align: usize,
214}
215
216/// A release that failed *before* any device mutation.
217///
218/// Allocators return this only when nothing was mutated and the caller's state
219/// is unchanged. It is the one shape that may imply "nothing happened"; every
220/// post-mutation failure must be [`AllocationReleaseOutcome::Quarantined`].
221#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct ReleaseFailure {
223 reason: Arc<str>,
224}
225
226impl ReleaseFailure {
227 pub fn new(reason: impl Into<Arc<str>>) -> Self {
228 Self {
229 reason: reason.into(),
230 }
231 }
232
233 pub fn reason(&self) -> &str {
234 &self.reason
235 }
236}
237
238impl std::fmt::Display for ReleaseFailure {
239 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 formatter.write_str(&self.reason)
241 }
242}
243
244/// The structured result of one whole-allocation release.
245#[derive(Clone, Debug, PartialEq, Eq)]
246pub enum AllocationReleaseOutcome {
247 /// The allocation was fully released (freed or pooled). `accounting`
248 /// reports the refund, and zero unmapped bytes is a valid complete result.
249 Complete { accounting: ReleaseAccounting },
250 /// Ownership was retained. `accounting` reports whatever was actually
251 /// unmapped before stopping, and `residual` reports what is still owned.
252 Quarantined {
253 accounting: ReleaseAccounting,
254 residual: ResidualOwnership,
255 },
256 /// Nothing was mutated. Only an allocator may produce this; the binding
257 /// layer converts it to `Quarantined` when the live record is already gone.
258 Failed { failure: ReleaseFailure },
259}
260
261impl AllocationReleaseOutcome {
262 pub const fn complete(accounting: ReleaseAccounting) -> Self {
263 Self::Complete { accounting }
264 }
265
266 pub const fn quarantined(accounting: ReleaseAccounting, residual: ResidualOwnership) -> Self {
267 Self::Quarantined {
268 accounting,
269 residual,
270 }
271 }
272
273 pub fn failed(reason: impl Into<Arc<str>>) -> Self {
274 Self::Failed {
275 failure: ReleaseFailure::new(reason),
276 }
277 }
278
279 /// The lifecycle state this outcome leaves the allocation in.
280 pub const fn state(&self) -> AllocationReleaseState {
281 match self {
282 Self::Complete { .. } => AllocationReleaseState::Released,
283 Self::Quarantined { residual, .. } => residual.state,
284 // Nothing was mutated, so the allocation is still exactly as live as
285 // the caller left it.
286 Self::Failed { .. } => AllocationReleaseState::Live,
287 }
288 }
289
290 pub const fn accounting(&self) -> Option<ReleaseAccounting> {
291 match self {
292 Self::Complete { accounting } | Self::Quarantined { accounting, .. } => {
293 Some(*accounting)
294 }
295 Self::Failed { .. } => None,
296 }
297 }
298
299 pub const fn residual(&self) -> Option<ResidualOwnership> {
300 match self {
301 Self::Quarantined { residual, .. } => Some(*residual),
302 _ => None,
303 }
304 }
305
306 pub const fn failure(&self) -> Option<&ReleaseFailure> {
307 match self {
308 Self::Failed { failure } => Some(failure),
309 _ => None,
310 }
311 }
312
313 pub const fn is_complete(&self) -> bool {
314 matches!(self, Self::Complete { .. })
315 }
316
317 pub const fn is_quarantined(&self) -> bool {
318 matches!(self, Self::Quarantined { .. })
319 }
320
321 /// Unmapped bytes, or zero when nothing was mutated.
322 pub const fn unmapped_bytes(&self) -> u64 {
323 match self {
324 Self::Complete { accounting } | Self::Quarantined { accounting, .. } => {
325 accounting.unmapped_bytes
326 }
327 Self::Failed { .. } => 0,
328 }
329 }
330}
331
332/// Why a [`DeferredReleaseQueue`] refused final ownership.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
334pub enum DeferredEnqueueRejection {
335 /// The queue is shutting down or already drained.
336 Closed,
337 /// The queue is bounded and full. Bounded queues are the reason this is a
338 /// first-class rejection rather than an unbounded backlog.
339 Full,
340 /// The queue observed device loss and will not accept allocator work.
341 DeviceLost,
342 /// Implementation-specific refusal.
343 Refused,
344}
345
346impl DeferredEnqueueRejection {
347 pub const fn name(self) -> &'static str {
348 match self {
349 Self::Closed => "closed",
350 Self::Full => "full",
351 Self::DeviceLost => "device lost",
352 Self::Refused => "refused",
353 }
354 }
355}
356
357/// Enqueue failure that hands the **exact** prepared request back.
358///
359/// Nothing is cloned or reconstructed: the request that failed to enqueue is
360/// the request returned. Dropping this error without calling
361/// [`into_request`](Self::into_request) quarantines that request rather than
362/// leaking or freeing it.
363#[derive(Debug)]
364pub struct DeferredEnqueueError {
365 rejection: DeferredEnqueueRejection,
366 /// Boxed so the `Ok` path of [`DeferredReleaseQueue::enqueue`] stays small.
367 /// The boxed value is the same request the queue was handed; nothing is
368 /// cloned or rebuilt.
369 request: Box<PreparedAllocationRelease>,
370}
371
372impl DeferredEnqueueError {
373 pub fn new(rejection: DeferredEnqueueRejection, request: PreparedAllocationRelease) -> Self {
374 Self {
375 rejection,
376 request: Box::new(request),
377 }
378 }
379
380 pub const fn rejection(&self) -> DeferredEnqueueRejection {
381 self.rejection
382 }
383
384 pub const fn request(&self) -> &PreparedAllocationRelease {
385 &self.request
386 }
387
388 /// Recover the exact prepared request.
389 pub fn into_request(self) -> PreparedAllocationRelease {
390 *self.request
391 }
392
393 /// Quarantine the request with the rejection recorded as its reason.
394 pub fn quarantine(self) -> AllocationReleaseOutcome {
395 let rejection = self.rejection;
396 (*self.request).quarantine(QuarantineReason::EnqueueRejected(rejection))
397 }
398}
399
400impl std::fmt::Display for DeferredEnqueueError {
401 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 write!(
403 formatter,
404 "the deferred release queue refused allocation {:?}: {}",
405 self.request.identity(),
406 self.rejection.name()
407 )
408 }
409}
410
411impl std::error::Error for DeferredEnqueueError {}
412
413/// A provider/context-owned sink for final allocation ownership.
414///
415/// The queue is *not* owned by this crate. A CUDA provider owns one per context
416/// or per stream, records a fence when a request arrives, and calls
417/// [`PreparedAllocationRelease::execute`] once that fence is observed.
418///
419/// # Contract
420///
421/// * `enqueue` is always called with no registry lock and no mechanism lock
422/// held, so an implementation may take its own locks freely.
423/// * `enqueue` must not block on the device.
424/// * On refusal, the implementation must return the exact request inside
425/// [`DeferredEnqueueError`]. It must never drop the request silently to
426/// signal failure; dropping it is defined as quarantine, not as free.
427pub trait DeferredReleaseQueue: Send + Sync + Debug {
428 /// Take final ownership of `request`.
429 fn enqueue(&self, request: PreparedAllocationRelease) -> Result<(), DeferredEnqueueError>;
430
431 /// How many requests are still waiting. Used for observability and for
432 /// asserting that a queue does not grow without bound.
433 fn pending(&self) -> usize {
434 0
435 }
436}
437
438/// What happened when an owning allocation was handed to a deferred queue.
439#[derive(Clone, Debug, PartialEq, Eq)]
440pub enum DeferredReleaseDisposition {
441 /// The queue accepted final ownership. The allocation is
442 /// [`AllocationReleaseState::Queued`] until the queue executes it.
443 Queued { identity: AllocationIdentity },
444 /// The queue refused, so ownership was quarantined at its mechanism.
445 Quarantined {
446 identity: AllocationIdentity,
447 rejection: DeferredEnqueueRejection,
448 outcome: AllocationReleaseOutcome,
449 },
450}
451
452impl DeferredReleaseDisposition {
453 pub const fn identity(&self) -> AllocationIdentity {
454 match self {
455 Self::Queued { identity } | Self::Quarantined { identity, .. } => *identity,
456 }
457 }
458
459 pub const fn state(&self) -> AllocationReleaseState {
460 match self {
461 Self::Queued { .. } => AllocationReleaseState::Queued,
462 Self::Quarantined { .. } => AllocationReleaseState::Quarantined,
463 }
464 }
465
466 pub const fn is_queued(&self) -> bool {
467 matches!(self, Self::Queued { .. })
468 }
469}
470
471/// Final ownership of one allocation, detached from its live record.
472///
473/// A prepared request is produced only after the binding identity and the
474/// allocation generation matched and the live record was removed exactly once
475/// under the per-mechanism lock. From that moment the allocation is
476/// [`AllocationReleaseState::Queued`]: no view, commit, or second release can
477/// match it, and address reuse cannot resurrect it.
478///
479/// The request pins the allocator, the accounting authority, and the provider
480/// context, so a queue may hold it across mechanism retirement and across
481/// threads.
482///
483/// # Abandonment is safe
484///
485/// Dropping a prepared request without calling [`execute`](Self::execute)
486/// quarantines it: the ownership is recorded at the mechanism with residual
487/// facts, and no allocator call and no blocking wait happen in `Drop`.
488pub struct PreparedAllocationRelease {
489 binding: MemoryBinding,
490 identity: AllocationIdentity,
491 ptr: NonNull<u8>,
492 bytes: usize,
493 align: usize,
494 allocator: Arc<dyn DeviceAllocator>,
495 authority: AuthorityIdentity,
496 context: ProviderContextIdentity,
497 /// Keeps the mechanism non-quiescent, so a queued request blocks mechanism
498 /// removal and provider-context termination. Dropped only after the final
499 /// state is recorded.
500 operation: Option<MechanismOperation>,
501 /// Cleared by any consuming path, so `Drop` quarantines only a genuinely
502 /// abandoned request.
503 armed: bool,
504}
505
506// SAFETY: the request carries allocation metadata over a provider-defined
507// device address plus `Send + Sync` pins. It exposes no safe dereference, so
508// moving it to a queue thread does not access the pointed-to bytes.
509unsafe impl Send for PreparedAllocationRelease {}
510// SAFETY: shared access exposes only copied metadata and a non-dereferenced
511// address; every consuming operation takes the request by value.
512unsafe impl Sync for PreparedAllocationRelease {}
513
514impl Debug for PreparedAllocationRelease {
515 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 formatter
517 .debug_struct("PreparedAllocationRelease")
518 .field("identity", &self.identity)
519 .field("address", &(self.ptr.as_ptr() as usize))
520 .field("bytes", &self.bytes)
521 .field("align", &self.align)
522 .field("authority", &self.authority)
523 .field("provider_context", &self.context)
524 .field("armed", &self.armed)
525 .finish()
526 }
527}
528
529/// The resources a prepared request pins for its whole lifetime.
530pub(crate) struct PreparedReleasePins {
531 pub(crate) allocator: Arc<dyn DeviceAllocator>,
532 pub(crate) authority: AuthorityIdentity,
533 pub(crate) context: ProviderContextIdentity,
534 pub(crate) operation: MechanismOperation,
535}
536
537impl PreparedAllocationRelease {
538 pub(crate) fn new(
539 binding: MemoryBinding,
540 identity: AllocationIdentity,
541 ptr: NonNull<u8>,
542 bytes: usize,
543 align: usize,
544 pins: PreparedReleasePins,
545 ) -> Self {
546 Self {
547 binding,
548 identity,
549 ptr,
550 bytes,
551 align,
552 allocator: pins.allocator,
553 authority: pins.authority,
554 context: pins.context,
555 operation: Some(pins.operation),
556 armed: true,
557 }
558 }
559
560 pub const fn identity(&self) -> AllocationIdentity {
561 self.identity
562 }
563
564 pub const fn binding_identity(&self) -> BindingIdentity {
565 self.identity.binding()
566 }
567
568 pub const fn device(&self) -> DeviceKey {
569 self.identity.binding().device()
570 }
571
572 /// The pinned accounting authority. A manager refunds against this identity
573 /// even if the mechanism has since been retired.
574 pub const fn authority(&self) -> AuthorityIdentity {
575 self.authority
576 }
577
578 /// The pinned provider context. The queue that owns this request belongs to
579 /// this context.
580 pub const fn provider_context(&self) -> ProviderContextIdentity {
581 self.context
582 }
583
584 /// The pinned allocator that must perform the physical release.
585 pub fn allocator(&self) -> &Arc<dyn DeviceAllocator> {
586 &self.allocator
587 }
588
589 /// The address to release. Never dereferenced by this crate.
590 pub const fn as_ptr(&self) -> NonNull<u8> {
591 self.ptr
592 }
593
594 pub const fn len(&self) -> usize {
595 self.bytes
596 }
597
598 pub const fn is_empty(&self) -> bool {
599 self.bytes == 0
600 }
601
602 pub const fn alignment(&self) -> usize {
603 self.align
604 }
605
606 /// Always [`AllocationReleaseState::Queued`]: the live record is gone and
607 /// the bytes are not released yet.
608 pub const fn state(&self) -> AllocationReleaseState {
609 AllocationReleaseState::Queued
610 }
611
612 /// Perform the physical release through the pinned allocator.
613 ///
614 /// Returns [`AllocationReleaseOutcome::Complete`] or
615 /// [`AllocationReleaseOutcome::Quarantined`], never
616 /// [`AllocationReleaseOutcome::Failed`]: the live record was already
617 /// retired at preparation, so "nothing changed" is no longer representable
618 /// here and an allocator-level `Failed` is conservatively quarantined.
619 ///
620 /// # Lock order
621 ///
622 /// The mechanism lifecycle is read under the mechanism lock, that lock is
623 /// dropped, the allocator runs with **no** lock held, and the final state is
624 /// then recorded under the mechanism lock again. The registry lock is never
625 /// taken here, so the two lock classes are still never nested.
626 ///
627 /// Device loss observed at this point never reaches the allocator: the
628 /// request finishes as device-lost quarantine and keeps its pins.
629 pub fn execute(mut self) -> AllocationReleaseOutcome {
630 self.armed = false;
631 match self.binding.mechanism().release_gate() {
632 ReleaseGate::Allowed => {}
633 ReleaseGate::DeviceLost => {
634 return self.settle_quarantine(
635 ReleaseAccounting::new(self.bytes as u64, 0),
636 AllocationReleaseState::DeviceLost,
637 QuarantineReason::DeviceLost,
638 self.bytes as u64,
639 );
640 }
641 ReleaseGate::Terminated => {
642 return self.settle_quarantine(
643 ReleaseAccounting::new(self.bytes as u64, 0),
644 AllocationReleaseState::Quarantined,
645 QuarantineReason::MechanismTerminated,
646 self.bytes as u64,
647 );
648 }
649 ReleaseGate::Poisoned => {
650 return self.settle_quarantine(
651 ReleaseAccounting::new(self.bytes as u64, 0),
652 AllocationReleaseState::Quarantined,
653 QuarantineReason::StatePoisoned,
654 self.bytes as u64,
655 );
656 }
657 }
658
659 // SAFETY: preparation matched the binding identity and the allocation
660 // generation and removed the exact live record under the mechanism
661 // lock, so this is one live allocation of this mechanism with exactly
662 // these bytes and alignment, and it cannot be released twice. No
663 // registry or mechanism lock is held here.
664 let outcome = unsafe { self.allocator.release(self.ptr, self.bytes, self.align) };
665
666 match outcome {
667 AllocationReleaseOutcome::Complete { accounting } => {
668 self.settle_released();
669 AllocationReleaseOutcome::Complete { accounting }
670 }
671 AllocationReleaseOutcome::Quarantined {
672 accounting,
673 residual,
674 } => self.settle_quarantine(
675 accounting,
676 residual.state,
677 residual.reason,
678 residual.retained_bytes,
679 ),
680 // The allocator promises it mutated nothing, but preparation
681 // already retired the live record and consumed the owner. Restoring
682 // live ownership would require inventing a record the owner can no
683 // longer reach, so the conservative, honest answer is quarantine.
684 //
685 // The allocator's own message is not carried into the residual
686 // facts, which are deliberately `Copy`; an implementation that needs
687 // the text should log it before returning `Failed`.
688 AllocationReleaseOutcome::Failed { .. } => {
689 let bytes = self.bytes as u64;
690 self.settle_quarantine(
691 ReleaseAccounting::new(bytes, 0),
692 AllocationReleaseState::Quarantined,
693 QuarantineReason::AllocatorRefused,
694 bytes,
695 )
696 }
697 }
698 }
699
700 /// Settle this request as device-lost quarantine, without calling the
701 /// allocator and without refunding anything.
702 ///
703 /// This is the same settlement [`execute`](Self::execute) performs when it
704 /// observes a device-lost release gate, exposed for the case where the
705 /// *provider's* queue learns the context is unusable first: the mechanism
706 /// lifecycle may not have been invalidated yet, so `execute` would still be
707 /// allowed to call the allocator, which is exactly what must not happen.
708 ///
709 /// It is deliberately distinct from
710 /// [`quarantine(QuarantineReason::DeviceLost)`](Self::quarantine), which
711 /// records the generic [`AllocationReleaseState::Quarantined`]. Device loss
712 /// is its own terminal state because it is discharged by confirmed
713 /// context/process termination rather than by anything the runtime can do
714 /// to the device.
715 ///
716 /// Consuming the request is the point: the binding records the exact
717 /// allocation identity as device-lost, the queued-release count settles,
718 /// and the active-operation pin — with the mechanism, provider-context, and
719 /// binding references behind it — is released, so a queue that holds the
720 /// residual does not keep its own provider context alive.
721 pub fn quarantine_device_lost(mut self) -> AllocationReleaseOutcome {
722 self.armed = false;
723 let bytes = self.bytes as u64;
724 self.settle_quarantine(
725 ReleaseAccounting::new(bytes, 0),
726 AllocationReleaseState::DeviceLost,
727 QuarantineReason::DeviceLost,
728 bytes,
729 )
730 }
731
732 /// Retain ownership deliberately without calling the allocator.
733 ///
734 /// This is the explicit form of what `Drop` does implicitly.
735 pub fn quarantine(mut self, reason: QuarantineReason) -> AllocationReleaseOutcome {
736 self.armed = false;
737 let bytes = self.bytes as u64;
738 self.settle_quarantine(
739 ReleaseAccounting::new(bytes, 0),
740 AllocationReleaseState::Quarantined,
741 reason,
742 bytes,
743 )
744 }
745
746 fn settle_released(&mut self) {
747 self.binding.mechanism().settle_release(self.identity);
748 // The active-operation pin is released only after the mechanism has
749 // observed the final state, so a snapshot never sees a quiescent
750 // mechanism with unsettled ownership.
751 self.operation = None;
752 }
753
754 fn settle_quarantine(
755 &mut self,
756 accounting: ReleaseAccounting,
757 state: AllocationReleaseState,
758 reason: QuarantineReason,
759 retained_bytes: u64,
760 ) -> AllocationReleaseOutcome {
761 let residual = ResidualOwnership {
762 state,
763 reason,
764 retained_bytes,
765 address: self.ptr.as_ptr() as usize,
766 align: self.align,
767 };
768 self.binding
769 .mechanism()
770 .settle_quarantine(QuarantinedAllocation {
771 identity: self.identity,
772 address: residual.address,
773 bytes: self.bytes,
774 align: self.align,
775 state,
776 reason,
777 retained_bytes,
778 });
779 self.operation = None;
780 AllocationReleaseOutcome::Quarantined {
781 accounting,
782 residual,
783 }
784 }
785}
786
787impl Drop for PreparedAllocationRelease {
788 /// Quarantine an abandoned request.
789 ///
790 /// This never calls the allocator, never enqueues, and never waits. It takes
791 /// only the per-mechanism lock, which is a leaf in the documented lock
792 /// order, and records residual ownership so the bytes stay accounted for.
793 fn drop(&mut self) {
794 if !self.armed {
795 return;
796 }
797 self.armed = false;
798 let bytes = self.bytes as u64;
799 let _ = self.settle_quarantine(
800 ReleaseAccounting::new(bytes, 0),
801 AllocationReleaseState::Quarantined,
802 QuarantineReason::AbandonedRequest,
803 bytes,
804 );
805 }
806}
807
808/// One piece of ownership the runtime kept instead of releasing.
809///
810/// Quarantined ownership stays observable through
811/// [`crate::MechanismSnapshot`] and
812/// [`crate::BindingRegistry::quarantined`], and blocks mechanism removal. It is
813/// cleared only by confirmed provider-context termination, which is the point
814/// where the device state it refers to provably no longer exists.
815#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
816pub struct QuarantinedAllocation {
817 pub identity: AllocationIdentity,
818 /// The address that is still owned. Never dereferenced by this crate.
819 pub address: usize,
820 pub bytes: usize,
821 pub align: usize,
822 pub state: AllocationReleaseState,
823 pub reason: QuarantineReason,
824 pub retained_bytes: u64,
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn zero_unmapped_bytes_is_a_valid_complete_outcome() {
833 let outcome = AllocationReleaseOutcome::complete(ReleaseAccounting::eager(4096));
834 assert!(outcome.is_complete());
835 assert_eq!(outcome.unmapped_bytes(), 0);
836 assert_eq!(outcome.state(), AllocationReleaseState::Released);
837 assert!(outcome.residual().is_none());
838 }
839
840 #[test]
841 fn failure_is_the_only_unchanged_shape() {
842 let failed = AllocationReleaseOutcome::failed("driver busy");
843 assert_eq!(failed.state(), AllocationReleaseState::Live);
844 assert!(failed.accounting().is_none());
845 assert_eq!(
846 failed.failure().map(ReleaseFailure::reason),
847 Some("driver busy")
848 );
849 }
850
851 #[test]
852 fn states_answer_ownership_and_callback_questions() {
853 assert!(AllocationReleaseState::Live.retains_ownership());
854 assert!(!AllocationReleaseState::Released.retains_ownership());
855 assert!(!AllocationReleaseState::DeviceLost.permits_allocator_call());
856 assert!(!AllocationReleaseState::Quarantined.permits_allocator_call());
857 assert!(AllocationReleaseState::Queued.permits_allocator_call());
858 assert!(AllocationReleaseState::PartiallyUnmapped.is_terminal());
859 }
860}