Skip to main content

xlog_cuda/
launch.rs

1//! Launch / use recorder for runtime-backed buffers.
2//!
3//! Closes the production-side of the cross-stream lifetime gap
4//! identified by A4 *and* the use-after-prior-write hazard
5//! discovered by the multi-threaded sort+hash-join regression.
6//! Code that enqueues kernels or copies on a `launch_stream`
7//! other than the buffer's `alloc_stream` MUST tell the runtime
8//! about the use BEFORE the launch (so prior cross-stream waits
9//! can be queued ahead of the work) AND AFTER the launch (so a
10//! use-event is recorded for future readers / writers and for
11//! the eventual deallocate).
12//!
13//! Without preflight, the CUDA mempool is free to reuse the
14//! address while the cross-stream work is still in flight, AND
15//! prior writes / reads on a different stream remain
16//! invisible to the new work — kernels read torn state.
17//!
18//! # Modes
19//!
20//! Two construction modes:
21//!
22//!   * [`LaunchRecorder::new_permissive`] — silently skips
23//!     buffers that have no runtime-side identity (legacy
24//!     cudarc-backed `TrackedCudaSlice`, external `Dlpack` /
25//!     `ArrowDevice` columns). Intended for low-level helpers
26//!     during the migration window where mixed legacy/runtime
27//!     calls are unavoidable. **Not safe for production
28//!     migrated paths** — silent skips are silent gaps.
29//!
30//!   * [`LaunchRecorder::new_strict`] — rejects any buffer that
31//!     cannot be tracked. Intended for production migrated
32//!     launch paths: any buffer the recorder cannot attach an
33//!     event to is a structural problem the caller must fix
34//!     (route the allocation through the runtime, or refuse
35//!     external memory in this code path).
36//!
37//! # Preflight + commit
38//!
39//! Production callers split the recorder into TWO phases around
40//! the actual CUDA call:
41//!
42//!   1. Build the recorder, register every buffer the launch
43//!      will touch via `read` / `write` / `read_write` /
44//!      `read_column` / `write_column` *before* enqueueing any
45//!      CUDA work. Fresh output buffers go through the same
46//!      `write` / `write_column` API — there is no separate
47//!      post-launch path. The recorder snapshots the block id
48//!      at record time and immediately drops the slice borrow,
49//!      so callers can take `&mut` afterwards.
50//!   2. Call [`LaunchRecorder::preflight`] BEFORE enqueueing
51//!      any CUDA work. Preflight verifies the active resource
52//!      supports cross-stream tracking and (in strict mode)
53//!      that every recorded buffer has a runtime block, then
54//!      queues the cross-stream waits required by each
55//!      recorded access kind via
56//!      [`crate::device_runtime::XlogDeviceRuntime::prepare_block_use`].
57//!      On failure no CUDA work has been queued yet.
58//!   3. Enqueue the CUDA call on `launch_stream`.
59//!   4. Call [`LaunchRecorder::commit`] AFTER the launch is
60//!      enqueued. Commit calls `finish_block_use` on each
61//!      tracked block — the runtime records its event on
62//!      `launch_stream` at this point, and that event becomes
63//!      part of the block's dependency state for future
64//!      readers / writers and the eventual deallocate.
65//!
66//! # Why preflight queues waits, not just validates
67//!
68//! Earlier revisions only validated the resource stack at
69//! preflight and queued waits implicitly via deallocate.
70//! That protected free-after-use but NOT use-after-prior-write
71//! across streams: if sort writes column A on stream X and
72//! join reads column A on stream Y, the join's read kernel
73//! could observe sort's pre-write contents because no event
74//! fenced X→Y. This recorder closes that gap by queuing
75//! `cuStreamWaitEvent` calls in preflight, before the join
76//! kernel is enqueued on Y, against sort's recorded write
77//! event on X.
78//!
79//! # External memory (DLPack, Arrow device)
80//!
81//! Strict mode rejects [`crate::memory::CudaColumn::Dlpack`]
82//! and [`crate::memory::CudaColumn::ArrowDevice`] columns
83//! outright. External memory has no xlog-side runtime identity
84//! — the prepare/finish APIs cannot attach events to a buffer
85//! the runtime did not allocate. Callers that need to consume
86//! external columns must either:
87//!   * use a permissive recorder (and accept that no
88//!     cross-stream safety applies to those buffers), or
89//!   * synchronize externally (e.g., wait on the producing
90//!     framework's stream / event before queueing xlog work).
91//!
92//! Permissive mode skips external columns silently, matching
93//! the legacy-buffer policy.
94
95use std::collections::HashMap;
96use std::sync::Arc;
97
98use crate::device_runtime::{
99    Access, BlockId, DeviceBlock, Generation, ResourceError, ResourceResult, StreamId,
100    XlogDeviceRuntime,
101};
102
103/// Recorder construction mode.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub enum RecorderMode {
106    /// Silently skip untracked buffers. Acceptable for low-level
107    /// helpers during the migration window; **not** safe for
108    /// production migrated paths.
109    Permissive,
110    /// Reject untracked buffers. Production migrated paths use
111    /// this so silent skips become loud failures.
112    Strict,
113}
114
115/// Records buffer uses for a single launch / copy on
116/// `launch_stream`. Drop without `commit` is a programmer error;
117/// the recorder logs (debug builds only) and never panics.
118///
119/// # Lifetime model
120///
121/// The recorder snapshots each registered block's identity
122/// ([`BlockId`]) at record time and immediately drops the source
123/// slice borrow. The recorder type itself carries no lifetime
124/// parameter, so callers can interleave `rec.read(&buf)` calls
125/// with later `&mut buf` kernel-param borrows freely. The
126/// runtime's generation guard catches misuse where the snapshot
127/// outlives the underlying allocation.
128///
129/// # Required call order for non-empty recorders
130///
131/// `preflight(&runtime)` MUST be called and return `Ok(())`
132/// BEFORE any CUDA work is enqueued, AND BEFORE `commit`.
133/// Preflight queues the cross-stream waits each recorded access
134/// kind requires (read waits on prior writes; write waits on
135/// prior writes AND prior reads), so the launch sees a
136/// well-fenced view of every input. Commit then records the
137/// new event on `launch_stream` so future ops can wait on it.
138///
139/// Empty recorders (no `read`/`write`/... calls) are a no-op
140/// and bypass the preflight requirement: there are no waits
141/// to queue, no events to record.
142pub struct LaunchRecorder {
143    launch_stream: StreamId,
144    mode: RecorderMode,
145    /// Recorded uses, snapshotted from source blocks at record
146    /// time. The recorder holds no slice borrows after the
147    /// record call returns — `&mut` kernel params are free.
148    uses: Vec<RecordedUse>,
149    /// First strict-mode rejection encountered while recording.
150    /// Surfaced from `preflight`; the recorder's record methods
151    /// return `&mut Self` so callers can chain naturally.
152    strict_reject: Option<ResourceError>,
153    /// `true` after a successful `preflight(&runtime)` returns
154    /// `Ok(())`. `commit` rejects non-empty recorders that
155    /// were not preflighted.
156    preflighted: bool,
157    committed: bool,
158    bound_runtime: Option<Arc<XlogDeviceRuntime>>,
159    bound_domain: Option<Arc<()>>,
160}
161
162#[derive(Clone, Copy)]
163struct RecordedUse {
164    block: BlockId,
165    access: Access,
166    /// Site label (e.g., `"read"`, `"write"`, `"read_column"`)
167    /// for diagnostics. Not used at runtime beyond error
168    /// messages.
169    #[allow(dead_code)]
170    label: &'static str,
171}
172
173impl LaunchRecorder {
174    /// Permissive recorder: silently skips untracked buffers.
175    pub fn new_permissive(launch_stream: StreamId) -> Self {
176        Self::new(launch_stream, RecorderMode::Permissive)
177    }
178
179    /// Strict recorder: rejects any untracked buffer.
180    /// Production migrated launch paths use this.
181    pub fn new_strict(launch_stream: StreamId) -> Self {
182        Self::new(launch_stream, RecorderMode::Strict)
183    }
184
185    pub(crate) fn new_strict_bound(
186        launch_stream: StreamId,
187        runtime: Arc<XlogDeviceRuntime>,
188        domain: Arc<()>,
189    ) -> Self {
190        let mut recorder = Self::new(launch_stream, RecorderMode::Strict);
191        recorder.bound_runtime = Some(runtime);
192        recorder.bound_domain = Some(domain);
193        recorder
194    }
195
196    fn new(launch_stream: StreamId, mode: RecorderMode) -> Self {
197        Self {
198            launch_stream,
199            mode,
200            uses: Vec::new(),
201            strict_reject: None,
202            preflighted: false,
203            committed: false,
204            bound_runtime: None,
205            bound_domain: None,
206        }
207    }
208
209    /// Configured launch stream.
210    pub fn launch_stream(&self) -> StreamId {
211        self.launch_stream
212    }
213
214    /// Configured mode.
215    pub fn mode(&self) -> RecorderMode {
216        self.mode
217    }
218
219    pub(crate) fn require_bound_domain<'a>(
220        &'a mut self,
221        runtime: &Arc<XlogDeviceRuntime>,
222        domain: &Arc<()>,
223        launch_stream: StreamId,
224    ) -> &'a mut Self {
225        let matches = self.mode == RecorderMode::Strict
226            && self.launch_stream == launch_stream
227            && same_arc_identity(self.bound_runtime.as_ref(), runtime)
228            && same_arc_identity(self.bound_domain.as_ref(), domain);
229        if !matches && self.strict_reject.is_none() {
230            self.strict_reject = Some(ResourceError::StreamMisuse(
231                "LaunchRecorder: recorder is not bound to the resident execution domain"
232                    .to_string(),
233            ));
234        }
235        self
236    }
237
238    pub(crate) fn preflight_bound(
239        &mut self,
240        runtime: &Arc<XlogDeviceRuntime>,
241    ) -> ResourceResult<()> {
242        if !same_arc_identity(self.bound_runtime.as_ref(), runtime) || self.bound_domain.is_none() {
243            return Err(ResourceError::StreamMisuse(
244                "LaunchRecorder::preflight_bound: foreign or unbound runtime".to_string(),
245            ));
246        }
247        self.preflight(runtime.as_ref())
248    }
249
250    /// Snapshot a block reference into a recorded use. Reject
251    /// post-preflight additions so the validity check at
252    /// preflight time stays the source of truth.
253    fn note(
254        &mut self,
255        label: &'static str,
256        block: Option<&DeviceBlock>,
257        access: Access,
258        external: bool,
259    ) -> &mut Self {
260        self.note_identity(label, block.map(BlockId::from_block), access, external)
261    }
262
263    fn note_identity(
264        &mut self,
265        label: &'static str,
266        block: Option<BlockId>,
267        access: Access,
268        external: bool,
269    ) -> &mut Self {
270        if self.preflighted && self.strict_reject.is_none() {
271            self.strict_reject = Some(ResourceError::StreamMisuse(format!(
272                "LaunchRecorder::{}: recorded after preflight — once preflight \
273                 succeeds, the set of uses is frozen so commit-time discoveries \
274                 cannot leave unprotected work in flight. Record this use BEFORE \
275                 preflight (the recorder is lifetime-free; snapshots release the \
276                 source borrow immediately, so kernel-param &mut borrows still \
277                 work)",
278                label,
279            )));
280            return self;
281        }
282        if let Some(b) = block {
283            self.uses.push(RecordedUse {
284                block: b,
285                access,
286                label,
287            });
288            return self;
289        }
290        if self.mode == RecorderMode::Strict && self.strict_reject.is_none() {
291            let why = if external {
292                "external (DLPack / ArrowDevice) memory has no runtime identity; \
293                 strict launch recorders cannot attach a cross-stream use to it. \
294                 Use a permissive recorder OR coordinate the cross-stream \
295                 synchronization explicitly outside xlog"
296            } else {
297                "buffer is legacy cudarc-backed (no runtime block); strict launch \
298                 recorders require the allocation to be routed through \
299                 GpuMemoryManager::with_runtime so a DeviceBlock is available"
300            };
301            self.strict_reject = Some(ResourceError::StreamMisuse(format!(
302                "LaunchRecorder::{}: untracked buffer rejected — {}",
303                label, why
304            )));
305        }
306        self
307    }
308
309    /// Record a runtime-backed [`crate::memory::TrackedCudaSlice`]
310    /// the launch will read.
311    pub fn read<T: cudarc::driver::DeviceRepr>(
312        &mut self,
313        slice: &crate::memory::TrackedCudaSlice<T>,
314    ) -> &mut Self {
315        self.note("read", slice.runtime_block(), Access::Read, false)
316    }
317
318    /// Record a read through an already-validated runtime block.
319    ///
320    /// Crate-internal owner capsules use this when a device pointer table
321    /// retains immutable host-side block identities instead of the typed
322    /// slices that originally supplied the pointees.
323    pub(crate) fn read_device_block(&mut self, block: &DeviceBlock) -> &mut Self {
324        self.note("read_device_block", Some(block), Access::Read, false)
325    }
326
327    /// Record a read through a prevalidated immutable block-identity snapshot.
328    pub(crate) fn read_block_identity(&mut self, block: BlockId) -> &mut Self {
329        self.note_identity("read_block_identity", Some(block), Access::Read, false)
330    }
331
332    pub(crate) fn read_optional_block_identity(&mut self, block: Option<BlockId>) -> &mut Self {
333        self.note_identity("read_optional_block_identity", block, Access::Read, false)
334    }
335
336    /// Record a runtime-backed slice the launch will write.
337    /// Use this for both pre-existing buffers being overwritten
338    /// AND for fresh runtime-backed allocations whose lifetime
339    /// began in the same operator. The recorder snapshots block
340    /// identity at record time and drops the borrow, so kernel
341    /// `&mut slice` borrows after preflight are unaffected.
342    pub fn write<T: cudarc::driver::DeviceRepr>(
343        &mut self,
344        slice: &crate::memory::TrackedCudaSlice<T>,
345    ) -> &mut Self {
346        self.note("write", slice.runtime_block(), Access::Write, false)
347    }
348
349    /// Record a runtime-backed slice the launch will both read
350    /// and write.
351    pub fn read_write<T: cudarc::driver::DeviceRepr>(
352        &mut self,
353        slice: &crate::memory::TrackedCudaSlice<T>,
354    ) -> &mut Self {
355        self.note(
356            "read_write",
357            slice.runtime_block(),
358            Access::ReadWrite,
359            false,
360        )
361    }
362
363    /// Record a [`crate::memory::CudaColumn`] the launch will
364    /// read. Owned columns surface their runtime block; external
365    /// (`Dlpack` / `ArrowDevice`) columns are rejected in strict
366    /// mode and silently skipped in permissive mode.
367    pub fn read_column(&mut self, col: &crate::memory::CudaColumn) -> &mut Self {
368        self.note(
369            "read_column",
370            col.runtime_block(),
371            Access::Read,
372            col.is_external(),
373        )
374    }
375
376    /// Record a [`crate::memory::CudaColumn`] the launch will
377    /// write.
378    pub fn write_column(&mut self, col: &crate::memory::CudaColumn) -> &mut Self {
379        self.note(
380            "write_column",
381            col.runtime_block(),
382            Access::Write,
383            col.is_external(),
384        )
385    }
386
387    /// Number of recorded runtime-backed uses. Diagnostic.
388    pub fn recorded_count(&self) -> usize {
389        self.uses.len()
390    }
391
392    /// Preflight: validate the recorder is ready to commit
393    /// against `runtime` AND queue every cross-stream wait the
394    /// recorded access kinds require. **Stateful** — sets a flag
395    /// that `commit` checks. MUST be called BEFORE enqueueing
396    /// the CUDA launch / copy. On failure no CUDA work has been
397    /// queued yet, the flag remains unset, and the caller can
398    /// either fix the recorder or abandon the launch.
399    ///
400    /// Verifies (in order):
401    ///   * No strict-mode rejection accumulated during recording
402    ///     (untracked / external buffer in strict mode, or
403    ///     post-preflight `note` attempt).
404    ///   * The active resource stack supports cross-stream
405    ///     tracking (`runtime.supports_block_use_tracking()`)
406    ///     OR the recorder has zero tracked uses (no events to
407    ///     record).
408    ///
409    /// Then for each recorded use, calls
410    /// [`XlogDeviceRuntime::prepare_block_use`] which queues
411    /// `cuStreamWaitEvent` calls on `launch_stream` for any
412    /// prior write (read access) or any prior write + prior
413    /// reads (write / read-write access) on a different stream.
414    /// Same-stream events are skipped — already ordered.
415    ///
416    /// Repeated registrations of the same block in the same
417    /// recorder are deduplicated to a single prepare call (the
418    /// strongest access kind wins): `read` + `write` of the
419    /// same block becomes one `Access::ReadWrite` prepare.
420    pub fn preflight(&mut self, runtime: &XlogDeviceRuntime) -> ResourceResult<()> {
421        if let Some(bound_runtime) = &self.bound_runtime {
422            if !std::ptr::eq(bound_runtime.as_ref(), runtime) {
423                return Err(ResourceError::StreamMisuse(
424                    "LaunchRecorder::preflight: bound recorder received a foreign runtime"
425                        .to_string(),
426                ));
427            }
428        }
429        if let Some(err) = &self.strict_reject {
430            // Surface the captured strict-mode rejection
431            // verbatim. Do NOT mark preflighted.
432            return Err(ResourceError::StreamMisuse(format!("{}", err)));
433        }
434        if !self.uses.is_empty() && !runtime.supports_block_use_tracking() {
435            return Err(ResourceError::StreamMisuse(
436                "LaunchRecorder::preflight: active resource does not support \
437                 cross-stream use tracking. Build the runtime around \
438                 AsyncCudaResource (or a decorator stack over it) for \
439                 stream-lifetime-safe launches"
440                    .to_string(),
441            ));
442        }
443
444        let deduped = dedup_uses(&self.uses);
445        for use_ in &deduped {
446            runtime.prepare_block_use(use_.block, self.launch_stream, use_.access)?;
447        }
448
449        self.preflighted = true;
450        Ok(())
451    }
452
453    /// Commit the recorded uses to the runtime. MUST be called
454    /// AFTER preflight succeeded AND the CUDA launch has been
455    /// enqueued on `launch_stream`.
456    ///
457    /// **Non-empty recorders that were not preflighted are
458    /// rejected** with `StreamMisuse`. This closes the footgun
459    /// where a caller could enqueue CUDA work, then call
460    /// commit, then discover at commit-time that the active
461    /// resource is unsupported — leaving unprotected work in
462    /// flight. Production migrated launch paths must therefore
463    /// always preflight BEFORE the CUDA call.
464    ///
465    /// Empty recorders (no recorded uses) bypass the check:
466    /// nothing to record, no events to fire, no contract to
467    /// honor.
468    ///
469    /// For each recorded use, calls
470    /// [`XlogDeviceRuntime::finish_block_use`] which records an
471    /// event on `launch_stream` and folds it into the block's
472    /// dependency state (writers replace `last_write` and clear
473    /// `outstanding_reads`; readers append to
474    /// `outstanding_reads`). Repeated registrations of the same
475    /// block are deduplicated identically to preflight.
476    pub fn commit(self, runtime: &XlogDeviceRuntime) -> ResourceResult<()> {
477        if self.bound_runtime.is_some() || self.bound_domain.is_some() {
478            return Err(ResourceError::StreamMisuse(
479                "LaunchRecorder::commit: domain-bound recorder requires commit_bound".to_string(),
480            ));
481        }
482        self.commit_inner(runtime)
483    }
484
485    pub(crate) fn commit_bound(
486        self,
487        runtime: &Arc<XlogDeviceRuntime>,
488        domain: &Arc<()>,
489    ) -> ResourceResult<()> {
490        if !same_arc_identity(self.bound_runtime.as_ref(), runtime)
491            || !same_arc_identity(self.bound_domain.as_ref(), domain)
492        {
493            return Err(ResourceError::StreamMisuse(
494                "LaunchRecorder::commit_bound: foreign or unbound execution domain".to_string(),
495            ));
496        }
497        self.commit_inner(runtime.as_ref())
498    }
499
500    fn commit_inner(mut self, runtime: &XlogDeviceRuntime) -> ResourceResult<()> {
501        // Re-check any strict reject that may have accumulated
502        // — preflight may not have been called, or may not have
503        // surfaced this particular path. (Same string as
504        // preflight would produce.)
505        if let Some(err) = self.strict_reject.take() {
506            return Err(err);
507        }
508        if !self.uses.is_empty() && !self.preflighted {
509            return Err(ResourceError::StreamMisuse(
510                "LaunchRecorder::commit: non-empty recorder reached commit without \
511                 a successful preflight. The caller MUST call preflight(&runtime) \
512                 BEFORE enqueueing CUDA work; otherwise commit-time failures leave \
513                 unprotected work in flight. See the preflight + commit contract \
514                 in the LaunchRecorder doc"
515                    .to_string(),
516            ));
517        }
518
519        let deduped = dedup_uses(&self.uses);
520        for use_ in &deduped {
521            runtime.finish_block_use(use_.block, self.launch_stream, use_.access)?;
522        }
523        self.committed = true;
524        Ok(())
525    }
526}
527
528/// Collapse multiple registrations of the same block into one
529/// use with the strongest access.
530///
531/// The dedup key is the complete [`BlockId`] identity
532/// `(ptr, generation, alloc_stream, device_ordinal)` — NOT
533/// `ptr` alone. ABA reuse inside a single recorder is rare but
534/// possible (record use of buffer X, drop X, allocate a new
535/// block reusing X's address, record THAT) and a ptr-only key
536/// would incorrectly merge those two distinct uses. Keying by
537/// the full identity tuple lets the prepare/finish path see
538/// each generation's events independently; the runtime's
539/// generation guard then catches any stale id at the resource
540/// boundary.
541///
542/// Access combine: Read+Write → ReadWrite; otherwise the
543/// strongest of the two operands wins.
544fn dedup_uses(uses: &[RecordedUse]) -> Vec<RecordedUse> {
545    let mut by_id: HashMap<(u64, Generation, StreamId, u32), usize> =
546        HashMap::with_capacity(uses.len());
547    let mut deduped: Vec<RecordedUse> = Vec::with_capacity(uses.len());
548    for use_ in uses {
549        let key = (
550            use_.block.ptr,
551            use_.block.generation,
552            use_.block.alloc_stream,
553            use_.block.device_ordinal,
554        );
555        match by_id.get(&key) {
556            Some(&idx) => {
557                deduped[idx].access = combine_access(deduped[idx].access, use_.access);
558            }
559            None => {
560                by_id.insert(key, deduped.len());
561                deduped.push(*use_);
562            }
563        }
564    }
565    deduped
566}
567
568/// Strongest-access lattice: ReadWrite >= Write/Read; Write+Read = ReadWrite.
569fn combine_access(a: Access, b: Access) -> Access {
570    match (a, b) {
571        (Access::ReadWrite, _) | (_, Access::ReadWrite) => Access::ReadWrite,
572        (Access::Read, Access::Write) | (Access::Write, Access::Read) => Access::ReadWrite,
573        (Access::Read, Access::Read) => Access::Read,
574        (Access::Write, Access::Write) => Access::Write,
575    }
576}
577
578fn same_arc_identity<T>(bound: Option<&Arc<T>>, expected: &Arc<T>) -> bool {
579    bound.is_some_and(|bound| Arc::ptr_eq(bound, expected))
580}
581
582impl Drop for LaunchRecorder {
583    fn drop(&mut self) {
584        if !self.committed && !self.uses.is_empty() {
585            #[cfg(debug_assertions)]
586            eprintln!(
587                "[xlog_cuda::launch] LaunchRecorder dropped without commit: \
588                 {} uses on launch_stream={} (mode={:?}) were NOT recorded; \
589                 cross-stream lifetime safety lost for this launch",
590                self.uses.len(),
591                self.launch_stream.0,
592                self.mode,
593            );
594        }
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::device_runtime::{
602        AsyncCudaResource, DeviceMemoryResource, DirectCudaResource, StreamPool,
603    };
604    use crate::CudaDevice;
605    use std::sync::Arc;
606    use xlog_core::MemoryBudget;
607
608    fn try_async_runtime() -> Option<(Arc<CudaDevice>, Arc<XlogDeviceRuntime>, StreamId)> {
609        let device = Arc::new(CudaDevice::new(0).ok()?);
610        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
611        let async_resource: Box<dyn DeviceMemoryResource + Send + Sync> = Box::new(
612            AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool)),
613        );
614        let runtime = Arc::new(XlogDeviceRuntime::with_resource(
615            Arc::clone(&device),
616            0,
617            Arc::clone(&pool),
618            async_resource,
619        ));
620        let launch_stream = pool.acquire().ok()?;
621        Some((device, runtime, launch_stream))
622    }
623
624    fn try_direct_runtime() -> Option<(Arc<CudaDevice>, Arc<XlogDeviceRuntime>, StreamId)> {
625        let device = Arc::new(CudaDevice::new(0).ok()?);
626        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
627        let direct: Box<dyn DeviceMemoryResource + Send + Sync> =
628            Box::new(DirectCudaResource::new(Arc::clone(&device), 0));
629        let runtime = Arc::new(XlogDeviceRuntime::with_resource(
630            Arc::clone(&device),
631            0,
632            Arc::clone(&pool),
633            direct,
634        ));
635        Some((device, runtime, StreamId::DEFAULT))
636    }
637
638    #[test]
639    fn empty_commit_is_ok_in_both_modes() {
640        let Some((_d, rt, ls)) = try_async_runtime() else {
641            return;
642        };
643        LaunchRecorder::new_permissive(ls)
644            .commit(&rt)
645            .expect("permissive empty");
646        LaunchRecorder::new_strict(ls)
647            .commit(&rt)
648            .expect("strict empty");
649    }
650
651    #[test]
652    fn permissive_skips_legacy_silently() {
653        let Some(device) = CudaDevice::new(0).ok().map(Arc::new) else {
654            return;
655        };
656        let pool = Arc::new(StreamPool::with_defaults(Arc::clone(&device)));
657        let async_resource: Box<dyn DeviceMemoryResource + Send + Sync> = Box::new(
658            AsyncCudaResource::new(Arc::clone(&device), 0, Arc::clone(&pool)),
659        );
660        let runtime = Arc::new(XlogDeviceRuntime::with_resource(
661            Arc::clone(&device),
662            0,
663            Arc::clone(&pool),
664            async_resource,
665        ));
666        let launch_stream = pool.acquire().expect("acquire");
667
668        // Legacy manager — no runtime — produces None block.
669        let manager = Arc::new(crate::GpuMemoryManager::new(
670            Arc::clone(&device),
671            MemoryBudget::with_limit(1024 * 1024),
672        ));
673        let legacy = manager.alloc::<u8>(64).expect("legacy alloc");
674        assert!(legacy.runtime_block().is_none());
675
676        let mut rec = LaunchRecorder::new_permissive(launch_stream);
677        rec.read(&legacy);
678        assert_eq!(rec.recorded_count(), 0);
679        rec.preflight(&runtime).expect("permissive preflight");
680        rec.commit(&runtime).expect("permissive commit");
681    }
682
683    #[test]
684    fn strict_rejects_legacy_at_preflight() {
685        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
686            return;
687        };
688        let manager = Arc::new(crate::GpuMemoryManager::new(
689            Arc::clone(&device),
690            MemoryBudget::with_limit(1024 * 1024),
691        ));
692        let legacy = manager.alloc::<u8>(64).expect("legacy alloc");
693
694        let mut rec = LaunchRecorder::new_strict(launch_stream);
695        rec.read(&legacy);
696        let err = rec.preflight(&runtime);
697        match err {
698            Err(ResourceError::StreamMisuse(msg)) => {
699                assert!(msg.contains("untracked buffer rejected"), "msg: {}", msg);
700            }
701            other => panic!(
702                "strict mode must reject untracked buffer at preflight; got {:?}",
703                other
704            ),
705        }
706    }
707
708    #[test]
709    fn preflight_rejects_direct_runtime_before_enqueue() {
710        let Some((device, runtime, launch_stream)) = try_direct_runtime() else {
711            return;
712        };
713        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
714            Arc::clone(&device),
715            MemoryBudget::with_limit(1024 * 1024),
716            Arc::clone(&runtime),
717        ));
718        let buf = manager.alloc::<u8>(64).expect("alloc");
719        assert!(buf.runtime_block().is_some());
720
721        let mut rec = LaunchRecorder::new_strict(launch_stream);
722        rec.read(&buf);
723        let err = rec.preflight(&runtime);
724        match err {
725            Err(ResourceError::StreamMisuse(msg)) => {
726                assert!(
727                    msg.contains("does not support cross-stream use tracking"),
728                    "msg: {}",
729                    msg
730                );
731            }
732            other => panic!(
733                "preflight must reject Direct-backed runtime before enqueue; got {:?}",
734                other
735            ),
736        }
737    }
738
739    #[test]
740    fn preflight_then_commit_async_runtime() {
741        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
742            return;
743        };
744        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
745            Arc::clone(&device),
746            MemoryBudget::with_limit(1024 * 1024),
747            Arc::clone(&runtime),
748        ));
749        let buf = manager.alloc::<u8>(64).expect("alloc");
750
751        let mut rec = LaunchRecorder::new_strict(launch_stream);
752        rec.read(&buf);
753        rec.preflight(&runtime).expect("preflight ok");
754        // (in production: enqueue CUDA launch here)
755        rec.commit(&runtime).expect("commit ok");
756    }
757
758    #[test]
759    fn commit_rejects_un_preflighted_strict_recorder() {
760        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
761            return;
762        };
763        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
764            Arc::clone(&device),
765            MemoryBudget::with_limit(1024 * 1024),
766            Arc::clone(&runtime),
767        ));
768        let buf = manager.alloc::<u8>(64).expect("alloc");
769
770        let mut rec = LaunchRecorder::new_strict(launch_stream);
771        rec.read(&buf);
772        let err = rec.commit(&runtime);
773        match err {
774            Err(ResourceError::StreamMisuse(msg)) => {
775                assert!(
776                    msg.contains("without a successful preflight"),
777                    "msg: {}",
778                    msg
779                );
780            }
781            other => panic!(
782                "non-empty un-preflighted commit must return StreamMisuse, got {:?}",
783                other
784            ),
785        }
786    }
787
788    #[test]
789    fn empty_recorder_commit_without_preflight_is_ok() {
790        let Some((_d, rt, ls)) = try_async_runtime() else {
791            return;
792        };
793        LaunchRecorder::new_strict(ls)
794            .commit(&rt)
795            .expect("empty strict commit without preflight");
796    }
797
798    #[test]
799    fn note_after_preflight_via_standard_method_is_rejected() {
800        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
801            return;
802        };
803        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
804            Arc::clone(&device),
805            MemoryBudget::with_limit(1024 * 1024),
806            Arc::clone(&runtime),
807        ));
808        let buf_a = manager.alloc::<u8>(64).expect("alloc a");
809        let buf_b = manager.alloc::<u8>(64).expect("alloc b");
810
811        let mut rec = LaunchRecorder::new_strict(launch_stream);
812        rec.read(&buf_a);
813        rec.preflight(&runtime).expect("preflight ok");
814        rec.read(&buf_b);
815        let err = rec.commit(&runtime);
816        match err {
817            Err(ResourceError::StreamMisuse(msg)) => {
818                assert!(msg.contains("recorded after preflight"), "msg: {}", msg);
819            }
820            other => panic!(
821                "post-preflight standard-method record must be rejected; got {:?}",
822                other
823            ),
824        }
825    }
826
827    /// Pre-launch fresh-write path: fresh outputs are recorded
828    /// BEFORE preflight via the regular `write` API. Snapshot
829    /// drops the source borrow, so kernel `&mut` borrows after
830    /// preflight remain valid.
831    #[test]
832    fn pre_preflight_fresh_write_is_accepted() {
833        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
834            return;
835        };
836        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
837            Arc::clone(&device),
838            MemoryBudget::with_limit(1024 * 1024),
839            Arc::clone(&runtime),
840        ));
841        let buf_a = manager.alloc::<u8>(64).expect("alloc a");
842        let mut buf_fresh = manager.alloc::<u8>(64).expect("alloc fresh");
843
844        let mut rec = LaunchRecorder::new_strict(launch_stream);
845        rec.read(&buf_a);
846        rec.write(&buf_fresh);
847        rec.preflight(&runtime).expect("preflight ok");
848        // Borrows are released; kernel-style &mut works here.
849        let _kernel_param = &mut buf_fresh;
850        rec.commit(&runtime).expect("commit ok");
851    }
852
853    /// Read+write of the same block in a single recorder
854    /// dedupes to a single ReadWrite prepare/finish call.
855    #[test]
856    fn read_then_write_same_block_dedupes_to_read_write() {
857        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
858            return;
859        };
860        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
861            Arc::clone(&device),
862            MemoryBudget::with_limit(1024 * 1024),
863            Arc::clone(&runtime),
864        ));
865        let buf = manager.alloc::<u8>(64).expect("alloc");
866
867        let mut rec = LaunchRecorder::new_strict(launch_stream);
868        rec.read(&buf);
869        rec.write(&buf);
870        rec.preflight(&runtime).expect("preflight");
871        rec.commit(&runtime).expect("commit");
872    }
873
874    /// Locks the dedup key: `(ptr, generation, device_ordinal)`,
875    /// not `ptr` alone. Two `RecordedUse`s sharing a ptr but
876    /// differing in generation MUST be treated as distinct
877    /// entries — otherwise an ABA reuse inside a single recorder
878    /// would silently collapse an event for the new allocation
879    /// onto the old block's prepare/finish chain.
880    #[test]
881    fn dedup_keys_on_full_block_id_not_ptr_alone() {
882        // Construct two RecordedUses with the same ptr but
883        // distinct generations — directly drive `dedup_uses` so
884        // the test is deterministic and does not require ABA to
885        // actually occur on real CUDA.
886        let block_a = BlockId {
887            ptr: 0xdead_beef,
888            generation: Generation(1),
889            alloc_stream: StreamId::DEFAULT,
890            device_ordinal: 0,
891        };
892        let block_b = BlockId {
893            ptr: 0xdead_beef,
894            generation: Generation(2),
895            alloc_stream: StreamId::DEFAULT,
896            device_ordinal: 0,
897        };
898        let uses = vec![
899            RecordedUse {
900                block: block_a,
901                access: Access::Read,
902                label: "read",
903            },
904            RecordedUse {
905                block: block_b,
906                access: Access::Write,
907                label: "write",
908            },
909        ];
910        let deduped = dedup_uses(&uses);
911        assert_eq!(deduped.len(), 2, "ABA generations must NOT collapse");
912        assert_eq!(deduped[0].block.generation, Generation(1));
913        assert_eq!(deduped[0].access, Access::Read);
914        assert_eq!(deduped[1].block.generation, Generation(2));
915        assert_eq!(deduped[1].access, Access::Write);
916
917        // Same ptr + same generation + duplicate access must
918        // collapse into one entry with combined access.
919        let same_id = vec![
920            RecordedUse {
921                block: block_a,
922                access: Access::Read,
923                label: "read",
924            },
925            RecordedUse {
926                block: block_a,
927                access: Access::Write,
928                label: "write",
929            },
930        ];
931        let collapsed = dedup_uses(&same_id);
932        assert_eq!(collapsed.len(), 1);
933        assert_eq!(collapsed[0].access, Access::ReadWrite);
934    }
935
936    #[test]
937    fn dedup_distinguishes_allocation_stream_in_full_block_identity() {
938        let block_a = BlockId {
939            ptr: 0xdead_beef,
940            generation: Generation(1),
941            alloc_stream: StreamId(7),
942            device_ordinal: 0,
943        };
944        let block_b = BlockId {
945            alloc_stream: StreamId(11),
946            ..block_a
947        };
948        let uses = vec![
949            RecordedUse {
950                block: block_a,
951                access: Access::Read,
952                label: "read",
953            },
954            RecordedUse {
955                block: block_b,
956                access: Access::Write,
957                label: "write",
958            },
959        ];
960
961        let deduped = dedup_uses(&uses);
962        assert_eq!(deduped.len(), 2, "allocation streams are part of BlockId");
963        assert_eq!(deduped[0].block.alloc_stream, StreamId(7));
964        assert_eq!(deduped[1].block.alloc_stream, StreamId(11));
965    }
966
967    #[test]
968    fn read_device_block_snapshots_complete_identity_as_read() {
969        let block = DeviceBlock {
970            ptr: 0x1234,
971            device_ordinal: 2,
972            alloc_stream: StreamId(5),
973            bytes: 64,
974            align: 16,
975            tag: crate::device_runtime::AllocTag::UNTAGGED,
976            generation: Generation(9),
977            state: crate::device_runtime::BlockState::Live,
978        };
979        let expected = BlockId::from_block(&block);
980        let mut recorder = LaunchRecorder::new_strict(StreamId(8));
981
982        recorder.read_device_block(&block);
983
984        assert_eq!(recorder.uses.len(), 1);
985        assert_eq!(recorder.uses[0].block, expected);
986        assert_eq!(recorder.uses[0].access, Access::Read);
987        recorder.committed = true;
988    }
989
990    #[test]
991    fn read_block_identity_records_prevalidated_receipt_pointee() {
992        let identity = BlockId {
993            ptr: 0x9876,
994            generation: Generation(12),
995            alloc_stream: StreamId(4),
996            device_ordinal: 3,
997        };
998        let mut recorder = LaunchRecorder::new_strict(StreamId(6));
999
1000        recorder.read_block_identity(identity);
1001
1002        assert_eq!(recorder.uses.len(), 1);
1003        assert_eq!(recorder.uses[0].block, identity);
1004        assert_eq!(recorder.uses[0].access, Access::Read);
1005        recorder.committed = true;
1006    }
1007
1008    #[test]
1009    fn bound_strict_recorder_retains_runtime_and_domain_identity() {
1010        let _: fn(StreamId, Arc<XlogDeviceRuntime>, Arc<()>) -> LaunchRecorder =
1011            LaunchRecorder::new_strict_bound;
1012    }
1013
1014    #[test]
1015    fn bound_identity_uses_arc_ownership_not_value_equality() {
1016        let owner = Arc::new(());
1017        let same_owner = Arc::clone(&owner);
1018        let equal_value_foreign_owner = Arc::new(());
1019
1020        assert!(same_arc_identity(Some(&owner), &same_owner));
1021        assert!(!same_arc_identity(Some(&owner), &equal_value_foreign_owner));
1022        assert!(!same_arc_identity::<()>(None, &owner));
1023    }
1024
1025    #[test]
1026    fn bound_recorder_exposes_domain_check_and_arc_preflight() {
1027        let _: for<'a> fn(
1028            &'a mut LaunchRecorder,
1029            &Arc<XlogDeviceRuntime>,
1030            &Arc<()>,
1031            StreamId,
1032        ) -> &'a mut LaunchRecorder = LaunchRecorder::require_bound_domain;
1033        let _: fn(&mut LaunchRecorder, &Arc<XlogDeviceRuntime>) -> ResourceResult<()> =
1034            LaunchRecorder::preflight_bound;
1035        let _: fn(LaunchRecorder, &Arc<XlogDeviceRuntime>, &Arc<()>) -> ResourceResult<()> =
1036            LaunchRecorder::commit_bound;
1037    }
1038
1039    #[test]
1040    fn bound_commit_checks_arc_domain_before_finish_use_path() {
1041        let source = include_str!("launch.rs");
1042        let start = source
1043            .find("pub(crate) fn commit_bound")
1044            .expect("bound commit");
1045        let end = source[start..]
1046            .find("fn commit_inner")
1047            .map(|offset| start + offset)
1048            .expect("commit implementation");
1049        let bound_commit = &source[start..end];
1050        let runtime_check = bound_commit
1051            .find("same_arc_identity(self.bound_runtime.as_ref(), runtime)")
1052            .expect("runtime Arc check");
1053        let domain_check = bound_commit
1054            .find("same_arc_identity(self.bound_domain.as_ref(), domain)")
1055            .expect("domain Arc check");
1056        let finish_path = bound_commit
1057            .find("self.commit_inner(runtime.as_ref())")
1058            .expect("finish-use path");
1059        assert!(runtime_check < finish_path && domain_check < finish_path);
1060    }
1061
1062    #[test]
1063    fn read_column_owned_runtime_backed() {
1064        use crate::memory::CudaColumn;
1065        let Some((device, runtime, launch_stream)) = try_async_runtime() else {
1066            return;
1067        };
1068        let manager = Arc::new(crate::GpuMemoryManager::with_runtime(
1069            Arc::clone(&device),
1070            MemoryBudget::with_limit(1024 * 1024),
1071            Arc::clone(&runtime),
1072        ));
1073        let slice = manager.alloc::<u8>(64).expect("alloc");
1074        let col = CudaColumn::owned(slice);
1075        assert!(col.runtime_block().is_some());
1076
1077        let mut rec = LaunchRecorder::new_strict(launch_stream);
1078        rec.read_column(&col);
1079        assert_eq!(rec.recorded_count(), 1);
1080        rec.preflight(&runtime).expect("preflight");
1081        rec.commit(&runtime).expect("commit");
1082    }
1083}