diff --git a/CHANGELOG.md b/CHANGELOG.md index b44f943..26b6840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) an ## [Unreleased] +### Changed + +- The `send_multicast` and `receive_reassemble` examples accept an optional local interface IPv4 address, pinning multicast egress and group membership to a chosen interface. This works around default-interface selection failing over Wi-Fi on Windows. Interface selection stays an application-level policy in the examples; the codec is unchanged. + ## [0.2.0] - 2026-07-13 ### Added diff --git a/README.md b/README.md index 34f75ec..7855f6e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,15 @@ When PONK data controls laser hardware, do not send decoded points directly to t cargo run --example roundtrip ``` +The two multicast examples accept an optional local interface IPv4 address: + +```sh +cargo run --example receive_reassemble -- 192.168.1.20 +cargo run --example send_multicast -- 192.168.1.20 +``` + +Omit the address to let the operating system choose the interface. Pass an interface's own address to pin multicast to it — useful on Windows, where a Wi-Fi interface is often not selected by default and multicast otherwise never crosses the WLAN. Socket ownership stays with the application, so interface selection is an application-level policy shown in the examples rather than part of the codec. + ## Minimum supported Rust version The minimum supported Rust version (MSRV) is Rust **1.88**. CI checks both stable Rust and Rust 1.88. An MSRV increase requires a documented minor-version change while the crate is pre-1.0. diff --git a/examples/receive_reassemble.rs b/examples/receive_reassemble.rs index 908171c..7deff92 100644 --- a/examples/receive_reassemble.rs +++ b/examples/receive_reassemble.rs @@ -1,6 +1,9 @@ //! Join the PONK multicast group and reassemble incoming mixed-format frames. //! -//! Run with: `cargo run --example receive_reassemble` +//! Run with: `cargo run --example receive_reassemble [interface-ipv4]` +//! +//! Pass a local interface's own IPv4 address to join the group on that +//! interface; omit it to let the operating system choose. //! //! Then, in another terminal, run the `send_multicast` example. @@ -11,10 +14,22 @@ use ponk_protocol::{DEFAULT_PORT, MULTICAST_ADDR, PonkAssembler}; const RECV_BUFFER_SIZE: usize = 65_536; fn main() -> std::io::Result<()> { + // Optionally join on a specific interface. + // + // With the default unspecified address the operating system picks the + // interface from its routing metrics. On Windows a Wi-Fi interface often + // loses that selection, so multicast is never received over WLAN. Passing + // the interface's own IPv4 address joins the group on that interface, + // mirroring the local-address field on TouchDesigner's UDP operators. + let interface = interface_from_args()?; + let group = Ipv4Addr::from(MULTICAST_ADDR); let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?; - socket.join_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED)?; - println!("listening for PONK on {group}:{DEFAULT_PORT} — Ctrl-C to stop"); + socket.join_multicast_v4(&group, &interface)?; + println!( + "listening for PONK on {group}:{DEFAULT_PORT} via interface {} — Ctrl-C to stop", + describe_interface(interface), + ); // Strict mode is the default. Configure sender-specific canonical boundary // repair only when the application knows that a sender needs it. @@ -45,3 +60,28 @@ fn main() -> std::io::Result<()> { } } } + +/// Read an optional interface IPv4 address from the first CLI argument. +/// +/// Falls back to the unspecified address (all interfaces) when absent. A +/// present but unparseable argument is a hard error, so a typo cannot silently +/// revert to the default interface the example exists to override. +fn interface_from_args() -> std::io::Result { + match std::env::args().nth(1) { + None => Ok(Ipv4Addr::UNSPECIFIED), + Some(arg) => arg.parse().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid interface address {arg:?}"), + ) + }), + } +} + +fn describe_interface(interface: Ipv4Addr) -> String { + if interface.is_unspecified() { + "default".to_string() + } else { + interface.to_string() + } +} diff --git a/examples/send_multicast.rs b/examples/send_multicast.rs index 8e4b871..7210ba9 100644 --- a/examples/send_multicast.rs +++ b/examples/send_multicast.rs @@ -1,9 +1,13 @@ //! Encode a frame and multicast it as PONK datagrams. //! -//! Run with: `cargo run --example send_multicast` +//! Run with: `cargo run --example send_multicast [interface-ipv4]` +//! +//! Pass a local interface's own IPv4 address to pin which interface the +//! multicast leaves through; omit it to let the operating system choose. //! //! Pair with the `receive_reassemble` example (run the receiver first). +use std::io::ErrorKind; use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; use ponk_protocol::{ @@ -14,7 +18,25 @@ use ponk_protocol::{ const MAX_DATAGRAM_LEN: usize = 1_472; fn main() -> std::io::Result<()> { - let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + // Optionally pin the outgoing interface by binding to its local address. + // + // With the default unspecified address the operating system picks the + // egress interface from its routing metrics. On Windows a Wi-Fi interface + // frequently loses that selection, so multicast never leaves the machine + // over WLAN. Binding to the interface's own IPv4 address reliably steers + // egress there on Windows, mirroring the local-address field on + // TouchDesigner's UDP operators. + // + // This is not a general guarantee: binding a local unicast source address + // is not the same as `IP_MULTICAST_IF`. On Linux (and other platforms) the + // routing table and `IP_MULTICAST_IF` govern multicast egress, so the bound + // address is only a hint there. Socket ownership stays with the application, + // so this policy lives in the example rather than the codec. An application + // needing the `IP_MULTICAST_IF` socket option directly can reach for + // `socket2` or the platform APIs. + let interface = interface_from_args()?; + + let socket = UdpSocket::bind((interface, 0))?; socket.set_multicast_ttl_v4(1)?; socket.set_multicast_loop_v4(true)?; @@ -51,10 +73,36 @@ fn main() -> std::io::Result<()> { socket.send_to(datagram, target)?; } println!( - "sent {} datagram(s) to {} (multicast group {:?})", + "sent {} datagram(s) to {} via interface {} (multicast group {:?})", datagrams.len(), target, + describe_interface(interface), MULTICAST_ADDR, ); Ok(()) } + +/// Read an optional interface IPv4 address from the first CLI argument. +/// +/// Falls back to the unspecified address (all interfaces) when absent. A +/// present-but-unparsable argument is a hard error, since silently reverting +/// to the default would defeat the point of selecting an interface. +fn interface_from_args() -> std::io::Result { + match std::env::args().nth(1) { + None => Ok(Ipv4Addr::UNSPECIFIED), + Some(arg) => arg.parse().map_err(|_| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("invalid interface address {arg:?}"), + ) + }), + } +} + +fn describe_interface(interface: Ipv4Addr) -> String { + if interface.is_unspecified() { + "default".to_string() + } else { + interface.to_string() + } +}