Skip to main content

air_sys_syscall/io_uring/
linked.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! **Opérations liées** (Temps 3c) : exprimer qu'une opération ne démarre
6//! qu'après l'achèvement de la précédente, **sans aller-retour userspace**.
7//! Sous-module `air-sys-syscall::io_uring::linked`. Repose sur les drapeaux SQE
8//! `IOSQE_IO_LINK` / `IOSQE_IO_HARDLINK` (axe D) et intègre l'op `LINK_TIMEOUT`
9//! (15). **Aucun register opcode.**
10//!
11//! Référence normative : `docs/specs/layer-0/io-uring-3c-linked.md`.
12//!
13//! ## Sémantique soft vs hard (point critique)
14//!
15//! L'ordre est garanti **dans les deux cas** ; ils diffèrent par la propagation
16//! des erreurs :
17//!
18//! - **soft** ([`LinkedChainBuilder::then`], `IOSQE_IO_LINK`) : si un maillon se
19//!   termine **en erreur**, la chaîne est **rompue** — les maillons restants sont
20//!   complétés avec **`-ECANCELED`**. **Définition large de l'« erreur »** : un
21//!   **`read`/`recv` court** (short read — moins d'octets que demandé) **compte
22//!   comme erreur** et rompt la chaîne. *Piège classique* : `read(N) → process`
23//!   casse si le `read` rend `< N` octets.
24//! - **hard** ([`LinkedChainBuilder::then_hard`], `IOSQE_IO_HARDLINK`) : une
25//!   erreur de **complétion** d'un maillon **n'interrompt pas** (suivants
26//!   exécutés) ; implique l'ordre du soft. Ne protège **pas** d'un **échec de
27//!   soumission** du parent (qui rompt quand même).
28//!
29//! ## Staging + réservation atomique + publication unique
30//!
31//! Pendant la durée du builder, le `&mut IoUring` est en **mode staging** : un
32//! `submit_*` appelé dans la closure réserve un slot S1 et **écrit** un SQE (avec
33//! le drapeau de lien posé sur le **prédécesseur**) **sans publier la queue**. La
34//! publication n'a lieu qu'au [`LinkedChainBuilder::submit`] final (**un seul
35//! store-release**, Temps 1 §3.2 → le kernel voit la chaîne complète d'un coup).
36//! On réutilise ainsi les `submit_*` existants **sans duplication** de `prepare_*`.
37//! Si un maillon ne peut être réservé (`EBUSY`), le builder **rembobine** (libère
38//! les slots déjà réservés, rembobine la `tail`) avant de retourner — **jamais de
39//! chaîne partielle publiée**.
40
41use super::owned::OwnedOp;
42use super::{IoUring, SubmissionToken, raw};
43use air_sys_types::Errno;
44use air_sys_types::io_uring::{TimeoutFlags, TimeoutSpec};
45use alloc::boxed::Box;
46use alloc::vec::Vec;
47
48impl IoUring {
49    /// Démarre la construction d'une **chaîne liée** (mode staging).
50    ///
51    /// Les `submit_*` appelés via le builder sont **mis en attente** (slot S1
52    /// réservé + SQE écrit, lien posé sur le prédécesseur) sans publier la queue ;
53    /// [`LinkedChainBuilder::submit`] publie la chaîne entière en une fois.
54    pub fn link_chain(&mut self) -> LinkedChainBuilder<'_> {
55        let start_tail = self.sq.local_tail();
56        LinkedChainBuilder {
57            ring: self,
58            start_tail,
59            tokens: Vec::new(),
60            prev_index: None,
61        }
62    }
63
64    /// `IORING_OP_LINK_TIMEOUT` (15) **staged** : borne le maillon précédent. Le
65    /// `__kernel_timespec` est gardé en vie dans le slot S1 (comme `submit_timeout`).
66    /// Réservé au [`LinkedChainBuilder`].
67    fn submit_link_timeout_staged(
68        &mut self,
69        spec: TimeoutSpec,
70        flags: TimeoutFlags,
71    ) -> Result<SubmissionToken, Errno> {
72        let ts = Box::new(super::async_ops::timespec_of(spec));
73        let ts_ptr = core::ptr::from_ref(&*ts) as u64;
74        let op_flags = flags.bits();
75        self.submit_op(Some(OwnedOp::Timeout(ts)), |sqe| {
76            sqe.opcode = raw::IORING_OP_LINK_TIMEOUT;
77            sqe.fd = -1;
78            sqe.addr_or_splice_off_in = ts_ptr;
79            sqe.len = 1;
80            sqe.op_flags = op_flags;
81        })
82    }
83}
84
85/// Constructeur d'une **chaîne d'opérations liées** sur un [`IoUring`] en mode
86/// staging (cf. module). Emprunte le ring `&mut` pour toute sa durée (aucune
87/// soumission directe ne peut s'intercaler).
88pub struct LinkedChainBuilder<'ring> {
89    /// Ring emprunté exclusivement (staging).
90    ring: &'ring mut IoUring,
91    /// `tail` locale avant le premier maillon (borne de rollback).
92    start_tail: u32,
93    /// Jetons des maillons stagés, dans l'ordre.
94    tokens: Vec<SubmissionToken>,
95    /// Index (dans l'anneau SQ) du **dernier** maillon stagé, pour y poser le
96    /// drapeau de lien au maillon suivant. `None` avant le premier.
97    prev_index: Option<u32>,
98}
99
100impl LinkedChainBuilder<'_> {
101    /// Cœur commun : stage un maillon (`op` = un `submit_*` normal). Si
102    /// `link_flag` est `Some`, le pose sur le **prédécesseur**. En cas d'échec de
103    /// réservation, **rembobine** la chaîne et propage l'erreur.
104    fn stage<F>(mut self, link_flag: Option<u8>, op: F) -> Result<Self, Errno>
105    where
106        F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>,
107    {
108        let index = self.ring.sq.local_tail();
109        match op(self.ring) {
110            Ok(token) => {
111                if let (Some(flag), Some(prev)) = (link_flag, self.prev_index) {
112                    // SAFETY: `prev` désigne le SQE du maillon précédent, stagé
113                    // pendant ce builder et **non publié** ; accès exclusif (`&mut`).
114                    unsafe {
115                        self.ring.sq.or_sqe_flags(prev, flag);
116                    }
117                }
118                self.tokens.push(token);
119                self.prev_index = Some(index);
120                Ok(self)
121            }
122            Err(error) => {
123                self.rollback();
124                Err(error)
125            }
126        }
127    }
128
129    /// **Premier maillon** : `op` appelle un `submit_*` normal, mis en attente
130    /// (staging). Ne pose aucun drapeau de lien (pas de prédécesseur).
131    ///
132    /// # Errors
133    ///
134    /// L'erreur du `submit_*` (ex. [`Errno::EBUSY`] si la SQ/le slab sont pleins) ;
135    /// la chaîne est alors rembobinée.
136    pub fn first<F>(self, op: F) -> Result<Self, Errno>
137    where
138        F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>,
139    {
140        self.stage(None, op)
141    }
142
143    /// Maillon **soft-lié** au précédent (`IOSQE_IO_LINK` posé sur le précédent) :
144    /// une **erreur de complétion** du précédent (`-errno`, **short read inclus**)
145    /// rompt la chaîne (suivants `-ECANCELED`).
146    ///
147    /// # Errors
148    ///
149    /// Voir [`LinkedChainBuilder::first`].
150    pub fn then<F>(self, op: F) -> Result<Self, Errno>
151    where
152        F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>,
153    {
154        self.stage(Some(raw::IOSQE_IO_LINK), op)
155    }
156
157    /// Maillon **hard-lié** au précédent (`IOSQE_IO_HARDLINK` posé sur le
158    /// précédent) : une erreur de complétion du précédent **n'interrompt pas** la
159    /// chaîne (l'ordre reste garanti).
160    ///
161    /// # Errors
162    ///
163    /// Voir [`LinkedChainBuilder::first`].
164    pub fn then_hard<F>(self, op: F) -> Result<Self, Errno>
165    where
166        F: FnOnce(&mut IoUring) -> Result<SubmissionToken, Errno>,
167    {
168        self.stage(Some(raw::IOSQE_IO_HARDLINK), op)
169    }
170
171    /// Borne dans le temps le **maillon précédent** via un `LINK_TIMEOUT` (op 15) :
172    /// si le timeout expire avant la fin du maillon, le maillon est annulé
173    /// (`-ECANCELED`) et la chaîne rompue (le timeout complète) ; si le maillon
174    /// finit avant, le timeout est annulé. **Unique** manière sûre d'émettre un
175    /// `LINK_TIMEOUT` (émission isolée refusée, Temps 2c).
176    ///
177    /// # Errors
178    ///
179    /// [`Errno::EINVAL`] s'il n'y a **aucun** maillon à borner ; sinon l'erreur du
180    /// staging (chaîne rembobinée).
181    pub fn with_link_timeout(
182        mut self,
183        spec: TimeoutSpec,
184        flags: TimeoutFlags,
185    ) -> Result<Self, Errno> {
186        let Some(prev) = self.prev_index else {
187            // Pas de maillon précédent à borner.
188            self.rollback();
189            return Err(Errno::EINVAL);
190        };
191        let index = self.ring.sq.local_tail();
192        match self.ring.submit_link_timeout_staged(spec, flags) {
193            Ok(token) => {
194                // SAFETY: `prev` (maillon borné) est stagé non publié ; on le lie
195                // (`IO_LINK`) au `LINK_TIMEOUT` qui le suit immédiatement. Accès
196                // exclusif (`&mut`).
197                unsafe {
198                    self.ring.sq.or_sqe_flags(prev, raw::IOSQE_IO_LINK);
199                }
200                self.tokens.push(token);
201                self.prev_index = Some(index);
202                Ok(self)
203            }
204            Err(error) => {
205                self.rollback();
206                Err(error)
207            }
208        }
209    }
210
211    /// Publie la chaîne **entière, atomiquement** (un seul store-release + un
212    /// `io_uring_enter`). Rend les jetons dans l'ordre de soumission.
213    ///
214    /// # Errors
215    ///
216    /// Les erreurs de [`IoUring::submit`] ([`Errno::EINTR`] remonté tel quel, etc.).
217    /// La réservation des slots ayant déjà réussi au staging, l'atomicité de la
218    /// chaîne est garantie en amont (jamais de chaîne partielle).
219    pub fn submit(mut self) -> Result<ChainTokens, Errno> {
220        self.ring.submit()?;
221        Ok(ChainTokens {
222            tokens: core::mem::take(&mut self.tokens),
223        })
224    }
225
226    /// Rembobine le staging : libère les slots déjà réservés (idempotent côté
227    /// slab) et rembobine la `tail` locale → aucun SQE de la chaîne ne sera publié.
228    fn rollback(&mut self) {
229        let tokens = core::mem::take(&mut self.tokens);
230        for token in tokens {
231            self.ring.slab.release(token);
232        }
233        self.ring.sq.rewind_to(self.start_tail);
234        self.prev_index = None;
235    }
236}
237
238/// Jetons des maillons d'une chaîne, **dans l'ordre de soumission** (corrèle
239/// chaque complétion à son maillon).
240#[derive(Debug, Clone)]
241pub struct ChainTokens {
242    tokens: Vec<SubmissionToken>,
243}
244
245impl ChainTokens {
246    /// Jetons des maillons, dans l'ordre de soumission.
247    #[must_use]
248    pub fn tokens(&self) -> &[SubmissionToken] {
249        &self.tokens
250    }
251
252    /// Jeton du maillon `index` (ordre de soumission), le cas échéant.
253    #[must_use]
254    pub fn get(&self, index: usize) -> Option<SubmissionToken> {
255        self.tokens.get(index).copied()
256    }
257
258    /// Nombre de maillons de la chaîne.
259    #[must_use]
260    pub fn len(&self) -> usize {
261        self.tokens.len()
262    }
263
264    /// `true` si la chaîne est vide.
265    #[must_use]
266    pub fn is_empty(&self) -> bool {
267        self.tokens.is_empty()
268    }
269
270    /// Consomme et rend les jetons possédés.
271    #[must_use]
272    pub fn into_vec(self) -> Vec<SubmissionToken> {
273        self.tokens
274    }
275}
276
277// ───────────────────────────────────────────────────────────────────────────
278// Tests
279// ───────────────────────────────────────────────────────────────────────────
280//
281// Les chaînes touchent `io_uring_enter` (non modélisé par Miri) →
282// `#[cfg_attr(miri, ignore)]`. Les accesseurs de `ChainTokens` (logique pure)
283// tournent **sous Miri**. Les erreurs déterministes sont injectées via
284// `submit_nop_with_result` (`IORING_NOP_INJECT_RESULT`) ; le piège du **short
285// read** est testé sur un pipe réel.
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::io_uring::{Completion, IoUring, IoUringBuilder, SubmitOptions};
291    use crate::test_support::std_file;
292    use air_sys_types::fd::AsFd;
293    use air_sys_types::io_uring::PollEvents;
294    use air_sys_types::ipc::PipeFlags;
295    use core::num::NonZeroU32;
296    use core::time::Duration;
297    use std::io::Write;
298
299    fn ring8() -> IoUring {
300        IoUring::new(NonZeroU32::new(8).expect("8 ≠ 0")).expect("io_uring_setup 6.12")
301    }
302
303    fn nz(n: u32) -> NonZeroU32 {
304        NonZeroU32::new(n).expect("n ≠ 0")
305    }
306
307    fn pipe_pair() -> (air_sys_types::fd::OwnedFd, air_sys_types::fd::OwnedFd) {
308        crate::ipc::pipe2(PipeFlags::empty()).expect("pipe2")
309    }
310
311    /// Draine `n` complétions et les rend **indexées par jeton** (corrélation à
312    /// l'ordre de soumission via `ChainTokens`).
313    fn drain(ring: &mut IoUring, n: u32) -> Vec<Completion> {
314        ring.submit_and_wait(n).expect("submit_and_wait");
315        let mut out = Vec::new();
316        while out.len() < n as usize {
317            out.push(ring.wait_completion().expect("wait_completion"));
318        }
319        out
320    }
321
322    /// Résultat brut de la complétion correspondant au jeton `tok`.
323    fn res_of(completions: &[Completion], tok: super::SubmissionToken) -> i32 {
324        completions
325            .iter()
326            .find(|c| c.token() == tok)
327            .map(Completion::raw_result)
328            .expect("complétion du jeton présente")
329    }
330
331    // ── Pur : ChainTokens (tourne sous Miri) ──────────────────────────────────
332
333    #[test]
334    fn chain_tokens_accessors() {
335        let t0 = super::SubmissionToken::new(0, 0);
336        let t1 = super::SubmissionToken::new(1, 0);
337        let tokens = ChainTokens {
338            tokens: vec![t0, t1],
339        };
340        assert_eq!(tokens.len(), 2);
341        assert!(!tokens.is_empty());
342        assert_eq!(tokens.get(0), Some(t0));
343        assert_eq!(tokens.get(1), Some(t1));
344        assert_eq!(tokens.get(2), None);
345        assert_eq!(tokens.tokens(), &[t0, t1]);
346        assert_eq!(tokens.clone().into_vec(), vec![t0, t1]);
347        let empty = ChainTokens { tokens: Vec::new() };
348        assert!(empty.is_empty());
349        assert!(format!("{tokens:?}").contains("ChainTokens"));
350    }
351
352    // ── Intégration : ordre + complétions ─────────────────────────────────────
353
354    #[test]
355    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
356    fn chain_of_nops_completes_in_order() {
357        let mut ring = ring8();
358        let tokens = ring
359            .link_chain()
360            .first(|r| r.submit_nop())
361            .expect("first")
362            .then(|r| r.submit_nop())
363            .expect("then")
364            .then(|r| r.submit_nop())
365            .expect("then")
366            .submit()
367            .expect("submit");
368        assert_eq!(tokens.len(), 3);
369        let completions = drain(&mut ring, 3);
370        for i in 0..3 {
371            let tok = tokens.get(i).expect("jeton");
372            assert_eq!(res_of(&completions, tok), 0, "maillon {i} succès");
373        }
374        assert_eq!(ring.in_flight(), 0, "aucun slot perdu");
375    }
376
377    // ── soft link : rupture sur erreur ────────────────────────────────────────
378
379    #[test]
380    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
381    fn soft_link_breaks_on_error_cancels_rest() {
382        let mut ring = ring8();
383        // [nop, nop_with_result(-EINVAL) soft, nop] : l'erreur du 2e rompt → 3e ECANCELED.
384        let tokens = ring
385            .link_chain()
386            .first(|r| r.submit_nop())
387            .expect("first")
388            .then(|r| r.submit_nop_with_result(-22)) // -EINVAL
389            .expect("then err")
390            .then(|r| r.submit_nop())
391            .expect("then")
392            .submit()
393            .expect("submit");
394        let completions = drain(&mut ring, 3);
395        assert_eq!(res_of(&completions, tokens.get(0).unwrap()), 0, "1er OK");
396        assert_eq!(
397            res_of(&completions, tokens.get(1).unwrap()),
398            -22,
399            "2e erreur"
400        );
401        assert_eq!(
402            res_of(&completions, tokens.get(2).unwrap()),
403            -125,
404            "3e annulé (-ECANCELED)"
405        );
406        assert_eq!(ring.in_flight(), 0);
407    }
408
409    #[test]
410    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
411    fn soft_link_short_read_breaks_chain() {
412        // PIÈGE : un read court (< octets demandés) rompt une chaîne soft.
413        let (reader, writer) = pipe_pair();
414        {
415            let mut w = std_file(writer);
416            w.write_all(b"short").expect("write 5"); // 5 octets dans le pipe
417            w.flush().expect("flush");
418        }
419        let mut ring = ring8();
420        // [read(64) soft, nop] : le read rend 5 (< 64) ⇒ short read ⇒ rompt → nop ECANCELED.
421        let tokens = ring
422            .link_chain()
423            .first(|r| r.submit_read(reader.as_fd(), vec![0u8; 64], None))
424            .expect("first read")
425            .then(|r| r.submit_nop())
426            .expect("then")
427            .submit()
428            .expect("submit");
429        let completions = drain(&mut ring, 2);
430        let read_res = res_of(&completions, tokens.get(0).unwrap());
431        let nop_res = res_of(&completions, tokens.get(1).unwrap());
432        assert_eq!(read_res, 5, "read court : 5 octets lus");
433        assert_eq!(
434            nop_res, -125,
435            "short read rompt la chaîne soft → nop -ECANCELED (piège confirmé)"
436        );
437        assert_eq!(ring.in_flight(), 0);
438    }
439
440    // ── hard link : l'erreur n'interrompt pas ─────────────────────────────────
441
442    #[test]
443    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
444    fn hard_link_survives_error() {
445        let mut ring = ring8();
446        // [nop_with_result(-EINVAL) HARD, nop] : l'erreur du 1er n'interrompt pas
447        // (then_hard pose IO_HARDLINK sur le 1er) → nop s'exécute (res 0).
448        let tokens = ring
449            .link_chain()
450            .first(|r| r.submit_nop_with_result(-22))
451            .expect("first err")
452            .then_hard(|r| r.submit_nop())
453            .expect("then_hard")
454            .submit()
455            .expect("submit");
456        let completions = drain(&mut ring, 2);
457        assert_eq!(
458            res_of(&completions, tokens.get(0).unwrap()),
459            -22,
460            "1er erreur"
461        );
462        assert_eq!(
463            res_of(&completions, tokens.get(1).unwrap()),
464            0,
465            "hard link : 2e exécuté malgré l'erreur"
466        );
467        assert_eq!(ring.in_flight(), 0);
468    }
469
470    // ── link_timeout : expirant / non expirant ────────────────────────────────
471
472    #[test]
473    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
474    fn link_timeout_expires_cancels_bounded() {
475        use air_sys_types::io_uring::{TimeoutFlags, TimeoutSpec};
476        // poll sur un pipe vide (jamais prêt) borné à 50 ms ⇒ poll annulé.
477        let (reader, _writer) = pipe_pair();
478        let mut ring = ring8();
479        let tokens = ring
480            .link_chain()
481            .first(|r| r.submit_poll_add(reader.as_fd(), PollEvents::IN))
482            .expect("first poll")
483            .with_link_timeout(
484                TimeoutSpec::after(Duration::from_millis(50)),
485                TimeoutFlags::empty(),
486            )
487            .expect("link_timeout")
488            .submit()
489            .expect("submit");
490        let completions = drain(&mut ring, 2);
491        let poll_res = res_of(&completions, tokens.get(0).unwrap());
492        let timeout_res = res_of(&completions, tokens.get(1).unwrap());
493        assert_eq!(poll_res, -125, "poll annulé (-ECANCELED) à l'expiration");
494        // Le timeout, lui, complète (a expiré : -ETIME en 6.12).
495        assert!(
496            timeout_res == -62 || timeout_res >= 0,
497            "timeout complète (ETIME ou succès), res = {timeout_res}"
498        );
499        assert_eq!(ring.in_flight(), 0);
500    }
501
502    #[test]
503    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
504    fn link_timeout_not_expiring_is_cancelled() {
505        use air_sys_types::io_uring::{TimeoutFlags, TimeoutSpec};
506        let mut ring = ring8();
507        // nop (instantané) borné à 5 s ⇒ le timeout est annulé.
508        let tokens = ring
509            .link_chain()
510            .first(|r| r.submit_nop())
511            .expect("first nop")
512            .with_link_timeout(
513                TimeoutSpec::after(Duration::from_secs(5)),
514                TimeoutFlags::empty(),
515            )
516            .expect("link_timeout")
517            .submit()
518            .expect("submit");
519        let completions = drain(&mut ring, 2);
520        assert_eq!(res_of(&completions, tokens.get(0).unwrap()), 0, "nop OK");
521        assert_eq!(
522            res_of(&completions, tokens.get(1).unwrap()),
523            -125,
524            "timeout annulé (maillon fini avant)"
525        );
526        assert_eq!(ring.in_flight(), 0);
527    }
528
529    #[test]
530    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
531    fn with_link_timeout_without_op_is_einval() {
532        let mut ring = ring8();
533        use air_sys_types::io_uring::{TimeoutFlags, TimeoutSpec};
534        let result = ring.link_chain().with_link_timeout(
535            TimeoutSpec::after(Duration::from_secs(1)),
536            TimeoutFlags::empty(),
537        );
538        assert!(matches!(result, Err(Errno::EINVAL)));
539        // Rien n'a été stagé : un nop direct fonctionne ensuite.
540        let tok = ring.submit_nop().expect("nop");
541        let _ = drain(&mut ring, 1);
542        let _ = tok;
543        assert_eq!(ring.in_flight(), 0);
544    }
545
546    #[test]
547    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
548    fn with_link_timeout_staging_error_rolls_back() {
549        use air_sys_types::io_uring::{TimeoutFlags, TimeoutSpec};
550        // slab capacité 1 : le 1er maillon consomme le slot ⇒ le LINK_TIMEOUT ne
551        // peut être réservé → branche d'erreur de `with_link_timeout` + rollback.
552        let mut ring = IoUringBuilder::new(nz(8))
553            .max_inflight(nz(1))
554            .build()
555            .expect("build max_inflight 1");
556        let result = ring.link_chain().first(|r| r.submit_nop()).and_then(|b| {
557            b.with_link_timeout(
558                TimeoutSpec::after(Duration::from_secs(1)),
559                TimeoutFlags::empty(),
560            )
561        });
562        assert!(
563            matches!(result, Err(Errno::EBUSY)),
564            "LINK_TIMEOUT slab plein"
565        );
566        assert_eq!(ring.in_flight(), 0, "rollback : slot du 1er maillon rendu");
567        // Le ring reste utilisable.
568        let tok = ring.submit_nop().expect("nop après rollback");
569        ring.submit_and_wait(1).expect("submit_and_wait");
570        assert_eq!(ring.wait_completion().expect("completion").token(), tok);
571    }
572
573    #[test]
574    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
575    fn then_without_first_is_a_valid_unlinked_start() {
576        // `then` comme tout premier maillon : pas de prédécesseur ⇒ aucun drapeau
577        // de lien posé (couvre la branche `(Some(flag), None)`). Chaîne valide.
578        let mut ring = ring8();
579        let tokens = ring
580            .link_chain()
581            .then(|r| r.submit_nop())
582            .expect("then sans first")
583            .then(|r| r.submit_nop())
584            .expect("then")
585            .submit()
586            .expect("submit");
587        assert_eq!(tokens.len(), 2);
588        let completions = drain(&mut ring, 2);
589        assert_eq!(res_of(&completions, tokens.get(0).unwrap()), 0);
590        assert_eq!(res_of(&completions, tokens.get(1).unwrap()), 0);
591        assert_eq!(ring.in_flight(), 0);
592    }
593
594    // ── skip_cqe_on_success sur intermédiaires ────────────────────────────────
595
596    #[test]
597    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
598    fn skip_cqe_intermediate_only_final_completes() {
599        let mut ring = ring8();
600        let skip = || SubmitOptions::default().skip_cqe_on_success();
601        let tokens = ring
602            .link_chain()
603            .first(|r| r.with(skip()).submit_nop())
604            .expect("first skip")
605            .then(|r| r.with(skip()).submit_nop())
606            .expect("then skip")
607            .then(|r| r.submit_nop())
608            .expect("then final")
609            .submit()
610            .expect("submit");
611        assert_eq!(tokens.len(), 3);
612        // Seule la complétion finale arrive (les succès intermédiaires skippent).
613        ring.submit_and_wait(1).expect("submit_and_wait");
614        let final_completion = ring.wait_completion().expect("finale");
615        assert_eq!(final_completion.token(), tokens.get(2).unwrap());
616        assert_eq!(final_completion.raw_result(), 0);
617        assert!(
618            ring.try_completion().is_none(),
619            "aucune complétion intermédiaire"
620        );
621        assert_eq!(
622            ring.in_flight(),
623            0,
624            "slots intermédiaires libérés au staging"
625        );
626    }
627
628    // ── Atomicité : slab insuffisant ⇒ EBUSY sans publication ─────────────────
629
630    #[test]
631    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
632    fn chain_rolls_back_on_slab_exhaustion() {
633        // slab capacité 2 : une chaîne de 3 ne tient pas ⇒ EBUSY au 3e, rollback.
634        let mut ring = IoUringBuilder::new(nz(8))
635            .max_inflight(nz(2))
636            .build()
637            .expect("build max_inflight 2");
638        let result = ring
639            .link_chain()
640            .first(|r| r.submit_nop())
641            .and_then(|b| b.then(|r| r.submit_nop()))
642            .and_then(|b| b.then(|r| r.submit_nop())); // 3e : slab plein
643        assert!(
644            matches!(result, Err(Errno::EBUSY)),
645            "EBUSY au maillon de trop"
646        );
647        // Aucun SQE publié, slab rendu : in_flight 0, un nop direct fonctionne.
648        assert_eq!(ring.in_flight(), 0, "rollback : slots rendus");
649        let tok = ring.submit_nop().expect("nop après rollback");
650        ring.submit_and_wait(1).expect("submit_and_wait");
651        let c = ring.wait_completion().expect("completion");
652        assert_eq!(c.token(), tok);
653        assert_eq!(ring.in_flight(), 0);
654    }
655
656    // ── Property-style : ordre = soumission, comptes cohérents ────────────────
657
658    #[test]
659    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
660    fn chain_lengths_order_and_counts() {
661        for len in 1u32..=6 {
662            let mut ring = ring8();
663            let mut builder = ring.link_chain();
664            builder = builder.first(|r| r.submit_nop()).expect("first");
665            for _ in 1..len {
666                builder = builder.then(|r| r.submit_nop()).expect("then");
667            }
668            let tokens = builder.submit().expect("submit");
669            assert_eq!(u32::try_from(tokens.len()).unwrap(), len);
670            let completions = drain(&mut ring, len);
671            // Chaque jeton a exactement une complétion à 0 (aucune perte/rupture).
672            for i in 0..usize::try_from(len).unwrap() {
673                assert_eq!(res_of(&completions, tokens.get(i).unwrap()), 0);
674            }
675            assert_eq!(ring.in_flight(), 0);
676        }
677    }
678
679    #[test]
680    #[cfg_attr(miri, ignore = "io_uring_enter non supporté par Miri")]
681    fn first_after_then_starts_new_unlinked_segment() {
682        // `first` après un `then` : aucun drapeau de lien posé sur le prédécesseur
683        // (couvre la combinaison `link_flag = None` avec `prev_index = Some`) — le
684        // maillon précédent devient une queue de chaîne, un nouveau segment démarre.
685        let mut ring = ring8();
686        let tokens = ring
687            .link_chain()
688            .first(|r| r.submit_nop())
689            .expect("first")
690            .then(|r| r.submit_nop())
691            .expect("then")
692            .first(|r| r.submit_nop()) // nouveau segment, non lié au précédent
693            .expect("first 2")
694            .submit()
695            .expect("submit");
696        assert_eq!(tokens.len(), 3);
697        let completions = drain(&mut ring, 3);
698        for i in 0..3 {
699            assert_eq!(res_of(&completions, tokens.get(i).unwrap()), 0);
700        }
701        assert_eq!(ring.in_flight(), 0);
702    }
703}