From 50943b00f0a4f92d6550fb71e7eaccf9d4d79813 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 13:03:18 -0400 Subject: [PATCH 1/2] perf: simple-absolute href cache and bounded string pool for ada::url On the simple-absolute path, store a prebuilt href in non_special_scheme and return it from get_href. Clear the cache on setters. Use a single thread-local spare (string_pool) to reuse that buffer capacity. Self-contained for main (includes widened simple-absolute scanner). Does not pool url_aggregator buffers (avoids IPv4 CodSpeed regressions). --- include/ada/string_pool.h | 32 +++ include/ada/url-inl.h | 128 +++++++--- include/ada/url.h | 16 +- src/ada.cpp | 1 + src/implementation.cpp | 3 + src/parser.cpp | 481 ++++++++++++++++++++++++++------------ src/string_pool.cpp | 40 ++++ src/url.cpp | 8 + tests/basic_tests.cpp | 51 ++++ 9 files changed, 581 insertions(+), 179 deletions(-) create mode 100644 include/ada/string_pool.h create mode 100644 src/string_pool.cpp diff --git a/include/ada/string_pool.h b/include/ada/string_pool.h new file mode 100644 index 000000000..a4bd4942b --- /dev/null +++ b/include/ada/string_pool.h @@ -0,0 +1,32 @@ +/** + * @file string_pool.h + * @brief Single thread-local spare for `std::string` heap capacity. + * + * @private Not part of the public Ada API; may change at any time. + * + * One spare buffer per thread. Capacity is retained only when it is above + * typical SSO and at most kMaxCapacity, so memory use stays bounded. + */ +#ifndef ADA_STRING_POOL_H +#define ADA_STRING_POOL_H + +#include +#include + +namespace ada::string_pool { + +/** Do not retain SSO-sized buffers. */ +inline constexpr size_t kMinCapacity = 24; + +/** Hard cap on retained capacity (bytes). */ +inline constexpr size_t kMaxCapacity = 1024; + +/** Prefer a recycled spare with capacity >= min_capacity; else reserve. */ +void adopt(std::string& dest, size_t min_capacity); + +/** Return capacity to the spare when within [kMinCapacity, kMaxCapacity]. */ +void recycle(std::string& s) noexcept; + +} // namespace ada::string_pool + +#endif // ADA_STRING_POOL_H diff --git a/include/ada/url-inl.h b/include/ada/url-inl.h index fe13ef18b..d4ddff968 100644 --- a/include/ada/url-inl.h +++ b/include/ada/url-inl.h @@ -7,6 +7,8 @@ #include "ada/url.h" #include "ada/url_components.h" +#include "ada/helpers.h" +#include "ada/string_pool.h" #include #include @@ -17,6 +19,10 @@ #endif // ADA_REGULAR_VISUAL_STUDIO namespace ada { + +// Inline destructor: recycle href-cache capacity (see string_pool). +inline url::~url() { string_pool::recycle(non_special_scheme); } + [[nodiscard]] ada_really_inline bool url::has_credentials() const noexcept { return !username.empty() || !password.empty(); } @@ -39,6 +45,18 @@ inline std::ostream& operator<<(std::ostream& out, const ada::url& u) { return out << u.to_string(); } +// non_special_scheme holds a full href only on the simple-absolute path for +// special schemes (otherwise empty or a non-special scheme name). +[[nodiscard]] inline bool url::has_simple_href_cache() const noexcept { + return !non_special_scheme.empty() && type != ada::scheme::type::NOT_SPECIAL; +} + +inline void url::clear_simple_href_cache() noexcept { + if (type != ada::scheme::type::NOT_SPECIAL) { + non_special_scheme.clear(); + } +} + [[nodiscard]] size_t url::get_pathname_length() const noexcept { return path.size(); } @@ -176,49 +194,98 @@ inline void url::set_scheme(std::string&& new_scheme) noexcept { } constexpr void url::copy_scheme(ada::url&& u) { - non_special_scheme = u.non_special_scheme; type = u.type; + // non_special_scheme holds the scheme name only for non-special URLs. For + // special URLs it may hold a simple-absolute href cache - never copy that. + if (u.type == ada::scheme::type::NOT_SPECIAL) { + non_special_scheme = std::move(u.non_special_scheme); + } else { + non_special_scheme.clear(); + } } constexpr void url::copy_scheme(const ada::url& u) { - non_special_scheme = u.non_special_scheme; type = u.type; + if (u.type == ada::scheme::type::NOT_SPECIAL) { + non_special_scheme = u.non_special_scheme; + } else { + non_special_scheme.clear(); + } +} + +namespace detail { +// Grow string to n bytes without requiring value-init of new chars when the +// platform provides that API. Not noexcept: allocation may throw bad_alloc. +ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { +#if defined(__cpp_lib_string_resize_and_overwrite) + s.resize_and_overwrite( + n, [](char*, std::size_t count) noexcept { return count; }); +#elif defined(_LIBCPP_VERSION) && defined(__APPLE__) + // Apple libc++ public extension; not available on all libc++ / libstdc++. + s.__resize_default_init(n); +#else + s.resize(n); +#endif } +} // namespace detail [[nodiscard]] ada_really_inline std::string url::get_href() const { - if (is_special() && host.has_value() && username.empty() && - password.empty() && !port.has_value()) [[likely]] { - const std::string_view scheme = ada::scheme::details::is_special_list[type]; + // Simple-absolute path prebuilds the full href in non_special_scheme. + if (has_simple_href_cache()) [[likely]] { + return non_special_scheme; + } + // Hot path: special URL, no credentials, no port (covers almost all + // benchdata / production absolute URLs). + if (host.has_value() && username.empty() && password.empty() && + !port.has_value() && type != ada::scheme::type::NOT_SPECIAL) [[likely]] { + // Hardcode common schemes to avoid table load + size branch. + const char* scheme_ptr; + size_t scheme_len; + if (type == ada::scheme::type::HTTPS) [[likely]] { + scheme_ptr = "https"; + scheme_len = 5; + } else if (type == ada::scheme::type::HTTP) { + scheme_ptr = "http"; + scheme_len = 4; + } else { + const std::string_view scheme = + ada::scheme::details::is_special_list[type]; + scheme_ptr = scheme.data(); + scheme_len = scheme.size(); + } const size_t host_size = host->size(); const size_t path_size = path.size(); - const size_t query_size = query.has_value() ? query->size() : 0; - const size_t hash_size = hash.has_value() ? hash->size() : 0; - const size_t total = scheme.size() + 3 + host_size + path_size + - (query.has_value() ? query_size + 1 : 0) + - (hash.has_value() ? hash_size + 1 : 0); - std::string output(total, '\0'); - char* p = output.data(); - std::memcpy(p, scheme.data(), scheme.size()); - p += scheme.size(); - p[0] = ':'; - p[1] = '/'; - p[2] = '/'; - p += 3; + const bool has_q = query.has_value(); + const bool has_h = hash.has_value(); + const size_t query_size = has_q ? query->size() : 0; + const size_t hash_size = has_h ? hash->size() : 0; + const size_t total = scheme_len + 3 + host_size + path_size + + (has_q ? query_size + 1 : 0) + + (has_h ? hash_size + 1 : 0); + std::string output; + detail::string_resize_uninitialized(output, total); + char* d = output.data(); + std::memcpy(d, scheme_ptr, scheme_len); + d += scheme_len; + d[0] = ':'; + d[1] = '/'; + d[2] = '/'; + d += 3; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, host->data(), host_size); - p += host_size; - std::memcpy(p, path.data(), path_size); - p += path_size; - if (query.has_value()) { - *p++ = '?'; + std::memcpy(d, host->data(), host_size); + d += host_size; + std::memcpy(d, path.data(), path_size); + d += path_size; + if (has_q) { + *d++ = '?'; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, query->data(), query_size); - p += query_size; + std::memcpy(d, query->data(), query_size); + d += query_size; } - if (hash.has_value()) { - *p++ = '#'; + if (has_h) { + *d++ = '#'; // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - std::memcpy(p, hash->data(), hash_size); + std::memcpy(d, hash->data(), hash_size); } return output; } @@ -273,6 +340,9 @@ constexpr void url::copy_scheme(const ada::url& u) { } [[nodiscard]] inline size_t url::get_href_size() const noexcept { + if (has_simple_href_cache()) { + return non_special_scheme.size(); + } size_t size = 0; if (is_special()) { size += ada::scheme::details::is_special_list[type].size() + 1; diff --git a/include/ada/url.h b/include/ada/url.h index 8b50e7a05..53b083733 100644 --- a/include/ada/url.h +++ b/include/ada/url.h @@ -65,7 +65,9 @@ struct url : url_base { url(url&& u) noexcept = default; url& operator=(url&& u) noexcept = default; url& operator=(const url& u) = default; - ~url() override = default; + // Inline (see url-inl.h): recycles path / href-cache capacity into a bounded + // thread-local freelist. Kept inline to match main's defaulted dtor ABI. + ~url() override; // Fields are ordered so that the most frequently accessed components // tend to occupy earlier cache lines and remain close together in memory. @@ -123,6 +125,18 @@ struct url : url_base { */ std::string password{}; + /** + * @private + * True when non_special_scheme holds a simple-absolute href cache. + */ + [[nodiscard]] inline bool has_simple_href_cache() const noexcept; + + /** + * @private + * Drop the simple-absolute href cache after mutation so get_href rebuilds. + */ + inline void clear_simple_href_cache() noexcept; + /** * Checks if the URL has an empty hostname (host is set but empty string). * @return `true` if host exists but is empty, `false` otherwise. diff --git a/src/ada.cpp b/src/ada.cpp index 321dbef16..fb0d82956 100644 --- a/src/ada.cpp +++ b/src/ada.cpp @@ -4,6 +4,7 @@ #include "serializers.cpp" #include "implementation.cpp" #include "helpers.cpp" +#include "string_pool.cpp" #include "url.cpp" #include "parser.cpp" #include "url_components.cpp" diff --git a/src/implementation.cpp b/src/implementation.cpp index f2137a2ca..89741fc8d 100644 --- a/src/implementation.cpp +++ b/src/implementation.cpp @@ -353,6 +353,9 @@ std::optional try_can_parse_absolute_fast( template ada_warn_unused tl::expected parse( std::string_view input, const result_type* base_url) { + // Single try_parse lives inside parse_url_impl. Do not call try_parse here + // as well: IPv4/IPv6 (and other non-simple) inputs would pay the fast-path + // reject twice and show large CodSpeed regressions on those benches. result_type u = ada::parser::parse_url_impl(input, base_url); if (!u.is_valid) { return tl::unexpected(errors::type_error); diff --git a/src/parser.cpp b/src/parser.cpp index ce11658f1..6597b4077 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -13,10 +13,18 @@ #include "ada/scheme-inl.h" #include "ada/unicode-inl.h" #include "ada/unicode.h" +#include "ada/string_pool.h" #include "ada/url.h" #include "ada/url_aggregator.h" #include "ada/url_aggregator-inl.h" +#if ADA_NEON +#include +#endif +#if ADA_SSE2 +#include +#endif + namespace ada::parser { // 0 = host byte, 1 = host delimiter (/ ? #), 2 = reject @@ -40,7 +48,8 @@ constexpr std::array k_host_class = []() consteval { return t; }(); -// 0 = ok, 1 = ?/#, 2 = reject. Path rejects '%' so "%2e" falls through. +// 0 = ok, 1 = ?/#, 2 = reject. '%' is allowed (already-encoded); "%2e" is +// checked later via path_needs_norm so serialization stays exact. constexpr std::array k_rest = []() consteval { std::array t{}; for (size_t i = 0; i < 256; ++i) { @@ -53,7 +62,7 @@ constexpr std::array k_rest = []() consteval { static_cast('>'), static_cast('`'), static_cast('{'), static_cast('}'), static_cast('^'), static_cast('\\'), - static_cast('%'), static_cast('\'')}) { + static_cast('\'')}) { t[c] = 2; } t[static_cast('?')] = 1; @@ -61,55 +70,236 @@ constexpr std::array k_rest = []() consteval { return t; }(); +// Lowercase domain host chars only (no uppercase). 1 = continue, 0 = stop. +// Used for the common already-normalized path (~99% of web URLs). +constexpr std::array k_host_clean = []() consteval { + std::array t{}; + for (uint8_t c = 'a'; c <= 'z'; ++c) { + t[c] = 1; + } + for (uint8_t c = '0'; c <= '9'; ++c) { + t[c] = 1; + } + t[static_cast('-')] = 1; + t[static_cast('.')] = 1; + t[static_cast('_')] = 1; + t[static_cast('~')] = 1; + return t; +}(); + +// Grow string to n bytes without requiring value-init of new chars when the +// platform provides that API. Not noexcept: allocation may throw bad_alloc. +// Prefer the standard C++23 API; only then the Apple libc++ extension. +ada_really_inline void string_resize_uninitialized(std::string& s, size_t n) { +#if defined(__cpp_lib_string_resize_and_overwrite) + s.resize_and_overwrite( + n, [](char*, std::size_t count) noexcept { return count; }); +#elif defined(_LIBCPP_VERSION) && defined(__APPLE__) + // Apple libc++ extension; not available on all libc++ / libstdc++. + s.__resize_default_init(n); +#else + s.resize(n); +#endif +} + +ada_really_inline bool eight_host_clean(const uint8_t* p) noexcept { + return k_host_clean[p[0]] & k_host_clean[p[1]] & k_host_clean[p[2]] & + k_host_clean[p[3]] & k_host_clean[p[4]] & k_host_clean[p[5]] & + k_host_clean[p[6]] & k_host_clean[p[7]]; +} + +#if ADA_NEON +// Advance over clean lowercase host bytes; returns first non-clean index. +ada_really_inline size_t scan_host_clean_neon(const uint8_t* b, size_t start, + size_t len) noexcept { + size_t i = start; + // Accept: a-z 0-9 - . _ ~ + for (; i + 16 <= len; i += 16) { + const uint8x16_t w = vld1q_u8(b + i); + const uint8x16_t ge_a = vcgeq_u8(w, vdupq_n_u8('a')); + const uint8x16_t le_z = vcleq_u8(w, vdupq_n_u8('z')); + const uint8x16_t is_alpha = vandq_u8(ge_a, le_z); + const uint8x16_t ge_0 = vcgeq_u8(w, vdupq_n_u8('0')); + const uint8x16_t le_9 = vcleq_u8(w, vdupq_n_u8('9')); + const uint8x16_t is_digit = vandq_u8(ge_0, le_9); + const uint8x16_t is_dash = vceqq_u8(w, vdupq_n_u8('-')); + const uint8x16_t is_dot = vceqq_u8(w, vdupq_n_u8('.')); + const uint8x16_t is_us = vceqq_u8(w, vdupq_n_u8('_')); + const uint8x16_t is_tilde = vceqq_u8(w, vdupq_n_u8('~')); + uint8x16_t ok = vorrq_u8(is_alpha, is_digit); + ok = vorrq_u8(ok, is_dash); + ok = vorrq_u8(ok, is_dot); + ok = vorrq_u8(ok, is_us); + ok = vorrq_u8(ok, is_tilde); + const uint8x16_t bad = vmvnq_u8(ok); + const uint8x8_t nib = vshrn_n_u16(vreinterpretq_u16_u8(bad), 4); + const uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nib), 0); + if (bits != 0) { + return i + (size_t(__builtin_ctzll(bits)) >> 2); + } + } + while (i < len && k_host_clean[b[i]]) { + ++i; + } + return i; +} +#endif // ADA_NEON + +// Host is acceptable for the simple-absolute fast path (not IPv4, not xn--). +// Domains like "example.de" end in [a-f] but are not IPv4: only leave the +// fast path when checkers::is_ipv4 is true (or punycode was seen). +ada_really_inline bool host_fast_ok(const uint8_t* host, size_t n, + bool saw_xn) noexcept { + if (n == 0 || n > 253 || saw_xn) [[unlikely]] { + return false; + } + const std::string_view hv(reinterpret_cast(host), n); + // is_ipv4 already applies the last-char filter + trailing-dot prune; cheap + // reject for the common non-IPv4 case (including example.de / example.be). + if (checkers::is_ipv4(hv)) [[unlikely]] { + return false; + } + return true; +} + +ada_really_inline bool path_needs_norm(const uint8_t* p, size_t n) noexcept { + if (n < 2) { + return false; + } + for (size_t i = 0; i < n; ++i) { + // Only segment starts (byte 0 is '/' or after '/') matter for "." / "..". + if (i != 0 && p[i - 1] != '/') { + // Still need to catch "%2e" anywhere. + if (p[i] == '%' && i + 2 < n && p[i + 1] == '2' && + (p[i + 2] | 0x20) == 'e') { + return true; + } + continue; + } + if (p[i] == '.') { + if (i + 1 == n || p[i + 1] == '/' || + (i + 1 < n && p[i + 1] == '.' && (i + 2 == n || p[i + 2] == '/'))) { + return true; + } + } + if (p[i] == '%' && i + 2 < n && p[i + 1] == '2' && + (p[i + 2] | 0x20) == 'e') { + return true; + } + } + return false; +} + } // namespace -// Fast path for already-normalized absolute http(s) URLs. noinline keeps -// the fallthrough path small (IPv4 microbenches). +// Table: 1 = allowed in path/query/hash for copy-as-is (no encoding). +// Allows ? # % for region content; forbids " < > ` { } ^ \ ' and controls. +constexpr std::array k_rest_ok = []() consteval { + std::array t{}; + for (uint8_t c = 0x21; c <= 0x7E; ++c) { + t[c] = 1; + } + for (uint8_t c : {static_cast('"'), static_cast('<'), + static_cast('>'), static_cast('`'), + static_cast('{'), static_cast('}'), + static_cast('^'), static_cast('\\'), + static_cast('\'')}) { + t[c] = 0; + } + return t; +}(); + +// Single-pass: all of [start,end) allowed for copy-as-is (no encoding). +// Path ./% for path_needs_norm is detected separately via memchr on the path. +ada_really_inline bool rest_is_clean(const uint8_t* b, size_t start, + size_t end) noexcept { + size_t i = start; +#if ADA_NEON + for (; i + 16 <= end; i += 16) { + const uint8x16_t w = vld1q_u8(b + i); + // printable 0x21..0x7E via (c - 0x21) <= 0x5d + const uint8x16_t adj = vsubq_u8(w, vdupq_n_u8(0x21)); + uint8x16_t ok = vcleq_u8(adj, vdupq_n_u8(0x7e - 0x21)); + // forbid " < > ` { } ^ \ ' + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('"'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('<'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('>'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('`'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('{'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('}'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('^'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\\'))); + ok = vbicq_u8(ok, vceqq_u8(w, vdupq_n_u8('\''))); + const uint8x8_t bad_nib = + vshrn_n_u16(vreinterpretq_u16_u8(vmvnq_u8(ok)), 4); + if (vget_lane_u64(vreinterpret_u64_u8(bad_nib), 0) != 0) { + return false; + } + } +#endif + for (; i < end; ++i) { + if (!k_rest_ok[b[i]]) [[unlikely]] { + return false; + } + } + return true; +} + +// Fast path for already-normalized absolute http(s) URLs. +// ada_never_inline: keep a single out-of-line symbol (ABI) and keep the +// fallthrough / IPv4 state-machine path small. template ada_never_inline bool try_parse_simple_absolute(std::string_view input, result_type& out) { - constexpr bool is_ada_url = std::is_same_v; constexpr bool is_aggregator = std::is_same_v; - static_assert(is_ada_url || is_aggregator); + static_assert(is_aggregator || std::is_same_v); const size_t len = input.size(); if (len < 8) [[unlikely]] { return false; } const auto* b = reinterpret_cast(input.data()); + const char* p = input.data(); size_t pos; - ada::scheme::type scheme_type; uint32_t protocol_end; + ada::scheme::type scheme_type; if (b[0] == 'h' && b[1] == 't' && b[2] == 't' && b[3] == 'p') { - if (b[4] == ':' && b[5] == '/' && b[6] == '/') { - pos = 7; - scheme_type = ada::scheme::type::HTTP; - protocol_end = 5; - } else if (len >= 8 && b[4] == 's' && b[5] == ':' && b[6] == '/' && - b[7] == '/') { + if (b[4] == 's' && b[5] == ':' && b[6] == '/' && b[7] == '/') [[likely]] { pos = 8; - scheme_type = ada::scheme::type::HTTPS; protocol_end = 6; + scheme_type = ada::scheme::type::HTTPS; + } else if (b[4] == ':' && b[5] == '/' && b[6] == '/') { + pos = 7; + protocol_end = 5; + scheme_type = ada::scheme::type::HTTP; } else { return false; } } else { return false; } - if (pos < len && (b[pos] == '/' || b[pos] == '\\')) [[unlikely]] { - return false; - } - - // Digit-led hosts are IPv4/numeric; skip before scanning. - if (pos < len && b[pos] >= '0' && b[pos] <= '9') { + if (pos >= len || b[pos] == '/' || b[pos] == '\\' || b[pos] == '[' || + (b[pos] >= '0' && b[pos] <= '9')) [[unlikely]] { return false; } const size_t host_start = pos; - bool has_upper = false; size_t i = pos; + bool has_upper = false; + // Bulk-advance over the common already-lowercase host alphabet, then + // finish with the full host class table (delimiters / reject / uppercase). +#if ADA_NEON + i = scan_host_clean_neon(b, pos, len); +#else + while (i + 8 <= len && eight_host_clean(b + i)) { + i += 8; + } + while (i < len && k_host_clean[b[i]]) { + ++i; + } +#endif for (; i < len; ++i) { const uint8_t c = b[i]; const uint8_t cls = k_host_class[c]; @@ -119,128 +309,94 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, if (cls == 2) [[unlikely]] { return false; } - if (c >= 'A' && c <= 'Z') { - has_upper = true; - } + has_upper |= (c >= 'A' && c <= 'Z'); } const size_t host_end = i; - if (host_start == host_end) [[unlikely]] { - return false; - } const size_t host_len = host_end - host_start; - if (host_len > 253) [[unlikely]] { - return false; - } - { - std::string_view hv(input.data() + host_start, host_len); + if (has_upper) [[unlikely]] { char host_buf[256]; - if (has_upper) [[unlikely]] { - std::memcpy(host_buf, input.data() + host_start, host_len); - unicode::to_lower_ascii(host_buf, host_len); - hv = std::string_view(host_buf, host_len); + if (host_len == 0 || host_len > 253) { + return false; } - if (checkers::is_ipv4(hv)) [[unlikely]] { + std::memcpy(host_buf, p + host_start, host_len); + unicode::to_lower_ascii(host_buf, host_len); + const std::string_view hv(host_buf, host_len); + if (hv.find("xn-") != std::string_view::npos || checkers::is_ipv4(hv)) + [[unlikely]] { return false; } - static constexpr std::string_view xn{"xn-", 3}; - if (hv.find(xn) != std::string_view::npos) [[unlikely]] { + } else { + bool saw_xn = false; + if (host_len >= 3 && std::memchr(p + host_start, 'x', host_len)) + [[unlikely]] { + saw_xn = std::string_view(p + host_start, host_len).find("xn-") != + std::string_view::npos; + } + if (!host_fast_ok(b + host_start, host_len, saw_xn)) [[unlikely]] { return false; } } + // Locate path/query/hash with memchr, then ONE bulk validate of the whole + // rest region (path+query+hash). path_needs_norm still only inspects path. size_t path_start = host_end; size_t path_end = host_end; size_t query_start = std::string_view::npos; size_t hash_start = std::string_view::npos; bool has_path = false; - bool path_has_dot = false; + bool path_suspicious = false; if (i < len && b[i] == '/') { has_path = true; path_start = i; - ++i; - for (; i < len; ++i) { - const uint8_t c = b[i]; - const uint8_t cls = k_rest[c]; - if (cls == 0) { - path_has_dot |= (c == '.'); - continue; + const void* qp = std::memchr(p + i, '?', len - i); + const void* hp = std::memchr(p + i, '#', len - i); + const size_t qi = qp ? static_cast(static_cast(qp) - p) + : std::string_view::npos; + const size_t hi = hp ? static_cast(static_cast(hp) - p) + : std::string_view::npos; + if (qi != std::string_view::npos && + (hi == std::string_view::npos || qi < hi)) { + path_end = qi; + query_start = qi; + if (hi != std::string_view::npos) { + hash_start = hi; } - if (cls == 1) { - path_end = i; - if (c == '?') { - query_start = i; - ++i; - goto scan_query; - } - hash_start = i; - ++i; - goto scan_hash; - } - return false; + } else if (hi != std::string_view::npos) { + path_end = hi; + hash_start = hi; + } else { + path_end = len; } - path_end = i; } else if (i < len && b[i] == '?') { query_start = i; - ++i; - goto scan_query; + const void* hp = std::memchr(p + i + 1, '#', len - i - 1); + if (hp) { + hash_start = static_cast(static_cast(hp) - p); + } } else if (i < len && b[i] == '#') { hash_start = i; - ++i; - goto scan_hash; + } else if (i != len) { + return false; } - goto after_rest; -scan_query: - for (; i < len; ++i) { - const uint8_t c = b[i]; - if (c == '#') { - hash_start = i; - ++i; - goto scan_hash; - } - if (c == '?' || c == '%') { - continue; - } - if (k_rest[c] == 2) [[unlikely]] { + if (i < len) { + // Single NEON/scalar pass over path+query+hash for forbidden bytes. + // '?' and '#' are allowed by rest_is_clean (they delimit regions here). + if (!rest_is_clean(b, i, len)) [[unlikely]] { return false; } } - goto after_rest; - -scan_hash: - for (; i < len; ++i) { - const uint8_t c = b[i]; - if (c == '?' || c == '#' || c == '%') { - continue; - } - if (k_rest[c] == 2) [[unlikely]] { - return false; - } + // Only the path slice can force normalization (. / .. / %2e). + if (has_path && path_end > path_start) { + const size_t plen = path_end - path_start; + path_suspicious = std::memchr(p + path_start, '.', plen) != nullptr || + std::memchr(p + path_start, '%', plen) != nullptr; } -after_rest: - if (path_has_dot) [[unlikely]] { - const std::string_view path_body(input.data() + path_start, - path_end - path_start); - if (path_body.size() >= 2 && path_body[1] == '.') { - if (path_body.size() == 2 || path_body[2] == '/' || - (path_body.size() >= 3 && path_body[2] == '.' && - (path_body.size() == 3 || path_body[3] == '/'))) { - return false; - } - } - static constexpr std::string_view slash_dot{"/.", 2}; - size_t p = 1; - while ((p = path_body.find(slash_dot, p)) != std::string_view::npos) { - const size_t after = p + 2; - if (after == path_body.size() || path_body[after] == '/' || - (after + 1 <= path_body.size() && path_body[after] == '.' && - (after + 1 == path_body.size() || path_body[after + 1] == '/'))) { - return false; - } - p = after; - } + if (path_suspicious && path_needs_norm(b + path_start, path_end - path_start)) + [[unlikely]] { + return false; } const bool need_slash = !has_path; @@ -250,13 +406,13 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.host_type = DEFAULT; if constexpr (is_aggregator) { - if (!need_slash) { - out.buffer.resize(len); - // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) - std::memcpy(out.buffer.data(), input.data(), len); - if (has_upper) { - unicode::to_lower_ascii(out.buffer.data() + host_start, - host_end - host_start); + // No freelist: recycling on every destroy regressed IPv4 aggregator + // CodSpeed (~3-5%). Buffer is short-lived and filled in one shot. + if (!need_slash) [[likely]] { + string_resize_uninitialized(out.buffer, len); + std::memcpy(out.buffer.data(), p, len); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.buffer.data() + host_start, host_len); } out.components.protocol_end = protocol_end; out.components.username_end = protocol_end + 2; @@ -264,24 +420,22 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.components.host_end = static_cast(host_end); out.components.port = url_components::omitted; out.components.pathname_start = static_cast(path_start); - out.components.search_start = (query_start != std::string_view::npos) + out.components.search_start = query_start != std::string_view::npos ? static_cast(query_start) : url_components::omitted; - out.components.hash_start = (hash_start != std::string_view::npos) + out.components.hash_start = hash_start != std::string_view::npos ? static_cast(hash_start) : url_components::omitted; } else { - out.buffer.resize(len + 1); - // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) - std::memcpy(out.buffer.data(), input.data(), host_end); + string_resize_uninitialized(out.buffer, len + 1); + std::memcpy(out.buffer.data(), p, host_end); out.buffer[host_end] = '/'; if (host_end < len) { - std::memcpy(out.buffer.data() + host_end + 1, input.data() + host_end, + std::memcpy(out.buffer.data() + host_end + 1, p + host_end, len - host_end); } - if (has_upper) { - unicode::to_lower_ascii(out.buffer.data() + host_start, - host_end - host_start); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.buffer.data() + host_start, host_len); } out.components.protocol_end = protocol_end; out.components.username_end = protocol_end + 2; @@ -289,32 +443,54 @@ ada_never_inline bool try_parse_simple_absolute(std::string_view input, out.components.host_end = static_cast(host_end); out.components.port = url_components::omitted; out.components.pathname_start = static_cast(host_end); - out.components.search_start = (query_start != std::string_view::npos) + out.components.search_start = query_start != std::string_view::npos ? static_cast(query_start + 1) : url_components::omitted; - out.components.hash_start = (hash_start != std::string_view::npos) + out.components.hash_start = hash_start != std::string_view::npos ? static_cast(hash_start + 1) : url_components::omitted; } } else { - std::string host_str(input.substr(host_start, host_end - host_start)); - if (has_upper) { - unicode::to_lower_ascii(host_str.data(), host_str.size()); + // Prebuild href for get_href(); also fill host/path/query/hash so getters + // and setters use normal fields. Setters clear the href cache. + const size_t href_len = need_slash ? len + 1 : len; + string_pool::adopt(out.non_special_scheme, href_len); + if (!need_slash) [[likely]] { + string_resize_uninitialized(out.non_special_scheme, len); + std::memcpy(out.non_special_scheme.data(), p, len); + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.non_special_scheme.data() + host_start, + host_len); + } + } else { + string_resize_uninitialized(out.non_special_scheme, len + 1); + std::memcpy(out.non_special_scheme.data(), p, host_end); + out.non_special_scheme[host_end] = '/'; + if (host_end < len) { + std::memcpy(out.non_special_scheme.data() + host_end + 1, p + host_end, + len - host_end); + } + if (has_upper) [[unlikely]] { + unicode::to_lower_ascii(out.non_special_scheme.data() + host_start, + host_len); + } } - out.host = std::move(host_str); + const char* href = out.non_special_scheme.data(); + out.host.emplace(href + host_start, host_len); if (need_slash) { out.path = "/"; } else { - out.path.assign(input.data() + path_start, path_end - path_start); + out.path.assign(href + path_start, path_end - path_start); } if (query_start != std::string_view::npos) { const size_t q_end = - (hash_start != std::string_view::npos) ? hash_start : len; - out.query.emplace(input.data() + query_start + 1, - q_end - query_start - 1); + hash_start != std::string_view::npos ? hash_start : len; + const size_t q_off = need_slash ? query_start + 1 : query_start; + out.query.emplace(href + q_off + 1, q_end - query_start - 1); } if (hash_start != std::string_view::npos) { - out.hash.emplace(input.data() + hash_start + 1, len - hash_start - 1); + const size_t h_off = need_slash ? hash_start + 1 : hash_start; + out.hash.emplace(href + h_off + 1, len - hash_start - 1); } } return true; @@ -361,22 +537,25 @@ result_type parse_url_impl(std::string_view user_input, } // Simple absolute http(s) fast path (before tabs/newline scan). - // Skip digit-led hosts (IPv4) with a cheap peek. + // Skip digit-led hosts (IPv4) and '[' (IPv6) with a cheap peek so pure IP + // microbenches never enter try_parse (matches main + avoids IPv6 call cost). if constexpr (store_values) { if (base_url == nullptr) { const auto* p = reinterpret_cast(user_input.data()); const size_t n = user_input.size(); - const bool digit_led_host = (n >= 8 && p[4] == ':' && p[5] == '/' && - p[6] == '/' && p[7] >= '0' && p[7] <= '9') || - (n >= 9 && p[5] == ':' && p[6] == '/' && - p[7] == '/' && p[8] >= '0' && p[8] <= '9'); - if (!digit_led_host && try_parse_simple_absolute(user_input, url)) { - if constexpr (result_type_is_ada_url_aggregator) { - if (url.buffer.size() > max_input_length) [[unlikely]] { - url.is_valid = false; - } - } else { - if (url.get_href_size() > max_input_length) [[unlikely]] { + // http://X... at [7] or https://X... at [8] + const uint8_t host0 = + (n >= 9 && p[4] == 's') ? p[8] : (n >= 8 ? p[7] : 0); + const bool skip_simple = (host0 >= '0' && host0 <= '9') || host0 == '['; + if (!skip_simple && try_parse_simple_absolute(user_input, url)) + [[likely]] { + // need-slash may grow by 1; simple-absolute never expands further. + if (user_input.size() + 1 > max_input_length) [[unlikely]] { + if constexpr (result_type_is_ada_url_aggregator) { + if (url.buffer.size() > max_input_length) { + url.is_valid = false; + } + } else if (url.get_href_size() > max_input_length) { url.is_valid = false; } } @@ -1312,6 +1491,10 @@ result_type parse_url_impl(std::string_view user_input, return url; } +template bool try_parse_simple_absolute(std::string_view input, url& out); +template bool try_parse_simple_absolute(std::string_view input, + url_aggregator& out); + template url parse_url_impl(std::string_view user_input, const url* base_url = nullptr); template url_aggregator parse_url_impl( diff --git a/src/string_pool.cpp b/src/string_pool.cpp new file mode 100644 index 000000000..bca762bc8 --- /dev/null +++ b/src/string_pool.cpp @@ -0,0 +1,40 @@ +/** + * @file string_pool.cpp + * @brief Single thread-local string spare (bounded freelist of size 1). + */ + +#include "ada/string_pool.h" + +namespace ada::string_pool { +namespace { + +thread_local std::string t_spare; + +bool retainable(size_t capacity) noexcept { + return capacity >= kMinCapacity && capacity <= kMaxCapacity; +} + +} // namespace + +void adopt(std::string& dest, size_t min_capacity) { + if (t_spare.capacity() >= min_capacity) { + dest.swap(t_spare); + dest.clear(); + t_spare.clear(); + return; + } + if (dest.capacity() < min_capacity) { + dest.reserve(min_capacity); + } +} + +void recycle(std::string& s) noexcept { + const size_t cap = s.capacity(); + if (!retainable(cap) || cap <= t_spare.capacity()) { + return; + } + s.clear(); + t_spare.swap(s); +} + +} // namespace ada::string_pool diff --git a/src/url.cpp b/src/url.cpp index ccda46035..ba8210999 100644 --- a/src/url.cpp +++ b/src/url.cpp @@ -594,6 +594,7 @@ bool url::set_host_or_hostname(const std::string_view input) { return false; } + clear_simple_href_cache(); url saved_url(*this); size_t host_end_pos = input.find('#'); @@ -718,6 +719,7 @@ bool url::set_username(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + clear_simple_href_cache(); auto previous_username = std::move(username); username = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -732,6 +734,7 @@ bool url::set_password(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + clear_simple_href_cache(); auto previous_password = std::move(password); password = ada::unicode::percent_encode( input, character_sets::USERINFO_PERCENT_ENCODE); @@ -746,6 +749,7 @@ bool url::set_port(const std::string_view input) { if (cannot_have_credentials_or_port()) { return false; } + clear_simple_href_cache(); if (input.empty()) { port = std::nullopt; @@ -786,6 +790,7 @@ bool url::set_port(const std::string_view input) { } void url::set_hash(const std::string_view input) { + clear_simple_href_cache(); if (input.empty()) { hash = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -804,6 +809,7 @@ void url::set_hash(const std::string_view input) { } void url::set_search(const std::string_view input) { + clear_simple_href_cache(); if (input.empty()) { query = std::nullopt; helpers::strip_trailing_spaces_from_opaque_path(*this); @@ -829,6 +835,7 @@ bool url::set_pathname(const std::string_view input) { if (has_opaque_path) { return false; } + clear_simple_href_cache(); auto previous_path = std::move(path); path.clear(); parse_path(input); @@ -840,6 +847,7 @@ bool url::set_pathname(const std::string_view input) { } bool url::set_protocol(const std::string_view input) { + clear_simple_href_cache(); std::string view(input); helpers::remove_ascii_tab_or_newline(view); if (view.empty()) { diff --git a/tests/basic_tests.cpp b/tests/basic_tests.cpp index 0e2025fbb..7f4b1eb31 100644 --- a/tests/basic_tests.cpp +++ b/tests/basic_tests.cpp @@ -572,6 +572,24 @@ TEST(basic_tests, can_parse_consistency_percent_encoded_host) { } } +// Regression: try_can_parse_clean_http treated the first ':' as a port and +// returned false on non-digit characters. Credentialed URLs use ':' in +// userinfo (user:pass@host), so can_parse must fail closed (full parse) and +// agree with parse() -- never hard-reject. +TEST(basic_tests, can_parse_consistency_credentials) { + for (const auto& input : std::vector{ + "http://user:pass@host/", + "https://user:pass@example.com/path", + "http://user:@host/", + "https://a:80@b/", + "http://evil.com:@ok.com/", + "https://u:p@h:8080/x", + "http://user:pass@192.168.0.1/", + }) { + assert_can_parse_consistent(input); + } +} + // Regression: try_can_parse_absolute_fast returned true for a valid IPv4 host // without validating the port. For "wS://1.3.3.51.:+" the host "1.3.3.51." // passes the IPv4 fast path, but the port "+" is not a valid digit, so the @@ -1295,6 +1313,39 @@ TYPED_TEST(basic_tests, simple_absolute_fast_path) { ASSERT_EQ(url->get_pathname(), "/"); ASSERT_EQ(url->get_href(), "https://example.com/"); } + // Already-encoded path bytes (not %2e) stay on the simple-absolute path. + { + auto url = ada::parse("https://example.com/a%20b/c"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_pathname(), "/a%20b/c"); + ASSERT_EQ(url->get_href(), "https://example.com/a%20b/c"); + } + // IPv4-like last labels (hex/0x) must fall through; agreement with SM. + { + auto url = ada::parse("http://foo.0x"); + // Invalid host (partial IPv4 hex form) - must not be accepted incorrectly. + ASSERT_FALSE(url); + } + // Domains ending in [a-f] (e.g. .de, .be) are not IPv4: stay valid and + // keep simple-absolute semantics (no credentials/port). + { + auto url = ada::parse("https://example.de/path"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "example.de"); + ASSERT_EQ(url->get_pathname(), "/path"); + ASSERT_EQ(url->get_href(), "https://example.de/path"); + } + { + auto url = ada::parse("http://www.example.be/"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "www.example.be"); + ASSERT_EQ(url->get_href(), "http://www.example.be/"); + } + { + auto url = ada::parse("http://1.2.3.4"); + ASSERT_TRUE(url); + ASSERT_EQ(url->get_hostname(), "1.2.3.4"); + } { auto url = ada::parse("https://user:pass@example.com:8080/x?y=1#z"); From 6a4fcd98786b29a3dcd39a694619655abbc5e5cf Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 31 Jul 2026 13:18:00 -0400 Subject: [PATCH 2/2] ci: use PRE_TEST gtest discovery on Windows CMake 4.x POST_BUILD discovery under clang-cl Debug sometimes yields empty JSON and fails the build. PRE_TEST defers listing to ctest and is reliable for our static Windows test links. Also set DISCOVERY_TIMEOUT explicitly. --- tests/CMakeLists.txt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8bcf303ae..d5812bc18 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,17 +52,18 @@ endif() macro(add_gtest_test exe cpp) add_executable(${exe} ${cpp}) target_link_libraries(${exe} PRIVATE simdjson GTest::gtest_main) - # Use PRE_TEST discovery mode for cross-compilation where the test binary - # cannot be executed at build time (e.g., LoongArch via QEMU). - # Use POST_BUILD (default) for native builds to avoid DLL/PATH issues on Windows. - if(CMAKE_CROSSCOMPILING) + # PRE_TEST: required for cross-compilation (binary not runnable at build). + # Also use PRE_TEST on Windows: CMake 4.x POST_BUILD discovery on clang-cl + # Debug can get empty JSON from the test binary and fail the whole build + # (same flake seen on main). MSVC shared builds disable these tests. + if(CMAKE_CROSSCOMPILING OR WIN32) set(GTEST_DISCOVERY_MODE PRE_TEST) else() set(GTEST_DISCOVERY_MODE POST_BUILD) endif() gtest_discover_tests(${exe} DISCOVERY_MODE ${GTEST_DISCOVERY_MODE} - PROPERTIES TEST_DISCOVERY_TIMEOUT 600 + DISCOVERY_TIMEOUT 120 ) set_source_files_properties(${cpp} PROPERTIES SKIP_LINTING ON) endmacro()