Skip to main content

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}