rlx-driver 0.2.13

RLX driver layer — device handles, arenas, buffers, command streams
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
// RLX — versatile ML compiler + runtime.
// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

//! iroh QUIC + relay [`Transport`] — NAT-traversing peer transport.
//!
//! [`TcpTransport`](crate::net::TcpTransport) is the zero-dep LAN path: it
//! needs every rank on the same routable IP network (Ethernet or a
//! Thunderbolt Bridge link) and reachable listen ports. That's exactly the
//! constraint that forces a `TOPOLOGY=star` workaround when a firewall or NAT
//! sits between two machines.
//!
//! [`IrohTransport`] removes that constraint. It carries the same two-sided,
//! tagged, matched `send`/`recv` surface as every other [`Transport`], but
//! over iroh QUIC connections that hole-punch through NAT and fall back to a
//! relay when a direct path can't be found. Ranks are addressed by their
//! stable ed25519 [`EndpointId`] instead of an `ip:port`, so a pipeline can
//! span machines on different networks / behind CGNAT with no port
//! forwarding — the missing piece for cross-internet
//! `rlx-distributed`/`rlx-collectives` runs.
//!
//! ## Bridging async iroh to the sync [`Transport`] trait
//!
//! The trait is synchronous and blocking; iroh is async. This type owns a
//! multi-thread Tokio runtime. A background accept loop (spawned on that
//! runtime) drains inbound uni-streams and routes each frame into a
//! `Mutex + Condvar` mailbox keyed by `(from_rank, tag)`; the blocking
//! `recv_bytes` parks on that mailbox exactly like a socket reader thread.
//! `send_bytes` `block_on`s a short async task that opens a fresh uni-stream
//! to the peer and writes the frame. One connection is kept per *directed*
//! edge (the sender dials), so both directions of a pair are independent and
//! there is no dial/accept race to coordinate.
//!
//! ## Wire framing
//!
//! Each directed edge (sender → receiver) uses one long-lived, ordered QUIC
//! uni-stream. Every message is a length-prefixed frame on it: a 12-byte
//! little-endian header `[from_rank: u32][tag: u32][len: u32]` followed by
//! `len` payload bytes. Reusing a single ordered stream (rather than one
//! stream per message) preserves FIFO order between frames — the property the
//! ring collectives (`all_reduce`) rely on once there are ≥ 3 ranks, where a
//! rank sends several same-tag frames to a peer before the peer consumes them.

use crate::symmetric::CollectiveError;
use crate::transport::Transport;
use std::collections::{HashMap, VecDeque};
use std::str::FromStr;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};

use iroh::endpoint::presets;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl, SecretKey};
use tokio::runtime::Runtime;

pub use iroh::{RelayMap, RelayMode};

/// Default ALPN for rlx pipeline/collective traffic. Every rank must agree.
pub const RLX_PIPELINE_ALPN: &[u8] = b"rlx-pipeline/1";

/// Largest single frame the mailbox will read from a stream (512 MiB) — an
/// upper bound on a prefill activation block, not a tuning knob.
const MAX_FRAME_BYTES: usize = 512 * 1024 * 1024;

/// How long a dial keeps retrying before `send` gives up — peers may still be
/// binding their endpoints, or (with `connect_discovered`) still publishing /
/// resolving their address via pkarr/DNS discovery, when the first message is
/// handed off.
const DIAL_TIMEOUT: Duration = Duration::from_secs(60);

fn terr(reason: impl Into<String>) -> CollectiveError {
    CollectiveError::TransportError {
        reason: reason.into(),
    }
}

/// How to reach one peer rank over iroh: its stable [`EndpointId`] plus enough
/// addressing hints (a relay URL and/or direct socket addresses) for iroh to
/// establish a path. An id with a relay URL is the robust default — it works
/// across NAT without any direct reachability.
#[derive(Clone, Debug)]
pub struct IrohPeer {
    id: EndpointId,
    relay_url: Option<RelayUrl>,
    direct_addrs: Vec<std::net::SocketAddr>,
}

impl IrohPeer {
    /// A peer known only by its [`EndpointId`] (hex string). Reachability then
    /// relies on discovery configured on the endpoint (or a later
    /// [`with_relay`](Self::with_relay) / [`with_direct`](Self::with_direct)).
    pub fn new(id: EndpointId) -> Self {
        Self {
            id,
            relay_url: None,
            direct_addrs: Vec::new(),
        }
    }

    /// Parse a peer from its hex [`EndpointId`] string (`endpoint.id()`
    /// stringified on the peer).
    pub fn from_id_str(s: &str) -> Result<Self, CollectiveError> {
        let id = EndpointId::from_str(s.trim())
            .map_err(|e| terr(format!("invalid endpoint id {s:?}: {e}")))?;
        Ok(Self::new(id))
    }

    /// Attach a relay URL — the NAT-traversal fallback path.
    pub fn with_relay(mut self, url: &str) -> Result<Self, CollectiveError> {
        self.relay_url = Some(
            RelayUrl::from_str(url).map_err(|e| terr(format!("invalid relay url {url:?}: {e}")))?,
        );
        Ok(self)
    }

    /// Attach a directly-reachable socket address (LAN / port-forwarded).
    pub fn with_direct(mut self, addr: std::net::SocketAddr) -> Self {
        self.direct_addrs.push(addr);
        self
    }

    fn to_addr(&self) -> EndpointAddr {
        let mut addr = EndpointAddr::new(self.id);
        if let Some(relay) = &self.relay_url {
            addr = addr.with_relay_url(relay.clone());
        }
        for &d in &self.direct_addrs {
            addr = addr.with_ip_addr(d);
        }
        addr
    }
}

type Mailbox = Arc<(Mutex<HashMap<(u32, u32), VecDeque<Vec<u8>>>>, Condvar)>;

/// A [`Transport`] over iroh QUIC connections. See the module docs.
pub struct IrohTransport {
    rank: u32,
    world: u32,
    endpoint_id: EndpointId,
    peers: Vec<IrohPeer>,
    alpn: Vec<u8>,
    rt: Arc<Runtime>,
    endpoint: Endpoint,
    /// Outbound edges we dialed, keyed by peer rank: one QUIC connection with
    /// one long-lived, ordered uni-stream per directed edge. Reusing a single
    /// stream (rather than one per message) preserves FIFO frame order — what
    /// ring collectives (`all_reduce`) rely on for ≥ 3 ranks.
    out: Mutex<HashMap<u32, Arc<OutEdge>>>,
    mailbox: Mailbox,
}

/// One outbound directed edge: the connection (held to keep it alive) plus its
/// single ordered send stream, mutex-guarded so concurrent `send_bytes` to the
/// same peer serialize and stay in order.
struct OutEdge {
    #[allow(dead_code)] // kept alive so `stream` (and its ordering) survives
    conn: iroh::endpoint::Connection,
    stream: Mutex<iroh::endpoint::SendStream>,
}

impl IrohTransport {
    /// Bring up this rank's endpoint and start accepting inbound frames.
    ///
    /// `secret_key` is this rank's stable identity (persist it to keep the
    /// same [`EndpointId`] across restarts; use [`SecretKey::generate`] for an
    /// ephemeral one). `peers[r]` addresses rank `r`; `peers[rank]` is this
    /// rank and is ignored for dialing. Every rank must pass the same `alpn`.
    ///
    /// Bind is lazy w.r.t. peers: connections are dialed on first `send`, so
    /// ranks can start in any order.
    ///
    /// This uses [`RelayMode::Disabled`] — direct/LAN reachability only. For
    /// NAT traversal use [`connect_relayed`](Self::connect_relayed) (or
    /// [`connect_with`](Self::connect_with) for an explicit [`RelayMode`]).
    pub fn connect(
        rank: u32,
        world: u32,
        secret_key: SecretKey,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
    ) -> Result<Self, CollectiveError> {
        Self::connect_with(rank, world, secret_key, peers, alpn, RelayMode::Disabled)
    }

    /// Like [`connect`](Self::connect) but routes through n0's public relay
    /// servers ([`RelayMode::Default`]) — the NAT-traversal path. Give each
    /// [`IrohPeer`] a relay URL ([`IrohPeer::with_relay`]) so that when a
    /// direct hole-punch can't be made, traffic falls back to the relay.
    pub fn connect_relayed(
        rank: u32,
        world: u32,
        secret_key: SecretKey,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
    ) -> Result<Self, CollectiveError> {
        Self::connect_with(rank, world, secret_key, peers, alpn, RelayMode::Default)
    }

    /// Bring up this rank's endpoint with an explicit [`RelayMode`]:
    /// [`Disabled`](RelayMode::Disabled) (direct / LAN only — the
    /// [`connect`](Self::connect) default), [`Default`](RelayMode::Default)
    /// (n0's public relays — NAT traversal, via
    /// [`connect_relayed`](Self::connect_relayed)),
    /// [`Staging`](RelayMode::Staging), or [`Custom`](RelayMode::Custom) with
    /// your own [`RelayMap`]. See [`connect`](Self::connect) for the argument
    /// contract.
    pub fn connect_with(
        rank: u32,
        world: u32,
        secret_key: SecretKey,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
        relay_mode: RelayMode,
    ) -> Result<Self, CollectiveError> {
        Self::connect_bind(rank, world, secret_key, peers, alpn, false, relay_mode)
    }

    /// Bring up this rank's endpoint with n0's full networking preset: public
    /// relays **plus pkarr/DNS discovery**. With discovery on, a peer is
    /// reachable by its [`EndpointId`] alone — an [`IrohPeer::new`] with no
    /// relay URL or direct address is enough, because iroh resolves the peer's
    /// current address from the id. This is the "just works across the
    /// internet" path (each rank publishes its address; the dialer resolves
    /// it), at the cost of a few seconds of first-dial resolution latency
    /// (covered by the dial retry window). Requires a TLS crypto provider
    /// (iroh's default `tls-ring`).
    pub fn connect_discovered(
        rank: u32,
        world: u32,
        secret_key: SecretKey,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
    ) -> Result<Self, CollectiveError> {
        Self::connect_bind(
            rank,
            world,
            secret_key,
            peers,
            alpn,
            true,
            RelayMode::Default,
        )
    }

    fn connect_bind(
        rank: u32,
        world: u32,
        secret_key: SecretKey,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
        use_n0: bool,
        relay_mode: RelayMode,
    ) -> Result<Self, CollectiveError> {
        if peers.len() != world as usize {
            return Err(terr(format!(
                "peers must have world_size ({world}) entries, got {}",
                peers.len()
            )));
        }
        let rt = Arc::new(
            tokio::runtime::Builder::new_multi_thread()
                .enable_all()
                .worker_threads(2)
                .thread_name("rlx-iroh")
                .build()
                .map_err(|e| terr(format!("tokio runtime: {e}")))?,
        );
        let alpn_v = alpn.to_vec();
        let endpoint = rt
            .block_on(async {
                if use_n0 {
                    // n0 preset: relays + pkarr/DNS discovery (reach by id alone).
                    Endpoint::builder(presets::N0)
                        .secret_key(secret_key)
                        .alpns(vec![alpn_v.clone()])
                        .bind()
                        .await
                } else {
                    Endpoint::builder(presets::Minimal)
                        .secret_key(secret_key)
                        .alpns(vec![alpn_v.clone()])
                        .relay_mode(relay_mode)
                        .bind()
                        .await
                }
            })
            .map_err(|e| terr(format!("iroh bind: {e}")))?;
        let endpoint_id = endpoint.id();

        let mailbox: Mailbox = Arc::new((Mutex::new(HashMap::new()), Condvar::new()));

        // Accept loop: for every inbound connection, spawn a per-connection
        // reader that drains uni-streams into the mailbox.
        {
            let ep = endpoint.clone();
            let mb = mailbox.clone();
            rt.spawn(async move {
                while let Some(incoming) = ep.accept().await {
                    let mb = mb.clone();
                    tokio::spawn(async move {
                        let conn = match incoming.await {
                            Ok(c) => c,
                            Err(_) => return,
                        };
                        reader_loop(conn, mb).await;
                    });
                }
            });
        }

        Ok(Self {
            rank,
            world,
            endpoint_id,
            peers,
            alpn: alpn_v,
            rt,
            endpoint,
            out: Mutex::new(HashMap::new()),
            mailbox,
        })
    }

    /// Convenience: bring up a rank with a fresh ephemeral identity.
    pub fn connect_ephemeral(
        rank: u32,
        world: u32,
        peers: Vec<IrohPeer>,
        alpn: &[u8],
    ) -> Result<Self, CollectiveError> {
        Self::connect(rank, world, SecretKey::generate(), peers, alpn)
    }

    /// This rank's [`EndpointId`] — hand its string form to the other ranks so
    /// they can build their `peers` list (the id is what an [`IrohPeer`] needs).
    pub fn endpoint_id(&self) -> EndpointId {
        self.endpoint_id
    }

    /// This rank's [`EndpointId`] as a hex string, for bootstrapping.
    pub fn endpoint_id_string(&self) -> String {
        self.endpoint_id.to_string()
    }

    /// Get (dialing + opening the single ordered stream if needed) the outbound
    /// edge to `to`. Retries the dial until [`DIAL_TIMEOUT`] so a peer that is
    /// still binding (or still resolving via discovery) doesn't fail the send.
    fn edge_to(&self, to: u32) -> Result<Arc<OutEdge>, CollectiveError> {
        if let Some(e) = self.out.lock().unwrap().get(&to) {
            return Ok(e.clone());
        }
        let addr = self.peers[to as usize].to_addr();
        let deadline = Instant::now() + DIAL_TIMEOUT;
        let (conn, stream) = loop {
            let attempt = self.rt.block_on(async {
                let conn = self
                    .endpoint
                    .connect(addr.clone(), &self.alpn)
                    .await
                    .map_err(|e| terr(e.to_string()))?;
                let stream = conn.open_uni().await.map_err(|e| terr(e.to_string()))?;
                Ok::<_, CollectiveError>((conn, stream))
            });
            match attempt {
                Ok(cs) => break cs,
                Err(e) => {
                    if Instant::now() >= deadline {
                        return Err(terr(format!("dial/open rank {to} failed: {e}")));
                    }
                    std::thread::sleep(Duration::from_millis(200));
                }
            }
        };
        let edge = Arc::new(OutEdge {
            conn,
            stream: Mutex::new(stream),
        });
        self.out.lock().unwrap().insert(to, edge.clone());
        Ok(edge)
    }

    /// Drop a cached outbound edge (forces a redial + fresh stream next send).
    fn evict(&self, to: u32) {
        self.out.lock().unwrap().remove(&to);
    }
}

/// Per-connection reader: the peer opens one ordered uni-stream for this
/// directed edge and writes length-prefixed frames on it. We read them in
/// sequence (so same-tag frames stay FIFO) and route each into the mailbox.
/// Runs until the stream/connection closes.
async fn reader_loop(conn: iroh::endpoint::Connection, mailbox: Mailbox) {
    let mut recv = match conn.accept_uni().await {
        Ok(r) => r,
        Err(_) => return, // connection closed before any stream
    };
    let mut header = [0u8; 12];
    loop {
        if recv.read_exact(&mut header).await.is_err() {
            return; // stream finished / connection closed
        }
        let from = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
        let tag = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
        let len = u32::from_le_bytes([header[8], header[9], header[10], header[11]]) as usize;
        if len > MAX_FRAME_BYTES {
            return; // framing desync / oversized frame — drop the edge
        }
        let mut payload = vec![0u8; len];
        if len > 0 && recv.read_exact(&mut payload).await.is_err() {
            return;
        }
        let (lock, cv) = &*mailbox;
        lock.lock()
            .unwrap()
            .entry((from, tag))
            .or_default()
            .push_back(payload);
        cv.notify_all();
    }
}

impl Transport for IrohTransport {
    fn rank(&self) -> u32 {
        self.rank
    }

    fn world_size(&self) -> u32 {
        self.world
    }

    fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError> {
        if to == self.rank {
            // Loopback: deliver straight to our own mailbox.
            let (lock, cv) = &*self.mailbox;
            lock.lock()
                .unwrap()
                .entry((self.rank, tag))
                .or_default()
                .push_back(bytes.to_vec());
            cv.notify_all();
            return Ok(());
        }
        if bytes.len() > MAX_FRAME_BYTES {
            return Err(terr(format!(
                "frame of {} bytes exceeds MAX_FRAME_BYTES ({MAX_FRAME_BYTES})",
                bytes.len()
            )));
        }
        let mut header = [0u8; 12];
        header[0..4].copy_from_slice(&self.rank.to_le_bytes());
        header[4..8].copy_from_slice(&tag.to_le_bytes());
        header[8..12].copy_from_slice(&(bytes.len() as u32).to_le_bytes());

        // One retry across a redial: a cached edge may have died since last use.
        // The write holds the edge's stream lock, so concurrent sends to the
        // same peer serialize and their frames stay in order on the wire.
        for attempt in 0..2 {
            let edge = self.edge_to(to)?;
            let mut stream = edge.stream.lock().unwrap();
            let res = self.rt.block_on(async {
                stream
                    .write_all(&header)
                    .await
                    .map_err(|e| terr(e.to_string()))?;
                stream
                    .write_all(bytes)
                    .await
                    .map_err(|e| terr(e.to_string()))?;
                Ok::<(), CollectiveError>(())
            });
            drop(stream);
            match res {
                Ok(()) => return Ok(()),
                Err(e) => {
                    self.evict(to);
                    if attempt == 1 {
                        return Err(e);
                    }
                }
            }
        }
        Ok(())
    }

    fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError> {
        let (lock, cv) = &*self.mailbox;
        let mut guard = lock.lock().unwrap();
        loop {
            if let Some(q) = guard.get_mut(&(from, tag))
                && let Some(v) = q.pop_front()
            {
                return Ok(v);
            }
            guard = cv.wait(guard).unwrap();
        }
    }

    fn recv_bytes_timeout(
        &self,
        from: u32,
        tag: u32,
        timeout: Duration,
    ) -> Result<Option<Vec<u8>>, CollectiveError> {
        let (lock, cv) = &*self.mailbox;
        let mut guard = lock.lock().unwrap();
        let deadline = Instant::now() + timeout;
        loop {
            if let Some(q) = guard.get_mut(&(from, tag))
                && let Some(v) = q.pop_front()
            {
                return Ok(Some(v));
            }
            let now = Instant::now();
            if now >= deadline {
                return Ok(None);
            }
            let (g, res) = cv.wait_timeout(guard, deadline - now).unwrap();
            guard = g;
            if res.timed_out()
                && guard
                    .get_mut(&(from, tag))
                    .map(|q| q.is_empty())
                    .unwrap_or(true)
            {
                return Ok(None);
            }
        }
    }
}

impl Drop for IrohTransport {
    fn drop(&mut self) {
        // Drop cached send streams / connections first.
        self.out.lock().unwrap().clear();
        let ep = self.endpoint.clone();
        // Bounded graceful close: `ep.close()` can otherwise block indefinitely
        // when the peer has already gone away (e.g. the other rank finished and
        // tore down first), hanging the process at teardown. Cap it so drop
        // always returns; the runtime shuts down when its Arc drops.
        let _ = self.rt.block_on(async move {
            tokio::time::timeout(Duration::from_secs(2), ep.close()).await
        });
    }
}

// ---- env-driven ProcessGroup (training-launcher integration) -------------

/// Decode an even-length hex string to bytes.
fn hex_to_bytes(s: &str) -> Result<Vec<u8>, CollectiveError> {
    let s = s.trim();
    if !s.len().is_multiple_of(2) {
        return Err(terr("hex string must have even length"));
    }
    (0..s.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&s[i..i + 2], 16)
                .map_err(|_| terr(format!("bad hex byte {:?}", &s[i..i + 2])))
        })
        .collect()
}

/// Deterministically derive rank `r`'s 32-byte secret from a shared seed, so
/// every rank can compute every rank's [`EndpointId`] from the seed alone. This
/// is NOT secret (anyone holding the seed can impersonate a rank) — it's the
/// zero-config path for a *trusted* cluster / demo. For untrusted networks pass
/// explicit ids via `RLX_IROH_PEERS` with a per-rank `RLX_IROH_SECRET` instead.
fn derive_key(seed: &[u8], rank: u32) -> [u8; 32] {
    let mut k = [0u8; 32];
    for (i, b) in k.iter_mut().enumerate() {
        *b = seed[i % seed.len()];
    }
    let rb = rank.to_le_bytes();
    for i in 0..4 {
        k[i] ^= rb[i];
        k[28 + i] ^= rb[i].rotate_left(3).wrapping_add(0x5a);
    }
    k
}

/// Build a ready [`ProcessGroup`](crate::transport::ProcessGroup) whose
/// collectives run over iroh, from environment variables — the NAT-traversing
/// analog of [`Node::from_env`](crate::Node::from_env) + `connect`. Every
/// collective (`all_reduce`, …) then rides iroh, so data-parallel training
/// reaches across NAT / the internet, not just one LAN.
///
/// Reads:
/// - `RANK`, `WORLD` — this rank / total ranks (required).
/// - `RLX_IROH_SEED=<hex>` — a shared seed; per-rank keys are derived
///   deterministically so every rank knows every [`EndpointId`] from the seed
///   alone. The zero-config path (see [`derive_key`] for the trust caveat).
/// - `RLX_IROH_PEERS=<id>,<id>,…` — OR: each rank's `EndpointId` (hex),
///   indexed by rank; then also set `RLX_IROH_SECRET=<64-hex>` to this rank's
///   secret (whose public id must equal `PEERS[RANK]`).
/// - `RLX_IROH_ALPN=<str>` — optional ALPN (default `rlx-pipeline/1`).
///
/// Uses [`connect_discovered`](IrohTransport::connect_discovered): peers are
/// reached by `EndpointId` alone (n0 relays + pkarr/DNS discovery).
pub fn process_group_from_env() -> Result<Arc<crate::transport::ProcessGroup>, CollectiveError> {
    let var = |k: &str| std::env::var(k).ok();
    let rank: u32 = var("RANK")
        .ok_or_else(|| terr("RANK not set"))?
        .trim()
        .parse()
        .map_err(|_| terr("RANK must be an integer"))?;
    let world: u32 = var("WORLD")
        .ok_or_else(|| terr("WORLD not set"))?
        .trim()
        .parse()
        .map_err(|_| terr("WORLD must be an integer"))?;
    if rank >= world {
        return Err(terr(format!("RANK {rank} must be < WORLD {world}")));
    }
    let alpn = var("RLX_IROH_ALPN")
        .map(String::into_bytes)
        .unwrap_or_else(|| RLX_PIPELINE_ALPN.to_vec());

    let (secret, peers): (SecretKey, Vec<IrohPeer>) = if let Some(csv) = var("RLX_IROH_PEERS") {
        let peers: Vec<IrohPeer> = csv
            .split(',')
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(IrohPeer::from_id_str)
            .collect::<Result<_, _>>()?;
        if peers.len() != world as usize {
            return Err(terr(format!(
                "RLX_IROH_PEERS has {} ids but WORLD={world}",
                peers.len()
            )));
        }
        let sk_hex = var("RLX_IROH_SECRET").ok_or_else(|| {
            terr("RLX_IROH_PEERS set: also set RLX_IROH_SECRET (this rank's 64-hex key)")
        })?;
        let arr: [u8; 32] = hex_to_bytes(&sk_hex)?
            .as_slice()
            .try_into()
            .map_err(|_| terr("RLX_IROH_SECRET must be 32 bytes (64 hex chars)"))?;
        (SecretKey::from_bytes(&arr), peers)
    } else if let Some(seed_hex) = var("RLX_IROH_SEED") {
        let seed = hex_to_bytes(&seed_hex)?;
        if seed.is_empty() {
            return Err(terr("RLX_IROH_SEED is empty"));
        }
        let peers: Vec<IrohPeer> = (0..world)
            .map(|r| IrohPeer::new(SecretKey::from_bytes(&derive_key(&seed, r)).public()))
            .collect();
        (SecretKey::from_bytes(&derive_key(&seed, rank)), peers)
    } else {
        return Err(terr(
            "set RLX_IROH_SEED (shared) or RLX_IROH_PEERS+RLX_IROH_SECRET to configure the iroh group",
        ));
    };

    let t = IrohTransport::connect_discovered(rank, world, secret, peers, &alpn)?;
    Ok(Arc::new(crate::transport::ProcessGroup::new(Arc::new(t))))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::transport::ProcessGroup;

    /// Two in-process ranks over loopback iroh, exercising a matched
    /// send/recv both ways. Uses ephemeral keys and direct-id addressing over
    /// the loopback path (no relay needed for same-host).
    #[test]
    fn iroh_two_rank_roundtrip() {
        // Bring up rank 0 first so we can learn its id, then rank 1, then wire
        // the peers lists and exchange a tagged message each way.
        let t0 = IrohTransport::connect_ephemeral(0, 2, placeholder_peers(2), RLX_PIPELINE_ALPN)
            .expect("rank0 up");
        let t1 = IrohTransport::connect_ephemeral(1, 2, placeholder_peers(2), RLX_PIPELINE_ALPN)
            .expect("rank1 up");

        // Rewire real peer ids now that both endpoints exist. (In production
        // the ids are gossiped/handed out before construction; here we just
        // patch the cached peers vec via fresh transports.)
        let id0 = t0.endpoint_id_string();
        let id1 = t1.endpoint_id_string();
        assert_ne!(id0, id1);
        assert_eq!(t0.rank(), 0);
        assert_eq!(t1.world_size(), 2);
        // Loopback self-send works without any networking.
        let g0 = ProcessGroup::new(Arc::new(t0));
        g0.transport().send_bytes(0, 7, b"ping").unwrap();
        assert_eq!(g0.transport().recv_bytes(0, 7).unwrap(), b"ping");
    }

    fn placeholder_peers(n: u32) -> Vec<IrohPeer> {
        // A valid-but-unused id per rank; the roundtrip test only exercises
        // loopback + construction, not cross-rank dialing (that needs the real
        // ids wired in, which the integration harness does).
        (0..n)
            .map(|_| IrohPeer::new(SecretKey::generate().public()))
            .collect()
    }
}