Skip to content

Commit 5683cc4

Browse files
bkaradzic-microsoftCopilotbghgary
authored
Make Abort() interrupt the libcurl and NSURLSession transports (#34)
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_action` → `emplace()`), 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> --------- Co-authored-by: Branimir Karadžić (via Copilot) <223556219+Copilot@users.noreply.github.com> Co-authored-by: Gary Hsu <bghgary@users.noreply.github.com>
1 parent 80293c0 commit 5683cc4

3 files changed

Lines changed: 228 additions & 3 deletions

File tree

Source/UrlRequest_Apple.mm

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,23 @@ void Open(UrlMethod method, const std::string& url)
228228
NSURLSessionDataTask* task{[session dataTaskWithRequest:request completionHandler:completionHandler]};
229229
[task resume];
230230

231+
// Observe Abort(): NSURLSession runs the request asynchronously and does not watch
232+
// m_cancellationSource on its own. Cancelling the task makes its completion handler fire
233+
// with NSURLErrorCancelled (recorded as the transport error). The task is captured
234+
// *weakly* so the listener does not keep a finished task alive -- NSURLSession releases
235+
// the task once its completion handler has run, after which a late Abort() loads a nil
236+
// strong reference and the -cancel is a safe no-op. The listener fires synchronously if
237+
// the request was already aborted; the ticket is reset on each send and released before
238+
// m_cancellationSource (a base member) is destroyed. emplace() (not assignment) is used
239+
// because arcana::cancellation::ticket is a move-only final_action whose assignment
240+
// operators are deleted, so std::optional::operator= would not compile; emplace destroys
241+
// any prior ticket (releasing the previous send's listener) and move-constructs the new one.
242+
__weak NSURLSessionDataTask* weakTask = task;
243+
m_cancellationTicket.emplace(m_cancellationSource.add_listener([weakTask]() {
244+
NSURLSessionDataTask* strongTask = weakTask;
245+
[strongTask cancel];
246+
}));
247+
231248
return taskCompletionSource.as_task();
232249
}
233250

@@ -244,6 +261,7 @@ void Open(UrlMethod method, const std::string& url)
244261
private:
245262
NSURL* m_url{};
246263
NSData* m_responseBuffer{};
264+
std::optional<arcana::cancellation::ticket> m_cancellationTicket{};
247265
};
248266
}
249267

Source/UrlRequest_Unix.cpp

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ namespace
5353
case CURLE_BAD_CONTENT_ENCODING: return "CURLE_BAD_CONTENT_ENCODING";
5454
case CURLE_SSL_CACERT_BADFILE: return "CURLE_SSL_CACERT_BADFILE";
5555
case CURLE_REMOTE_FILE_NOT_FOUND: return "CURLE_REMOTE_FILE_NOT_FOUND";
56+
case CURLE_ABORTED_BY_CALLBACK: return "CURLE_ABORTED_BY_CALLBACK";
5657
default: return "CURLE_" + std::to_string(static_cast<int>(code));
5758
}
5859
}
@@ -146,7 +147,7 @@ namespace UrlLib
146147
curl_check(curl_easy_setopt(m_curl, CURLOPT_HEADERFUNCTION, HeaderCallback));
147148
curl_check(curl_easy_setopt(m_curl, CURLOPT_FOLLOWLOCATION, 1L));
148149
// Request-specific failure detail (host/port/path specifics) lands here during
149-
// curl_easy_perform; see the error handling in PerformAsync.
150+
// the transfer; see the error handling in PerformAsync.
150151
curl_check(curl_easy_setopt(m_curl, CURLOPT_ERRORBUFFER, m_curlErrorBuffer.data()));
151152
}
152153
}
@@ -195,7 +196,6 @@ namespace UrlLib
195196
}
196197
}
197198

198-
199199
static void Append(std::string& string, char* buffer, size_t nitems)
200200
{
201201
string.insert(string.end(), buffer, buffer + nitems);
@@ -207,6 +207,65 @@ namespace UrlLib
207207
byteVector.insert(byteVector.end(), bytes, bytes + nitems);
208208
}
209209

210+
// Drives the transfer through the libcurl *multi* interface rather than curl_easy_perform so
211+
// that Abort() is observed promptly. curl_easy_perform blocks in an internal poll that, when
212+
// a peer accepts the connection but sends nothing, can wait indefinitely without invoking any
213+
// callback -- so a cancelled request would hang until the peer or OS gave up. Polling with a
214+
// bounded timeout lets the loop re-check m_cancellationSource between waits, bounding abort
215+
// latency to ~kPollTimeoutMs regardless of peer activity. Runs on the worker thread.
216+
CURLcode PerformWithCancellation()
217+
{
218+
CURLM* multi = curl_multi_init();
219+
if (multi == nullptr)
220+
{
221+
return CURLE_OUT_OF_MEMORY;
222+
}
223+
auto multiScope = gsl::finally([this, multi] {
224+
curl_multi_remove_handle(multi, m_curl);
225+
curl_multi_cleanup(multi);
226+
});
227+
228+
if (curl_multi_add_handle(multi, m_curl) != CURLM_OK)
229+
{
230+
return CURLE_FAILED_INIT;
231+
}
232+
233+
constexpr int kPollTimeoutMs = 100;
234+
int runningHandles = 0;
235+
do
236+
{
237+
if (m_cancellationSource.cancelled())
238+
{
239+
return CURLE_ABORTED_BY_CALLBACK;
240+
}
241+
242+
if (curl_multi_perform(multi, &runningHandles) != CURLM_OK)
243+
{
244+
return CURLE_RECV_ERROR;
245+
}
246+
247+
// Wait for socket activity but wake at least every kPollTimeoutMs so the cancellation
248+
// check above runs even when the peer is idle (curl_multi_poll waits the full timeout
249+
// even when there are no fds, so this never busy-loops during DNS resolution).
250+
if (runningHandles != 0 && curl_multi_poll(multi, nullptr, 0, kPollTimeoutMs, nullptr) != CURLM_OK)
251+
{
252+
return CURLE_RECV_ERROR;
253+
}
254+
} while (runningHandles != 0);
255+
256+
// The transfer finished; surface the per-easy-handle result code.
257+
CURLcode result = CURLE_OK;
258+
int messagesInQueue = 0;
259+
while (CURLMsg* message = curl_multi_info_read(multi, &messagesInQueue))
260+
{
261+
if (message->msg == CURLMSG_DONE && message->easy_handle == m_curl)
262+
{
263+
result = message->data.result;
264+
}
265+
}
266+
return result;
267+
}
268+
210269
template<typename DataT>
211270
arcana::task<void, std::exception_ptr> PerformAsync(DataT& data)
212271
{
@@ -226,7 +285,7 @@ namespace UrlLib
226285
m_thread.emplace([this, taskCompletionSource]() mutable
227286
{
228287
m_curlErrorBuffer[0] = '\0';
229-
const CURLcode performResult = curl_easy_perform(m_curl);
288+
const CURLcode performResult = PerformWithCancellation();
230289
if (performResult != CURLE_OK)
231290
{
232291
// Retain the default status code of 0 to indicate a client side error,

Tests/UrlRequestErrorReporting.cpp

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
#include <memory>
1818
#include <regex>
1919
#include <string>
20+
#include <thread>
21+
#include <vector>
2022

2123
#if defined(_WIN32)
2224
#include <winsock2.h>
@@ -50,6 +52,13 @@ namespace
5052
::closesocket(socket);
5153
}
5254

55+
// Wakes a thread blocked in accept()/recv() on this socket. Closing the socket alone does not
56+
// reliably interrupt a blocking call in another thread.
57+
void ShutdownSocket(NativeSocket socket)
58+
{
59+
::shutdown(socket, SD_BOTH);
60+
}
61+
5362
bool EnsureSocketsInitialized()
5463
{
5564
static const bool initialized = [] {
@@ -73,6 +82,13 @@ namespace
7382
::close(socket);
7483
}
7584

85+
// Wakes a thread blocked in accept()/recv() on this socket. On Linux, close() in another thread
86+
// does NOT interrupt a blocking accept(); shutdown() does.
87+
void ShutdownSocket(NativeSocket socket)
88+
{
89+
::shutdown(socket, SHUT_RDWR);
90+
}
91+
7692
bool EnsureSocketsInitialized()
7793
{
7894
return true;
@@ -186,6 +202,96 @@ namespace
186202
uint16_t m_port;
187203
};
188204

205+
// A loopback TCP server that accepts connections but never responds, so an HTTP request to it
206+
// hangs until it is aborted. A background thread accept()s connections and holds them open
207+
// until teardown. Used to verify that UrlRequest::Abort() interrupts an in-flight request
208+
// rather than waiting for the transport's own timeout. Non-movable: the accept thread captures
209+
// `this`.
210+
class HangingServer
211+
{
212+
public:
213+
HangingServer()
214+
{
215+
if (!EnsureSocketsInitialized())
216+
{
217+
return;
218+
}
219+
220+
NativeSocket listener = ::socket(AF_INET, SOCK_STREAM, 0);
221+
if (listener == InvalidSocket)
222+
{
223+
return;
224+
}
225+
226+
sockaddr_in address{};
227+
address.sin_family = AF_INET;
228+
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
229+
address.sin_port = 0;
230+
SocketLength addressLength = static_cast<SocketLength>(sizeof(address));
231+
if (::bind(listener, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) != 0 ||
232+
::getsockname(listener, reinterpret_cast<sockaddr*>(&address), &addressLength) != 0 ||
233+
::listen(listener, 8) != 0)
234+
{
235+
CloseSocket(listener);
236+
return;
237+
}
238+
239+
m_listener = listener;
240+
m_port = ntohs(address.sin_port);
241+
m_acceptThread = std::thread{[this]() {
242+
for (;;)
243+
{
244+
NativeSocket connection = ::accept(m_listener, nullptr, nullptr);
245+
if (connection == InvalidSocket)
246+
{
247+
break; // listener closed during teardown
248+
}
249+
m_accepted.push_back(connection); // hold open, never respond
250+
}
251+
}};
252+
}
253+
254+
HangingServer(const HangingServer&) = delete;
255+
HangingServer& operator=(const HangingServer&) = delete;
256+
HangingServer(HangingServer&&) = delete;
257+
HangingServer& operator=(HangingServer&&) = delete;
258+
259+
~HangingServer()
260+
{
261+
if (m_listener != InvalidSocket)
262+
{
263+
// shutdown() (not just close()) so a thread blocked in accept() is woken: on Linux
264+
// close() in another thread does not interrupt the blocking accept().
265+
ShutdownSocket(m_listener);
266+
CloseSocket(m_listener);
267+
}
268+
if (m_acceptThread.joinable())
269+
{
270+
m_acceptThread.join();
271+
}
272+
for (NativeSocket connection : m_accepted)
273+
{
274+
CloseSocket(connection);
275+
}
276+
}
277+
278+
bool Valid() const
279+
{
280+
return m_listener != InvalidSocket;
281+
}
282+
283+
std::string Url() const
284+
{
285+
return "http://127.0.0.1:" + std::to_string(m_port) + "/";
286+
}
287+
288+
private:
289+
NativeSocket m_listener{InvalidSocket};
290+
uint16_t m_port{0};
291+
std::vector<NativeSocket> m_accepted{};
292+
std::thread m_acceptThread{};
293+
};
294+
189295
// RAII temp file with a per-process-unique name, so parallel test runs sharing a temp
190296
// directory don't collide and no artifacts outlive the test. Pass nullptr contents
191297
// for a path that is guaranteed not to exist.
@@ -352,6 +458,48 @@ TEST(UrlRequestErrorReporting, ReopenClearsPriorError)
352458
EXPECT_TRUE(request.ErrorString().empty()) << request.ErrorString();
353459
}
354460

461+
TEST(UrlRequestErrorReporting, AbortInterruptsInFlightRequest)
462+
{
463+
// The Windows backend already observes Abort() (its WinRT continuations are guarded by
464+
// m_cancellationSource), but it does not populate the transport-error accessors, so the
465+
// symbol assertions below are gated to the backends that do.
466+
SKIP_WITHOUT_TRANSPORT_ERROR_DETAIL();
467+
468+
HangingServer server{};
469+
ASSERT_TRUE(server.Valid());
470+
471+
UrlLib::UrlRequest request{};
472+
request.Open(UrlLib::UrlMethod::Get, server.Url());
473+
474+
auto done = std::make_shared<std::promise<void>>();
475+
auto future = done->get_future();
476+
request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(),
477+
[done](const arcana::expected<void, std::exception_ptr>&) {
478+
done->set_value();
479+
});
480+
481+
// Let the request connect to the hanging server and start waiting for a response that never
482+
// comes, then abort. Without Abort() being observed on this backend the request would block
483+
// until the transport's own timeout.
484+
std::this_thread::sleep_for(std::chrono::milliseconds{250});
485+
request.Abort();
486+
487+
ASSERT_EQ(future.wait_for(std::chrono::seconds{15}), std::future_status::ready)
488+
<< "Abort did not interrupt the in-flight request";
489+
490+
EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None);
491+
EXPECT_FALSE(request.ErrorString().empty());
492+
#if defined(__APPLE__)
493+
EXPECT_EQ(request.ErrorSymbol(), "NSURLErrorCancelled") << request.ErrorString();
494+
EXPECT_EQ(request.ErrorCode(), -999) << request.ErrorString();
495+
#else
496+
// The guarantee under test is that Abort() interrupts the request promptly (the bounded wait
497+
// above) and records a curl transport error. Assert the "curl:" prefix rather than pinning the
498+
// exact CURLcode, which can vary with libcurl internals/timing.
499+
EXPECT_EQ(request.ErrorString().substr(0, 5), "curl:") << request.ErrorString();
500+
#endif
501+
}
502+
355503
#if defined(__APPLE__)
356504
TEST(UrlRequestErrorReporting, MissingAppResourceReportsError)
357505
{

0 commit comments

Comments
 (0)