From e230a411d5f53344fd41ca09e70921a03fa4a9bc Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 8 Nov 2026 12:20:10 +0000 Subject: [PATCH 1/4] feat: add --h2 and --h1, and make --h3 a choice of protocol like them --h3, --h2 and --h1 now say which protocols to speak, in any combination, and all three without any of them. HTTP/3 still comes only by a record that offers it, while HTTP/2 and HTTP/1.1 come by a record that offers them or by having no usable record, on the default port. A host that offers none of the protocols given, wherever its records point, is left out, with a note on why, and counted as "left out" instead of "without h3". So --h3 alone no longer falls back to TCP. anyhttp offers HTTP/1.1 along with h2, and a server may take it. With --h2 and without --h1, the connection is closed before the request goes out, which counts as one that failed, so the next address or route still gets its turn. A test server that speaks nothing but HTTP/1.1 shows that. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++-- src/main.cpp | 14 ++- src/output.cpp | 6 +- src/prober.cpp | 24 +++-- src/prober.hpp | 9 +- src/routes.cpp | 110 ++++++++++++++------ src/routes.hpp | 27 ++++- test/output_test.cpp | 14 +-- test/prober_test.cpp | 234 ++++++++++++++++++++++++++++++++++++++----- test/routes_test.cpp | 165 ++++++++++++++++++++++++++---- test/site.hpp | 1 + 11 files changed, 516 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 849b30d..c7a5c60 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ look like this, on stderr: | has a record that says the service is not available | out of reach, not connected to | | does not exist | not found, not connected to | | gets no answer from DNS, or has no address | lookup failed, so there is no telling, not connected to | -| has no record that offers `h3`, with `--h3` | without h3, not connected to | +| offers none of the protocols of `--h3`, `--h2` and `--h1`, with any of them | left out, not connected to | | could be connected to, with `--dns-only` | resolved, not connected to | | could be connected to, with `--each-address` | answered if all of its addresses did, otherwise failed | @@ -46,10 +46,17 @@ record, HTTP/3 goes first, and then TCP on the same port: HTTP/2 if the record h HTTP/1.1, which is in every record that does not say `no-default-alpn`. The route in the output is what the server went for, which may be HTTP/1.1 where HTTP/2 was offered. -**With `--h3`,** only hosts with a compatible record that has `h3` in its `alpn` go ahead, on whichever -target, and they are treated as above, so that one can still end up out of reach, or going over TCP -when HTTP/3 can not be connected to. The others are left out after their HTTPS lookup, and printed -only with `--all`. The counts at the end then add how many there were, as `5 without h3`. +**With `--h3`, `--h2` or `--h1`,** only the protocols given are spoken, in any combination, and all +three without any of them. HTTP/3 needs a compatible record with `h3` in its `alpn`. HTTP/2 and +HTTP/1.1 need a record that offers them, or no compatible record at all, which means port 443. A host +that offers none of the protocols given, on whichever target, is left out after its HTTPS lookup, +printed only with `--all` and with a note on why, and counted at the end as `5 left out`. The others +are treated as above, with the routes of those protocols only, so that one can still end up out of +reach. With `--h3` alone, that means no going over TCP when HTTP/3 can not be connected to. + +anyhttp offers HTTP/1.1 along with HTTP/2, and a server may take it, or not use ALPN at all, which +comes to the same. With `--h2` but not `--h1`, such a server is not sent the request. That counts as a +connection that failed, so the next address or route gets its turn. **With `--dns-only`,** it stops at the point where it would connect, so that no host is connected to or requested. A host that would have been goes by `resolved` and comes with its records and all of @@ -64,7 +71,8 @@ example.com: resolved All other hosts get the outcome they always get, which leaves `answered` and `failed` at 0. The addresses are only looked up for a host that would have been connected to, so an `out of reach` one has none. -The counts at the end then add how many there were, as `5 resolved`, and `--h3` works as above. +The counts at the end then add how many there were, as `5 resolved`, and `--h3`, `--h2` and `--h1` +work as above. **With `--each-address`,** every address of a host gets a request of its own instead of only the first that can be connected to, one after the other, with a line per address for the status line of the diff --git a/src/main.cpp b/src/main.cpp index 6eddd78..4ab5426 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -158,8 +158,14 @@ po::options_description describe_options(Arguments& arguments) config.timeout = std::chrono::milliseconds(not_negative("--timeout-ms", value)); }), "how long one request may take"); - opts("h3", po::bool_switch(&config.h3_only), - "connect only to the hosts with an HTTPS record that offers HTTP/3"); + opts("h3", po::bool_switch(&config.protocols.h3), + "speak HTTP/3, where an HTTPS record offers it. Of --h3, --h2 and --h1, only those given " + "are spoken, all of them without any, and a host that offers none of them is left out"); + opts("h2", po::bool_switch(&config.protocols.h2), + "speak HTTP/2, over TCP where a record offers it or there is none. Without --h1, a server " + "that chooses HTTP/1.1 is not sent the request"); + opts("h1", po::bool_switch(&config.protocols.h1), + "speak HTTP/1.1, over TCP where a record offers it or there is none"); opts("dns-only", po::bool_switch(&config.dns_only), "connect to nothing: stop after the lookups, and print the records and the addresses of " "each host"); @@ -167,7 +173,7 @@ po::options_description describe_options(Arguments& arguments) "send a request to each address of a host and print the status line of each, instead of " "one request per host"); opts("all", po::bool_switch(&arguments.all), - "also print the hosts that do not exist, and those that --h3 leaves out"); + "also print the hosts that do not exist, and those that --h3, --h2 or --h1 leave out"); opts("color", po::bool_switch(&arguments.color), "colour the output even where it does not go to a terminal, e.g. into 'less -R'"); opts("verbose,v", po::value>()->zero_tokens()->composing(), @@ -305,7 +311,7 @@ int main(int argc, char* argv[]) ++counts[result.outcome]; if (!result.records.empty() || result.aliased) ++with_records; - if (all || (result.outcome != Outcome::not_found && result.outcome != Outcome::no_h3)) + if (all || (result.outcome != Outcome::not_found && result.outcome != Outcome::left_out)) { std::print("{}", http_probe::format_result(result, color_results)); std::fflush(stdout); // a run can take a while, and it is worth watching diff --git a/src/output.cpp b/src/output.cpp index 6cb60ce..cad3b4b 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -133,8 +133,8 @@ std::string format_result(const Result& result, bool color) case Outcome::lookup_failed: text = std::format("{}: {}\n", host, colored("lookup failed", color_of::bad, color)); break; - case Outcome::no_h3: - text = std::format("{}: no h3 record\n", host); + case Outcome::left_out: + text = std::format("{}: left out\n", host); break; case Outcome::resolved: text = std::format("{}: {}\n", host, colored("resolved", color_of::good, color)); @@ -168,7 +168,7 @@ std::string format_summary(Counts counts, bool color) count(Outcome::failed, "failed", color_of::bad), count(Outcome::out_of_reach, "out of reach", color_of::moved), counts[Outcome::not_found], count(Outcome::lookup_failed, "lookups failed", color_of::bad), - counts[Outcome::no_h3] ? std::format(", {} without h3", counts[Outcome::no_h3]) : "", + counts[Outcome::left_out] ? std::format(", {} left out", counts[Outcome::left_out]) : "", counts[Outcome::resolved] ? std::format(", {}", count(Outcome::resolved, "resolved", color_of::good)) : ""); diff --git a/src/prober.cpp b/src/prober.cpp index 76074cb..b0087ec 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -354,22 +354,16 @@ capy::task Prober::probe(const std::string& host) result.aliased = found.aliased; result.records = found.records; - const auto plan = plan_routes(host, found, config_.default_port); + const auto plan = plan_routes(host, found, config_.protocols, config_.default_port); result.notes = plan.rejected; for (const auto& rejected : plan.rejected) config_.logger->debug("{}: rejected: {}", host, rejected); - if (config_.h3_only && !found.lookup_failed && !found.not_found && !has_h3_record(found)) - { - config_.logger->debug("{}: left out, no record offers HTTP/3", host); - result.outcome = Result::Outcome::no_h3; - co_return result; - } - if (plan.routes.empty()) { result.outcome = found.lookup_failed ? Result::Outcome::lookup_failed : found.not_found ? Result::Outcome::not_found + : plan.left_out ? Result::Outcome::left_out : Result::Outcome::out_of_reach; co_return result; } @@ -559,6 +553,20 @@ capy::task> Prober::exchange(anyhttp::client::Cl session = std::move(connected); + // + // anyhttp's h2 offers HTTP/1.1 as well, and a server that does not use ALPN at all gets it, + // too. Where that is not one to speak, nothing is asked, and this counts as a connection that + // failed, for the next address or route to have a go. + // + if (const auto protocol = session.protocol(); !config_.protocols.speaks(protocol)) + { + if (auto [shutdown_ec] = co_await session.shutdown(); shutdown_ec) + config_.logger->debug("{}: shutdown: {}", url.host(), shutdown_ec.message()); + co_return {std::make_error_code(std::errc::protocol_not_supported), + Response{.error = std::format("connect: the server chose {}, so no request", + protocol_name(protocol))}}; + } + anyhttp::Fields headers; headers.set(http::field::user_agent, user_agent); request_sent = true; diff --git a/src/prober.hpp b/src/prober.hpp index bec1950..48796f3 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -66,9 +66,10 @@ struct Config /// Where to go for HTTPS when the records don't say. Only tests have a use for anything else. std::uint16_t default_port = 443; - /// Connect only to the hosts with a record that offers HTTP/3, see has_h3_record(). Those are - /// treated as usual, which includes going over TCP if HTTP/3 can not be connected to. - bool h3_only = false; + /// The protocols to speak, and no others, see plan_routes(): a host that offers none of them is + /// left out. A server that takes HTTP/1.1 where h2 was offered is not sent the request without + /// h1 among them. + Protocols protocols; /// Stop after the lookups, with the addresses that it would have gone on to connect to, and /// connect to nothing. See Result::Outcome::resolved. @@ -116,7 +117,7 @@ struct Result out_of_reach, ///< not connected to, its records say so or lead elsewhere, see plan_routes() not_found, ///< not connected to, the name does not exist lookup_failed, ///< not connected to, DNS gave no answer, so there is no telling - no_h3, ///< not connected to, it has no record that offers HTTP/3, see Config::h3_only + left_out, ///< not connected to, it offers none of Config::protocols resolved, ///< not connected to, but it could have been tried, see Config::dns_only }; diff --git a/src/routes.cpp b/src/routes.cpp index 1fc85a1..64c73d9 100644 --- a/src/routes.cpp +++ b/src/routes.cpp @@ -28,18 +28,19 @@ struct Service bool offers(std::string_view protocol) const { return std::ranges::contains(alpn, protocol); } // - // What to connect with, in the order to try them, which is at most one connection per transport. - // anyhttp's h2 offers HTTP/1.1 along with it, so that a server still has the choice over TCP. + // What to connect with, of those to speak, in the order to try them, which is at most one + // connection per transport. anyhttp's h2 offers HTTP/1.1 along with it, so that a server still + // has the choice over TCP. Where h1 is not to be spoken, the prober does not take that choice. // - std::vector protocols() const + std::vector protocols(const Protocols& to_speak = {}) const { using enum anyhttp::Protocol; std::vector result; - if (offers("h3")) + if (offers("h3") && to_speak.speaks(h3)) result.push_back(h3); - if (offers("h2")) + if (offers("h2") && to_speak.speaks(h2)) result.push_back(h2); - else if (offers("http/1.1")) + else if (offers("http/1.1") && to_speak.speaks(h1)) result.push_back(h1); return result; } @@ -122,29 +123,63 @@ std::string to_text(const c_ares_async::https_record& record) return text; } -std::string Route::to_string() const +std::string_view protocol_name(anyhttp::Protocol protocol) { using enum anyhttp::Protocol; - return std::format("{} {}:{}", protocol == h3 ? "h3" : protocol == h2 ? "h2" : "h1", host, port); + return protocol == h3 ? "h3" : protocol == h2 ? "h2" : "h1"; +} + +// +// No 'using enum' in the members, which would hide the fields of the same names. +// +bool Protocols::speaks(anyhttp::Protocol protocol) const +{ + if (!h3 && !h2 && !h1) + return true; + switch (protocol) + { + case anyhttp::Protocol::h3: + return h3; + case anyhttp::Protocol::h2: + return h2; + case anyhttp::Protocol::h1: + return h1; + } + return false; +} + +bool Protocols::speaks_tcp() const +{ + return speaks(anyhttp::Protocol::h2) || speaks(anyhttp::Protocol::h1); } -Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16_t default_port) +std::string Protocols::to_string() const +{ + std::string text; + for (const auto protocol : {anyhttp::Protocol::h3, anyhttp::Protocol::h2, anyhttp::Protocol::h1}) + if (speaks(protocol)) + text += std::format("{}{}", text.empty() ? "" : " or ", protocol_name(protocol)); + return text; +} + +std::string Route::to_string() const +{ + return std::format("{} {}:{}", protocol_name(protocol), host, port); +} + +Plan plan_routes(std::string_view host, const ServiceRecords& found, const Protocols& protocols, + std::uint16_t default_port) { Plan plan{.rejected = found.notes}; if (found.lookup_failed || found.not_found) return plan; - if (found.unavailable) - { - plan.out_of_reach = true; - return plan; - } - auto records = found.records; std::ranges::stable_sort(records, {}, &c_ares_async::https_record::priority); - bool any_compatible = false; + bool any_compatible = false; // a record to take seriously, with a protocol that anyhttp speaks + bool any_to_speak = false; // one of those that offers a protocol to speak, wherever it points for (const auto& record : records) { const auto text = to_text(record); @@ -155,14 +190,21 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 continue; } - const auto protocols = service.protocols(); - if (protocols.empty()) + if (service.protocols().empty()) { plan.rejected.push_back(std::format("{}: no protocol that anyhttp speaks", text)); continue; } any_compatible = true; + const auto to_speak = service.protocols(protocols); + if (to_speak.empty()) + { + plan.rejected.push_back(std::format("{}: offers no {}", text, protocols.to_string())); + continue; + } + any_to_speak = true; + const auto target = normalize_host(is_own_name(record.target) ? found.owner : record.target); if (target != host) { @@ -172,7 +214,7 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 continue; } - for (const auto protocol : protocols) + for (const auto protocol : to_speak) { Route route{.protocol = protocol, .host = target, @@ -189,6 +231,25 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 if (!plan.routes.empty()) return plan; + // + // Not one of the protocols to speak, wherever it would have been. Without a usable record, what + // there is is TCP, and that goes for one who says that the service is not available, too. + // + if (any_compatible ? !any_to_speak : !protocols.speaks_tcp()) + { + if (!any_compatible) + plan.rejected.push_back( + std::format("no usable HTTPS record offers {}", protocols.to_string())); + plan.left_out = true; + return plan; + } + + if (found.unavailable) + { + plan.out_of_reach = true; + return plan; + } + // // After an alias, the name it led to is an endpoint even without any parameters (section 3). // @@ -203,21 +264,14 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 return plan; } - plan.routes.push_back(Route{.protocol = anyhttp::Protocol::h2, + using enum anyhttp::Protocol; + plan.routes.push_back(Route{.protocol = protocols.speaks(h2) ? h2 : h1, .host = std::string(host), .port = default_port, .source = "no usable HTTPS record"}); return plan; } -bool has_h3_record(const ServiceRecords& found) -{ - return std::ranges::any_of(found.records, [](const c_ares_async::https_record& record) { - const auto service = parse_service(record); - return !service.incompatible && service.offers("h3"); - }); -} - // ================================================================================================= } // namespace http_probe diff --git a/src/routes.hpp b/src/routes.hpp index c854bfd..ecd9b28 100644 --- a/src/routes.hpp +++ b/src/routes.hpp @@ -38,6 +38,22 @@ struct ServiceRecords bool unavailable = false; ///< an alias to "." says that the service is not available }; +/// "h3", "h2" or "h1", as in the options and the routes. +std::string_view protocol_name(anyhttp::Protocol protocol); + +/// The protocols to speak, as --h3, --h2 and --h1 say. None of them means all of them. +struct Protocols +{ + bool h3 = false; + bool h2 = false; + bool h1 = false; + + bool speaks(anyhttp::Protocol protocol) const; + bool speaks_tcp() const; ///< h2 or h1, which is what a host without a usable record gets + + std::string to_string() const; ///< "h3 or h2" +}; + /// One way of connecting, always over TLS. The TLS name and the authority stay the host's. struct Route { @@ -55,10 +71,11 @@ struct Plan std::vector routes; ///< in the order to try them; empty if there is no usable way std::vector rejected; ///< what the records offered, or we knew of, and why not bool out_of_reach = false; ///< the records say not to connect, or only to a target we can't use + bool left_out = false; ///< what the host offers is none of the protocols to speak }; /** - * Says how to connect to https://\p host/, given what was found for it. + * Says how to connect to https://\p host/, given what was found for it, with \p protocols only. * * Records go by priority, and within a record, HTTP/3 comes ahead of TCP. A name without a usable * record, the most common thing there is, gets \p default_port over TCP, and so does a record that @@ -68,12 +85,12 @@ struct Plan * wants that to be the origin, so a record is usable only if it stays on the host's own name. If * none of them does, the host is out of reach: the records do say where to go, and it is not here. * A name that does not exist and a lookup that failed have no routes either. + * + * Before any of that, the host is left out if none of its records that have to be taken seriously + * (section 8) offers one of \p protocols, wherever it points. Without such a record, it offers TCP. */ Plan plan_routes(std::string_view host, const ServiceRecords& found, - std::uint16_t default_port = 443); - -/// Whether a record that has to be taken seriously (section 8) offers HTTP/3, wherever it points. -bool has_h3_record(const ServiceRecords& found); + const Protocols& protocols = {}, std::uint16_t default_port = 443); // ================================================================================================= diff --git a/test/output_test.cpp b/test/output_test.cpp index f02002d..464b185 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -77,15 +77,15 @@ TEST(Output, SaysWhyThereIsNoResponse) TEST(Output, SaysThatAHostWasLeftOut) { - EXPECT_EQ(format_result({.host = "example.com", .outcome = Outcome::no_h3}, false), - "example.com: no h3 record\n"); + EXPECT_EQ(format_result({.host = "example.com", .outcome = Outcome::left_out}, false), + "example.com: left out\n"); } TEST(Output, CountsTheHostsLeftOutOnlyIfThereAreAny) { - EXPECT_EQ(format_summary({{Outcome::answered, 3}, {Outcome::no_h3, 5}}, false), - "3 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 without h3"); - EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("h3"))); + EXPECT_EQ(format_summary({{Outcome::answered, 3}, {Outcome::left_out, 5}}, false), + "3 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 left out"); + EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("left out"))); } TEST(Output, ListsTheAddressesOfAHostThatWasOnlyLookedUp) @@ -108,8 +108,8 @@ TEST(Output, ListsTheAddressesOfAHostThatWasOnlyLookedUp) TEST(Output, CountsTheHostsOnlyLookedUpOnlyIfThereAreAny) { - EXPECT_EQ(format_summary({{Outcome::resolved, 3}, {Outcome::no_h3, 5}}, false), - "0 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 without h3, " + EXPECT_EQ(format_summary({{Outcome::resolved, 3}, {Outcome::left_out, 5}}, false), + "0 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 left out, " "3 resolved"); EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("resolved"))); } diff --git a/test/prober_test.cpp b/test/prober_test.cpp index c6fb602..3f2edef 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -5,12 +5,17 @@ #include "prober.hpp" #include "site.hpp" +#include #include #include +#include #include #include #include +#include #include +#include +#include #include #include @@ -21,6 +26,7 @@ #include +#include #include #include #include @@ -945,16 +951,16 @@ TEST_F(Probe, ReportsAFailedLookup) } // ================================================================================================= -// --h3 +// --h3, --h2 and --h1 // ================================================================================================= -TEST_F(Probe, H3OnlyRequestsAHostWithARecordThatOffersIt) +TEST_F(Probe, H3RequestsAHostWithARecordThatOffersIt) { site.dns().add_https("localhost", 1, ".", {alpn_h3, port_param(site.port())}); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -963,61 +969,62 @@ TEST_F(Probe, H3OnlyRequestsAHostWithARecordThatOffersIt) EXPECT_THAT(site.targets(), ElementsAre("/")); } -TEST_F(Probe, H3OnlyLeavesOutAHostWithoutARecord) +TEST_F(Probe, H3LeavesOutAHostWithoutARecord) { site.dns().add_address("localhost", "127.0.0.1"); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].outcome, Result::Outcome::no_h3); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); EXPECT_THAT(results[0].address, IsEmpty()); EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyLeavesOutAHostWhoseRecordsDoNotOfferIt) +TEST_F(Probe, H3LeavesOutAHostWhoseRecordsDoNotOfferIt) { site.dns().add_https("localhost", 1, ".", h2_at(site.port())); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].outcome, Result::Outcome::no_h3); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); EXPECT_EQ(results[0].records.size(), 1u) << "for --all to say why"; EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillFallsBackToTcpWhenAConnectionFails) +TEST_F(Probe, H3DoesNotFallBackToTcp) { auto silent = silent_udp_socket(); site.dns().add_https("localhost", 1, ".", h3_at(silent.local_endpoint().port())); - site.dns().add_https("localhost", 2, ".", h2_at(site.port())); + site.dns().add_https("localhost", 2, ".", {alpn_h2, port_param(site.port())}); site.page("/", {.body = "hello"}); auto configuration = config(); configuration.timeout = 200ms; - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].status, 200) << notes(results[0]); - EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); - EXPECT_THAT(site.targets(), ElementsAre("/")); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + EXPECT_THAT(results[0].error, HasSubstr("timed out")); + EXPECT_THAT(notes(results[0]), HasSubstr("offers no h3")); + EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillSaysThatARecordLeadsElsewhere) +TEST_F(Probe, H3StillSaysThatARecordLeadsElsewhere) { site.dns().add_https("localhost", 1, "elsewhere.example.test", h3_at(site.port())); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -1025,22 +1032,22 @@ TEST_F(Probe, H3OnlyStillSaysThatARecordLeadsElsewhere) EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillSaysThatANameDoesNotExist) +TEST_F(Probe, H3StillSaysThatANameDoesNotExist) { auto configuration = config({"nowhere.example.test"}); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].outcome, Result::Outcome::not_found); } -TEST_F(Probe, H3OnlyDoesNotTakeAFailedLookupForNoHttp3) +TEST_F(Probe, H3DoesNotTakeAFailedLookupForNoHttp3) { site.dns().set_rcode(ARES_RCODE_SERVFAIL); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -1048,6 +1055,185 @@ TEST_F(Probe, H3OnlyDoesNotTakeAFailedLookupForNoHttp3) EXPECT_THAT(site.requests(), IsEmpty()); } +TEST_F(Probe, H2SpeaksNothingElseThatTheRecordOffers) +{ + const fake_dns_server::svc_param alpn_h3_h2{ARES_SVCB_PARAM_ALPN, {2, 'h', '3', 2, 'h', '2'}}; + site.dns().add_https("localhost", 1, ".", {alpn_h3_h2, port_param(site.port())}); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H1SpeaksHttp1WhereTheRecordOffersHttp2AsWell) +{ + site.dns().add_https("localhost", 1, ".", {alpn_h2, port_param(site.port())}); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H1LeavesOutAHostWhoseRecordsOfferOnlyHttp2) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); + EXPECT_THAT(notes(results[0]), HasSubstr("offers no h1")); + EXPECT_THAT(results[0].address, IsEmpty()); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, H1GoesOverTcpToTheDefaultPortWithoutARecord) +{ + site.dns().add_address("localhost", "127.0.0.1"); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H3AndH2FallBackToTcpWhenAConnectionFails) +{ + auto silent = silent_udp_socket(); + + site.dns().add_https("localhost", 1, ".", h3_at(silent.local_endpoint().port())); + site.dns().add_https("localhost", 2, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.timeout = 200ms; + configuration.protocols = {.h3 = true, .h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].status, 200) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +namespace +{ + +// +// A server over TLS that speaks HTTP/1.1 and nothing else, by ALPN, where the site's server takes +// HTTP/2 whenever it is offered. It answers a request with a 204 and keeps what it was sent. +// +class Http1Server : public Probe +{ +protected: + void SetUp() override + { + ASSERT_FALSE(tls.use_certificate_chain_file(site.ca_file().string())); + ASSERT_FALSE( + tls.use_private_key_file(site.key_file().string(), corosio::tls_file_format::pem)); + ASSERT_FALSE(tls.set_alpn({"http/1.1"})); + capy::run_async(context.get_executor())(serve()); + + port = acceptor.local_endpoint().port(); // which is 0 once it is closed + site.dns().add_https("localhost", 1, ".", {alpn_h2, port_param(port)}); + } + + void stop_sites() override + { + site.stop(); + acceptor.close(); + } + + capy::task<> serve() + { + for (;;) + { + corosio::tcp_socket socket(context); + if (auto [ec] = co_await acceptor.accept(socket); ec) + co_return; + ++connections; + + corosio::openssl_stream stream(std::move(socket), tls); + if (auto [ec] = co_await stream.handshake(corosio::tls_role::server); ec) + continue; + + std::string request; + std::array buffer; + while (!request.contains("\r\n\r\n")) + { + auto [ec, n] = co_await stream.read_some(capy::make_buffer(buffer)); + if (ec || n == 0) + break; + request.append(buffer.data(), n); + } + received += request; + + if (request.contains("\r\n\r\n")) + { + constexpr std::string_view response = + "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"; + std::ignore = co_await capy::write(stream, capy::make_buffer(response)); + } + std::ignore = co_await stream.shutdown(); + } + } + + corosio::tcp_acceptor acceptor = silent_tcp_acceptor(); + std::uint16_t port = 0; + corosio::tls_context tls; + std::string received; ///< all that came in over all connections, once decrypted + int connections = 0; +}; + +} // namespace + +TEST_F(Http1Server, H2DoesNotSendTheRequestToAServerThatChoosesHttp1) +{ + auto configuration = config(); + configuration.protocols = {.h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + EXPECT_THAT(results[0].error, HasSubstr("chose h1")); + EXPECT_GE(connections, 1); + EXPECT_THAT(received, IsEmpty()); +} + +TEST_F(Http1Server, H2AndH1LetTheServerChooseHttp1) +{ + auto configuration = config(); + configuration.protocols = {.h2 = true, .h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].status, 204); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", port)); + EXPECT_THAT(received, testing::StartsWith("GET / HTTP/1.1\r\n")); + EXPECT_EQ(connections, 1); +} + // ================================================================================================= // --dns-only // ================================================================================================= @@ -1146,7 +1332,7 @@ TEST_F(Probe, DnsOnlyStillSaysThatThereIsNoAddress) EXPECT_THAT(notes(results[0]), HasSubstr("address lookup")); } -TEST_F(Probe, DnsOnlyGoesWithH3Only) +TEST_F(Probe, DnsOnlyGoesWithH3) { site.dns().add_https("offers.example.test", 1, ".", h3_at(site.port())); site.dns().add_address("offers.example.test", "127.0.0.1"); @@ -1155,13 +1341,13 @@ TEST_F(Probe, DnsOnlyGoesWithH3Only) auto configuration = config({"offers.example.test", "lacks.example.test"}); configuration.dns_only = true; - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; configuration.concurrency = 1; probe(std::move(configuration)); ASSERT_EQ(results.size(), 2u); EXPECT_EQ(results[0].outcome, Result::Outcome::resolved) << notes(results[0]); - EXPECT_EQ(results[1].outcome, Result::Outcome::no_h3); + EXPECT_EQ(results[1].outcome, Result::Outcome::left_out); EXPECT_THAT(site.requests(), IsEmpty()); } diff --git a/test/routes_test.cpp b/test/routes_test.cpp index 068d9ea..d2ab206 100644 --- a/test/routes_test.cpp +++ b/test/routes_test.cpp @@ -121,13 +121,13 @@ TEST(Plan, GoesOverTcpToTheDefaultPortWithoutARecord) { EXPECT_THAT(routes(plan_routes("example.com", found({}))), ElementsAre("h2 example.com:443")); EXPECT_THAT(plan_routes("example.com", found({})).rejected, IsEmpty()) << "nothing odd about it"; - EXPECT_THAT(routes(plan_routes("example.com", found({}), 8443)), + EXPECT_THAT(routes(plan_routes("example.com", found({}), {}, 8443)), ElementsAre("h2 example.com:8443")); } TEST(Plan, RecordsThatDoNotSayGoToTheDefaultPort) { - EXPECT_THAT(routes(plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), 8443)), + EXPECT_THAT(routes(plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), {}, 8443)), ElementsAre("h2 example.com:8443")); } @@ -289,36 +289,163 @@ TEST(Plan, TreatsARecordWithAnInvalidPortAsNoRecord) EXPECT_THAT(routes(plan), ElementsAre("h2 example.com:443")); } -TEST(HasH3Record, IsTrueForARecordThatOffersIt) +TEST(Plan, PassesOnWhatWentWrongInTheLookup) { - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h3"})}))); - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h2,h3"})}))); - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h2"}), record(2, ".", {"alpn=h3"})}))); + auto records = found({}); + records.notes.push_back("HTTPS lookup failed"); + + EXPECT_THAT(plan_routes("example.com", records).rejected.front(), "HTTPS lookup failed"); } -TEST(HasH3Record, DoesNotCareWhereTheRecordPoints) +// ------------------------------------------------------------------------------------------------- +// --h3, --h2 and --h1 +// ------------------------------------------------------------------------------------------------- + +namespace { - EXPECT_TRUE(has_h3_record(found({record(1, "other.example", {"alpn=h3"})}))); +const Protocols h3{.h3 = true}; +const Protocols h2{.h2 = true}; +const Protocols h1{.h1 = true}; +} // namespace + +TEST(Protocols, SpeaksThoseGivenOrAllWithoutAny) +{ + EXPECT_TRUE(Protocols{}.speaks(anyhttp::Protocol::h3)); + EXPECT_TRUE(Protocols{}.speaks(anyhttp::Protocol::h1)); + EXPECT_TRUE(h2.speaks(anyhttp::Protocol::h2)); + EXPECT_FALSE(h2.speaks(anyhttp::Protocol::h1)); + EXPECT_FALSE(h3.speaks_tcp()); + EXPECT_TRUE((Protocols{.h3 = true, .h1 = true}).speaks_tcp()); } -TEST(HasH3Record, IsFalseForAnythingElse) +TEST(Protocols, SaysWhichTheyAre) { - EXPECT_FALSE(has_h3_record(found({}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".")}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h2"})}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3-29,h2"})}))); + EXPECT_EQ(h2.to_string(), "h2"); + EXPECT_EQ((Protocols{.h3 = true, .h1 = true}).to_string(), "h3 or h1"); + EXPECT_EQ(Protocols{}.to_string(), "h3 or h2 or h1"); } -TEST(HasH3Record, IgnoresARecordThatHasToBeIgnored) +TEST(Plan, SpeaksOnlyTheProtocolsGiven) { - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3", "mandatory=ech", "ech=dead"})}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3", "port=none"})}))); + const auto records = found({record(1, ".", {"alpn=h3,h2"})}); + + EXPECT_THAT(routes(plan_routes("example.com", records, h3)), ElementsAre("h3 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", records, h2)), ElementsAre("h2 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", records, h1)), ElementsAre("h1 example.com:443")) + << "the default set"; + EXPECT_THAT(routes(plan_routes("example.com", records, {.h3 = true, .h1 = true})), + ElementsAre("h3 example.com:443", "h1 example.com:443")); } -TEST(Plan, PassesOnWhatWentWrongInTheLookup) +TEST(Plan, LeavesTheChoiceOverTcpToTheServerWithBothOfThem) +{ + const auto plan = + plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), {.h2 = true, .h1 = true}); + + EXPECT_THAT(routes(plan), ElementsAre("h2 example.com:443")); +} + +TEST(Plan, GoesOverTcpWithoutARecordWithTheProtocolGiven) +{ + EXPECT_THAT(routes(plan_routes("example.com", found({}), h2)), + ElementsAre("h2 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", found({}), h1)), + ElementsAre("h1 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", found({}), {.h3 = true, .h1 = true})), + ElementsAre("h1 example.com:443")); +} + +TEST(Plan, LeavesOutAHostWithoutARecordForH3Alone) +{ + const auto plan = plan_routes("example.com", found({}), h3); + + EXPECT_THAT(plan.routes, IsEmpty()); + EXPECT_TRUE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + EXPECT_THAT(plan.rejected, ElementsAre("no usable HTTPS record offers h3")); +} + +TEST(Plan, LeavesOutAHostWhoseRecordsOfferNoneOfThem) +{ + const auto plan = plan_routes("example.com", + found({record(1, ".", {"alpn=h3", "no-default-alpn"}), + record(2, ".", {"alpn=h3-29,h2", "no-default-alpn"})}), + h1); + + EXPECT_THAT(plan.routes, IsEmpty()); + EXPECT_TRUE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + EXPECT_THAT(plan.rejected, ElementsAre(HasSubstr("offers no h1"), HasSubstr("offers no h1"))); +} + +TEST(Plan, SaysWhichRecordsOfferNoneOfThem) +{ + const auto plan = plan_routes( + "example.com", found({record(1, ".", {"alpn=h2"}), record(2, ".", {"alpn=h3", "port=8443"})}), + h3); + + EXPECT_THAT(routes(plan), ElementsAre("h3 example.com:8443")); + EXPECT_THAT(plan.rejected, ElementsAre("HTTPS 1 . alpn=h2: offers no h3")); +} + +TEST(Plan, LeavesOutOnlyByWhatTheRecordsOfferWhereverTheyPoint) +{ + // + // A record that offers the protocol, on a target that can't be used, is out of reach as ever. + // + const auto offered = + plan_routes("example.com", found({record(1, "other.example", {"alpn=h3"})}), h3); + EXPECT_TRUE(offered.out_of_reach); + EXPECT_FALSE(offered.left_out); + + const auto not_offered = plan_routes( + "example.com", found({record(1, "other.example", {"alpn=h2", "no-default-alpn"})}), h3); + EXPECT_TRUE(not_offered.left_out); + EXPECT_FALSE(not_offered.out_of_reach); +} + +TEST(Plan, TakesARecordThatHasToBeIgnoredForNoRecordWithTheProtocolsGiven) +{ + for (const auto& params : std::vector>{ + {"alpn=h3", "mandatory=ech", "ech=dead"}, {"alpn=h3", "port=none"}}) + { + const auto records = found({record(1, ".", params)}); + + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_THAT(routes(plan_routes("example.com", records, h2)), + ElementsAre("h2 example.com:443")); + } +} + +TEST(Plan, LeavesOutAnUnavailableServiceWithoutTcp) { auto records = found({}); - records.notes.push_back("HTTPS lookup failed"); + records.unavailable = true; - EXPECT_THAT(plan_routes("example.com", records).rejected.front(), "HTTPS lookup failed"); + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_TRUE(plan_routes("example.com", records, h1).out_of_reach); + EXPECT_FALSE(plan_routes("example.com", records, h1).left_out); +} + +TEST(Plan, LeavesOutTheTargetOfAnAliasWithoutTcp) +{ + const auto records = found({}, "other.example", true); + + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_TRUE(plan_routes("example.com", records, h2).out_of_reach); +} + +TEST(Plan, LeavesOutNeitherANameThatDoesNotExistNorOneThatWasNotLookedUp) +{ + auto not_found = found({}); + not_found.not_found = true; + auto lookup_failed = found({}); + lookup_failed.lookup_failed = true; + + for (const auto& records : {not_found, lookup_failed}) + { + const auto plan = plan_routes("example.com", records, h3); + EXPECT_FALSE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + } } diff --git a/test/site.hpp b/test/site.hpp index c126cc3..b6b8dd4 100644 --- a/test/site.hpp +++ b/test/site.hpp @@ -63,6 +63,7 @@ class Site std::uint16_t port() const { return port_; } const std::filesystem::path& ca_file() const { return certificate_file_; } + const std::filesystem::path& key_file() const { return key_file_; } ///< of the certificate /// The zone. Good until stop(), as opposed to what was asked of it, see dns_queries(). fake_dns_server& dns() { return *dns_; } From c4b767b1fd5acd5851e117f85bfed6d866eceae5 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 9 Nov 2026 19:51:50 +0000 Subject: [PATCH 2/4] feat: ask all addresses of a host at the same time with --each-address A host now takes as long as its slowest address rather than all of them together. Replies and notes stay in the order of the addresses. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- src/prober.cpp | 40 +++++++++++++++++++++++++++++----------- src/prober.hpp | 15 +++++++++------ test/prober_test.cpp | 26 ++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c7a5c60..4c918b3 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ example.com: answered All other hosts get the outcome they always get. A host counts as `answered` only if all of its addresses did, whatever the status, and otherwise as `failed`. This is the one exception to one request -per host, and it can't go with `--dns-only`. `--timeout-ms` then is per address on each route. Beyond +per host, and it can't go with `--dns-only`. The addresses are all asked at the same time, so a host +takes as long as its slowest address, and they are printed in the order of trying them all the same. +`--timeout-ms` then is per address on each route. Beyond that, the next paragraph holds for each address, and nothing is followed here either. HTTP/2 and HTTP/3 have no reason phrase, so there the `OK` is what that status is called. diff --git a/src/prober.cpp b/src/prober.cpp index b0087ec..66c3e5d 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -397,16 +398,30 @@ capy::task Prober::probe(const std::string& host) co_return result; } + // + // All addresses at the same time, as each of them gets a request of its own anyway, so that a + // host takes as long as its slowest address and not as long as all of them together. The replies + // and the notes are in the order of the addresses all the same. + // if (config_.each_address) { + std::vector> notes(addresses.size()); + std::vector>> asks; + for (std::size_t i = 0; i < addresses.size(); ++i) + asks.push_back(ask_address(plan.routes, host, addresses[i], notes[i])); + + auto [ec, replies] = co_await capy::when_all(std::move(asks)); + if (ec) // never, see ask_address() + throw std::logic_error(std::format("{}: {}", host, ec.message())); + result.outcome = Result::Outcome::answered; - for (const auto& address : addresses) + for (std::size_t i = 0; i < replies.size(); ++i) { - auto reply = co_await ask_address(plan.routes, host, address, result.notes); - if (!reply.error.empty()) + if (!replies[i].error.empty()) result.outcome = Result::Outcome::failed; - result.replies.push_back(std::move(reply)); + std::ranges::move(notes[i], std::back_inserter(result.notes)); } + result.replies = std::move(replies); co_return result; } @@ -453,10 +468,13 @@ capy::task Prober::probe(const std::string& host) // One request to one address: the next route is for a connection that did not come about, and once // a request has gone out, that was it for this address, however it ended. // -capy::task Prober::ask_address(const std::vector& routes, - const std::string& host, - const corosio::endpoint& address, - std::vector& notes) +// What went wrong is in the reply, and the error code always success: when_all() would take one +// for a reason to stop the others, which have nothing to do with this address. +// +capy::task> Prober::ask_address(const std::vector& routes, + const std::string& host, + const corosio::endpoint& address, + std::vector& notes) { AddressReply reply{.address = address.address().to_string()}; for (const auto& route : routes) @@ -471,16 +489,16 @@ capy::task Prober::ask_address(const std::vector& routes, reply.status = static_cast(response.message.result_int()); reply.reason = response.message.reason(); reply.error.clear(); - co_return reply; + co_return {std::error_code(), std::move(reply)}; } reply.error = std::format("[{}] {}", route.to_string(), response.error); notes.push_back(std::format("{} at {}", reply.error, reply.address)); config_.logger->debug("{}: {} at {}", host, reply.error, reply.address); if (response.request_sent) - co_return reply; + break; } - co_return reply; + co_return {std::error_code(), std::move(reply)}; } // diff --git a/src/prober.hpp b/src/prober.hpp index 48796f3..17d586f 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -75,8 +75,9 @@ struct Config /// connect to nothing. See Result::Outcome::resolved. bool dns_only = false; - /// Send a request to each address of a host, one at a time, rather than to the first that can be - /// connected to. Config::timeout then is per address on each route. See Result::replies. + /// Send a request to each address of a host, all of them at the same time, rather than to the + /// first that can be connected to. Config::timeout then is per address on each route. See + /// Result::replies. bool each_address = false; /// Where the messages of this program and of c-ares go. anyhttp can't be told: it always uses @@ -148,7 +149,8 @@ struct Result * and nothing else on the host is requested. The next route is for a connection that did not come * about: once a request has gone out, that was it for this host, however it ended. With * Config::dns_only, it is the lookups and nothing else. With Config::each_address, all of this goes - * for each address of the host instead of for the host: one request per address, in order. + * for each address of the host instead of for the host: one request per address, to all of them at + * the same time, with the replies in the order of the addresses. * * Hosts are worked on Config::concurrency at a time, so results arrive as they are ready. All of * this is on the one executor, which is also what calls the observer. @@ -179,9 +181,10 @@ class Prober capy::task> work(); capy::task resolve_service(const std::string& host); capy::task probe(const std::string& host); - capy::task ask_address(const std::vector& routes, const std::string& host, - const corosio::endpoint& address, - std::vector& notes); + capy::task> ask_address(const std::vector& routes, + const std::string& host, + const corosio::endpoint& address, + std::vector& notes); capy::task> attempt(const Route& route, const std::string& host, const corosio::endpoint& endpoint, std::chrono::steady_clock::time_point deadline); diff --git a/test/prober_test.cpp b/test/prober_test.cpp index 3f2edef..00030dd 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -26,6 +26,7 @@ #include +#include #include #include #include @@ -36,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -1381,6 +1383,30 @@ TEST_F(Probe, EachAddressSendsOneRequestToEachOfThem) EXPECT_EQ(site.requests().size(), results[0].replies.size()); } +// +// The server takes its time with each of them, so that one after the other, no request would arrive +// before the response to the one before. +// +TEST_F(Probe, EachAddressAsksAllOfThemAtTheSameTime) +{ + constexpr auto delay = 400ms; + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.body = "hello", .delay = delay}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + if (results[0].replies.size() < 2) + GTEST_SKIP() << "localhost has only one address here"; + ASSERT_EQ(site.requests().size(), results[0].replies.size()); + const auto [first, last] = + std::ranges::minmax(site.requests() | std::views::transform(&Site::Request::time)); + EXPECT_LT(last - first, delay); +} + TEST_F(Probe, EachAddressGoesOverHttp3) { site.dns().add_https("localhost", 1, ".", h3_at(site.port())); From ef2d2766a847aa24e8796dbcb8b484a75e61c935 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 9 Nov 2026 19:59:52 +0000 Subject: [PATCH 3/4] feat: print where a redirect points, without following it A 3xx response gets its Location at the end of the status line, as the server sent it: not resolved against the URL and not requested. This matters most with --each-address, which prints no header fields. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- doc/README.md | 2 +- src/output.cpp | 26 +++++++++++++++++--------- src/prober.cpp | 13 +++++++++++++ src/prober.hpp | 4 ++++ test/output_test.cpp | 24 ++++++++++++++++++++++++ test/prober_test.cpp | 19 +++++++++++++++++++ 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4c918b3..bcd6c43 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ It looks up the HTTPS records of each host in DNS ([RFC 9460](https://www.rfc-ed and requests `https:///` once, over TLS, in the way that the records say. It prints the status, the route, the IP address it connected to, the time it took and the header fields of the response, along with the host's records. A redirect is reported and not followed, and nothing else on the host -is requested. [Here is what that looks like](doc/README.md). +is requested. Its `Location` goes at the end of the status line, as in `301 … -> https://www.example.com/`, +as it came: not resolved against the URL, and not looked up. [Here is what that looks like](doc/README.md). There is output for every host but those that do not exist, which `--all` adds. The counts at the end look like this, on stderr: @@ -91,7 +92,8 @@ per host, and it can't go with `--dns-only`. The addresses are all asked at the takes as long as its slowest address, and they are printed in the order of trying them all the same. `--timeout-ms` then is per address on each route. Beyond that, the next paragraph holds for each address, and nothing is followed here either. HTTP/2 and HTTP/3 -have no reason phrase, so there the `OK` is what that status is called. +have no reason phrase, so there the `OK` is what that status is called. A redirect has its `Location` +at the end of the line, as in `301 Moved Permanently -> /elsewhere`, and the header fields are left out. **One request means one.** The next route is for when connecting failed. Once a request has gone out, the host has been asked, so that a timeout or a response cut short ends it there, as a failure. diff --git a/doc/README.md b/doc/README.md index 9ac5edd..873164e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -184,7 +184,7 @@ The two tokens in `set-cookie` and `report-to` are cut short to `…`. 21:01:20.687 debug [h3:[2606:4700::6810:84e5]:443] write_pkt: nghttp3_conn_writev_stream -> stream=-1 sveccnt=0 fin=0 --- 62 ------------------------------------------------------------------------ 21:01:20.688 info [client] dtor -cloudflare.com: 301 [h3 cloudflare.com:443] at 2606:4700::6810:84e5, 167 bytes in 82ms +cloudflare.com: 301 [h3 cloudflare.com:443] at 2606:4700::6810:84e5, 167 bytes in 82ms -> https://www.cloudflare.com/ record: HTTPS 1 . alpn=h3,h2 ipv4hint=104.16.132.229,104.16.133.229 ipv6hint=2606:4700::6810:84e5,2606:4700::6810:85e5 content-type: text/html; charset=UTF-8 date: Tue, 03 Nov 2026 21:01:21 GMT diff --git a/src/output.cpp b/src/output.cpp index cad3b4b..0e66a86 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -43,6 +43,14 @@ std::string address_text(std::string_view address, bool color) return colored(address, v6 ? color_of::v6 : color_of::v4, color); } +// +// Where a redirect points, as the server put it, or nothing. +// +std::string location_text(std::string_view location) +{ + return location.empty() ? std::string() : std::format(" -> {}", location); +} + // // What came of the request to one address: the status line, as far as it goes without the version, // which the routes say more about, or what went wrong. @@ -51,9 +59,9 @@ std::string reply_text(const AddressReply& reply, bool color) { if (!reply.error.empty()) return std::format("{}: {}", colored("failed", color_of::bad, color), reply.error); - return reply.reason.empty() - ? status_text(reply.status, color) - : std::format("{} {}", status_text(reply.status, color), reply.reason); + return std::format("{}{}{}", status_text(reply.status, color), + reply.reason.empty() ? "" : std::format(" {}", reply.reason), + location_text(reply.location)); } // @@ -108,12 +116,12 @@ std::string format_result(const Result& result, bool color) switch (result.outcome) { case Outcome::answered: - text = - !result.replies.empty() - ? std::format("{}: {}\n", host, colored("answered", color_of::good, color)) - : std::format("{}: {} [{}] at {}, {} bytes in {}\n", host, - status_text(result.status, color), result.route, - address_text(result.address, color), result.body_size, result.duration); + text = !result.replies.empty() + ? std::format("{}: {}\n", host, colored("answered", color_of::good, color)) + : std::format("{}: {} [{}] at {}, {} bytes in {}{}\n", host, + status_text(result.status, color), result.route, + address_text(result.address, color), result.body_size, + result.duration, location_text(result.location)); break; case Outcome::failed: text = !result.replies.empty() diff --git a/src/prober.cpp b/src/prober.cpp index 66c3e5d..c1997c4 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -37,6 +37,17 @@ constexpr std::string_view user_agent = "http-probe/0.0.1 (+https://github.com/p constexpr int max_alias_chain = 8; +// +// Where a redirect points, as the server put it: nothing is resolved against the URL that was +// asked for, and nothing is requested. Empty for anything but a redirect. +// +std::string redirect_location(const anyhttp::client::Message& message) +{ + const auto status = message.result_int(); + return status >= 300 && status < 400 ? std::string(message[http::field::location]) + : std::string(); +} + // // The resolver does not write anywhere by itself. Its messages go where our own go. // @@ -451,6 +462,7 @@ capy::task Prober::probe(const std::string& host) result.outcome = Result::Outcome::answered; result.error.clear(); result.status = static_cast(response.message.result_int()); + result.location = redirect_location(response.message); auto spoken = route; spoken.protocol = response.protocol; result.route = spoken.to_string(); @@ -488,6 +500,7 @@ capy::task> Prober::ask_address(const std::vector(response.message.result_int()); reply.reason = response.message.reason(); + reply.location = redirect_location(response.message); reply.error.clear(); co_return {std::error_code(), std::move(reply)}; } diff --git a/src/prober.hpp b/src/prober.hpp index 17d586f..7537e77 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -105,6 +105,7 @@ struct AddressReply std::string address; int status = 0; ///< without \c error std::string reason; ///< without \c error: "OK", which HTTP/2 and HTTP/3 only know by the status + std::string location; ///< with a 3xx status: the Location field as it came, see Result::location std::string error; ///< what went wrong on the last route, if no response could be had }; @@ -130,6 +131,9 @@ struct Result int status = 0; ///< with \c answered std::string route; ///< with \c answered: what the response came over, as the server chose it std::vector> fields; ///< with \c answered + /// With \c answered and a 3xx status: the Location field as it came, neither resolved nor + /// followed, and empty without one. + std::string location; std::size_t body_size = 0; ///< with \c answered std::chrono::milliseconds duration{}; ///< with \c answered: of the attempt that got through diff --git a/test/output_test.cpp b/test/output_test.cpp index 464b185..2137d14 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -40,6 +40,30 @@ TEST(Output, SaysWhatCameBack) " server: test\n"); } +TEST(Output, SaysWhereARedirectPoints) +{ + auto result = answered(301); + result.location = "https://www.example.com/"; + EXPECT_EQ(format_result(result, false), + "example.com: 301 [h3 example.com:443] at 192.0.2.1, 5 bytes in 12ms" + " -> https://www.example.com/\n" + " record: HTTPS 1 . alpn=h3 ipv4hint=192.0.2.1 ipv6hint=2001:db8::1\n" + " server: test\n"); +} + +TEST(Output, SaysWhereARedirectPointsForEachAddress) +{ + const Result result{ + .host = "example.com", + .outcome = Outcome::answered, + .replies = {{.address = "2001:db8::1", .status = 308, .location = "/elsewhere"}, + {.address = "192.0.2.1", .status = 301, .reason = "Moved", .location = "/b"}}}; + + EXPECT_EQ(format_result(result, false), "example.com: answered\n" + " address: 2001:db8::1 308 -> /elsewhere\n" + " address: 192.0.2.1 301 Moved -> /b\n"); +} + TEST(Output, SaysWhichAddressFailed) { EXPECT_EQ(format_result({.host = "example.com", diff --git a/test/prober_test.cpp b/test/prober_test.cpp index 00030dd..8deba40 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -297,6 +297,7 @@ TEST_F(Probe, ReportsARedirectAndDoesNotFollowIt) ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].status, 302); EXPECT_THAT(results[0].fields, Contains(Pair("location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); // as it came, not resolved EXPECT_THAT(site.targets(), ElementsAre("/")); } @@ -483,6 +484,7 @@ TEST_F(Probe, DoesNotFollowARedirectOverHttp2) ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].status, 302) << notes(results[0]); EXPECT_THAT(results[0].fields, Contains(Pair("location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); // as it came, not resolved EXPECT_THAT(site.targets(), ElementsAre("/")); } @@ -498,9 +500,23 @@ TEST_F(Probe, DoesNotFollowARedirectOverHttp1) EXPECT_EQ(results[0].status, 302) << notes(results[0]); // As it came, and only HTTP/2 and HTTP/3 have field names in lower case on the wire. EXPECT_THAT(results[0].fields, Contains(Pair("Location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); EXPECT_THAT(site.targets(), ElementsAre("/")); } +TEST_F(Probe, HasADestinationForARedirectOnly) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.status = 201, .location = "/created"}); + + probe(config()); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].status, 201) << notes(results[0]); + EXPECT_THAT(results[0].fields, Contains(Pair("location", "/created"))); + EXPECT_THAT(results[0].location, IsEmpty()); +} + TEST_F(Probe, FallsBackToTheNextRecordWhenAConnectionFails) { // @@ -1453,7 +1469,10 @@ TEST_F(Probe, EachAddressReportsARedirectAndDoesNotFollowIt) EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); ASSERT_THAT(results[0].replies, Not(IsEmpty())); for (const auto& reply : results[0].replies) + { EXPECT_EQ(reply.status, 301); + EXPECT_EQ(reply.location, "/elsewhere"); + } EXPECT_THAT(site.targets(), testing::Each("/")); EXPECT_EQ(site.requests().size(), results[0].replies.size()); } From 29277a6422a3179c0403017984427b88c43b474e Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 9 Nov 2026 20:25:23 +0000 Subject: [PATCH 4/4] chore: take over .clang-tidy from anyhttp, and clean up what it finds Copy anyhttp's config at the pin (ecc70cf): re-enable modernize-concat-nested-namespaces and performance-avoid-endl, which find nothing here, disable readability-redundant-qualified-alias and performance-enum-size, and allow std::exception_ptr by value. Fix the findings other than missing includes: designated initializers, reserve() and emplace_back, std::move of the exception, the executor by const reference, internal linkage in the tests, explicit discard of fflush(), and the program_options headers that are used instead of the umbrella header. Co-Authored-By: Claude Opus 5.5 --- .clang-tidy | 7 +++---- src/main.cpp | 15 ++++++++++----- src/prober.cpp | 7 +++---- src/prober.hpp | 2 +- test/demo_site.cpp | 10 +++++----- test/prober_test.cpp | 31 +++++++++++++++++-------------- test/routes_test.cpp | 3 ++- 7 files changed, 41 insertions(+), 34 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index a5e47ca..2cf5c3f 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,9 +1,6 @@ # clang-tidy config file --- -Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize*,performance*,readability-*size*,readability*redundant*,-readability-redundant-member-init,-readability-redundant-access-specifiers,misc*,boost-use-to-string,cert*,google-explicit-constructor,-cert-err58-cpp,-modernize-concat-nested-namespaces,-modernize-use-nodiscard,-misc-non-private-member-variables-in-classes,-modernize-use-trailing-return-type,-misc-no-recursion,-misc-const-correctness,-misc-use-anonymous-namespace,-performance-avoid-endl' -# note DEV: -# disabled 'hicpp-signed-bitwise' because it's too noisy -# (IgnorePositiveIntegerLiterals doesn't seem to work, re-evaluate with Clang 11+) +Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize*,performance*,readability-*size*,readability*redundant*,-readability-redundant-member-init,-readability-redundant-access-specifiers,misc*,boost-use-to-string,cert*,google-explicit-constructor,-cert-err58-cpp,-modernize-use-nodiscard,-misc-non-private-member-variables-in-classes,-modernize-use-trailing-return-type,-misc-no-recursion,-misc-const-correctness,-misc-use-anonymous-namespace,-readability-redundant-qualified-alias,-performance-enum-size' WarningsAsErrors: '' HeaderFilterRegex: '' @@ -22,4 +19,6 @@ CheckOptions: value: llvm - key: modernize-use-nullptr.NullMacros value: 'NULL' + - key: performance-unnecessary-value-param.AllowedTypes + value: 'std::(__[A-Za-z0-9_]+::)*exception_ptr$' ... diff --git a/src/main.cpp b/src/main.cpp index 4ab5426..05464a5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,7 +6,12 @@ #include #include -#include +#include +#include +#include +#include +#include +#include #include #include @@ -192,7 +197,7 @@ void print_usage(std::ostream& out, const po::options_description& options) << wrap(exit_code, columns) << '\n'; } -void parse_arguments(int argc, char* argv[], const po::options_description& options, +void parse_arguments(int argc, char** argv, const po::options_description& options, Arguments& arguments) { try @@ -314,7 +319,7 @@ int main(int argc, char* argv[]) if (all || (result.outcome != Outcome::not_found && result.outcome != Outcome::left_out)) { std::print("{}", http_probe::format_result(result, color_results)); - std::fflush(stdout); // a run can take a while, and it is worth watching + std::ignore = std::fflush(stdout); // a run can take a while, and it is worth watching } }; @@ -326,9 +331,9 @@ int main(int argc, char* argv[]) // What a handler throws ends the process, so this one leaves it for after run(). // boost::capy::run_async( - context.get_executor(), []() noexcept {}, + context.get_executor(), [] noexcept {}, [&](std::exception_ptr ex) noexcept { - failed = ex; + failed = std::move(ex); context.stop(); })(prober.run()); if (verbosity >= 2) diff --git a/src/prober.cpp b/src/prober.cpp index c1997c4..c8b2880 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -10,8 +10,6 @@ #include #include -#include - #include #include #include @@ -111,7 +109,7 @@ std::optional server_name(std::string_view server) (!port.empty() && (port.size() == 1 || !std::ranges::all_of(port.substr(1), is_digit)))) return std::nullopt; - return ServerName{name, port}; + return ServerName{.name = name, .port = port}; } // @@ -193,7 +191,7 @@ void prefer_ipv6(std::vector& addresses) std::ranges::stable_partition(addresses, &corosio::endpoint::is_v6); } -Prober::Prober(capy::any_executor executor, Config config, Observer observer) +Prober::Prober(const capy::any_executor& executor, Config config, Observer observer) : executor_(executor), config_(std::move(config)), observer_(std::move(observer)) { if (config_.hosts.empty()) @@ -418,6 +416,7 @@ capy::task Prober::probe(const std::string& host) { std::vector> notes(addresses.size()); std::vector>> asks; + asks.reserve(addresses.size()); for (std::size_t i = 0; i < addresses.size(); ++i) asks.push_back(ask_address(plan.routes, host, addresses[i], notes[i])); diff --git a/src/prober.hpp b/src/prober.hpp index 7537e77..fb520e8 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -167,7 +167,7 @@ class Prober /// Throws \c std::invalid_argument for a configuration that is not good to go, and does nothing /// else until run(), which has more to say about the DNS servers: the same for a list that can't /// be used, and \c std::runtime_error for a host name in it that can't be looked up. - Prober(capy::any_executor executor, Config config, Observer observer); + Prober(const capy::any_executor& executor, Config config, Observer observer); capy::task<> run(); diff --git a/test/demo_site.cpp b/test/demo_site.cpp index 41980fa..063e629 100644 --- a/test/demo_site.cpp +++ b/test/demo_site.cpp @@ -32,16 +32,16 @@ int main() http_probe::test::Site site(context.get_executor()); site.dns().add_https("localhost", 1, ".", - {{ARES_SVCB_PARAM_ALPN, {2, 'h', '3'}}, - {ARES_SVCB_PARAM_PORT, - {static_cast(site.port() >> 8), - static_cast(site.port() & 0xff)}}}); + {{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '3'}}, + {.key = ARES_SVCB_PARAM_PORT, + .value = {static_cast(site.port() >> 8), + static_cast(site.port() & 0xff)}}}); site.page("/", {.body = "

hello

"}); std::println("http-probe --dns-server {} --ca-file {} localhost", site.dns_address(), site.ca_file().string()); - std::fflush(stdout); + (void)std::fflush(stdout); corosio::signal_set signals(context, SIGINT, SIGTERM); capy::run_async(context.get_executor())(stop_on_signal(signals, site)); diff --git a/test/prober_test.cpp b/test/prober_test.cpp index 8deba40..5c3d065 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -62,7 +62,7 @@ namespace boost::corosio { /// What a failed expectation shows of an endpoint: Corosio has no output operator for one. -inline void PrintTo(const endpoint& endpoint, std::ostream* out) +static void PrintTo(const endpoint& endpoint, std::ostream* out) { *out << endpoint.address() << ':' << endpoint.port(); } @@ -82,15 +82,17 @@ corosio::ip_address make_address(std::string_view text) fake_dns_server::svc_param port_param(std::uint16_t port) { - return {ARES_SVCB_PARAM_PORT, - {static_cast(port >> 8), static_cast(port & 0xff)}}; + return { + .key = ARES_SVCB_PARAM_PORT, + .value = {static_cast(port >> 8), static_cast(port & 0xff)}}; } -const fake_dns_server::svc_param alpn_h3{ARES_SVCB_PARAM_ALPN, {2, 'h', '3'}}; -const fake_dns_server::svc_param alpn_h2{ARES_SVCB_PARAM_ALPN, {2, 'h', '2'}}; -const fake_dns_server::svc_param alpn_h1{ARES_SVCB_PARAM_ALPN, - {8, 'h', 't', 't', 'p', '/', '1', '.', '1'}}; -const fake_dns_server::svc_param no_default_alpn{ARES_SVCB_PARAM_NO_DEFAULT_ALPN, {}}; +const fake_dns_server::svc_param alpn_h3{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '3'}}; +const fake_dns_server::svc_param alpn_h2{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '2'}}; +const fake_dns_server::svc_param alpn_h1{.key = ARES_SVCB_PARAM_ALPN, + .value = {8, 'h', 't', 't', 'p', '/', '1', '.', '1'}}; +const fake_dns_server::svc_param no_default_alpn{.key = ARES_SVCB_PARAM_NO_DEFAULT_ALPN, + .value = {}}; // // One protocol and no other, for a test to know which one it is getting. A record with nothing but @@ -125,16 +127,16 @@ class Probe : public testing::Test // // In a variable: the coroutine refers to what the lambda captured, for as long as it runs. // - const auto run = [&]() -> capy::task<> { + const auto run = [&] -> capy::task<> { co_await prober.run(); stop_sites(); }; std::exception_ptr failed; capy::run_async( - context.get_executor(), []() noexcept {}, + context.get_executor(), [] noexcept {}, [&](std::exception_ptr ex) noexcept { - failed = ex; + failed = std::move(ex); context.stop(); })(run()); context.run(); @@ -157,7 +159,7 @@ class Probe : public testing::Test corosio::tcp_acceptor silent_tcp_acceptor() { - return corosio::tcp_acceptor(context, {corosio::ipv6_address::any(), 0}); + return {context, {corosio::ipv6_address::any(), 0}}; } // @@ -701,7 +703,7 @@ TEST_F(Probe, TriesIPv6First) EXPECT_EQ(results[0].address, "127.0.0.1"); // the last one tried } -corosio::endpoint at(const char* address) { return {make_address(address), 443}; } +static corosio::endpoint at(const char* address) { return {make_address(address), 443}; } TEST(PreferIpv6, MovesIPv6ToTheFrontAndKeepsTheOrderWithinEachFamily) { @@ -1075,7 +1077,8 @@ TEST_F(Probe, H3DoesNotTakeAFailedLookupForNoHttp3) TEST_F(Probe, H2SpeaksNothingElseThatTheRecordOffers) { - const fake_dns_server::svc_param alpn_h3_h2{ARES_SVCB_PARAM_ALPN, {2, 'h', '3', 2, 'h', '2'}}; + const fake_dns_server::svc_param alpn_h3_h2{.key = ARES_SVCB_PARAM_ALPN, + .value = {2, 'h', '3', 2, 'h', '2'}}; site.dns().add_https("localhost", 1, ".", {alpn_h3_h2, port_param(site.port())}); site.page("/", {.body = "hello"}); diff --git a/test/routes_test.cpp b/test/routes_test.cpp index d2ab206..9b6468f 100644 --- a/test/routes_test.cpp +++ b/test/routes_test.cpp @@ -29,6 +29,7 @@ ServiceRecords found(std::vector records, std::vector routes(const Plan& plan) { std::vector result; + result.reserve(plan.routes.size()); for (const auto& route : plan.routes) result.push_back(route.to_string()); return result; @@ -292,7 +293,7 @@ TEST(Plan, TreatsARecordWithAnInvalidPortAsNoRecord) TEST(Plan, PassesOnWhatWentWrongInTheLookup) { auto records = found({}); - records.notes.push_back("HTTPS lookup failed"); + records.notes.emplace_back("HTTPS lookup failed"); EXPECT_THAT(plan_routes("example.com", records).rejected.front(), "HTTPS lookup failed"); }