From 3636af8e110fce3914b421090ff39063c87fdcab Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 17 Jun 2026 09:39:39 -0400 Subject: [PATCH 1/2] resolver: probe machine addresses across the service-port range Same-machine discovery relied solely on a unicast query to the shared multicast_port (127.0.0.1:16571). Because that port is unicast rather than a multicast group, the OS delivers the datagram to only one of the responder sockets bound there, so only one local stream resolves and which one depends on socket bind order. Restarting programs shuffles which stream is visible. This is the root cause behind issues #28, #92, #202 and #207. Additionally probe the machine-local (loopback/unicast) addresses across the per-stream service-port range [base_port, base_port+port_range), where every stream owns a unique socket, so all local streams are discoverable regardless of bind order. This mirrors how KnownPeers are already sprayed and needs no OS routing changes. - api_config: parse and expose machine_addresses() (the unicast subset of multicast.MachineAddresses). - resolver_impl: add those addresses to the unicast target list. - test: lsl_test_resolve_machine creates 3 local outlets under ResolveScope=machine and asserts all are resolved (fails as 1==3 pre-fix). --- src/api_config.cpp | 12 +++++++++ src/api_config.h | 13 ++++++++++ src/resolver_impl.cpp | 10 ++++++++ testing/CMakeLists.txt | 13 +++++++++- testing/int/resolve_machine.cpp | 45 +++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 testing/int/resolve_machine.cpp diff --git a/src/api_config.cpp b/src/api_config.cpp index 4aeb43950..acec1ddf1 100644 --- a/src/api_config.cpp +++ b/src/api_config.cpp @@ -262,6 +262,18 @@ void api_config::load(INI &pt) { multicast_addresses_.push_back(addr); } + // Parse and store the machine-local (loopback/unicast) addresses separately. They are also + // part of multicast_addresses_, but because a unicast datagram to the shared multicast_port is + // delivered to only one of the sockets bound there, the resolver must additionally probe these + // across the per-stream service-port range to reach every local stream (see resolver_impl). + for (auto &it : machine_group) { + try { + ip::address addr = ip::make_address(it); + if ((addr.is_v4() && allow_ipv4_) || (addr.is_v6() && allow_ipv6_)) + machine_addresses_.push_back(addr); + } catch (std::exception &) {} + } + // The network stack requires the source interfaces for multicast packets to be // specified as IPv4 address or an IPv6 interface index // Try getting the interfaces from the configuration files diff --git a/src/api_config.h b/src/api_config.h index fe1646725..6a4182ae0 100644 --- a/src/api_config.h +++ b/src/api_config.h @@ -134,6 +134,18 @@ class api_config { */ const std::vector &multicast_addresses() const { return multicast_addresses_; } + /** + * @brief The machine-local (loopback/unicast) addresses from multicast.MachineAddresses. + * + * These are a subset of multicast_addresses() but, being unicast, a datagram sent to them + * at the shared multicast_port is delivered to only one of the responder sockets bound there + * (the "unicast lottery" — only one local stream answers, depending on bind order). The + * resolver therefore additionally probes these addresses across the per-stream service-port + * range [base_port, base_port+port_range), where each stream owns a unique socket, so every + * local stream is discoverable regardless of bind order. + */ + const std::vector &machine_addresses() const { return machine_addresses_; } + /** * @brief The address of the local interface on which to listen to multicast traffic. * @@ -277,6 +289,7 @@ class api_config { uint16_t multicast_port_; std::string resolve_scope_; std::vector multicast_addresses_; + std::vector machine_addresses_; int multicast_ttl_; std::string listen_address_; std::vector known_peers_; diff --git a/src/resolver_impl.cpp b/src/resolver_impl.cpp index a70c5b2e1..417e9e6ea 100644 --- a/src/resolver_impl.cpp +++ b/src/resolver_impl.cpp @@ -46,6 +46,16 @@ resolver_impl::resolver_impl() } catch (std::exception &) {} } + // Machine-local addresses (e.g. 127.0.0.1) are multicast targets too, but a unicast datagram + // to the shared multicast_port is delivered to only one of the responder sockets bound there + // (the "unicast lottery"): only one local stream answers, and which one depends on bind order. + // Probe them across the per-stream service-port range as well, where every stream owns a unique + // socket, so all local streams are discoverable regardless of bind order. + for (const auto &addr : cfg_->machine_addresses()) { + for (int p = cfg_->base_port(); p < cfg_->base_port() + cfg_->port_range(); p++) + ucast_endpoints_.emplace_back(addr, p); + } + // generate the list of protocols to use if (cfg_->allow_ipv6()) { udp_protocols_.push_back(udp::v6()); diff --git a/testing/CMakeLists.txt b/testing/CMakeLists.txt index 90928601f..5789d153c 100644 --- a/testing/CMakeLists.txt +++ b/testing/CMakeLists.txt @@ -107,6 +107,17 @@ add_executable(lsl_test_runtime_config int/runtime_config.cpp) target_link_libraries(lsl_test_runtime_config PRIVATE lslobj lslboost common catch_main) target_compile_definitions(lsl_test_runtime_config PRIVATE LIBLSL_EXPORTS) +# resolve_machine test sets the api_config singleton, so it lives in its own executable. +add_executable(lsl_test_resolve_machine int/resolve_machine.cpp) +target_link_libraries(lsl_test_resolve_machine PRIVATE lslobj lslboost common catch_main) +target_compile_definitions(lsl_test_resolve_machine PRIVATE LIBLSL_EXPORTS) +# Needs pugixml headers (resolver_impl.h -> stream_info_impl.h includes pugixml) +if(LSL_PUGIXML_IS_FETCHED) + target_include_directories(lsl_test_resolve_machine PRIVATE ${pugixml_SOURCE_DIR}/src) +else() + target_link_libraries(lsl_test_resolve_machine PRIVATE pugixml::pugixml) +endif() + if(LSL_BENCHMARKS) # to get somewhat reproducible performance numbers: # /usr/bin/time -v testing/lsl_test_exported --benchmark-samples 100 bounce @@ -122,7 +133,7 @@ if(LSL_BENCHMARKS) ) endif() -set(LSL_TESTS lsl_test_exported lsl_test_internal lsl_test_runtime_config) +set(LSL_TESTS lsl_test_exported lsl_test_internal lsl_test_runtime_config lsl_test_resolve_machine) foreach(lsltest ${LSL_TESTS}) add_test(NAME ${lsltest} COMMAND ${lsltest} --wait-for-keypress never) if(LSL_INSTALL) diff --git a/testing/int/resolve_machine.cpp b/testing/int/resolve_machine.cpp new file mode 100644 index 000000000..b1a858610 --- /dev/null +++ b/testing/int/resolve_machine.cpp @@ -0,0 +1,45 @@ +#include "api_config.h" +#include +#include +#include +#include + +// Regression test for the same-machine "unicast lottery" (only one local stream resolves). +// +// With ResolveScope=machine the only discovery target is the machine address (127.0.0.1) at the +// shared multicast_port. That port is unicast, not multicast, so a query datagram is delivered to +// exactly one of the responder sockets bound there — only one outlet answers, and which one +// depends on bind order. Several outlets are therefore created, but pre-fix only one is found. +// +// The fix additionally probes the machine addresses across the per-stream service-port range, +// where every outlet owns a unique socket, so all of them are discoverable. +// +// Sets the api_config singleton, so (like runtime_config) it lives in its own executable. + +TEST_CASE("all same-machine streams resolve under ResolveScope=machine", "[resolver][machine]") { + lsl::api_config::set_api_config_content( + "[ports]\n" + "IPv6 = disable\n" + "[multicast]\n" + "ResolveScope = machine\n" + "MachineAddresses = {127.0.0.1}\n" + "[lab]\n" + "SessionID = resolve_machine_test\n" + "KnownPeers = {}\n"); // no KnownPeers: discovery relies solely on the machine address + + // Stand up several outlets on this machine. Each binds its own per-stream service port. + constexpr int n = 3; + std::vector outlets; + outlets.reserve(n); + for (int i = 0; i < n; i++) { + lsl::stream_info info("machtest" + std::to_string(i), "machtest", 1, lsl::IRREGULAR_RATE, + lsl::cf_float32, "machsrc" + std::to_string(i)); + outlets.emplace_back(info); + } + + // Resolve by the shared type. Pre-fix the unicast lottery yields fewer than n; the fix probes + // each outlet's unique service port, so all n are found. + std::vector found = lsl::resolve_stream("type", "machtest", n, 5.0); + + CHECK(found.size() == n); +} From fcf36db3a7e829ff91c4833a1ea075878d605ab1 Mon Sep 17 00:00:00 2001 From: Chadwick Boulay Date: Wed, 17 Jun 2026 10:10:06 -0400 Subject: [PATCH 2/2] resolver: dedupe query endpoints before sending A machine address that also appears in KnownPeers (or a repeated config entry) produced the same (address, port) target twice in the unicast list, so the resolver sent identical queries to the same socket. Sort + unique the multicast and unicast endpoint lists once, after they are built, so each endpoint is queried at most once per wave. This is independent of the existing UID-based dedup of results, which must stay: two genuinely different endpoints (e.g. a stream's multicast_port and its service port) can return the same stream, and only the result dedup collapses those. --- src/resolver_impl.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/resolver_impl.cpp b/src/resolver_impl.cpp index 417e9e6ea..34e95bf3f 100644 --- a/src/resolver_impl.cpp +++ b/src/resolver_impl.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,17 @@ resolver_impl::resolver_impl() ucast_endpoints_.emplace_back(addr, p); } + // The same endpoint can be enqueued more than once (e.g. a machine address that also appears in + // KnownPeers, or a repeated config entry). Drop duplicates so we don't send identical queries to + // the same socket. This is distinct from the UID-based dedup of *results*: two different + // endpoints can still return the same stream (e.g. its multicast_port and its service port). + auto dedupe = [](std::vector &eps) { + std::sort(eps.begin(), eps.end()); + eps.erase(std::unique(eps.begin(), eps.end()), eps.end()); + }; + dedupe(mcast_endpoints_); + dedupe(ucast_endpoints_); + // generate the list of protocols to use if (cfg_->allow_ipv6()) { udp_protocols_.push_back(udp::v6());