]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
curl-util: bring CurlGlue/CurlSlot in line with sd-bus and qmp-client 41905/head
authorDaan De Meyer <daan@amutable.com>
Fri, 1 May 2026 09:08:35 +0000 (09:08 +0000)
committerDaan De Meyer <daan@amutable.com>
Fri, 8 May 2026 14:52:57 +0000 (16:52 +0200)
Refactor curl-util to use the same per-request, refcounted, cancellable
slot model as sd-bus, sd-varlink and qmp-client.

CurlGlue becomes opaque and refcounted, and dispatches per-slot
completion callbacks through CURLOPT_PRIVATE instead of a single
g->on_finished demux that every caller had to switch on. The new
curl_glue_perform_async(g, easy, cb, userdata, &slot) replaces
curl_glue_add + the on_finished/userdata wiring.

CurlSlot is the per-request handle: it owns the easy handle,
curl_slot_unref does curl_multi_remove_handle + curl_easy_cleanup
(which doubles as cancel since remove aborts in-flight transfers
without queuing CURLMSG_DONE), and floating slots (ret_slot=NULL) are
kept alive in the glue's slot set until the callback fires. Drop the
userdata parameter from curl_glue_make: CURLOPT_PRIVATE is now used
internally to route completions to the slot.

Migrate pull-job and the pull-{oci,raw,tar} drivers, and imdsd, to the
new shape. PullJob.curl becomes PullJob.slot; pull_job_curl_on_finished
becomes a per-slot callback. imdsd routes its token-vs-data branch off
slot identity rather than easy-handle pointer comparison. Both daemons
drop the global on_finished/userdata wiring on the glue. pull_job_finish
and context_fail{,_full} now return int (always 0) so the callbacks
stay in the `return finish(...);` style.

Add test-curl-util covering glue lifecycle, easy-handle defaults,
floating and non-floating perform paths, cancel-via-slot-unref (verified
by a sentinel request that drives the loop to completion), and three
concurrent requests on a single glue. Tests fetch local files via
file:// URLs so no network is needed; libcurl availability is probed
once via dlopen_curl in intro().

src/imds/imdsd.c
src/import/pull-job.c
src/import/pull-job.h
src/import/pull-oci.c
src/import/pull-raw.c
src/import/pull-tar.c
src/shared/curl-util.c
src/shared/curl-util.h
src/shared/shared-forward.h
src/test/meson.build
src/test/test-curl-util.c [new file with mode: 0644]

index a0c54ad84d7af49a434cce4e74fae5974cc14e27..9c194c09005a035f6d69b1fc8cd9661187a49652 100644 (file)
@@ -182,8 +182,8 @@ struct Context {
 
         /* Mode 1 "direct": we go directly to the network (this is done if we know the interface index to
          * use) */
-        CURL *curl_token;
-        CURL *curl_data;
+        CurlSlot *slot_token;
+        CurlSlot *slot_data;
         struct curl_slist *request_header_token, *request_header_data;
         sd_event_source *retry_source;
         unsigned n_retry;
@@ -247,15 +247,8 @@ static void context_reset_for_refresh(Context *c) {
 
         /* Flush out all fields, up to the point we can restart the current request */
 
-        if (c->curl_token) {
-                curl_glue_remove_and_free(c->glue, c->curl_token);
-                c->curl_token = NULL;
-        }
-
-        if (c->curl_data) {
-                curl_glue_remove_and_free(c->glue, c->curl_data);
-                c->curl_data = NULL;
-        }
+        c->slot_token = curl_slot_unref(c->slot_token);
+        c->slot_data = curl_slot_unref(c->slot_data);
 
         sym_curl_slist_free_all(c->request_header_token);
         c->request_header_token = NULL;
@@ -325,11 +318,12 @@ static void context_done(Context *c) {
         c->system_bus = sd_bus_flush_close_unref(c->system_bus);
 }
 
-static void context_fail_full(Context *c, int r, const char *varlink_error) {
+static int context_fail_full(Context *c, int r, const char *varlink_error) {
         assert(c);
         assert(r != 0);
 
-        /* Called whenever the current retrieval fails asynchronously */
+        /* Called whenever the current retrieval fails asynchronously. Returns 0 so callers in
+         * int-returning paths can `return context_fail_full(...)` directly. */
 
         r = -abs(r);
 
@@ -349,10 +343,11 @@ static void context_fail_full(Context *c, int r, const char *varlink_error) {
                 sd_event_exit(c->event, r);
 
         context_reset_full(c);
+        return 0;
 }
 
-static void context_fail(Context *c, int r) {
-        context_fail_full(c, r, /* varlink_error= */ NULL);
+static int context_fail(Context *c, int r) {
+        return context_fail_full(c, r, /* varlink_error= */ NULL);
 }
 
 static void context_success(Context *c) {
@@ -898,17 +893,12 @@ static int context_save_data(Context *c) {
         return 0;
 }
 
-static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
+static int curl_on_finished(CurlSlot *slot, CURL *curl, CURLcode result, void *userdata) {
+        Context *c = ASSERT_PTR(userdata);
         int r;
 
-        assert(g);
-
         /* Called whenever libcurl did its thing and reports a download being complete or having failed */
 
-        Context *c = NULL;
-        if (sym_curl_easy_getinfo(curl, CURLINFO_PRIVATE, (char**) &c) != CURLE_OK)
-                return;
-
         switch (result) {
 
         case CURLE_OK: /* yay! */
@@ -934,7 +924,7 @@ static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 if (r < 0)
                         return context_fail(c, r);
 
-                return;
+                return 0;
 
         default:
                 return context_fail_full(
@@ -951,12 +941,12 @@ static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 return context_fail(c, r);
         if (r == 0) { /* We shall retry */
                 (void) context_schedule_retry(c);
-                return;
+                return 0;
         }
         if (result != CURLE_OK) /* if getting the HTTP status didn't work, propagate a generic error */
                 return context_fail(c, SYNTHETIC_ERRNO(ENOTRECOVERABLE));
 
-        if (curl == c->curl_token) {
+        if (slot == c->slot_token) {
                 r = context_validate_token_http_status(c, status);
                 if (r < 0)
                         return context_fail(c, r);
@@ -975,7 +965,7 @@ static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 if (r < 0)
                         return context_fail(c, r);
 
-        } else if (curl == c->curl_data) {
+        } else if (slot == c->slot_data) {
 
                 r = context_validate_data_http_status(c, status);
                 if (r == -ENOENT)
@@ -983,7 +973,7 @@ static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 if (r < 0)
                         return context_fail(c, r);
                 if (r == 0) /* Immediately restarted */
-                        return;
+                        return 0;
 
                 context_log(c, LOG_DEBUG, "Data download successful.");
 
@@ -994,6 +984,8 @@ static void curl_glue_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 context_success(c);
         } else
                 assert_not_reached();
+
+        return 0;
 }
 
 static int context_acquire_glue(Context *c) {
@@ -1010,9 +1002,6 @@ static int context_acquire_glue(Context *c) {
         if (r < 0)
                 return context_log_errno(c, LOG_ERR, r, "Failed to allocate curl glue: %m");
 
-        c->glue->on_finished = curl_glue_on_finished;
-        c->glue->userdata = c;
-
         return 0;
 }
 
@@ -1028,13 +1017,13 @@ static size_t data_write_callback(void *contents, size_t size, size_t nmemb, voi
         (void) context_save_ifname(c);
 
         /* Before we use the acquired data, let's verify the HTTP status, if there's a failure or we need to
-         * restart, abort the write here. Note that the curl_glue_on_finished() call will then check the HTTP
+         * restart, abort the write here. Note that the curl_on_finished() call will then check the HTTP
          * status again and act on it. */
         long status;
-        r = context_acquire_http_status(c, c->curl_data, &status);
+        r = context_acquire_http_status(c, curl_slot_get_easy(c->slot_data), &status);
         if (r <= 0)
-                return 0; /* fail the thing, so that curl_glue_on_finished() can handle this failure or retry request */
-        if (status >= 300) /* any status equal or above 300 needs to be handled by curl_glue_on_finished() too */
+                return 0; /* fail the thing, so that curl_on_finished() can handle this failure or retry request */
+        if (status >= 300) /* any status equal or above 300 needs to be handled by curl_on_finished() too */
                 return 0;
 
         if (sz > UINT64_MAX - c->data_size ||
@@ -1103,7 +1092,8 @@ static int context_acquire_data(Context *c) {
         if (!url)
                 return context_log_oom(c);
 
-        r = curl_glue_make(&c->curl_data, url, c);
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        r = curl_glue_make(&easy, url);
         if (r < 0)
                 return context_log_errno(c, LOG_ERR, r, "Failed to create CURL request for data: %m");
 
@@ -1122,30 +1112,31 @@ static int context_acquire_data(Context *c) {
                 return context_log_errno(c, LOG_ERR, r, "Failed to create curl header: %m");
 
         if (c->request_header_data)
-                if (sym_curl_easy_setopt(c->curl_data, CURLOPT_HTTPHEADER, c->request_header_data) != CURLE_OK)
+                if (sym_curl_easy_setopt(easy, CURLOPT_HTTPHEADER, c->request_header_data) != CURLE_OK)
                         return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set HTTP request header.");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_WRITEFUNCTION, data_write_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, data_write_callback) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL write function.");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_WRITEDATA, c) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEDATA, c) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL write function userdata.");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_SOCKOPTFUNCTION, setsockopt_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_SOCKOPTFUNCTION, setsockopt_callback) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt function.");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_SOCKOPTDATA, c) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_SOCKOPTDATA, c) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt function userdata.");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_LOCALPORT, 1L) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_LOCALPORT, 1L) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt local port");
 
-        if (sym_curl_easy_setopt(c->curl_data, CURLOPT_LOCALPORTRANGE, 1023L) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_LOCALPORTRANGE, 1023L) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt local port range");
 
-        r = curl_glue_add(c->glue, c->curl_data);
+        r = curl_glue_perform_async(c->glue, easy, curl_on_finished, c, &c->slot_data);
         if (r < 0)
                 return context_log_errno(c, LOG_ERR, r, "Failed to add CURL request to glue: %m");
+        TAKE_PTR(easy);
 
         return 0;
 }
@@ -1163,10 +1154,10 @@ static size_t token_write_callback(void *contents, size_t size, size_t nmemb, vo
 
         /* Before we use acquired data, let's verify the HTTP status */
         long status;
-        r = context_acquire_http_status(c, c->curl_token, &status);
+        r = context_acquire_http_status(c, curl_slot_get_easy(c->slot_token), &status);
         if (r <= 0)
-                return 0; /* fail the thing, so that curl_glue_on_finished() can handle this failure or retry request */
-        if (status >= 300) /* any status equal or above 300 needs to be handled by curl_glue_on_finished() */
+                return 0; /* fail the thing, so that curl_on_finished() can handle this failure or retry request */
+        if (status >= 300) /* any status equal or above 300 needs to be handled by curl_on_finished() */
                 return 0;
 
         if (sz > SIZE_MAX - c->token.iov_len ||
@@ -1199,7 +1190,8 @@ static int context_acquire_token(Context *c) {
         if (r < 0)
                 return r;
 
-        r = curl_glue_make(&c->curl_token, arg_token_url, c);
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        r = curl_glue_make(&easy, arg_token_url);
         if (r < 0)
                 return context_log_errno(c, LOG_ERR, r, "Failed to create CURL request for API token: %m");
 
@@ -1216,27 +1208,28 @@ static int context_acquire_token(Context *c) {
                         return context_log_oom(c);
         }
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_HTTPHEADER, c->request_header_token) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_HTTPHEADER, c->request_header_token) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set HTTP request header.");
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_CUSTOMREQUEST, "PUT") != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_CUSTOMREQUEST, "PUT") != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set HTTP request method.");
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_WRITEFUNCTION, token_write_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, token_write_callback) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL write function.");
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_WRITEDATA, c) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEDATA, c) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL write function userdata.");
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_SOCKOPTFUNCTION, setsockopt_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_SOCKOPTFUNCTION, setsockopt_callback) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt function.");
 
-        if (sym_curl_easy_setopt(c->curl_token, CURLOPT_SOCKOPTDATA, c) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_SOCKOPTDATA, c) != CURLE_OK)
                 return context_log_errno(c, LOG_ERR, SYNTHETIC_ERRNO(EIO), "Failed to set CURL setsockopt function userdata.");
 
-        r = curl_glue_add(c->glue, c->curl_token);
+        r = curl_glue_perform_async(c->glue, easy, curl_on_finished, c, &c->slot_token);
         if (r < 0)
                 return context_log_errno(c, LOG_ERR, r, "Failed to add CURL request to glue: %m");
+        TAKE_PTR(easy);
 
         return 0;
 }
index 4c3fb05dd3533a18002d4e6b3f104cf5748a7f6b..5b8aa6da269421ecfd0fba5797c1a6dea1539fbb 100644 (file)
@@ -53,7 +53,7 @@ PullJob* pull_job_unref(PullJob *j) {
 
         pull_job_close_disk_fd(j);
 
-        curl_glue_remove_and_free(j->glue, j->curl);
+        curl_slot_unref(j->slot);
         sym_curl_slist_free_all(j->request_header);
 
         j->compress = compressor_free(j->compress);
@@ -83,11 +83,13 @@ static const char* pull_job_description(PullJob *j) {
         return j->description ?: j->url;
 }
 
-static void pull_job_finish(PullJob *j, int ret) {
+static int pull_job_finish(PullJob *j, int ret) {
         assert(j);
 
+        /* Returns 0 so callers in int-returning paths can `return pull_job_finish(...)` directly. */
+
         if (IN_SET(j->state, PULL_JOB_DONE, PULL_JOB_FAILED))
-                return;
+                return 0;
 
         if (ret == 0) {
                 j->state = PULL_JOB_DONE;
@@ -100,6 +102,8 @@ static void pull_job_finish(PullJob *j, int ret) {
 
         if (j->on_finished)
                 j->on_finished(j);
+
+        return 0;
 }
 
 int pull_job_restart(PullJob *j, const char *new_url) {
@@ -134,8 +138,7 @@ int pull_job_restart(PullJob *j, const char *new_url) {
                 j->expected_content_length = UINT64_MAX;
         }
 
-        curl_glue_remove_and_free(j->glue, j->curl);
-        j->curl = NULL;
+        j->slot = curl_slot_unref(j->slot);
 
         j->compress = compressor_free(j->compress);
 
@@ -160,23 +163,18 @@ static uint64_t pull_job_content_length_effective(PullJob *j) {
         return j->content_length;
 }
 
-void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
-        PullJob *j = NULL;
+static int pull_job_curl_on_finished(CurlSlot *slot, CURL *curl, CURLcode result, void *userdata) {
+        PullJob *j = ASSERT_PTR(userdata);
         char *scheme = NULL;
         CURLcode code;
         int r;
 
-        if (sym_curl_easy_getinfo(curl, CURLINFO_PRIVATE, (char **)&j) != CURLE_OK)
-                return;
-
-        if (!j || IN_SET(j->state, PULL_JOB_DONE, PULL_JOB_FAILED))
-                return;
+        if (IN_SET(j->state, PULL_JOB_DONE, PULL_JOB_FAILED))
+                return 0;
 
         code = sym_curl_easy_getinfo(curl, CURLINFO_SCHEME, &scheme);
-        if (code != CURLE_OK || !scheme) {
-                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve URL scheme.");
-                goto finish;
-        }
+        if (code != CURLE_OK || !scheme)
+                return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve URL scheme."));
 
         if (strcaseeq(scheme, "FILE") && result == CURLE_FILE_COULDNT_READ_FILE && j->on_not_found) {
                 _cleanup_free_ char *new_url = NULL;
@@ -184,43 +182,37 @@ void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                 /* This resource wasn't found, but the implementer wants to maybe let us know a new URL, query for it. */
                 r = j->on_not_found(j, &new_url);
                 if (r < 0)
-                        goto finish;
+                        return pull_job_finish(j, r);
                 if (r > 0) { /* A new url to use */
                         assert(new_url);
 
                         r = pull_job_restart(j, new_url);
                         if (r < 0)
-                                goto finish;
+                                return pull_job_finish(j, r);
 
-                        return;
+                        return 0;
                 }
 
                 /* if this didn't work, handle like any other error below */
         }
 
-        if (result != CURLE_OK) {
-                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Transfer failed: %s", sym_curl_easy_strerror(result));
-                goto finish;
-        }
+        if (result != CURLE_OK)
+                return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Transfer failed: %s", sym_curl_easy_strerror(result)));
 
         if (STRCASE_IN_SET(scheme, "HTTP", "HTTPS")) {
                 long status;
 
                 code = sym_curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
-                if (code != CURLE_OK) {
-                        r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve response code: %s", sym_curl_easy_strerror(code));
-                        goto finish;
-                }
+                if (code != CURLE_OK)
+                        return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve response code: %s", sym_curl_easy_strerror(code)));
 
                 if (http_status_etag_exists(status)) {
                         log_info("Image already downloaded. Skipping download.");
                         j->etag_exists = true;
-                        r = 0;
-                        goto finish;
+                        return pull_job_finish(j, 0);
                 } else if (http_status_need_authentication(status)) {
                         log_info("Access to image requires authentication.");
-                        r = -ENOKEY;
-                        goto finish;
+                        return pull_job_finish(j, -ENOKEY);
                 } else if (status >= 300) {
 
                         if (status == 404 && j->on_not_found) {
@@ -229,81 +221,64 @@ void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                                 /* This resource wasn't found, but the implementer wants to maybe let us know a new URL, query for it. */
                                 r = j->on_not_found(j, &new_url);
                                 if (r < 0)
-                                        goto finish;
+                                        return pull_job_finish(j, r);
 
                                 if (r > 0) { /* A new url to use */
                                         assert(new_url);
 
                                         r = pull_job_restart(j, new_url);
                                         if (r < 0)
-                                                goto finish;
+                                                return pull_job_finish(j, r);
 
-                                        code = sym_curl_easy_getinfo(j->curl, CURLINFO_RESPONSE_CODE, &status);
-                                        if (code != CURLE_OK) {
-                                                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve response code: %s", sym_curl_easy_strerror(code));
-                                                goto finish;
-                                        }
+                                        code = sym_curl_easy_getinfo(curl_slot_get_easy(j->slot), CURLINFO_RESPONSE_CODE, &status);
+                                        if (code != CURLE_OK)
+                                                return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve response code: %s", sym_curl_easy_strerror(code)));
 
                                         if (status == 0)
-                                                return;
+                                                return 0;
                                 }
                         }
 
-                        r = log_notice_errno(
+                        return pull_job_finish(j, log_notice_errno(
                                         status == 404 ? SYNTHETIC_ERRNO(ENOMEDIUM) : SYNTHETIC_ERRNO(EIO), /* Make the most common error recognizable */
-                                        "HTTP request to %s failed with code %li.", j->url, status);
-                        goto finish;
-                } else if (status < 200) {
-                        r = log_error_errno(SYNTHETIC_ERRNO(EIO), "HTTP request to %s finished with unexpected code %li.", j->url, status);
-                        goto finish;
-                }
+                                        "HTTP request to %s failed with code %li.", j->url, status));
+                } else if (status < 200)
+                        return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "HTTP request to %s finished with unexpected code %li.", j->url, status));
         }
 
-        if (j->state != PULL_JOB_RUNNING) {
-                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Premature connection termination.");
-                goto finish;
-        }
+        if (j->state != PULL_JOB_RUNNING)
+                return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Premature connection termination."));
 
         uint64_t cl = pull_job_content_length_effective(j);
         if (cl != UINT64_MAX &&
-            cl != j->written_compressed) {
-                r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Download truncated.");
-                goto finish;
-        }
+            cl != j->written_compressed)
+                return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Download truncated."));
 
         if (j->checksum_ctx) {
                 unsigned checksum_len;
 
                 iovec_done(&j->checksum);
                 j->checksum.iov_base = malloc(EVP_MAX_MD_SIZE);
-                if (!j->checksum.iov_base) {
-                        r = log_oom();
-                        goto finish;
-                }
+                if (!j->checksum.iov_base)
+                        return pull_job_finish(j, log_oom());
 
                 r = sym_EVP_DigestFinal_ex(j->checksum_ctx, j->checksum.iov_base, &checksum_len);
-                if (r == 0) {
-                        r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to get checksum.");
-                        goto finish;
-                }
+                if (r == 0)
+                        return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to get checksum."));
                 assert(checksum_len <= EVP_MAX_MD_SIZE);
                 j->checksum.iov_len = checksum_len;
 
                 if (DEBUG_LOGGING) {
                         _cleanup_free_ char *h = hexmem(j->checksum.iov_base, j->checksum.iov_len);
-                        if (!h) {
-                                r = log_oom();
-                                goto finish;
-                        }
+                        if (!h)
+                                return pull_job_finish(j, log_oom());
 
                         log_debug("%s of %s is %s.", sym_EVP_MD_CTX_get0_name(j->checksum_ctx), pull_job_description(j), h);
                 }
 
                 if (iovec_is_set(&j->expected_checksum) &&
-                    !iovec_equal(&j->checksum, &j->expected_checksum)) {
-                        r = log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Checksum of downloaded resource does not match expected checksum, yikes.");
-                        goto finish;
-                }
+                    !iovec_equal(&j->checksum, &j->expected_checksum))
+                        return pull_job_finish(j, log_error_errno(SYNTHETIC_ERRNO(EBADMSG), "Checksum of downloaded resource does not match expected checksum, yikes."));
         }
 
         /* Do a couple of finishing disk operations, but only if we are the sole owner of the file (i.e. no
@@ -318,10 +293,8 @@ void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
                                 if (j->written_compressed > 0) {
                                         /* Make sure the file size is right, in case the file was sparse and
                                          * we just moved to the last part. */
-                                        if (ftruncate(j->disk_fd, j->written_uncompressed) < 0) {
-                                                r = log_error_errno(errno, "Failed to truncate file: %m");
-                                                goto finish;
-                                        }
+                                        if (ftruncate(j->disk_fd, j->written_uncompressed) < 0)
+                                                return pull_job_finish(j, log_error_errno(errno, "Failed to truncate file: %m"));
                                 }
 
                                 if (j->etag)
@@ -345,27 +318,20 @@ void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result) {
 
                         if (j->sync) {
                                 r = fsync_full(j->disk_fd);
-                                if (r < 0) {
-                                        log_error_errno(r, "Failed to synchronize file to disk: %m");
-                                        goto finish;
-                                }
+                                if (r < 0)
+                                        return pull_job_finish(j, log_error_errno(r, "Failed to synchronize file to disk: %m"));
                         }
 
                 } else if (S_ISBLK(j->disk_stat.st_mode) && j->sync) {
 
-                        if (fsync(j->disk_fd) < 0) {
-                                r = log_error_errno(errno, "Failed to synchronize block device: %m");
-                                goto finish;
-                        }
+                        if (fsync(j->disk_fd) < 0)
+                                return pull_job_finish(j, log_error_errno(errno, "Failed to synchronize block device: %m"));
                 }
         }
 
         log_info("Acquired %s for %s.", FORMAT_BYTES(j->written_uncompressed), pull_job_description(j));
 
-        r = 0;
-
-finish:
-        pull_job_finish(j, r);
+        return pull_job_finish(j, 0);
 }
 
 static int pull_job_write_uncompressed(const void *p, size_t sz, void *userdata) {
@@ -595,7 +561,7 @@ static size_t pull_job_header_callback(void *contents, size_t size, size_t nmemb
 
         assert(j->state == PULL_JOB_ANALYZING);
 
-        code = sym_curl_easy_getinfo(j->curl, CURLINFO_RESPONSE_CODE, &status);
+        code = sym_curl_easy_getinfo(curl_slot_get_easy(j->slot), CURLINFO_RESPONSE_CODE, &status);
         if (code != CURLE_OK) {
                 r = log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to retrieve response code: %s", sym_curl_easy_strerror(code));
                 goto fail;
@@ -809,7 +775,8 @@ int pull_job_begin(PullJob *j) {
         if (j->state != PULL_JOB_INIT)
                 return -EBUSY;
 
-        r = curl_glue_make(&j->curl, j->url, j);
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        r = curl_glue_make(&easy, j->url);
         if (r < 0)
                 return r;
 
@@ -830,34 +797,35 @@ int pull_job_begin(PullJob *j) {
         }
 
         if (j->request_header) {
-                if (sym_curl_easy_setopt(j->curl, CURLOPT_HTTPHEADER, j->request_header) != CURLE_OK)
+                if (sym_curl_easy_setopt(easy, CURLOPT_HTTPHEADER, j->request_header) != CURLE_OK)
                         return -EIO;
         }
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_WRITEFUNCTION, pull_job_write_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, pull_job_write_callback) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_WRITEDATA, j) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_WRITEDATA, j) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_HEADERFUNCTION, pull_job_header_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_HEADERFUNCTION, pull_job_header_callback) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_HEADERDATA, j) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_HEADERDATA, j) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_XFERINFOFUNCTION, pull_job_progress_callback) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_XFERINFOFUNCTION, pull_job_progress_callback) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_XFERINFODATA, j) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_XFERINFODATA, j) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(j->curl, CURLOPT_NOPROGRESS, 0L) != CURLE_OK)
+        if (sym_curl_easy_setopt(easy, CURLOPT_NOPROGRESS, 0L) != CURLE_OK)
                 return -EIO;
 
-        r = curl_glue_add(j->glue, j->curl);
+        r = curl_glue_perform_async(j->glue, easy, pull_job_curl_on_finished, j, &j->slot);
         if (r < 0)
                 return r;
+        TAKE_PTR(easy);
 
         j->state = PULL_JOB_ANALYZING;
 
index 0b878292f096b7b7922fb60f7dcd596032f01f88..00d001680ff203ca4757898e752c68e8a3db762a 100644 (file)
@@ -7,7 +7,6 @@
 
 #include "shared-forward.h"
 
-typedef struct CurlGlue CurlGlue;
 typedef struct PullJob PullJob;
 
 typedef void (*PullJobFinished)(PullJob *job);
@@ -46,7 +45,7 @@ typedef struct PullJob {
         PullJobNotFound on_not_found;
 
         CurlGlue *glue;
-        CURL *curl;
+        CurlSlot *slot;
         struct curl_slist *request_header;
 
         char *etag;
@@ -95,8 +94,6 @@ PullJob* pull_job_unref(PullJob *job);
 
 int pull_job_begin(PullJob *j);
 
-void pull_job_curl_on_finished(CurlGlue *g, CURL *curl, CURLcode result);
-
 void pull_job_close_disk_fd(PullJob *j);
 
 int pull_job_add_request_header(PullJob *j, const char *hdr);
index acea93b09de9a6fc3741009915e8ba4bc090e3ba..c1c76fc89801784531482e2352ecc431af79eb72 100644 (file)
@@ -195,9 +195,6 @@ int oci_pull_new(
                 .userns_fd = -EBADF,
         };
 
-        i->glue->on_finished = pull_job_curl_on_finished;
-        i->glue->userdata = i;
-
         *ret = TAKE_PTR(i);
 
         return 0;
index 0ddde7c0919625f0a741cc78a464a7994ba6ace9..c63a453177cff572b9716d11d2a2ff4029e5ba10 100644 (file)
@@ -149,9 +149,6 @@ int raw_pull_new(
                 .offset = UINT64_MAX,
         };
 
-        p->glue->on_finished = pull_job_curl_on_finished;
-        p->glue->userdata = p;
-
         *ret = TAKE_PTR(p);
 
         return 0;
index fe18636eb7d9d9f68de544df305c075b890d4efc..453ad1187cf7bb13d069fcef9a22779dc3ad5b2b 100644 (file)
@@ -153,9 +153,6 @@ int tar_pull_new(
                 .progress_ratelimit = { 100 * USEC_PER_MSEC, 1 },
         };
 
-        p->glue->on_finished = pull_job_curl_on_finished;
-        p->glue->userdata = p;
-
         *ret = TAKE_PTR(p);
 
         return 0;
index 0a6bdeefe5589e7adc5595163ba3d0fb5d1d9c04..e438ddf61a4d253cc88a18169b4fea608c0f2e9d 100644 (file)
@@ -12,6 +12,7 @@
 #include "dlfcn-util.h"
 #include "fd-util.h"
 #include "hashmap.h"
+#include "set.h"
 #include "string-util.h"
 #include "strv.h"
 #include "time-util.h"
@@ -42,6 +43,77 @@ DLSYM_PROTOTYPE(curl_slist_free_all) = NULL;
 
 DEFINE_TRIVIAL_CLEANUP_FUNC_FULL_RENAME(CURLM*, sym_curl_multi_cleanup, curl_multi_cleanupp, NULL);
 
+struct CurlGlue {
+        unsigned n_ref;
+        sd_event *event;
+        CURLM *curl;
+        sd_event_source *timer;
+        Hashmap *ios;
+        sd_event_source *defer;
+        Set *slots;             /* CurlSlot* — back-pointer set; floating slots are kept alive here */
+};
+
+struct CurlSlot {
+        unsigned n_ref;
+        CurlGlue *glue;         /* NULL once disconnected (callback fired, cancelled, or glue died) */
+        CURL *easy;             /* owned; cleared once the easy handle has been freed */
+        bool floating;
+        curl_finished_t callback;
+        void *userdata;
+};
+
+static void curl_slot_disconnect(CurlSlot *slot, bool unref) {
+        assert(slot);
+
+        /* Tear down the slot's connection to the glue: pull the easy handle out of the multi,
+         * curl_easy_cleanup() it, and remove the slot from the glue's lookup set. Floating
+         * slots are owned by that set, so on disconnect we drop the implicit ref (when
+         * unref=true; the recursive call from curl_slot_free passes false to avoid infinite
+         * recursion). Non-floating slots release the back-ref they held on the glue.
+         *
+         * Idempotent: once slot->glue is NULL, subsequent calls are no-ops. */
+
+        if (!slot->glue)
+                return;
+
+        CurlGlue *glue = slot->glue;
+
+        if (slot->easy) {
+                if (glue->curl)
+                        (void) sym_curl_multi_remove_handle(glue->curl, slot->easy);
+                sym_curl_easy_cleanup(slot->easy);
+                slot->easy = NULL;
+        }
+
+        set_remove(glue->slots, slot);
+        slot->glue = NULL;
+
+        if (!slot->floating)
+                curl_glue_unref(glue);
+        else if (unref)
+                curl_slot_unref(slot);
+}
+
+static CurlSlot* curl_slot_free(CurlSlot *slot) {
+        if (!slot)
+                return NULL;
+
+        curl_slot_disconnect(slot, /* unref= */ false);
+        return mfree(slot);
+}
+
+DEFINE_TRIVIAL_REF_UNREF_FUNC(CurlSlot, curl_slot, curl_slot_free);
+
+CURL* curl_slot_get_easy(CurlSlot *slot) {
+        assert(slot);
+        return slot->easy;
+}
+
+CurlGlue* curl_slot_get_glue(CurlSlot *slot) {
+        assert(slot);
+        return slot->glue;
+}
+
 static void curl_glue_check_finished(CurlGlue *g) {
         int r;
 
@@ -60,8 +132,27 @@ static void curl_glue_check_finished(CurlGlue *g) {
         if (!msg)
                 return;
 
-        if (msg->msg == CURLMSG_DONE && g->on_finished)
-                g->on_finished(g, msg->easy_handle, msg->data.result);
+        if (msg->msg == CURLMSG_DONE) {
+                CURL *easy = msg->easy_handle;
+                CURLcode code = msg->data.result;
+                CurlSlot *slot = NULL;
+
+                if (sym_curl_easy_getinfo(easy, CURLINFO_PRIVATE, (char**) &slot) == CURLE_OK && slot) {
+                        /* Pin the slot across the callback: a floating slot's only
+                         * reference is the one held via the glue's slots set, and
+                         * disconnect drops it. */
+                        curl_slot_ref(slot);
+
+                        if (slot->callback) {
+                                r = slot->callback(slot, easy, code, slot->userdata);
+                                if (r < 0)
+                                        log_debug_errno(r, "Curl finished callback returned error, ignoring: %m");
+                        }
+
+                        curl_slot_disconnect(slot, /* unref= */ true);
+                        curl_slot_unref(slot);
+                }
+        }
 
         /* This is a queue, process another item soon, but do so in a later event loop iteration. */
         (void) sd_event_source_set_enabled(g->defer, SD_EVENT_ONESHOT);
@@ -212,12 +303,22 @@ static int curl_glue_on_defer(sd_event_source *s, void *userdata) {
         return 0;
 }
 
-CurlGlue *curl_glue_unref(CurlGlue *g) {
+static CurlGlue* curl_glue_free(CurlGlue *g) {
         sd_event_source *io;
+        CurlSlot *slot;
 
         if (!g)
                 return NULL;
 
+        /* Drain any slots still hanging off us. By construction only floating slots can
+         * be here: connected non-floating slots hold a glue back-ref, so glue's last ref
+         * couldn't have dropped while one was attached. disconnect(unref=true) does the
+         * floating slot's free as part of its work. set_steal_first() pops up front so
+         * forward progress doesn't depend on disconnect's internal set_remove(). */
+        while ((slot = set_steal_first(g->slots)))
+                curl_slot_disconnect(slot, /* unref= */ true);
+        g->slots = set_free(g->slots);
+
         if (g->curl)
                 sym_curl_multi_cleanup(g->curl);
 
@@ -232,6 +333,8 @@ CurlGlue *curl_glue_unref(CurlGlue *g) {
         return mfree(g);
 }
 
+DEFINE_TRIVIAL_REF_UNREF_FUNC(CurlGlue, curl_glue, curl_glue_free);
+
 int curl_glue_new(CurlGlue **glue, sd_event *event) {
         _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
         _cleanup_(curl_multi_cleanupp) CURLM *c = NULL;
@@ -261,6 +364,7 @@ int curl_glue_new(CurlGlue **glue, sd_event *event) {
                 return -ENOMEM;
 
         *g = (CurlGlue) {
+                .n_ref = 1,
                 .event = TAKE_PTR(e),
                 .curl = TAKE_PTR(c),
         };
@@ -288,7 +392,7 @@ int curl_glue_new(CurlGlue **glue, sd_event *event) {
         return 0;
 }
 
-int curl_glue_make(CURL **ret, const char *url, void *userdata) {
+int curl_glue_make(CURL **ret, const char *url) {
         _cleanup_(curl_easy_cleanupp) CURL *c = NULL;
         const char *useragent;
         int r;
@@ -310,9 +414,6 @@ int curl_glue_make(CURL **ret, const char *url, void *userdata) {
         if (sym_curl_easy_setopt(c, CURLOPT_URL, url) != CURLE_OK)
                 return -EIO;
 
-        if (sym_curl_easy_setopt(c, CURLOPT_PRIVATE, userdata) != CURLE_OK)
-                return -EIO;
-
         useragent = strjoina(program_invocation_short_name, "/" GIT_VERSION);
         if (sym_curl_easy_setopt(c, CURLOPT_USERAGENT, useragent) != CURLE_OK)
                 return -EIO;
@@ -342,26 +443,61 @@ int curl_glue_make(CURL **ret, const char *url, void *userdata) {
         return 0;
 }
 
-int curl_glue_add(CurlGlue *g, CURL *c) {
+int curl_glue_perform_async(
+                CurlGlue *g,
+                CURL *easy,
+                curl_finished_t cb,
+                void *userdata,
+                CurlSlot **ret_slot) {
+
+        int r;
+
         assert(g);
-        assert(c);
+        assert(easy);
 
-        if (sym_curl_multi_add_handle(g->curl, c) != CURLM_OK)
-                return -EIO;
+        _cleanup_(curl_slot_unrefp) CurlSlot *slot = new(CurlSlot, 1);
+        if (!slot)
+                return -ENOMEM;
 
-        return 0;
-}
+        *slot = (CurlSlot) {
+                .n_ref = 1,
+                .glue = NULL,    /* wired up below, after we've committed to the multi */
+                .easy = easy,
+                .floating = !ret_slot,
+                .callback = cb,
+                .userdata = userdata,
+        };
 
-void curl_glue_remove_and_free(CurlGlue *g, CURL *c) {
-        assert(g);
+        r = set_ensure_put(&g->slots, &trivial_hash_ops, slot);
+        if (r < 0)
+                return r;
+        assert(r > 0);
 
-        if (!c)
-                return;
+        if (sym_curl_multi_add_handle(g->curl, easy) != CURLM_OK) {
+                set_remove(g->slots, slot);
+                return -EIO;
+        }
 
-        if (g->curl)
-                sym_curl_multi_remove_handle(g->curl, c);
+        /* Stash the slot pointer on the easy handle so curl_glue_check_finished() can recover
+         * it on completion. Set this only after we've fully committed to the multi, so that
+         * error paths above don't leave a dangling pointer on the easy handle. */
+        if (sym_curl_easy_setopt(easy, CURLOPT_PRIVATE, slot) != CURLE_OK) {
+                sym_curl_multi_remove_handle(g->curl, easy);
+                set_remove(g->slots, slot);
+                return -EIO;
+        }
+
+        slot->glue = g;
+        if (!slot->floating)
+                curl_glue_ref(g);
 
-        sym_curl_easy_cleanup(c);
+        /* Transfer the slot's single reference: to the caller for non-floating slots, or to
+         * the glue's slot set (implicitly, until disconnect drops it) for floating ones. */
+        if (ret_slot)
+                *ret_slot = slot;
+
+        TAKE_PTR(slot);
+        return 0;
 }
 
 struct curl_slist *curl_slist_new(const char *first, ...) {
index 33ab0a5fb204bb23e3e80bf4610b02fb197eee8e..3436188952fbce5093b5011d18aaaf6c1fcdd9a6 100644 (file)
@@ -30,27 +30,42 @@ extern DLSYM_PROTOTYPE(curl_slist_free_all);
         code == CURLE_OK;                                                   \
 })
 
-typedef struct CurlGlue CurlGlue;
-
-typedef struct CurlGlue {
-        sd_event *event;
-        CURLM *curl;
-        sd_event_source *timer;
-        Hashmap *ios;
-        sd_event_source *defer;
-
-        void (*on_finished)(CurlGlue *g, CURL *curl, CURLcode code);
-        void *userdata;
-} CurlGlue;
+typedef int (*curl_finished_t)(CurlSlot *slot, CURL *curl, CURLcode code, void *userdata);
 
 int curl_glue_new(CurlGlue **glue, sd_event *event);
+CurlGlue* curl_glue_ref(CurlGlue *glue);
 CurlGlue* curl_glue_unref(CurlGlue *glue);
 
 DEFINE_TRIVIAL_CLEANUP_FUNC(CurlGlue*, curl_glue_unref);
 
-int curl_glue_make(CURL **ret, const char *url, void *userdata);
-int curl_glue_add(CurlGlue *g, CURL *c);
-void curl_glue_remove_and_free(CurlGlue *g, CURL *c);
+/* Build a CURL easy handle with sane defaults. The caller configures any
+ * additional options (headers, write callbacks, …) before handing it off to
+ * curl_glue_perform_async(). */
+int curl_glue_make(CURL **ret, const char *url);
+
+/* Hand a configured CURL easy handle off to the multi for execution. The slot
+ * takes ownership of the easy handle: once the slot is released (the callback
+ * has fired, the caller has dropped its last ref, or the glue is being freed),
+ * the handle is removed from the multi and freed.
+ *
+ * If ret_slot is NULL the slot is allocated as floating: the glue keeps it
+ * alive until the callback fires or the glue is torn down. Otherwise a
+ * reference is returned to the caller; releasing that reference cancels the
+ * call. */
+int curl_glue_perform_async(
+                CurlGlue *g,
+                CURL *easy,
+                curl_finished_t cb,
+                void *userdata,
+                CurlSlot **ret_slot);
+
+CURL* curl_slot_get_easy(CurlSlot *slot);
+CurlGlue* curl_slot_get_glue(CurlSlot *slot);
+
+CurlSlot* curl_slot_ref(CurlSlot *slot);
+CurlSlot* curl_slot_unref(CurlSlot *slot);
+
+DEFINE_TRIVIAL_CLEANUP_FUNC(CurlSlot*, curl_slot_unref);
 
 struct curl_slist *curl_slist_new(const char *first, ...) _sentinel_;
 int curl_header_strdup(const void *contents, size_t sz, const char *field, char **value);
index e850d8982bd303a4aa16fd994d33eeed77e55a0d..751a6f71dc359bab7f8a4ba87b913dd3dc882ef9 100644 (file)
@@ -57,6 +57,8 @@ typedef struct Condition Condition;
 typedef struct ConfigSection ConfigSection;
 typedef struct ConfigTableItem ConfigTableItem;
 typedef struct CPUSet CPUSet;
+typedef struct CurlGlue CurlGlue;
+typedef struct CurlSlot CurlSlot;
 typedef struct DissectedImage DissectedImage;
 typedef struct DnsAnswer DnsAnswer;
 typedef struct DnsPacket DnsPacket;
index f4288119f94ba874e884f7bd027a32003da89fd3..ba890e73410175515e4598b3305872071b128f51 100644 (file)
@@ -348,6 +348,10 @@ executables += [
                 'sources' : files('test-kexec.c'),
                 'link_with' : [libshared],
         },
+        test_template + {
+                'sources' : files('test-curl-util.c'),
+                'conditions' : ['HAVE_LIBCURL'],
+        },
         test_template + {
                 'sources' : files('test-libcrypt-util.c'),
                 'conditions' : ['HAVE_LIBCRYPT'],
diff --git a/src/test/test-curl-util.c b/src/test/test-curl-util.c
new file mode 100644 (file)
index 0000000..fb3d278
--- /dev/null
@@ -0,0 +1,280 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include <unistd.h>
+
+#include "sd-event.h"
+
+#include "alloc-util.h"
+#include "curl-util.h"
+#include "fd-util.h"
+#include "fs-util.h"
+#include "io-util.h"
+#include "string-util.h"
+#include "tests.h"
+#include "tmpfile-util.h"
+
+#define ASSERT_CURL_OK(expr)                                                            \
+        ({                                                                              \
+                CURLcode _code = (expr);                                                \
+                if (_code != CURLE_OK)                                                  \
+                        log_test_failed("Expected \"%s\" to be CURLE_OK, but got %d/%s",\
+                                        #expr, (int) _code, sym_curl_easy_strerror(_code)); \
+        })
+
+/* Per-request context: the write callback appends bytes to ->body, and the
+ * on_finished callback stashes the CURLcode plus a "fired" flag. Each test
+ * uses one or more of these and cleans them up via context_done(). */
+typedef struct Context {
+        sd_event *event;
+        char *body;
+        size_t body_len;
+        bool finished;
+        CURLcode result;
+} Context;
+
+static void context_done(Context *f) {
+        f->event = sd_event_unref(f->event);
+        f->body = mfree(f->body);
+}
+
+static size_t write_callback(void *contents, size_t size, size_t nmemb, void *userdata) {
+        Context *f = ASSERT_PTR(userdata);
+        size_t sz = size * nmemb;
+
+        if (!GREEDY_REALLOC(f->body, f->body_len + sz + 1))
+                return 0;
+        memcpy(f->body + f->body_len, contents, sz);
+        f->body[f->body_len + sz] = 0;
+        f->body_len += sz;
+        return sz;
+}
+
+static int on_finished(CurlSlot *slot, CURL *curl, CURLcode code, void *userdata) {
+        Context *f = ASSERT_PTR(userdata);
+
+        f->finished = true;
+        f->result = code;
+
+        return sd_event_exit(f->event, 0);
+}
+
+static int make_tmp_url(char **ret_path, char **ret_url, const char *body) {
+        const char *t;
+        ASSERT_OK(tmp_dir(&t));
+
+        _cleanup_(unlink_and_freep) char *path = ASSERT_NOT_NULL(strjoin(t, "/test-curl-util.XXXXXX"));
+
+        _cleanup_close_ int fd = ASSERT_OK(mkostemp_safe(path));
+        ASSERT_OK(loop_write(fd, body, strlen(body)));
+
+        char *url = ASSERT_NOT_NULL(strjoin("file://", path));
+
+        *ret_url = url;
+        *ret_path = TAKE_PTR(path);
+        return 0;
+}
+
+static int build_easy(const char *url, Context *f, CURL **ret) {
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        ASSERT_OK(curl_glue_make(&easy, url));
+
+        ASSERT_CURL_OK(sym_curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_callback));
+        ASSERT_CURL_OK(sym_curl_easy_setopt(easy, CURLOPT_WRITEDATA, f));
+
+        *ret = TAKE_PTR(easy);
+        return 0;
+}
+
+TEST(curl_glue_lifecycle) {
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        ASSERT_OK(sd_event_default(&event));
+
+        _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
+        ASSERT_OK(curl_glue_new(&g, event));
+
+        /* ref/unref roundtrip */
+        ASSERT_PTR_EQ(curl_glue_ref(g), g);
+        ASSERT_NULL(curl_glue_unref(g));
+}
+
+TEST(curl_glue_make) {
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        ASSERT_OK(curl_glue_make(&easy, "file:///dev/null"));
+        ASSERT_NOT_NULL(easy);
+}
+
+TEST(curl_perform_floating) {
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        ASSERT_OK(sd_event_default(&event));
+
+        _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
+        ASSERT_OK(curl_glue_new(&g, event));
+
+        _cleanup_(unlink_and_freep) char *path = NULL;
+        _cleanup_free_ char *url = NULL;
+        ASSERT_OK(make_tmp_url(&path, &url, "hello world"));
+
+        _cleanup_(context_done) Context f = { .event = sd_event_ref(event) };
+
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        ASSERT_OK(build_easy(url, &f, &easy));
+
+        /* Floating: pass NULL for ret_slot. The glue owns the slot until completion. */
+        ASSERT_OK(curl_glue_perform_async(g, easy, on_finished, &f, /* ret_slot= */ NULL));
+        TAKE_PTR(easy);
+
+        ASSERT_OK(sd_event_loop(event));
+
+        ASSERT_TRUE(f.finished);
+        ASSERT_CURL_OK(f.result);
+        ASSERT_STREQ(f.body, "hello world");
+}
+
+TEST(curl_perform_slot) {
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        ASSERT_OK(sd_event_default(&event));
+
+        _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
+        ASSERT_OK(curl_glue_new(&g, event));
+
+        _cleanup_(unlink_and_freep) char *path = NULL;
+        _cleanup_free_ char *url = NULL;
+        ASSERT_OK(make_tmp_url(&path, &url, "slot test"));
+
+        _cleanup_(context_done) Context f = { .event = sd_event_ref(event) };
+
+        _cleanup_(curl_easy_cleanupp) CURL *easy = NULL;
+        ASSERT_OK(build_easy(url, &f, &easy));
+
+        _cleanup_(curl_slot_unrefp) CurlSlot *slot = NULL;
+        ASSERT_OK(curl_glue_perform_async(g, easy, on_finished, &f, &slot));
+        TAKE_PTR(easy);
+
+        ASSERT_NOT_NULL(slot);
+        ASSERT_NOT_NULL(curl_slot_get_easy(slot));
+        ASSERT_PTR_EQ(curl_slot_get_glue(slot), g);
+
+        ASSERT_OK(sd_event_loop(event));
+
+        ASSERT_TRUE(f.finished);
+        ASSERT_CURL_OK(f.result);
+        ASSERT_STREQ(f.body, "slot test");
+
+        /* After completion, disconnect has cleared the slot's back-pointers; the slot itself
+         * is still alive because we hold a ref. Releasing it must be a clean no-op. */
+        ASSERT_NULL(curl_slot_get_easy(slot));
+        ASSERT_NULL(curl_slot_get_glue(slot));
+}
+
+TEST(curl_perform_cancel) {
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        ASSERT_OK(sd_event_default(&event));
+
+        _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
+        ASSERT_OK(curl_glue_new(&g, event));
+
+        _cleanup_(unlink_and_freep) char *path = NULL;
+        _cleanup_free_ char *url = NULL;
+        ASSERT_OK(make_tmp_url(&path, &url, "payload"));
+
+        /* Two requests: cancelled is unref'd before we run the loop; sentinel runs to
+         * completion and exits the loop. After the loop returns we know the dispatcher had
+         * an opportunity to fire any pending completion — so cancelled.finished staying false
+         * means our cancel actually prevented the callback from running, not just outraced it. */
+        _cleanup_(context_done) Context cancelled = { .event = sd_event_ref(event) };
+        _cleanup_(context_done) Context sentinel = { .event = sd_event_ref(event) };
+
+        _cleanup_(curl_easy_cleanupp) CURL *easy_cancelled = NULL, *easy_sentinel = NULL;
+        ASSERT_OK(build_easy(url, &cancelled, &easy_cancelled));
+        ASSERT_OK(build_easy(url, &sentinel, &easy_sentinel));
+
+        _cleanup_(curl_slot_unrefp) CurlSlot *slot = NULL;
+        ASSERT_OK(curl_glue_perform_async(g, easy_cancelled, on_finished, &cancelled, &slot));
+        TAKE_PTR(easy_cancelled);
+
+        /* Cancel by dropping our only reference: removes the easy handle from the multi and
+         * cleans it up. The callback must not fire afterwards. */
+        slot = curl_slot_unref(slot);
+
+        /* The sentinel runs as floating; its callback will exit the loop on completion. */
+        ASSERT_OK(curl_glue_perform_async(g, easy_sentinel, on_finished, &sentinel, /* ret_slot= */ NULL));
+        TAKE_PTR(easy_sentinel);
+
+        ASSERT_OK(sd_event_loop(event));
+
+        ASSERT_TRUE(sentinel.finished);
+        ASSERT_FALSE(cancelled.finished);
+}
+
+typedef struct ConcurrentReq {
+        Context ctx;
+        const char *expected;
+        unsigned *remaining;
+} ConcurrentReq;
+
+static int concurrent_on_finished(CurlSlot *slot, CURL *curl, CURLcode code, void *userdata) {
+        ConcurrentReq *cr = ASSERT_PTR(userdata);
+
+        cr->ctx.finished = true;
+        cr->ctx.result = code;
+
+        (*cr->remaining)--;
+        if (*cr->remaining == 0)
+                return sd_event_exit(cr->ctx.event, 0);
+        return 0;
+}
+
+TEST(curl_concurrent) {
+        _cleanup_(sd_event_unrefp) sd_event *event = NULL;
+        ASSERT_OK(sd_event_default(&event));
+
+        _cleanup_(curl_glue_unrefp) CurlGlue *g = NULL;
+        ASSERT_OK(curl_glue_new(&g, event));
+
+        _cleanup_(unlink_and_freep) char *path_a = NULL, *path_b = NULL, *path_c = NULL;
+        _cleanup_free_ char *url_a = NULL, *url_b = NULL, *url_c = NULL;
+        ASSERT_OK(make_tmp_url(&path_a, &url_a, "alpha"));
+        ASSERT_OK(make_tmp_url(&path_b, &url_b, "bravo"));
+        ASSERT_OK(make_tmp_url(&path_c, &url_c, "charlie"));
+
+        unsigned remaining = 3;
+        ConcurrentReq reqs[3] = {
+                { .ctx = { .event = sd_event_ref(event) }, .expected = "alpha",   .remaining = &remaining },
+                { .ctx = { .event = sd_event_ref(event) }, .expected = "bravo",   .remaining = &remaining },
+                { .ctx = { .event = sd_event_ref(event) }, .expected = "charlie", .remaining = &remaining },
+        };
+
+        _cleanup_(curl_easy_cleanupp) CURL *ea = NULL, *eb = NULL, *ec = NULL;
+        ASSERT_OK(build_easy(url_a, &reqs[0].ctx, &ea));
+        ASSERT_OK(build_easy(url_b, &reqs[1].ctx, &eb));
+        ASSERT_OK(build_easy(url_c, &reqs[2].ctx, &ec));
+
+        /* All three fire as floating slots; the only way the loop exits is through the
+         * remaining-counter hitting zero, which means every callback fired with the right
+         * userdata routed to its respective body. */
+        ASSERT_OK(curl_glue_perform_async(g, ea, concurrent_on_finished, &reqs[0], NULL));
+        TAKE_PTR(ea);
+        ASSERT_OK(curl_glue_perform_async(g, eb, concurrent_on_finished, &reqs[1], NULL));
+        TAKE_PTR(eb);
+        ASSERT_OK(curl_glue_perform_async(g, ec, concurrent_on_finished, &reqs[2], NULL));
+        TAKE_PTR(ec);
+
+        ASSERT_OK(sd_event_loop(event));
+
+        ASSERT_EQ(remaining, 0u);
+
+        FOREACH_ARRAY(r, reqs, ELEMENTSOF(reqs)) {
+                ASSERT_TRUE(r->ctx.finished);
+                ASSERT_CURL_OK(r->ctx.result);
+                ASSERT_STREQ(r->ctx.body, r->expected);
+                context_done(&r->ctx);
+        }
+}
+
+static int intro(void) {
+        if (dlopen_curl(LOG_DEBUG) < 0)
+                return log_tests_skipped("libcurl not available");
+        return EXIT_SUCCESS;
+}
+
+DEFINE_TEST_MAIN_WITH_INTRO(LOG_DEBUG, intro);