Skip to content

Add pluggable URL scheme resolver (e.g. blob:) to UrlRequest#37

Open
bkaradzic-microsoft wants to merge 6 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:feature/blob-scheme-resolver
Open

Add pluggable URL scheme resolver (e.g. blob:) to UrlRequest#37
bkaradzic-microsoft wants to merge 6 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:feature/blob-scheme-resolver

Conversation

@bkaradzic-microsoft

Copy link
Copy Markdown
Member

What

Adds a pluggable URL scheme resolver hook to UrlRequest, so a consumer can register a resolver for a non-transport URL scheme (e.g. blob:) and have every UrlRequest-based consumer (fetch, XMLHttpRequest, image/video src, texture loaders, ...) resolve such URLs uniformly through the transport layer instead of each carrying its own branch.

UrlRequest::RegisterSchemeResolver("blob", [](const std::string& url) -> UrlSchemeResolverResult {
    // look up url in your store...
    return { /*handled*/ true, UrlStatusCode::Ok, "OK", contentType, bodyBytes };
});

How

  • New public API: UrlRequest::RegisterSchemeResolver(std::string scheme, UrlSchemeResolver resolver), plus the UrlSchemeResolverResult struct and UrlSchemeResolver alias.
  • When a UrlRequest is opened with a URL whose (lower-cased) scheme has a registered resolver, the platform transport is bypassed. Resolution is deferred to SendAsync() (rather than Open()) so a URL revoked between open() and send() is honored.
  • A resolver that reports handled == false (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 registry is process-global and mutex-guarded. All logic lives in the shared ImplBase + the shared wrapper, so every platform backend (Win32/Unix/Apple/Android) inherits it with no per-platform changes.

Testing

Builds clean with the Win32 backend (UrlLib and UrlLibTests targets). Downstream, this powers URL.createObjectURL blob: support in JsRuntimeHost — see BabylonJS/JsRuntimeHost#207 (tracked by BabylonJS/JsRuntimeHost#213), where all 216 unit tests pass with fetch/XMLHttpRequest resolving blob: through this hook.

Opened because BabylonJS/UrlLib has Issues disabled; JsRuntimeHost#207 currently pins its UrlLib dependency at this fork commit and should be repointed once this lands.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a process-global, pluggable URL scheme resolver mechanism to UrlRequest, allowing non-transport schemes (e.g. blob:) to be resolved via a registered callback and served through the existing UrlRequest consumer surface (fetch/XHR/resource loaders) without per-consumer special-casing.

Changes:

  • Introduces UrlSchemeResolverResult and UrlSchemeResolver, plus the public UrlRequest::RegisterSchemeResolver() API for process-global registration.
  • Diverts UrlRequest::Open() when a registered scheme is detected, and performs resolver execution during SendAsync() to honor revoke-after-open scenarios.
  • Adds shared-layer storage for resolver state and exposes resolved buffers via ResponseBuffer() for scheme-resolved requests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
Source/UrlRequest_Shared.h Hooks scheme diversion into Open(), resolves via resolver path in SendAsync(), and routes ResponseBuffer() for resolved responses.
Source/UrlRequest_Base.h Implements the resolver registry, scheme detection, and shared-layer scheme resolution response population.
Include/UrlLib/UrlLib.h Adds the public resolver API surface (UrlSchemeResolverResult, UrlSchemeResolver, RegisterSchemeResolver).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Source/UrlRequest_Base.h Outdated
Comment thread Include/UrlLib/UrlLib.h
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

@bghgary bghgary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

LGTM. A few optional comments inline (one API-design point, two minor nits).

Comment thread Source/UrlRequest_Base.h
Comment thread Source/UrlRequest_Base.h Outdated
Comment thread Include/UrlLib/UrlLib.h
Copilot AI added 3 commits July 24, 2026 12:02
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
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
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
Comment thread Tests/SchemeResolver.cpp Outdated
Comment on lines +250 to +255
// 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Reviewed by Copilot on behalf of @bghgary]

This leaves a transport request in flight across the destruction of afterUnregister, and the stated rationale doesn't hold: ~ImplBase() calls the same non-virtual Abort() as the explicit call did, so removing the explicit one doesn't change what happens to the request.

On the Apple backend it changes nothing at all. UrlRequest::Impl there has no destructor, never reads m_cancellationSource, and never calls [task cancel], so neither path stops a resumed NSURLSessionDataTask. Since nothing cancels the task, an explicit Abort() also can't be the source of the NSURLErrorCancelled (-999) attributed to it here.

The hazard is lifetime, not cancellation. SendAsync()'s completion block touches m_statusCode, m_headers, m_responseBuffer and SetError(...), so it implicitly captures a raw this, and nothing extends the Impl's lifetime (no enable_shared_from_this, no captured shared_ptr). "urllibtest-unregister:anything" yields a valid NSURL with a scheme, so the task really is resumed and NSURLSession fails it asynchronously after this scope exits, at which point the handler writes into a freed Impl. That is a likelier source of the instability the last two commits chased than an explicit abort, and it also explains why SendCompletesSynchronously() returns false here.

The lifetime gap is pre-existing rather than something this PR introduced, but this test is the first code to exercise it. Waiting for the request to settle before leaving scope, rather than abandoning it, would avoid depending on that timing. The Apple backend gap looks worth its own issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right on every point, and my rationale in the previous commit was wrong. I verified each claim against the source:

  1. ~ImplBase() calls the same non-virtual Abort(), so removing the explicit call changed nothing about cancellation.
  2. Abort() only does m_cancellationSource.cancel(), and the Apple Impl has no destructor, never reads m_cancellationSource, and never calls [task cancel] — so neither path stops a resumed NSURLSessionDataTask, and an explicit abort could not have been the source of the -999.
  3. The completion handler in UrlRequest_Apple.mm writes m_statusCode, m_headers, m_responseString/m_responseBuffer and calls SetError(...) through a raw this, with nothing extending the impl's lifetime.

Your lifetime diagnosis also explains the failure far better than mine did. The -999 surfaced in a different, previously-passing test (SuccessfulLocalFileReportsNoError) as a non-empty ErrorString/ErrorSymbol with code -999 — exactly what you would expect when the abandoned task's late failure handler writes into the freed allocation that the next test's Impl was reused for. That is a use-after-free, not session cancellation, and it also explains why SendCompletesSynchronously() returned false there.

Fixed in 6d45aeb: the test now blocks until the transport request settles (SendAndWait, 60s cap) before leaving scope, and calls ADD_FAILURE() rather than silently continuing if it does not settle — so it never abandons an in-flight request or depends on that timing. The misleading comment about aborting perturbing a shared session is gone.

Agreed the backend gap deserves its own issue, and that it is pre-existing rather than introduced here. Filed as BabylonJS/JsRuntimeHost#214 (UrlLib has Issues disabled), covering the missing enable_shared_from_this/strong capture and the ignored m_cancellationSource, with a note to audit the other backends for the same pattern.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants