From 769cef7921e6e5c0f545d24748f3d27c5d525ded Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 3 Sep 2026 15:21:16 +0200 Subject: [PATCH 1/3] feat(tus): support Upload-Offset Query persisted TUS uploads for their current offset and send only the remaining bytes from file-backed request bodies. https://tus.io/protocols/resumable-upload#upload-offset --- CHANGELOG.md | 1 + include/sentry.h | 17 +- src/path/sentry_path.c | 36 +++ src/sentry_path.h | 7 + src/transports/sentry_http_transport.c | 228 +++++++++++++----- src/transports/sentry_http_transport.h | 4 +- src/transports/sentry_http_transport_curl.c | 11 +- .../sentry_http_transport_winhttp.c | 23 ++ tests/test_integration_tus.py | 55 +++-- tests/unit/test_http_transport.c | 13 + tests/unit/test_tus.c | 22 +- 11 files changed, 331 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7c3b420d..b3b12ac1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Features**: +- Resume interrupted TUS attachment uploads from the server-provided upload offset. - Add `sentry_is_enabled` for checking whether the SDK has been initialized. ([#2045](https://github.com/getsentry/sentry-native/pull/2045)) - Add `sentry_event_set_level` for setting the level of an individual event. ([#2038](https://github.com/getsentry/sentry-native/pull/2038)) diff --git a/include/sentry.h b/include/sentry.h index 962803a7bc..f794ffc20e 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -1100,6 +1100,15 @@ SENTRY_EXPERIMENTAL_API const char *sentry_http_request_get_body( SENTRY_EXPERIMENTAL_API const char *sentry_http_request_get_body_file_path( const sentry_http_request_t *req, size_t *len_out); +/** + * Returns the byte offset at which a file-backed request body starts. The + * client must seek to this position before streaming the number of bytes + * returned by `sentry_http_request_get_body_file_path`. Returns `0` for + * requests without a file-backed body. + */ +SENTRY_EXPERIMENTAL_API size_t sentry_http_request_get_body_file_offset( + const sentry_http_request_t *req); + #ifdef SENTRY_PLATFORM_WINDOWS /** * Wide char version of `sentry_http_request_get_body_file_path`. @@ -1126,10 +1135,10 @@ SENTRY_EXPERIMENTAL_API void sentry_http_response_set_status_code( /** * Records a response header on `resp`. `key` is matched case-insensitively * against the headers sentry-native cares about (currently `Retry-After`, - * `X-Sentry-Rate-Limits`, and `Location`); anything else is ignored. Pass - * every header the HTTP response actually had -- sentry-native, not the - * client, decides which ones matter, so headers Sentry starts caring about - * later don't require client changes. + * `X-Sentry-Rate-Limits`, `Location`, and `Upload-Offset`); anything else is + * ignored. Pass every header the HTTP response actually had -- sentry-native, + * not the client, decides which ones matter, so headers Sentry starts caring + * about later don't require client changes. */ SENTRY_EXPERIMENTAL_API void sentry_http_response_set_header( sentry_http_response_t *resp, const char *key, const char *value); diff --git a/src/path/sentry_path.c b/src/path/sentry_path.c index 2f47e550c9..5aab12c397 100644 --- a/src/path/sentry_path.c +++ b/src/path/sentry_path.c @@ -1,8 +1,10 @@ #include "sentry_path.h" #include "sentry_alloc.h" +#include #include #include +#include sentry_path_t * sentry__path_from_str_n(const char *s, size_t s_len) @@ -153,3 +155,37 @@ sentry__path_basename(const sentry_path_t *path, const char *suffix) } return NULL; } + +FILE * +sentry__path_open(const sentry_path_t *path, const char *mode, size_t offset) +{ + if (!path || !mode) { + return NULL; + } +#ifdef SENTRY_PLATFORM_WINDOWS + wchar_t *mode_w = sentry__string_to_wstr(mode); + FILE *file = mode_w ? _wfopen(path->path_w, mode_w) : NULL; + sentry_free(mode_w); +#else + FILE *file = fopen(path->path, mode); +#endif + if (!file) { + return NULL; + } +#ifdef SENTRY_PLATFORM_WINDOWS + int result + = offset > INT64_MAX ? -1 : _fseeki64(file, (__int64)offset, SEEK_SET); +#elif defined(SENTRY_PLATFORM_NX) + int result = offset > LONG_MAX ? -1 : fseek(file, (long)offset, SEEK_SET); +#else + off_t file_offset = (off_t)offset; + int result = file_offset < 0 || (size_t)file_offset != offset + ? -1 + : fseeko(file, file_offset, SEEK_SET); +#endif + if (result != 0) { + fclose(file); + return NULL; + } + return file; +} diff --git a/src/sentry_path.h b/src/sentry_path.h index afe8247905..7eb6d5715f 100644 --- a/src/sentry_path.h +++ b/src/sentry_path.h @@ -4,6 +4,7 @@ #include "sentry_boot.h" #include "sentry_string.h" +#include #include struct sentry_path_s { @@ -207,6 +208,12 @@ int sentry__path_touch(const sentry_path_t *path); */ size_t sentry__path_get_size(const sentry_path_t *path); +/** + * Opens `path` with `mode` and seeks to `offset`, or returns NULL on failure. + */ +FILE *sentry__path_open( + const sentry_path_t *path, const char *mode, size_t offset); + /** * This will return the last modification time of the file at `path`, or 0 on * failure. diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index 69ee8182cb..4671e931f2 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -280,9 +280,10 @@ prepare_tus_request_common(size_t upload_size, const char *attachment_type, static sentry_prepared_http_request_t * prepare_tus_upload_request(const char *location, const sentry_path_t *path, - size_t file_size, const sentry_dsn_t *dsn, const char *user_agent) + size_t file_size, size_t upload_offset, const sentry_dsn_t *dsn, + const char *user_agent) { - if (!location || !path) { + if (!location || !path || upload_offset > file_size) { return NULL; } @@ -304,7 +305,8 @@ prepare_tus_upload_request(const char *location, const sentry_path_t *path, req->method = "PATCH"; req->url = sentry__string_clone(location); req->body_path = sentry__path_clone(path); - req->body_len = file_size; + req->body_len = file_size - upload_offset; + req->body_offset = upload_offset; sentry_prepared_http_header_t *h; h = &req->headers[req->headers_len++]; @@ -321,7 +323,43 @@ prepare_tus_upload_request(const char *location, const sentry_path_t *path, h = &req->headers[req->headers_len++]; h->key = "upload-offset"; - h->value = sentry__string_clone("0"); + h->value = sentry__uint64_to_string((uint64_t)upload_offset); + + return req; +} + +static sentry_prepared_http_request_t * +prepare_tus_offset_request( + const char *location, const sentry_dsn_t *dsn, const char *user_agent) +{ + if (!location) { + return NULL; + } + + sentry_prepared_http_request_t *req + = SENTRY_MAKE(sentry_prepared_http_request_t); + if (!req) { + return NULL; + } + memset(req, 0, sizeof(*req)); + + req->headers = sentry_malloc(sizeof(sentry_prepared_http_header_t) * 2); + if (!req->headers) { + sentry_free(req); + return NULL; + } + + req->method = "HEAD"; + req->url = sentry__string_clone(location); + + sentry_prepared_http_header_t *h; + h = &req->headers[req->headers_len++]; + h->key = "x-sentry-auth"; + h->value = sentry__dsn_get_auth_header(dsn, user_agent); + + h = &req->headers[req->headers_len++]; + h->key = "tus-resumable"; + h->value = sentry__string_clone("1.0.0"); return req; } @@ -332,6 +370,7 @@ http_response_cleanup(sentry_http_response_t *resp) sentry_free(resp->retry_after); sentry_free(resp->x_sentry_rate_limits); sentry_free(resp->location); + sentry_free(resp->upload_offset); } void @@ -356,6 +395,9 @@ sentry_http_response_set_header( } else if (sentry__string_eq(lower_key, "location")) { sentry_free(resp->location); resp->location = sentry__string_clone(value); + } else if (sentry__string_eq(lower_key, "upload-offset")) { + sentry_free(resp->upload_offset); + resp->upload_offset = sentry__string_clone(value); } sentry_free(lower_key); } @@ -435,6 +477,12 @@ sentry_http_request_get_body_file_path( return req->body_path->path; } +size_t +sentry_http_request_get_body_file_offset(const sentry_http_request_t *req) +{ + return req && req->body_path ? req->body_offset : 0; +} + #ifdef SENTRY_PLATFORM_WINDOWS const wchar_t * sentry_http_request_get_body_file_pathw( @@ -504,16 +552,41 @@ http_update_ratelimiter( http_response_cleanup(resp); } +static bool +tus_parse_upload_offset( + const char *value, size_t file_size, size_t *upload_offset) +{ + if (sentry__string_empty(value) || !upload_offset) { + return false; + } + + size_t offset = 0; + for (const char *p = value; *p; p++) { + if (*p < '0' || *p > '9') { + return false; + } + size_t digit = (size_t)(*p - '0'); + if (offset > file_size / 10 + || (offset == file_size / 10 && digit > file_size % 10)) { + return false; + } + offset = offset * 10 + digit; + } + + *upload_offset = offset; + return true; +} + // Perform a TUS upload for the file at / and put the -// resulting remote location URL (caller frees) in `location_out`. +// resulting remote location into the attachment-ref item. static int tus_upload_file(http_transport_state_t *state, const sentry_path_t *cache_path, - const sentry_attachment_ref_t *ref, char **location_out) + sentry_envelope_item_t *item, const sentry_attachment_ref_t *ref) { if (sentry__string_empty(ref->path)) { return RESULT_ERROR; } - *location_out = NULL; + sentry_path_t *att_file = sentry__path_join_str(cache_path, ref->path); size_t file_size = att_file ? sentry__path_get_size(att_file) : 0; if (!att_file || file_size == 0) { @@ -521,42 +594,89 @@ tus_upload_file(http_transport_state_t *state, const sentry_path_t *cache_path, return RESULT_ERROR; } - // Step 1: TUS creation (POST, no body) - sentry_prepared_http_request_t *req = prepare_tus_request_common( - file_size, ref->attachment_type, state->dsn, state->user_agent); - if (!req) { - sentry__path_free(att_file); - return RESULT_ERROR; - } - + size_t upload_offset = 0; + char *location = NULL; + sentry_prepared_http_request_t *req; sentry_http_response_t resp; - int status_code = http_send_request(state, req, &resp); - sentry__prepared_http_request_free(req); - if (status_code < 0) { - sentry__path_free(att_file); - return status_code; + if (!sentry__string_empty(ref->location)) { + // Step 1a: TUS upload offset (HEAD, no body) + char *upload_url = sentry__dsn_resolve_url(state->dsn, ref->location); + req = prepare_tus_offset_request( + upload_url, state->dsn, state->user_agent); + sentry_free(upload_url); + if (!req) { + sentry__path_free(att_file); + return RESULT_ERROR; + } + + int status_code = http_send_request(state, req, &resp); + sentry__prepared_http_request_free(req); + if (status_code < 0) { + sentry__path_free(att_file); + return status_code; + } + + bool valid_offset = resp.status_code == 200 + && tus_parse_upload_offset( + resp.upload_offset, file_size, &upload_offset); + int status = resp.status_code; + http_response_cleanup(&resp); + if (!valid_offset) { + sentry__path_free(att_file); + return status ? status : RESULT_ERROR; + } + + location = sentry__string_clone(ref->location); + if (!location) { + sentry__path_free(att_file); + return RESULT_ERROR; + } + } else { + // Step 1b: TUS creation (POST, no body) + req = prepare_tus_request_common( + file_size, ref->attachment_type, state->dsn, state->user_agent); + if (!req) { + sentry__path_free(att_file); + return RESULT_ERROR; + } + + int status_code = http_send_request(state, req, &resp); + sentry__prepared_http_request_free(req); + if (status_code < 0) { + sentry__path_free(att_file); + return status_code; + } + + if (resp.status_code != 201 || sentry__string_empty(resp.location)) { + int status = resp.status_code; + sentry__path_free(att_file); + http_response_cleanup(&resp); + return status ? status : RESULT_ERROR; + } + + location = sentry__string_clone(resp.location); + http_response_cleanup(&resp); + if (!location + || !sentry__envelope_item_resolve_attachment_ref(item, location)) { + sentry_free(location); + sentry__path_free(att_file); + return RESULT_ERROR; + } } - if (resp.status_code != 201 || !resp.location) { + if (upload_offset == file_size) { + sentry_free(location); sentry__path_free(att_file); - http_response_cleanup(&resp); - return RESULT_ERROR; + return RESULT_OK; } // Step 2: TUS upload (PATCH with file body). The placeholder needs the // raw `Location` value the TUS endpoint returned (relative path); only the // PATCH itself needs an absolute URL. - char *patch_url = sentry__dsn_resolve_url(state->dsn, resp.location); - char *location = sentry__string_clone(resp.location); - http_response_cleanup(&resp); - if (!location) { - sentry_free(patch_url); - sentry__path_free(att_file); - return RESULT_ERROR; - } - req = prepare_tus_upload_request( - patch_url, att_file, file_size, state->dsn, state->user_agent); + char *patch_url = sentry__dsn_resolve_url(state->dsn, location); + req = prepare_tus_upload_request(patch_url, att_file, file_size, + upload_offset, state->dsn, state->user_agent); sentry_free(patch_url); sentry__path_free(att_file); if (!req) { @@ -564,7 +684,7 @@ tus_upload_file(http_transport_state_t *state, const sentry_path_t *cache_path, return RESULT_ERROR; } - status_code = http_send_request(state, req, &resp); + int status_code = http_send_request(state, req, &resp); sentry__prepared_http_request_free(req); if (status_code < 0) { sentry_free(location); @@ -576,11 +696,11 @@ tus_upload_file(http_transport_state_t *state, const sentry_path_t *cache_path, if (status != 204) { sentry_free(location); - return RESULT_ERROR; + return status ? status : RESULT_ERROR; } - *location_out = location; - return status; + sentry_free(location); + return RESULT_OK; } // Collect the non-NULL `path` values from every attachment-ref item in the @@ -667,9 +787,9 @@ prune_attachment_refs(const sentry_run_t *run, sentry_value_t paths, } } -// Walk attachment-ref items: for each one with `path` and no `location`, try -// TUS upload and set `location`. If TUS is unavailable or fails for a given -// item, drop it and send the event without the large attachment. +// Walk attachment-ref items: upload new ones and resume previously interrupted +// ones. HTTP errors drop the affected item; recoverable network errors keep the +// envelope intact for transport retry, caching, or shutdown persistence. static int resolve_attachment_refs( http_transport_state_t *state, sentry_envelope_t *envelope) @@ -700,7 +820,8 @@ resolve_attachment_refs( continue; } - if (!sentry__string_empty(ref.location)) { + if (!sentry__string_empty(ref.location) + && sentry__string_empty(ref.path)) { // Crash-resume: TUS already done on a prior attempt. Nothing to do. sentry__attachment_ref_cleanup(&ref); i++; @@ -714,22 +835,15 @@ resolve_attachment_refs( continue; } - char *new_location = NULL; - int result = tus_upload_file(state, cache_path, &ref, &new_location); - if (new_location) { - bool resolved = sentry__envelope_item_resolve_attachment_ref( - item, new_location); - sentry_free(new_location); - if (resolved) { - sentry__attachment_ref_cleanup(&ref); - i++; - continue; - } + int result = tus_upload_file(state, cache_path, item, &ref); + if (result == RESULT_OK) { sentry__attachment_ref_cleanup(&ref); - return RESULT_ERROR; + i++; + continue; } - if (result == RESULT_SHUTDOWN) { + if (result == RESULT_SHUTDOWN + || (result < 0 && (state->retry || state->cache_keep))) { sentry__attachment_ref_cleanup(&ref); return result; } @@ -1149,9 +1263,9 @@ sentry__prepare_tus_create_request(size_t file_size, sentry_prepared_http_request_t * sentry__prepare_tus_upload_request(const char *location, - const sentry_path_t *path, size_t file_size, const sentry_dsn_t *dsn, - const char *user_agent) + const sentry_path_t *path, size_t file_size, size_t upload_offset, + const sentry_dsn_t *dsn, const char *user_agent) { return prepare_tus_upload_request( - location, path, file_size, dsn, user_agent); + location, path, file_size, upload_offset, dsn, user_agent); } diff --git a/src/transports/sentry_http_transport.h b/src/transports/sentry_http_transport.h index 135a7a584f..2c1d8e7b49 100644 --- a/src/transports/sentry_http_transport.h +++ b/src/transports/sentry_http_transport.h @@ -19,6 +19,7 @@ typedef struct sentry_http_request_s { size_t headers_len; char *body; size_t body_len; + size_t body_offset; bool body_owned; sentry_path_t *body_path; } sentry_prepared_http_request_t; @@ -31,7 +32,7 @@ sentry_prepared_http_request_t *sentry__prepare_tus_create_request( const char *user_agent); sentry_prepared_http_request_t *sentry__prepare_tus_upload_request( const char *location, const sentry_path_t *path, size_t file_size, - const sentry_dsn_t *dsn, const char *user_agent); + size_t upload_offset, const sentry_dsn_t *dsn, const char *user_agent); void sentry__prepared_http_request_free(sentry_prepared_http_request_t *req); @@ -40,6 +41,7 @@ struct sentry_http_response_s { char *retry_after; char *x_sentry_rate_limits; char *location; + char *upload_offset; }; /** diff --git a/src/transports/sentry_http_transport_curl.c b/src/transports/sentry_http_transport_curl.c index 32296facb8..dddeb5bb1b 100644 --- a/src/transports/sentry_http_transport_curl.c +++ b/src/transports/sentry_http_transport_curl.c @@ -470,14 +470,11 @@ curl_send_task(void *_client, sentry_prepared_http_request_t *req, FILE *body_file = NULL; file_body_t file_body = { 0 }; if (req->body_path) { -#ifdef SENTRY_PLATFORM_WINDOWS - body_file = _wfopen(req->body_path->path_w, L"rb"); -#else - body_file = fopen(req->body_path->path, "rb"); -#endif + body_file = sentry__path_open(req->body_path, "rb", req->body_offset); if (!body_file) { - SENTRY_WARNF("failed to open request body file \"%s\"", - sentry__path_filename(req->body_path)); + SENTRY_WARNF("failed to open request body file \"%s\" at offset " + "%zu", + sentry__path_filename(req->body_path), req->body_offset); g_curl.slist_free_all(headers); return false; } diff --git a/src/transports/sentry_http_transport_winhttp.c b/src/transports/sentry_http_transport_winhttp.c index cc9db472d2..3992ae024b 100644 --- a/src/transports/sentry_http_transport_winhttp.c +++ b/src/transports/sentry_http_transport_winhttp.c @@ -281,6 +281,22 @@ winhttp_send_task(void *_client, sentry_prepared_http_request_t *req, goto exit; } + LARGE_INTEGER file_offset; + if (req->body_offset > INT64_MAX) { + SENTRY_WARNF("failed to seek request body file \"%s\"", + sentry__path_filename(req->body_path)); + CloseHandle(hFile); + goto exit; + } + file_offset.QuadPart = (LONGLONG)req->body_offset; + if (!SetFilePointerEx(hFile, file_offset, NULL, FILE_BEGIN)) { + SENTRY_WARNF( + "failed to seek request body file \"%s\" with code `%d`", + sentry__path_filename(req->body_path), GetLastError()); + CloseHandle(hFile); + goto exit; + } + // https://learn.microsoft.com/en-us/windows/win32/api/winhttp/nf-winhttp-winhttpsendrequest#support-for-greater-than-4-gb-upload DWORD total_length = req->body_len > (size_t)(DWORD)-1 ? WINHTTP_IGNORE_REQUEST_TOTAL_LENGTH @@ -394,6 +410,13 @@ winhttp_send_task(void *_client, sentry_prepared_http_request_t *req, sentry_http_response_set_header(resp, "location", location); sentry_free(location); } + + char *upload_offset = query_header(client->request, L"upload-offset"); + if (upload_offset) { + sentry_http_response_set_header( + resp, "upload-offset", upload_offset); + sentry_free(upload_offset); + } } uint64_t now = sentry__monotonic_time(); diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index a0480fba8a..235a9f21c4 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -251,7 +251,7 @@ def test_tus_rate_limit(cmake, httpserver): assert leftovers == [] -def test_tus_shutdown(cmake, httpserver): +def test_tus_resume_after_shutdown(cmake, httpserver): tmp_path = cmake( ["sentry_example"], {"SENTRY_BACKEND": "none"}, @@ -262,17 +262,26 @@ def test_tus_shutdown(cmake, httpserver): upload_uri = "/api/123456/upload/abc123def456789/" upload_qs = "length=104857600&signature=xyz" location = httpserver.url_for(upload_uri) + "?" + upload_qs - - release_create = threading.Event() - - def delayed_create(_req): - release_create.wait() - return Response("OK", status=201, headers={"Location": location}) + upload_size = 100 * 1024 * 1024 + upload_offset = upload_size // 2 httpserver.expect_oneshot_request( "/api/123456/upload/", headers={"tus-resumable": "1.0.0"}, - ).respond_with_handler(delayed_create) + ).respond_with_data("OK", status=201, headers={"Location": location}) + + release_upload = threading.Event() + + def delayed_upload(_req): + release_upload.wait() + return Response("", status=204, headers={"Upload-Offset": str(upload_offset)}) + + httpserver.expect_oneshot_request( + upload_uri, + method="PATCH", + headers={"tus-resumable": "1.0.0", "upload-offset": "0"}, + query_string=upload_qs, + ).respond_with_handler(delayed_upload) with httpserver.wait(timeout=10): try: @@ -290,7 +299,7 @@ def delayed_create(_req): timeout=30, ) finally: - release_create.set() + release_upload.set() db_dir = os.path.join(tmp_path, ".sentry-native") cache_dir = os.path.join(db_dir, "cache") @@ -306,22 +315,27 @@ def delayed_create(_req): base = envelope_files[0][: -len(".envelope")] siblings = [f for f in os.listdir(cache_dir) if f.startswith(base)] assert len(siblings) > 0 + resume_marker = b"resumed-body" + with open(os.path.join(cache_dir, siblings[0]), "r+b") as attachment: + attachment.seek(upload_offset) + attachment.write(resume_marker) httpserver.clear_all_handlers() httpserver.expect_oneshot_request( - "/api/123456/upload/", + upload_uri, + method="HEAD", headers={"tus-resumable": "1.0.0"}, - ).respond_with_data( - "OK", - status=201, - headers={"Location": location}, - ) + query_string=upload_qs, + ).respond_with_data("", status=200, headers={"Upload-Offset": str(upload_offset)}) httpserver.expect_oneshot_request( upload_uri, method="PATCH", - headers={"tus-resumable": "1.0.0"}, + headers={ + "tus-resumable": "1.0.0", + "upload-offset": str(upload_offset), + }, query_string=upload_qs, ).respond_with_data("", status=204) @@ -357,6 +371,15 @@ def delayed_create(_req): assert attachment_ref is not None assert attachment_ref.payload.json["location"] == location + upload_reqs = [ + req + for req, _resp in httpserver.log + if req.path == upload_uri and req.method == "PATCH" + ] + assert len(upload_reqs) == 2 + assert int(upload_reqs[-1].headers["content-length"]) == upload_size - upload_offset + assert upload_reqs[-1].get_data().startswith(resume_marker) + leftover_siblings = [f for f in os.listdir(cache_dir) if f.startswith(base)] assert leftover_siblings == [] diff --git a/tests/unit/test_http_transport.c b/tests/unit/test_http_transport.c index b3a3552b32..6068e5931b 100644 --- a/tests/unit/test_http_transport.c +++ b/tests/unit/test_http_transport.c @@ -18,14 +18,17 @@ SENTRY_TEST(http_response_set_header_sets_known_headers) sentry_http_response_set_header( &resp, "x-sentry-rate-limits", "60:error:key"); sentry_http_response_set_header(&resp, "location", "/uploads/1"); + sentry_http_response_set_header(&resp, "upload-offset", "42"); TEST_CHECK_STRING_EQUAL(resp.retry_after, "60"); TEST_CHECK_STRING_EQUAL(resp.x_sentry_rate_limits, "60:error:key"); TEST_CHECK_STRING_EQUAL(resp.location, "/uploads/1"); + TEST_CHECK_STRING_EQUAL(resp.upload_offset, "42"); sentry_free(resp.retry_after); sentry_free(resp.x_sentry_rate_limits); sentry_free(resp.location); + sentry_free(resp.upload_offset); } SENTRY_TEST(http_response_set_header_is_case_insensitive) @@ -37,14 +40,17 @@ SENTRY_TEST(http_response_set_header_is_case_insensitive) sentry_http_response_set_header( &resp, "X-SENTRY-RATE-LIMITS", "30:error:key"); sentry_http_response_set_header(&resp, "Location", "/uploads/2"); + sentry_http_response_set_header(&resp, "Upload-Offset", "84"); TEST_CHECK_STRING_EQUAL(resp.retry_after, "30"); TEST_CHECK_STRING_EQUAL(resp.x_sentry_rate_limits, "30:error:key"); TEST_CHECK_STRING_EQUAL(resp.location, "/uploads/2"); + TEST_CHECK_STRING_EQUAL(resp.upload_offset, "84"); sentry_free(resp.retry_after); sentry_free(resp.x_sentry_rate_limits); sentry_free(resp.location); + sentry_free(resp.upload_offset); } SENTRY_TEST(http_response_set_header_ignores_unknown_headers) @@ -58,6 +64,7 @@ SENTRY_TEST(http_response_set_header_ignores_unknown_headers) TEST_CHECK(resp.retry_after == NULL); TEST_CHECK(resp.x_sentry_rate_limits == NULL); TEST_CHECK(resp.location == NULL); + TEST_CHECK(resp.upload_offset == NULL); } SENTRY_TEST(http_response_set_header_overwrites_previous_value) @@ -165,6 +172,7 @@ SENTRY_TEST(http_request_accessors_file_backed_body) req->headers_len = 0; req->body_path = sentry__path_from_str("/tmp/does-not-need-to-exist"); req->body_len = 100 * 1024 * 1024; + req->body_offset = 42; size_t len = 0; TEST_CHECK(sentry_http_request_get_body(req, &len) == NULL); @@ -174,6 +182,8 @@ SENTRY_TEST(http_request_accessors_file_backed_body) const char *path = sentry_http_request_get_body_file_path(req, &len); TEST_CHECK_STRING_EQUAL(path, "/tmp/does-not-need-to-exist"); TEST_CHECK_INT_EQUAL((int)len, 100 * 1024 * 1024); + TEST_CHECK_INT_EQUAL( + (int)sentry_http_request_get_body_file_offset(req), 42); #ifdef SENTRY_PLATFORM_WINDOWS // The wide variant must stay in sync with the narrow one, since that is @@ -210,6 +220,7 @@ SENTRY_TEST(http_request_accessors_bodyless_request) len = 123; TEST_CHECK(sentry_http_request_get_body_file_path(req, &len) == NULL); TEST_CHECK_INT_EQUAL((int)len, 0); + TEST_CHECK_INT_EQUAL((int)sentry_http_request_get_body_file_offset(req), 0); sentry__prepared_http_request_free(req); } @@ -223,6 +234,8 @@ SENTRY_TEST(http_request_accessors_null_safety) size_t len = 0; TEST_CHECK(sentry_http_request_get_body(NULL, &len) == NULL); TEST_CHECK(sentry_http_request_get_body_file_path(NULL, &len) == NULL); + TEST_CHECK_INT_EQUAL( + (int)sentry_http_request_get_body_file_offset(NULL), 0); } static void * diff --git a/tests/unit/test_tus.c b/tests/unit/test_tus.c index 27b89cbde0..f56b962335 100644 --- a/tests/unit/test_tus.c +++ b/tests/unit/test_tus.c @@ -96,7 +96,7 @@ SENTRY_TEST(tus_request_preparation) const char *location = "https://sentry.invalid/api/42/upload/abc123/"; req = sentry__prepare_tus_upload_request( - location, test_file_path, 9, dsn, NULL); + location, test_file_path, 9, 0, dsn, NULL); TEST_CHECK(!!req); TEST_CHECK_STRING_EQUAL(req->method, "PATCH"); TEST_CHECK_STRING_EQUAL(req->url, location); @@ -127,6 +127,26 @@ SENTRY_TEST(tus_request_preparation) TEST_CHECK(has_upload_offset); sentry__prepared_http_request_free(req); + + // Test a resumed upload starts at the requested file offset. + req = sentry__prepare_tus_upload_request( + location, test_file_path, 9, 4, dsn, NULL); + TEST_CHECK(!!req); + TEST_CHECK_INT_EQUAL(req->body_offset, 4); + TEST_CHECK_INT_EQUAL(req->body_len, 5); + has_upload_offset = false; + for (size_t i = 0; i < req->headers_len; i++) { + if (strcmp(req->headers[i].key, "upload-offset") == 0) { + TEST_CHECK_STRING_EQUAL(req->headers[i].value, "4"); + has_upload_offset = true; + } + } + TEST_CHECK(has_upload_offset); + sentry__prepared_http_request_free(req); + + TEST_CHECK(!sentry__prepare_tus_upload_request( + location, test_file_path, 9, 10, dsn, NULL)); + sentry__path_remove(test_file_path); sentry__path_free(test_file_path); sentry__dsn_decref(dsn); From 881ab09ae22b88d5b42b21cb0c656baf238f8a90 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 3 Sep 2026 15:40:12 +0200 Subject: [PATCH 2/3] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b12ac1cb..965d29ec38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Features**: -- Resume interrupted TUS attachment uploads from the server-provided upload offset. +- Resume interrupted TUS attachment uploads from the server-provided upload offset. ([#2058](https://github.com/getsentry/sentry-native/pull/2058)) - Add `sentry_is_enabled` for checking whether the SDK has been initialized. ([#2045](https://github.com/getsentry/sentry-native/pull/2045)) - Add `sentry_event_set_level` for setting the level of an individual event. ([#2038](https://github.com/getsentry/sentry-native/pull/2038)) From 77817ab73f6b311bceadb0f16d0bfd0c92ff2997 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 3 Sep 2026 15:42:33 +0200 Subject: [PATCH 3/3] 204 --- src/transports/sentry_http_transport.c | 2 +- tests/test_integration_tus.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index 4671e931f2..087dc4138a 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -617,7 +617,7 @@ tus_upload_file(http_transport_state_t *state, const sentry_path_t *cache_path, return status_code; } - bool valid_offset = resp.status_code == 200 + bool valid_offset = (resp.status_code == 200 || resp.status_code == 204) && tus_parse_upload_offset( resp.upload_offset, file_size, &upload_offset); int status = resp.status_code; diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 235a9f21c4..0f23b179ea 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -327,7 +327,7 @@ def delayed_upload(_req): method="HEAD", headers={"tus-resumable": "1.0.0"}, query_string=upload_qs, - ).respond_with_data("", status=200, headers={"Upload-Offset": str(upload_offset)}) + ).respond_with_data("", status=204, headers={"Upload-Offset": str(upload_offset)}) httpserver.expect_oneshot_request( upload_uri,