From a6d4ac49139150ca904f5bd2b03c1518661c3b87 Mon Sep 17 00:00:00 2001 From: Kees Kluskens Date: Mon, 13 Jul 2026 21:51:24 +0200 Subject: [PATCH 1/2] feat(examples): allow selecting the multicast interface The send_multicast and receive_reassemble examples hardcoded INADDR_ANY, so on Windows a Wi-Fi interface is often not selected by default and PONK multicast never crosses the WLAN. Both examples now take an optional local interface IPv4 address: the sender binds to it to pin egress, the receiver joins the group on it. Omitting the address preserves the previous all-interfaces behavior; an unparseable address warns and falls back. Interface selection stays an application-level policy in the examples, keeping the codec socket-agnostic and unsafe-free. Ports the intent of madmappersoftware/Ponk#16 to this Rust crate's I/O-owning layer. --- CHANGELOG.md | 4 +++ README.md | 9 +++++++ examples/receive_reassemble.rs | 43 +++++++++++++++++++++++++++++--- examples/send_multicast.rs | 45 +++++++++++++++++++++++++++++++--- 4 files changed, 95 insertions(+), 6 deletions(-) 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..edb0afb 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,25 @@ 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, and +/// warns but keeps the fallback when the argument does not parse. +fn interface_from_args() -> Ipv4Addr { + match std::env::args().nth(1) { + None => Ipv4Addr::UNSPECIFIED, + Some(arg) => arg.parse().unwrap_or_else(|_| { + eprintln!("invalid interface address {arg:?}; using all interfaces"); + Ipv4Addr::UNSPECIFIED + }), + } +} + +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..00dccf5 100644 --- a/examples/send_multicast.rs +++ b/examples/send_multicast.rs @@ -1,6 +1,9 @@ //! 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). @@ -14,7 +17,20 @@ 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 pins egress to it, + // mirroring the local-address field on TouchDesigner's UDP operators. + // + // 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 +67,33 @@ 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, and +/// warns but keeps the fallback when the argument does not parse. +fn interface_from_args() -> Ipv4Addr { + match std::env::args().nth(1) { + None => Ipv4Addr::UNSPECIFIED, + Some(arg) => arg.parse().unwrap_or_else(|_| { + eprintln!("invalid interface address {arg:?}; using all interfaces"); + Ipv4Addr::UNSPECIFIED + }), + } +} + +fn describe_interface(interface: Ipv4Addr) -> String { + if interface.is_unspecified() { + "default".to_string() + } else { + interface.to_string() + } +} From 8e9ac729c0111bd78fa58568324975fd33c9a905 Mon Sep 17 00:00:00 2001 From: Kees Kluskens Date: Mon, 13 Jul 2026 21:54:45 +0200 Subject: [PATCH 2/2] fix(examples): fail hard on an invalid interface address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typo in the interface argument silently reverted to INADDR_ANY — the exact default-selection behavior the argument exists to override — so the example looked broken over WLAN rather than reporting the typo. An invalid address is now a hard error; a missing argument still defaults to all interfaces. Also scope the sender's bind() comment: it reliably steers multicast egress on Windows, but binding a unicast source address is not IP_MULTICAST_IF and is only a hint on other platforms. --- examples/receive_reassemble.rs | 19 ++++++++++-------- examples/send_multicast.rs | 35 +++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/examples/receive_reassemble.rs b/examples/receive_reassemble.rs index edb0afb..7deff92 100644 --- a/examples/receive_reassemble.rs +++ b/examples/receive_reassemble.rs @@ -21,7 +21,7 @@ fn main() -> std::io::Result<()> { // 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 interface = interface_from_args()?; let group = Ipv4Addr::from(MULTICAST_ADDR); let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?; @@ -63,14 +63,17 @@ 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, and -/// warns but keeps the fallback when the argument does not parse. -fn interface_from_args() -> Ipv4Addr { +/// 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 => Ipv4Addr::UNSPECIFIED, - Some(arg) => arg.parse().unwrap_or_else(|_| { - eprintln!("invalid interface address {arg:?}; using all interfaces"); - Ipv4Addr::UNSPECIFIED + None => Ok(Ipv4Addr::UNSPECIFIED), + Some(arg) => arg.parse().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid interface address {arg:?}"), + ) }), } } diff --git a/examples/send_multicast.rs b/examples/send_multicast.rs index 00dccf5..7210ba9 100644 --- a/examples/send_multicast.rs +++ b/examples/send_multicast.rs @@ -7,6 +7,7 @@ //! //! Pair with the `receive_reassemble` example (run the receiver first). +use std::io::ErrorKind; use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; use ponk_protocol::{ @@ -22,13 +23,18 @@ fn main() -> std::io::Result<()> { // 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 pins egress to it, - // mirroring the local-address field on TouchDesigner's UDP operators. + // 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. // - // 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(); + // 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)?; @@ -78,14 +84,17 @@ 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, and -/// warns but keeps the fallback when the argument does not parse. -fn interface_from_args() -> Ipv4Addr { +/// 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 => Ipv4Addr::UNSPECIFIED, - Some(arg) => arg.parse().unwrap_or_else(|_| { - eprintln!("invalid interface address {arg:?}; using all interfaces"); - Ipv4Addr::UNSPECIFIED + None => Ok(Ipv4Addr::UNSPECIFIED), + Some(arg) => arg.parse().map_err(|_| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("invalid interface address {arg:?}"), + ) }), } }