From 109e373a2cb26815bea70af4ca04f9ca4b546577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:46:36 -0700 Subject: [PATCH 1/6] Add pluggable URL scheme resolver (e.g. blob:) to UrlRequest Introduce UrlRequest::RegisterSchemeResolver so a consumer can register a process-global resolver for a non-transport URL scheme such as "blob:". When a UrlRequest is opened with a URL whose scheme has a registered resolver, the platform transport is bypassed and the resolver supplies the response at SendAsync() time. Resolution is deferred to SendAsync (rather than Open) so a blob: URL revoked between open() and send() is honored, and an unhandled URL (e.g. revoked) surfaces as a status-0 transport-style error. This lets every consumer (fetch, XMLHttpRequest, image/video src, texture loaders, ...) resolve such URLs uniformly through UrlRequest instead of each carrying its own branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Include/UrlLib/UrlLib.h | 25 +++++++ Source/UrlRequest_Base.h | 146 ++++++++++++++++++++++++++++++++++++- Source/UrlRequest_Shared.h | 26 +++++++ 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/Include/UrlLib/UrlLib.h b/Include/UrlLib/UrlLib.h index ca42336..3809be0 100644 --- a/Include/UrlLib/UrlLib.h +++ b/Include/UrlLib/UrlLib.h @@ -1,12 +1,14 @@ #pragma once #include +#include #include #include #include #include #include #include +#include namespace UrlLib { @@ -28,6 +30,21 @@ namespace UrlLib Buffer, }; + // Result returned by a custom URL scheme resolver (see UrlRequest::RegisterSchemeResolver). + // When `handled` is false the URL had no live entry (e.g. a revoked blob: URL) and the request + // surfaces as a network error (status stays 0/None), mirroring the transport-failure contract. + struct UrlSchemeResolverResult + { + bool handled{false}; + UrlStatusCode statusCode{UrlStatusCode::None}; + std::string statusText{}; + std::string contentType{}; + std::shared_ptr> body{}; + }; + + // Resolves a URL of a registered non-transport scheme (e.g. "blob") to an in-memory response. + using UrlSchemeResolver = std::function; + class UrlRequest final { public: @@ -46,6 +63,14 @@ namespace UrlLib void Open(UrlMethod method, const std::string& url); + // Registers (or, with a null resolver, clears) a resolver for a non-transport URL scheme + // such as "blob". Registration is process-global. When a UrlRequest is opened with a URL + // whose scheme has a registered resolver, the platform transport is bypassed and the + // resolver supplies the response at SendAsync() time, so every consumer (fetch, + // XMLHttpRequest, image / video src, texture loaders, ...) resolves such URLs uniformly + // through UrlRequest instead of each carrying its own branch. + static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver); + UrlResponseType ResponseType() const; void ResponseType(UrlResponseType value); diff --git a/Source/UrlRequest_Base.h b/Source/UrlRequest_Base.h index 2ddd53f..e44a82c 100644 --- a/Source/UrlRequest_Base.h +++ b/Source/UrlRequest_Base.h @@ -2,9 +2,12 @@ #include #include -#include #include +#include +#include +#include #include +#include namespace UrlLib { @@ -21,6 +24,106 @@ namespace UrlLib m_cancellationSource.cancel(); } + // ---- Custom scheme resolvers (e.g. blob:) --------------------------------------------- + // Resolution for registered schemes is handled entirely in the shared layer; the platform + // transport is never involved. RegisterSchemeResolver installs a process-global resolver; + // BeginSchemeResolution is called from Open() to divert a matching URL; ResolveScheme is + // called from SendAsync() so revoke-after-open is honored; ResolvedResponseBuffer backs + // ResponseBuffer() for the Buffer response type. + static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver) + { + ToLower(scheme); + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + if (resolver) + { + registry.resolvers[std::move(scheme)] = std::move(resolver); + } + else + { + registry.resolvers.erase(scheme); + } + } + + // Returns true (and defers the actual work to ResolveScheme()) when `url`'s scheme has a + // registered resolver, in which case the caller must not touch the platform transport. + bool BeginSchemeResolution(const std::string& url) + { + const std::string scheme = SchemeOf(url); + if (scheme.empty()) + { + return false; + } + + UrlSchemeResolver resolver{}; + { + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + const auto it = registry.resolvers.find(scheme); + if (it == registry.resolvers.end()) + { + return false; + } + resolver = it->second; + } + + ResetForOpen(); + m_pendingResolver = std::move(resolver); + m_pendingResolverUrl = url; + m_usingSchemeResolver = true; + return true; + } + + bool IsSchemeResolution() const + { + return m_usingSchemeResolver; + } + + // Invokes the registered resolver and populates the response state. A resolver that reports + // the URL as not handled (e.g. a revoked blob: URL) leaves the status at 0 (None) and + // records a transport-style error, mirroring how a genuine network failure surfaces. + void ResolveScheme() + { + if (!m_pendingResolver) + { + return; + } + + const UrlSchemeResolverResult result = m_pendingResolver(m_pendingResolverUrl); + m_responseUrl = m_pendingResolverUrl; + + if (!result.handled) + { + SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + m_pendingResolverUrl + "'"); + return; + } + + m_statusCode = result.statusCode; + if (!result.statusText.empty()) + { + m_statusText = result.statusText; + } + if (!result.contentType.empty()) + { + m_headers["content-type"] = result.contentType; + } + + m_resolvedBuffer = result.body ? result.body : std::make_shared>(); + if (m_responseType == UrlResponseType::String) + { + m_responseString.assign(reinterpret_cast(m_resolvedBuffer->data()), m_resolvedBuffer->size()); + } + } + + gsl::span ResolvedResponseBuffer() const + { + if (m_resolvedBuffer) + { + return {m_resolvedBuffer->data(), m_resolvedBuffer->size()}; + } + return {}; + } + void SetRequestBody(std::string requestBody) { m_requestBody = requestBody; } @@ -113,6 +216,20 @@ namespace UrlLib std::transform(s.cbegin(), s.cend(), s.begin(), [](auto c) { return static_cast(std::tolower(c)); }); } + // Returns the lower-cased scheme of `url` (the substring before the first ':'), or "" if + // the URL has no scheme. + static std::string SchemeOf(const std::string& url) + { + const auto pos = url.find(':'); + if (pos == std::string::npos) + { + return {}; + } + std::string scheme = url.substr(0, pos); + ToLower(scheme); + return scheme; + } + // Canonical HTTP reason phrases, used as a fallback when the transport does not carry // a reason phrase on the wire (HTTP/2+ status lines omit it, and some platform HTTP // stacks don't surface it). Returns "" for codes not in the table. @@ -213,6 +330,10 @@ namespace UrlLib m_errorCode = 0; m_errorSymbol.clear(); m_errorString.clear(); + m_usingSchemeResolver = false; + m_pendingResolver = nullptr; + m_pendingResolverUrl.clear(); + m_resolvedBuffer.reset(); } arcana::cancellation_source m_cancellationSource{}; @@ -228,5 +349,28 @@ namespace UrlLib std::unordered_map m_headers; std::string m_requestBody{}; std::unordered_map m_requestHeaders; + + // Custom-scheme (e.g. blob:) resolution state. Populated by BeginSchemeResolution() / + // ResolveScheme(); inert for ordinary transport requests. + bool m_usingSchemeResolver{false}; + UrlSchemeResolver m_pendingResolver{}; + std::string m_pendingResolverUrl{}; + std::shared_ptr> m_resolvedBuffer{}; + + private: + // Process-global registry of scheme resolvers, keyed by lower-cased scheme (no trailing + // ':'). Guarded by a mutex since RegisterSchemeResolver and request handling can run on + // different threads. + struct ResolverRegistry + { + std::mutex mutex; + std::unordered_map resolvers; + }; + + static ResolverRegistry& Registry() + { + static ResolverRegistry registry; + return registry; + } }; } diff --git a/Source/UrlRequest_Shared.h b/Source/UrlRequest_Shared.h index 78e5e07..0e1956f 100644 --- a/Source/UrlRequest_Shared.h +++ b/Source/UrlRequest_Shared.h @@ -24,9 +24,21 @@ namespace UrlLib void UrlRequest::Open(UrlMethod method, const std::string& url) { + // Divert URLs whose scheme has a registered resolver (e.g. blob:) away from the platform + // transport; the resolver supplies the response in SendAsync(). + if (m_impl->BeginSchemeResolution(url)) + { + return; + } + m_impl->Open(method, url); } + void UrlRequest::RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver) + { + Impl::RegisterSchemeResolver(std::move(scheme), std::move(resolver)); + } + UrlResponseType UrlRequest::ResponseType() const { return m_impl->ResponseType(); @@ -59,6 +71,15 @@ namespace UrlLib arcana::task UrlRequest::SendAsync() { + // Registered-scheme requests (e.g. blob:) are served synchronously from the resolver; the + // resolution is deferred to here (rather than Open) so a blob: URL revoked between open() + // and send() is honored. + if (m_impl->IsSchemeResolution()) + { + m_impl->ResolveScheme(); + return arcana::task_from_result(); + } + return m_impl->SendAsync(); } @@ -99,6 +120,11 @@ namespace UrlLib gsl::span UrlRequest::ResponseBuffer() const { + if (m_impl->IsSchemeResolution()) + { + return m_impl->ResolvedResponseBuffer(); + } + return m_impl->ResponseBuffer(); } } From b50bc9d311e3b6a249f0f46a3a7c89bb8d6d2656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:23:18 -0700 Subject: [PATCH 2/6] Make ResolveScheme idempotent and add scheme-resolver tests Consume the pending resolver on the first ResolveScheme() call (move it out and clear it before invoking) so a second SendAsync() on the same request does not re-run the resolver -- avoiding repeated resolver side effects or clobbering the already-populated response. IsSchemeResolution() stays true so ResponseBuffer() keeps serving the resolved bytes. Add a UrlLibTests suite (SchemeResolver.cpp) covering: a handled resolver populating a String and a Buffer response (status, statusText, content-type, body, response URL); the handled == false transport-style error contract (status None, ErrorSymbol "SchemeResolverNotFound", empty buffer); and resolve-exactly-once across repeated sends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Source/UrlRequest_Base.h | 16 ++++- Tests/CMakeLists.txt | 2 +- Tests/SchemeResolver.cpp | 133 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 Tests/SchemeResolver.cpp diff --git a/Source/UrlRequest_Base.h b/Source/UrlRequest_Base.h index e44a82c..5274d55 100644 --- a/Source/UrlRequest_Base.h +++ b/Source/UrlRequest_Base.h @@ -82,6 +82,10 @@ namespace UrlLib // Invokes the registered resolver and populates the response state. A resolver that reports // the URL as not handled (e.g. a revoked blob: URL) leaves the status at 0 (None) and // records a transport-style error, mirroring how a genuine network failure surfaces. + // The pending resolver is consumed here so a second SendAsync() on the same request does not + // re-run the resolver (which could re-trigger side effects or clobber the response); the + // resolved response state is retained and IsSchemeResolution() stays true so ResponseBuffer() + // keeps serving the resolved bytes. void ResolveScheme() { if (!m_pendingResolver) @@ -89,12 +93,18 @@ namespace UrlLib return; } - const UrlSchemeResolverResult result = m_pendingResolver(m_pendingResolverUrl); - m_responseUrl = m_pendingResolverUrl; + // Move the resolver/url out and clear them before invoking, so this runs exactly once. + const UrlSchemeResolver resolver = std::move(m_pendingResolver); + const std::string url = std::move(m_pendingResolverUrl); + m_pendingResolver = nullptr; + m_pendingResolverUrl.clear(); + + const UrlSchemeResolverResult result = resolver(url); + m_responseUrl = url; if (!result.handled) { - SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + m_pendingResolverUrl + "'"); + SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + url + "'"); return; } diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index c0ef53a..aea1951 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -9,7 +9,7 @@ FetchContent_MakeAvailable(googletest) set_property(TARGET gtest PROPERTY FOLDER Dependencies) set_property(TARGET gtest_main PROPERTY FOLDER Dependencies) -add_executable(UrlLibTests UrlRequestErrorReporting.cpp) +add_executable(UrlLibTests UrlRequestErrorReporting.cpp SchemeResolver.cpp) target_link_libraries(UrlLibTests PRIVATE UrlLib diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp new file mode 100644 index 0000000..3c038a7 --- /dev/null +++ b/Tests/SchemeResolver.cpp @@ -0,0 +1,133 @@ +#include + +#include + +#include +#include +#include +#include +#include + +namespace +{ + std::shared_ptr> MakeBody(const std::string& text) + { + const auto* first = reinterpret_cast(text.data()); + return std::make_shared>(first, first + text.size()); + } + + // Scheme resolution is synchronous: SendAsync() returns an already-completed task, so the inline + // continuation runs before this helper returns, leaving the request fully populated. + void Send(UrlLib::UrlRequest& request) + { + bool completed = false; + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [&completed](const arcana::expected&) { completed = true; }); + ASSERT_TRUE(completed); + } +} + +// A handled resolver populates status, status text, the content-type header and the response body. +TEST(SchemeResolver, HandledResolverPopulatesStringResponse) +{ + const std::string scheme = "urllibtest-string"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.statusText = "OK"; + result.contentType = "text/plain"; + result.body = MakeBody("hello"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::String); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.StatusText(), "OK"); + EXPECT_EQ(request.ResponseString(), "hello"); + EXPECT_EQ(request.ResponseUrl(), scheme + ":anything"); + ASSERT_TRUE(request.GetResponseHeader("content-type").has_value()); + EXPECT_EQ(*request.GetResponseHeader("content-type"), "text/plain"); + EXPECT_TRUE(request.ErrorSymbol().empty()); + EXPECT_TRUE(request.ErrorString().empty()); + + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); +} + +// A handled resolver serves the raw bytes for a Buffer response type. +TEST(SchemeResolver, HandledResolverPopulatesBufferResponse) +{ + const std::string scheme = "urllibtest-buffer"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("world!"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + const auto buffer = request.ResponseBuffer(); + ASSERT_EQ(buffer.size(), static_cast(6)); + EXPECT_EQ(std::string(reinterpret_cast(buffer.data()), buffer.size()), "world!"); + + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); +} + +// A resolver that reports handled == false (e.g. a revoked blob: URL) surfaces as a transport-style +// error: status stays None (0), an error symbol is recorded, and the response buffer is empty. +TEST(SchemeResolver, NotHandledResolverSurfacesTransportError) +{ + const std::string scheme = "urllibtest-missing"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + return UrlLib::UrlSchemeResolverResult{}; // handled == false + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":gone"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_EQ(request.ErrorSymbol(), "SchemeResolverNotFound"); + EXPECT_FALSE(request.ErrorString().empty()); + EXPECT_TRUE(request.ResponseBuffer().empty()); + + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); +} + +// The resolver is consumed on the first SendAsync(); a second send does not re-run it (no repeated +// side effects) while the already-resolved response remains available. +TEST(SchemeResolver, ResolverRunsExactlyOncePerRequest) +{ + const std::string scheme = "urllibtest-once"; + std::atomic calls{0}; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [&calls](const std::string&) { + ++calls; + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("once"); + return result; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + Send(request); + + EXPECT_EQ(calls.load(), 1); + EXPECT_EQ(request.ResponseBuffer().size(), static_cast(4)); + + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); +} From 6437417e7c2886588d5656d1c1d2db28358cdb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:02:21 -0700 Subject: [PATCH 3/6] Contain throwing resolvers and make scheme-resolver removal explicit A resolver that throws no longer escapes SendAsync() synchronously: the call is guarded and the exception is mapped to the same transport-style error surface as handled == false, reported as "SchemeResolverThrew" with the exception message as detail. Removal is now the explicit UnregisterSchemeResolver(scheme) rather than registering an empty std::function. RegisterSchemeResolver throws std::invalid_argument for a null resolver or empty scheme, so a moved-from or default-constructed resolver can no longer silently unregister a scheme whose captured state (e.g. a blob store) is still in use. Also adds a regression test showing the resolver path already inherits the canonical reason-phrase fallback in StatusText() when a resolver supplies a status code but no status text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Include/UrlLib/UrlLib.h | 25 ++++++-- Source/UrlRequest_Base.h | 64 ++++++++++++++----- Source/UrlRequest_Shared.h | 5 ++ Tests/SchemeResolver.cpp | 122 +++++++++++++++++++++++++++++++++++-- 4 files changed, 192 insertions(+), 24 deletions(-) diff --git a/Include/UrlLib/UrlLib.h b/Include/UrlLib/UrlLib.h index 3809be0..9ded3b7 100644 --- a/Include/UrlLib/UrlLib.h +++ b/Include/UrlLib/UrlLib.h @@ -63,14 +63,27 @@ namespace UrlLib void Open(UrlMethod method, const std::string& url); - // Registers (or, with a null resolver, clears) a resolver for a non-transport URL scheme - // such as "blob". Registration is process-global. When a UrlRequest is opened with a URL - // whose scheme has a registered resolver, the platform transport is bypassed and the - // resolver supplies the response at SendAsync() time, so every consumer (fetch, - // XMLHttpRequest, image / video src, texture loaders, ...) resolves such URLs uniformly - // through UrlRequest instead of each carrying its own branch. + // Registers a resolver for a non-transport URL scheme such as "blob". Registration is + // process-global. When a UrlRequest is opened with a URL whose scheme has a registered + // resolver, the platform transport is bypassed and the resolver supplies the response at + // SendAsync() time, so every consumer (fetch, XMLHttpRequest, image / video src, texture + // loaders, ...) resolves such URLs uniformly through UrlRequest instead of each carrying + // its own branch. + // + // A resolver that reports the URL as not handled -- or that throws -- surfaces as a + // transport-style failure (status stays None, with an error symbol recorded); an exception + // never escapes SendAsync(). + // + // Throws std::invalid_argument if `scheme` is empty or `resolver` is null. Removal is the + // explicit UnregisterSchemeResolver call rather than registering an empty std::function, so + // a moved-from or default-constructed resolver cannot silently unregister a scheme. static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver); + // Removes the resolver registered for `scheme`, if any. Unknown schemes are ignored. + // In-flight requests already diverted to that resolver are unaffected: each request holds + // its own reference to the resolver from the time it was opened. + static void UnregisterSchemeResolver(std::string scheme); + UrlResponseType ResponseType() const; void ResponseType(UrlResponseType value); diff --git a/Source/UrlRequest_Base.h b/Source/UrlRequest_Base.h index 5274d55..4cd1888 100644 --- a/Source/UrlRequest_Base.h +++ b/Source/UrlRequest_Base.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -30,19 +31,36 @@ namespace UrlLib // BeginSchemeResolution is called from Open() to divert a matching URL; ResolveScheme is // called from SendAsync() so revoke-after-open is honored; ResolvedResponseBuffer backs // ResponseBuffer() for the Buffer response type. + // + // A null resolver is rejected rather than treated as an implicit removal: a resolver can + // capture state that must outlive registration, so silently unregistering on a moved-from + // or default-constructed std::function would be an easy mistake to miss. Removal is the + // explicit UnregisterSchemeResolver call. static void RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver) { - ToLower(scheme); - auto& registry = Registry(); - const std::lock_guard lock{registry.mutex}; - if (resolver) + if (!resolver) { - registry.resolvers[std::move(scheme)] = std::move(resolver); + throw std::invalid_argument{"RegisterSchemeResolver: resolver must not be null (use UnregisterSchemeResolver to remove a scheme)"}; } - else + + ToLower(scheme); + if (scheme.empty()) { - registry.resolvers.erase(scheme); + throw std::invalid_argument{"RegisterSchemeResolver: scheme must not be empty"}; } + + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + registry.resolvers[std::move(scheme)] = std::move(resolver); + } + + // Removes the resolver registered for `scheme`, if any. Unknown schemes are ignored. + static void UnregisterSchemeResolver(std::string scheme) + { + ToLower(scheme); + auto& registry = Registry(); + const std::lock_guard lock{registry.mutex}; + registry.resolvers.erase(scheme); } // Returns true (and defers the actual work to ResolveScheme()) when `url`'s scheme has a @@ -80,12 +98,12 @@ namespace UrlLib } // Invokes the registered resolver and populates the response state. A resolver that reports - // the URL as not handled (e.g. a revoked blob: URL) leaves the status at 0 (None) and - // records a transport-style error, mirroring how a genuine network failure surfaces. - // The pending resolver is consumed here so a second SendAsync() on the same request does not - // re-run the resolver (which could re-trigger side effects or clobber the response); the - // resolved response state is retained and IsSchemeResolution() stays true so ResponseBuffer() - // keeps serving the resolved bytes. + // the URL as not handled (e.g. a revoked blob: URL) -- or that throws -- leaves the status at + // 0 (None) and records a transport-style error, mirroring how a genuine network failure + // surfaces. The pending resolver is consumed here so a second SendAsync() on the same request + // does not re-run the resolver (which could re-trigger side effects or clobber the response); + // the resolved response state is retained and IsSchemeResolution() stays true so + // ResponseBuffer() keeps serving the resolved bytes. void ResolveScheme() { if (!m_pendingResolver) @@ -99,9 +117,27 @@ namespace UrlLib m_pendingResolver = nullptr; m_pendingResolverUrl.clear(); - const UrlSchemeResolverResult result = resolver(url); m_responseUrl = url; + // A throwing resolver is contained here and reported through the same error surface as + // any other failure, so it cannot escape SendAsync() synchronously -- callers observe a + // failed request (status 0 + error) exactly as they would for a transport failure. + UrlSchemeResolverResult result{}; + try + { + result = resolver(url); + } + catch (const std::exception& e) + { + SetError("urllib", "SchemeResolverThrew", 0, e.what()); + return; + } + catch (...) + { + SetError("urllib", "SchemeResolverThrew", 0, "unknown exception"); + return; + } + if (!result.handled) { SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + url + "'"); diff --git a/Source/UrlRequest_Shared.h b/Source/UrlRequest_Shared.h index 0e1956f..f0b10e8 100644 --- a/Source/UrlRequest_Shared.h +++ b/Source/UrlRequest_Shared.h @@ -39,6 +39,11 @@ namespace UrlLib Impl::RegisterSchemeResolver(std::move(scheme), std::move(resolver)); } + void UrlRequest::UnregisterSchemeResolver(std::string scheme) + { + Impl::UnregisterSchemeResolver(std::move(scheme)); + } + UrlResponseType UrlRequest::ResponseType() const { return m_impl->ResponseType(); diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp index 3c038a7..f720cfe 100644 --- a/Tests/SchemeResolver.cpp +++ b/Tests/SchemeResolver.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -55,7 +56,7 @@ TEST(SchemeResolver, HandledResolverPopulatesStringResponse) EXPECT_TRUE(request.ErrorSymbol().empty()); EXPECT_TRUE(request.ErrorString().empty()); - UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); } // A handled resolver serves the raw bytes for a Buffer response type. @@ -80,7 +81,7 @@ TEST(SchemeResolver, HandledResolverPopulatesBufferResponse) ASSERT_EQ(buffer.size(), static_cast(6)); EXPECT_EQ(std::string(reinterpret_cast(buffer.data()), buffer.size()), "world!"); - UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); } // A resolver that reports handled == false (e.g. a revoked blob: URL) surfaces as a transport-style @@ -102,7 +103,7 @@ TEST(SchemeResolver, NotHandledResolverSurfacesTransportError) EXPECT_FALSE(request.ErrorString().empty()); EXPECT_TRUE(request.ResponseBuffer().empty()); - UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); } // The resolver is consumed on the first SendAsync(); a second send does not re-run it (no repeated @@ -129,5 +130,118 @@ TEST(SchemeResolver, ResolverRunsExactlyOncePerRequest) EXPECT_EQ(calls.load(), 1); EXPECT_EQ(request.ResponseBuffer().size(), static_cast(4)); - UrlLib::UrlRequest::RegisterSchemeResolver(scheme, nullptr); + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + + +// A handled resolver that leaves statusText empty still reports the canonical reason phrase, since +// StatusText() falls back to the code->phrase table for the resolver path exactly as it does for +// the transport path (HTTP/2+ status lines carry no reason phrase). +TEST(SchemeResolver, EmptyStatusTextFallsBackToReasonPhrase) +{ + const std::string scheme = "urllibtest-nostatustext"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("x"); + return result; // statusText intentionally left empty + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(request); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.StatusText(), "OK"); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// A resolver that throws must not let the exception escape SendAsync(); it is reported through the +// same transport-style error surface as a failed request. +TEST(SchemeResolver, ThrowingResolverSurfacesTransportError) +{ + const std::string scheme = "urllibtest-throws"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) -> UrlLib::UrlSchemeResolverResult { + throw std::runtime_error{"resolver blew up"}; + }); + + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + EXPECT_NO_THROW(Send(request)); + + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_EQ(request.ErrorSymbol(), "SchemeResolverThrew"); + EXPECT_NE(std::string{request.ErrorString()}.find("resolver blew up"), std::string::npos); + EXPECT_TRUE(request.ResponseBuffer().empty()); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// Removal is explicit: a null resolver is rejected rather than silently unregistering the scheme. +TEST(SchemeResolver, RegisteringNullResolverThrows) +{ + const std::string scheme = "urllibtest-null"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + return result; + }); + + EXPECT_THROW(UrlLib::UrlRequest::RegisterSchemeResolver(scheme, {}), std::invalid_argument); + EXPECT_THROW(UrlLib::UrlRequest::RegisterSchemeResolver("", [](const std::string&) { + return UrlLib::UrlSchemeResolverResult{}; + }), std::invalid_argument); + + // The rejected registration left the existing resolver in place. + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + +// After unregistering, the scheme is no longer diverted -- it falls through to the platform +// transport, which fails on the unknown scheme rather than silently succeeding. +TEST(SchemeResolver, UnregisterStopsDivertingScheme) +{ + const std::string scheme = "urllibtest-unregister"; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = UrlLib::UrlStatusCode::Ok; + result.body = MakeBody("still here"); + return result; + }); + + UrlLib::UrlRequest resolved; + resolved.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + Send(resolved); + EXPECT_EQ(resolved.StatusCode(), UrlLib::UrlStatusCode::Ok); + + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); + + // Unregistering an unknown scheme is a no-op, not an error. + EXPECT_NO_THROW(UrlLib::UrlRequest::UnregisterSchemeResolver(scheme)); + + UrlLib::UrlRequest afterUnregister; + bool opened = true; + try + { + afterUnregister.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + } + catch (...) + { + opened = false; // the transport rejected the unknown scheme outright + } + + if (opened) + { + Send(afterUnregister); + EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); + } } From aca50ffbe536888433a49cfa2eab93d2ea28a213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:12:31 -0700 Subject: [PATCH 4/6] Fix UnregisterStopsDivertingScheme to not assume a synchronous transport The test sent a request through the real platform transport after unregistering the resolver, but used the resolver-path helper that asserts the send settles inline. On Apple backends the transport completes asynchronously, so the assert failed in CI (macOS_Xcode264). Assert the property actually under test instead: after unregistering, the resolver is never consulted again. The transport's outcome for an unknown scheme is platform-specific (throw at Open, fail inline, or fail async), so each of those is tolerated and an in-flight request is aborted. The completion flag is now held by shared_ptr rather than captured by reference, so a continuation that runs later cannot touch a dangling stack slot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Tests/SchemeResolver.cpp | 50 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp index f720cfe..2b9d08a 100644 --- a/Tests/SchemeResolver.cpp +++ b/Tests/SchemeResolver.cpp @@ -18,13 +18,22 @@ namespace } // Scheme resolution is synchronous: SendAsync() returns an already-completed task, so the inline - // continuation runs before this helper returns, leaving the request fully populated. - void Send(UrlLib::UrlRequest& request) + // continuation runs before this returns. The flag is held by shared_ptr rather than captured by + // reference so the continuation stays safe even if a caller uses this on a request that was NOT + // diverted to a resolver and therefore completes later on the real transport. + bool SendCompletesSynchronously(UrlLib::UrlRequest& request) { - bool completed = false; + auto completed = std::make_shared>(false); request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), - [&completed](const arcana::expected&) { completed = true; }); - ASSERT_TRUE(completed); + [completed](const arcana::expected&) { completed->store(true); }); + return completed->load(); + } + + // Sends a request that is expected to be served by a scheme resolver, so it settles inline and + // leaves the request fully populated by the time this returns. + void Send(UrlLib::UrlRequest& request) + { + ASSERT_TRUE(SendCompletesSynchronously(request)); } } @@ -205,12 +214,16 @@ TEST(SchemeResolver, RegisteringNullResolverThrows) UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); } -// After unregistering, the scheme is no longer diverted -- it falls through to the platform -// transport, which fails on the unknown scheme rather than silently succeeding. +// After unregistering, the scheme is no longer diverted: the resolver is never invoked again and the +// request falls through to the platform transport. The transport's outcome for an unknown scheme is +// platform-specific (it may throw at Open, fail synchronously, or fail asynchronously), so this +// asserts only the property under test -- that the resolver is no longer consulted. TEST(SchemeResolver, UnregisterStopsDivertingScheme) { const std::string scheme = "urllibtest-unregister"; - UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [](const std::string&) { + auto calls = std::make_shared>(0); + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [calls](const std::string&) { + ++*calls; UrlLib::UrlSchemeResolverResult result; result.handled = true; result.statusCode = UrlLib::UrlStatusCode::Ok; @@ -222,6 +235,7 @@ TEST(SchemeResolver, UnregisterStopsDivertingScheme) resolved.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); Send(resolved); EXPECT_EQ(resolved.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(calls->load(), 1); UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); @@ -229,19 +243,25 @@ TEST(SchemeResolver, UnregisterStopsDivertingScheme) EXPECT_NO_THROW(UrlLib::UrlRequest::UnregisterSchemeResolver(scheme)); UrlLib::UrlRequest afterUnregister; - bool opened = true; try { afterUnregister.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + + // Not diverted, so this now goes to the real transport. Whether it settles inline or on a + // worker thread, it must not produce the resolver's response. + if (SendCompletesSynchronously(afterUnregister)) + { + EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); + } + else + { + afterUnregister.Abort(); // wind down the in-flight transport request + } } catch (...) { - opened = false; // the transport rejected the unknown scheme outright + // Some backends reject an unknown scheme outright at Open(); that is also "not diverted". } - if (opened) - { - Send(afterUnregister); - EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); - } + EXPECT_EQ(calls->load(), 1); // the resolver was not consulted after unregistering } From 35620bca23df0935d9b7c5a67b90aadb3c775c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:16:43 -0700 Subject: [PATCH 5/6] Don't explicitly abort the transport request in the unregister test Explicitly aborting the (non-diverted) request perturbed the shared NSURLSession on the Apple backend, so the following test in the suite failed with a spurious NSURLErrorCancelled (-999). Let the request wind down through its own destructor instead, which the earlier CI run showed leaves subsequent tests unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Tests/SchemeResolver.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp index 2b9d08a..7ce271f 100644 --- a/Tests/SchemeResolver.cpp +++ b/Tests/SchemeResolver.cpp @@ -248,15 +248,14 @@ TEST(SchemeResolver, UnregisterStopsDivertingScheme) afterUnregister.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); // Not diverted, so this now goes to the real transport. Whether it settles inline or on a - // worker thread, it must not produce the resolver's response. + // worker thread, it must not produce the resolver's response. An in-flight request is left + // to wind down through the request's own destructor rather than an explicit Abort(): on the + // NSURLSession backend an explicit abort perturbs the shared session and surfaces as a + // spurious NSURLErrorCancelled (-999) in subsequent tests. if (SendCompletesSynchronously(afterUnregister)) { EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); } - else - { - afterUnregister.Abort(); // wind down the in-flight transport request - } } catch (...) { From 6d45aeb971b87d6c5b450d72987b1f87325ee375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Branimir=20Karad=C5=BEi=C4=87=20=28via=20Copilot=29?= <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:21:45 -0700 Subject: [PATCH 6/6] Wait for the transport request to settle instead of abandoning it The previous rationale was wrong: ~ImplBase() calls the same non-virtual Abort() as the explicit call, so dropping the explicit Abort() changed nothing about cancellation, and on the Apple backend neither path stops a resumed NSURLSessionDataTask -- so an explicit abort could not have produced the NSURLErrorCancelled (-999) attributed to it. The real hazard is lifetime, not cancellation. UrlRequest_Apple.mm's completion handler writes m_statusCode / m_headers / m_responseBuffer and calls SetError(...) through a raw `this`, and nothing extends the Impl's lifetime. Abandoning the in-flight request let that handler write into freed memory, which is what corrupted a later test's request state. Wait for the request to settle before leaving scope, and fail explicitly if it does not, so the test never depends on that timing. The underlying backend gap is pre-existing and tracked separately in BabylonJS/JsRuntimeHost#214. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: af4ea82e-5fb0-4e56-b71a-f8ff3932d033 --- Tests/SchemeResolver.cpp | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp index 7ce271f..dbc24fb 100644 --- a/Tests/SchemeResolver.cpp +++ b/Tests/SchemeResolver.cpp @@ -3,7 +3,9 @@ #include #include +#include #include +#include #include #include #include @@ -35,6 +37,20 @@ namespace { ASSERT_TRUE(SendCompletesSynchronously(request)); } + + // Sends a request that goes to the real platform transport and blocks until it settles. A + // request must never be abandoned while in flight: backends complete on their own thread and + // write the response into the request's impl through a raw `this`, so letting the request go out + // of scope first would corrupt whatever memory the impl's allocation is reused for. Returns + // false if the request did not settle within the timeout. + bool SendAndWait(UrlLib::UrlRequest& request, std::chrono::seconds timeout = std::chrono::seconds{60}) + { + auto settled = std::make_shared>(); + auto future = settled->get_future(); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [settled](const arcana::expected&) { settled->set_value(); }); + return future.wait_for(timeout) == std::future_status::ready; + } } // A handled resolver populates status, status text, the content-type header and the response body. @@ -247,15 +263,19 @@ TEST(SchemeResolver, UnregisterStopsDivertingScheme) { afterUnregister.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); - // Not diverted, so this now goes to the real transport. Whether it settles inline or on a - // worker thread, it must not produce the resolver's response. An in-flight request is left - // to wind down through the request's own destructor rather than an explicit Abort(): on the - // NSURLSession backend an explicit abort perturbs the shared session and surfaces as a - // spurious NSURLErrorCancelled (-999) in subsequent tests. - if (SendCompletesSynchronously(afterUnregister)) + // Not diverted, so this now goes to the real transport. Wait for it to settle before + // leaving scope: some backends (e.g. NSURLSession) neither cancel nor keep the impl alive + // for an abandoned request, and their completion handler would then write the response + // into freed memory. The transport's verdict on an unknown scheme is platform-specific, so + // only assert that it is not the resolver's response. + if (SendAndWait(afterUnregister)) { EXPECT_NE(afterUnregister.StatusCode(), UrlLib::UrlStatusCode::Ok); } + else + { + ADD_FAILURE() << "transport request did not settle; leaving it in flight would be unsafe"; + } } catch (...) {