Skip to main content

monarch_rdma/backend/ibverbs/
manager_actor.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! # Ibverbs Manager
10//!
11//! Contains ibverbs-specific RDMA logic.
12//!
13//! Manages ibverbs resources including:
14//! - Memory registration (CPU and CUDA via dmabuf or segment scanning)
15//! - Queue pair creation and connection establishment
16//! - RDMA domain and protection domain management
17//! - Device selection and PCI-to-RDMA device mapping
18
19use std::collections::HashMap;
20use std::collections::hash_map::DefaultHasher;
21use std::fmt::Write as _;
22use std::hash::Hash;
23use std::hash::Hasher;
24use std::sync::OnceLock;
25use std::time::Duration;
26
27use anyhow::Result;
28use async_trait::async_trait;
29use hyperactor::Actor;
30use hyperactor::ActorHandle;
31use hyperactor::ActorRef;
32use hyperactor::Context;
33use hyperactor::Endpoint as _;
34use hyperactor::HandleClient;
35use hyperactor::Handler;
36use hyperactor::Instance;
37use hyperactor::OncePortHandle;
38use hyperactor::OncePortRef;
39use hyperactor::PortHandle;
40use hyperactor::RefClient;
41use hyperactor::actor::Referable;
42use serde::Deserialize;
43use serde::Serialize;
44use typeuri::Named;
45
46use super::IbvBuffer;
47use super::IbvOp;
48use super::device::IbvDevice;
49use super::device::IbvDeviceImpl;
50use super::device_selection::resolve_target;
51use super::device_selection::select_optimal_ibv_devices;
52use super::domain::IbvDomain;
53use super::domain::IbvDomainImpl;
54use super::efa_device::EfaDevice;
55use super::memory_region::IbvMemoryRegionView;
56use super::mlx_device::MlxDevice;
57use super::primitives::IbvConfig;
58use super::primitives::IbvQpInfo;
59use super::primitives::ibverbs_supported;
60use super::queue_pair::IbvQueuePair;
61use super::queue_pair::OpResult;
62use super::queue_pair::ProcessOps;
63use super::queue_pair::QpKey;
64use super::queue_pair::QueuePairActor;
65use super::queue_pair::legacy;
66use crate::RdmaOp;
67use crate::RdmaTransportLevel;
68use crate::backend::RdmaBackend;
69use crate::backend::RdmaConfig;
70use crate::backend::ResolveRemoteBackendContext;
71use crate::device_selection::MemoryLocation;
72use crate::local_memory::KeepaliveLocalMemory;
73use crate::local_memory::is_device_ptr;
74use crate::rdma_components::RdmaRemoteBuffer;
75use crate::rdma_manager_actor::RdmaManagerActor;
76use crate::validate_execution_context;
77
78/// Cross-proc message: the active side asks the peer's manager to
79/// create and connect a mirror QP for an in-flight [`QueuePairActor`].
80/// Generic over the manager actor type so test code can swap in a
81/// mock.
82#[derive(Debug, Serialize, Deserialize, Named)]
83#[serde(bound(serialize = "", deserialize = ""))]
84pub(super) struct CreatePeerQueuePair<M: Referable> {
85    /// The active side's manager.
86    pub(super) sender: ActorRef<M>,
87    /// Device the active side picked for its QP.
88    pub(super) sender_device: String,
89    /// Device the peer should create its mirror QP on.
90    pub(super) receiver_device: String,
91    /// Active side's endpoint, captured right after QP creation.
92    pub(super) sender_info: IbvQpInfo,
93    /// One-shot reply carrying the peer's endpoint, or an error.
94    pub(super) reply: OncePortRef<Result<IbvQpInfo, String>>,
95}
96wirevalue::register_type!(CreatePeerQueuePair<IbvManagerActor<MlxDevice>>);
97wirevalue::register_type!(CreatePeerQueuePair<IbvManagerActor<EfaDevice>>);
98
99/// Local-only message: submit a batch of RDMA ops for end-to-end
100/// execution. The manager iterates the batch, resolves each op's
101/// local MR via [`IbvManagerActor::resolve_local_mr`], looks up
102/// (or spawns) the active-side [`QueuePairActor`] for the op's
103/// [`QpKey`], and immediately dispatches a one-item [`ProcessOps`]
104/// to that QP — so the QP can start posting op `i` while the
105/// manager resolves the MR for op `i+1`.
106///
107/// Per-op completion notifications stream back on `reply` as
108/// [`OpResult`] values.
109pub(super) struct SubmitOps<I: IbvDeviceImpl> {
110    pub(super) ops: Vec<IbvOp<IbvManagerActor<I>>>,
111    pub(super) reply: PortHandle<OpResult>,
112}
113
114/// Local-only message: create a fresh, unconnected legacy
115/// [`legacy::IbvQueuePair`] on `self_device` and return it. The caller drives
116/// the connection itself — exchange [`IbvQpInfo`] with the other endpoint's QP
117/// and call `connect` on each side. Lets doorbell tests and the
118/// `cuda_ping_pong` example poke a real QP without going through
119/// [`QueuePairActor`]; both want the legacy queue pair for its direct
120/// device-doorbell data path, independent of the backend's production
121/// [`IbvDomainImpl::QueuePair`].
122pub struct RawQueuePair {
123    pub self_device: String,
124    pub reply: OncePortHandle<Result<legacy::IbvQueuePair, String>>,
125}
126
127/// Cross-proc messages handled by [`IbvManagerActor`].
128#[derive(Handler, HandleClient, RefClient, Debug, Serialize, Deserialize, Named)]
129pub enum IbvManagerMessage {
130    /// Release a buffer registration by `remote_buf_id`. Fire-and-forget
131    /// (no reply port) to avoid blocking the caller during teardown.
132    ReleaseBuffer { remote_buf_id: usize },
133}
134wirevalue::register_type!(IbvManagerMessage);
135
136/// Local-only messages for [`IbvManagerActor`].
137#[derive(Handler, HandleClient, Debug)]
138pub enum IbvManagerLocalMessage {
139    /// Register a remote-facing buffer's MR and return its
140    /// [`IbvBuffer`]. Called by
141    /// [`crate::rdma_manager_actor::RdmaManagerActor::request_buffer`]
142    /// at buffer-creation time.
143    ///
144    /// The MR lives in [`IbvManagerActor::buffer_registrations`] and
145    /// is deregistered on [`IbvManagerMessage::ReleaseBuffer`].
146    RegisterRemoteBuffer {
147        remote_buf_id: usize,
148        local: KeepaliveLocalMemory,
149        #[reply]
150        reply: OncePortHandle<Result<IbvBuffer, String>>,
151    },
152}
153
154/// Default key used for the per-device protection domain inside
155/// each [`IbvDevice<I>`] entry of [`IbvManagerActor::devices`].
156const DEFAULT_DOMAIN: &str = "default";
157
158/// Manages all ibverbs-specific RDMA resources and operations.
159///
160/// This struct handles memory registration, queue pair management,
161/// and connection establishment using the ibverbs API.
162///
163/// Generic over `I: IbvDeviceImpl` so the same actor implementation
164/// drives every concrete backend (`IbvManagerActor<MlxDevice>`,
165/// `IbvManagerActor<EfaDevice>`, ...).
166#[derive(Debug)]
167#[hyperactor::export(
168    handlers = [
169        IbvManagerMessage,
170        CreatePeerQueuePair<IbvManagerActor<I>>,
171    ],
172)]
173pub struct IbvManagerActor<I: IbvDeviceImpl> {
174    owner: OnceLock<ActorHandle<RdmaManagerActor>>,
175
176    /// Active-side [`QueuePairActor`] children, keyed from this
177    /// manager's perspective. Lazily populated on the first
178    /// [`SubmitOps`] that targets a new `(self_device, peer,
179    /// other_device)` triple.
180    qp_handles: HashMap<
181        QpKey,
182        ActorHandle<QueuePairActor<IbvManagerActor<I>, <I::Domain as IbvDomainImpl>::QueuePair>>,
183    >,
184
185    /// Passive-side mirror QPs, created in response to a peer's
186    /// [`CreatePeerQueuePair`]. The peer's [`QueuePairActor`] owns
187    /// the active side; we hold the connected mirror here so the
188    /// peer can read/write our memory. The map's `Drop` destroys
189    /// each QP via its own `Drop`.
190    peer_created_qps: HashMap<QpKey, <I::Domain as IbvDomainImpl>::QueuePair>,
191
192    /// Map of RDMA device names to their opened [`IbvDevice<I>`], each of
193    /// which owns the per-device `Arc<IbvContext>` and the `DEFAULT_DOMAIN`
194    /// `Arc<IbvDomain>`.
195    devices: HashMap<String, IbvDevice<I>>,
196
197    config: IbvConfig,
198
199    /// Map from buffer_id to the registered MR view. The view keeps the MR (and
200    /// its PD) alive for the lifetime of the registration; `ReleaseBuffer` drops
201    /// the entry, and the FFI resources are released by the `Arc`s' `Drop`s once
202    /// no other holder of the view remains. The wire-facing [`IbvBuffer`] is
203    /// derived from the view on demand.
204    buffer_registrations: HashMap<usize, IbvMemoryRegionView>,
205}
206
207#[async_trait]
208impl<I: IbvDeviceImpl> Actor for IbvManagerActor<I> {
209    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
210        let owner = if let Some(owner) = this.parent_handle() {
211            owner
212        } else {
213            anyhow::bail!("RdmaManagerActor not found as parent of IbvManagerActor");
214        };
215        self.owner
216            .set(owner)
217            .expect("owner should only be set once during init");
218        Ok(())
219    }
220
221    // This actor is implemented in Rust, but the RDMA registration path may enter
222    // Python and take the GIL. Run its loop on the dedicated rdma runtime rather
223    // than the shared control-plane runtime; see `crate::rdma_runtime`.
224    fn spawn_server_task<F>(future: F) -> tokio::task::JoinHandle<F::Output>
225    where
226        F: std::future::Future + Send + 'static,
227        F::Output: Send + 'static,
228    {
229        crate::rdma_runtime::spawn_on_rdma_runtime(future)
230    }
231}
232
233impl<I: IbvDeviceImpl> Drop for IbvManagerActor<I> {
234    fn drop(&mut self) {
235        // Drain active-side QP actors. Each child owns its
236        // `IbvQueuePair`; `drain_and_stop` schedules the actor to
237        // finish in-flight ops and exit, dropping the QP via its
238        // own `Drop`.
239        for (_key, handle) in self.qp_handles.drain() {
240            let _ = handle.drain_and_stop("IbvManagerActor dropped");
241        }
242
243        // The remaining fields (`peer_created_qps`,
244        // `buffer_registrations`, `devices`) free their FFI resources
245        // through their elements' `Drop`s when this struct is dropped.
246    }
247}
248
249impl<I: IbvDeviceImpl> IbvManagerActor<I> {
250    /// Create a new IbvManagerActor with the given configuration.
251    pub async fn new(params: Option<IbvConfig>) -> Result<Self, anyhow::Error> {
252        if !ibverbs_supported() {
253            return Err(anyhow::anyhow!(
254                "Cannot create IbvManagerActor because RDMA is not supported on this machine"
255            ));
256        }
257
258        // Use the caller's config; when none is given, start from the
259        // defaults and let the backend seed its own.
260        let mut config = match params {
261            Some(config) => config,
262            None => {
263                let mut config = IbvConfig::default();
264                I::apply_config_defaults(&mut config);
265                config
266            }
267        };
268        tracing::debug!("rdma is enabled, config target: {:?}", config.target);
269
270        // check config and hardware support align
271        if config.use_gpu_direct {
272            match validate_execution_context().await {
273                Ok(_) => {
274                    tracing::info!("GPU Direct RDMA execution context validated successfully");
275                }
276                Err(e) => {
277                    tracing::warn!(
278                        "GPU Direct RDMA execution context validation failed: {}. Downgrading to standard ibverbs mode.",
279                        e
280                    );
281                    config.use_gpu_direct = false;
282                }
283            }
284        }
285
286        let actor = Self {
287            owner: OnceLock::new(),
288            qp_handles: HashMap::new(),
289            peer_created_qps: HashMap::new(),
290            devices: HashMap::new(),
291            config,
292            buffer_registrations: HashMap::new(),
293        };
294
295        Ok(actor)
296    }
297
298    /// Get or create the `DEFAULT_DOMAIN` for the named RDMA device, opening
299    /// the device on first use.
300    fn get_or_create_device_domain(
301        &mut self,
302        device_name: &str,
303    ) -> Result<&IbvDomain<I::Domain>, anyhow::Error> {
304        if !self.devices.contains_key(device_name) {
305            let device =
306                IbvDevice::<I>::open(device_name, self.config.clone()).ok_or_else(|| {
307                    anyhow::anyhow!("{} does not advertise {}", I::backend_name(), device_name,)
308                })?;
309            // Print device info if MONARCH_DEBUG_RDMA=1 is set.
310            crate::print_device_info_if_debug_enabled(device.context().as_ptr());
311            self.devices.insert(device_name.to_string(), device);
312        }
313        self.devices
314            .get_mut(device_name)
315            .expect("device just inserted or already present")
316            .get_or_create_domain(DEFAULT_DOMAIN)
317    }
318
319    /// Resolve `mem` to an [`IbvMemoryRegionView`] using the slot shared by
320    /// every clone of `mem`. On a cold slot, picks the RDMA device (an explicit
321    /// `config.target` if set, else the CUDA-co-located NIC for device memory,
322    /// else a hash-assigned NIC for host memory) and registers the region
323    /// through that device's [`IbvDomainImpl`] strategy, installing the result;
324    /// on a warm slot, returns the cached view.
325    fn resolve_local_mr(
326        &mut self,
327        mem: &KeepaliveLocalMemory,
328    ) -> Result<IbvMemoryRegionView, anyhow::Error> {
329        if let Some(mrv) = mem.mr_slot().get() {
330            return Ok(mrv.clone());
331        }
332        let addr = mem.addr();
333
334        // Device selection, in priority order:
335        //   1. an explicit `config.target`, resolved to its NIC;
336        //   2. otherwise the CUDA-co-located NIC for device memory;
337        //   3. otherwise a host-memory NIC assigned by hashing (addr, size).
338        let device_name = if let Some(target) = &self.config.target {
339            resolve_target::<I>(target)
340                .ok_or_else(|| anyhow::anyhow!("configured device target {:?} not found", target))?
341                .name()
342                .clone()
343        } else {
344            let cuda_nic = if is_device_ptr(addr) {
345                let mut device_ordinal: i32 = -1;
346                // SAFETY: `addr` is a CUDA device pointer (per `is_device_ptr`);
347                // the FFI call writes the owning device ordinal through the
348                // out-pointer.
349                let err = unsafe {
350                    rdmaxcel_sys::rdmaxcel_cuPointerGetAttribute(
351                        &mut device_ordinal as *mut _ as *mut std::ffi::c_void,
352                        rdmaxcel_sys::CU_POINTER_ATTRIBUTE_DEVICE_ORDINAL,
353                        addr as rdmaxcel_sys::CUdeviceptr,
354                    )
355                };
356                let ordinal = (err == rdmaxcel_sys::CUDA_SUCCESS)
357                    .then_some(device_ordinal)
358                    .ok_or_else(|| {
359                        anyhow::anyhow!(
360                            "could not get CUDA device ordinal for device memory at 0x{:x}: {}",
361                            addr,
362                            err
363                        )
364                    })?;
365                assert!(ordinal >= 0, "CUDA device ordinal must be non-negative");
366                Some(
367                    super::device_selection::get_cuda_device_to_ibv_device::<I>()
368                        .get(ordinal as usize)
369                        .and_then(|d| d.clone())
370                        .ok_or_else(|| {
371                            anyhow::anyhow!(
372                                "no RDMA device found for CUDA device ordinal {}",
373                                ordinal
374                            )
375                        })?,
376                )
377            } else {
378                None
379            };
380            match cuda_nic {
381                Some(info) => info.name().clone(),
382                None => {
383                    // Host memory has no co-located GPU NIC. Rather than funnel
384                    // every host registration through a single device, spread them
385                    // across all NICs that tie for the best CPU path by hashing the
386                    // region's (addr, size); the NICs share the load for host
387                    // memory, increasing aggregate throughput.
388                    let devices = select_optimal_ibv_devices::<I>(MemoryLocation::Cpu(None));
389                    match devices.len() {
390                        0 => anyhow::bail!("no RDMA devices found"),
391                        n => {
392                            let mut hasher = DefaultHasher::new();
393                            (mem.addr(), mem.size()).hash(&mut hasher);
394                            devices[(hasher.finish() % n as u64) as usize]
395                                .name()
396                                .clone()
397                        }
398                    }
399                }
400            }
401        };
402        tracing::debug!(
403            "Using RDMA device: {} for memory at 0x{:x}",
404            device_name,
405            addr
406        );
407
408        let domain = self.get_or_create_device_domain(&device_name)?;
409        // The backend strategy handles host vs. device memory (standard MR,
410        // dmabuf MR, or a device-specific segment binding).
411        let mrv = domain.register_mr(mem)?;
412        Ok(mem.mr_slot().get_or_init(|| mrv).clone())
413    }
414
415    /// Build a passive-side mirror QP for `qp_key`, connect it to
416    /// `sender_info`, and store it in [`Self::peer_created_qps`].
417    /// Returns the local endpoint the active side needs to finish
418    /// its own `connect`. Called from
419    /// [`Handler<CreatePeerQueuePair>`].
420    fn create_peer_qp(
421        &mut self,
422        qp_key: &QpKey,
423        sender_info: &IbvQpInfo,
424    ) -> Result<IbvQpInfo, anyhow::Error> {
425        if self.peer_created_qps.contains_key(qp_key) {
426            anyhow::bail!("peer queue pair already exists for {qp_key:?}");
427        }
428        let self_device = &qp_key.self_device;
429        let config = self.config.clone();
430        let domain = self.get_or_create_device_domain(self_device)?;
431        let mut qp = domain
432            .create_queue_pair(&config)
433            .map_err(|e| anyhow::anyhow!("could not create peer IbvQueuePair: {}", e))?;
434        let local_info = qp
435            .get_qp_info()
436            .map_err(|e| anyhow::anyhow!("could not extract peer QP info: {}", e))?;
437        qp.connect(sender_info)
438            .map_err(|e| anyhow::anyhow!("could not connect peer QP: {}", e))?;
439        self.peer_created_qps.insert(qp_key.clone(), qp);
440        Ok(local_info)
441    }
442
443    /// Lazy active-side QP actor: if `qp_key` is absent from
444    /// [`Self::qp_handles`], create an [`IbvQueuePair`] on the
445    /// requested device and spawn a [`QueuePairActor`] to drive its
446    /// handshake + data path. Returns a clone of the actor handle.
447    fn ensure_qp_actor(
448        &mut self,
449        cx: &Context<'_, Self>,
450        qp_key: &QpKey,
451        peer_manager: ActorRef<Self>,
452    ) -> Result<
453        ActorHandle<QueuePairActor<Self, <I::Domain as IbvDomainImpl>::QueuePair>>,
454        anyhow::Error,
455    > {
456        if let Some(h) = self.qp_handles.get(qp_key) {
457            return Ok(h.clone());
458        }
459        let self_device = &qp_key.self_device;
460        let config = self.config.clone();
461        let domain = self.get_or_create_device_domain(self_device)?;
462        let qp = domain
463            .create_queue_pair(&config)
464            .map_err(|e| anyhow::anyhow!("could not create IbvQueuePair for {qp_key:?}: {}", e))?;
465        let local_manager: ActorRef<Self> = cx.bind();
466        let is_loopback = local_manager.actor_addr() == peer_manager.actor_addr()
467            && qp_key.self_device == qp_key.other_device;
468        let actor = cx.spawn(QueuePairActor::new(
469            qp_key.clone(),
470            local_manager,
471            peer_manager,
472            qp,
473            is_loopback,
474            config.max_send_wr,
475            config.max_rd_atomic as u32,
476        ));
477        self.qp_handles.insert(qp_key.clone(), actor.clone());
478        Ok(actor)
479    }
480}
481
482#[async_trait]
483impl<I: IbvDeviceImpl> IbvManagerMessageHandler for IbvManagerActor<I> {
484    async fn release_buffer(
485        &mut self,
486        _cx: &Context<Self>,
487        remote_buf_id: usize,
488    ) -> Result<(), anyhow::Error> {
489        // Dropping the entry releases the manager's `Arc` clones on
490        // the view's MR and PD; FFI cleanup happens via their `Drop`s
491        // once the last referencing view is gone.
492        self.buffer_registrations.remove(&remote_buf_id);
493        Ok(())
494    }
495}
496
497// `#[hyperactor::handle(IbvManagerMessage)]` would generate a
498// non-generic `impl Handler<...> for IbvManagerActor<I>` that
499// can't see `I`; we write the generic delegation by hand.
500#[async_trait]
501impl<I: IbvDeviceImpl> Handler<IbvManagerMessage> for IbvManagerActor<I> {
502    async fn handle(
503        &mut self,
504        cx: &Context<Self>,
505        message: IbvManagerMessage,
506    ) -> Result<(), anyhow::Error> {
507        <Self as IbvManagerMessageHandler>::handle(self, cx, message).await
508    }
509}
510
511#[async_trait]
512impl<I: IbvDeviceImpl> Handler<SubmitOps<I>> for IbvManagerActor<I> {
513    async fn handle(&mut self, cx: &Context<Self>, msg: SubmitOps<I>) -> Result<(), anyhow::Error> {
514        let SubmitOps { ops, reply } = msg;
515
516        // Interleave MR resolution with QP dispatch: as soon as op `i`'s
517        // local MR is resolved and its QP actor is in place, ship a
518        // one-item `ProcessOps` to that QP. The QP can then post and
519        // poll op `i` while we run `resolve_local_mr` for op `i+1`.
520        for (i, op) in ops.into_iter().enumerate() {
521            let mrv = match self.resolve_local_mr(&op.local_memory) {
522                Ok(mrv) => mrv,
523                Err(e) => {
524                    reply.try_post(
525                        cx,
526                        OpResult {
527                            op_idx: i,
528                            result: Err(e.to_string()),
529                        },
530                    )?;
531                    continue;
532                }
533            };
534            let qp_key = QpKey {
535                self_device: mrv.device_name.clone(),
536                other_id: op.remote_manager.actor_addr().id().clone(),
537                other_device: op.remote_buffer.device_name.clone(),
538            };
539            let peer_manager = op.remote_manager.clone();
540            let handle = match self.ensure_qp_actor(cx, &qp_key, peer_manager) {
541                Ok(h) => h,
542                Err(e) => {
543                    reply.try_post(
544                        cx,
545                        OpResult {
546                            op_idx: i,
547                            result: Err(e.to_string()),
548                        },
549                    )?;
550                    continue;
551                }
552            };
553            handle.try_post(
554                cx,
555                ProcessOps {
556                    items: vec![(i, op, mrv)],
557                    reply: reply.clone(),
558                },
559            )?;
560        }
561        Ok(())
562    }
563}
564
565#[async_trait]
566impl<I: IbvDeviceImpl> Handler<RawQueuePair> for IbvManagerActor<I> {
567    async fn handle(&mut self, cx: &Context<Self>, msg: RawQueuePair) -> Result<(), anyhow::Error> {
568        let RawQueuePair { self_device, reply } = msg;
569        // Build a fresh, unconnected legacy QP on `self_device` and hand it
570        // back; the caller exchanges endpoint info and connects it.
571        let config = self.config.clone();
572        let result = self
573            .get_or_create_device_domain(&self_device)
574            .and_then(|domain| legacy::IbvQueuePair::new(domain, config))
575            .map_err(|e| e.to_string());
576        let _ = reply.try_post(cx, result);
577        Ok(())
578    }
579}
580
581#[async_trait]
582impl<I: IbvDeviceImpl> Handler<CreatePeerQueuePair<IbvManagerActor<I>>> for IbvManagerActor<I> {
583    async fn handle(
584        &mut self,
585        cx: &Context<Self>,
586        msg: CreatePeerQueuePair<IbvManagerActor<I>>,
587    ) -> Result<(), anyhow::Error> {
588        let CreatePeerQueuePair {
589            sender,
590            sender_device,
591            receiver_device,
592            sender_info,
593            reply,
594        } = msg;
595        let qp_key = QpKey {
596            self_device: receiver_device,
597            other_id: sender.actor_addr().id().clone(),
598            other_device: sender_device,
599        };
600        match self.create_peer_qp(&qp_key, &sender_info) {
601            Ok(local_info) => reply.post(cx, Ok(local_info)),
602            Err(e) => reply.post(cx, Err(e.to_string())),
603        }
604        Ok(())
605    }
606}
607
608#[async_trait]
609impl<I: IbvDeviceImpl> IbvManagerLocalMessageHandler for IbvManagerActor<I> {
610    async fn register_remote_buffer(
611        &mut self,
612        _cx: &Context<Self>,
613        remote_buf_id: usize,
614        local: KeepaliveLocalMemory,
615    ) -> Result<Result<IbvBuffer, String>, anyhow::Error> {
616        if let Some(mrv) = self.buffer_registrations.get(&remote_buf_id) {
617            return Ok(Ok(IbvBuffer::from(mrv)));
618        }
619        // `resolve_local_mr` installs the view in `local`'s shared MR
620        // slot, so every clone of this handle — including the one the
621        // caller holds — reuses this registration instead of registering
622        // the same region again.
623        let mrv = match self.resolve_local_mr(&local) {
624            Ok(v) => v,
625            Err(e) => return Ok(Err(e.to_string())),
626        };
627        let buf = IbvBuffer::from(&mrv);
628        self.buffer_registrations.insert(remote_buf_id, mrv);
629        Ok(Ok(buf))
630    }
631}
632
633// `#[hyperactor::handle(IbvManagerLocalMessage)]` analogue, written
634// generically; see the `IbvManagerMessage` block above.
635#[async_trait]
636impl<I: IbvDeviceImpl> Handler<IbvManagerLocalMessage> for IbvManagerActor<I> {
637    async fn handle(
638        &mut self,
639        cx: &Context<Self>,
640        message: IbvManagerLocalMessage,
641    ) -> Result<(), anyhow::Error> {
642        <Self as IbvManagerLocalMessageHandler>::handle(self, cx, message).await
643    }
644}
645
646/// Wrapper around [`ActorHandle<IbvManagerActor<I>>`] that moves the RDMA
647/// data-plane (post send/recv, poll CQ) off the actor loop while keeping
648/// state-mutating operations (MR registration/deregistration, QP management)
649/// serialized through actor messages.
650#[derive(Debug)]
651pub struct IbvBackend<I: IbvDeviceImpl>(pub ActorHandle<IbvManagerActor<I>>);
652
653impl<I: IbvDeviceImpl> Clone for IbvBackend<I> {
654    fn clone(&self) -> Self {
655        Self(self.0.clone())
656    }
657}
658
659impl<I: IbvDeviceImpl> std::ops::Deref for IbvBackend<I> {
660    type Target = ActorHandle<IbvManagerActor<I>>;
661    fn deref(&self) -> &Self::Target {
662        &self.0
663    }
664}
665
666/// Serializable per-buffer context for an ibverbs backend: the manager
667/// to route ops through and the wire description of the registered MR.
668#[derive(Serialize, Deserialize, Named)]
669#[serde(bound = "")]
670pub struct IbvRemoteBackendContext<I: IbvDeviceImpl> {
671    pub manager: ActorRef<IbvManagerActor<I>>,
672    pub buffer: IbvBuffer,
673}
674
675// `Clone` and `Debug` are hand-rolled to avoid the spurious `I: Clone`
676// and `I: Debug` bounds the derives would impose; neither field depends
677// on `I` implementing them.
678impl<I: IbvDeviceImpl> Clone for IbvRemoteBackendContext<I> {
679    fn clone(&self) -> Self {
680        Self {
681            manager: self.manager.clone(),
682            buffer: self.buffer.clone(),
683        }
684    }
685}
686
687impl<I: IbvDeviceImpl> std::fmt::Debug for IbvRemoteBackendContext<I> {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        f.debug_struct("IbvRemoteBackendContext")
690            .field("manager", &self.manager)
691            .field("buffer", &self.buffer)
692            .finish()
693    }
694}
695
696#[async_trait]
697impl<I: IbvDeviceImpl> RdmaBackend for IbvBackend<I>
698where
699    RdmaRemoteBuffer: ResolveRemoteBackendContext<IbvBackend<I>>,
700{
701    type RemoteBackendContext = IbvRemoteBackendContext<I>;
702    type TransportInfo = ();
703
704    fn available() -> bool {
705        if IbvDevice::<I>::available() {
706            if hyperactor_config::global::get(crate::config::RDMA_DISABLE_IBVERBS) {
707                tracing::warn!(
708                    "ibverbs ({}) is available, but it was disabled by configuration (RDMA_DISABLE_IBVERBS=true)",
709                    I::backend_name()
710                );
711                return false;
712            }
713            return true;
714        }
715        false
716    }
717
718    fn transport_level(&self) -> RdmaTransportLevel {
719        RdmaTransportLevel::Nic
720    }
721
722    fn transport_info(&self) -> Option<Self::TransportInfo> {
723        None
724    }
725
726    async fn spawn(
727        cx: &(impl hyperactor::context::Actor + Send + Sync),
728        config: &RdmaConfig,
729    ) -> Result<Self> {
730        let actor = IbvManagerActor::<I>::new(config.ibv.clone()).await?;
731        Ok(IbvBackend(cx.spawn(actor)))
732    }
733
734    async fn register_remote_buffer(
735        &self,
736        cx: &(impl hyperactor::context::Actor + Send + Sync),
737        remote_buf_id: usize,
738        local: KeepaliveLocalMemory,
739    ) -> Result<IbvRemoteBackendContext<I>> {
740        let buffer = self
741            .0
742            .register_remote_buffer(cx, remote_buf_id, local)
743            .await?
744            .map_err(|e| anyhow::anyhow!(e))?;
745        Ok(IbvRemoteBackendContext {
746            manager: self.0.bind(),
747            buffer,
748        })
749    }
750
751    async fn release_buffer(
752        &self,
753        cx: &(impl hyperactor::context::Actor + Send + Sync),
754        remote_buf_id: usize,
755    ) -> Result<()> {
756        self.0.release_buffer(cx, remote_buf_id).await
757    }
758
759    /// Submit a batch of RDMA operations.
760    ///
761    /// Translates each op to an `IbvOp`, then ships the whole batch to
762    /// [`IbvManagerActor`] via [`SubmitOps`]. The manager interleaves
763    /// local-MR resolution with per-op dispatch: each op is sent to its
764    /// [`QueuePairActor`] as a one-item [`ProcessOps`] the moment its MR
765    /// is ready, so QP work on op `i` overlaps MR registration for op
766    /// `i+1`.
767    ///
768    /// Always waits for exactly `ops.len()` per-op replies before
769    /// returning. Per-op failures are collected and formatted into a single
770    /// multi-line `Err` listing each `op_idx` and its error message.
771    async fn submit(
772        &self,
773        cx: &(impl hyperactor::context::Actor + Send + Sync),
774        ops: Vec<RdmaOp>,
775        timeout: Duration,
776    ) -> Result<(), anyhow::Error> {
777        let mut ibv_ops = Vec::with_capacity(ops.len());
778        for op in ops {
779            let ctx = <RdmaRemoteBuffer as ResolveRemoteBackendContext<IbvBackend<I>>>::resolve(
780                &op.remote,
781            )
782            .expect("op routed to incompatible backend");
783            ibv_ops.push(IbvOp {
784                op_type: op.op_type,
785                local_memory: op.local.clone(),
786                remote_buffer: ctx.buffer,
787                remote_manager: ctx.manager,
788            });
789        }
790        let n = ibv_ops.len();
791
792        let (reply, mut reply_rx) = cx.mailbox().open_port::<OpResult>();
793
794        self.0.try_post(
795            cx,
796            SubmitOps {
797                ops: ibv_ops,
798                reply,
799            },
800        )?;
801
802        let mut failures: Vec<(usize, String)> = Vec::with_capacity(n);
803        let mut received = 0usize;
804        let mut terminal: Option<String> = None;
805        let deadline = tokio::time::Instant::now() + timeout;
806        while received < n {
807            tokio::select! {
808                () = tokio::time::sleep_until(deadline) => {
809                    terminal = Some(format!(
810                        "submit timed out after {received}/{n} replies with {} failures",
811                        failures.len()
812                    ));
813                    break;
814                }
815                recv = reply_rx.recv() => {
816                    match recv {
817                        Ok(OpResult { result: Ok(()), .. }) => received += 1,
818                        Ok(OpResult { op_idx, result: Err(e) }) => {
819                            received += 1;
820                            failures.push((op_idx, e));
821                        }
822                        Err(e) => {
823                            terminal = Some(format!(
824                                "SubmitOps reply port closed after {received}/{n} replies with {} failures: {e}",
825                                failures.len()
826                            ));
827                            break;
828                        }
829                    }
830                }
831            }
832        }
833
834        if terminal.is_none() && failures.is_empty() {
835            return Ok(());
836        }
837
838        failures.sort_by_key(|(idx, _)| *idx);
839        let mut msg = terminal.unwrap_or_else(|| format!("{}/{n} ops failed", failures.len()));
840        if !failures.is_empty() {
841            msg.push(':');
842            for (idx, err) in &failures {
843                write!(msg, "\n  op {idx}: {err}").expect("infallible String write");
844            }
845        }
846        Err(anyhow::anyhow!(msg))
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    //! End-to-end coverage of the [`SubmitOps`] → [`ProcessOps`] →
853    //! [`QueuePairActor`] data path.
854    //!
855    //! Each test stands up two RDMA participants in two
856    //! [`Proc::direct`] procs in the test process. Each proc hosts an
857    //! [`RdmaManagerActor`] and a [`BufferHelperActor`]. Tests
858    //! allocate buffers on either side via the helpers, drive RDMA
859    //! through [`IbvBackend::submit`] (called inside the helper
860    //! actor), and verify by reading back local contents through
861    //! [`BufferHelperMessage::ReadContents`]. The
862    //! [`BufferHelperActor::cleanup`] impl releases any CUDA
863    //! allocations when the actor stops; [`TestEnv::shutdown`]
864    //! explicitly drains both procs.
865
866    use std::sync::Arc;
867    use std::sync::atomic::AtomicUsize;
868    use std::sync::atomic::Ordering;
869    use std::time::Duration;
870
871    use async_trait::async_trait;
872    use hyperactor::Actor;
873    use hyperactor::ActorRef;
874    use hyperactor::Context;
875    use hyperactor::Handler;
876    use hyperactor::Instance;
877    use hyperactor::Label;
878    use hyperactor::OncePortRef;
879    use hyperactor::Proc;
880    use hyperactor::RefClient;
881    use hyperactor::RemoteSpawn;
882    use hyperactor::Uid;
883    use hyperactor::actor::ActorError;
884    use hyperactor::channel::ChannelAddr;
885    use hyperactor::channel::ChannelTransport;
886    use hyperactor_config::Flattrs;
887    use serde::Deserialize;
888    use serde::Serialize;
889    use typeuri::Named;
890
891    use crate::IbvConfig;
892    use crate::RdmaManagerActor;
893    use crate::RdmaManagerMessageClient;
894    use crate::RdmaOp;
895    use crate::RdmaOpType;
896    use crate::RdmaRemoteBuffer;
897    use crate::backend::RdmaBackendHandle;
898    use crate::backend::cuda_test_utils::CudaAllocation;
899    use crate::backend::cuda_test_utils::CudaAllocator;
900    use crate::backend::ibverbs::device::list_all_devices;
901    use crate::backend::ibverbs::device_selection::IbvDeviceTarget;
902    use crate::backend::ibverbs::primitives::IbvQpType;
903    use crate::local_memory::KeepaliveLocalMemory;
904
905    // ====================================================================
906    // BufferHelperActor
907    // ====================================================================
908
909    /// Device a test buffer is allocated on.
910    #[derive(Debug, Clone, Copy, Serialize, Deserialize, Named)]
911    pub enum BufferDevice {
912        Cpu,
913        Cuda(i32),
914    }
915
916    /// One op for [`BufferHelperMessage::Submit`]. The helper looks up
917    /// the local memory behind `local_buf` (registered earlier via
918    /// `Allocate`) and pairs it with `remote_buf` to form an
919    /// [`RdmaOp`].
920    #[derive(Debug, Clone, Serialize, Deserialize, Named)]
921    pub struct BufferHelperOp {
922        op_type: RdmaOpType,
923        local_buf: RdmaRemoteBuffer,
924        remote_buf: RdmaRemoteBuffer,
925    }
926
927    /// Test helper that owns local buffers (CPU or CUDA) and drives
928    /// [`IbvBackend::submit`] against its own [`RdmaManagerActor`].
929    #[hyperactor::export(handlers = [BufferHelperMessage])]
930    #[hyperactor::spawnable]
931    #[derive(Debug)]
932    pub struct BufferHelperActor {
933        rdma_manager: ActorRef<RdmaManagerActor>,
934        /// CUDA allocations tracked for cleanup. Each is also held as
935        /// `Keepalive` inside the registered `KeepaliveLocalMemory`;
936        /// both clones must drop before the FFI memory is released.
937        cuda_allocs: Vec<CudaAllocation>,
938    }
939
940    #[async_trait]
941    impl Actor for BufferHelperActor {
942        async fn cleanup(
943            &mut self,
944            _this: &Instance<Self>,
945            _err: Option<&ActorError>,
946        ) -> Result<(), anyhow::Error> {
947            for alloc in self.cuda_allocs.drain(..) {
948                alloc.try_free();
949            }
950            Ok(())
951        }
952    }
953
954    #[async_trait]
955    impl RemoteSpawn for BufferHelperActor {
956        type Params = ActorRef<RdmaManagerActor>;
957
958        async fn new(
959            rdma_manager: ActorRef<RdmaManagerActor>,
960            _env: Flattrs,
961        ) -> Result<Self, anyhow::Error> {
962            Ok(Self {
963                rdma_manager,
964                cuda_allocs: Vec::new(),
965            })
966        }
967    }
968
969    #[derive(Handler, RefClient, Named, Serialize, Deserialize, Debug)]
970    pub enum BufferHelperMessage {
971        /// Allocate `size` bytes on `device`, pre-fill with `pattern`,
972        /// register with the local `RdmaManagerActor`, and reply with
973        /// the resulting `RdmaRemoteBuffer`.
974        Allocate {
975            size: usize,
976            device: BufferDevice,
977            pattern: u8,
978            #[reply]
979            reply: OncePortRef<RdmaRemoteBuffer>,
980        },
981        /// Look up the local memory behind `remote.id` and reply with
982        /// the byte range `[offset, offset + len)`. Tests use this to
983        /// sample buffers too large to ship over a single actor
984        /// message in one piece.
985        ReadContents {
986            remote: Box<RdmaRemoteBuffer>,
987            offset: usize,
988            len: usize,
989            #[reply]
990            reply: OncePortRef<Vec<u8>>,
991        },
992        /// Drive a batch of RDMA ops through `IbvBackend::submit`.
993        /// Each op's `local_buf` is resolved against this helper's
994        /// `RdmaManagerActor`; `remote_buf` is shipped as-is to the
995        /// peer.
996        Submit {
997            ops: Vec<BufferHelperOp>,
998            timeout_secs: u64,
999            #[reply]
1000            reply: OncePortRef<Result<(), String>>,
1001        },
1002    }
1003
1004    impl BufferHelperActor {
1005        async fn allocate_impl(
1006            &mut self,
1007            cx: &Context<'_, Self>,
1008            size: usize,
1009            device: BufferDevice,
1010            pattern: u8,
1011        ) -> Result<RdmaRemoteBuffer, anyhow::Error> {
1012            let local = match device {
1013                BufferDevice::Cpu => {
1014                    let buf: Box<[u8]> = vec![pattern; size].into_boxed_slice();
1015                    KeepaliveLocalMemory::new(Arc::new(buf))
1016                }
1017                BufferDevice::Cuda(device_id) => {
1018                    let alloc = CudaAllocator::get().allocate(device_id, size, size);
1019                    let local = KeepaliveLocalMemory::new(Arc::new(alloc.clone()));
1020                    self.cuda_allocs.push(alloc);
1021                    let fill = vec![pattern; size];
1022                    // SAFETY: `local` is freshly constructed; no other
1023                    // holder touches this CUDA range yet.
1024                    unsafe { local.write_at(0, &fill) }?;
1025                    local
1026                }
1027            };
1028            let handle = self
1029                .rdma_manager
1030                .downcast_handle(cx)
1031                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local to BufferHelperActor"))?;
1032            handle.request_buffer(cx, local).await
1033        }
1034
1035        async fn read_contents_impl(
1036            &mut self,
1037            cx: &Context<'_, Self>,
1038            remote: RdmaRemoteBuffer,
1039            offset: usize,
1040            len: usize,
1041        ) -> Result<Vec<u8>, anyhow::Error> {
1042            let handle = self
1043                .rdma_manager
1044                .downcast_handle(cx)
1045                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local"))?;
1046            let local = handle
1047                .request_local_memory(cx, remote.id)
1048                .await?
1049                .ok_or_else(|| {
1050                    anyhow::anyhow!(
1051                        "no local memory registered on this side for remote_buf_id={}",
1052                        remote.id,
1053                    )
1054                })?;
1055            let mut out = vec![0u8; len];
1056            // SAFETY: by convention the caller has ensured all RDMA
1057            // ops against this buffer have completed before invoking
1058            // ReadContents.
1059            unsafe { local.read_at(offset, &mut out)? };
1060            Ok(out)
1061        }
1062
1063        async fn submit_impl(
1064            &mut self,
1065            cx: &Context<'_, Self>,
1066            ops: Vec<BufferHelperOp>,
1067            timeout_secs: u64,
1068        ) -> Result<Result<(), String>, anyhow::Error> {
1069            let handle = self
1070                .rdma_manager
1071                .downcast_handle(cx)
1072                .ok_or_else(|| anyhow::anyhow!("rdma_manager not local"))?;
1073            let mut rdma_ops = Vec::with_capacity(ops.len());
1074            for (i, op) in ops.into_iter().enumerate() {
1075                let local = handle
1076                    .request_local_memory(cx, op.local_buf.id)
1077                    .await?
1078                    .ok_or_else(|| {
1079                        anyhow::anyhow!(
1080                            "op {i}: no local memory registered for remote_buf_id={}",
1081                            op.local_buf.id,
1082                        )
1083                    })?;
1084                rdma_ops.push(RdmaOp {
1085                    op_type: op.op_type,
1086                    local,
1087                    remote: op.remote_buf,
1088                });
1089            }
1090            let nic = RdmaManagerActor::local_handle(cx)
1091                .get_backend_handles(cx)
1092                .await?
1093                .into_iter()
1094                .find(|h| !matches!(h, RdmaBackendHandle::Tcp(_)))
1095                .ok_or_else(|| anyhow::anyhow!("no NIC backend on this proc"))?;
1096            let result = nic
1097                .submit(cx, rdma_ops, Duration::from_secs(timeout_secs))
1098                .await;
1099            Ok(result.map_err(|e| format!("{e}")))
1100        }
1101    }
1102
1103    #[async_trait]
1104    #[hyperactor::handle(BufferHelperMessage)]
1105    impl BufferHelperMessageHandler for BufferHelperActor {
1106        async fn allocate(
1107            &mut self,
1108            cx: &Context<Self>,
1109            size: usize,
1110            device: BufferDevice,
1111            pattern: u8,
1112        ) -> Result<RdmaRemoteBuffer, anyhow::Error> {
1113            self.allocate_impl(cx, size, device, pattern).await
1114        }
1115
1116        async fn read_contents(
1117            &mut self,
1118            cx: &Context<Self>,
1119            remote: Box<RdmaRemoteBuffer>,
1120            offset: usize,
1121            len: usize,
1122        ) -> Result<Vec<u8>, anyhow::Error> {
1123            self.read_contents_impl(cx, *remote, offset, len).await
1124        }
1125
1126        async fn submit(
1127            &mut self,
1128            cx: &Context<Self>,
1129            ops: Vec<BufferHelperOp>,
1130            timeout_secs: u64,
1131        ) -> Result<Result<(), String>, anyhow::Error> {
1132            self.submit_impl(cx, ops, timeout_secs).await
1133        }
1134    }
1135
1136    // ====================================================================
1137    // TestEnv
1138    // ====================================================================
1139
1140    static COUNTER: AtomicUsize = AtomicUsize::new(0);
1141
1142    /// Two-sided test environment.
1143    ///
1144    /// Each side is a `Proc::direct` in the test process hosting its
1145    /// own `RdmaManagerActor` and `BufferHelperActor`. A client minted
1146    /// from `proc_b` drives both helpers through their `ActorRef`s.
1147    struct TestEnv {
1148        client: hyperactor::Client,
1149        proc_a: Proc,
1150        helper_a: ActorRef<BufferHelperActor>,
1151        proc_b: Proc,
1152        helper_b: ActorRef<BufferHelperActor>,
1153    }
1154
1155    impl TestEnv {
1156        /// Asymmetric setup: side A uses `config_a`, side B uses `config_b`.
1157        async fn new(config_a: IbvConfig, config_b: IbvConfig) -> Result<Self, anyhow::Error> {
1158            let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1159            let proc_a = Proc::direct(
1160                ChannelAddr::any(ChannelTransport::Unix),
1161                format!("rdma_side_a_{id}"),
1162            )?;
1163            let helper_a = Self::spawn_side(&proc_a, config_a).await?;
1164            let proc_b = Proc::direct(
1165                ChannelAddr::any(ChannelTransport::Unix),
1166                format!("rdma_side_b_{id}"),
1167            )?;
1168            let helper_b = Self::spawn_side(&proc_b, config_b).await?;
1169            let client = proc_b.client("test_client");
1170            Ok(Self {
1171                client,
1172                proc_a,
1173                helper_a,
1174                proc_b,
1175                helper_b,
1176            })
1177        }
1178
1179        /// Symmetric setup: both sides use `config`.
1180        async fn same_config(config: IbvConfig) -> Result<Self, anyhow::Error> {
1181            Self::new(config.clone(), config).await
1182        }
1183
1184        /// Spawn an `RdmaManagerActor` + `BufferHelperActor` on `proc`
1185        /// and return the helper's `ActorRef`.
1186        async fn spawn_side(
1187            proc: &Proc,
1188            config: IbvConfig,
1189        ) -> Result<ActorRef<BufferHelperActor>, anyhow::Error> {
1190            let rdma_actor = RdmaManagerActor::new(Some(config), Flattrs::default()).await?;
1191            // Must match `RdmaManagerActor::local_handle`'s singleton lookup of "rdma_manager".
1192            let rdma_handle =
1193                proc.spawn_with_uid(Uid::singleton(Label::strip("rdma_manager")), rdma_actor)?;
1194            let rdma: ActorRef<RdmaManagerActor> = rdma_handle.bind();
1195            let helper_actor = BufferHelperActor::new(rdma, Flattrs::default()).await?;
1196            let helper_handle = proc.spawn_with_label("helper", helper_actor);
1197            Ok(helper_handle.bind())
1198        }
1199
1200        async fn shutdown(mut self) -> Result<(), anyhow::Error> {
1201            let _ = self
1202                .proc_a
1203                .destroy_and_wait(Duration::from_secs(10), "TestEnv shutdown proc_a")
1204                .await?;
1205            let _ = self
1206                .proc_b
1207                .destroy_and_wait(Duration::from_secs(10), "TestEnv shutdown proc_b")
1208                .await?;
1209            Ok(())
1210        }
1211    }
1212
1213    // ====================================================================
1214    // Shared test bodies
1215    // ====================================================================
1216
1217    async fn assert_remote_pattern(
1218        helper: &ActorRef<BufferHelperActor>,
1219        cx: &hyperactor::Client,
1220        remote: RdmaRemoteBuffer,
1221        size: usize,
1222        pattern: u8,
1223    ) -> Result<(), anyhow::Error> {
1224        let got = helper.read_contents(cx, Box::new(remote), 0, size).await?;
1225        assert_eq!(got, vec![pattern; size]);
1226        Ok(())
1227    }
1228
1229    /// Drive a single write from side A's buffer into side B's buffer
1230    /// and assert the destination now matches the pattern.
1231    async fn run_cross_actor_write(
1232        env: &TestEnv,
1233        src_dev: BufferDevice,
1234        dst_dev: BufferDevice,
1235        size: usize,
1236        pattern: u8,
1237        timeout_secs: u64,
1238    ) -> Result<(), anyhow::Error> {
1239        let src = env
1240            .helper_a
1241            .allocate(&env.client, size, src_dev, pattern)
1242            .await?;
1243        let dst = env.helper_b.allocate(&env.client, size, dst_dev, 0).await?;
1244        env.helper_a
1245            .submit(
1246                &env.client,
1247                vec![BufferHelperOp {
1248                    op_type: RdmaOpType::WriteFromLocal,
1249                    local_buf: src,
1250                    remote_buf: dst.clone(),
1251                }],
1252                timeout_secs,
1253            )
1254            .await?
1255            .map_err(|e| anyhow::anyhow!(e))?;
1256        assert_remote_pattern(&env.helper_b, &env.client, dst, size, pattern).await
1257    }
1258
1259    /// Drive a single read from side B's buffer into side A's buffer
1260    /// and assert the destination now matches the pattern.
1261    async fn run_cross_actor_read(
1262        env: &TestEnv,
1263        dst_dev: BufferDevice,
1264        src_dev: BufferDevice,
1265        size: usize,
1266        pattern: u8,
1267        timeout_secs: u64,
1268    ) -> Result<(), anyhow::Error> {
1269        let dst = env.helper_a.allocate(&env.client, size, dst_dev, 0).await?;
1270        let src = env
1271            .helper_b
1272            .allocate(&env.client, size, src_dev, pattern)
1273            .await?;
1274        env.helper_a
1275            .submit(
1276                &env.client,
1277                vec![BufferHelperOp {
1278                    op_type: RdmaOpType::ReadIntoLocal,
1279                    local_buf: dst.clone(),
1280                    remote_buf: src,
1281                }],
1282                timeout_secs,
1283            )
1284            .await?
1285            .map_err(|e| anyhow::anyhow!(e))?;
1286        assert_remote_pattern(&env.helper_a, &env.client, dst, size, pattern).await
1287    }
1288
1289    /// Drive both a write and a read in a single
1290    /// `IbvBackend::submit` batch — both ops target the same peer
1291    /// QP and so resolve to a single `ProcessOps` group. After the
1292    /// batch completes, side B's `write_dst` and side A's `read_dst`
1293    /// both contain their respective patterns.
1294    async fn run_multi_op_same_qp(
1295        env: &TestEnv,
1296        dev_a: BufferDevice,
1297        dev_b: BufferDevice,
1298        size: usize,
1299        timeout_secs: u64,
1300    ) -> Result<(), anyhow::Error> {
1301        const WRITE_PATTERN: u8 = 0xa1;
1302        const READ_PATTERN: u8 = 0xb2;
1303        let write_src = env
1304            .helper_a
1305            .allocate(&env.client, size, dev_a, WRITE_PATTERN)
1306            .await?;
1307        let write_dst = env.helper_b.allocate(&env.client, size, dev_b, 0).await?;
1308        let read_dst = env.helper_a.allocate(&env.client, size, dev_a, 0).await?;
1309        let read_src = env
1310            .helper_b
1311            .allocate(&env.client, size, dev_b, READ_PATTERN)
1312            .await?;
1313        env.helper_a
1314            .submit(
1315                &env.client,
1316                vec![
1317                    BufferHelperOp {
1318                        op_type: RdmaOpType::WriteFromLocal,
1319                        local_buf: write_src,
1320                        remote_buf: write_dst.clone(),
1321                    },
1322                    BufferHelperOp {
1323                        op_type: RdmaOpType::ReadIntoLocal,
1324                        local_buf: read_dst.clone(),
1325                        remote_buf: read_src,
1326                    },
1327                ],
1328                timeout_secs,
1329            )
1330            .await?
1331            .map_err(|e| anyhow::anyhow!(e))?;
1332        assert_remote_pattern(&env.helper_b, &env.client, write_dst, size, WRITE_PATTERN).await?;
1333        assert_remote_pattern(&env.helper_a, &env.client, read_dst, size, READ_PATTERN).await
1334    }
1335
1336    /// Drive a write + read between two buffers registered with the
1337    /// *same* `RdmaManagerActor` on the *same* device. Exercises the
1338    /// loopback path (`is_loopback = true`) where the active actor
1339    /// connects its QP to its own endpoint and skips the
1340    /// `CreatePeerQueuePair` round trip.
1341    async fn run_true_loopback(
1342        env: &TestEnv,
1343        dev: BufferDevice,
1344        size: usize,
1345    ) -> Result<(), anyhow::Error> {
1346        const PATTERN: u8 = 0x5d;
1347        let src = env
1348            .helper_a
1349            .allocate(&env.client, size, dev, PATTERN)
1350            .await?;
1351        let dst = env.helper_a.allocate(&env.client, size, dev, 0).await?;
1352        env.helper_a
1353            .submit(
1354                &env.client,
1355                vec![BufferHelperOp {
1356                    op_type: RdmaOpType::WriteFromLocal,
1357                    local_buf: src,
1358                    remote_buf: dst.clone(),
1359                }],
1360                5,
1361            )
1362            .await?
1363            .map_err(|e| anyhow::anyhow!(e))?;
1364        assert_remote_pattern(&env.helper_a, &env.client, dst, size, PATTERN).await
1365    }
1366
1367    // ====================================================================
1368    // Helpers
1369    // ====================================================================
1370
1371    fn require_rdma() {
1372        if list_all_devices().is_empty() {
1373            panic!("SKIPPED: no RDMA devices available");
1374        }
1375    }
1376
1377    fn require_cuda() {
1378        if !crate::is_cuda_available() {
1379            panic!("SKIPPED: CUDA not available");
1380        }
1381    }
1382
1383    // ====================================================================
1384    // Tests
1385    // ====================================================================
1386
1387    /// `register_remote_buffer` must populate the MR slot shared by
1388    /// every clone of the `KeepaliveLocalMemory` it is handed, so that
1389    /// later `resolve_local_mr` calls reuse the registered MR instead
1390    /// of registering the same region again.
1391    #[timed_test::async_timed_test(timeout_secs = 60)]
1392    async fn test_register_remote_buffer_fills_mr_slot() -> Result<(), anyhow::Error> {
1393        require_rdma();
1394        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1395        let buf: Box<[u8]> = vec![0u8; 1024].into_boxed_slice();
1396        let local = KeepaliveLocalMemory::new(Arc::new(buf));
1397        assert!(
1398            local.mr_slot().get().is_none(),
1399            "MR slot should be empty before registration",
1400        );
1401        RdmaManagerActor::local_handle(&env.client)
1402            .request_buffer(&env.client, local.clone())
1403            .await?;
1404        assert!(
1405            local.mr_slot().get().is_some(),
1406            "registration should populate the MR slot",
1407        );
1408        env.shutdown().await
1409    }
1410
1411    /// Cross-actor RDMA write over a single device (both sides target
1412    /// `cpu:0`). The two `RdmaManagerActor`s differ — this exercises
1413    /// the asymmetric `CreatePeerQueuePair` handshake even though the
1414    /// underlying device is shared.
1415    #[timed_test::async_timed_test(timeout_secs = 60)]
1416    async fn test_cross_actor_same_device_write() -> Result<(), anyhow::Error> {
1417        require_rdma();
1418        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1419        run_cross_actor_write(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0xa5, 5).await?;
1420        env.shutdown().await
1421    }
1422
1423    /// Cross-actor RDMA read over a single device.
1424    #[timed_test::async_timed_test(timeout_secs = 60)]
1425    async fn test_cross_actor_same_device_read() -> Result<(), anyhow::Error> {
1426        require_rdma();
1427        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1428        run_cross_actor_read(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x3c, 5).await?;
1429        env.shutdown().await
1430    }
1431
1432    /// True loopback write: both buffers registered with the same
1433    /// `RdmaManagerActor` on the same device. The `QueuePairActor`
1434    /// sees `is_loopback = true` and connects its QP to its own
1435    /// endpoint without going through `CreatePeerQueuePair`.
1436    #[timed_test::async_timed_test(timeout_secs = 60)]
1437    async fn test_loopback_write() -> Result<(), anyhow::Error> {
1438        require_rdma();
1439        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1440        run_true_loopback(&env, BufferDevice::Cpu, 32).await?;
1441        env.shutdown().await
1442    }
1443
1444    /// Cross-device write (cpu:0 → cpu:1).
1445    #[timed_test::async_timed_test(timeout_secs = 60)]
1446    async fn test_cross_device_write() -> Result<(), anyhow::Error> {
1447        require_rdma();
1448        let env = TestEnv::new(
1449            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
1450            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
1451        )
1452        .await?;
1453        run_cross_actor_write(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x77, 5).await?;
1454        env.shutdown().await
1455    }
1456
1457    /// Cross-device read (cpu:0 ← cpu:1).
1458    #[timed_test::async_timed_test(timeout_secs = 60)]
1459    async fn test_cross_device_read() -> Result<(), anyhow::Error> {
1460        require_rdma();
1461        let env = TestEnv::new(
1462            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
1463            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
1464        )
1465        .await?;
1466        run_cross_actor_read(&env, BufferDevice::Cpu, BufferDevice::Cpu, 32, 0x88, 5).await?;
1467        env.shutdown().await
1468    }
1469
1470    /// One write + one read in a single `IbvBackend::submit` batch.
1471    /// Both ops share the same `QpKey` so the manager groups them
1472    /// into a single `ProcessOps` dispatched to one `QueuePairActor`.
1473    #[timed_test::async_timed_test(timeout_secs = 60)]
1474    async fn test_multi_op_same_qp_cpu() -> Result<(), anyhow::Error> {
1475        require_rdma();
1476        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1477        run_multi_op_same_qp(&env, BufferDevice::Cpu, BufferDevice::Cpu, 64, 5).await?;
1478        env.shutdown().await
1479    }
1480
1481    /// Same as `test_multi_op_same_qp_cpu` but with 2 MiB CUDA buffers,
1482    /// pulled apart into a separate test because the buffer-size +
1483    /// device split is the only thing that differs.
1484    #[timed_test::async_timed_test(timeout_secs = 120)]
1485    async fn test_multi_op_same_qp_cuda() -> Result<(), anyhow::Error> {
1486        require_rdma();
1487        require_cuda();
1488        const SIZE: usize = 2 * 1024 * 1024;
1489        let env = TestEnv::new(
1490            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
1491            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
1492        )
1493        .await?;
1494        run_multi_op_same_qp(&env, BufferDevice::Cuda(0), BufferDevice::Cuda(1), SIZE, 10).await?;
1495        env.shutdown().await
1496    }
1497
1498    /// CUDA → CPU write.
1499    #[timed_test::async_timed_test(timeout_secs = 60)]
1500    async fn test_cuda_to_cpu_write() -> Result<(), anyhow::Error> {
1501        require_rdma();
1502        require_cuda();
1503        const SIZE: usize = 2 * 1024 * 1024;
1504        let env = TestEnv::new(
1505            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
1506            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
1507        )
1508        .await?;
1509        run_cross_actor_write(
1510            &env,
1511            BufferDevice::Cuda(0),
1512            BufferDevice::Cpu,
1513            SIZE,
1514            0x9b,
1515            10,
1516        )
1517        .await?;
1518        env.shutdown().await
1519    }
1520
1521    /// CUDA → CPU read (source is the CUDA side).
1522    #[timed_test::async_timed_test(timeout_secs = 60)]
1523    async fn test_cuda_to_cpu_read() -> Result<(), anyhow::Error> {
1524        require_rdma();
1525        require_cuda();
1526        const SIZE: usize = 2 * 1024 * 1024;
1527        let env = TestEnv::new(
1528            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
1529            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
1530        )
1531        .await?;
1532        run_cross_actor_read(
1533            &env,
1534            BufferDevice::Cpu,
1535            BufferDevice::Cuda(1),
1536            SIZE,
1537            0x37,
1538            10,
1539        )
1540        .await?;
1541        env.shutdown().await
1542    }
1543
1544    /// CPU → CUDA write.
1545    #[timed_test::async_timed_test(timeout_secs = 60)]
1546    async fn test_cpu_to_cuda_write() -> Result<(), anyhow::Error> {
1547        require_rdma();
1548        require_cuda();
1549        const SIZE: usize = 2 * 1024 * 1024;
1550        let env = TestEnv::new(
1551            IbvConfig::targeting(IbvDeviceTarget::cpu(0)),
1552            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
1553        )
1554        .await?;
1555        run_cross_actor_write(
1556            &env,
1557            BufferDevice::Cpu,
1558            BufferDevice::Cuda(1),
1559            SIZE,
1560            0x5a,
1561            10,
1562        )
1563        .await?;
1564        env.shutdown().await
1565    }
1566
1567    /// CPU → CUDA read (source is the CPU side).
1568    #[timed_test::async_timed_test(timeout_secs = 60)]
1569    async fn test_cpu_to_cuda_read() -> Result<(), anyhow::Error> {
1570        require_rdma();
1571        require_cuda();
1572        const SIZE: usize = 2 * 1024 * 1024;
1573        let env = TestEnv::new(
1574            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
1575            IbvConfig::targeting(IbvDeviceTarget::cpu(1)),
1576        )
1577        .await?;
1578        run_cross_actor_read(
1579            &env,
1580            BufferDevice::Cuda(0),
1581            BufferDevice::Cpu,
1582            SIZE,
1583            0x4e,
1584            10,
1585        )
1586        .await?;
1587        env.shutdown().await
1588    }
1589
1590    /// CUDA → CUDA write.
1591    #[timed_test::async_timed_test(timeout_secs = 60)]
1592    async fn test_cuda_to_cuda_write() -> Result<(), anyhow::Error> {
1593        require_rdma();
1594        require_cuda();
1595        const SIZE: usize = 2 * 1024 * 1024;
1596        let env = TestEnv::new(
1597            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
1598            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
1599        )
1600        .await?;
1601        run_cross_actor_write(
1602            &env,
1603            BufferDevice::Cuda(0),
1604            BufferDevice::Cuda(1),
1605            SIZE,
1606            0xee,
1607            10,
1608        )
1609        .await?;
1610        env.shutdown().await
1611    }
1612
1613    /// CUDA → CUDA read.
1614    #[timed_test::async_timed_test(timeout_secs = 60)]
1615    async fn test_cuda_to_cuda_read() -> Result<(), anyhow::Error> {
1616        require_rdma();
1617        require_cuda();
1618        const SIZE: usize = 2 * 1024 * 1024;
1619        let env = TestEnv::new(
1620            IbvConfig::targeting(IbvDeviceTarget::gpu(0)),
1621            IbvConfig::targeting(IbvDeviceTarget::gpu(1)),
1622        )
1623        .await?;
1624        run_cross_actor_read(
1625            &env,
1626            BufferDevice::Cuda(0),
1627            BufferDevice::Cuda(1),
1628            SIZE,
1629            0x42,
1630            10,
1631        )
1632        .await?;
1633        env.shutdown().await
1634    }
1635
1636    /// CUDA buffers with `IbvQpType::Standard` (no mlx5dv).
1637    /// Exercises the per-buffer dmabuf MR-registration path: without
1638    /// mlx5dv the manager cannot use indirect mkeys via segment
1639    /// scanning and instead registers each buffer as a standalone
1640    /// dmabuf MR (`ibv_reg_dmabuf_mr`).
1641    #[timed_test::async_timed_test(timeout_secs = 60)]
1642    async fn test_standard_qp_cuda_dmabuf_fallback() -> Result<(), anyhow::Error> {
1643        require_rdma();
1644        require_cuda();
1645        const SIZE: usize = 16 * 1024 * 1024;
1646        let mut config_a = IbvConfig::targeting(IbvDeviceTarget::gpu(0));
1647        config_a.qp_type = IbvQpType::Standard;
1648        let mut config_b = IbvConfig::targeting(IbvDeviceTarget::gpu(1));
1649        config_b.qp_type = IbvQpType::Standard;
1650        let env = TestEnv::new(config_a, config_b).await?;
1651        run_cross_actor_write(
1652            &env,
1653            BufferDevice::Cuda(0),
1654            BufferDevice::Cuda(1),
1655            SIZE,
1656            0x33,
1657            10,
1658        )
1659        .await?;
1660        env.shutdown().await
1661    }
1662
1663    /// Two `IbvBackend::submit` calls back-to-back through the same
1664    /// helper. The second batch reuses the cached `QueuePairActor`
1665    /// from the first (the manager's `qp_handles` entry persists
1666    /// across submits).
1667    #[timed_test::async_timed_test(timeout_secs = 60)]
1668    async fn test_multi_batch_same_qp() -> Result<(), anyhow::Error> {
1669        require_rdma();
1670        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1671        let src1 = env
1672            .helper_a
1673            .allocate(&env.client, 32, BufferDevice::Cpu, 0xa1)
1674            .await?;
1675        let dst1 = env
1676            .helper_b
1677            .allocate(&env.client, 32, BufferDevice::Cpu, 0)
1678            .await?;
1679        env.helper_a
1680            .submit(
1681                &env.client,
1682                vec![BufferHelperOp {
1683                    op_type: RdmaOpType::WriteFromLocal,
1684                    local_buf: src1,
1685                    remote_buf: dst1.clone(),
1686                }],
1687                5,
1688            )
1689            .await?
1690            .map_err(|e| anyhow::anyhow!(e))?;
1691        assert_remote_pattern(&env.helper_b, &env.client, dst1, 32, 0xa1).await?;
1692
1693        let src2 = env
1694            .helper_a
1695            .allocate(&env.client, 32, BufferDevice::Cpu, 0xb2)
1696            .await?;
1697        let dst2 = env
1698            .helper_b
1699            .allocate(&env.client, 32, BufferDevice::Cpu, 0)
1700            .await?;
1701        env.helper_a
1702            .submit(
1703                &env.client,
1704                vec![BufferHelperOp {
1705                    op_type: RdmaOpType::WriteFromLocal,
1706                    local_buf: src2,
1707                    remote_buf: dst2.clone(),
1708                }],
1709                5,
1710            )
1711            .await?
1712            .map_err(|e| anyhow::anyhow!(e))?;
1713        assert_remote_pattern(&env.helper_b, &env.client, dst2, 32, 0xb2).await?;
1714        env.shutdown().await
1715    }
1716
1717    /// Single submit batch with ops landing on multiple `QpKey`
1718    /// groups: loopback (helper_a → helper_a), cross-actor cpu↔cpu,
1719    /// cpu↔cuda, cuda↔cpu, cuda↔cuda. Exercises the manager's
1720    /// per-QP slicing and concurrent multi-QP dispatch.
1721    #[timed_test::async_timed_test(timeout_secs = 120)]
1722    async fn test_multi_op_multi_qp() -> Result<(), anyhow::Error> {
1723        require_rdma();
1724        require_cuda();
1725        const SIZE: usize = 2 * 1024 * 1024;
1726
1727        let env = TestEnv::same_config(IbvConfig::default()).await?;
1728
1729        const LOOPBACK_PAT: u8 = 0x11;
1730        let lb_src = env
1731            .helper_a
1732            .allocate(&env.client, SIZE, BufferDevice::Cpu, LOOPBACK_PAT)
1733            .await?;
1734        let lb_dst = env
1735            .helper_a
1736            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1737            .await?;
1738
1739        const CC_PAT: u8 = 0x22;
1740        let cc_src = env
1741            .helper_a
1742            .allocate(&env.client, SIZE, BufferDevice::Cpu, CC_PAT)
1743            .await?;
1744        let cc_dst = env
1745            .helper_b
1746            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1747            .await?;
1748
1749        const CG_PAT: u8 = 0x33;
1750        let cg_src = env
1751            .helper_a
1752            .allocate(&env.client, SIZE, BufferDevice::Cpu, CG_PAT)
1753            .await?;
1754        let cg_dst = env
1755            .helper_b
1756            .allocate(&env.client, SIZE, BufferDevice::Cuda(1), 0)
1757            .await?;
1758
1759        const GC_PAT: u8 = 0x44;
1760        let gc_src = env
1761            .helper_a
1762            .allocate(&env.client, SIZE, BufferDevice::Cuda(0), GC_PAT)
1763            .await?;
1764        let gc_dst = env
1765            .helper_b
1766            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1767            .await?;
1768
1769        const GG_PAT: u8 = 0x55;
1770        let gg_src = env
1771            .helper_b
1772            .allocate(&env.client, SIZE, BufferDevice::Cuda(1), GG_PAT)
1773            .await?;
1774        let gg_dst = env
1775            .helper_a
1776            .allocate(&env.client, SIZE, BufferDevice::Cuda(0), 0)
1777            .await?;
1778
1779        env.helper_a
1780            .submit(
1781                &env.client,
1782                vec![
1783                    BufferHelperOp {
1784                        op_type: RdmaOpType::WriteFromLocal,
1785                        local_buf: lb_src,
1786                        remote_buf: lb_dst.clone(),
1787                    },
1788                    BufferHelperOp {
1789                        op_type: RdmaOpType::WriteFromLocal,
1790                        local_buf: cc_src,
1791                        remote_buf: cc_dst.clone(),
1792                    },
1793                    BufferHelperOp {
1794                        op_type: RdmaOpType::WriteFromLocal,
1795                        local_buf: cg_src,
1796                        remote_buf: cg_dst.clone(),
1797                    },
1798                    BufferHelperOp {
1799                        op_type: RdmaOpType::WriteFromLocal,
1800                        local_buf: gc_src,
1801                        remote_buf: gc_dst.clone(),
1802                    },
1803                    BufferHelperOp {
1804                        op_type: RdmaOpType::ReadIntoLocal,
1805                        local_buf: gg_dst.clone(),
1806                        remote_buf: gg_src,
1807                    },
1808                ],
1809                30,
1810            )
1811            .await?
1812            .map_err(|e| anyhow::anyhow!(e))?;
1813
1814        assert_remote_pattern(&env.helper_a, &env.client, lb_dst, SIZE, LOOPBACK_PAT).await?;
1815        assert_remote_pattern(&env.helper_b, &env.client, cc_dst, SIZE, CC_PAT).await?;
1816        assert_remote_pattern(&env.helper_b, &env.client, cg_dst, SIZE, CG_PAT).await?;
1817        assert_remote_pattern(&env.helper_b, &env.client, gc_dst, SIZE, GC_PAT).await?;
1818        assert_remote_pattern(&env.helper_a, &env.client, gg_dst, SIZE, GG_PAT).await?;
1819
1820        env.shutdown().await
1821    }
1822
1823    /// Force the timeout branch in `IbvBackend::submit`. A near-zero
1824    /// timeout fires before any per-op replies arrive, so the
1825    /// aggregated error reports the timeout terminal cause.
1826    #[timed_test::async_timed_test(timeout_secs = 60)]
1827    async fn test_submit_timeout() -> Result<(), anyhow::Error> {
1828        require_rdma();
1829        const SIZE: usize = 1024 * 1024;
1830        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1831        let src = env
1832            .helper_a
1833            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0x77)
1834            .await?;
1835        let dst = env
1836            .helper_b
1837            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1838            .await?;
1839        let result = env
1840            .helper_a
1841            .submit(
1842                &env.client,
1843                vec![BufferHelperOp {
1844                    op_type: RdmaOpType::WriteFromLocal,
1845                    local_buf: src,
1846                    remote_buf: dst,
1847                }],
1848                0,
1849            )
1850            .await?;
1851        let err = result.expect_err("expected submit to time out");
1852        assert!(
1853            err.contains("submit timed out"),
1854            "unexpected error message: {err}",
1855        );
1856        env.shutdown().await
1857    }
1858
1859    /// Submit a batch with a bogus op in the middle. RC
1860    /// completions fire in posting order, so the good op before it
1861    /// completes normally, the bogus op fails with `REM_ACCESS_ERR`
1862    /// (it puts the QP into error state), and the good op after it
1863    /// gets flushed with `WC_WR_FLUSH_ERR`. Verifies (a) op 0 is
1864    /// absent from the aggregated error and its bytes transferred,
1865    /// (b) ops 1 and 2 both appear in the error, and (c) op 2's
1866    /// destination was *not* written (the flush meant nothing was
1867    /// transferred).
1868    #[timed_test::async_timed_test(timeout_secs = 60)]
1869    async fn test_partial_failure_batch() -> Result<(), anyhow::Error> {
1870        require_rdma();
1871        const SIZE: usize = 32;
1872        let env = TestEnv::same_config(IbvConfig::targeting(IbvDeviceTarget::cpu(0))).await?;
1873
1874        const GOOD_PAT: u8 = 0xc3;
1875        const POST_FLUSH_PAT: u8 = 0xde;
1876
1877        let good_src_0 = env
1878            .helper_a
1879            .allocate(&env.client, SIZE, BufferDevice::Cpu, GOOD_PAT)
1880            .await?;
1881        let good_dst_0 = env
1882            .helper_b
1883            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1884            .await?;
1885
1886        let bogus_src = env
1887            .helper_a
1888            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0xee)
1889            .await?;
1890        let real_remote = env
1891            .helper_b
1892            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1893            .await?;
1894        let mut bogus_remote = real_remote.clone();
1895        let bufs = [
1896            bogus_remote
1897                .backends
1898                .mlx
1899                .as_mut()
1900                .map(|ctx| &mut ctx.buffer),
1901            bogus_remote
1902                .backends
1903                .efa
1904                .as_mut()
1905                .map(|ctx| &mut ctx.buffer),
1906        ];
1907        for buf in bufs.into_iter().flatten() {
1908            buf.rkey = 0xdead_beef;
1909            buf.addr = 0xdead_0000;
1910        }
1911
1912        let post_flush_src = env
1913            .helper_a
1914            .allocate(&env.client, SIZE, BufferDevice::Cpu, POST_FLUSH_PAT)
1915            .await?;
1916        let post_flush_dst = env
1917            .helper_b
1918            .allocate(&env.client, SIZE, BufferDevice::Cpu, 0)
1919            .await?;
1920
1921        let result = env
1922            .helper_a
1923            .submit(
1924                &env.client,
1925                vec![
1926                    BufferHelperOp {
1927                        op_type: RdmaOpType::WriteFromLocal,
1928                        local_buf: good_src_0,
1929                        remote_buf: good_dst_0.clone(),
1930                    },
1931                    BufferHelperOp {
1932                        op_type: RdmaOpType::WriteFromLocal,
1933                        local_buf: bogus_src,
1934                        remote_buf: bogus_remote,
1935                    },
1936                    BufferHelperOp {
1937                        op_type: RdmaOpType::WriteFromLocal,
1938                        local_buf: post_flush_src,
1939                        remote_buf: post_flush_dst.clone(),
1940                    },
1941                ],
1942                10,
1943            )
1944            .await?;
1945        let err = result.expect_err("expected at least one op to fail");
1946        let rem_access = format!(
1947            "status={:?}",
1948            rdmaxcel_sys::ibv_wc_status::IBV_WC_REM_ACCESS_ERR,
1949        );
1950        let wr_flush = format!(
1951            "status={:?}",
1952            rdmaxcel_sys::ibv_wc_status::IBV_WC_WR_FLUSH_ERR,
1953        );
1954        assert!(
1955            !err.contains("op 0:"),
1956            "op 0 should not appear in error: {err}",
1957        );
1958        let op1 = err
1959            .split("\n  ")
1960            .find(|line| line.starts_with("op 1:"))
1961            .unwrap_or_else(|| panic!("expected op 1 line in error: {err}"));
1962        assert!(
1963            op1.contains("completion failed") && op1.contains(&rem_access),
1964            "expected op 1 to fail with REM_ACCESS_ERR: {op1}",
1965        );
1966        let op2 = err
1967            .split("\n  ")
1968            .find(|line| line.starts_with("op 2:"))
1969            .unwrap_or_else(|| panic!("expected op 2 line in error: {err}"));
1970        assert!(
1971            op2.contains("completion failed") && op2.contains(&wr_flush),
1972            "expected op 2 to be flushed with WR_FLUSH_ERR: {op2}",
1973        );
1974
1975        assert_remote_pattern(&env.helper_b, &env.client, good_dst_0, SIZE, GOOD_PAT).await?;
1976        // The flushed op was never transferred; destination stays zero.
1977        assert_remote_pattern(&env.helper_b, &env.client, post_flush_dst, SIZE, 0).await?;
1978
1979        env.shutdown().await
1980    }
1981}