Skip to content

Make Abort() interrupt the libcurl and NSURLSession transports#34

Open
bkaradzic-microsoft wants to merge 2 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:urllib-abort-backends
Open

Make Abort() interrupt the libcurl and NSURLSession transports#34
bkaradzic-microsoft wants to merge 2 commits into
BabylonJS:mainfrom
bkaradzic-microsoft:urllib-abort-backends

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Jun 16, 2026

Copy link
Copy Markdown
Member

Makes UrlRequest::Abort() actually interrupt the libcurl and NSURLSession transports. Today Abort() cancels m_cancellationSource, but only the Windows backend observes it (its WinRT continuations are guarded by .then(m_cancellationSource)); the curl and NSURLSession backends ignore it, so an in-flight request blocks until the transport's own timeout and a consumer's cancellation can't actually stop the work. Flagged as a follow-up in #31.

It's the transport-side prerequisite for fetch AbortSignal support in JsRuntimeHost (BabylonJS/JsRuntimeHost#196): the polyfill there wires init.signal to UrlRequest::Abort(), which currently no-ops on Linux/Apple.

Changes

  • libcurl: drive the transfer through the multi interface (curl_multi_perform + curl_multi_poll with a bounded 100 ms timeout) instead of curl_easy_perform, re-checking m_cancellationSource each iteration. curl_easy_perform blocks in an internal poll that, against a peer which accepts but never responds, can wait indefinitely without invoking any callback; the bounded poll bounds abort latency to ~100 ms regardless of peer activity. A cancelled transfer reports CURLE_ABORTED_BY_CALLBACK.
  • NSURLSession: register a cancellation listener that [task cancel]s the in-flight data task; its completion handler then fires with NSURLErrorCancelled. The task is captured weakly (no retention past completion; a late Abort() no-ops) and the ticket is emplace()d because arcana::cancellation::ticket is a move-only final_action.
  • Test: AbortInterruptsInFlightRequest, backed by an offline-deterministic HangingServer (a loopback listener that accepts but never responds). Skipped on the Windows backend, which observes Abort() but doesn't populate the transport-error accessors.

Validation

Built and ran UrlLibTests on Win32 / macOS / Linux (gcc + clang) via a fork CI mirror (this branch combined with #32's workflows) — all four jobs green. The abort test runs and passes in 301 ms on Ubuntu (curl) and exercises NSURLErrorCancelled on macOS (NSURLSession):

[ RUN      ] UrlRequestErrorReporting.AbortInterruptsInFlightRequest
[       OK ] UrlRequestErrorReporting.AbortInterruptsInFlightRequest (301 ms)
[  PASSED  ] 7 tests.

This validation caught and fixed two bugs that Win32-only building had hidden: a macOS-only compile error (std::optional<ticket>::operator= on a move-only final_actionemplace()), and a Linux hang (the original progress-callback abort blocked against an idle peer → reworked to the multi interface; the HangingServer fixture also deadlocked its own teardown because close() doesn't wake a blocked accept() on Linux → shutdown() first).

UrlLib main has no CI yet (the workflows are in #32, still open), so this PR doesn't get automated in-repo runs — the fork mirror is the evidence until #32 lands.

Scope / follow-ups

  • AbortSignal.timeout() and the JSC/JSI rejection trackers are unrelated JsRuntimeHost follow-ups.

Related: #31, #32, BabylonJS/JsRuntimeHost#196.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

@bkaradzic-microsoft bkaradzic-microsoft marked this pull request as ready for review June 18, 2026 15:12
Copilot AI review requested due to automatic review settings June 18, 2026 15:12

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 makes UrlRequest::Abort() actually interrupt in-flight requests on the libcurl (Unix) and NSURLSession (Apple) backends, aligning abort behavior with the existing Windows backend and enabling upstream AbortSignal plumbing to work cross-platform.

Changes:

  • Unix/libcurl: add a CURLOPT_XFERINFOFUNCTION progress callback that aborts curl_easy_perform() when the request cancellation source is cancelled.
  • Apple/NSURLSession: register a cancellation listener that cancels the active NSURLSessionDataTask.
  • Tests: add an offline-deterministic HangingServer and an AbortInterruptsInFlightRequest test asserting prompt interruption and transport error details.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
Tests/UrlRequestErrorReporting.cpp Adds HangingServer fixture and a new abort-interrupts-in-flight test case.
Source/UrlRequest_Unix.cpp Wires Abort() into libcurl via the transfer progress callback and adds the corresponding stable error symbol.
Source/UrlRequest_Apple.mm Wires Abort() into NSURLSession by cancelling the in-flight data task via a cancellation listener.

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

Comment thread Source/UrlRequest_Apple.mm Outdated
Comment on lines +231 to +238
// Observe Abort(): NSURLSession runs the request asynchronously and does not watch
// m_cancellationSource on its own. Cancelling the task makes its completion handler fire
// with NSURLErrorCancelled (recorded as the transport error). The listener fires
// synchronously if the request was already aborted. The ticket is reset on each send and
// released before m_cancellationSource (a base member) is destroyed.
m_cancellationTicket = m_cancellationSource.add_listener([task]() {
[task cancel];
});

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.

Good catch — fixed in ace3224. The listener now captures the task __weak and loads a strong ref before -cancel: once the request completes NSURLSession releases the task, the weak ref reads nil, and the cancel is a safe no-op, so there's no retention past completion and a late Abort() won't message a finished task.

@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]

Have the curl and NSURLSession paths been exercised locally? No CI covers them yet (#32 is still open), and the description notes only Win32 was built — where the abort test skips.

Comment thread Source/UrlRequest_Unix.cpp Outdated
// worker thread while Abort() is called from another.
curl_check(curl_easy_setopt(m_curl, CURLOPT_NOPROGRESS, 0L));
curl_check(curl_easy_setopt(m_curl, CURLOPT_XFERINFODATA, &m_cancellationSource));
curl_check(curl_easy_setopt(m_curl, CURLOPT_XFERINFOFUNCTION,

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.

This idle-tick abort relies on the easy interface; the multi interface does not call the progress fn during idle periods, so a future migration would silently break abort against a hanging/idle peer. Worth noting that dependency in the comment.

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.

Switched to the libcurl multi interface to remove this dependency entirely. The transfer now runs through curl_multi_perform + curl_multi_poll with a bounded 100ms timeout, re-checking cancelled() each iteration, so abort latency is ~100ms regardless of whether the peer is idle -- no reliance on the easy interface ticking a progress callback. (The progress-callback + socket-shutdown approaches both empirically hung against an idle peer in CI; the multi loop is deterministic.)

Copilot AI added 2 commits June 18, 2026 17:05
UrlRequest::Abort() cancels m_cancellationSource, but only the Windows backend
observed it (its WinRT continuations are guarded by .then(m_cancellationSource)).
The libcurl and NSURLSession backends ignored it, so an in-flight request blocked
until the transport's own timeout -- and a consumer's cancellation (e.g. fetch's
AbortSignal in JsRuntimeHost) could not actually stop the work. Noted as a
follow-up in BabylonJS#31.

- libcurl: drive the transfer through the multi interface (curl_multi_perform +
  curl_multi_poll with a bounded 100ms timeout) instead of curl_easy_perform,
  re-checking m_cancellationSource each iteration. curl_easy_perform blocks in an
  internal poll that, against a peer which accepts but never responds, can wait
  indefinitely without invoking any callback; the bounded poll bounds abort latency
  to ~100ms regardless of peer activity. A cancelled transfer reports
  CURLE_ABORTED_BY_CALLBACK (added to the stable-symbol map).
- NSURLSession: register a cancellation listener that [task cancel]s the in-flight
  data task; its completion handler then fires with NSURLErrorCancelled. The task is
  captured weakly so the listener does not keep a finished task alive (and a late
  Abort() no-ops), and the ticket is emplace()d because arcana::cancellation::ticket
  is a move-only final_action whose assignment operators are deleted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Verifies UrlRequest::Abort() promptly interrupts a request that is connected but
awaiting a response, rather than waiting for the transport's own timeout. Backed by
a loopback HangingServer fixture that accepts connections but never responds; the
test sends a request, aborts mid-flight, and asserts the promise settles quickly
(~300ms) with a transport error (NSURLErrorCancelled on Apple, a curl: error on
Linux). Skipped on the Windows backend, which observes Abort() but does not populate
the transport-error accessors.

The fixture shutdown()s its listening socket before close() in teardown: on Linux
close() in another thread does not interrupt a blocked accept(), so the accept
thread would never exit and ~HangingServer would deadlock in join().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Good call to push on this — they hadn't been, and validating them caught two real bugs. I ran the curl and NSURLSession paths on a fork CI mirror (this branch combined with #32's workflows) so the Linux and macOS jobs actually build and run UrlLibTests. All four jobs green (Win32 / macOS / Ubuntu gcc+clang).

What the validation caught and fixed:

  • macOS compile error: std::optional<arcana::cancellation::ticket>::operator= doesn't compile (the ticket is a move-only final_action); now uses emplace(). UrlRequest_Apple.mm isn't compiled on Win32, so this only surfaced on a real macOS build.
  • Linux hang: the original progress-callback abort (and a socket-shutdown variant) hung against a peer that accepts but never responds — curl_easy_perform's internal poll blocks without ticking the callback. Reworked to drive the easy handle through the multi interface with a bounded 100ms poll, re-checking cancelled() each iteration (your earlier comment's direction). Also fixed the test's HangingServer fixture, which deadlocked its own teardown on Linux (close() doesn't wake a blocked accept() there; shutdown() does).

Evidence — the abort test now runs and passes in 301ms on Ubuntu (it previously ran to the job timeout):

[ RUN      ] UrlRequestErrorReporting.AbortInterruptsInFlightRequest
[       OK ] UrlRequestErrorReporting.AbortInterruptsInFlightRequest (301 ms)
[  PASSED  ] 7 tests.

macOS exercises the NSURLSession path (NSURLErrorCancelled) and is green as well.

I've also tidied the branch to two coherent commits (transport change + test). This PR still depends on #32 landing for in-repo CI; until then the fork mirror is the evidence.

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