Skip to main content

monarch_rdma/backend/ibverbs/
queue_pair.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 queue pair, doorbell, and completion polling.
10//!
11//! An [`IbvQueuePair`] encapsulates the send and receive queues, completion
12//! queues, and other resources needed for RDMA communication. It provides
13//! methods for establishing connections and performing RDMA operations.
14
15/// Maximum size for a single RDMA operation in bytes (1 GiB).
16const MAX_RDMA_MSG_SIZE: usize = 1024 * 1024 * 1024;
17
18use std::collections::HashMap;
19use std::collections::HashSet;
20use std::collections::VecDeque;
21use std::io::Error;
22use std::result::Result;
23use std::sync::Arc;
24use std::time::Duration;
25use std::time::Instant;
26
27use async_trait::async_trait;
28use backoff::ExponentialBackoff;
29use backoff::ExponentialBackoffBuilder;
30use backoff::backoff::Backoff;
31use hyperactor::Actor;
32use hyperactor::ActorId;
33use hyperactor::ActorRef;
34use hyperactor::Context;
35use hyperactor::Endpoint as _;
36use hyperactor::Handler;
37use hyperactor::Instance;
38use hyperactor::PortHandle;
39use hyperactor::actor::Referable;
40use hyperactor::actor::RemoteHandles;
41use hyperactor::context::Mailbox;
42use serde::Deserialize;
43use serde::Serialize;
44use typeuri::Named;
45
46use super::IbvBuffer;
47use super::IbvOp;
48use super::domain::IbvDomain;
49use super::domain::IbvDomainImpl;
50use super::manager_actor::CreatePeerQueuePair;
51use super::memory_region::IbvMemoryRegionView;
52use super::primitives::Gid;
53use super::primitives::GidScope;
54use super::primitives::GidType;
55use super::primitives::IbvConfig;
56use super::primitives::IbvCq;
57use super::primitives::IbvOperation;
58use super::primitives::IbvQp;
59use super::primitives::IbvQpInfo;
60use super::primitives::IbvWc;
61use super::primitives::resolve_qp_type;
62use crate::RdmaOpType;
63
64/// A per-work-request completion failure: a work request completed with
65/// a non-success `ibv_wc_status`. Carries the `wr_id`, status, and vendor
66/// error code so callers can correlate the failure to the request and
67/// match on the status without string parsing. The QP may or may not be
68/// in error state.
69#[derive(Debug)]
70pub struct WorkRequestError {
71    pub wr_id: u64,
72    pub status: rdmaxcel_sys::ibv_wc_status::Type,
73    pub vendor_err: u32,
74    message: String,
75}
76
77impl std::fmt::Display for WorkRequestError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str(&self.message)
80    }
81}
82
83impl std::error::Error for WorkRequestError {}
84
85impl WorkRequestError {
86    /// Returns `true` when the completion status is `IBV_WC_WR_FLUSH_ERR`,
87    /// which typically indicates a secondary failure after the QP entered
88    /// error state due to a different work request's failure.
89    pub fn is_wr_flush_err(&self) -> bool {
90        self.status == rdmaxcel_sys::ibv_wc_status::IBV_WC_WR_FLUSH_ERR
91    }
92
93    #[cfg(test)]
94    pub(super) fn for_test(wr_id: u64, message: &str) -> Self {
95        Self {
96            wr_id,
97            status: rdmaxcel_sys::ibv_wc_status::IBV_WC_GENERAL_ERR,
98            vendor_err: 0,
99            message: message.to_string(),
100        }
101    }
102}
103
104/// A CQ-level poll failure from [`IbvQueuePair::poll_completion`]:
105/// `ibv_poll_cq` itself failed and the completion queue is no longer
106/// usable. The owning QP should be treated as poisoned.
107#[derive(Debug)]
108pub struct PollCompletionError {
109    message: String,
110}
111
112impl std::fmt::Display for PollCompletionError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(&self.message)
115    }
116}
117
118impl std::error::Error for PollCompletionError {}
119
120impl PollCompletionError {
121    #[cfg(test)]
122    pub(super) fn for_test(message: &str) -> Self {
123        Self {
124            message: message.to_string(),
125        }
126    }
127}
128
129/// A doorbell trigger for batched RDMA operations.
130///
131/// Rings the hardware doorbell to execute previously enqueued work requests.
132#[derive(Debug, Named, Clone, Serialize, Deserialize)]
133pub struct DoorBell {
134    pub src_ptr: usize,
135    pub dst_ptr: usize,
136    pub size: usize,
137}
138wirevalue::register_type!(DoorBell);
139
140/// Specifies which completion queue to poll.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub enum PollTarget {
143    Send,
144    Recv,
145}
146
147/// The legacy single-type queue pair, retained while the backends migrate onto
148/// the [`IbvQueuePair`] trait.
149pub mod legacy;
150
151/// Identifies a per-peer queue pair held by one
152/// [`super::manager_actor::IbvManagerActor`]. The same conceptual
153/// QP is referenced by two distinct keys, one from each side: each
154/// manager stores the local view (its own device, the peer's actor
155/// id, the peer's device).
156#[derive(Clone, Hash, Eq, PartialEq, Debug, Serialize, Deserialize, Named)]
157pub(super) struct QpKey {
158    pub(super) self_device: String,
159    pub(super) other_id: ActorId,
160    pub(super) other_device: String,
161}
162
163// =====================================================================
164// QueuePairActor
165// =====================================================================
166//
167// Owns one simplex queue pair to a peer. The active side spawns a
168// `QueuePairActor` that owns and operates the local QP; the peer's
169// manager eagerly creates its mirror QP during the handshake, stores
170// it as a passive endpoint, and never wraps it in an actor. If the
171// peer later wants to RDMA us, it spawns its own `QueuePairActor`
172// locally, which creates a fresh pair the same way.
173
174/// A NIC-backend queue pair: the unit of RDMA communication between two
175/// endpoints, and the operations a [`QueuePairActor`] performs on it. Each
176/// [`IbvDomainImpl`] names its concrete queue-pair type via
177/// [`IbvDomainImpl::QueuePair`](super::domain::IbvDomainImpl::QueuePair) and builds
178/// it through [`Self::new`].
179pub trait IbvQueuePair: std::fmt::Debug + Send + Sync + 'static + Sized {
180    /// Creates a queue pair against `domain` in the RESET state;
181    /// [`Self::connect`] transitions it to RTS before use.
182    ///
183    /// # Safety
184    ///
185    /// `domain`'s PD (`domain.as_ptr()`) must be null or a valid protection
186    /// domain. Callers must ensure the PD outlives the QP; the easiest way to
187    /// do this is for implementers to store a clone of `domain.pd()` (an
188    /// `Arc<IbvPd>`) inside the QP.
189    unsafe fn new<I: IbvDomainImpl<QueuePair = Self>>(
190        domain: &IbvDomain<I>,
191        config: IbvConfig,
192    ) -> Result<Self, anyhow::Error>;
193
194    /// Transitions the QP through `INIT -> RTR -> RTS`, connected to `info`.
195    fn connect(&mut self, info: &IbvQpInfo) -> Result<(), anyhow::Error>;
196
197    /// Returns the local endpoint other peers need in order to connect to this
198    /// QP.
199    fn get_qp_info(&mut self) -> Result<IbvQpInfo, anyhow::Error>;
200
201    /// Returns the current `ibv_qp_state` of the QP.
202    fn state(&mut self) -> Result<u32, anyhow::Error>;
203
204    /// Post an RDMA WRITE of `local_src` into `remote_dst`. The request may
205    /// be chunked into multiple WRs. This method returns the list of WR ids
206    /// that were posted.
207    fn put(
208        &mut self,
209        remote_dst: IbvBuffer,
210        local_src: IbvBuffer,
211    ) -> Result<Vec<u64>, anyhow::Error>;
212
213    /// Post an RDMA READ of `remote_src` into `local_dst`. The request may
214    /// be chunked into multiple WRs. This method returns the list of WR ids
215    /// that were posted.
216    fn get(
217        &mut self,
218        local_dst: IbvBuffer,
219        remote_src: IbvBuffer,
220    ) -> Result<Vec<u64>, anyhow::Error>;
221
222    /// Poll `target`'s completion queue for a single work completion.
223    ///
224    /// # Returns
225    ///
226    /// * `Ok(None)` — the CQ is currently empty.
227    /// * `Ok(Some(Ok(wc)))` — a completion landed with success status.
228    /// * `Ok(Some(Err(_)))` — a completion landed with a non-success
229    ///   status; the [`WorkRequestError`] names the failed request.
230    /// * `Err(_)` — `ibv_poll_cq` itself failed; the QP should be treated
231    ///   as poisoned.
232    fn poll_completion(
233        &mut self,
234        target: PollTarget,
235    ) -> Result<Option<Result<IbvWc, WorkRequestError>>, PollCompletionError>;
236}
237
238impl IbvQueuePair for legacy::IbvQueuePair {
239    unsafe fn new<I: IbvDomainImpl<QueuePair = Self>>(
240        domain: &IbvDomain<I>,
241        config: IbvConfig,
242    ) -> Result<Self, anyhow::Error> {
243        legacy::IbvQueuePair::new(domain, config)
244    }
245
246    fn connect(&mut self, info: &IbvQpInfo) -> Result<(), anyhow::Error> {
247        legacy::IbvQueuePair::connect(self, info)
248    }
249
250    fn get_qp_info(&mut self) -> Result<IbvQpInfo, anyhow::Error> {
251        legacy::IbvQueuePair::get_qp_info(self)
252    }
253
254    fn state(&mut self) -> Result<u32, anyhow::Error> {
255        legacy::IbvQueuePair::state(self)
256    }
257
258    fn put(
259        &mut self,
260        remote_dst: IbvBuffer,
261        local_src: IbvBuffer,
262    ) -> Result<Vec<u64>, anyhow::Error> {
263        legacy::IbvQueuePair::put(self, local_src, remote_dst)
264    }
265
266    fn get(
267        &mut self,
268        local_dst: IbvBuffer,
269        remote_src: IbvBuffer,
270    ) -> Result<Vec<u64>, anyhow::Error> {
271        legacy::IbvQueuePair::get(self, local_dst, remote_src)
272    }
273
274    fn poll_completion(
275        &mut self,
276        target: PollTarget,
277    ) -> Result<Option<Result<IbvWc, WorkRequestError>>, PollCompletionError> {
278        legacy::IbvQueuePair::poll_completion(self, target)
279    }
280}
281
282/// Queries the local endpoint info for `qp`, whose device `context` and the QP
283/// `config` (port, PSN) describe the connection. `gid` is the port's source GID.
284///
285/// # Safety
286///
287/// `qp` must be a live `ibv_qp` (non-null) and `context` must be its live device
288/// context.
289pub(super) unsafe fn get_qp_info(
290    qp: *mut rdmaxcel_sys::ibv_qp,
291    context: *mut rdmaxcel_sys::ibv_context,
292    config: &IbvConfig,
293    gid: Gid,
294) -> Result<IbvQpInfo, anyhow::Error> {
295    let mut port_attr = rdmaxcel_sys::ibv_port_attr::default();
296    // SAFETY: `context` is a live device context (caller contract); the
297    // out-param is a writable, properly aligned `ibv_port_attr`. `ibv_query_port`
298    // returns the errno on failure.
299    let errno = unsafe {
300        rdmaxcel_sys::ibv_query_port(
301            context,
302            config.port_num,
303            &mut port_attr as *mut rdmaxcel_sys::ibv_port_attr as *mut _,
304        )
305    };
306    if errno != 0 {
307        return Err(anyhow::anyhow!(
308            "failed to query port attributes: {}",
309            Error::from_raw_os_error(errno)
310        ));
311    }
312
313    // SAFETY: `qp` is a live `ibv_qp` (caller contract).
314    let qp_num = unsafe { (*qp).qp_num };
315    Ok(IbvQpInfo {
316        qp_num,
317        lid: port_attr.lid,
318        gid: Some(gid),
319        psn: config.psn,
320    })
321}
322
323/// Returns the current `ibv_qp_state` of `qp`.
324///
325/// # Safety
326///
327/// `qp` must be a live `ibv_qp` (non-null).
328unsafe fn state(qp: *mut rdmaxcel_sys::ibv_qp) -> Result<u32, anyhow::Error> {
329    let mut qp_attr = rdmaxcel_sys::ibv_qp_attr::default();
330    let mut qp_init_attr = rdmaxcel_sys::ibv_qp_init_attr::default();
331    let mask = rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_STATE;
332    // SAFETY: `qp` wraps a live `ibv_qp` (caller contract); the out-params are
333    // writable, properly aligned attr structs. `ibv_query_qp` returns the errno.
334    let errno =
335        unsafe { rdmaxcel_sys::ibv_query_qp(qp, &mut qp_attr, mask.0 as i32, &mut qp_init_attr) };
336    if errno != 0 {
337        return Err(anyhow::anyhow!(
338            "failed to query QP state: {}",
339            Error::from_raw_os_error(errno)
340        ));
341    }
342    Ok(qp_attr.qp_state)
343}
344
345/// Transitions `qp` through `INIT -> RTR -> RTS`, connected to `info`, granting
346/// remote peers `access_flags` and using the connection parameters in `config`.
347/// `sgid_index` is the local source-GID table index, used as the address
348/// handle's `sgid_index` when `info` carries a (remote) GID.
349///
350/// # Safety
351///
352/// `qp` must be a live `ibv_qp` (non-null).
353pub(super) unsafe fn connect(
354    qp: *mut rdmaxcel_sys::ibv_qp,
355    config: &IbvConfig,
356    access_flags: i32,
357    info: &IbvQpInfo,
358    sgid_index: u8,
359) -> Result<(), anyhow::Error> {
360    // Transition to INIT.
361    let mut qp_attr = rdmaxcel_sys::ibv_qp_attr {
362        qp_state: rdmaxcel_sys::ibv_qp_state::IBV_QPS_INIT,
363        qp_access_flags: access_flags as u32,
364        pkey_index: config.pkey_index,
365        port_num: config.port_num,
366        ..Default::default()
367    };
368    let mask = rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_STATE
369        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_PKEY_INDEX
370        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_PORT
371        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_ACCESS_FLAGS;
372    // SAFETY: `qp` is a live `ibv_qp` (caller contract); `qp_attr` is a valid
373    // `ibv_qp_attr` whose populated fields match `mask`. `ibv_modify_qp` returns
374    // the errno.
375    let errno = unsafe { rdmaxcel_sys::ibv_modify_qp(qp, &mut qp_attr, mask.0 as i32) };
376    if errno != 0 {
377        return Err(anyhow::anyhow!(
378            "failed to transition QP to INIT: {}",
379            Error::from_raw_os_error(errno)
380        ));
381    }
382
383    // Transition to RTR (Ready to Receive).
384    let mut qp_attr = rdmaxcel_sys::ibv_qp_attr {
385        qp_state: rdmaxcel_sys::ibv_qp_state::IBV_QPS_RTR,
386        path_mtu: config.path_mtu,
387        dest_qp_num: info.qp_num,
388        rq_psn: info.psn,
389        max_dest_rd_atomic: config.max_dest_rd_atomic,
390        min_rnr_timer: config.min_rnr_timer,
391        ah_attr: rdmaxcel_sys::ibv_ah_attr {
392            dlid: info.lid,
393            sl: 0,
394            src_path_bits: 0,
395            port_num: config.port_num,
396            grh: Default::default(),
397            ..Default::default()
398        },
399        ..Default::default()
400    };
401    if let Some(gid) = info.gid {
402        qp_attr.ah_attr.is_global = 1;
403        qp_attr.ah_attr.grh.dgid = rdmaxcel_sys::ibv_gid::from(gid);
404        qp_attr.ah_attr.grh.hop_limit = 0xff;
405        qp_attr.ah_attr.grh.sgid_index = sgid_index;
406    } else {
407        qp_attr.ah_attr.is_global = 0;
408    }
409    let mask = rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_STATE
410        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_AV
411        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_PATH_MTU
412        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_DEST_QPN
413        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_RQ_PSN
414        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_MAX_DEST_RD_ATOMIC
415        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_MIN_RNR_TIMER;
416    // SAFETY: as for the INIT transition above.
417    let errno = unsafe { rdmaxcel_sys::ibv_modify_qp(qp, &mut qp_attr, mask.0 as i32) };
418    if errno != 0 {
419        return Err(anyhow::anyhow!(
420            "failed to transition QP to RTR: {}",
421            Error::from_raw_os_error(errno)
422        ));
423    }
424
425    // Transition to RTS (Ready to Send).
426    let mut qp_attr = rdmaxcel_sys::ibv_qp_attr {
427        qp_state: rdmaxcel_sys::ibv_qp_state::IBV_QPS_RTS,
428        sq_psn: config.psn,
429        max_rd_atomic: config.max_rd_atomic,
430        retry_cnt: config.retry_cnt,
431        rnr_retry: config.rnr_retry,
432        timeout: config.qp_timeout,
433        ..Default::default()
434    };
435    let mask = rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_STATE
436        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_TIMEOUT
437        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_RETRY_CNT
438        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_SQ_PSN
439        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_RNR_RETRY
440        | rdmaxcel_sys::ibv_qp_attr_mask::IBV_QP_MAX_QP_RD_ATOMIC;
441    // SAFETY: as for the INIT transition above.
442    let errno = unsafe { rdmaxcel_sys::ibv_modify_qp(qp, &mut qp_attr, mask.0 as i32) };
443    if errno != 0 {
444        return Err(anyhow::anyhow!(
445            "failed to transition QP to RTS: {}",
446            Error::from_raw_os_error(errno)
447        ));
448    }
449    Ok(())
450}
451
452/// An RDMA reliable-connected (RC) queue pair built on plain ibverbs
453/// (`ibv_post_send`), independent of any device-specific verbs.
454///
455/// Single-owner: it owns the [`IbvQp`] — which in turn owns its two completion
456/// queues and the protection domain — and destroys them on drop, so the type is
457/// intentionally `!Clone`. The device context and completion queues are reached
458/// through the [`IbvQp`].
459#[derive(Debug)]
460pub struct RCQueuePair {
461    qp: IbvQp,
462    config: IbvConfig,
463    /// The source GID (carrying its table index), resolved from the owning
464    /// device's `IbvDeviceInfo` at construction: the first global RoCE v2 GID on
465    /// `config.port_num`.
466    gid: Gid,
467    /// Remote-access flags granted to peers at connect time, taken from the
468    /// owning domain at construction.
469    access_flags: i32,
470    /// Monotonic work-request id, handed out one per posted WR. Standard
471    /// ibverbs carries no internal counter, so the QP tracks its own.
472    next_wr_id: u64,
473}
474
475impl RCQueuePair {
476    /// Assembles an `RCQueuePair` that owns `qp` (and, through it, its
477    /// completion queues, protection domain, and device context). `access_flags`
478    /// is granted to peers at [`Self::connect`]; it is taken from the owning
479    /// domain by the caller.
480    ///
481    /// # Safety
482    ///
483    /// `qp` must wrap a live, non-null `ibv_qp`, and everything reached through
484    /// it — its send/recv completion queues, protection domain, and device
485    /// context — must likewise be non-null and valid: the data-path verbs invoke
486    /// them without re-checking.
487    pub(super) unsafe fn from_qp(
488        qp: IbvQp,
489        config: IbvConfig,
490        gid: Gid,
491        access_flags: i32,
492    ) -> Self {
493        RCQueuePair {
494            qp,
495            config,
496            gid,
497            access_flags,
498            next_wr_id: 0,
499        }
500    }
501
502    /// Posts `op` over `[laddr, laddr + total_size)` to `[raddr, ...)`,
503    /// splitting into `MAX_RDMA_MSG_SIZE`-bound chunks and returning one wr_id
504    /// per chunk.
505    fn post_chunked(
506        &mut self,
507        op: IbvOperation,
508        laddr: usize,
509        lkey: u32,
510        raddr: usize,
511        rkey: u32,
512        total_size: usize,
513    ) -> Result<Vec<u64>, anyhow::Error> {
514        let mut remaining = total_size;
515        let mut offset = 0;
516        let mut wr_ids = Vec::new();
517        while remaining > 0 {
518            let chunk = std::cmp::min(remaining, MAX_RDMA_MSG_SIZE);
519            let wr_id = self.next_wr_id;
520            self.next_wr_id += 1;
521            self.post_one(op, laddr + offset, lkey, chunk, raddr + offset, rkey, wr_id)?;
522            wr_ids.push(wr_id);
523            remaining -= chunk;
524            offset += chunk;
525        }
526        Ok(wr_ids)
527    }
528
529    /// Posts a single signaled RDMA `op` work request via `ibv_post_send`.
530    fn post_one(
531        &self,
532        op: IbvOperation,
533        laddr: usize,
534        lkey: u32,
535        length: usize,
536        raddr: usize,
537        rkey: u32,
538        wr_id: u64,
539    ) -> Result<(), anyhow::Error> {
540        let qp = self.qp.as_ptr();
541        let context = self.qp.context().as_ptr();
542        let mut sge = rdmaxcel_sys::ibv_sge {
543            addr: laddr as u64,
544            length: length as u32,
545            lkey,
546        };
547        let mut wr = rdmaxcel_sys::ibv_send_wr {
548            wr_id,
549            next: std::ptr::null_mut(),
550            sg_list: &mut sge as *mut _,
551            num_sge: 1,
552            opcode: op.into(),
553            send_flags: rdmaxcel_sys::ibv_send_flags::IBV_SEND_SIGNALED.0,
554            wr: Default::default(),
555            qp_type: Default::default(),
556            __bindgen_anon_1: Default::default(),
557            __bindgen_anon_2: Default::default(),
558        };
559        // Set the RDMA target. Writing through a union field is safe; the device
560        // reads the `rdma` member for the RDMA_WRITE/RDMA_READ opcodes set above.
561        wr.wr.rdma.remote_addr = raddr as u64;
562        wr.wr.rdma.rkey = rkey;
563        let mut bad_wr: *mut rdmaxcel_sys::ibv_send_wr = std::ptr::null_mut();
564        // SAFETY: `context` is the QP's live device context (read from the live
565        // `qp`); we invoke its `post_send` verb through the ops table. `qp` is
566        // live and `wr`/`sge`/`bad_wr` are valid for the duration of the call.
567        let errno = unsafe {
568            let post_send = (*context)
569                .ops
570                .post_send
571                .expect("post_send verb missing from ibv_context ops");
572            post_send(qp, &mut wr as *mut _, &mut bad_wr)
573        };
574        if errno != 0 {
575            return Err(anyhow::anyhow!(
576                "failed to post {:?} request: {}",
577                op,
578                Error::from_raw_os_error(errno)
579            ));
580        }
581        Ok(())
582    }
583}
584
585impl IbvQueuePair for RCQueuePair {
586    unsafe fn new<I: IbvDomainImpl<QueuePair = Self>>(
587        domain: &IbvDomain<I>,
588        config: IbvConfig,
589    ) -> Result<Self, anyhow::Error> {
590        tracing::debug!("creating an RCQueuePair from config {}", config);
591        // `IbvDomain`'s `pd` accessor permits null (e.g. a test domain); a real
592        // QP needs one, so reject null up front (`IbvCq::create` likewise rejects
593        // a null context).
594        let pd = domain.as_ptr();
595        if pd.is_null() {
596            anyhow::bail!("cannot create an RCQueuePair on a null protection domain");
597        }
598
599        // Resolve the source GID up front (before allocating any FFI resources),
600        // so a port without a global RoCE v2 GID fails cleanly here.
601        let gid = domain.device_info().select_gid(
602            config.port_num,
603            Some(GidScope::Global),
604            Some(GidType::RoCEv2),
605        )?;
606
607        // Separate send/recv completion queues. Each `IbvCq` destroys its queue
608        // on drop, so an early return below (or a panic) cleans them up.
609        // SAFETY: `domain`'s context is null or live; `IbvCq::create` rejects
610        // a null context.
611        let send_cq = unsafe { IbvCq::create(domain.context().clone(), config.cq_entries) }?;
612        // SAFETY: as for `send_cq` above.
613        let recv_cq = unsafe { IbvCq::create(domain.context().clone(), config.cq_entries) }?;
614
615        // A standard RC QP with the caps from `config`.
616        let mut init_attr = rdmaxcel_sys::ibv_qp_init_attr {
617            send_cq: send_cq.as_ptr(),
618            recv_cq: recv_cq.as_ptr(),
619            cap: rdmaxcel_sys::ibv_qp_cap {
620                max_send_wr: config.max_send_wr,
621                max_recv_wr: config.max_recv_wr,
622                max_send_sge: config.max_send_sge,
623                max_recv_sge: config.max_recv_sge,
624                max_inline_data: 0,
625            },
626            qp_type: rdmaxcel_sys::ibv_qp_type::IBV_QPT_RC,
627            sq_sig_all: 0,
628            ..Default::default()
629        };
630        // SAFETY: `pd` is non-null (checked above) and live per `IbvDomain`'s
631        // construction contract; `init_attr` is a fully initialized
632        // `ibv_qp_init_attr`. `ibv_create_qp` returns null on failure.
633        let qp = unsafe { rdmaxcel_sys::ibv_create_qp(pd, &mut init_attr) };
634        if qp.is_null() {
635            // `send_cq`/`recv_cq` drop here, destroying the CQs.
636            anyhow::bail!(
637                "failed to create queue pair (QP): {}",
638                Error::last_os_error()
639            );
640        }
641        // SAFETY: `qp` is a live RC QP just created against `pd` with
642        // `send_cq`/`recv_cq`; `IbvQp` takes ownership of all of them plus the
643        // PD and destroys them in order on drop.
644        let qp = unsafe { IbvQp::from_raw(qp, send_cq, recv_cq, domain.pd().clone()) };
645        let access_flags = domain.access_flags();
646        // SAFETY: `qp` and its `send_cq`/`recv_cq`/PD/context were all created
647        // non-null.
648        Ok(unsafe { Self::from_qp(qp, config, gid, access_flags) })
649    }
650
651    fn connect(&mut self, info: &IbvQpInfo) -> Result<(), anyhow::Error> {
652        // SAFETY: `self.qp` is the live QP, kept alive for `self`'s lifetime.
653        unsafe {
654            connect(
655                self.qp.as_ptr(),
656                &self.config,
657                self.access_flags,
658                info,
659                self.gid.index(),
660            )
661        }
662    }
663
664    fn get_qp_info(&mut self) -> Result<IbvQpInfo, anyhow::Error> {
665        let context = self.qp.context().as_ptr();
666        // SAFETY: `self.qp` is the live QP and `context` its non-null device
667        // context (validated inside `new`), both valid for `self`'s lifetime.
668        unsafe { get_qp_info(self.qp.as_ptr(), context, &self.config, self.gid) }
669    }
670
671    fn state(&mut self) -> Result<u32, anyhow::Error> {
672        // SAFETY: `self.qp` is the live QP, kept alive for `self`'s lifetime.
673        unsafe { state(self.qp.as_ptr()) }
674    }
675
676    fn put(
677        &mut self,
678        remote_dst: IbvBuffer,
679        local_src: IbvBuffer,
680    ) -> Result<Vec<u64>, anyhow::Error> {
681        if remote_dst.size < local_src.size {
682            return Err(anyhow::anyhow!(
683                "remote buffer size ({}) is smaller than local buffer size ({})",
684                remote_dst.size,
685                local_src.size
686            ));
687        }
688        self.post_chunked(
689            IbvOperation::Write,
690            local_src.addr,
691            local_src.lkey,
692            remote_dst.addr,
693            remote_dst.rkey,
694            local_src.size,
695        )
696    }
697
698    fn get(
699        &mut self,
700        local_dst: IbvBuffer,
701        remote_src: IbvBuffer,
702    ) -> Result<Vec<u64>, anyhow::Error> {
703        if local_dst.size < remote_src.size {
704            return Err(anyhow::anyhow!(
705                "local buffer size ({}) is smaller than remote buffer size ({})",
706                local_dst.size,
707                remote_src.size
708            ));
709        }
710        self.post_chunked(
711            IbvOperation::Read,
712            local_dst.addr,
713            local_dst.lkey,
714            remote_src.addr,
715            remote_src.rkey,
716            remote_src.size,
717        )
718    }
719
720    fn poll_completion(
721        &mut self,
722        target: PollTarget,
723    ) -> Result<Option<Result<IbvWc, WorkRequestError>>, PollCompletionError> {
724        let (cq, cq_type) = match target {
725            PollTarget::Send => (self.qp.send_cq().as_ptr(), "send"),
726            PollTarget::Recv => (self.qp.recv_cq().as_ptr(), "recv"),
727        };
728        let context = self.qp.context().as_ptr();
729        // SAFETY: `context` is the QP's live device context (read from the live
730        // `qp`); we invoke its `poll_cq` verb through the ops table.
731        let poll_cq = unsafe {
732            (*context)
733                .ops
734                .poll_cq
735                .expect("poll_cq verb missing from ibv_context ops")
736        };
737        let mut wc = rdmaxcel_sys::ibv_wc::default();
738        // SAFETY: `cq` is a live `ibv_cq` belonging to this QP; `&mut wc` has
739        // room for the single entry requested, and `poll_cq` overwrites it
740        // whenever it returns a completion (`ret >= 1`).
741        let ret = unsafe { poll_cq(cq, 1, &mut wc) };
742
743        if ret < 0 {
744            return Err(PollCompletionError {
745                message: format!("{} CQ poll failed (ibv_poll_cq returned {})", cq_type, ret),
746            });
747        }
748        if ret == 0 {
749            return Ok(None);
750        }
751
752        // `ret >= 1`: a single entry was requested, so `wc` holds one completion.
753        // `error()` is `Some` exactly when the status is not `IBV_WC_SUCCESS`.
754        if let Some((status, vendor_err)) = wc.error() {
755            return Ok(Some(Err(WorkRequestError {
756                wr_id: wc.wr_id(),
757                status,
758                vendor_err,
759                message: format!(
760                    "{} completion failed for wr_id={}: status={:?}, vendor_err={}",
761                    cq_type,
762                    wc.wr_id(),
763                    status,
764                    vendor_err,
765                ),
766            })));
767        }
768        Ok(Some(Ok(IbvWc::from(wc))))
769    }
770}
771
772/// Adaptive backoff for the scheduler's `Tick` self-message. Use
773/// [`Self::next_interval`] to ask "how long should I wait before the
774/// next poll attempt?"; call [`Self::reset`] whenever the previous
775/// poll observed completions (so the actor stays tight while work
776/// is making progress).
777///
778/// While the elapsed time since the first non-zero interval is below
779/// `yield_window`, the policy returns `Duration::ZERO` so the actor
780/// just re-sends `Tick` to itself with no delay — keeping latency
781/// tight when WRs are about to complete. Past the window, it walks
782/// an exponential backoff (1ms initial, doubling, capped at 10ms)
783/// so a long-running op doesn't keep the runtime spinning. When
784/// `yield_window` is `None` (the default for
785/// `RDMA_CQ_BUSY_POLL_WINDOW`) the policy always returns
786/// `Duration::ZERO`.
787#[derive(Debug)]
788struct PollSleepPolicy {
789    yield_window: Option<Duration>,
790    started_at: Option<Instant>,
791    backoff: Option<ExponentialBackoff>,
792}
793
794impl PollSleepPolicy {
795    fn new() -> Self {
796        let yield_window = hyperactor_config::global::get(crate::config::RDMA_CQ_BUSY_POLL_WINDOW);
797        Self {
798            yield_window,
799            started_at: None,
800            backoff: None,
801        }
802    }
803
804    /// Forget all accumulated backoff state. Called after a poll
805    /// returns completions, so the next idle stretch starts fresh.
806    fn reset(&mut self) {
807        self.started_at = None;
808        self.backoff = None;
809    }
810
811    /// Suggested delay before the next `Tick`. `Duration::ZERO`
812    /// means "send `Tick` immediately".
813    fn next_interval(&mut self) -> Duration {
814        let Some(window) = self.yield_window else {
815            return Duration::ZERO;
816        };
817        let started = *self.started_at.get_or_insert_with(Instant::now);
818        if started.elapsed() < window {
819            return Duration::ZERO;
820        }
821        let backoff = self.backoff.get_or_insert_with(|| {
822            ExponentialBackoffBuilder::new()
823                .with_initial_interval(Duration::from_millis(1))
824                .with_max_interval(Duration::from_millis(10))
825                .with_multiplier(2.0)
826                .with_randomization_factor(0.0)
827                .with_max_elapsed_time(None)
828                .build()
829        });
830        backoff.next_backoff().unwrap_or(Duration::ZERO)
831    }
832}
833
834/// Bundle of trait bounds for an actor type that can serve as the
835/// peer manager — i.e. the recipient of [`CreatePeerQueuePair`].
836pub(super) trait Manager:
837    Actor + Referable + RemoteHandles<CreatePeerQueuePair<Self>>
838{
839}
840
841impl<T> Manager for T where T: Actor + Referable + RemoteHandles<CreatePeerQueuePair<T>> {}
842
843/// Per-op completion reply emitted by [`QueuePairActor`] back to the
844/// manager via [`ProcessOps::reply`]. A named newtype (rather than a
845/// raw `(usize, Result<…>)` tuple) so the manager can identify
846/// undeliverable `OpResult`s by type name in
847/// `handle_undeliverable_message` and absorb them when the original
848/// caller has gone away (typical at test teardown).
849#[allow(dead_code)] // not yet referenced by IbvManagerActor
850#[derive(Debug)]
851pub(super) struct OpResult {
852    pub(super) op_idx: usize,
853    pub(super) result: Result<(), String>,
854}
855
856/// Local-only message: enqueue a batch of ops on this QP. As each
857/// op resolves the actor sends one `(op_idx, result)` tuple on
858/// `reply` — `op_idx` is the original index of the op in the
859/// user-facing `IbvManagerActor::submit_ops` request, so the receiver
860/// can correlate replies across batches that were sliced per-QP. The
861/// inner `Result` is `Ok(())` if every WR for the op completed
862/// successfully, otherwise `Err` carrying the first per-WR error
863/// observed (held back until the op's other WRs also report, so the
864/// MR registration outlives the data path).
865#[derive(Debug)]
866pub(super) struct ProcessOps<M: Referable> {
867    pub(super) items: Vec<(usize, IbvOp<M>, IbvMemoryRegionView)>,
868    pub(super) reply: PortHandle<OpResult>,
869}
870
871/// Local-only self-message that drives one round of the scheduler.
872#[derive(Debug)]
873struct Tick;
874
875/// An op accepted by the actor but not yet posted to the QP.
876#[derive(Debug)]
877struct PendingOp<M: Referable> {
878    op_idx: usize,
879    op: IbvOp<M>,
880    mrv: IbvMemoryRegionView,
881    reply: PortHandle<OpResult>,
882    /// WR count this op will issue when posted, computed once at
883    /// construction so retries from a credit head-block don't redo
884    /// the work.
885    wrs: u32,
886    is_read: bool,
887}
888
889/// State of an op whose WRs are in flight on the QP.
890#[derive(Debug)]
891struct PostedOpEntry {
892    op_idx: usize,
893    pending_wrs: HashSet<u64>,
894    is_read: bool,
895    /// Kept alive so the MR registration outlives every in-flight
896    /// WR touching it. Field is intentionally unread.
897    _mrv: IbvMemoryRegionView,
898    reply: PortHandle<OpResult>,
899    /// First per-WR error observed for this op. The op's final
900    /// reply is held back until `pending_wrs.is_empty()` so we don't
901    /// release the MR while remaining WRs are still in flight.
902    first_error: Option<String>,
903}
904
905/// Per-peer queue-pair actor.
906///
907/// Generic over the manager actor type `M` (so tests can swap in a
908/// mock) and the queue-pair type `Qp` (so unit tests run without
909/// RDMA hardware). The QP is constructed by the spawning manager
910/// and handed in as a spawn param; the actor owns it for life and
911/// drops it when the actor stops.
912#[derive(Debug)]
913pub(super) struct QueuePairActor<M: Manager, Qp: IbvQueuePair> {
914    qp_key: QpKey,
915    /// Filled into [`CreatePeerQueuePair::sender`] so the peer can
916    /// build its own [`QpKey`] from our identity.
917    local_manager: ActorRef<M>,
918    /// Recipient of [`CreatePeerQueuePair`].
919    peer_manager: ActorRef<M>,
920    qp: Qp,
921    /// `true` when the peer QP is colocated with this actor's QP —
922    /// i.e. both endpoints live in the same `IbvManagerActor` *and*
923    /// target the same RDMA device. In that case `init` connects
924    /// the QP to its own endpoint and skips the cross-actor
925    /// handshake.
926    is_loopback: bool,
927    init_timeout: Duration,
928    /// QP-wide cap on outstanding send-queue WRs (reads + writes).
929    max_send_wr: u32,
930    /// QP-wide cap on outstanding RDMA-READ WRs at the initiator. A
931    /// configured value of 0 means the device imposes no separate read
932    /// limit; the constructor normalizes it to `max_send_wr`, so reads
933    /// gate only against the send-queue slot cap.
934    max_rd_atomic: u32,
935    /// Single FIFO of ops awaiting their first post attempt. If the
936    /// head op bumps against either credit cap, ops queued behind
937    /// it stall — a write stuck behind a credit-blocked read is the
938    /// known consequence; smarter interleaving is future work.
939    queue: VecDeque<PendingOp<M>>,
940    /// op-id → entry, for tracking WR completion. op-ids are local
941    /// to this actor (monotonic counter); they exist so we can
942    /// route per-WR completions to the right `PostedOpEntry`
943    /// without making any assumptions about uniqueness of `op_idx`
944    /// across batches.
945    posted: HashMap<u64, PostedOpEntry>,
946    /// wr_id → local op-id.
947    wr_to_op: HashMap<u64, u64>,
948    next_op_id: u64,
949    in_flight_reads: u32,
950    in_flight_writes: u32,
951    /// `true` while a `Tick` self-message is already in flight; the
952    /// flag prevents stacking redundant ticks.
953    tick_armed: bool,
954    poll_policy: PollSleepPolicy,
955}
956
957impl<M: Manager, Qp: IbvQueuePair> QueuePairActor<M, Qp> {
958    pub(super) fn new(
959        qp_key: QpKey,
960        local_manager: ActorRef<M>,
961        peer_manager: ActorRef<M>,
962        qp: Qp,
963        is_loopback: bool,
964        max_send_wr: u32,
965        max_rd_atomic: u32,
966    ) -> Self {
967        let init_timeout = hyperactor_config::global::get(crate::config::RDMA_QP_INIT_TIMEOUT);
968        // A configured max_rd_atomic of 0 means "no separate read
969        // limit"; fall back to the send-queue slot cap so reads gate
970        // only against max_send_wr.
971        let max_rd_atomic = if max_rd_atomic == 0 {
972            max_send_wr
973        } else {
974            max_rd_atomic
975        };
976        Self {
977            qp_key,
978            local_manager,
979            peer_manager,
980            qp,
981            is_loopback,
982            init_timeout,
983            max_send_wr,
984            max_rd_atomic,
985            queue: VecDeque::new(),
986            posted: HashMap::new(),
987            wr_to_op: HashMap::new(),
988            next_op_id: 0,
989            in_flight_reads: 0,
990            in_flight_writes: 0,
991            tick_armed: false,
992            poll_policy: PollSleepPolicy::new(),
993        }
994    }
995
996    /// Number of WRs the QP will issue for an op that targets
997    /// `local_size` bytes. The QP splits large transfers into
998    /// `MAX_RDMA_MSG_SIZE`-bound chunks; a zero-byte op still
999    /// consumes one WR.
1000    fn wr_count(local_size: usize) -> u32 {
1001        local_size.div_ceil(MAX_RDMA_MSG_SIZE).max(1) as u32
1002    }
1003
1004    /// Try to post the head of `queue`.
1005    ///
1006    /// * `Ok(true)` — head was either posted or rejected with a
1007    ///   per-op error (e.g. op too large for this QP); caller
1008    ///   should attempt the next head.
1009    /// * `Ok(false)` — head can't be posted because the QP is at
1010    ///   either `max_send_wr` or, for a head read, `max_rd_atomic`.
1011    ///   The op stays at the head; caller should stop walking.
1012    /// * `Err(_)` — `qp.put`/`qp.get` failed (e.g. the QP is in
1013    ///   error state). Fatal: the actor's handler returns this,
1014    ///   which raises a supervision event.
1015    fn try_post_head(&mut self, cx: &Instance<Self>) -> Result<bool, anyhow::Error> {
1016        let pending = self.queue.pop_front().expect("non-empty queue");
1017        let PendingOp {
1018            op_idx,
1019            op,
1020            mrv,
1021            reply,
1022            wrs,
1023            is_read,
1024        } = pending;
1025
1026        let local_buf = IbvBuffer {
1027            lkey: mrv.lkey,
1028            rkey: mrv.rkey,
1029            addr: mrv.rdma_addr,
1030            size: mrv.size,
1031            device_name: mrv.device_name.clone(),
1032        };
1033
1034        // 1. Per-op fatal: op alone exceeds the QP's capacity.
1035        if wrs > self.max_send_wr {
1036            let err = format!(
1037                "op too large for this QP [op_idx={}, qp_key={:?}, op_type={:?}, wrs={}, max_send_wr={}, local: {:?}, remote: {:?}]",
1038                op_idx, self.qp_key, op.op_type, wrs, self.max_send_wr, local_buf, op.remote_buffer,
1039            );
1040            reply.try_post(
1041                cx,
1042                OpResult {
1043                    op_idx,
1044                    result: Err(err),
1045                },
1046            )?;
1047            return Ok(true);
1048        }
1049        if is_read && wrs > self.max_rd_atomic {
1050            let err = format!(
1051                "read op too large for this QP [op_idx={}, qp_key={:?}, wrs={}, max_rd_atomic={}, local: {:?}, remote: {:?}]",
1052                op_idx, self.qp_key, wrs, self.max_rd_atomic, local_buf, op.remote_buffer,
1053            );
1054            reply.try_post(
1055                cx,
1056                OpResult {
1057                    op_idx,
1058                    result: Err(err),
1059                },
1060            )?;
1061            return Ok(true);
1062        }
1063
1064        // 2. Credit gating. Every op consumes a send-queue slot;
1065        //    reads additionally consume read credits. A read at the
1066        //    head that hits either cap stalls the whole queue.
1067        let projected_total = self.in_flight_reads + self.in_flight_writes + wrs;
1068        if projected_total > self.max_send_wr
1069            || (is_read && self.in_flight_reads + wrs > self.max_rd_atomic)
1070        {
1071            self.queue.push_front(PendingOp {
1072                op_idx,
1073                op,
1074                mrv,
1075                reply,
1076                wrs,
1077                is_read,
1078            });
1079            return Ok(false);
1080        }
1081
1082        // 3. Post.
1083        let post_result = match op.op_type {
1084            RdmaOpType::WriteFromLocal => self.qp.put(op.remote_buffer.clone(), local_buf.clone()),
1085            RdmaOpType::ReadIntoLocal => self.qp.get(local_buf.clone(), op.remote_buffer.clone()),
1086        };
1087        let wr_ids = post_result.map_err(|e| {
1088            anyhow::anyhow!(
1089                "qp.{} failed [op_idx={}, qp_key={:?}, local: {:?}, remote: {:?}]: {e}",
1090                if is_read { "get" } else { "put" },
1091                op_idx,
1092                self.qp_key,
1093                local_buf,
1094                op.remote_buffer,
1095            )
1096        })?;
1097
1098        // 4. Track in-flight state.
1099        let op_id = self.next_op_id;
1100        self.next_op_id += 1;
1101        let mut pending_wrs = HashSet::with_capacity(wr_ids.len());
1102        for id in &wr_ids {
1103            self.wr_to_op.insert(*id, op_id);
1104            pending_wrs.insert(*id);
1105        }
1106        if is_read {
1107            self.in_flight_reads += wr_ids.len() as u32;
1108        } else {
1109            self.in_flight_writes += wr_ids.len() as u32;
1110        }
1111        self.posted.insert(
1112            op_id,
1113            PostedOpEntry {
1114                op_idx,
1115                pending_wrs,
1116                is_read,
1117                _mrv: mrv,
1118                reply,
1119                first_error: None,
1120            },
1121        );
1122        Ok(true)
1123    }
1124
1125    /// One scheduler round: post everything that fits, poll for
1126    /// completions, emit replies for finished ops, and re-arm
1127    /// `Tick` if work remains. Returns `Err` for fatal QP-level
1128    /// failures; the surrounding handler propagates the error so
1129    /// supervision tears the actor down.
1130    fn advance(&mut self, cx: &Instance<Self>) -> Result<(), anyhow::Error> {
1131        // 1. Post from the queue head until either it's empty or
1132        //    the head is credit-blocked.
1133        while !self.queue.is_empty() {
1134            if !self.try_post_head(cx)? {
1135                break;
1136            }
1137        }
1138
1139        // 2. Drain the send CQ. Each completion identifies its WR by
1140        //    `wr_id`; we correlate it back to its op and, once every WR
1141        //    for an op has reported, emit the op's reply. Polling only
1142        //    while WRs are outstanding keeps the loop from spinning on an
1143        //    empty queue, and draining to `None` ensures no completion is
1144        //    left behind.
1145        let mut progressed = false;
1146        while !self.wr_to_op.is_empty() {
1147            let completion = self
1148                .qp
1149                .poll_completion(PollTarget::Send)
1150                .map_err(|e| anyhow::anyhow!("CQ poll failed for qp_key={:?}: {e}", self.qp_key))?;
1151            let Some(wc_result) = completion else {
1152                break;
1153            };
1154            progressed = true;
1155
1156            let (wr_id, wr_error) = match wc_result {
1157                Ok(wc) => (wc.wr_id(), None),
1158                Err(per_wr) => (per_wr.wr_id, Some(per_wr.to_string())),
1159            };
1160
1161            let op_id = self
1162                .wr_to_op
1163                .remove(&wr_id)
1164                .expect("completed wr_id missing from wr_to_op");
1165            let entry = self
1166                .posted
1167                .get_mut(&op_id)
1168                .expect("op_id missing from posted");
1169            entry.pending_wrs.remove(&wr_id);
1170            if entry.is_read {
1171                self.in_flight_reads -= 1;
1172            } else {
1173                self.in_flight_writes -= 1;
1174            }
1175            if let Some(err) = wr_error
1176                && entry.first_error.is_none()
1177            {
1178                entry.first_error = Some(err);
1179            }
1180            if entry.pending_wrs.is_empty() {
1181                let entry = self.posted.remove(&op_id).expect("just verified");
1182                let result = match entry.first_error {
1183                    Some(err) => Err(err),
1184                    None => Ok(()),
1185                };
1186                entry.reply.try_post(
1187                    cx,
1188                    OpResult {
1189                        op_idx: entry.op_idx,
1190                        result,
1191                    },
1192                )?;
1193            }
1194        }
1195        if progressed {
1196            self.poll_policy.reset();
1197        }
1198
1199        // 3. Re-arm Tick if any work remains.
1200        let pending_work = !self.queue.is_empty() || !self.posted.is_empty();
1201        if pending_work && !self.tick_armed {
1202            self.tick_armed = true;
1203            let interval = self.poll_policy.next_interval();
1204            if interval.is_zero() {
1205                cx.handle().try_post(cx, Tick)?;
1206            } else {
1207                cx.post_after(cx, Tick, interval);
1208            }
1209        }
1210        Ok(())
1211    }
1212}
1213
1214#[async_trait]
1215impl<M: Manager, Qp: IbvQueuePair> Actor for QueuePairActor<M, Qp> {
1216    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
1217        let local_info = self.qp.get_qp_info().map_err(|e| {
1218            tracing::error!(qp_key = ?self.qp_key, error = %e, "QueuePairActor init: get_qp_info failed");
1219            anyhow::anyhow!("could not extract local QP info: {e}")
1220        })?;
1221
1222        let peer_info = if self.is_loopback {
1223            // The "peer" is ourselves; skip the round-trip and
1224            // connect to our own endpoint.
1225            local_info.clone()
1226        } else {
1227            let (reply, rx) = this.mailbox().open_once_port::<Result<IbvQpInfo, String>>();
1228            self.peer_manager.post(
1229                this,
1230                CreatePeerQueuePair {
1231                    sender: self.local_manager.clone(),
1232                    sender_device: self.qp_key.self_device.clone(),
1233                    receiver_device: self.qp_key.other_device.clone(),
1234                    sender_info: local_info,
1235                    reply: reply.bind(),
1236                },
1237            );
1238            match tokio::time::timeout(self.init_timeout, rx.recv()).await {
1239                Ok(Ok(Ok(info))) => info,
1240                Ok(Ok(Err(e))) => {
1241                    tracing::error!(
1242                        qp_key = ?self.qp_key,
1243                        peer_manager = ?self.peer_manager,
1244                        error = %e,
1245                        "QueuePairActor init: peer manager rejected CreatePeerQueuePair",
1246                    );
1247                    return Err(anyhow::anyhow!("peer manager rejected QP request: {e}"));
1248                }
1249                Ok(Err(e)) => {
1250                    tracing::error!(
1251                        qp_key = ?self.qp_key,
1252                        peer_manager = ?self.peer_manager,
1253                        error = %e,
1254                        "QueuePairActor init: peer reply port closed",
1255                    );
1256                    return Err(anyhow::anyhow!("peer reply port closed: {e}"));
1257                }
1258                Err(_) => {
1259                    tracing::error!(
1260                        qp_key = ?self.qp_key,
1261                        peer_manager = ?self.peer_manager,
1262                        timeout = ?self.init_timeout,
1263                        "QueuePairActor init: timed out waiting for peer reply",
1264                    );
1265                    return Err(anyhow::anyhow!(
1266                        "QP initialization timed out after {:?}",
1267                        self.init_timeout
1268                    ));
1269                }
1270            }
1271        };
1272
1273        self.qp.connect(&peer_info).map_err(|e| {
1274            tracing::error!(
1275                qp_key = ?self.qp_key,
1276                peer_info = ?peer_info,
1277                error = %e,
1278                "QueuePairActor init: connect failed",
1279            );
1280            anyhow::anyhow!("could not connect QP to peer: {e}")
1281        })?;
1282        Ok(())
1283    }
1284
1285    // This actor is implemented in Rust, but the RDMA registration path may enter
1286    // Python and take the GIL. Run its loop on the dedicated rdma runtime rather
1287    // than the shared control-plane runtime; see `crate::rdma_runtime`.
1288    fn spawn_server_task<F>(future: F) -> tokio::task::JoinHandle<F::Output>
1289    where
1290        F: std::future::Future + Send + 'static,
1291        F::Output: Send + 'static,
1292    {
1293        crate::rdma_runtime::spawn_on_rdma_runtime(future)
1294    }
1295}
1296
1297#[async_trait]
1298impl<M: Manager, Qp: IbvQueuePair> Handler<ProcessOps<M>> for QueuePairActor<M, Qp> {
1299    async fn handle(
1300        &mut self,
1301        cx: &Context<Self>,
1302        msg: ProcessOps<M>,
1303    ) -> Result<(), anyhow::Error> {
1304        for (op_idx, op, mrv) in msg.items.into_iter() {
1305            let wrs = Self::wr_count(op.local_memory.size());
1306            let is_read = matches!(op.op_type, RdmaOpType::ReadIntoLocal);
1307            self.queue.push_back(PendingOp {
1308                op_idx,
1309                op,
1310                mrv,
1311                reply: msg.reply.clone(),
1312                wrs,
1313                is_read,
1314            });
1315        }
1316        // If a tick is already armed it will pick up the new ops on
1317        // its next round; advancing here would just duplicate work.
1318        if !self.tick_armed {
1319            self.advance(cx)?;
1320        }
1321        Ok(())
1322    }
1323}
1324
1325#[async_trait]
1326impl<M: Manager, Qp: IbvQueuePair> Handler<Tick> for QueuePairActor<M, Qp> {
1327    async fn handle(&mut self, cx: &Context<Self>, _msg: Tick) -> Result<(), anyhow::Error> {
1328        self.tick_armed = false;
1329        self.advance(cx)?;
1330        Ok(())
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use std::sync::Arc;
1337    use std::sync::Mutex;
1338    use std::time::Duration;
1339
1340    use anyhow::Result;
1341    use async_trait::async_trait;
1342    use hyperactor::ActorHandle;
1343    use hyperactor::Context;
1344    use hyperactor::Handler;
1345    use hyperactor::proc::Proc;
1346
1347    use super::*;
1348    use crate::backend::ibverbs::device::IbvDevice;
1349    use crate::backend::ibverbs::device::list_all_devices;
1350    use crate::backend::ibverbs::device_selection::IbvDeviceTarget;
1351    use crate::backend::ibverbs::device_selection::resolve_target;
1352    use crate::backend::ibverbs::mlx_device::MlxDevice;
1353    use crate::backend::ibverbs::primitives::IbvConfig;
1354
1355    #[test]
1356    fn test_create_connection() {
1357        if list_all_devices().is_empty() {
1358            println!("Skipping test: RDMA devices not available");
1359            return;
1360        }
1361
1362        let config = IbvConfig {
1363            use_gpu_direct: false,
1364            ..Default::default()
1365        };
1366        let device_info = resolve_target::<MlxDevice>(&IbvDeviceTarget::cpu(0)).unwrap();
1367        let mut device = IbvDevice::<MlxDevice>::open(device_info.name(), config.clone())
1368            .expect("resolved device should open");
1369        let domain = device
1370            .get_or_create_domain("test")
1371            .expect("domain creation should succeed");
1372        let queue_pair = legacy::IbvQueuePair::new(domain, config.clone());
1373        assert!(queue_pair.is_ok());
1374    }
1375
1376    #[test]
1377    fn test_loopback_connection() {
1378        if list_all_devices().is_empty() {
1379            println!("Skipping test: RDMA devices not available");
1380            return;
1381        }
1382
1383        let server_config = IbvConfig {
1384            use_gpu_direct: false,
1385            ..Default::default()
1386        };
1387        let client_config = IbvConfig {
1388            use_gpu_direct: false,
1389            ..Default::default()
1390        };
1391
1392        let server_info = resolve_target::<MlxDevice>(&IbvDeviceTarget::cpu(0)).unwrap();
1393        let mut server_device =
1394            IbvDevice::<MlxDevice>::open(server_info.name(), server_config.clone())
1395                .expect("server device should open");
1396        let server_domain = server_device
1397            .get_or_create_domain("test")
1398            .expect("server domain creation should succeed");
1399        let client_info = resolve_target::<MlxDevice>(&IbvDeviceTarget::cpu(0)).unwrap();
1400        let mut client_device =
1401            IbvDevice::<MlxDevice>::open(client_info.name(), client_config.clone())
1402                .expect("client device should open");
1403        let client_domain = client_device
1404            .get_or_create_domain("test")
1405            .expect("client domain creation should succeed");
1406
1407        let mut server_qp =
1408            legacy::IbvQueuePair::new(server_domain, server_config.clone()).unwrap();
1409        let mut client_qp =
1410            legacy::IbvQueuePair::new(client_domain, client_config.clone()).unwrap();
1411
1412        let server_connection_info = server_qp.get_qp_info().unwrap();
1413        let client_connection_info = client_qp.get_qp_info().unwrap();
1414
1415        assert!(server_qp.connect(&client_connection_info).is_ok());
1416        assert!(client_qp.connect(&server_connection_info).is_ok());
1417    }
1418
1419    // =================================================================
1420    // QueuePairActor init handshake
1421    // =================================================================
1422
1423    /// Captured fields from a `CreatePeerQueuePair` message; we
1424    /// can't keep the original because the `reply` port is consumed
1425    /// to send the response.
1426    #[derive(Debug, Clone)]
1427    struct CreateCapture {
1428        sender_id: hyperactor::ActorId,
1429        sender_device: String,
1430        receiver_device: String,
1431        sender_qp_num: u32,
1432    }
1433
1434    #[derive(Debug)]
1435    struct QpaMockState {
1436        creates: Vec<CreateCapture>,
1437        response: Option<Result<IbvQpInfo, String>>,
1438        /// Forwards every supervision event the parent receives to
1439        /// the test.
1440        supervision_tx: Option<
1441            tokio::sync::mpsc::UnboundedSender<hyperactor::supervision::ActorSupervisionEvent>,
1442        >,
1443    }
1444
1445    /// Mock manager used by `QueuePairActor` tests.
1446    ///
1447    /// Plays two roles:
1448    /// 1. As the *parent*, it spawns `QueuePairActor` children via
1449    ///    [`SpawnQpaChild`].
1450    /// 2. As the *peer*, it handles `CreatePeerQueuePair`.
1451    #[derive(Debug)]
1452    #[hyperactor::export(handlers = [CreatePeerQueuePair<QpaMockManager>])]
1453    struct QpaMockManager {
1454        state: Arc<Mutex<QpaMockState>>,
1455    }
1456
1457    #[async_trait]
1458    impl Actor for QpaMockManager {
1459        async fn handle_supervision_event(
1460            &mut self,
1461            _this: &Instance<Self>,
1462            event: &hyperactor::supervision::ActorSupervisionEvent,
1463        ) -> Result<bool> {
1464            let tx = self.state.lock().unwrap().supervision_tx.clone();
1465            let Some(tx) = tx else {
1466                return Ok(!event.is_error());
1467            };
1468            tx.send(event.clone())
1469                .map_err(|e| anyhow::anyhow!("supervision_tx send failed: {e}"))?;
1470            Ok(true)
1471        }
1472    }
1473
1474    #[async_trait]
1475    impl Handler<CreatePeerQueuePair<QpaMockManager>> for QpaMockManager {
1476        async fn handle(
1477            &mut self,
1478            cx: &Context<Self>,
1479            msg: CreatePeerQueuePair<QpaMockManager>,
1480        ) -> Result<()> {
1481            let response = {
1482                let mut state = self.state.lock().unwrap();
1483                state.creates.push(CreateCapture {
1484                    sender_id: msg.sender.actor_addr().id().clone(),
1485                    sender_device: msg.sender_device.clone(),
1486                    receiver_device: msg.receiver_device.clone(),
1487                    sender_qp_num: msg.sender_info.qp_num,
1488                });
1489                state.response.take()
1490            };
1491            if let Some(response) = response {
1492                msg.reply.post(cx, response);
1493            }
1494            // None → drop reply intentionally to test the timeout path.
1495            Ok(())
1496        }
1497    }
1498
1499    /// Local message that spawns a `QueuePairActor` as a child of
1500    /// this manager so supervision events route here. The reply
1501    /// carries the resulting `ActorHandle` so the test can observe
1502    /// lifecycle transitions.
1503    #[derive(Debug)]
1504    struct SpawnQpaChild {
1505        qp_key: QpKey,
1506        peer_manager: ActorRef<QpaMockManager>,
1507        qp: MockQp,
1508        is_loopback: bool,
1509        max_send_wr: u32,
1510        max_rd_atomic: u32,
1511        reply: hyperactor::OncePortHandle<ActorHandle<QueuePairActor<QpaMockManager, MockQp>>>,
1512    }
1513
1514    #[async_trait]
1515    impl Handler<SpawnQpaChild> for QpaMockManager {
1516        async fn handle(&mut self, cx: &Context<Self>, msg: SpawnQpaChild) -> Result<()> {
1517            let local_manager = cx.bind::<QpaMockManager>();
1518            let actor = QueuePairActor::new(
1519                msg.qp_key,
1520                local_manager,
1521                msg.peer_manager,
1522                msg.qp,
1523                msg.is_loopback,
1524                msg.max_send_wr,
1525                msg.max_rd_atomic,
1526            );
1527            let handle = cx.spawn(actor);
1528            msg.reply.try_post(cx, handle)?;
1529            Ok(())
1530        }
1531    }
1532
1533    /// One `put` or `get` call recorded by the mock, surfaced to the
1534    /// test via the `posted_rx` channel returned alongside the QP.
1535    #[derive(Debug)]
1536    enum PostedOp {
1537        Put {
1538            remote_dst: IbvBuffer,
1539            local_src: IbvBuffer,
1540            wr_ids: Vec<u64>,
1541        },
1542        Get {
1543            local_dst: IbvBuffer,
1544            remote_src: IbvBuffer,
1545            wr_ids: Vec<u64>,
1546        },
1547    }
1548
1549    #[derive(Debug)]
1550    struct MockQpInner {
1551        connect_calls: Vec<IbvQpInfo>,
1552        next_wr_id: u64,
1553        /// Every `put`/`get` call is forwarded here in order, so the
1554        /// test body can `await` rather than poll.
1555        posted_tx: tokio::sync::mpsc::UnboundedSender<PostedOp>,
1556        /// FIFO of completions the next `poll_completion` calls hand
1557        /// back, one per call. Each entry is either `Ok(IbvWc)`
1558        /// (success) or `Err(...)` (per-WR completion failure).
1559        pending_completions: VecDeque<std::result::Result<IbvWc, WorkRequestError>>,
1560        /// One-shot CQ-level error; cleared after the next poll consumes it.
1561        poll_error: Option<PollCompletionError>,
1562        /// One-shot error for the next `put` or `get` call.
1563        post_error: Option<String>,
1564    }
1565
1566    /// `IbvQueuePair` mock used by `QueuePairActor` tests. Cloning is
1567    /// cheap (shared `Arc<Mutex<...>>`); the test typically holds
1568    /// one clone while handing another to the actor.
1569    #[derive(Debug, Clone)]
1570    struct MockQp {
1571        info: IbvQpInfo,
1572        inner: Arc<Mutex<MockQpInner>>,
1573    }
1574
1575    impl MockQp {
1576        /// Returns the mock QP and the receiver that observes its
1577        /// posts. Tests that don't care about post events can drop
1578        /// the receiver.
1579        fn new(qp_num: u32, psn: u32) -> (Self, tokio::sync::mpsc::UnboundedReceiver<PostedOp>) {
1580            let (posted_tx, posted_rx) = tokio::sync::mpsc::unbounded_channel();
1581            let qp = Self {
1582                info: IbvQpInfo {
1583                    qp_num,
1584                    lid: 0,
1585                    gid: None,
1586                    psn,
1587                },
1588                inner: Arc::new(Mutex::new(MockQpInner {
1589                    connect_calls: Vec::new(),
1590                    next_wr_id: 0,
1591                    posted_tx,
1592                    pending_completions: VecDeque::new(),
1593                    poll_error: None,
1594                    post_error: None,
1595                })),
1596            };
1597            (qp, posted_rx)
1598        }
1599
1600        fn connect_calls(&self) -> Vec<IbvQpInfo> {
1601            self.inner.lock().unwrap().connect_calls.clone()
1602        }
1603
1604        /// Queue a successful WC for `wr_id`. A subsequent
1605        /// `poll_completion` call returns it in FIFO order.
1606        fn queue_completion(&self, wr_id: u64) {
1607            self.inner
1608                .lock()
1609                .unwrap()
1610                .pending_completions
1611                .push_back(Ok(IbvWc::for_test(wr_id, true)));
1612        }
1613
1614        /// Queue a per-WR completion failure for `wr_id` — the actor
1615        /// receives it as the inner `Err` from `poll_completion` and
1616        /// should fail just that op (not poison the QP).
1617        fn queue_per_wr_error(&self, wr_id: u64, message: &str) {
1618            self.inner
1619                .lock()
1620                .unwrap()
1621                .pending_completions
1622                .push_back(Err(WorkRequestError::for_test(wr_id, message)));
1623        }
1624
1625        /// Queue a CQ-level poll error (one-shot). The next poll
1626        /// returns this as the outer `Err`, simulating a poisoned QP.
1627        fn queue_poll_error(&self, err: PollCompletionError) {
1628            self.inner.lock().unwrap().poll_error = Some(err);
1629        }
1630
1631        /// Make the next `put`/`get` return this error (one-shot).
1632        fn queue_post_error(&self, message: &str) {
1633            self.inner.lock().unwrap().post_error = Some(message.to_string());
1634        }
1635    }
1636
1637    impl IbvQueuePair for MockQp {
1638        unsafe fn new<I: IbvDomainImpl<QueuePair = Self>>(
1639            _domain: &IbvDomain<I>,
1640            _config: IbvConfig,
1641        ) -> Result<Self> {
1642            // No `IbvDomainImpl` sets `Q = MockQp`, so this is never reached;
1643            // the mock is built directly via `MockQp::new`.
1644            unreachable!("MockQp is constructed directly, not from a domain")
1645        }
1646
1647        fn connect(&mut self, info: &IbvQpInfo) -> Result<()> {
1648            self.inner.lock().unwrap().connect_calls.push(info.clone());
1649            Ok(())
1650        }
1651
1652        fn get_qp_info(&mut self) -> Result<IbvQpInfo> {
1653            Ok(self.info.clone())
1654        }
1655
1656        fn state(&mut self) -> Result<u32> {
1657            Ok(rdmaxcel_sys::ibv_qp_state::IBV_QPS_RTS)
1658        }
1659
1660        fn put(&mut self, remote_dst: IbvBuffer, local_src: IbvBuffer) -> Result<Vec<u64>> {
1661            let mut inner = self.inner.lock().unwrap();
1662            if let Some(msg) = inner.post_error.take() {
1663                return Err(anyhow::anyhow!(msg));
1664            }
1665            let wrs = local_src.size.div_ceil(MAX_RDMA_MSG_SIZE).max(1);
1666            let mut wr_ids = Vec::with_capacity(wrs);
1667            for _ in 0..wrs {
1668                wr_ids.push(inner.next_wr_id);
1669                inner.next_wr_id += 1;
1670            }
1671            let _ = inner.posted_tx.send(PostedOp::Put {
1672                remote_dst,
1673                local_src,
1674                wr_ids: wr_ids.clone(),
1675            });
1676            Ok(wr_ids)
1677        }
1678
1679        fn get(&mut self, local_dst: IbvBuffer, remote_src: IbvBuffer) -> Result<Vec<u64>> {
1680            let mut inner = self.inner.lock().unwrap();
1681            if let Some(msg) = inner.post_error.take() {
1682                return Err(anyhow::anyhow!(msg));
1683            }
1684            let wrs = local_dst.size.div_ceil(MAX_RDMA_MSG_SIZE).max(1);
1685            let mut wr_ids = Vec::with_capacity(wrs);
1686            for _ in 0..wrs {
1687                wr_ids.push(inner.next_wr_id);
1688                inner.next_wr_id += 1;
1689            }
1690            let _ = inner.posted_tx.send(PostedOp::Get {
1691                local_dst,
1692                remote_src,
1693                wr_ids: wr_ids.clone(),
1694            });
1695            Ok(wr_ids)
1696        }
1697
1698        fn poll_completion(
1699            &mut self,
1700            _target: PollTarget,
1701        ) -> std::result::Result<
1702            Option<std::result::Result<IbvWc, WorkRequestError>>,
1703            PollCompletionError,
1704        > {
1705            let mut inner = self.inner.lock().unwrap();
1706            if let Some(err) = inner.poll_error.take() {
1707                return Err(err);
1708            }
1709            Ok(inner.pending_completions.pop_front())
1710        }
1711    }
1712
1713    struct QpaHarness {
1714        proc: Proc,
1715        parent: ActorHandle<QpaMockManager>,
1716        peer: ActorHandle<QpaMockManager>,
1717        peer_state: Arc<Mutex<QpaMockState>>,
1718        client: hyperactor::Client,
1719        supervision_rx:
1720            tokio::sync::mpsc::UnboundedReceiver<hyperactor::supervision::ActorSupervisionEvent>,
1721    }
1722
1723    impl QpaHarness {
1724        fn build() -> Result<Self> {
1725            let proc = Proc::anonymous();
1726            let (supervision_tx, supervision_rx) = tokio::sync::mpsc::unbounded_channel();
1727            let parent = proc.spawn_with_label(
1728                "parent",
1729                QpaMockManager {
1730                    state: Arc::new(Mutex::new(QpaMockState {
1731                        creates: Vec::new(),
1732                        response: None,
1733                        supervision_tx: Some(supervision_tx),
1734                    })),
1735                },
1736            );
1737            let peer_state = Arc::new(Mutex::new(QpaMockState {
1738                creates: Vec::new(),
1739                response: None,
1740                supervision_tx: None,
1741            }));
1742            let peer = proc.spawn_with_label(
1743                "peer",
1744                QpaMockManager {
1745                    state: Arc::clone(&peer_state),
1746                },
1747            );
1748            let client = proc.client("client");
1749            Ok(Self {
1750                proc,
1751                parent,
1752                peer,
1753                peer_state,
1754                client,
1755                supervision_rx,
1756            })
1757        }
1758
1759        fn peer_id(&self) -> hyperactor::ActorId {
1760            self.peer.actor_addr().id().clone()
1761        }
1762
1763        fn parent_id(&self) -> hyperactor::ActorId {
1764            self.parent.actor_addr().id().clone()
1765        }
1766
1767        /// Await the next forwarded child-error supervision event.
1768        async fn next_supervision_failure(
1769            &mut self,
1770        ) -> hyperactor::supervision::ActorSupervisionEvent {
1771            tokio::time::timeout(Duration::from_secs(5), self.supervision_rx.recv())
1772                .await
1773                .expect("timed out waiting for child failure event")
1774                .expect("supervision channel closed")
1775        }
1776
1777        /// Destroys the proc, closes the supervision channel, drains
1778        /// every remaining event, and asserts none are unexpected.
1779        async fn teardown(mut self) {
1780            self.supervision_rx.close();
1781            self.proc
1782                .destroy_and_wait(Duration::from_secs(30), "test teardown")
1783                .await
1784                .expect("destroy_and_wait failed");
1785            let mut leftover = Vec::new();
1786            while let Some(event) = self.supervision_rx.recv().await {
1787                leftover.push(event);
1788            }
1789            assert!(
1790                leftover.is_empty(),
1791                "unexpected supervision events at teardown: {leftover:?}",
1792            );
1793        }
1794
1795        async fn spawn_actor(
1796            &self,
1797            qp_key: QpKey,
1798            peer_manager: ActorRef<QpaMockManager>,
1799            qp: MockQp,
1800            is_loopback: bool,
1801        ) -> Result<ActorHandle<QueuePairActor<QpaMockManager, MockQp>>> {
1802            self.spawn_actor_with_caps(qp_key, peer_manager, qp, is_loopback, 4, 2)
1803                .await
1804        }
1805
1806        async fn spawn_actor_with_caps(
1807            &self,
1808            qp_key: QpKey,
1809            peer_manager: ActorRef<QpaMockManager>,
1810            qp: MockQp,
1811            is_loopback: bool,
1812            max_send_wr: u32,
1813            max_rd_atomic: u32,
1814        ) -> Result<ActorHandle<QueuePairActor<QpaMockManager, MockQp>>> {
1815            let (reply, rx) = self.client.mailbox().open_once_port();
1816            self.parent.try_post(
1817                &self.client,
1818                SpawnQpaChild {
1819                    qp_key,
1820                    peer_manager,
1821                    qp,
1822                    is_loopback,
1823                    max_send_wr,
1824                    max_rd_atomic,
1825                    reply,
1826                },
1827            )?;
1828            Ok(rx.recv().await?)
1829        }
1830    }
1831
1832    async fn await_status(
1833        handle: &ActorHandle<QueuePairActor<QpaMockManager, MockQp>>,
1834        expected: impl Fn(&hyperactor::actor::ActorStatus) -> bool,
1835    ) -> hyperactor::actor::ActorStatus {
1836        let mut status = handle.status();
1837        status.wait_for(|s| expected(s)).await.unwrap();
1838        status.borrow().clone()
1839    }
1840
1841    #[timed_test::async_timed_test(timeout_secs = 60)]
1842    async fn qpa_init_succeeds_via_peer() -> Result<()> {
1843        let harness = QpaHarness::build()?;
1844        let peer_info = IbvQpInfo {
1845            qp_num: 0xbeef,
1846            lid: 0,
1847            gid: None,
1848            psn: 0xc0ffee,
1849        };
1850        harness.peer_state.lock().unwrap().response = Some(Ok(peer_info.clone()));
1851
1852        let (qp, _posted_rx) = MockQp::new(0x1234, 0xdead);
1853        let qp_key = QpKey {
1854            self_device: "mlx5_0".into(),
1855            other_id: harness.peer_id(),
1856            other_device: "mlx5_1".into(),
1857        };
1858        let handle = harness
1859            .spawn_actor(
1860                qp_key.clone(),
1861                harness.peer.bind::<QpaMockManager>(),
1862                qp.clone(),
1863                false,
1864            )
1865            .await?;
1866
1867        await_status(&handle, |s| {
1868            matches!(s, hyperactor::actor::ActorStatus::Idle)
1869        })
1870        .await;
1871
1872        let creates = harness.peer_state.lock().unwrap().creates.clone();
1873        assert_eq!(creates.len(), 1);
1874        assert_eq!(creates[0].sender_device, "mlx5_0");
1875        assert_eq!(creates[0].receiver_device, "mlx5_1");
1876        assert_eq!(creates[0].sender_qp_num, 0x1234);
1877        // The sender ref carries the local manager's identity so the
1878        // receiver can build its own `QpKey`.
1879        assert_eq!(creates[0].sender_id, harness.parent_id());
1880
1881        let connects = qp.connect_calls();
1882        assert_eq!(connects.len(), 1);
1883        assert_eq!(connects[0].qp_num, peer_info.qp_num);
1884        assert_eq!(connects[0].psn, peer_info.psn);
1885        harness.teardown().await;
1886        Ok(())
1887    }
1888
1889    #[timed_test::async_timed_test(timeout_secs = 60)]
1890    async fn qpa_init_loopback_skips_peer_round_trip() -> Result<()> {
1891        let harness = QpaHarness::build()?;
1892
1893        let (qp, _posted_rx) = MockQp::new(0xaaa, 0xbbb);
1894        let qp_key = QpKey {
1895            self_device: "mlx5_0".into(),
1896            other_id: harness.parent_id(),
1897            other_device: "mlx5_0".into(),
1898        };
1899        let handle = harness
1900            .spawn_actor(
1901                qp_key,
1902                harness.peer.bind::<QpaMockManager>(),
1903                qp.clone(),
1904                true,
1905            )
1906            .await?;
1907
1908        await_status(&handle, |s| {
1909            matches!(s, hyperactor::actor::ActorStatus::Idle)
1910        })
1911        .await;
1912        assert!(harness.peer_state.lock().unwrap().creates.is_empty());
1913
1914        let connects = qp.connect_calls();
1915        assert_eq!(connects.len(), 1);
1916        assert_eq!(connects[0].qp_num, 0xaaa);
1917        assert_eq!(connects[0].psn, 0xbbb);
1918        harness.teardown().await;
1919        Ok(())
1920    }
1921
1922    #[timed_test::async_timed_test(timeout_secs = 60)]
1923    async fn qpa_init_peer_error_fails_actor() -> Result<()> {
1924        let mut harness = QpaHarness::build()?;
1925        harness.peer_state.lock().unwrap().response =
1926            Some(Err("peer rejected, no domain on receiver_device".into()));
1927
1928        let qp_key = QpKey {
1929            self_device: "mlx5_0".into(),
1930            other_id: harness.peer_id(),
1931            other_device: "mlx5_99".into(),
1932        };
1933        let (qp, _posted_rx) = MockQp::new(1, 2);
1934        let handle = harness
1935            .spawn_actor(qp_key, harness.peer.bind::<QpaMockManager>(), qp, false)
1936            .await?;
1937
1938        let event = harness.next_supervision_failure().await;
1939        assert_eq!(&event.actor_id, handle.actor_addr());
1940        let report = event.failure_report().expect("event should be a failure");
1941        assert!(
1942            report.contains("peer rejected"),
1943            "failure report should surface the peer's error string; got: {report}"
1944        );
1945        await_status(&handle, |s| {
1946            matches!(s, hyperactor::actor::ActorStatus::Failed(_))
1947        })
1948        .await;
1949        harness.teardown().await;
1950        Ok(())
1951    }
1952
1953    #[timed_test::async_timed_test(timeout_secs = 60)]
1954    async fn qpa_init_timeout_fails_actor() -> Result<()> {
1955        let lock = hyperactor_config::global::lock();
1956        let _guard = lock.override_key(
1957            crate::config::RDMA_QP_INIT_TIMEOUT,
1958            Duration::from_millis(100),
1959        );
1960
1961        let mut harness = QpaHarness::build()?;
1962
1963        let qp_key = QpKey {
1964            self_device: "mlx5_0".into(),
1965            other_id: harness.peer_id(),
1966            other_device: "mlx5_1".into(),
1967        };
1968        let (qp, _posted_rx) = MockQp::new(1, 2);
1969        let handle = harness
1970            .spawn_actor(qp_key, harness.peer.bind::<QpaMockManager>(), qp, false)
1971            .await?;
1972
1973        let event = harness.next_supervision_failure().await;
1974        assert_eq!(&event.actor_id, handle.actor_addr());
1975        let report = event.failure_report().expect("event should be a failure");
1976        assert!(
1977            report.contains("timed out"),
1978            "failure report should mention timeout; got: {report}"
1979        );
1980        await_status(&handle, |s| {
1981            matches!(s, hyperactor::actor::ActorStatus::Failed(_))
1982        })
1983        .await;
1984        harness.teardown().await;
1985        Ok(())
1986    }
1987
1988    // =================================================================
1989    // QueuePairActor op processing
1990    // =================================================================
1991
1992    use crate::backend::ibverbs::primitives::IbvMr;
1993    use crate::local_memory::Keepalive;
1994    use crate::local_memory::KeepaliveLocalMemory;
1995
1996    /// No-op [`Keepalive`] for tests that mint a [`KeepaliveLocalMemory`]
1997    /// from a fake address never actually read or written through.
1998    struct FakeKeepalive {
1999        addr: usize,
2000        size: usize,
2001    }
2002    impl Keepalive for FakeKeepalive {
2003        fn addr(&self) -> usize {
2004            self.addr
2005        }
2006        fn size(&self) -> usize {
2007            self.size
2008        }
2009    }
2010
2011    fn fake_local_memory(addr: usize, size: usize) -> KeepaliveLocalMemory {
2012        KeepaliveLocalMemory::new(Arc::new(FakeKeepalive { addr, size }))
2013    }
2014
2015    fn fake_mrv(addr: usize, size: usize) -> IbvMemoryRegionView {
2016        IbvMemoryRegionView::new(
2017            addr,
2018            addr,
2019            size,
2020            0x1234,
2021            0x5678,
2022            "dev0".to_string(),
2023            // A null MR keepalive: its `Drop` is a no-op.
2024            Arc::new(IbvMr::null()),
2025        )
2026    }
2027
2028    /// Build a `QpaMockManager` ref attested to an unrelated proc;
2029    /// only used to populate `IbvOp::remote_manager` (the actor
2030    /// never sends to it during op processing — it just reads
2031    /// `op.remote_buffer`).
2032    fn fake_remote_ref() -> ActorRef<QpaMockManager> {
2033        let proc_id = hyperactor::id::ProcId::new(
2034            hyperactor::id::Uid::Instance(0xc0ffee, None),
2035            Some(hyperactor::id::Label::new("remote").unwrap()),
2036        );
2037        let proc_addr =
2038            hyperactor::ProcAddr::new(proc_id, hyperactor::channel::ChannelAddr::Local(1).into());
2039        ActorRef::attest(proc_addr.actor_addr("remote-mgr"))
2040    }
2041
2042    fn make_op(op_type: RdmaOpType, addr: usize, size: usize) -> IbvOp<QpaMockManager> {
2043        IbvOp {
2044            op_type,
2045            local_memory: fake_local_memory(addr, size),
2046            remote_buffer: IbvBuffer {
2047                lkey: 0,
2048                rkey: 0,
2049                addr: 0x4000_0000,
2050                size,
2051                device_name: "remote_dev".to_string(),
2052            },
2053            remote_manager: fake_remote_ref(),
2054        }
2055    }
2056
2057    impl QpaHarness {
2058        /// Spawn a `QueuePairActor` in loopback mode (no peer round
2059        /// trip) so init completes immediately; await it reaching
2060        /// `Idle`. Returns the actor handle along with the mock QP
2061        /// the test can drive.
2062        async fn spawn_ready_actor(
2063            &self,
2064            max_send_wr: u32,
2065            max_rd_atomic: u32,
2066        ) -> Result<(
2067            ActorHandle<QueuePairActor<QpaMockManager, MockQp>>,
2068            MockQp,
2069            tokio::sync::mpsc::UnboundedReceiver<PostedOp>,
2070        )> {
2071            let (qp, posted_rx) = MockQp::new(0x1, 0x2);
2072            let qp_key = QpKey {
2073                self_device: "mlx5_0".into(),
2074                other_id: self.parent_id(),
2075                other_device: "mlx5_0".into(),
2076            };
2077            let handle = self
2078                .spawn_actor_with_caps(
2079                    qp_key,
2080                    self.peer.bind::<QpaMockManager>(),
2081                    qp.clone(),
2082                    true,
2083                    max_send_wr,
2084                    max_rd_atomic,
2085                )
2086                .await?;
2087            await_status(&handle, |s| {
2088                matches!(s, hyperactor::actor::ActorStatus::Idle)
2089            })
2090            .await;
2091            Ok((handle, qp, posted_rx))
2092        }
2093    }
2094
2095    /// Await the next `PostedOp` from the mock; panics on timeout.
2096    async fn recv_posted(rx: &mut tokio::sync::mpsc::UnboundedReceiver<PostedOp>) -> PostedOp {
2097        tokio::time::timeout(Duration::from_secs(5), rx.recv())
2098            .await
2099            .expect("timed out waiting for post on MockQp")
2100            .expect("MockQp post channel closed")
2101    }
2102
2103    /// Assert that no further `PostedOp` arrives in `wait`. Used to
2104    /// pin down a credit-blocked state.
2105    async fn assert_no_post(
2106        rx: &mut tokio::sync::mpsc::UnboundedReceiver<PostedOp>,
2107        wait: Duration,
2108    ) {
2109        if let Ok(Some(p)) = tokio::time::timeout(wait, rx.recv()).await {
2110            panic!("unexpected post on MockQp: {p:?}");
2111        }
2112    }
2113
2114    fn expect_put(p: PostedOp) -> (IbvBuffer, IbvBuffer, Vec<u64>) {
2115        match p {
2116            PostedOp::Put {
2117                remote_dst,
2118                local_src,
2119                wr_ids,
2120            } => (local_src, remote_dst, wr_ids),
2121            other => panic!("expected Put, got {other:?}"),
2122        }
2123    }
2124
2125    fn expect_get(p: PostedOp) -> (IbvBuffer, IbvBuffer, Vec<u64>) {
2126        match p {
2127            PostedOp::Get {
2128                local_dst,
2129                remote_src,
2130                wr_ids,
2131            } => (local_dst, remote_src, wr_ids),
2132            other => panic!("expected Get, got {other:?}"),
2133        }
2134    }
2135
2136    /// Open a multi-shot port and send a `ProcessOps` batch. Returns
2137    /// the receiver so the caller can await per-op results.
2138    fn submit_ops(
2139        harness: &QpaHarness,
2140        actor: &ActorHandle<QueuePairActor<QpaMockManager, MockQp>>,
2141        items: Vec<(usize, IbvOp<QpaMockManager>, IbvMemoryRegionView)>,
2142    ) -> Result<hyperactor::mailbox::PortReceiver<OpResult>> {
2143        let (reply, rx) = harness.client.mailbox().open_port::<OpResult>();
2144        actor.try_post(&harness.client, ProcessOps { items, reply })?;
2145        Ok(rx)
2146    }
2147
2148    /// Collect exactly `n` replies with a per-recv timeout, sorted
2149    /// by op_idx for deterministic comparison.
2150    async fn collect_replies(
2151        rx: &mut hyperactor::mailbox::PortReceiver<OpResult>,
2152        n: usize,
2153    ) -> Vec<(usize, Result<(), String>)> {
2154        let mut out = Vec::with_capacity(n);
2155        for _ in 0..n {
2156            let m = tokio::time::timeout(Duration::from_secs(5), rx.recv())
2157                .await
2158                .expect("timed out waiting for ProcessOps reply")
2159                .expect("ProcessOps reply port closed");
2160            out.push((m.op_idx, m.result));
2161        }
2162        out.sort_by_key(|(i, _)| *i);
2163        out
2164    }
2165
2166    /// Try to recv with a short timeout, returning `None` on timeout
2167    /// so callers can assert "no reply yet".
2168    async fn try_recv(
2169        rx: &mut hyperactor::mailbox::PortReceiver<OpResult>,
2170        wait: Duration,
2171    ) -> Option<(usize, Result<(), String>)> {
2172        match tokio::time::timeout(wait, rx.recv()).await {
2173            Ok(Ok(m)) => Some((m.op_idx, m.result)),
2174            Ok(Err(e)) => panic!("ProcessOps reply port closed: {e}"),
2175            Err(_) => None,
2176        }
2177    }
2178
2179    #[timed_test::async_timed_test(timeout_secs = 60)]
2180    async fn qpa_processes_single_write() -> Result<()> {
2181        let harness = QpaHarness::build()?;
2182        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(4, 2).await?;
2183
2184        let items = vec![(
2185            7usize,
2186            make_op(RdmaOpType::WriteFromLocal, 0x1000, 4096),
2187            fake_mrv(0x1000, 4096),
2188        )];
2189        let mut rx = submit_ops(&harness, &actor, items)?;
2190
2191        // wr_ids start at 0 (fresh MockQp), so the single WR is wr 0.
2192        let (lhandle, rhandle, wr_ids) = expect_put(recv_posted(&mut posted_rx).await);
2193        assert_eq!(wr_ids, vec![0]);
2194        assert_eq!(lhandle.addr, 0x1000);
2195        assert_eq!(lhandle.size, 4096);
2196        assert_eq!(lhandle.lkey, 0x1234);
2197        assert_eq!(lhandle.rkey, 0x5678);
2198        assert_eq!(lhandle.device_name, "dev0");
2199        assert_eq!(rhandle.addr, 0x4000_0000);
2200        assert_eq!(rhandle.size, 4096);
2201        assert_eq!(rhandle.device_name, "remote_dev");
2202
2203        qp.queue_completion(0);
2204        let replies = collect_replies(&mut rx, 1).await;
2205        assert_eq!(replies, vec![(7, Ok(()))]);
2206        harness.teardown().await;
2207        Ok(())
2208    }
2209
2210    #[timed_test::async_timed_test(timeout_secs = 60)]
2211    async fn qpa_chunked_op_waits_for_all_wrs() -> Result<()> {
2212        let harness = QpaHarness::build()?;
2213        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(8, 4).await?;
2214
2215        // A 3-chunk write (3 * MAX_RDMA_MSG_SIZE) splits into 3 WRs;
2216        // the op's reply must be held back until all 3 complete.
2217        let items = vec![(
2218            11usize,
2219            make_op(RdmaOpType::WriteFromLocal, 0x1000, 3 * MAX_RDMA_MSG_SIZE),
2220            fake_mrv(0x1000, 3 * MAX_RDMA_MSG_SIZE),
2221        )];
2222        let mut rx = submit_ops(&harness, &actor, items)?;
2223
2224        let (_, _, wr_ids) = expect_put(recv_posted(&mut posted_rx).await);
2225        assert_eq!(wr_ids, vec![0, 1, 2]);
2226
2227        // Complete the first two — no reply yet.
2228        qp.queue_completion(wr_ids[0]);
2229        qp.queue_completion(wr_ids[1]);
2230        assert!(
2231            try_recv(&mut rx, Duration::from_millis(100))
2232                .await
2233                .is_none(),
2234            "op should not be reported until every WR has completed",
2235        );
2236
2237        // Complete the third — now the op resolves.
2238        qp.queue_completion(wr_ids[2]);
2239        let replies = collect_replies(&mut rx, 1).await;
2240        assert_eq!(replies, vec![(11, Ok(()))]);
2241        harness.teardown().await;
2242        Ok(())
2243    }
2244
2245    #[timed_test::async_timed_test(timeout_secs = 60)]
2246    async fn qpa_per_wr_error_held_until_all_wrs_complete() -> Result<()> {
2247        let harness = QpaHarness::build()?;
2248        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(8, 4).await?;
2249
2250        // 3-WR write: simulate the second WR failing first; the op's
2251        // Err must not fire until the other 2 WRs have also reported
2252        // so the MR registration outlives every in-flight WR.
2253        let items = vec![(
2254            42usize,
2255            make_op(RdmaOpType::WriteFromLocal, 0x1000, 3 * MAX_RDMA_MSG_SIZE),
2256            fake_mrv(0x1000, 3 * MAX_RDMA_MSG_SIZE),
2257        )];
2258        let mut rx = submit_ops(&harness, &actor, items)?;
2259
2260        let (_, _, wr_ids) = expect_put(recv_posted(&mut posted_rx).await);
2261        assert_eq!(wr_ids.len(), 3);
2262
2263        // Deliver the per-WR error first.
2264        qp.queue_per_wr_error(wr_ids[1], "simulated WR fail");
2265        assert!(
2266            try_recv(&mut rx, Duration::from_millis(100))
2267                .await
2268                .is_none(),
2269            "op error must wait for the other WRs to drain",
2270        );
2271
2272        // The remaining WRs complete (flush-style or otherwise) and
2273        // finally the op's Err is reported.
2274        qp.queue_completion(wr_ids[0]);
2275        qp.queue_completion(wr_ids[2]);
2276
2277        let replies = collect_replies(&mut rx, 1).await;
2278        assert_eq!(replies.len(), 1);
2279        assert_eq!(replies[0].0, 42);
2280        let err = replies[0]
2281            .1
2282            .as_ref()
2283            .expect_err("op should fail because one WR errored");
2284        assert!(
2285            err.contains("simulated WR fail"),
2286            "op error should surface the per-WR error: {err}",
2287        );
2288        harness.teardown().await;
2289        Ok(())
2290    }
2291
2292    #[timed_test::async_timed_test(timeout_secs = 60)]
2293    async fn qpa_per_wr_error_isolates_to_failing_op() -> Result<()> {
2294        let harness = QpaHarness::build()?;
2295        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(8, 4).await?;
2296
2297        // Two writes share a batch: op_idx 0 is multi-WR (3 chunks),
2298        // op_idx 1 is single-WR. One of op_idx 0's WRs fails;
2299        // op_idx 1's WR succeeds independently.
2300        let items = vec![
2301            (
2302                0usize,
2303                make_op(RdmaOpType::WriteFromLocal, 0x1000, 3 * MAX_RDMA_MSG_SIZE),
2304                fake_mrv(0x1000, 3 * MAX_RDMA_MSG_SIZE),
2305            ),
2306            (
2307                1usize,
2308                make_op(RdmaOpType::WriteFromLocal, 0x2000, 4096),
2309                fake_mrv(0x2000, 4096),
2310            ),
2311        ];
2312        let mut rx = submit_ops(&harness, &actor, items)?;
2313
2314        let (_, _, big_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2315        let (_, _, small_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2316        assert_eq!(big_wrs, vec![0, 1, 2]);
2317        assert_eq!(small_wrs, vec![3]);
2318
2319        // op_idx 1 completes successfully on its own.
2320        qp.queue_completion(small_wrs[0]);
2321        // op_idx 0: middle WR fails, others succeed.
2322        qp.queue_per_wr_error(big_wrs[1], "isolated per-WR fail");
2323        qp.queue_completion(big_wrs[0]);
2324        qp.queue_completion(big_wrs[2]);
2325
2326        let replies = collect_replies(&mut rx, 2).await;
2327        assert_eq!(replies.len(), 2);
2328        assert_eq!(replies[0].0, 0);
2329        let err = replies[0]
2330            .1
2331            .as_ref()
2332            .expect_err("op_idx 0 should fail because one WR errored");
2333
2334        assert!(
2335            err.contains("isolated per-WR fail"),
2336            "op_idx 0 error should name the per-WR error: {err}",
2337        );
2338        assert_eq!(replies[1], (1usize, Ok(())));
2339        harness.teardown().await;
2340        Ok(())
2341    }
2342
2343    #[timed_test::async_timed_test(timeout_secs = 60)]
2344    async fn qpa_read_credit_gating() -> Result<()> {
2345        let harness = QpaHarness::build()?;
2346        // max_rd_atomic=2 lets at most 2 RDMA_READs sit on the QP.
2347        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(8, 2).await?;
2348
2349        let items = (0..3usize)
2350            .map(|i| {
2351                (
2352                    i,
2353                    make_op(RdmaOpType::ReadIntoLocal, 0x1000 + i * 0x1000, 4096),
2354                    fake_mrv(0x1000 + i * 0x1000, 4096),
2355                )
2356            })
2357            .collect();
2358        let mut rx = submit_ops(&harness, &actor, items)?;
2359
2360        // Only the first two reads make it onto the wire; the third
2361        // stays parked at the queue head.
2362        let (_, _, r0_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2363        let (_, _, r1_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2364        assert_eq!(r0_wrs, vec![0]);
2365        assert_eq!(r1_wrs, vec![1]);
2366        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2367
2368        // Complete the first read; the third should post.
2369        qp.queue_completion(r0_wrs[0]);
2370        let (_, _, r2_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2371        assert_eq!(r2_wrs, vec![2]);
2372
2373        qp.queue_completion(r1_wrs[0]);
2374        qp.queue_completion(r2_wrs[0]);
2375        let replies = collect_replies(&mut rx, 3).await;
2376        assert_eq!(replies, vec![(0, Ok(())), (1, Ok(())), (2, Ok(()))]);
2377        harness.teardown().await;
2378        Ok(())
2379    }
2380
2381    #[timed_test::async_timed_test(timeout_secs = 60)]
2382    async fn qpa_zero_max_rd_atomic_uses_send_wr() -> Result<()> {
2383        let harness = QpaHarness::build()?;
2384        // max_rd_atomic=0 means "no separate read limit"; reads gate
2385        // only against max_send_wr=2.
2386        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(2, 0).await?;
2387
2388        let items = (0..3usize)
2389            .map(|i| {
2390                (
2391                    i,
2392                    make_op(RdmaOpType::ReadIntoLocal, 0x1000 + i * 0x1000, 4096),
2393                    fake_mrv(0x1000 + i * 0x1000, 4096),
2394                )
2395            })
2396            .collect();
2397        let mut rx = submit_ops(&harness, &actor, items)?;
2398
2399        // Two reads fit the send-queue cap; the third parks. Were 0
2400        // taken literally, every read would be rejected as too large.
2401        let (_, _, r0_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2402        let (_, _, r1_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2403        assert_eq!(r0_wrs, vec![0]);
2404        assert_eq!(r1_wrs, vec![1]);
2405        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2406
2407        // Freeing one slot lets the third read post.
2408        qp.queue_completion(r0_wrs[0]);
2409        let (_, _, r2_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2410        assert_eq!(r2_wrs, vec![2]);
2411
2412        qp.queue_completion(r1_wrs[0]);
2413        qp.queue_completion(r2_wrs[0]);
2414        let replies = collect_replies(&mut rx, 3).await;
2415        assert_eq!(replies, vec![(0, Ok(())), (1, Ok(())), (2, Ok(()))]);
2416        harness.teardown().await;
2417        Ok(())
2418    }
2419
2420    #[timed_test::async_timed_test(timeout_secs = 60)]
2421    async fn qpa_write_slot_gating() -> Result<()> {
2422        let harness = QpaHarness::build()?;
2423        // max_send_wr=2 caps total in-flight WRs; submit 4 writes.
2424        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(2, 8).await?;
2425
2426        let items = (0..4usize)
2427            .map(|i| {
2428                (
2429                    i,
2430                    make_op(RdmaOpType::WriteFromLocal, 0x1000 + i * 0x1000, 4096),
2431                    fake_mrv(0x1000 + i * 0x1000, 4096),
2432                )
2433            })
2434            .collect();
2435        let mut rx = submit_ops(&harness, &actor, items)?;
2436
2437        let (_, _, w0_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2438        let (_, _, w1_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2439        assert_eq!(w0_wrs, vec![0]);
2440        assert_eq!(w1_wrs, vec![1]);
2441        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2442
2443        // Complete one — the third write should post; one slot still busy.
2444        qp.queue_completion(w0_wrs[0]);
2445        let (_, _, w2_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2446        assert_eq!(w2_wrs, vec![2]);
2447        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2448
2449        // Complete another — the fourth write posts.
2450        qp.queue_completion(w1_wrs[0]);
2451        let (_, _, w3_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2452        assert_eq!(w3_wrs, vec![3]);
2453
2454        qp.queue_completion(w2_wrs[0]);
2455        qp.queue_completion(w3_wrs[0]);
2456        let replies = collect_replies(&mut rx, 4).await;
2457        assert_eq!(
2458            replies,
2459            vec![(0, Ok(())), (1, Ok(())), (2, Ok(())), (3, Ok(()))],
2460        );
2461        harness.teardown().await;
2462        Ok(())
2463    }
2464
2465    #[timed_test::async_timed_test(timeout_secs = 60)]
2466    async fn qpa_blocked_until_one_read_and_one_write_complete() -> Result<()> {
2467        let harness = QpaHarness::build()?;
2468        // max_send_wr=4, max_rd_atomic=2. The trailing 2-WR read
2469        // needs *both* a free read credit (only 1 in use) and a free
2470        // slot (currently full at 4) — so it sits parked until one
2471        // read AND one write complete.
2472        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(4, 2).await?;
2473
2474        let items = vec![
2475            (
2476                0usize,
2477                make_op(RdmaOpType::ReadIntoLocal, 0x1000, 4096),
2478                fake_mrv(0x1000, 4096),
2479            ),
2480            (
2481                1usize,
2482                make_op(RdmaOpType::WriteFromLocal, 0x2000, 4096),
2483                fake_mrv(0x2000, 4096),
2484            ),
2485            (
2486                2usize,
2487                make_op(RdmaOpType::WriteFromLocal, 0x3000, 4096),
2488                fake_mrv(0x3000, 4096),
2489            ),
2490            (
2491                3usize,
2492                make_op(RdmaOpType::WriteFromLocal, 0x4000, 4096),
2493                fake_mrv(0x4000, 4096),
2494            ),
2495            (
2496                4usize,
2497                make_op(RdmaOpType::ReadIntoLocal, 0x5000, 2 * MAX_RDMA_MSG_SIZE),
2498                fake_mrv(0x5000, 2 * MAX_RDMA_MSG_SIZE),
2499            ),
2500        ];
2501        let mut rx = submit_ops(&harness, &actor, items)?;
2502
2503        // First four post: 1 read WR (op 0) + 3 write WRs (ops 1-3).
2504        let (_, _, r0_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2505        let (_, _, w1_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2506        let (_, _, w2_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2507        let (_, _, w3_wrs) = expect_put(recv_posted(&mut posted_rx).await);
2508        assert_eq!(r0_wrs, vec![0]);
2509        assert_eq!(w1_wrs, vec![1]);
2510        assert_eq!(w2_wrs, vec![2]);
2511        assert_eq!(w3_wrs, vec![3]);
2512        // The 2-WR read at op_idx 4 stays parked.
2513        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2514
2515        // Completing just the in-flight read frees a read credit but
2516        // doesn't free a slot — op 4 still blocks.
2517        qp.queue_completion(r0_wrs[0]);
2518        let first_reply = collect_replies(&mut rx, 1).await;
2519        assert_eq!(first_reply, vec![(0, Ok(()))]);
2520        assert_no_post(&mut posted_rx, Duration::from_millis(50)).await;
2521
2522        // Completing one write frees the last slot needed; op 4 posts.
2523        qp.queue_completion(w1_wrs[0]);
2524        let second_reply = collect_replies(&mut rx, 1).await;
2525        assert_eq!(second_reply, vec![(1, Ok(()))]);
2526        let (_, _, r4_wrs) = expect_get(recv_posted(&mut posted_rx).await);
2527        assert_eq!(r4_wrs, vec![4, 5]);
2528
2529        // Drain everything else.
2530        qp.queue_completion(w2_wrs[0]);
2531        qp.queue_completion(w3_wrs[0]);
2532        for &id in &r4_wrs {
2533            qp.queue_completion(id);
2534        }
2535        let rest = collect_replies(&mut rx, 3).await;
2536        assert_eq!(rest, vec![(2, Ok(())), (3, Ok(())), (4, Ok(()))]);
2537        harness.teardown().await;
2538        Ok(())
2539    }
2540
2541    #[timed_test::async_timed_test(timeout_secs = 60)]
2542    async fn qpa_multiple_batches_share_credit() -> Result<()> {
2543        let harness = QpaHarness::build()?;
2544        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(4, 2).await?;
2545
2546        // Batch A: 2 writes with op_idx 10, 11.
2547        let batch_a = vec![
2548            (
2549                10usize,
2550                make_op(RdmaOpType::WriteFromLocal, 0x1000, 4096),
2551                fake_mrv(0x1000, 4096),
2552            ),
2553            (
2554                11usize,
2555                make_op(RdmaOpType::WriteFromLocal, 0x2000, 4096),
2556                fake_mrv(0x2000, 4096),
2557            ),
2558        ];
2559        let mut rx_a = submit_ops(&harness, &actor, batch_a)?;
2560
2561        // Batch B: 2 writes with op_idx 20, 21. Shares the QP with
2562        // Batch A — together they sit at 4/4 max_send_wr.
2563        let batch_b = vec![
2564            (
2565                20usize,
2566                make_op(RdmaOpType::WriteFromLocal, 0x3000, 4096),
2567                fake_mrv(0x3000, 4096),
2568            ),
2569            (
2570                21usize,
2571                make_op(RdmaOpType::WriteFromLocal, 0x4000, 4096),
2572                fake_mrv(0x4000, 4096),
2573            ),
2574        ];
2575        let mut rx_b = submit_ops(&harness, &actor, batch_b)?;
2576
2577        // Collect 4 post events (one per write).
2578        let mut all_wr_ids = Vec::new();
2579        for _ in 0..4 {
2580            let (_, _, wrs) = expect_put(recv_posted(&mut posted_rx).await);
2581            all_wr_ids.extend(wrs);
2582        }
2583        for &id in &all_wr_ids {
2584            qp.queue_completion(id);
2585        }
2586
2587        let replies_a = collect_replies(&mut rx_a, 2).await;
2588        assert_eq!(replies_a, vec![(10, Ok(())), (11, Ok(()))]);
2589        let replies_b = collect_replies(&mut rx_b, 2).await;
2590        assert_eq!(replies_b, vec![(20, Ok(())), (21, Ok(()))]);
2591        harness.teardown().await;
2592        Ok(())
2593    }
2594
2595    #[timed_test::async_timed_test(timeout_secs = 60)]
2596    async fn qpa_op_too_large_for_qp() -> Result<()> {
2597        let harness = QpaHarness::build()?;
2598        // max_send_wr=1 with a 2-chunk write → can never fit.
2599        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(1, 1).await?;
2600
2601        let items = vec![
2602            (
2603                0usize,
2604                make_op(RdmaOpType::WriteFromLocal, 0x1000, 2 * MAX_RDMA_MSG_SIZE),
2605                fake_mrv(0x1000, 2 * MAX_RDMA_MSG_SIZE),
2606            ),
2607            (
2608                1usize,
2609                make_op(RdmaOpType::WriteFromLocal, 0x2000, 4096),
2610                fake_mrv(0x2000, 4096),
2611            ),
2612        ];
2613        let mut rx = submit_ops(&harness, &actor, items)?;
2614
2615        // Only op_idx 1 reaches the wire (op_idx 0 was rejected).
2616        let (_, _, wrs) = expect_put(recv_posted(&mut posted_rx).await);
2617        assert_eq!(wrs, vec![0]);
2618        qp.queue_completion(0);
2619
2620        let replies = collect_replies(&mut rx, 2).await;
2621        // op_idx 0 must report a "too large" error; op_idx 1 succeeds.
2622        assert_eq!(replies.len(), 2);
2623        assert_eq!(replies[0].0, 0);
2624        let err = replies[0]
2625            .1
2626            .as_ref()
2627            .expect_err("op_idx 0 should fail as too large");
2628        assert!(err.contains("too large"), "expected too-large error: {err}");
2629        assert_eq!(replies[1], (1usize, Ok(())));
2630        harness.teardown().await;
2631        Ok(())
2632    }
2633
2634    #[timed_test::async_timed_test(timeout_secs = 60)]
2635    async fn qpa_poll_error_kills_actor_via_supervision() -> Result<()> {
2636        let mut harness = QpaHarness::build()?;
2637        let (actor, qp, mut posted_rx) = harness.spawn_ready_actor(4, 2).await?;
2638
2639        // Post one op so the next poll has something to look at.
2640        let items = vec![(
2641            0usize,
2642            make_op(RdmaOpType::WriteFromLocal, 0x1000, 4096),
2643            fake_mrv(0x1000, 4096),
2644        )];
2645        let _rx = submit_ops(&harness, &actor, items)?;
2646        let _ = recv_posted(&mut posted_rx).await;
2647        qp.queue_poll_error(PollCompletionError::for_test("simulated CQ poison"));
2648
2649        let event = harness.next_supervision_failure().await;
2650        assert_eq!(&event.actor_id, actor.actor_addr());
2651        let report = event.failure_report().expect("event should be a failure");
2652        assert!(
2653            report.contains("CQ poll failed") && report.contains("simulated CQ poison"),
2654            "supervision report should name the poll failure: {report}",
2655        );
2656        await_status(&actor, |s| {
2657            matches!(s, hyperactor::actor::ActorStatus::Failed(_))
2658        })
2659        .await;
2660        harness.teardown().await;
2661        Ok(())
2662    }
2663
2664    #[timed_test::async_timed_test(timeout_secs = 60)]
2665    async fn qpa_post_error_kills_actor_via_supervision() -> Result<()> {
2666        let mut harness = QpaHarness::build()?;
2667        let (actor, qp, _posted_rx) = harness.spawn_ready_actor(4, 2).await?;
2668
2669        qp.queue_post_error("simulated post failure");
2670        let items = vec![(
2671            0usize,
2672            make_op(RdmaOpType::WriteFromLocal, 0x1000, 4096),
2673            fake_mrv(0x1000, 4096),
2674        )];
2675        let _rx = submit_ops(&harness, &actor, items)?;
2676
2677        let event = harness.next_supervision_failure().await;
2678        assert_eq!(&event.actor_id, actor.actor_addr());
2679        let report = event.failure_report().expect("event should be a failure");
2680        assert!(
2681            report.contains("qp.put failed") && report.contains("simulated post failure"),
2682            "supervision report should name the post failure: {report}",
2683        );
2684        await_status(&actor, |s| {
2685            matches!(s, hyperactor::actor::ActorStatus::Failed(_))
2686        })
2687        .await;
2688        harness.teardown().await;
2689        Ok(())
2690    }
2691}