Skip to main content

hyperactor/
ordering.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//! This module contains utilities used to help messages are delivered in order
10//! for any given sender and receiver actor pair.
11
12use std::any::TypeId;
13use std::collections::HashMap;
14use std::fmt;
15use std::sync::Arc;
16use std::sync::Mutex;
17
18use hyperactor_config::AttrValue;
19use hyperactor_config::attrs::declare_attrs;
20use serde::Deserialize;
21use serde::Serialize;
22use typeuri::Named;
23use uuid::Uuid;
24
25use crate::ActorAddr;
26use crate::PortAddr;
27
28// Control ports are delivered through runtime-owned channels rather than the
29// actor's work queue, so they must not share the per-actor handler-port
30// sequence counter.
31
32/// Returns true if `id` is the `TypeId` of a bypass-channel message type
33/// (i.e. one that must not be wired through `Ports::get`).
34pub(crate) fn is_bypass_workq_type_id(id: TypeId) -> bool {
35    id == TypeId::of::<crate::introspect::IntrospectMessage>()
36        || id == TypeId::of::<crate::proc::StatusMessage>()
37}
38
39/// Per-session receiver-local ordering snapshot. This does not lock the
40/// work queue or hold sequencing state across awaits.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Named, AttrValue)]
42pub struct OrderingSessionSnapshot {
43    /// Identifier of the session whose ordering state this snapshot
44    /// describes; matches `SeqInfo::Session { session_id, .. }` on the
45    /// wire.
46    pub session_id: Uuid,
47
48    /// Sender `ActorAddr` captured by the first-non-None-wins
49    /// rule. `None` when every message in this session bypassed the
50    /// normal stamping sites (rare: test fixtures, non-handler routes).
51    pub sender: Option<ActorAddr>,
52
53    /// Highest seq released from the reorder buffer into the actor work
54    /// queue. NOT "handler processed" -- that's downstream of the queue.
55    pub last_released_seq: u64,
56
57    /// `last_released_seq + 1` -- the seq the next contiguous send must
58    /// carry for delivery without further buffering.
59    pub expected_next_seq: u64,
60
61    /// Number of messages currently sitting in the reorder buffer waiting
62    /// for a seq gap to be filled. Zero on healthy in-order sessions.
63    pub buffered_count: usize,
64
65    /// Lowest seq currently buffered. `None` when `buffered_count == 0`.
66    pub oldest_buffered_seq: Option<u64>,
67
68    /// Highest seq currently buffered. `None` when `buffered_count == 0`.
69    pub newest_buffered_seq: Option<u64>,
70}
71
72/// Diagnostic projection of receiver-local ordering state.
73///
74/// Each `OrderingSessionSnapshot` corresponds to one sender session keyed
75/// by `SeqInfo::Session.session_id`. Sequence progress here means
76/// "released into the actor work queue", not "processed by the actor handler".
77///
78/// Carries completeness metadata so callers can distinguish "no stalled
79/// sessions" from "snapshot was partial" from "buffering is disabled".
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Named, AttrValue)]
81pub struct OrderingSnapshot {
82    /// Whether reorder buffering is enabled. When `false`, messages
83    /// bypass sequencing, and `sessions` is empty even under load.
84    pub enabled: bool,
85
86    /// Per-session entries, sorted by `session_id` for stable output.
87    /// Includes idle (drained) sessions with `buffered_count == 0`.
88    pub sessions: Vec<OrderingSessionSnapshot>,
89
90    /// Sessions that could not be observed because sequencing state was
91    /// busy when we tried to snapshot. NOT in `sessions`. Zero means
92    /// the snapshot is complete.
93    pub skipped_session_count: usize,
94}
95
96impl OrderingSnapshot {
97    /// True when no session was skipped, i.e., every live session's
98    /// state was successfully captured in `sessions`.
99    pub fn is_complete(&self) -> bool {
100        self.skipped_session_count == 0
101    }
102}
103
104impl fmt::Display for OrderingSessionSnapshot {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        // Infallible for this struct (all fields serialize cleanly).
107        write!(f, "{}", serde_json::to_string(self).unwrap())
108    }
109}
110
111impl std::str::FromStr for OrderingSessionSnapshot {
112    type Err = serde_json::Error;
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        serde_json::from_str(s)
115    }
116}
117
118impl fmt::Display for OrderingSnapshot {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "{}", serde_json::to_string(self).unwrap())
121    }
122}
123
124impl std::str::FromStr for OrderingSnapshot {
125    type Err = serde_json::Error;
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        serde_json::from_str(s)
128    }
129}
130
131/// Key for sequence assignment.
132/// Handler ports share a sequence per actor; ephemeral ports get individual
133/// sequences. Control ports are direct.
134#[derive(Clone, Debug, Hash, PartialEq, Eq)]
135enum SeqKey {
136    /// Shared sequence for all handler ports of an actor
137    Actor(ActorAddr),
138    /// Individual sequence for a specific ephemeral port.
139    Port(PortAddr),
140}
141
142/// A message's sequencer number infomation.
143#[derive(Debug, Serialize, Deserialize, Clone, Named, AttrValue, PartialEq)]
144pub enum SeqInfo {
145    /// Messages with the same session ID should be delivered in order.
146    Session {
147        /// Message's session ID
148        session_id: Uuid,
149        /// Message's sequence number in the given session.
150        seq: u64,
151    },
152    /// This message does not have a seq number and should be delivered
153    /// immediately.
154    Direct,
155}
156
157impl SeqInfo {
158    /// Whether this sequencing metadata is valid for receive-side ordering.
159    pub fn is_valid(&self) -> bool {
160        !matches!(self, Self::Session { seq: 0, .. })
161    }
162}
163
164impl fmt::Display for SeqInfo {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::Direct => write!(f, "direct"),
168            Self::Session { session_id, seq } => write!(f, "{}:{}", session_id, seq),
169        }
170    }
171}
172
173impl std::str::FromStr for SeqInfo {
174    type Err = anyhow::Error;
175
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        if s == "direct" {
178            return Ok(SeqInfo::Direct);
179        }
180
181        let parts: Vec<_> = s.split(':').collect();
182        if parts.len() != 2 {
183            return Err(anyhow::anyhow!("invalid SeqInfo: {}", s));
184        }
185        let session_id: Uuid = parts[0].parse()?;
186        let seq: u64 = parts[1].parse()?;
187        Ok(SeqInfo::Session { session_id, seq })
188    }
189}
190
191declare_attrs! {
192    /// The sender of this message, the session ID, and the message's sequence
193    /// number assigned by this session.
194    pub attr SEQ_INFO: SeqInfo;
195}
196
197/// Used by sender to track the message sequence numbers it sends to each destination.
198/// Each [Sequencer] object has a session id, sequence numbers are scoped by
199/// the (session_id, SeqKey) pair.
200#[derive(Clone, Debug)]
201pub struct Sequencer {
202    session_id: Uuid,
203    // Map's key is the sequence key (actor or port), value is the last seq number.
204    last_seqs: Arc<Mutex<HashMap<SeqKey, u64>>>,
205}
206
207impl Sequencer {
208    pub(crate) fn new(session_id: Uuid) -> Self {
209        Self {
210            session_id,
211            last_seqs: Arc::new(Mutex::new(HashMap::new())),
212        }
213    }
214
215    /// Assign the next seq for a port, mutate the sequencer with the new seq,
216    /// and return the new seq.
217    ///
218    /// - Handler ports: share the same sequence scheme per actor (keyed by ActorAddr).
219    /// - Ephemeral ports: get individual sequence schemes (keyed by PortAddr).
220    /// - Control ports: bypass receive-side reordering and use [`SeqInfo::Direct`].
221    pub fn assign_seq(&self, port_id: &PortAddr) -> SeqInfo {
222        if port_id.port().is_control() {
223            return SeqInfo::Direct;
224        }
225
226        let key = if port_id.is_handler_port() {
227            SeqKey::Actor(port_id.actor_addr().clone())
228        } else {
229            SeqKey::Port(port_id.clone())
230        };
231
232        let mut guard = self.last_seqs.lock().unwrap();
233        let entry = guard.entry(key).or_default();
234        *entry += 1;
235        SeqInfo::Session {
236            session_id: self.session_id,
237            seq: *entry,
238        }
239    }
240
241    /// Id of the session this sequencer belongs to.
242    pub fn session_id(&self) -> Uuid {
243        self.session_id
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::collections::HashSet;
250    use std::sync::Arc;
251    use std::sync::atomic::AtomicUsize;
252    use std::sync::atomic::Ordering as AtomicOrdering;
253
254    use async_trait::async_trait;
255    use hyperactor_config::Flattrs;
256    use rand::SeedableRng;
257    use rand::rngs::StdRng;
258    use rand::seq::SliceRandom;
259    use timed_test::async_timed_test;
260    use tokio::sync::Barrier;
261    use tokio::sync::oneshot;
262
263    use super::*;
264    use crate as hyperactor;
265    use crate::Actor;
266    use crate::ActorHandle;
267    use crate::ActorRef;
268    use crate::Context;
269    use crate::Endpoint as _;
270    use crate::Handler;
271    use crate::Proc;
272    use crate::config;
273    use crate::introspect::IntrospectMessage;
274    use crate::mailbox::headers::SENDER_ACTOR_ID;
275    use crate::mailbox::headers::stamp_sender_actor_id;
276    use crate::port::ControlPort;
277    use crate::port::Port;
278    use crate::proc::StatusMessage;
279    use crate::testing::ids::test_actor_id;
280
281    /// Test message type 1 for handler port sequencing tests.
282    #[derive(Named)]
283    struct TestMsg1;
284
285    /// Test message type 2 for handler port sequencing tests.
286    #[derive(Named)]
287    struct TestMsg2;
288
289    /// Helper to extract seq from SeqInfo::Session variant (for tests only)
290    fn get_seq(seq_info: SeqInfo) -> u64 {
291        match seq_info {
292            SeqInfo::Session { seq, .. } => seq,
293            SeqInfo::Direct => panic!("expected Session variant, got Direct"),
294        }
295    }
296
297    #[test]
298    fn seq_info_validity_rejects_zero_session_seq() {
299        let session_id = Uuid::now_v7();
300
301        assert!(SeqInfo::Direct.is_valid());
302        assert!(SeqInfo::Session { session_id, seq: 1 }.is_valid());
303        assert!(!SeqInfo::Session { session_id, seq: 0 }.is_valid());
304    }
305
306    #[test]
307    fn test_sequencer_clone() {
308        let sequencer = Sequencer {
309            session_id: Uuid::now_v7(),
310            last_seqs: Arc::new(Mutex::new(HashMap::new())),
311        };
312
313        let actor_ref: ActorAddr = test_actor_id("test_0", "test");
314        let port_ref = actor_ref.port_addr(Port::from(1));
315
316        // Modify original sequencer
317        sequencer.assign_seq(&port_ref);
318        sequencer.assign_seq(&port_ref);
319
320        // Clone should share the same state
321        let cloned_sequencer = sequencer.clone();
322        assert_eq!(sequencer.session_id(), cloned_sequencer.session_id(),);
323        assert_eq!(get_seq(cloned_sequencer.assign_seq(&port_ref)), 3);
324    }
325
326    #[test]
327    fn test_sequencer_handler_ports_share_sequence() {
328        let sequencer = Sequencer {
329            session_id: Uuid::now_v7(),
330            last_seqs: Arc::new(Mutex::new(HashMap::new())),
331        };
332
333        let actor_ref: ActorAddr = test_actor_id("worker_0", "worker");
334        // Two different handler ports for the same actor.
335        let handler_port_1 = actor_ref.port_addr(Port::handler::<TestMsg1>());
336        let handler_port_2 = actor_ref.port_addr(Port::handler::<TestMsg2>());
337
338        // Handler ports should share a sequence (keyed by ActorAddr)
339        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_1)), 1);
340        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_2)), 2); // continues from 1
341        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_1)), 3);
342
343        // Handler ports from a different actor get their own shared sequence
344        let actor_ref_2: ActorAddr = test_actor_id("worker_1", "worker");
345        let handler_port_3 = actor_ref_2.port_addr(Port::handler::<TestMsg1>());
346        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_3)), 1); // independent from actor_ref
347    }
348
349    #[test]
350    fn test_sequencer_non_handler_ports_have_independent_sequences() {
351        let sequencer = Sequencer {
352            session_id: Uuid::now_v7(),
353            last_seqs: Arc::new(Mutex::new(HashMap::new())),
354        };
355
356        let actor_ref_0: ActorAddr = test_actor_id("worker_0", "worker");
357        let actor_ref_1: ActorAddr = test_actor_id("worker_1", "worker");
358
359        // Ephemeral ports from the same actor.
360        let port_1 = actor_ref_0.port_addr(Port::from(1));
361        let port_2 = actor_ref_0.port_addr(Port::from(2));
362
363        // Non-handler ports should have independent sequences (keyed by PortAddr)
364        assert_eq!(get_seq(sequencer.assign_seq(&port_1)), 1);
365        assert_eq!(get_seq(sequencer.assign_seq(&port_2)), 1); // independent, starts at 1
366        assert_eq!(get_seq(sequencer.assign_seq(&port_1)), 2);
367        assert_eq!(get_seq(sequencer.assign_seq(&port_2)), 2);
368
369        // Non-handler ports from different actors are also independent
370        let port_3 = actor_ref_1.port_addr(Port::from(1));
371        assert_eq!(get_seq(sequencer.assign_seq(&port_3)), 1); // independent from port_1
372        assert_eq!(get_seq(sequencer.assign_seq(&port_1)), 3);
373        assert_eq!(get_seq(sequencer.assign_seq(&port_3)), 2);
374    }
375
376    #[test]
377    fn test_sequencer_mixed_handler_and_non_handler_ports() {
378        let sequencer = Sequencer {
379            session_id: Uuid::now_v7(),
380            last_seqs: Arc::new(Mutex::new(HashMap::new())),
381        };
382
383        let actor_ref: ActorAddr = test_actor_id("worker_0", "worker");
384
385        // Handler ports (share sequence per actor)
386        let handler_port_1 = actor_ref.port_addr(Port::handler::<TestMsg1>());
387        let handler_port_2 = actor_ref.port_addr(Port::handler::<TestMsg2>());
388
389        // Non-handler ports (independent sequences per port)
390        let non_handler_port_1 = actor_ref.port_addr(Port::from(1));
391        let non_handler_port_2 = actor_ref.port_addr(Port::from(2));
392
393        // Interleave sends to all port types
394        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_1)), 1);
395        assert_eq!(get_seq(sequencer.assign_seq(&non_handler_port_1)), 1); // independent
396        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_2)), 2); // continues handler sequence
397        assert_eq!(get_seq(sequencer.assign_seq(&non_handler_port_2)), 1); // independent
398        assert_eq!(get_seq(sequencer.assign_seq(&non_handler_port_1)), 2); // continues its own
399        assert_eq!(get_seq(sequencer.assign_seq(&handler_port_1)), 3); // continues handler sequence
400        assert_eq!(get_seq(sequencer.assign_seq(&non_handler_port_2)), 2); // continues its own
401    }
402
403    #[test]
404    fn bypass_registry_introspect_message() {
405        assert!(is_bypass_workq_type_id(TypeId::of::<IntrospectMessage>()));
406    }
407
408    #[test]
409    fn bypass_registry_status_message() {
410        assert!(is_bypass_workq_type_id(TypeId::of::<StatusMessage>()));
411    }
412
413    #[test]
414    fn control_port_uses_direct_seq_info() {
415        // Control messages bypass the actor work queue and therefore must not
416        // allocate session sequence numbers that a receive-side reorder buffer
417        // would interpret.
418        let sequencer = Sequencer::new(Uuid::now_v7());
419        let actor_ref: ActorAddr = test_actor_id("agent_0", "proc_agent");
420
421        let introspect_port = actor_ref.port_addr(Port::control(ControlPort::Introspect));
422        let regular_actor_port = actor_ref.port_addr(Port::handler::<TestMsg1>());
423
424        assert_eq!(sequencer.assign_seq(&introspect_port), SeqInfo::Direct);
425        assert_eq!(get_seq(sequencer.assign_seq(&regular_actor_port)), 1);
426        assert_eq!(sequencer.assign_seq(&introspect_port), SeqInfo::Direct);
427        assert_eq!(get_seq(sequencer.assign_seq(&regular_actor_port)), 2);
428    }
429
430    /// Sequencer-level test for the debug-skip helper's underlying behavior:
431    /// `assign_seq` called `count` times advances the per-(actor,dest)
432    /// counter, so a subsequent `assign_seq` returns `count + 1` not 1.
433    /// The `Instance<A>::debug_skip_next_ordering_seq` method is a tiny
434    /// for-loop of `assign_seq` calls; its correctness reduces to this.
435    #[test]
436    fn test_sequencer_skip_advances_counter() {
437        let sequencer = Sequencer::new(Uuid::now_v7());
438        let actor_ref: ActorAddr = test_actor_id("test_0", "test");
439        let dest = actor_ref.port_addr(Port::handler::<TestMsg1>());
440
441        // Assign once: seq 1.
442        assert_eq!(get_seq(sequencer.assign_seq(&dest)), 1);
443
444        // Skip 2 (the helper would loop assign_seq).
445        for _ in 0..2 {
446            let _ = sequencer.assign_seq(&dest);
447        }
448
449        // Next assignment is seq 4, not 2.
450        assert_eq!(get_seq(sequencer.assign_seq(&dest)), 4);
451    }
452
453    /// Pins the exact `AttrValue` contract used by `declare_attrs!`
454    /// storage: `AttrValue::display(&self) -> String` then
455    /// `AttrValue::parse(&str) -> Result<Self, _>`.
456    #[test]
457    fn test_snapshot_attr_roundtrip() {
458        let addr: ActorAddr = test_actor_id("a", "client");
459        let snap = OrderingSnapshot {
460            enabled: true,
461            sessions: vec![OrderingSessionSnapshot {
462                session_id: Uuid::from_u128(42),
463                sender: Some(addr),
464                last_released_seq: 7,
465                expected_next_seq: 8,
466                buffered_count: 2,
467                oldest_buffered_seq: Some(9),
468                newest_buffered_seq: Some(11),
469            }],
470            skipped_session_count: 3,
471        };
472        let s = AttrValue::display(&snap);
473        let parsed = <OrderingSnapshot as AttrValue>::parse(&s).unwrap();
474        assert_eq!(snap, parsed);
475    }
476
477    // End-to-end ordering tests with real actor mailboxes. SenderActors
478    // send normally to a ChaosActor. ChaosActor forwards the same messages
479    // to ReceiverActor, but in shuffled batches and with optional duplicate
480    // replay. ReceiverActor only records what its handler observes; its
481    // sequenced handler receiver restores per-session order.
482
483    #[derive(Clone, Debug, Serialize, Deserialize, Named)]
484    struct Frame {
485        sender_idx: u32,
486        payload_idx: u64,
487    }
488
489    #[derive(Clone, Debug, Serialize, Deserialize, Named)]
490    struct SenderDone {
491        sender_idx: u32,
492    }
493
494    // What ReceiverActor observed after its sequenced handler receiver released a
495    // frame: the preserved ordering header, the sender-owner header, and the
496    // test payload.
497    type ReceivedFrame = (SeqInfo, Option<ActorAddr>, Frame);
498
499    // Start is only sent to a local ActorHandle, so it can carry local-only
500    // test handles like ActorHandle and Arc<Barrier>. Do not export it.
501    #[derive(Clone, Debug)]
502    struct Start {
503        count: u64,
504        target: ActorHandle<ChaosActor>,
505        start_barrier: Option<Arc<Barrier>>,
506        yield_between_frames: bool,
507    }
508
509    #[derive(Debug)]
510    struct SenderActor {
511        sender_idx: u32,
512    }
513
514    #[async_trait]
515    impl Actor for SenderActor {}
516
517    #[async_trait]
518    impl Handler<Start> for SenderActor {
519        async fn handle(&mut self, cx: &Context<Self>, msg: Start) -> Result<(), anyhow::Error> {
520            let Start {
521                count,
522                target,
523                start_barrier,
524                yield_between_frames,
525            } = msg;
526            if let Some(b) = start_barrier {
527                b.wait().await;
528            }
529            for payload_idx in 0..count {
530                target.post(
531                    cx,
532                    Frame {
533                        sender_idx: self.sender_idx,
534                        payload_idx,
535                    },
536                );
537                if yield_between_frames {
538                    tokio::task::yield_now().await;
539                }
540            }
541            target.post(
542                cx,
543                SenderDone {
544                    sender_idx: self.sender_idx,
545                },
546            );
547            Ok(())
548        }
549    }
550
551    // Which original frames should be replayed after their batch has been
552    // forwarded once.
553    #[derive(Debug, Clone, Copy)]
554    enum DuplicatePolicy {
555        None,
556        EveryNth { stride: usize },
557    }
558
559    #[derive(Debug, Default)]
560    struct ChaosStats {
561        // Number of original frames that reveal a per-session inversion.
562        // The counter is bumped on the lower sequence number, after a higher
563        // sequence number from the same SEQ_INFO session was already
564        // forwarded.
565        out_of_order_original_forwards: AtomicUsize,
566
567        // Number of original frames selected for replay.
568        duplicates_selected: AtomicUsize,
569
570        // Number of selected replays actually forwarded.
571        late_duplicate_forwards: AtomicUsize,
572
573        // Sessions that saw at least one inversion.
574        out_of_order_sessions: Mutex<HashSet<Uuid>>,
575
576        // Every frame ChaosActor forwarded, in forward order. Printed on
577        // failures so the panic includes the chaos-side trace.
578        forwarded_trace: Mutex<Vec<ForwardedEntry>>,
579
580        // Frames observed by ReceiverActor after it already sent the result
581        // snapshot. Non-zero is a failure. Zero is diagnostic only because
582        // the test does not wait for a final drain.
583        receiver_overflow: AtomicUsize,
584    }
585
586    // Used only in panic output.
587    #[allow(dead_code)]
588    #[derive(Debug, Clone)]
589    struct ForwardedEntry {
590        session_id: Uuid,
591        seq: u64,
592        sender_idx: u32,
593        is_duplicate: bool,
594    }
595
596    #[derive(Debug)]
597    #[hyperactor::export(handlers = [Frame, SenderDone])]
598    struct ChaosActor {
599        window: Vec<(SeqInfo, Flattrs, Frame)>,
600        window_size: usize,
601        target_port: PortAddr,
602        rng: StdRng,
603        expected_dones: u32,
604        done_count: u32,
605        expected_count_per_sender: u64,
606        duplicate_policy: DuplicatePolicy,
607        duplicate_cursor: usize,
608        stats: Arc<ChaosStats>,
609        session_owners: HashMap<Uuid, ActorAddr>,
610        max_forwarded_seq_by_session: HashMap<Uuid, u64>,
611    }
612
613    #[async_trait]
614    impl Actor for ChaosActor {}
615
616    impl ChaosActor {
617        // Forward the current batch to the receiver. The batch is shuffled,
618        // then adjusted if needed so at least one session is out of order
619        // whenever the batch has enough frames to make that possible.
620        fn flush(&mut self, cx: &Context<Self>) -> Result<(), anyhow::Error> {
621            self.window.shuffle(&mut self.rng);
622
623            // A shuffled batch can accidentally still be ordered. If so,
624            // pick the first session in window order that has at least two
625            // frames and swap its first two frames.
626            let mut first_seen_order: Vec<Uuid> = Vec::new();
627            let mut session_indices: HashMap<Uuid, Vec<usize>> = HashMap::new();
628            for (i, (seq_info, _, _)) in self.window.iter().enumerate() {
629                if let SeqInfo::Session { session_id, .. } = seq_info {
630                    if !session_indices.contains_key(session_id) {
631                        first_seen_order.push(*session_id);
632                    }
633                    session_indices.entry(*session_id).or_default().push(i);
634                }
635            }
636            for sid in &first_seen_order {
637                let indices = &session_indices[sid];
638                if indices.len() < 2 {
639                    continue;
640                }
641                let mut already_inverted = false;
642                for w in indices.windows(2) {
643                    if seq_of(&self.window[w[0]].0) > seq_of(&self.window[w[1]].0) {
644                        already_inverted = true;
645                        break;
646                    }
647                }
648                if !already_inverted {
649                    self.window.swap(indices[0], indices[1]);
650                    break;
651                }
652            }
653
654            // Send each shuffled frame once. Some frames are cloned for a
655            // later replay.
656            let mut duplicates: Vec<(SeqInfo, Flattrs, Frame)> = Vec::new();
657            let entries: Vec<(SeqInfo, Flattrs, Frame)> = self.window.drain(..).collect();
658            for (seq_info, headers, frame) in entries {
659                let (session_id, seq) = match &seq_info {
660                    SeqInfo::Session { session_id, seq } => (*session_id, *seq),
661                    SeqInfo::Direct => panic!("Direct SeqInfo at flush"),
662                };
663                // This frame proves an inversion if a higher sequence number
664                // from the same session was already forwarded.
665                let prev_max = self
666                    .max_forwarded_seq_by_session
667                    .get(&session_id)
668                    .copied()
669                    .unwrap_or(0);
670                if seq < prev_max {
671                    self.stats
672                        .out_of_order_original_forwards
673                        .fetch_add(1, AtomicOrdering::Relaxed);
674                    self.stats
675                        .out_of_order_sessions
676                        .lock()
677                        .unwrap()
678                        .insert(session_id);
679                }
680                self.max_forwarded_seq_by_session
681                    .insert(session_id, prev_max.max(seq));
682                // Select every Nth original frame for later replay.
683                let should_duplicate = match self.duplicate_policy {
684                    DuplicatePolicy::None => false,
685                    DuplicatePolicy::EveryNth { stride } => {
686                        assert!(stride > 0, "DuplicatePolicy::EveryNth requires stride > 0");
687                        self.duplicate_cursor = self.duplicate_cursor.wrapping_add(1);
688                        self.duplicate_cursor.is_multiple_of(stride)
689                    }
690                };
691                if should_duplicate {
692                    duplicates.push((seq_info.clone(), headers.clone(), frame.clone()));
693                    self.stats
694                        .duplicates_selected
695                        .fetch_add(1, AtomicOrdering::Relaxed);
696                }
697                // Forward with the original sequence header and original
698                // sender owner, like a routing hop would.
699                let owner = self
700                    .session_owners
701                    .get(&session_id)
702                    .expect("session owner missing at original forward")
703                    .clone();
704                let dest = self.target_port.clone();
705                let mut outbound = headers;
706                stamp_sender_actor_id(&mut outbound, &seq_info, &dest, &owner);
707                cx.post_with_external_seq_info(dest, outbound, wirevalue::Any::serialize(&frame)?);
708                self.stats
709                    .forwarded_trace
710                    .lock()
711                    .unwrap()
712                    .push(ForwardedEntry {
713                        session_id,
714                        seq,
715                        sender_idx: frame.sender_idx,
716                        is_duplicate: false,
717                    });
718            }
719
720            // Replay selected frames after the original batch. A duplicate
721            // that arrives while its original is still buffered is dropped
722            // as a duplicate; these replays are intended to hit the
723            // late-duplicate drop path instead.
724            for (seq_info, headers, frame) in duplicates {
725                let (session_id, seq) = match &seq_info {
726                    SeqInfo::Session { session_id, seq } => (*session_id, *seq),
727                    SeqInfo::Direct => unreachable!(),
728                };
729                let owner = self
730                    .session_owners
731                    .get(&session_id)
732                    .expect("session owner missing at duplicate forward")
733                    .clone();
734                let dest = self.target_port.clone();
735                let mut outbound = headers;
736                stamp_sender_actor_id(&mut outbound, &seq_info, &dest, &owner);
737                cx.post_with_external_seq_info(dest, outbound, wirevalue::Any::serialize(&frame)?);
738                self.stats
739                    .late_duplicate_forwards
740                    .fetch_add(1, AtomicOrdering::Relaxed);
741                self.stats
742                    .forwarded_trace
743                    .lock()
744                    .unwrap()
745                    .push(ForwardedEntry {
746                        session_id,
747                        seq,
748                        sender_idx: frame.sender_idx,
749                        is_duplicate: true,
750                    });
751            }
752
753            Ok(())
754        }
755    }
756
757    fn seq_of(s: &SeqInfo) -> u64 {
758        match s {
759            SeqInfo::Session { seq, .. } => *seq,
760            SeqInfo::Direct => panic!("seq_of called on SeqInfo::Direct"),
761        }
762    }
763
764    #[async_trait]
765    impl Handler<Frame> for ChaosActor {
766        async fn handle(&mut self, cx: &Context<Self>, frame: Frame) -> Result<(), anyhow::Error> {
767            let seq_info = cx.headers().get(SEQ_INFO);
768            let (session_id, seq) = match &seq_info {
769                Some(SeqInfo::Session { session_id, seq }) => (*session_id, *seq),
770                Some(SeqInfo::Direct) => {
771                    panic!("chaos inbound has SeqInfo::Direct; bind misconfigured")
772                }
773                None => panic!("chaos inbound missing SEQ_INFO"),
774            };
775            let sender_addr = cx.headers().get(SENDER_ACTOR_ID);
776            if seq <= 4 {
777                assert!(
778                    sender_addr.is_some(),
779                    "missing SENDER_ACTOR_ID on early-session chaos inbound (seq={seq})",
780                );
781            }
782            if let Some(addr) = &sender_addr {
783                match self.session_owners.entry(session_id) {
784                    std::collections::hash_map::Entry::Vacant(v) => {
785                        v.insert(addr.clone());
786                    }
787                    std::collections::hash_map::Entry::Occupied(o) => {
788                        assert_eq!(
789                            o.get(),
790                            addr,
791                            "session owner changed mid-stream for session_id={session_id}",
792                        );
793                    }
794                }
795            }
796            let inbound_headers = cx.headers().clone();
797            self.window
798                .push((seq_info.unwrap(), inbound_headers, frame));
799            if self.window.len() >= self.window_size {
800                self.flush(cx)?;
801            }
802            Ok(())
803        }
804    }
805
806    #[async_trait]
807    impl Handler<SenderDone> for ChaosActor {
808        async fn handle(
809            &mut self,
810            cx: &Context<Self>,
811            done: SenderDone,
812        ) -> Result<(), anyhow::Error> {
813            let seq_info = cx.headers().get(SEQ_INFO);
814            let (session_id, seq) = match &seq_info {
815                Some(SeqInfo::Session { session_id, seq }) => (*session_id, *seq),
816                Some(SeqInfo::Direct) => {
817                    panic!("SenderDone arrived as SeqInfo::Direct; bind misconfigured")
818                }
819                None => panic!("SenderDone missing SEQ_INFO"),
820            };
821            // The sender posts all Frames and then SenderDone to the same
822            // actor. They should share one sequence stream.
823            assert_eq!(
824                seq,
825                self.expected_count_per_sender + 1,
826                "SenderDone(sender_idx={}) seq={seq}; expected {} (count+1)",
827                done.sender_idx,
828                self.expected_count_per_sender + 1,
829            );
830            assert!(
831                self.session_owners.contains_key(&session_id),
832                "SenderDone(sender_idx={}) for session_id={session_id} \
833                 before any Frame from that session",
834                done.sender_idx,
835            );
836            // SenderDone is usually past the early messages that carry the
837            // sender-owner header. If the header is present, it must still
838            // match the session owner learned from the Frames.
839            if let Some(addr) = cx.headers().get(SENDER_ACTOR_ID) {
840                let owner = self.session_owners.get(&session_id).unwrap();
841                assert_eq!(
842                    &addr, owner,
843                    "SenderDone SENDER_ACTOR_ID inconsistent with session_owner \
844                     for session_id={session_id}",
845                );
846            }
847            assert!(
848                self.done_count < self.expected_dones,
849                "extra SenderDone(sender_idx={}); done_count already at expected_dones={}",
850                done.sender_idx,
851                self.expected_dones,
852            );
853            self.done_count += 1;
854            if self.done_count == self.expected_dones && !self.window.is_empty() {
855                self.flush(cx)?;
856            }
857            Ok(())
858        }
859    }
860
861    #[derive(Debug)]
862    #[hyperactor::export(handlers = [Frame])]
863    struct ReceiverActor {
864        received: Vec<ReceivedFrame>,
865        expected_total: usize,
866        done: Option<oneshot::Sender<Vec<ReceivedFrame>>>,
867        stats: Arc<ChaosStats>,
868    }
869
870    #[async_trait]
871    impl Actor for ReceiverActor {}
872
873    #[async_trait]
874    impl Handler<Frame> for ReceiverActor {
875        async fn handle(&mut self, cx: &Context<Self>, frame: Frame) -> Result<(), anyhow::Error> {
876            let seq_info = match cx.headers().get(SEQ_INFO) {
877                Some(SeqInfo::Session { session_id, seq }) => SeqInfo::Session { session_id, seq },
878                Some(SeqInfo::Direct) => {
879                    panic!("receiver inbound has SeqInfo::Direct; chaos forward bypassed SEQ_INFO")
880                }
881                None => panic!("receiver inbound missing SEQ_INFO"),
882            };
883            let sender_addr = cx.headers().get(SENDER_ACTOR_ID);
884            // Anything after the snapshot is an extra delivery. Count it, but
885            // keep the original snapshot unchanged for assertions.
886            if self.done.is_none() {
887                self.stats
888                    .receiver_overflow
889                    .fetch_add(1, AtomicOrdering::Relaxed);
890                return Ok(());
891            }
892            self.received.push((seq_info, sender_addr, frame));
893            if self.received.len() == self.expected_total {
894                let tx = self.done.take().expect("done sender already consumed");
895                let snapshot = std::mem::take(&mut self.received);
896                let _ = tx.send(snapshot);
897            }
898            Ok(())
899        }
900    }
901
902    // Checks the receiver-side invariants shared by all chaos tests. Panics
903    // include the ChaosActor forward trace.
904    fn assert_received_protocol_correct(
905        received: &[ReceivedFrame],
906        expected_senders: usize,
907        expected_count_per_sender: u64,
908        sender_addrs: &[ActorAddr],
909        stats: &ChaosStats,
910    ) {
911        let expected_total = expected_senders * (expected_count_per_sender as usize);
912        let dump_trace = || -> String {
913            let trace = stats.forwarded_trace.lock().unwrap();
914            format!("forwarded_trace ({} entries) = {:#?}", trace.len(), *trace,)
915        };
916
917        assert_eq!(
918            received.len(),
919            expected_total,
920            "expected {} frames at receiver, got {}; {}",
921            expected_total,
922            received.len(),
923            dump_trace(),
924        );
925
926        // This catches extra deliveries after the receiver snapshot.
927        let overflow = stats.receiver_overflow.load(AtomicOrdering::Acquire);
928        assert_eq!(
929            overflow,
930            0,
931            "stats.receiver_overflow = {overflow}; duplicate leaked past \
932             sequenced receiver drop branch after snapshot; {}",
933            dump_trace(),
934        );
935
936        // Each logical sender should map to exactly one wire session.
937        let mut sender_session: HashMap<u32, Uuid> = HashMap::new();
938        for (seq_info, _, frame) in received {
939            let session_id = match seq_info {
940                SeqInfo::Session { session_id, .. } => *session_id,
941                SeqInfo::Direct => {
942                    panic!("receiver captured Direct SEQ_INFO; {}", dump_trace())
943                }
944            };
945            match sender_session.entry(frame.sender_idx) {
946                std::collections::hash_map::Entry::Vacant(v) => {
947                    v.insert(session_id);
948                }
949                std::collections::hash_map::Entry::Occupied(o) => {
950                    assert_eq!(
951                        o.get(),
952                        &session_id,
953                        "sender_idx={} mapped to two distinct sessions ({} and {}); {}",
954                        frame.sender_idx,
955                        o.get(),
956                        session_id,
957                        dump_trace(),
958                    );
959                }
960            }
961        }
962
963        // And each wire session should belong to exactly one logical sender.
964        assert_eq!(
965            sender_session.len(),
966            expected_senders,
967            "expected {} distinct sender_idx, got {}; {}",
968            expected_senders,
969            sender_session.len(),
970            dump_trace(),
971        );
972        let distinct_sessions: HashSet<Uuid> = sender_session.values().copied().collect();
973        assert_eq!(
974            distinct_sessions.len(),
975            expected_senders,
976            "sender_idx -> session_id is not a bijection (got {} distinct sessions for {} senders); {}",
977            distinct_sessions.len(),
978            expected_senders,
979            dump_trace(),
980        );
981
982        // Each sender's delivered subsequence should be exactly 0..K payloads
983        // and 1..=K sequence numbers.
984        for sender_idx in 0..(expected_senders as u32) {
985            let subseq: Vec<&ReceivedFrame> = received
986                .iter()
987                .filter(|(_, _, f)| f.sender_idx == sender_idx)
988                .collect();
989            assert_eq!(
990                subseq.len() as u64,
991                expected_count_per_sender,
992                "sender_idx={sender_idx}: expected {} frames, got {}; {}",
993                expected_count_per_sender,
994                subseq.len(),
995                dump_trace(),
996            );
997            for (i, (seq_info, _, frame)) in subseq.iter().enumerate() {
998                let expected_payload = i as u64;
999                assert_eq!(
1000                    frame.payload_idx,
1001                    expected_payload,
1002                    "sender_idx={sender_idx}, position {i}: payload_idx={} (expected {}); {}",
1003                    frame.payload_idx,
1004                    expected_payload,
1005                    dump_trace(),
1006                );
1007                let expected_seq = (i as u64) + 1;
1008                let actual_seq = seq_of(seq_info);
1009                assert_eq!(
1010                    actual_seq,
1011                    expected_seq,
1012                    "sender_idx={sender_idx}, position {i}: SEQ_INFO.seq={} (expected {}); {}",
1013                    actual_seq,
1014                    expected_seq,
1015                    dump_trace(),
1016                );
1017            }
1018        }
1019
1020        // Early messages should identify the actor that owns the sequence.
1021        for (seq_info, sender_addr_opt, frame) in received {
1022            let seq = seq_of(seq_info);
1023            if seq > 4 {
1024                continue;
1025            }
1026            let expected_addr = &sender_addrs[frame.sender_idx as usize];
1027            let captured = sender_addr_opt.as_ref().unwrap_or_else(|| {
1028                panic!(
1029                    "sender_idx={}, seq={seq}: SENDER_ACTOR_ID is None at early-session; {}",
1030                    frame.sender_idx,
1031                    dump_trace(),
1032                )
1033            });
1034            assert_eq!(
1035                captured,
1036                expected_addr,
1037                "sender_idx={}, seq={seq}: SENDER_ACTOR_ID {captured:?} != expected {expected_addr:?}; {}",
1038                frame.sender_idx,
1039                dump_trace(),
1040            );
1041        }
1042    }
1043
1044    // One sender, shuffled batches, no duplicates.
1045    #[async_timed_test(timeout_secs = 30)]
1046    async fn test_chaos_single_sender_preserves_order() {
1047        let config = hyperactor_config::global::lock();
1048        let _g = config.override_key(config::ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1049
1050        let proc = Proc::isolated();
1051        let client = proc.client("client");
1052
1053        let stats = Arc::new(ChaosStats::default());
1054        let n: usize = 1;
1055        let k: u64 = 50;
1056
1057        let (done_tx, done_rx) = oneshot::channel();
1058        let receiver_handle = proc.spawn_with_label(
1059            "receiver",
1060            ReceiverActor {
1061                received: Vec::new(),
1062                expected_total: n * (k as usize),
1063                done: Some(done_tx),
1064                stats: stats.clone(),
1065            },
1066        );
1067        let receiver_ref: ActorRef<ReceiverActor> = receiver_handle.bind();
1068        let target_port = receiver_ref.port::<Frame>().port_addr().clone();
1069
1070        let chaos_handle = proc.spawn_with_label(
1071            "chaos",
1072            ChaosActor {
1073                window: Vec::new(),
1074                window_size: 10,
1075                target_port,
1076                rng: StdRng::seed_from_u64(0xC4A0_5EED),
1077                expected_dones: n as u32,
1078                done_count: 0,
1079                expected_count_per_sender: k,
1080                duplicate_policy: DuplicatePolicy::None,
1081                duplicate_cursor: 0,
1082                stats: stats.clone(),
1083                session_owners: HashMap::new(),
1084                max_forwarded_seq_by_session: HashMap::new(),
1085            },
1086        );
1087        // Bind exported handler ports so local handle posts carry
1088        // SEQ_INFO::Session instead of SeqInfo::Direct.
1089        let _bound_chaos_ref: ActorRef<ChaosActor> = chaos_handle.bind();
1090
1091        let sender_handle = proc.spawn_with_label("sender0", SenderActor { sender_idx: 0 });
1092        let sender_addr = sender_handle.actor_addr().clone();
1093
1094        sender_handle.post(
1095            &client,
1096            Start {
1097                count: k,
1098                target: chaos_handle.clone(),
1099                start_barrier: None,
1100                yield_between_frames: false,
1101            },
1102        );
1103
1104        let received = match tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await
1105        {
1106            Ok(Ok(v)) => v,
1107            Ok(Err(_)) => panic!("done_rx sender dropped before sending; receiver crashed?"),
1108            Err(_) => {
1109                let trace = stats.forwarded_trace.lock().unwrap();
1110                panic!(
1111                    "timed out waiting for receiver; forwarded_trace ({} entries) = {:#?}",
1112                    trace.len(),
1113                    *trace,
1114                );
1115            }
1116        };
1117
1118        assert_received_protocol_correct(&received, n, k, &[sender_addr], &stats);
1119
1120        let oo = stats
1121            .out_of_order_original_forwards
1122            .load(AtomicOrdering::Acquire);
1123        assert!(
1124            oo > 0,
1125            "expected out_of_order_original_forwards > 0, got {oo}: \
1126             chaos shuffle never inverted a real frame (shuffle was identity?)",
1127        );
1128    }
1129
1130    // Multiple senders share one chaos actor. Each sender must be delivered
1131    // in order; cross-sender interleaving is unconstrained.
1132    #[async_timed_test(timeout_secs = 30)]
1133    async fn test_chaos_multi_sender_preserves_per_session_order() {
1134        let config = hyperactor_config::global::lock();
1135        let _g = config.override_key(config::ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1136
1137        let proc = Proc::isolated();
1138        let client = proc.client("client");
1139
1140        let stats = Arc::new(ChaosStats::default());
1141        let n: usize = 4;
1142        let k: u64 = 25;
1143
1144        let (done_tx, done_rx) = oneshot::channel();
1145        let receiver_handle = proc.spawn_with_label(
1146            "receiver",
1147            ReceiverActor {
1148                received: Vec::new(),
1149                expected_total: n * (k as usize),
1150                done: Some(done_tx),
1151                stats: stats.clone(),
1152            },
1153        );
1154        let receiver_ref: ActorRef<ReceiverActor> = receiver_handle.bind();
1155        let target_port = receiver_ref.port::<Frame>().port_addr().clone();
1156
1157        let chaos_handle = proc.spawn_with_label(
1158            "chaos",
1159            ChaosActor {
1160                window: Vec::new(),
1161                window_size: 10,
1162                target_port,
1163                rng: StdRng::seed_from_u64(0xC4A0_5EED),
1164                expected_dones: n as u32,
1165                done_count: 0,
1166                expected_count_per_sender: k,
1167                duplicate_policy: DuplicatePolicy::None,
1168                duplicate_cursor: 0,
1169                stats: stats.clone(),
1170                session_owners: HashMap::new(),
1171                max_forwarded_seq_by_session: HashMap::new(),
1172            },
1173        );
1174        // Bind exported handler ports so local handle posts carry
1175        // SEQ_INFO::Session instead of SeqInfo::Direct.
1176        let _bound_chaos_ref: ActorRef<ChaosActor> = chaos_handle.bind();
1177
1178        let barrier = Arc::new(Barrier::new(n));
1179        let mut sender_addrs: Vec<ActorAddr> = Vec::with_capacity(n);
1180        let mut sender_handles: Vec<ActorHandle<SenderActor>> = Vec::with_capacity(n);
1181        for sender_idx in 0..n {
1182            let h = proc.spawn_with_label(
1183                &format!("sender{sender_idx}"),
1184                SenderActor {
1185                    sender_idx: sender_idx as u32,
1186                },
1187            );
1188            sender_addrs.push(h.actor_addr().clone());
1189            sender_handles.push(h);
1190        }
1191        for h in &sender_handles {
1192            h.post(
1193                &client,
1194                Start {
1195                    count: k,
1196                    target: chaos_handle.clone(),
1197                    start_barrier: Some(barrier.clone()),
1198                    yield_between_frames: true,
1199                },
1200            );
1201        }
1202
1203        let received = match tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await
1204        {
1205            Ok(Ok(v)) => v,
1206            Ok(Err(_)) => panic!("done_rx sender dropped before sending; receiver crashed?"),
1207            Err(_) => {
1208                let trace = stats.forwarded_trace.lock().unwrap();
1209                panic!(
1210                    "timed out waiting for receiver; forwarded_trace ({} entries) = {:#?}",
1211                    trace.len(),
1212                    *trace,
1213                );
1214            }
1215        };
1216
1217        assert_received_protocol_correct(&received, n, k, &sender_addrs, &stats);
1218
1219        let oo = stats
1220            .out_of_order_original_forwards
1221            .load(AtomicOrdering::Acquire);
1222        assert!(
1223            oo > 0,
1224            "expected out_of_order_original_forwards > 0, got {oo}",
1225        );
1226        // Keep the diagnostic set consistent with the global counter.
1227        let sessions_with_inversion = stats.out_of_order_sessions.lock().unwrap().len();
1228        assert!(
1229            sessions_with_inversion >= 1,
1230            "expected out_of_order_sessions.len() >= 1, got {sessions_with_inversion}",
1231        );
1232    }
1233
1234    // Same as the multi-sender case, but replay every 10th original frame.
1235    // The receiver should still see each original exactly once.
1236    #[async_timed_test(timeout_secs = 30)]
1237    async fn test_chaos_drops_duplicates() {
1238        let config = hyperactor_config::global::lock();
1239        let _g = config.override_key(config::ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1240
1241        let proc = Proc::isolated();
1242        let client = proc.client("client");
1243
1244        let stats = Arc::new(ChaosStats::default());
1245        let n: usize = 4;
1246        let k: u64 = 25;
1247
1248        let (done_tx, done_rx) = oneshot::channel();
1249        let receiver_handle = proc.spawn_with_label(
1250            "receiver",
1251            ReceiverActor {
1252                received: Vec::new(),
1253                expected_total: n * (k as usize),
1254                done: Some(done_tx),
1255                stats: stats.clone(),
1256            },
1257        );
1258        let receiver_ref: ActorRef<ReceiverActor> = receiver_handle.bind();
1259        let target_port = receiver_ref.port::<Frame>().port_addr().clone();
1260
1261        let chaos_handle = proc.spawn_with_label(
1262            "chaos",
1263            ChaosActor {
1264                window: Vec::new(),
1265                window_size: 10,
1266                target_port,
1267                rng: StdRng::seed_from_u64(0xC4A0_5EED),
1268                expected_dones: n as u32,
1269                done_count: 0,
1270                expected_count_per_sender: k,
1271                duplicate_policy: DuplicatePolicy::EveryNth { stride: 10 },
1272                duplicate_cursor: 0,
1273                stats: stats.clone(),
1274                session_owners: HashMap::new(),
1275                max_forwarded_seq_by_session: HashMap::new(),
1276            },
1277        );
1278        // Bind exported handler ports so local handle posts carry
1279        // SEQ_INFO::Session instead of SeqInfo::Direct.
1280        let _bound_chaos_ref: ActorRef<ChaosActor> = chaos_handle.bind();
1281
1282        let barrier = Arc::new(Barrier::new(n));
1283        let mut sender_addrs: Vec<ActorAddr> = Vec::with_capacity(n);
1284        let mut sender_handles: Vec<ActorHandle<SenderActor>> = Vec::with_capacity(n);
1285        for sender_idx in 0..n {
1286            let h = proc.spawn_with_label(
1287                &format!("sender{sender_idx}"),
1288                SenderActor {
1289                    sender_idx: sender_idx as u32,
1290                },
1291            );
1292            sender_addrs.push(h.actor_addr().clone());
1293            sender_handles.push(h);
1294        }
1295        for h in &sender_handles {
1296            h.post(
1297                &client,
1298                Start {
1299                    count: k,
1300                    target: chaos_handle.clone(),
1301                    start_barrier: Some(barrier.clone()),
1302                    yield_between_frames: true,
1303                },
1304            );
1305        }
1306
1307        let received = match tokio::time::timeout(std::time::Duration::from_secs(5), done_rx).await
1308        {
1309            Ok(Ok(v)) => v,
1310            Ok(Err(_)) => panic!("done_rx sender dropped before sending; receiver crashed?"),
1311            Err(_) => {
1312                let trace = stats.forwarded_trace.lock().unwrap();
1313                panic!(
1314                    "timed out waiting for receiver; forwarded_trace ({} entries) = {:#?}",
1315                    trace.len(),
1316                    *trace,
1317                );
1318            }
1319        };
1320
1321        assert_received_protocol_correct(&received, n, k, &sender_addrs, &stats);
1322
1323        let selected = stats.duplicates_selected.load(AtomicOrdering::Acquire);
1324        let emitted = stats.late_duplicate_forwards.load(AtomicOrdering::Acquire);
1325        assert!(
1326            selected > 0,
1327            "expected duplicates_selected > 0, got {selected}: chaos policy never fired",
1328        );
1329        assert_eq!(
1330            emitted, selected,
1331            "duplicate selections did not match duplicate forwards: duplicates_selected={selected}, \
1332             late_duplicate_forwards={emitted}",
1333        );
1334        let oo = stats
1335            .out_of_order_original_forwards
1336            .load(AtomicOrdering::Acquire);
1337        assert!(
1338            oo > 0,
1339            "expected out_of_order_original_forwards > 0, got {oo}",
1340        );
1341    }
1342}