Skip to main content

xlog_cuda/device_runtime/
async_resource.rs

1//! Stream-bound allocation through the shared raw storage owner.
2//!
3//! Allocation uses one device-owned lifecycle stream and completes before block
4//! publication. Failed initialization retains actual storage and its byte charge.
5//! Deallocation moves real owners into the existing pending queue. Cold reaping
6//! proves physical free before releasing accounting; pool IDs alone are not
7//! completion evidence. Device/sanitizer qualification remains a separate gate.
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::{Arc, Mutex};
12
13use crate::memory::RawDeviceAllocation;
14
15#[cfg(test)]
16use super::resource::AllocTag;
17use super::resource::{
18    Access, AllocationAccounting, AllocationRequest, BlockId, BlockState, DeviceAccessDependencies,
19    DeviceBlock, DeviceMemoryResource, Generation, ResourceBudgetSnapshot, ResourceError,
20    ResourceResult, StreamId,
21};
22use super::stream_pool::StreamPool;
23use crate::CudaDevice;
24
25/// A live raw allocation, its generation, and the authoritative dependency
26/// owner shared by storage aliases. Read and write frontiers retain every
27/// recorded execution stream until its ordering is proved. Deallocation moves
28/// this owner to the pending queue; physical release waits those frontiers.
29/// Generation checks reject stale identities before mutating the live entry.
30struct LiveEntry {
31    slice: Arc<RawDeviceAllocation>,
32    generation: Generation,
33    alloc_stream: StreamId,
34    /// The same event history used by storage aliases. Allocation is complete
35    /// before publication; subsequent accesses record the authoritative read
36    /// and write frontiers on their exact CUDA streams.
37    dependencies: Arc<DeviceAccessDependencies>,
38}
39
40/// Stream-bound allocator with explicitly owned deferred reclamation.
41pub struct AsyncCudaResource {
42    device: Arc<CudaDevice>,
43    device_ordinal: u32,
44    stream_pool: Arc<StreamPool>,
45    /// Live raw owners keyed by device pointer. Deallocation transfers the
46    /// complete owner and dependency history into `pending_per_stream`.
47    live: Mutex<HashMap<u64, LiveEntry>>,
48    /// Bytes for blocks currently in `live`. Always accurate.
49    live_bytes: AtomicUsize,
50    /// Includes allocations retained after failed initialization, before a
51    /// public block exists. The raw owner releases this counter only after free.
52    outstanding_bytes: Arc<AllocationAccounting>,
53    /// Bytes transferred to pending reclamation but not yet proven freed.
54    /// Charged to each exact allocation ticket, independently of queue or
55    /// backend lifetime. Retained stream handles alone do not count as bytes.
56    pending_bytes: Arc<AtomicUsize>,
57    /// Actual owners awaiting physical reclamation, grouped by stream. A reap
58    /// removes only its own batch; concurrently queued owners remain here until
59    /// the next reap. Unfinished owners are restored; their tickets settle
60    /// physical bytes independently of any later handle-cleanup failure.
61    pending_per_stream: Mutex<HashMap<StreamId, Vec<Option<Arc<RawDeviceAllocation>>>>>,
62}
63
64impl Drop for AsyncCudaResource {
65    fn drop(&mut self) {
66        // Backend destruction must not bypass the access history merely because
67        // no DeviceStorage wrapper remains (for example, a bare recorded block).
68        // Move real native allocations and their context/stream owners out before
69        // scheduling cold cleanup; no live-map or admission mutex is held there.
70        let live = std::mem::take(
71            self.live
72                .get_mut()
73                .unwrap_or_else(|error| error.into_inner()),
74        );
75        let pending = std::mem::take(
76            self.pending_per_stream
77                .get_mut()
78                .unwrap_or_else(|error| error.into_inner()),
79        );
80        // The last raw owner performs its own dependency wait and retryable
81        // physical release. A second batch-level wait here would strand every
82        // allocation if it failed before handing them to that canonical owner.
83        // Outstanding leases keep their exact raw allocation alive independently.
84        drop(live);
85        drop(pending);
86    }
87}
88
89impl AsyncCudaResource {
90    /// Construct a resource bound to `device` using `stream_pool` for
91    /// stream resolution. `device_ordinal` is the CUDA ordinal for
92    /// logging / multi-device disambiguation.
93    pub fn new(device: Arc<CudaDevice>, device_ordinal: u32, stream_pool: Arc<StreamPool>) -> Self {
94        Self {
95            device,
96            device_ordinal,
97            stream_pool,
98            live: Mutex::new(HashMap::new()),
99            live_bytes: AtomicUsize::new(0),
100            outstanding_bytes: Arc::default(),
101            pending_bytes: Arc::new(AtomicUsize::new(0)),
102            pending_per_stream: Mutex::new(HashMap::new()),
103        }
104    }
105
106    pub fn device(&self) -> &Arc<CudaDevice> {
107        &self.device
108    }
109
110    pub fn stream_pool(&self) -> &Arc<StreamPool> {
111        &self.stream_pool
112    }
113
114    /// Bytes currently held by live blocks (excludes pending frees).
115    /// Test/diagnostic accessor — production code should use
116    /// `bytes_outstanding`.
117    pub fn live_bytes(&self) -> usize {
118        self.live_bytes.load(Ordering::Relaxed)
119    }
120
121    /// Bytes queued for `cuMemFreeAsync` whose stream has not yet
122    /// been synchronized by us. Test/diagnostic accessor.
123    pub fn pending_free_bytes(&self) -> usize {
124        self.pending_bytes.load(Ordering::Relaxed)
125    }
126
127    /// Sum of per-stream pending byte tallies. Test/diagnostic
128    /// accessor used to assert the invariant
129    /// `pending_free_bytes() == pending_per_stream_total()`. The
130    /// invariant holds after successful queue handoff at quiescent moments.
131    /// After a failed handoff, the canonical cold queue can own charged bytes
132    /// outside this map. Physically freed owners retained for handle cleanup
133    /// are excluded from both physical-byte counts.
134    pub fn pending_per_stream_total(&self) -> usize {
135        let map = self
136            .pending_per_stream
137            .lock()
138            .expect("AsyncCudaResource pending_per_stream poisoned");
139        map.values()
140            .flatten()
141            .flatten()
142            .filter(|owner| !owner.reclamation().was_released())
143            .map(|owner| owner.len())
144            .sum()
145    }
146
147    /// Number of recorded outstanding-read events plus a
148    /// last_write event (0 or 1) currently attached to the live
149    /// block at `ptr`. Test/diagnostic accessor — used by
150    /// reproducers to confirm `finish_block_use` actually
151    /// attached events before deallocate consumed them. Returns
152    /// `None` if `ptr` is not currently in the live map.
153    pub fn pending_use_event_count(&self, ptr: u64) -> Option<usize> {
154        let live = self
155            .live
156            .lock()
157            .expect("AsyncCudaResource live map poisoned");
158        live.get(&ptr)
159            .map(|entry| entry.dependencies.pending_event_count())
160    }
161}
162
163impl DeviceMemoryResource for AsyncCudaResource {
164    fn materialize(&self, request: AllocationRequest) -> ResourceResult<DeviceBlock> {
165        let AllocationRequest {
166            bytes,
167            stream,
168            tag,
169            reclamation,
170            ..
171        } = request;
172        if bytes == 0 {
173            return Err(ResourceError::Driver(
174                "AsyncCudaResource: zero-byte allocation not supported".to_string(),
175            ));
176        }
177        let cu_stream = self.stream_pool.resolve(stream).ok_or_else(|| {
178            ResourceError::StreamMisuse(format!(
179                "AsyncCudaResource: unknown StreamId({})",
180                stream.0
181            ))
182        })?;
183        reclamation.attach_resource(Arc::clone(&self.outstanding_bytes), bytes)?;
184        let allocation = crate::memory::RawDeviceAllocation::allocate(
185            Arc::clone(&cu_stream),
186            Arc::clone(self.device.inner().allocation_stream()),
187            bytes,
188            None,
189            Arc::clone(&reclamation),
190        )?;
191        let ptr = allocation.ptr();
192        let dependencies = allocation.dependencies();
193        let mut live = self
194            .live
195            .lock()
196            .expect("AsyncCudaResource live map poisoned");
197        if live.contains_key(&ptr) {
198            return Err(
199                ResourceError::Driver(format!("allocation pointer collision: {ptr:#x}"))
200                    .retaining(bytes, reclamation),
201            );
202        }
203        let generation = Generation::next();
204        live.insert(
205            ptr,
206            LiveEntry {
207                slice: allocation,
208                generation,
209                alloc_stream: stream,
210                dependencies,
211            },
212        );
213        self.live_bytes.fetch_add(bytes, Ordering::Relaxed);
214        Ok(DeviceBlock {
215            ptr,
216            device_ordinal: self.device_ordinal,
217            alloc_stream: stream,
218            bytes,
219            align: std::mem::align_of::<u8>(),
220            tag,
221            generation,
222            state: BlockState::Live,
223        })
224    }
225
226    fn deallocate(&self, block: DeviceBlock) -> ResourceResult<()> {
227        if block.device_ordinal != self.device_ordinal {
228            return Err(ResourceError::Driver(format!(
229                "AsyncCudaResource: deallocate on wrong device (block ord {} vs resource ord {})",
230                block.device_ordinal, self.device_ordinal
231            )));
232        }
233        // Resolve the alloc stream FIRST. If resolution fails the
234        // live entry stays in place and accounting is unchanged —
235        // the caller can retry. Removing the entry first then
236        // erroring would queue `cuMemFreeAsync` on a stream the
237        // caller did not expect (via the slice drop on the error
238        // return path) AND leave accounting drift behind.
239        let _alloc_stream = self
240            .stream_pool
241            .resolve(block.alloc_stream)
242            .ok_or_else(|| {
243                ResourceError::StreamMisuse(format!(
244                    "AsyncCudaResource::deallocate: alloc_stream StreamId({}) does not resolve",
245                    block.alloc_stream.0
246                ))
247            })?;
248
249        // Logical detach validates the complete identity but submits no free
250        // or dependency waits. Existing storage leases remain usable. The
251        // unique physical owner later reserves and waits its exact range.
252        let (slice, dependencies) = {
253            let mut live = self
254                .live
255                .lock()
256                .expect("AsyncCudaResource live map poisoned");
257            match live.get(&block.ptr) {
258                Some(entry) => {
259                    BlockId::from_block(&block).validate_allocation(
260                        block.bytes,
261                        BlockId {
262                            ptr: block.ptr,
263                            generation: entry.generation,
264                            alloc_stream: entry.alloc_stream,
265                            device_ordinal: self.device_ordinal,
266                        },
267                        entry.slice.len(),
268                    )?;
269                    entry
270                        .slice
271                        .reclamation()
272                        .attach_pending(Arc::clone(&self.pending_bytes), block.bytes)?;
273                    let LiveEntry {
274                        slice,
275                        dependencies,
276                        ..
277                    } = live
278                        .remove(&block.ptr)
279                        .expect("present under lock per get above");
280                    self.live_bytes.fetch_sub(block.bytes, Ordering::Relaxed);
281                    (slice, dependencies)
282                }
283                None => {
284                    return Err(ResourceError::UseAfterFree {
285                        generation: block.generation,
286                    });
287                }
288            }
289        };
290
291        // Keep the real owner in the existing pending-free queue until cold
292        // reclamation proves free. A byte tally alone cannot retain CUDA events.
293        let mut pending = self
294            .pending_per_stream
295            .lock()
296            .expect("pending allocation queue poisoned");
297        pending
298            .entry(block.alloc_stream)
299            .or_default()
300            .push(Some(slice));
301        drop(pending);
302        drop(dependencies);
303        Ok(())
304    }
305
306    fn device_ordinal(&self) -> u32 {
307        self.device_ordinal
308    }
309
310    fn bytes_outstanding(&self) -> usize {
311        self.outstanding_bytes.snapshot().0
312    }
313
314    fn allocation_accounting(&self) -> Arc<AllocationAccounting> {
315        Arc::clone(&self.outstanding_bytes)
316    }
317
318    fn budget_snapshot(&self) -> Option<ResourceBudgetSnapshot> {
319        None
320    }
321
322    fn reap_pending(&self) -> ResourceResult<()> {
323        // Each pending owner authenticates and waits its own context/event
324        // history. Pool-local default-stream IDs are not completion evidence.
325        self.reap_pending_with(|_| Ok(()))
326    }
327
328    fn supports_block_use_tracking(&self) -> bool {
329        true
330    }
331
332    fn record_block_use(&self, block: &DeviceBlock, use_stream: StreamId) -> ResourceResult<()> {
333        // Backward-compatibility shim. Pre-migration callers used
334        // `record_block_use` for "this stream did SOMETHING with
335        // this block; please wait on me before freeing." That
336        // semantics maps to `finish_block_use(.., Access::Read)`:
337        // the event is recorded on `use_stream` and appended to
338        // outstanding_reads so deallocate waits on it. New
339        // callers MUST call `prepare_block_use` BEFORE the launch
340        // and `finish_block_use` after; this shim does NOT queue
341        // the pre-launch wait so it is unsafe for use-after-write
342        // / use-after-prior-read scenarios.
343        self.finish_block_use(BlockId::from_block(block), use_stream, Access::Read)
344    }
345
346    fn access_dependencies(
347        &self,
348        block: BlockId,
349        bytes: usize,
350    ) -> ResourceResult<Option<Arc<DeviceAccessDependencies>>> {
351        let live = self
352            .live
353            .lock()
354            .expect("AsyncCudaResource live map poisoned");
355        let entry = live.get(&block.ptr).ok_or(ResourceError::UseAfterFree {
356            generation: block.generation,
357        })?;
358        block.validate_allocation(
359            bytes,
360            BlockId {
361                ptr: block.ptr,
362                generation: entry.generation,
363                alloc_stream: entry.alloc_stream,
364                device_ordinal: self.device_ordinal,
365            },
366            entry.slice.len(),
367        )?;
368        Ok(Some(Arc::clone(&entry.dependencies)))
369    }
370
371    fn prepare_block_use(
372        &self,
373        block: BlockId,
374        use_stream: StreamId,
375        access: Access,
376    ) -> ResourceResult<()> {
377        if block.device_ordinal != self.device_ordinal {
378            return Err(ResourceError::Driver(format!(
379                "AsyncCudaResource::prepare_block_use: block device {} != resource device {}",
380                block.device_ordinal, self.device_ordinal
381            )));
382        }
383        let use_cu_stream = self.stream_pool.resolve(use_stream).ok_or_else(|| {
384            ResourceError::StreamMisuse(format!(
385                "AsyncCudaResource::prepare_block_use: unknown StreamId({})",
386                use_stream.0
387            ))
388        })?;
389
390        // Validate (ptr, generation) and queue cross-stream
391        // waits while holding the live-map lock. The waits are
392        // cuStreamWaitEvent calls which record a dependency in
393        // the use stream and return — they don't block, so the
394        // lock is held only briefly. Same-stream events are
395        // skipped (already ordered).
396        let live = self
397            .live
398            .lock()
399            .expect("AsyncCudaResource live map poisoned");
400        let entry = match live.get(&block.ptr) {
401            Some(entry) if entry.generation == block.generation => entry,
402            Some(_) | None => {
403                return Err(ResourceError::UseAfterFree {
404                    generation: block.generation,
405                });
406            }
407        };
408        entry.dependencies.prepare(&use_cu_stream, access)
409    }
410
411    fn finish_block_use(
412        &self,
413        block: BlockId,
414        use_stream: StreamId,
415        access: Access,
416    ) -> ResourceResult<()> {
417        if block.device_ordinal != self.device_ordinal {
418            return Err(ResourceError::Driver(format!(
419                "AsyncCudaResource::finish_block_use: block device {} != resource device {}",
420                block.device_ordinal, self.device_ordinal
421            )));
422        }
423        let use_cu_stream = self.stream_pool.resolve(use_stream).ok_or_else(|| {
424            ResourceError::StreamMisuse(format!(
425                "AsyncCudaResource::finish_block_use: unknown StreamId({})",
426                use_stream.0
427            ))
428        })?;
429        // Keep validation and publication atomic with backend deallocation,
430        // including direct resource callers without a runtime reservation.
431        let live = self
432            .live
433            .lock()
434            .expect("AsyncCudaResource live map poisoned");
435        let entry = match live.get(&block.ptr) {
436            Some(entry) if entry.generation == block.generation => entry,
437            _ => {
438                return Err(ResourceError::UseAfterFree {
439                    generation: block.generation,
440                });
441            }
442        };
443        // Frontier retirement replaces only the same execution stream, whose
444        // new event retains that stream/context; it cannot drop their last owner.
445        entry.dependencies.record_completion(use_cu_stream, access)
446    }
447}
448
449impl AsyncCudaResource {
450    /// Reap drained actual owners, skipping shared leases. Each unique raw
451    /// owner proves its own dependency and physical-free completion. The
452    /// per-stream hook is a no-op in production and lets tests inject failure
453    /// before a stream's owners are visited.
454    ///
455    /// Errors and unwind restore every unfinished owner to the same pending
456    /// queue. Only proven physical releases decrement `pending_bytes`.
457    pub(crate) fn reap_pending_with<F>(&self, mut sync_stream: F) -> ResourceResult<()>
458    where
459        F: FnMut(StreamId) -> ResourceResult<()>,
460    {
461        // Drain the per-stream map atomically. Anything added by a
462        // racing `deallocate` after this point lands in a fresh
463        // entry and waits for the next reap.
464        //
465        // Each allocation ticket subtracts only its own pending charge after
466        // physical release. A concurrent detach or a retained handle owner
467        // cannot erase another allocation's bytes.
468        let mut pending = crate::memory::PendingReclamationBatch::take(
469            &self.pending_per_stream,
470            |queue, pending| {
471                for (id, owners) in pending.iter_mut() {
472                    owners.retain(Option::is_some);
473                    if !owners.is_empty() {
474                        queue.entry(*id).or_default().append(owners);
475                    }
476                }
477                pending.clear();
478            },
479        );
480        let mut failure = None;
481        for (stream_id, allocations) in pending.iter_mut() {
482            if let Err(error) = sync_stream(*stream_id) {
483                failure = Some(error);
484                break;
485            }
486            if let Err(error) = crate::memory::reap_raw_allocations(allocations) {
487                failure = Some(error);
488                break;
489            }
490        }
491        // The batch guard restores unfinished owners on return and unwind.
492        match failure {
493            Some(error) => Err(error),
494            None => Ok(()),
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn try_setup() -> Option<(Arc<CudaDevice>, Arc<StreamPool>)> {
504        let device = Arc::new(CudaDevice::new(0).ok()?);
505        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
506        Some((device, pool))
507    }
508
509    #[test]
510    fn allocate_then_deallocate_round_trips_on_default_stream() {
511        let Some((device, pool)) = try_setup() else {
512            return;
513        };
514        let r = AsyncCudaResource::new(device, 0, pool);
515        let block = r
516            .allocate(2048, StreamId::DEFAULT, AllocTag::UNTAGGED)
517            .expect("alloc");
518        assert_eq!(block.bytes, 2048);
519        assert_eq!(block.alloc_stream, StreamId::DEFAULT);
520        assert_eq!(r.bytes_outstanding(), 2048);
521        assert_eq!(r.live_bytes(), 2048);
522        assert_eq!(r.pending_free_bytes(), 0);
523
524        r.deallocate(block).expect("dealloc");
525        // Pending after dealloc — cuMemFreeAsync is queued, not drained.
526        assert_eq!(r.live_bytes(), 0);
527        assert_eq!(r.pending_free_bytes(), 2048);
528        assert_eq!(r.bytes_outstanding(), 2048);
529
530        r.reap_pending().expect("reap pending");
531        assert_eq!(r.bytes_outstanding(), 0);
532        assert_eq!(r.pending_free_bytes(), 0);
533    }
534
535    #[test]
536    fn allocate_on_acquired_non_default_stream() {
537        let Some((device, pool)) = try_setup() else {
538            return;
539        };
540        let r = AsyncCudaResource::new(device, 0, Arc::clone(&pool));
541        let stream = pool.acquire().expect("acquire non-default stream");
542        let block = r
543            .allocate(1024, stream, AllocTag("async-test"))
544            .expect("alloc on non-default stream");
545        assert_eq!(block.alloc_stream, stream);
546        r.deallocate(block).expect("dealloc");
547        // Still counted as outstanding until reap.
548        assert_eq!(r.bytes_outstanding(), 1024);
549        r.reap_pending().expect("reap pending");
550        assert_eq!(r.bytes_outstanding(), 0);
551    }
552
553    #[test]
554    fn allocate_unknown_stream_id_rejected() {
555        let Some((device, pool)) = try_setup() else {
556            return;
557        };
558        let r = AsyncCudaResource::new(device, 0, pool);
559        let err = r.allocate(64, StreamId(99), AllocTag::UNTAGGED);
560        assert!(matches!(err, Err(ResourceError::StreamMisuse(_))));
561    }
562
563    #[test]
564    fn deallocate_unknown_block_returns_use_after_free() {
565        let Some((device, pool)) = try_setup() else {
566            return;
567        };
568        let r = AsyncCudaResource::new(device, 0, pool);
569        let bogus = DeviceBlock {
570            ptr: 0xfeed_face,
571            device_ordinal: 0,
572            alloc_stream: StreamId::DEFAULT,
573            bytes: 16,
574            align: 1,
575            tag: AllocTag::UNTAGGED,
576            generation: Generation::next(),
577            state: BlockState::Live,
578        };
579        assert!(matches!(
580            r.deallocate(bogus),
581            Err(ResourceError::UseAfterFree { .. })
582        ));
583    }
584
585    #[test]
586    fn reap_with_no_pending_is_noop() {
587        let Some((device, pool)) = try_setup() else {
588            return;
589        };
590        let r = AsyncCudaResource::new(device, 0, pool);
591        r.reap_pending().expect("reap on empty");
592        assert_eq!(r.bytes_outstanding(), 0);
593    }
594
595    /// Test-only helper: install pending state directly so we can
596    /// exercise `reap_pending_with` without going through real
597    /// CUDA streams. Bypasses the normal `allocate`/`deallocate`
598    /// path; intended exclusively for the failure-recovery test.
599    fn install_pending(r: &AsyncCudaResource, entries: &[(StreamId, usize)]) {
600        for (id, bytes) in entries {
601            let block = r
602                .allocate(*bytes, *id, AllocTag::UNTAGGED)
603                .expect("allocate pending owner");
604            r.deallocate(block).expect("retire pending owner");
605        }
606    }
607
608    #[test]
609    fn reap_pending_recovers_unsynced_streams_when_sync_fails() {
610        // No CUDA needed for the recovery semantics — we use the
611        // real AsyncCudaResource (constructor needs a device only)
612        // and inject sync failures via `reap_pending_with`.
613        let Some((device, pool)) = try_setup() else {
614            return;
615        };
616        let r = AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool));
617
618        let first = pool.acquire().expect("first allocation stream");
619        let second = pool.acquire().expect("second allocation stream");
620        assert_eq!((first, second), (StreamId(1), StreamId(2)));
621
622        // Install two pending entries: the test will fail sync for
623        // StreamId(2). Bytes total 3072.
624        install_pending(&r, &[(StreamId(1), 1024), (StreamId(2), 2048)]);
625        assert_eq!(r.pending_free_bytes(), 3072);
626        assert_eq!(r.pending_per_stream_total(), 3072);
627
628        // Track which streams the closure successfully synchronized.
629        // HashMap iteration order is unspecified, so an
630        // order-independent assertion uses this set: the test must
631        // hold for any iteration order.
632        let synced = std::sync::Mutex::new(Vec::<StreamId>::new());
633        let result = r.reap_pending_with(|stream_id| {
634            if stream_id == StreamId(2) {
635                Err(ResourceError::Driver(
636                    "simulated sync failure on StreamId(2)".into(),
637                ))
638            } else {
639                synced.lock().unwrap().push(stream_id);
640                Ok(())
641            }
642        });
643
644        assert!(matches!(result, Err(ResourceError::Driver(_))));
645
646        let synced = synced.into_inner().unwrap();
647        // Iteration order [1,2]: 1 syncs ok, 2 fails → synced=[1],
648        //   synced_total=1024, pending_bytes=2048, map=[(2,2048)].
649        // Iteration order [2,1]: 2 fails first, break aborts → synced=[],
650        //   synced_total=0, pending_bytes=3072, map=[(1,1024),(2,2048)].
651        // Both must satisfy: pending == 3072 - synced_bytes.
652        let synced_bytes: usize = if synced.contains(&StreamId(1)) {
653            1024
654        } else {
655            0
656        };
657        let expected_pending = 3072 - synced_bytes;
658        assert_eq!(
659            r.pending_free_bytes(),
660            expected_pending,
661            "synced={:?}; pending_bytes must reflect only un-synced bytes",
662            synced
663        );
664        assert_eq!(
665            r.pending_per_stream_total(),
666            expected_pending,
667            "synced={:?}; pending_per_stream_total must equal pending_free_bytes \
668             (cross-counter invariant)",
669            synced
670        );
671
672        // A second reap with a closure that succeeds for everything
673        // must drain the rest cleanly — proves the restored entries
674        // are retried, not lost.
675        r.reap_pending_with(|_| Ok(())).expect("retry reap");
676        assert_eq!(r.pending_free_bytes(), 0);
677        assert_eq!(r.pending_per_stream_total(), 0);
678    }
679
680    #[test]
681    fn reap_pending_drains_normally_when_sync_always_succeeds() {
682        // Sanity: closure-based variant of the success path. Proves
683        // the new factoring hasn't regressed the happy case.
684        let Some((device, pool)) = try_setup() else {
685            return;
686        };
687        let r = AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool));
688
689        let first = pool.acquire().expect("first allocation stream");
690        let second = pool.acquire().expect("second allocation stream");
691        assert_eq!((first, second), (StreamId(1), StreamId(2)));
692
693        install_pending(&r, &[(StreamId(1), 256), (StreamId(2), 512)]);
694        r.reap_pending_with(|_| Ok(())).expect("reap");
695        assert_eq!(r.pending_free_bytes(), 0);
696        assert_eq!(r.pending_per_stream_total(), 0);
697    }
698}