diff --git a/src/api_config.cpp b/src/api_config.cpp index 4aeb4395..acec1ddf 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 fe164672..6a4182ae 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 a70c5b2e..34e95bf3 100644 --- a/src/resolver_impl.cpp +++ b/src/resolver_impl.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,27 @@ 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); + } + + // 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()); diff --git a/testing/CMakeLists.txt b/testing/CMakeLists.txt index 90928601..5789d153 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 00000000..b1a85861 --- /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); +}