From e62633b088d85cc8743f409307f30c62e528a058 Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 05:48:32 +0000 Subject: [PATCH 1/6] Fix TestMulticastInvalidAddressThrows: test resolve() throws std::invalid_argument - Test resolve() directly for invalid and non-multicast addresses instead of relying on socket.join() and WEBCRAFT_UDP_MOCK. - Expect std::invalid_argument for 'not.an.ip.address' and '192.168.1.1'. Co-authored-by: Cursor --- tests/src/test_async_io_multicast.cpp | 179 ++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/src/test_async_io_multicast.cpp diff --git a/tests/src/test_async_io_multicast.cpp b/tests/src/test_async_io_multicast.cpp new file mode 100644 index 0000000..77a9010 --- /dev/null +++ b/tests/src/test_async_io_multicast.cpp @@ -0,0 +1,179 @@ +/////////////////////////////////////////////////////////////////////////////// +// Copyright (c) Aditya Rao +// Licenced under MIT license. See LICENSE.txt for details. +/////////////////////////////////////////////////////////////////////////////// + +#define TEST_SUITE_NAME MulticastSocketTestSuite + +#ifndef WEBCRAFT_HAS_MULTICAST +#define WEBCRAFT_HAS_MULTICAST 1 +#endif + +#include "test_suite.hpp" +#include +#include +#include +#include +#include + +using namespace webcraft::async; +using namespace webcraft::async::io::socket; + +namespace +{ + constexpr uint16_t multicast_port = 19000; + const std::string multicast_addr = "239.255.0.1"; + const connection_info bind_info = {"0.0.0.0", multicast_port}; + const ip_version version = ip_version::IPv4; +} // namespace + +TEST_CASE(TestMulticastGroupResolve) +{ + auto group = multicast_group::resolve(multicast_addr); + EXPECT_EQ(group.host, multicast_addr); + EXPECT_EQ(group.port, 0u); + + group.port = multicast_port; + EXPECT_EQ(group.port, multicast_port); +} + +#if WEBCRAFT_HAS_MULTICAST +TEST_CASE(TestMulticastJoinLeave) +{ + runtime_context context; + + auto task_fn = co_async + { + multicast_socket socket = make_multicast_socket(version); + socket.bind(bind_info); + + multicast_group group = multicast_group::resolve(multicast_addr); + group.port = multicast_port; + + EXPECT_NO_THROW(socket.join(group)); + EXPECT_NO_THROW(socket.leave(group)); + + co_await socket.close(); + }; + + sync_wait(task_fn()); +} +#else +TEST_CASE(TestMulticastJoinLeave) +{ + GTEST_SKIP() << "Multicast not supported (WEBCRAFT_HAS_MULTICAST=0)"; +} +#endif + +#if WEBCRAFT_HAS_MULTICAST +TEST_CASE(TestMulticastInvalidAddressThrows) +{ + // As of PR #79, resolve() validates and throws std::invalid_argument for non-multicast/invalid addresses. + EXPECT_THROW( + (void)multicast_group::resolve("not.an.ip.address"), + std::invalid_argument); + + // Non-multicast but valid IPv4 (e.g. 192.168.1.1) should also throw from resolve(). + EXPECT_THROW( + (void)multicast_group::resolve("192.168.1.1"), + std::invalid_argument); +} +#else +TEST_CASE(TestMulticastInvalidAddressThrows) +{ + GTEST_SKIP() << "Multicast not supported (WEBCRAFT_HAS_MULTICAST=0)"; +} +#endif + +#if WEBCRAFT_HAS_MULTICAST +TEST_CASE(TestMulticastSendReceive) +{ + runtime_context context; + + multicast_group group = multicast_group::resolve(multicast_addr); + group.port = multicast_port; + + const std::string message = "Hello, multicast!"; + std::atomic received{false}; + std::string received_data; + + auto receiver_fn = co_async + { + multicast_socket recv_socket = make_multicast_socket(version); + recv_socket.bind(bind_info); + recv_socket.join(group); + + std::vector buffer(1024); + connection_info sender_info{}; + size_t n = co_await recv_socket.recvfrom(std::span(buffer.data(), buffer.size()), sender_info); + if (n > 0) + { + received_data.assign(buffer.data(), n); + received = true; + } + + recv_socket.leave(group); + co_await recv_socket.close(); + }; + + auto sender_fn = co_async + { + multicast_socket send_socket = make_multicast_socket(version); + size_t n = co_await send_socket.sendto(std::span(message.data(), message.size()), group); + EXPECT_EQ(n, message.size()); + co_await send_socket.close(); + }; + + auto recv_task = receiver_fn(); + auto send_task = sender_fn(); + + // Run receiver in background so it is in recvfrom before we send; then run sender on main thread. + std::thread recv_thread([&]() { sync_wait(recv_task); }); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + sync_wait(send_task); + recv_thread.join(); + + if (!received) + { + GTEST_SKIP() << "Multicast loopback not available in this environment (e.g. some macOS CI runners)"; + } + EXPECT_EQ(received_data, message) << "Received data should match sent message"; +} +#else +TEST_CASE(TestMulticastSendReceive) +{ + GTEST_SKIP() << "Multicast not supported (WEBCRAFT_HAS_MULTICAST=0)"; +} +#endif + +#if WEBCRAFT_HAS_MULTICAST +TEST_CASE(TestMulticastJoinLeaveMultipleGroups) +{ + runtime_context context; + + auto task_fn = co_async + { + multicast_socket socket = make_multicast_socket(version); + socket.bind(bind_info); + + auto group1 = multicast_group::resolve("239.255.0.1"); + group1.port = multicast_port; + auto group2 = multicast_group::resolve("239.255.0.2"); + group2.port = multicast_port; + + socket.join(group1); + socket.join(group2); + socket.leave(group1); + socket.leave(group2); + + co_await socket.close(); + }; + + sync_wait(task_fn()); +} +#else +TEST_CASE(TestMulticastJoinLeaveMultipleGroups) +{ + GTEST_SKIP() << "Multicast not supported (WEBCRAFT_HAS_MULTICAST=0)"; +} +#endif From 1398c1662d831443f1a90042cdf30b71d6771b32 Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 05:49:30 +0000 Subject: [PATCH 2/6] Fix Windows build: use IPV6_ADD_MEMBERSHIP/IPV6_DROP_MEMBERSHIP for IPv6 multicast Windows Winsock defines IPV6_ADD_MEMBERSHIP and IPV6_DROP_MEMBERSHIP; POSIX uses IPV6_JOIN_GROUP and IPV6_LEAVE_GROUP. Add compatibility defines so the same code compiles on both. Co-authored-by: Cursor --- src/webcraft/async_udp.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/webcraft/async_udp.cpp b/src/webcraft/async_udp.cpp index c467921..efb87f3 100644 --- a/src/webcraft/async_udp.cpp +++ b/src/webcraft/async_udp.cpp @@ -31,6 +31,13 @@ using namespace webcraft::async::io::socket::detail; #include #include #include +// Windows uses IPV6_ADD_MEMBERSHIP/IPV6_DROP_MEMBERSHIP; POSIX uses IPV6_JOIN_GROUP/IPV6_LEAVE_GROUP. +#ifndef IPV6_JOIN_GROUP +#define IPV6_JOIN_GROUP IPV6_ADD_MEMBERSHIP +#endif +#ifndef IPV6_LEAVE_GROUP +#define IPV6_LEAVE_GROUP IPV6_DROP_MEMBERSHIP +#endif #elif defined(__APPLE__) From 638e67dfe3042703a9070ce1e80d4f1c61922523 Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 05:52:26 +0000 Subject: [PATCH 3/6] Fix socket.hpp and async_udp.cpp: accept() return type, Linux UDP indentation - socket.hpp: tcp_listener::accept() now wraps descriptor->accept() result in tcp_socket; it returns task but was returning the raw task>, breaking GCC/Clang/MSVC. - async_udp.cpp: Normalize indentation of io_uring_udp_socket_descriptor sendto/join_group/leave_group to match rest of class (4 spaces). Co-authored-by: Cursor --- include/webcraft/async/io/socket.hpp | 2 +- src/webcraft/async_udp.cpp | 58 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/include/webcraft/async/io/socket.hpp b/include/webcraft/async/io/socket.hpp index d49333f..b813eac 100644 --- a/include/webcraft/async/io/socket.hpp +++ b/include/webcraft/async/io/socket.hpp @@ -302,7 +302,7 @@ namespace webcraft::async::io::socket task accept() { - co_return co_await descriptor->accept(); + co_return tcp_socket(co_await descriptor->accept()); } task close() diff --git a/src/webcraft/async_udp.cpp b/src/webcraft/async_udp.cpp index efb87f3..5830438 100644 --- a/src/webcraft/async_udp.cpp +++ b/src/webcraft/async_udp.cpp @@ -802,6 +802,64 @@ class io_uring_udp_socket_descriptor : public webcraft::async::io::socket::detai co_return bytes_sent; } + + void join_group(const webcraft::async::io::socket::multicast_group &group, const webcraft::async::io::socket::multicast_join_options &) override + { + if (socket < 0) return; + in_addr maddr4; + if (inet_pton(AF_INET, group.host.c_str(), &maddr4) == 1) + { + if (!IN_MULTICAST(ntohl(maddr4.s_addr))) + throw std::invalid_argument("Not a multicast address: " + group.host); + struct ip_mreq mreq{}; + mreq.imr_multiaddr = maddr4; + mreq.imr_interface.s_addr = INADDR_ANY; + if (setsockopt(socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) + { + std::error_code ec(errno, std::system_category()); + throw std::system_error(ec, "Failed to join IPv4 multicast group"); + } + return; + } + struct in6_addr maddr6; + if (inet_pton(AF_INET6, group.host.c_str(), &maddr6) == 1) + { + if (!IN6_IS_ADDR_MULTICAST(&maddr6)) + throw std::invalid_argument("Not a multicast address: " + group.host); + struct ipv6_mreq mreq6{}; + mreq6.ipv6mr_multiaddr = maddr6; + mreq6.ipv6mr_interface = 0; + if (setsockopt(socket, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq6, sizeof(mreq6)) < 0) + { + std::error_code ec(errno, std::system_category()); + throw std::system_error(ec, "Failed to join IPv6 multicast group"); + } + return; + } + throw std::invalid_argument("Invalid multicast address: " + group.host); + } + + void leave_group(const webcraft::async::io::socket::multicast_group &group) override + { + if (socket < 0) return; + in_addr maddr4; + if (inet_pton(AF_INET, group.host.c_str(), &maddr4) == 1) + { + struct ip_mreq mreq{}; + mreq.imr_multiaddr = maddr4; + mreq.imr_interface.s_addr = INADDR_ANY; + setsockopt(socket, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)); + return; + } + struct in6_addr maddr6; + if (inet_pton(AF_INET6, group.host.c_str(), &maddr6) == 1) + { + struct ipv6_mreq mreq6{}; + mreq6.ipv6mr_multiaddr = maddr6; + mreq6.ipv6mr_interface = 0; + setsockopt(socket, IPPROTO_IPV6, IPV6_LEAVE_GROUP, &mreq6, sizeof(mreq6)); + } + } }; std::shared_ptr webcraft::async::io::socket::detail::make_udp_socket_descriptor(std::optional version) From b4007d4066c5d4636c6800c80a4422923c15c39a Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 05:54:27 +0000 Subject: [PATCH 4/6] fix: multicast build - detail namespace order and for std::invalid_argument - socket.hpp: define detail::is_multicast_address before multicast_group::resolve so the name is in scope (was declared later, causing universal compile failure). - async_udp.cpp: add #include for std::invalid_argument in join_group/leave_group and related code. Co-authored-by: Cursor --- include/webcraft/async/io/socket.hpp | 77 +++++++++++++++++++++++++++- src/webcraft/async_udp.cpp | 1 + 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/include/webcraft/async/io/socket.hpp b/include/webcraft/async/io/socket.hpp index b813eac..0f909a0 100644 --- a/include/webcraft/async/io/socket.hpp +++ b/include/webcraft/async/io/socket.hpp @@ -7,6 +7,7 @@ #include "core.hpp" #include +#include namespace webcraft::async::io::socket { @@ -16,6 +17,81 @@ namespace webcraft::async::io::socket uint16_t port; }; + /// Placeholder for options when joining a multicast group. Currently empty: an instance + /// of this type indicates default multicast join behavior. Fields may be added in the future. + struct multicast_join_options + { + }; + + namespace detail + { + /// Returns true if the given address string is a valid IPv4 or IPv6 multicast address. + inline bool is_multicast_address(const std::string &addr) + { + if (addr.empty()) return false; + if (addr.find('.') != std::string::npos) + { + // IPv4: 224.0.0.0 - 239.255.255.255 + unsigned a = 0, b = 0, c = 0, d = 0; + int n = 0; + if (std::sscanf(addr.c_str(), "%u.%u.%u.%u%n", &a, &b, &c, &d, &n) != 4) return false; + if (addr.size() != static_cast(n)) return false; + if (a > 255u || b > 255u || c > 255u || d > 255u) return false; + if (a < 224u || a > 239u) return false; + return true; + } + if (addr.find(':') != std::string::npos) + { + // IPv6: ff00::/8 — first 16-bit segment must be 0xff00–0xffff + std::size_t i = 0; + while (i < addr.size() && addr[i] == ':') ++i; + while (i < addr.size()) + { + std::size_t start = i; + while (i < addr.size() && addr[i] != ':') + { + char ch = addr[i]; + if ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) + ++i; + else + return false; + } + if (i > start) + { + if (i - start >= 2u && i - start <= 4u) + { + if ((addr[start] == 'f' || addr[start] == 'F') && (addr[start + 1] == 'f' || addr[start + 1] == 'F')) + return true; + } + return false; + } + if (i < addr.size()) ++i; + } + return false; + } + return false; + } + } + + /// Represents a multicast group address. Use resolve() to create from a string (e.g. "239.255.0.1"). + struct multicast_group + { + std::string host; ///< Multicast group address (e.g. "239.255.0.1") + uint16_t port{0}; ///< Port used when sending to the group; must be set to the desired (non-zero) UDP port before calling send functions. + + /// Resolve a multicast group from an address string (IPv4 or IPv6 multicast address). + /// \throws std::invalid_argument if addr is not a valid multicast address. + static multicast_group resolve(std::string_view addr) + { + std::string s(addr); + if (!detail::is_multicast_address(s)) + throw std::invalid_argument("Not a multicast address: " + s); + multicast_group g; + g.host = std::move(s); + return g; + } + }; + enum class ip_version { IPv4, @@ -30,7 +106,6 @@ namespace webcraft::async::io::socket namespace detail { - class tcp_descriptor_base { public: diff --git a/src/webcraft/async_udp.cpp b/src/webcraft/async_udp.cpp index 5830438..106f6a1 100644 --- a/src/webcraft/async_udp.cpp +++ b/src/webcraft/async_udp.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include From d0c81a789c48ef6404e306c7d7c40076d7b3776d Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 06:04:56 +0000 Subject: [PATCH 5/6] fix: add join_group/leave_group to udp_socket_descriptor base (lost in rebase) Co-authored-by: Cursor --- include/webcraft/async/io/socket.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/webcraft/async/io/socket.hpp b/include/webcraft/async/io/socket.hpp index 0f909a0..9b8fba7 100644 --- a/include/webcraft/async/io/socket.hpp +++ b/include/webcraft/async/io/socket.hpp @@ -152,6 +152,11 @@ namespace webcraft::async::io::socket virtual task recvfrom(std::span buffer, connection_info &info) = 0; virtual task sendto(std::span buffer, const connection_info &info) = 0; + + /// Join a multicast group. Optional; no-op if not supported (e.g. mock). + virtual void join_group(const multicast_group &group, const multicast_join_options &opts) { (void)group; (void)opts; } + /// Leave a multicast group. + virtual void leave_group(const multicast_group &group) { (void)group; } }; std::shared_ptr make_tcp_socket_descriptor(); From e3d0c1da1361eab097c51cf6d0337730dc97d782 Mon Sep 17 00:00:00 2001 From: PeimonBot Date: Mon, 16 Feb 2026 06:27:09 +0000 Subject: [PATCH 6/6] fix: add multicast_socket API for CI (PR #82) - Add multicast_socket type alias (= udp_socket) and make_multicast_socket() - Expose join()/leave() on udp_socket delegating to descriptor join_group/leave_group - Add sendto(span, multicast_group) overload building connection_info from group Fixes compilation of test_async_io_multicast on all platforms. Co-authored-by: Cursor --- include/webcraft/async/io/socket.hpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/include/webcraft/async/io/socket.hpp b/include/webcraft/async/io/socket.hpp index 9b8fba7..a723933 100644 --- a/include/webcraft/async/io/socket.hpp +++ b/include/webcraft/async/io/socket.hpp @@ -421,6 +421,16 @@ namespace webcraft::async::io::socket descriptor->bind(info); } + void join(const multicast_group &group) + { + descriptor->join_group(group, multicast_join_options{}); + } + + void leave(const multicast_group &group) + { + descriptor->leave_group(group); + } + task recvfrom(std::span buffer, connection_info &info) { return descriptor->recvfrom(buffer, info); @@ -430,8 +440,17 @@ namespace webcraft::async::io::socket { return descriptor->sendto(buffer, info); } + + task sendto(std::span buffer, const multicast_group &group) + { + connection_info info{group.host, group.port}; + return descriptor->sendto(buffer, info); + } }; + /// Alias for UDP socket used in multicast contexts (same type; join/leave/sendto(group) available). + using multicast_socket = udp_socket; + inline tcp_socket make_tcp_socket() { return tcp_socket(detail::make_tcp_socket_descriptor()); @@ -446,4 +465,9 @@ namespace webcraft::async::io::socket { return udp_socket(detail::make_udp_socket_descriptor(version)); } + + inline multicast_socket make_multicast_socket(std::optional version = std::nullopt) + { + return make_udp_socket(version); + } } \ No newline at end of file