From 4a59a354de8fe968da2ac1f240e1d715200f85c7 Mon Sep 17 00:00:00 2001 From: Andreas Buhr Date: Wed, 10 Dec 2025 11:43:23 +0100 Subject: [PATCH 1/4] Extend unit tests --- test/ip_address_tests.cpp | 62 ++++++++++++++++++++++++++++++------ test/ip_endpoint_tests.cpp | 30 +++++++++++++++++ test/ipv4_address_tests.cpp | 27 ++++++++++++++++ test/ipv4_endpoint_tests.cpp | 28 ++++++++++++++++ test/ipv6_address_tests.cpp | 29 +++++++++++++++++ test/ipv6_endpoint_tests.cpp | 26 +++++++++++++++ 6 files changed, 193 insertions(+), 9 deletions(-) diff --git a/test/ip_address_tests.cpp b/test/ip_address_tests.cpp index a09ae327..857e2d82 100644 --- a/test/ip_address_tests.cpp +++ b/test/ip_address_tests.cpp @@ -6,6 +6,7 @@ #include #include "doctest/cppcoro_doctest.h" +#include TEST_SUITE_BEGIN("ip_address"); @@ -31,15 +32,58 @@ TEST_CASE("to_string") TEST_CASE("from_string") { - CHECK(ip_address::from_string("") == std::nullopt); - CHECK(ip_address::from_string("foo") == std::nullopt); - CHECK(ip_address::from_string(" 192.168.0.1") == std::nullopt); - CHECK(ip_address::from_string("192.168.0.1asdf") == std::nullopt); - - CHECK(ip_address::from_string("192.168.0.1") == ipv4_address(192, 168, 0, 1)); - CHECK(ip_address::from_string("::192.168.0.1") == ipv6_address(0, 0, 0, 0, 0, 0, 0xc0a8, 0x1)); - CHECK(ip_address::from_string("aabb:ccdd:11:2233:102:304:506:708") == - ipv6_address{ 0xAABBCCDD00112233, 0x0102030405060708 }); + CHECK(ip_address::from_string("") == std::nullopt); + CHECK(ip_address::from_string("foo") == std::nullopt); + CHECK(ip_address::from_string(" 192.168.0.1") == std::nullopt); + CHECK(ip_address::from_string("192.168.0.1asdf") == std::nullopt); + + CHECK(ip_address::from_string("192.168.0.1") == ipv4_address(192, 168, 0, 1)); + CHECK(ip_address::from_string("::192.168.0.1") == ipv6_address(0, 0, 0, 0, 0, 0, 0xc0a8, 0x1)); + CHECK(ip_address::from_string("aabb:ccdd:11:2233:102:304:506:708") == + ipv6_address{ 0xAABBCCDD00112233, 0x0102030405060708 }); +} + +TEST_CASE("round-trip and ordering") +{ + // Round-trip: to_string(from_string(s)) yields canonical string, and parsing back preserves value + auto check_round_trip = [](std::string_view s) { + auto p = ip_address::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ip_address::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + // Canonical string should be stable + CHECK(p2->to_string() == canon); + }; + + // IPv4 + check_round_trip("0.0.0.0"); + check_round_trip("255.255.255.255"); + + // IPv6 (mixed case input should parse and normalise to lower-case hex without leading zeros) + check_round_trip("::"); + check_round_trip("::1"); + check_round_trip("FFFF:0000:0000:0000:0000:0000:0000:0001"); + check_round_trip("::192.168.0.1"); + + // Ordering: IPv4 sorts less than IPv6 + ip_address v4 = ipv4_address{127, 0, 0, 1}; + ip_address v6 = ipv6_address{0, 0, 0, 0, 0, 0, 0, 1}; // ::1 + CHECK(v4 < v6); + + // Ordering within same family + CHECK(ipv4_address{1, 1, 1, 1} < ipv4_address{1, 1, 1, 2}); + CHECK(ipv6_address{0, 0, 0, 0, 0, 0, 0, 1} < ipv6_address{0, 0, 0, 0, 0, 0, 0, 2}); + + // Reject trailing/leading whitespace + CHECK(ip_address::from_string("192.168.0.1 ") == std::nullopt); + CHECK(ip_address::from_string("\t::1") == std::nullopt); + + // Reject malformed IPv6 + CHECK(ip_address::from_string("::::") == std::nullopt); + CHECK(ip_address::from_string("gggg::1") == std::nullopt); + CHECK(ip_address::from_string("12345::1") == std::nullopt); } TEST_SUITE_END(); diff --git a/test/ip_endpoint_tests.cpp b/test/ip_endpoint_tests.cpp index 6eafaeba..7c607d99 100644 --- a/test/ip_endpoint_tests.cpp +++ b/test/ip_endpoint_tests.cpp @@ -52,5 +52,35 @@ TEST_CASE("from_string" * doctest::skip{ isMsvc15_5X86Optimised }) 443 }); } +TEST_CASE("round-trip and ordering" * doctest::skip{ isMsvc15_5X86Optimised }) +{ + // Round-trip + auto check_round_trip = [](const char* s) { + auto p = ip_endpoint::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ip_endpoint::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + CHECK(p2->to_string() == canon); + }; + + check_round_trip("192.168.2.254:80"); + check_round_trip("[2001:db8:85a3::8a2e:370:7334]:22"); + + // Invalid ports/whitespace + CHECK(ip_endpoint::from_string("192.168.1.1:65536") == std::nullopt); + CHECK(ip_endpoint::from_string("[::1]:65536") == std::nullopt); + CHECK(ip_endpoint::from_string(" 192.168.1.1:80") == std::nullopt); + CHECK(ip_endpoint::from_string("[::1]:80 ") == std::nullopt); + + // Ordering: IPv4 < IPv6, and within family compare address/port + ip_endpoint v4a = ipv4_endpoint{ ipv4_address{127,0,0,1}, 1 }; + ip_endpoint v4b = ipv4_endpoint{ ipv4_address{127,0,0,1}, 2 }; + ip_endpoint v6 = ipv6_endpoint{ ipv6_address{0,0,0,0,0,0,0,1}, 1 }; + CHECK(v4a < v6); + CHECK(v4a < v4b); +} + TEST_SUITE_END(); diff --git a/test/ipv4_address_tests.cpp b/test/ipv4_address_tests.cpp index d241657a..d8956c30 100644 --- a/test/ipv4_address_tests.cpp +++ b/test/ipv4_address_tests.cpp @@ -87,4 +87,31 @@ TEST_CASE("from_string") CHECK(ipv4_address::from_string("0.0.0.0") == ipv4_address(0, 0, 0, 0)); CHECK(ipv4_address::from_string("1.2.3.4") == ipv4_address(1, 2, 3, 4)); } + +TEST_CASE("round-trip and ordering") +{ + // Round-trip canonicalisation via to_string()/from_string() + auto check_round_trip = [](const char* s) { + auto p = ipv4_address::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ipv4_address::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + CHECK(p2->to_string() == canon); // stable + }; + + check_round_trip("0.0.0.0"); + check_round_trip("255.255.255.255"); + check_round_trip("192.168.0.1"); + + // Single-integer form canonicals to dotted-decimal + auto p = ipv4_address::from_string("1"); + REQUIRE(p.has_value()); + CHECK(p->to_string() == std::string("0.0.0.1")); + + // Ordering within IPv4 space + CHECK(ipv4_address{1,2,3,4} < ipv4_address{1,2,3,5}); + CHECK(ipv4_address{10,0,0,1} < ipv4_address{10,0,0,2}); +} TEST_SUITE_END(); diff --git a/test/ipv4_endpoint_tests.cpp b/test/ipv4_endpoint_tests.cpp index 7d817bbb..30629558 100644 --- a/test/ipv4_endpoint_tests.cpp +++ b/test/ipv4_endpoint_tests.cpp @@ -30,4 +30,32 @@ TEST_CASE("from_string") ipv4_endpoint{ ipv4_address{ 192, 168, 2, 254 }, 80 }); } +TEST_CASE("round-trip, boundary ports and invalid inputs") +{ + // Round-trip canonicalisation + auto check_round_trip = [](const char* s) { + auto p = ipv4_endpoint::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ipv4_endpoint::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + CHECK(p2->to_string() == canon); + }; + + check_round_trip("192.168.2.254:80"); + check_round_trip("0.0.0.0:0"); + check_round_trip("255.255.255.255:65535"); + + // Boundary ports + CHECK(ipv4_endpoint{ ipv4_address{0,0,0,0}, 0 }.to_string() == std::string("0.0.0.0:0")); + CHECK(ipv4_endpoint{ ipv4_address{255,255,255,255}, 65535 }.to_string() == std::string("255.255.255.255:65535")); + + // Invalid: whitespace and out-of-range ports + CHECK(ipv4_endpoint::from_string(" 192.168.0.1:80") == std::nullopt); + CHECK(ipv4_endpoint::from_string("192.168.0.1:80 ") == std::nullopt); + CHECK(ipv4_endpoint::from_string("192.168.0.1:65536") == std::nullopt); + CHECK(ipv4_endpoint::from_string("192.168.0.1:-1") == std::nullopt); +} + TEST_SUITE_END(); diff --git a/test/ipv6_address_tests.cpp b/test/ipv6_address_tests.cpp index b8fd1322..0feac289 100644 --- a/test/ipv6_address_tests.cpp +++ b/test/ipv6_address_tests.cpp @@ -109,6 +109,35 @@ TEST_CASE("from_string") ipv6_address(0x20010db885a308d3, 0x13198a2e03707348)); } +TEST_CASE("round-trip and normalization") +{ + auto check_round_trip = [](const char* s) { + auto p = ipv6_address::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ipv6_address::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + CHECK(p2->to_string() == canon); + }; + + // Basic + check_round_trip("::"); + check_round_trip("::1"); + check_round_trip("FFFF:0000:0000:0000:0000:0000:0000:0001"); + + // Mixed case input should normalise to lower-case + check_round_trip("AbCd:Ef01::"); + + // IPv4 interop + check_round_trip("::ffff:192.168.0.1"); + + // Ordering stability already covered elsewhere; here just ensure canonical contraction is stable + auto p = ipv6_address::from_string("0001:0010:0100:1000::"); + REQUIRE(p.has_value()); + CHECK(p->to_string() == std::string("1:10:100:1000::")); +} + TEST_CASE("from_string IPv4 interop format") { CHECK( diff --git a/test/ipv6_endpoint_tests.cpp b/test/ipv6_endpoint_tests.cpp index b72350f1..997c8356 100644 --- a/test/ipv6_endpoint_tests.cpp +++ b/test/ipv6_endpoint_tests.cpp @@ -52,4 +52,30 @@ TEST_CASE("from_string" * doctest::skip{ isMsvc15_5X86Optimised }) ipv6_endpoint{ ipv6_address{ 0x20010db885a30000, 0x00008a2e03707334 }, 65535 }); } +TEST_CASE("round-trip and invalid ports/brackets" * doctest::skip{ isMsvc15_5X86Optimised }) +{ + // Round-trip canonicalisation + auto check_round_trip = [](const char* s) { + auto p = ipv6_endpoint::from_string(s); + REQUIRE(p.has_value()); + auto canon = p->to_string(); + auto p2 = ipv6_endpoint::from_string(canon); + REQUIRE(p2.has_value()); + CHECK(*p == *p2); + CHECK(p2->to_string() == canon); + }; + + check_round_trip("[::1]:1"); + check_round_trip("[2001:db8:85a3::8a2e:370:7334]:22"); + + // Invalid port ranges and whitespace + CHECK(ipv6_endpoint::from_string("[::1]:65536") == std::nullopt); + CHECK(ipv6_endpoint::from_string(" [::1]:80") == std::nullopt); + CHECK(ipv6_endpoint::from_string("[::1]:80 ") == std::nullopt); + + // Malformed brackets + CHECK(ipv6_endpoint::from_string("[[::1]]:80") == std::nullopt); + CHECK(ipv6_endpoint::from_string("[::1:80") == std::nullopt); +} + TEST_SUITE_END(); From 639b93570cf3d1e177fdddb1bdf5e9dad570f086 Mon Sep 17 00:00:00 2001 From: Andreas Buhr Date: Wed, 10 Dec 2025 11:51:22 +0100 Subject: [PATCH 2/4] Extend unit tests --- test/task_tests.cpp | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/test/task_tests.cpp b/test/task_tests.cpp index 68a0c0c9..8c1d8aac 100644 --- a/test/task_tests.cpp +++ b/test/task_tests.cpp @@ -8,12 +8,17 @@ #include #include #include +#include +#include +#include #include "counted.hpp" #include #include #include +#include +#include #include "doctest/cppcoro_doctest.h" @@ -346,4 +351,114 @@ TEST_CASE("lots of synchronous completions doesn't result in stack-overflow") cppcoro::sync_wait(run()); } +TEST_CASE("exception propagation from task") +{ + using cppcoro::task; + + auto throws_now = []() -> task + { + throw std::runtime_error{"boom"}; + co_return 0; + }; + + // sync_wait should propagate the exception + CHECK_THROWS_AS(cppcoro::sync_wait(throws_now()), const std::runtime_error&); + + // co_await inside another task should also propagate + cppcoro::sync_wait([&]() -> task<> + { + CHECK_THROWS_AS(co_await throws_now(), const std::runtime_error&); + co_return; + }()); +} + +TEST_CASE("cancellation observed by task") +{ + using cppcoro::task; + using cppcoro::cancellation_source; + using cppcoro::operation_cancelled; + + // Not cancelled path does not throw + { + cancellation_source s; + auto t = [tok = s.token()]() -> task<> + { + // Should not throw if not cancelled + tok.throw_if_cancellation_requested(); + co_return; + }; + CHECK_NOTHROW(cppcoro::sync_wait(t())); + } + + // Cancellation requested before start throws + { + cancellation_source s; + auto t = [tok = s.token()]() -> task<> + { + tok.throw_if_cancellation_requested(); + co_return; + }; + s.request_cancellation(); + CHECK_THROWS_AS(cppcoro::sync_wait(t()), const operation_cancelled&); + } +} + +TEST_CASE("move-only result type") +{ + using cppcoro::task; + + auto make_ptr = []() -> task> + { + co_return std::make_unique(42); + }; + + // sync_wait returns a move-only result + auto p = cppcoro::sync_wait(make_ptr()); + REQUIRE(p); + CHECK(*p == 42); + + // co_await should also move out the value + cppcoro::sync_wait([&]() -> task<> + { + auto q = co_await make_ptr(); + REQUIRE(q); + CHECK(*q == 42); + co_return; + }()); +} + +TEST_CASE("task can be moved and awaited") +{ + using cppcoro::task; + using cppcoro::single_consumer_event; + + single_consumer_event evt; + + auto make = [&]() -> task + { + co_await evt; + co_return 7; + }; + + task t = make(); + + // Move-construct and await moved-to task + task u = std::move(t); + + int result = 0; + cppcoro::sync_wait(cppcoro::when_all_ready( + [&]() -> task<> + { + result = co_await u; + co_return; + }(), + [&]() -> task<> + { + evt.set(); + co_return; + }())); + + CHECK(result == 7); +} + TEST_SUITE_END(); From 96ae1d37cb3f2e8804d7d30d5eb7624cd548f0a7 Mon Sep 17 00:00:00 2001 From: Andreas Buhr Date: Wed, 10 Dec 2025 12:37:06 +0100 Subject: [PATCH 3/4] Fix generator::co_yield for rvalues --- include/cppcoro/generator.hpp | 10 ++++- test/generator_tests.cpp | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/include/cppcoro/generator.hpp b/include/cppcoro/generator.hpp index 2ba75e6b..b35238f6 100644 --- a/include/cppcoro/generator.hpp +++ b/include/cppcoro/generator.hpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace cppcoro { @@ -40,13 +41,16 @@ namespace cppcoro std::enable_if_t::value, int> = 0> cppcoro::suspend_always yield_value(std::remove_reference_t& value) noexcept { + m_storage = std::nullopt; m_value = std::addressof(value); return {}; } cppcoro::suspend_always yield_value(std::remove_reference_t&& value) noexcept { - m_value = std::addressof(value); + // Store rvalues in promise-owned storage to ensure lifetime spans the suspension. + m_storage.emplace(std::move(value)); + m_value = std::addressof(*m_storage); return {}; } @@ -80,6 +84,8 @@ namespace cppcoro pointer_type m_value{}; std::exception_ptr m_exception{}; + // For non-reference T we may need to materialise rvalues across suspension. + std::optional m_storage{}; }; @@ -150,6 +156,8 @@ namespace cppcoro return m_coroutine.promise().value(); } + template, int> = 0> pointer operator->() const noexcept { return std::addressof(operator*()); diff --git a/test/generator_tests.cpp b/test/generator_tests.cpp index e34f21a6..1ba2131c 100644 --- a/test/generator_tests.cpp +++ b/test/generator_tests.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include "doctest/cppcoro_doctest.h" @@ -19,6 +21,14 @@ TEST_SUITE_BEGIN("generator"); using cppcoro::generator; +namespace { + // Helper to detect presence of operator-> on iterator type + template + struct has_arrow : std::false_type {}; + template + struct has_arrow().operator->())>> : std::true_type {}; +} + TEST_CASE("default-constructed generator is empty sequence") { generator ints; @@ -129,6 +139,73 @@ TEST_CASE("value-category of fmap() matches reference type") consume([]() -> generator { co_yield 123; }() | fmap(checkIsConstRvalue)); } +TEST_CASE("iterator arrow for lvalue yields and disabled for rvalue yields") +{ + SUBCASE("arrow works for generator") + { + auto g = []() -> generator + { + co_yield 10; + }(); + auto it = g.begin(); + REQUIRE(it != g.end()); + auto p = it.operator->(); + CHECK(p == &*it); + CHECK(*p == 10); + } + + SUBCASE("arrow works for generator") + { + int x = 42; + auto g = [&]() -> generator + { + co_yield x; + }(); + auto it = g.begin(); + REQUIRE(it != g.end()); + CHECK(it.operator->() == &x); + CHECK(*it == 42); + } + + SUBCASE("arrow is disabled for generator") + { + using It = generator::iterator; + static_assert(!has_arrow::value, "operator-> must be disabled for rvalue reference yields"); + // Still ensure rvalue-yielding generator produces correct values + auto g = []() -> generator + { + int v = 7; + co_yield std::move(v); + }(); + int count = 0; + for (auto&& v : g) + { + CHECK(v == 7); + ++count; + } + CHECK(count == 1); + } +} + +TEST_CASE("yielding rvalues produces stable values across suspension") +{ + auto make = []() -> generator + { + co_yield std::string{"a"}; + co_yield std::string{"bc"}; + }; + + auto gen = make(); + auto it = gen.begin(); + REQUIRE(it != gen.end()); + CHECK(*it == std::string{"a"}); + ++it; + REQUIRE(it != gen.end()); + CHECK(*it == std::string{"bc"}); + ++it; + CHECK(it == gen.end()); +} + TEST_CASE("generator doesn't start until its called") { bool reachedA = false; From a799dfb435b583ec3007a1bcfe16a10c1972ce10 Mon Sep 17 00:00:00 2001 From: Andreas Buhr Date: Wed, 10 Dec 2025 12:58:07 +0100 Subject: [PATCH 4/4] Fix generator::co_yield for rvalues --- include/cppcoro/generator.hpp | 37 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/include/cppcoro/generator.hpp b/include/cppcoro/generator.hpp index b35238f6..d6d671cb 100644 --- a/include/cppcoro/generator.hpp +++ b/include/cppcoro/generator.hpp @@ -36,21 +36,30 @@ namespace cppcoro constexpr cppcoro::suspend_always initial_suspend() const noexcept { return {}; } constexpr cppcoro::suspend_always final_suspend() const noexcept { return {}; } - template< - typename U = T, - std::enable_if_t::value, int> = 0> - cppcoro::suspend_always yield_value(std::remove_reference_t& value) noexcept - { - m_storage = std::nullopt; - m_value = std::addressof(value); - return {}; - } - - cppcoro::suspend_always yield_value(std::remove_reference_t&& value) noexcept + template + cppcoro::suspend_always yield_value(U&& value) noexcept { - // Store rvalues in promise-owned storage to ensure lifetime spans the suspension. - m_storage.emplace(std::move(value)); - m_value = std::addressof(*m_storage); + if constexpr (std::is_lvalue_reference_v) + { + if constexpr (std::is_lvalue_reference_v) + { + // Yielding an lvalue for a generator of lvalue-reference: just reference it. + m_storage = std::nullopt; + m_value = std::addressof(value); + } + else + { + // Yielding an rvalue for a generator of lvalue-reference: materialise to extend lifetime. + m_storage.emplace(std::forward(value)); + m_value = std::addressof(*m_storage); + } + } + else + { + // For value- or rvalue-reference-yielding generators, always materialise. + m_storage.emplace(std::forward(value)); + m_value = std::addressof(*m_storage); + } return {}; }