Skip to main content

rlx_driver/
net.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Networked multi-node transport (plan #12/#49, multi-node).
17//!
18//! [`NetTransport`] is one TCP connection per rank pair (full mesh)
19//! with a single reader thread per connection that *demultiplexes*
20//! every incoming frame into the right place:
21//!
22//!   - `SEND`    → a two-sided inbox keyed by `(src, tag)` that
23//!     [`Transport::recv_bytes`] pops (pipeline-parallel hidden states).
24//!   - `PUT`     → this rank's symmetric heap (one-sided write).
25//!   - `GETREQ`  → read this rank's heap, reply with `GETRESP`.
26//!   - `GETRESP` → a response inbox the blocked
27//!     [`SymmetricTransport::get`] pops.
28//!
29//! Because the reader thread services the socket, a blocked `recv`/`get`
30//! on the main thread never stalls the peer — so the gather-to-root
31//! collectives in [`crate::transport::ProcessGroup`] (and the
32//! one-sided collectives in [`crate::collective`]) are deadlock-free
33//! over any rank count.
34//!
35//! Two public constructors expose the same engine with different
36//! intent:
37//!   - [`TcpTransport::bind`] — portable; give it any reachable IPs
38//!     (Ethernet, or the macOS Thunderbolt Bridge link).
39//!   - [`ThunderboltTransport::bind`] — same wire protocol, intended
40//!     for IPs on the Thunderbolt interface, and the place a future
41//!     zero-copy DMA backend slots in behind the unchanged
42//!     [`Transport`] + [`SymmetricTransport`] traits.
43
44use crate::symmetric::{CollectiveError, Rank, SymmetricBuffer, SymmetricTransport};
45use crate::transport::{Transport, default_barrier};
46use std::collections::{HashMap, VecDeque};
47use std::io::{self, Read, Write};
48use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream};
49use std::sync::atomic::{AtomicBool, Ordering};
50use std::sync::{Arc, Condvar, Mutex, RwLock};
51use std::thread::{self, JoinHandle};
52use std::time::Duration;
53
54// Frame kinds. Header is fixed 13 bytes: [kind:u8][a:u32 le][b:u32 le][len:u32 le].
55const HELLO: u8 = 1; // a = connector rank, no payload
56const SEND: u8 = 2; // a = src rank, b = tag, payload = bytes
57const PUT: u8 = 3; // a = src rank, b = offset, payload = bytes to write
58const GETREQ: u8 = 4; // a = requester rank, payload = [offset:u32 le][len:u32 le]
59const GETRESP: u8 = 5; // a = responder rank, payload = bytes
60
61const HEADER_LEN: usize = 13;
62
63fn write_frame<W: Write>(w: &mut W, kind: u8, a: u32, b: u32, payload: &[u8]) -> io::Result<()> {
64    let mut hdr = [0u8; HEADER_LEN];
65    hdr[0] = kind;
66    hdr[1..5].copy_from_slice(&a.to_le_bytes());
67    hdr[5..9].copy_from_slice(&b.to_le_bytes());
68    hdr[9..13].copy_from_slice(&(payload.len() as u32).to_le_bytes());
69    w.write_all(&hdr)?;
70    if !payload.is_empty() {
71        w.write_all(payload)?;
72    }
73    w.flush()
74}
75
76fn read_frame<R: Read>(r: &mut R) -> io::Result<(u8, u32, u32, Vec<u8>)> {
77    let mut hdr = [0u8; HEADER_LEN];
78    r.read_exact(&mut hdr)?;
79    let kind = hdr[0];
80    let a = u32::from_le_bytes(hdr[1..5].try_into().unwrap());
81    let b = u32::from_le_bytes(hdr[5..9].try_into().unwrap());
82    let len = u32::from_le_bytes(hdr[9..13].try_into().unwrap()) as usize;
83    let mut payload = vec![0u8; len];
84    if len > 0 {
85        r.read_exact(&mut payload)?;
86    }
87    Ok((kind, a, b, payload))
88}
89
90fn connect_retry(addr: SocketAddr) -> io::Result<TcpStream> {
91    let mut last: Option<io::Error> = None;
92    for _ in 0..400 {
93        match TcpStream::connect(addr) {
94            Ok(s) => return Ok(s),
95            Err(e) => {
96                last = Some(e);
97                thread::sleep(Duration::from_millis(25));
98            }
99        }
100    }
101    Err(last.unwrap_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "connect retry exhausted")))
102}
103
104struct NetInner {
105    rank: u32,
106    world: u32,
107    heap_size: usize,
108    /// Write half of the connection to each peer (index = peer rank).
109    /// `None` at our own rank and until a peer connects.
110    writers: Vec<Mutex<Option<TcpStream>>>,
111    /// Two-sided inbox: `(src_rank, tag) -> queued payloads`.
112    inbox: Mutex<HashMap<(u32, u32), VecDeque<Vec<u8>>>>,
113    inbox_cv: Condvar,
114    /// This rank's symmetric heap (target of remote PUT / GETREQ).
115    heap: RwLock<Vec<u8>>,
116    /// Responses to our one-sided GETs: `responder_rank -> payloads`.
117    getresp: Mutex<HashMap<u32, VecDeque<Vec<u8>>>>,
118    getresp_cv: Condvar,
119    shutdown: AtomicBool,
120}
121
122impl NetInner {
123    fn send_frame(
124        &self,
125        peer: u32,
126        kind: u8,
127        a: u32,
128        b: u32,
129        payload: &[u8],
130    ) -> Result<(), CollectiveError> {
131        let mut guard = self.writers[peer as usize].lock().unwrap();
132        match guard.as_mut() {
133            Some(s) => {
134                write_frame(s, kind, a, b, payload).map_err(|e| CollectiveError::TransportError {
135                    reason: format!("send to rank {peer}: {e}"),
136                })
137            }
138            None => Err(CollectiveError::TransportError {
139                reason: format!("no connection to rank {peer}"),
140            }),
141        }
142    }
143
144    fn recv_inbox(&self, from: u32, tag: u32) -> Vec<u8> {
145        let mut guard = self.inbox.lock().unwrap();
146        loop {
147            if let Some(q) = guard.get_mut(&(from, tag))
148                && let Some(v) = q.pop_front()
149            {
150                return v;
151            }
152            guard = self.inbox_cv.wait(guard).unwrap();
153        }
154    }
155
156    fn wait_getresp(&self, peer: u32) -> Vec<u8> {
157        let mut guard = self.getresp.lock().unwrap();
158        loop {
159            if let Some(q) = guard.get_mut(&peer)
160                && let Some(v) = q.pop_front()
161            {
162                return v;
163            }
164            guard = self.getresp_cv.wait(guard).unwrap();
165        }
166    }
167
168    fn check_buf(&self, buf: SymmetricBuffer) -> Result<(), CollectiveError> {
169        if buf.rank.0 >= self.world {
170            return Err(CollectiveError::UnknownRank {
171                rank: buf.rank,
172                num_ranks: self.world,
173            });
174        }
175        if buf.offset + buf.len > self.heap_size {
176            return Err(CollectiveError::OutOfBounds {
177                rank: buf.rank,
178                offset: buf.offset,
179                len: buf.len,
180                heap_size: self.heap_size,
181            });
182        }
183        Ok(())
184    }
185
186    fn dispatch(&self, kind: u8, a: u32, b: u32, payload: Vec<u8>) {
187        match kind {
188            SEND => {
189                self.inbox
190                    .lock()
191                    .unwrap()
192                    .entry((a, b))
193                    .or_default()
194                    .push_back(payload);
195                self.inbox_cv.notify_all();
196            }
197            PUT => {
198                let off = b as usize;
199                if off + payload.len() <= self.heap_size {
200                    let mut h = self.heap.write().unwrap();
201                    h[off..off + payload.len()].copy_from_slice(&payload);
202                }
203            }
204            GETREQ if payload.len() >= 8 => {
205                let off = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
206                let rlen = u32::from_le_bytes(payload[4..8].try_into().unwrap()) as usize;
207                let data = {
208                    let h = self.heap.read().unwrap();
209                    if off + rlen <= self.heap_size {
210                        h[off..off + rlen].to_vec()
211                    } else {
212                        Vec::new()
213                    }
214                };
215                // a = requester rank; respond with our rank as sender.
216                let _ = self.send_frame(a, GETRESP, self.rank, off as u32, &data);
217            }
218            GETRESP => {
219                self.getresp
220                    .lock()
221                    .unwrap()
222                    .entry(a)
223                    .or_default()
224                    .push_back(payload);
225                self.getresp_cv.notify_all();
226            }
227            _ => {}
228        }
229    }
230}
231
232fn reader_loop(mut stream: TcpStream, inner: Arc<NetInner>) {
233    // Exits when read_frame errors (peer closed or we were shut down).
234    while let Ok((kind, a, b, payload)) = read_frame(&mut stream) {
235        inner.dispatch(kind, a, b, payload);
236        if inner.shutdown.load(Ordering::Relaxed) {
237            break;
238        }
239    }
240}
241
242/// Full-mesh TCP transport implementing both [`Transport`] (two-sided
243/// send/recv) and [`SymmetricTransport`] (one-sided put/get/barrier).
244/// Construct via [`TcpTransport::bind`] or [`ThunderboltTransport::bind`].
245pub struct NetTransport {
246    inner: Arc<NetInner>,
247    readers: Mutex<Vec<JoinHandle<()>>>,
248}
249
250impl NetTransport {
251    /// Build a transport from an already-bound listener. Ranks above us
252    /// we connect to; ranks below us we accept from (each connector
253    /// announces itself with a `HELLO`). `peers[r]` is rank `r`'s listen
254    /// address; `peers[rank]` must match `listener`.
255    ///
256    /// Public so callers (and multi-rank tests) can pre-bind ephemeral
257    /// ports, exchange the addresses out-of-band, then construct the
258    /// mesh — avoiding the fixed-port races [`TcpTransport::bind`] is
259    /// subject to.
260    pub fn from_listener(
261        rank: u32,
262        world: u32,
263        listener: TcpListener,
264        peers: Vec<SocketAddr>,
265        heap_size: usize,
266    ) -> io::Result<Self> {
267        assert_eq!(
268            peers.len(),
269            world as usize,
270            "peers must have world_size entries"
271        );
272        assert!(rank < world, "rank out of range");
273
274        let inner = Arc::new(NetInner {
275            rank,
276            world,
277            heap_size,
278            writers: (0..world).map(|_| Mutex::new(None)).collect(),
279            inbox: Mutex::new(HashMap::new()),
280            inbox_cv: Condvar::new(),
281            heap: RwLock::new(vec![0u8; heap_size]),
282            getresp: Mutex::new(HashMap::new()),
283            getresp_cv: Condvar::new(),
284            shutdown: AtomicBool::new(false),
285        });
286        let mut readers = Vec::new();
287
288        // Connect to every higher-ranked peer.
289        for p in (rank + 1)..world {
290            let stream = connect_retry(peers[p as usize])?;
291            stream.set_nodelay(true).ok();
292            let mut wr = stream.try_clone()?;
293            write_frame(&mut wr, HELLO, rank, 0, &[])?;
294            let rd = stream.try_clone()?;
295            let inner2 = inner.clone();
296            readers.push(thread::spawn(move || reader_loop(rd, inner2)));
297            *inner.writers[p as usize].lock().unwrap() = Some(wr);
298        }
299
300        // Accept exactly `rank` connections from lower-ranked peers.
301        for _ in 0..rank {
302            let (stream, _addr) = listener.accept()?;
303            stream.set_nodelay(true).ok();
304            let mut rd = stream.try_clone()?;
305            let (kind, peer, _b, _payload) = read_frame(&mut rd)?;
306            if kind != HELLO || peer >= world {
307                return Err(io::Error::new(
308                    io::ErrorKind::InvalidData,
309                    format!("expected HELLO with valid rank, got kind={kind} rank={peer}"),
310                ));
311            }
312            let wr = stream.try_clone()?;
313            let inner2 = inner.clone();
314            readers.push(thread::spawn(move || reader_loop(rd, inner2)));
315            *inner.writers[peer as usize].lock().unwrap() = Some(wr);
316        }
317
318        Ok(Self {
319            inner,
320            readers: Mutex::new(readers),
321        })
322    }
323}
324
325impl Drop for NetTransport {
326    fn drop(&mut self) {
327        self.inner.shutdown.store(true, Ordering::SeqCst);
328        // Closing each socket makes the reader threads' read_exact fail.
329        for w in &self.inner.writers {
330            if let Some(s) = w.lock().unwrap().as_ref() {
331                let _ = s.shutdown(Shutdown::Both);
332            }
333        }
334        self.inner.inbox_cv.notify_all();
335        self.inner.getresp_cv.notify_all();
336        if let Ok(mut hs) = self.readers.lock() {
337            for h in hs.drain(..) {
338                let _ = h.join();
339            }
340        }
341    }
342}
343
344impl Transport for NetTransport {
345    fn rank(&self) -> u32 {
346        self.inner.rank
347    }
348    fn world_size(&self) -> u32 {
349        self.inner.world
350    }
351    fn send_bytes(&self, to: u32, tag: u32, bytes: &[u8]) -> Result<(), CollectiveError> {
352        if to == self.inner.rank {
353            // Self-message: deliver straight to our own inbox.
354            self.inner
355                .inbox
356                .lock()
357                .unwrap()
358                .entry((to, tag))
359                .or_default()
360                .push_back(bytes.to_vec());
361            self.inner.inbox_cv.notify_all();
362            return Ok(());
363        }
364        self.inner.send_frame(to, SEND, self.inner.rank, tag, bytes)
365    }
366    fn recv_bytes(&self, from: u32, tag: u32) -> Result<Vec<u8>, CollectiveError> {
367        Ok(self.inner.recv_inbox(from, tag))
368    }
369    fn barrier(&self) -> Result<(), CollectiveError> {
370        default_barrier(self)
371    }
372}
373
374impl SymmetricTransport for NetTransport {
375    fn num_ranks(&self) -> u32 {
376        self.inner.world
377    }
378    fn this_rank(&self) -> Rank {
379        Rank(self.inner.rank)
380    }
381    fn put(&self, buf: SymmetricBuffer, src: &[u8]) -> Result<(), CollectiveError> {
382        self.inner.check_buf(buf)?;
383        if src.len() != buf.len {
384            return Err(CollectiveError::LengthMismatch {
385                expected: buf.len,
386                got: src.len(),
387            });
388        }
389        if buf.rank.0 == self.inner.rank {
390            let mut h = self.inner.heap.write().unwrap();
391            h[buf.offset..buf.offset + buf.len].copy_from_slice(src);
392            Ok(())
393        } else {
394            self.inner
395                .send_frame(buf.rank.0, PUT, self.inner.rank, buf.offset as u32, src)
396        }
397    }
398    fn get(&self, buf: SymmetricBuffer, dst: &mut [u8]) -> Result<(), CollectiveError> {
399        self.inner.check_buf(buf)?;
400        if dst.len() != buf.len {
401            return Err(CollectiveError::LengthMismatch {
402                expected: buf.len,
403                got: dst.len(),
404            });
405        }
406        if buf.rank.0 == self.inner.rank {
407            let h = self.inner.heap.read().unwrap();
408            dst.copy_from_slice(&h[buf.offset..buf.offset + buf.len]);
409            Ok(())
410        } else {
411            let mut payload = [0u8; 8];
412            payload[0..4].copy_from_slice(&(buf.offset as u32).to_le_bytes());
413            payload[4..8].copy_from_slice(&(buf.len as u32).to_le_bytes());
414            self.inner
415                .send_frame(buf.rank.0, GETREQ, self.inner.rank, 0, &payload)?;
416            let data = self.inner.wait_getresp(buf.rank.0);
417            if data.len() != buf.len {
418                return Err(CollectiveError::LengthMismatch {
419                    expected: buf.len,
420                    got: data.len(),
421                });
422            }
423            dst.copy_from_slice(&data);
424            Ok(())
425        }
426    }
427    fn barrier(&self) -> Result<(), CollectiveError> {
428        default_barrier(self)
429    }
430}
431
432/// Default symmetric-heap size per rank (bytes) when a constructor is
433/// not given one. 64 MiB comfortably holds a single transformer hidden
434/// state at typical batch×seq×d_model for collective scratch.
435pub const DEFAULT_HEAP_BYTES: usize = 64 * 1024 * 1024;
436
437/// Portable TCP transport. Works over any reachable IP — Ethernet or
438/// the macOS Thunderbolt Bridge link. This is the baseline that
439/// pipeline- and tensor-parallel inference run on before any fast path.
440pub struct TcpTransport;
441
442impl TcpTransport {
443    /// Bind this rank's listener at `peers[rank]` and establish the full
444    /// mesh. Every rank must pass the same `peers` list.
445    pub fn bind(
446        rank: u32,
447        world: u32,
448        peers: Vec<SocketAddr>,
449        heap_size: usize,
450    ) -> io::Result<NetTransport> {
451        let listener = TcpListener::bind(peers[rank as usize])?;
452        NetTransport::from_listener(rank, world, listener, peers, heap_size)
453    }
454}
455
456/// Thunderbolt transport. Same wire protocol as [`TcpTransport`] today
457/// (TCP over the Thunderbolt Bridge IP link), but a distinct type so
458/// the symmetric one-sided heap path is the intended interface and so a
459/// future zero-copy Thunderbolt DMA backend can replace the engine
460/// behind the unchanged [`Transport`] + [`SymmetricTransport`] traits.
461pub struct ThunderboltTransport;
462
463impl ThunderboltTransport {
464    /// Bind at `peers[rank]`. The addresses are expected to be on the
465    /// Thunderbolt interface (see [`ThunderboltTransport::looks_like_thunderbolt`]);
466    /// correctness does not depend on it, but bandwidth does.
467    pub fn bind(
468        rank: u32,
469        world: u32,
470        peers: Vec<SocketAddr>,
471        heap_size: usize,
472    ) -> io::Result<NetTransport> {
473        TcpTransport::bind(rank, world, peers, heap_size)
474    }
475
476    /// Heuristic: is `ip` plausibly a Thunderbolt Bridge address? macOS
477    /// auto-assigns link-local `169.254.0.0/16` to the bridge when no
478    /// static IP is configured; static setups commonly use a private
479    /// `10.0.0.0/8` block. This is advisory only.
480    pub fn looks_like_thunderbolt(ip: IpAddr) -> bool {
481        match ip {
482            IpAddr::V4(v4) => {
483                let o = v4.octets();
484                o[0] == 169 && o[1] == 254 || o[0] == 10
485            }
486            IpAddr::V6(_) => false,
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::collective::{ReduceKind, all_reduce};
495    use crate::transport::ProcessGroup;
496
497    /// Pre-bind `n` loopback listeners and run `body(rank, transport)`
498    /// on a thread per rank. Returns once every rank's body returns.
499    fn run_net<F>(n: u32, heap: usize, body: F)
500    where
501        F: Fn(u32, Arc<NetTransport>) + Send + Sync + 'static,
502    {
503        let listeners: Vec<TcpListener> = (0..n)
504            .map(|_| TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap())
505            .collect();
506        let addrs: Vec<SocketAddr> = listeners.iter().map(|l| l.local_addr().unwrap()).collect();
507        let body = Arc::new(body);
508        let handles: Vec<_> = listeners
509            .into_iter()
510            .enumerate()
511            .map(|(rank, listener)| {
512                let addrs = addrs.clone();
513                let body = body.clone();
514                thread::spawn(move || {
515                    let t = Arc::new(
516                        NetTransport::from_listener(rank as u32, n, listener, addrs, heap)
517                            .expect("transport build"),
518                    );
519                    body(rank as u32, t.clone());
520                    // Trailing barrier: keep every rank's reader threads alive
521                    // until all peers finish, so a late remote GET still gets a
522                    // response before any transport is dropped.
523                    <NetTransport as Transport>::barrier(&*t).unwrap();
524                })
525            })
526            .collect();
527        for h in handles {
528            h.join().unwrap();
529        }
530    }
531
532    #[test]
533    fn two_sided_pipeline_handoff() {
534        // rank r sends [r*10..] to r-1 (toward the leader), like a
535        // pipeline returning hidden states.
536        run_net(3, 4096, |rank, t| {
537            let g = ProcessGroup::new(t);
538            let n = g.world_size();
539            if rank + 1 < n {
540                let got = g.recv_f32(rank + 1, 7).unwrap();
541                assert_eq!(got, vec![(rank as f32 + 1.0) * 10.0, 1.0]);
542            }
543            if rank > 0 {
544                g.send_f32(rank - 1, 7, &[rank as f32 * 10.0, 1.0]).unwrap();
545            }
546        });
547    }
548
549    #[test]
550    fn process_group_all_reduce_over_tcp() {
551        run_net(4, 4096, |rank, t| {
552            let g = ProcessGroup::new(t);
553            let mut data = vec![rank as f32 + 1.0; 5];
554            g.all_reduce(&mut data, ReduceKind::Sum).unwrap();
555            assert_eq!(data, vec![10.0; 5], "rank {rank}");
556        });
557    }
558
559    #[test]
560    fn process_group_barrier_and_broadcast() {
561        run_net(3, 4096, |rank, t| {
562            let g = ProcessGroup::new(t);
563            g.barrier().unwrap();
564            let mut data = if rank == 0 {
565                vec![1.0, 2.0]
566            } else {
567                vec![0.0, 0.0]
568            };
569            g.broadcast(0, &mut data).unwrap();
570            assert_eq!(data, vec![1.0, 2.0], "rank {rank}");
571        });
572    }
573
574    #[test]
575    fn symmetric_remote_put_get() {
576        // Rank 0 PUTs into rank 1's heap; rank 1 reads its own slot and
577        // sees it. Then rank 1 GETs from rank 0's heap.
578        run_net(2, 4096, |rank, t| {
579            let off = 128;
580            let payload = [5u8, 6, 7, 8];
581            if rank == 0 {
582                // seed our own heap for rank 1 to GET later
583                t.put(
584                    SymmetricBuffer {
585                        rank: Rank(0),
586                        offset: off,
587                        len: 4,
588                    },
589                    &[1u8, 2, 3, 4],
590                )
591                .unwrap();
592                // write into rank 1's heap
593                t.put(
594                    SymmetricBuffer {
595                        rank: Rank(1),
596                        offset: off,
597                        len: 4,
598                    },
599                    &payload,
600                )
601                .unwrap();
602            }
603            // Make sure the PUT landed before the reads.
604            <NetTransport as Transport>::barrier(&t).unwrap();
605            // ^ disambiguates Transport::barrier from SymmetricTransport::barrier
606            if rank == 1 {
607                let mut got = [0u8; 4];
608                t.get(
609                    SymmetricBuffer {
610                        rank: Rank(1),
611                        offset: off,
612                        len: 4,
613                    },
614                    &mut got,
615                )
616                .unwrap();
617                assert_eq!(got, payload, "local read of remote PUT");
618                let mut remote = [0u8; 4];
619                t.get(
620                    SymmetricBuffer {
621                        rank: Rank(0),
622                        offset: off,
623                        len: 4,
624                    },
625                    &mut remote,
626                )
627                .unwrap();
628                assert_eq!(remote, [1u8, 2, 3, 4], "remote GET from rank 0");
629            }
630        });
631    }
632
633    #[test]
634    fn symmetric_collective_all_reduce_over_net() {
635        // The existing one-sided collective (collective::all_reduce)
636        // runs unmodified over NetTransport.
637        run_net(4, 4096, |rank, t| {
638            let elems = 3usize;
639            let bytes = elems * 4;
640            let buf = SymmetricBuffer {
641                rank: Rank(rank),
642                offset: 0,
643                len: bytes,
644            };
645            let mut local = vec![rank as f32 + 1.0; elems];
646            all_reduce(&*t, buf, &mut local, ReduceKind::Sum).unwrap();
647            assert_eq!(local, vec![10.0; elems], "rank {rank}");
648        });
649    }
650
651    #[test]
652    fn thunderbolt_addr_heuristic() {
653        use std::net::Ipv4Addr;
654        assert!(ThunderboltTransport::looks_like_thunderbolt(
655            Ipv4Addr::new(169, 254, 3, 1).into()
656        ));
657        assert!(ThunderboltTransport::looks_like_thunderbolt(
658            Ipv4Addr::new(10, 0, 0, 2).into()
659        ));
660        assert!(!ThunderboltTransport::looks_like_thunderbolt(
661            Ipv4Addr::new(192, 168, 1, 5).into()
662        ));
663    }
664}