Make Abort() interrupt the libcurl and NSURLSession transports#34
Make Abort() interrupt the libcurl and NSURLSession transports#34bkaradzic-microsoft wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
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_XFERINFOFUNCTIONprogress callback that abortscurl_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
HangingServerand anAbortInterruptsInFlightRequesttest 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.
| // 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]; | ||
| }); |
There was a problem hiding this comment.
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.
| // 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
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>
4abf687 to
fa80a75
Compare
|
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 What the validation caught and fixed:
Evidence — the abort test now runs and passes in 301ms on Ubuntu (it previously ran to the job timeout): macOS exercises the NSURLSession path ( 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. |
Makes
UrlRequest::Abort()actually interrupt the libcurl and NSURLSession transports. TodayAbort()cancelsm_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
AbortSignalsupport in JsRuntimeHost (BabylonJS/JsRuntimeHost#196): the polyfill there wiresinit.signaltoUrlRequest::Abort(), which currently no-ops on Linux/Apple.Changes
curl_multi_perform+curl_multi_pollwith a bounded 100 ms timeout) instead ofcurl_easy_perform, re-checkingm_cancellationSourceeach iteration.curl_easy_performblocks 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 reportsCURLE_ABORTED_BY_CALLBACK.[task cancel]s the in-flight data task; its completion handler then fires withNSURLErrorCancelled. The task is captured weakly (no retention past completion; a lateAbort()no-ops) and the ticket isemplace()d becausearcana::cancellation::ticketis a move-onlyfinal_action.AbortInterruptsInFlightRequest, backed by an offline-deterministicHangingServer(a loopback listener that accepts but never responds). Skipped on the Windows backend, which observesAbort()but doesn't populate the transport-error accessors.Validation
Built and ran
UrlLibTestson 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 exercisesNSURLErrorCancelledon macOS (NSURLSession):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-onlyfinal_action→emplace()), and a Linux hang (the original progress-callback abort blocked against an idle peer → reworked to the multi interface; theHangingServerfixture also deadlocked its own teardown becauseclose()doesn't wake a blockedaccept()on Linux →shutdown()first).UrlLib
mainhas 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