diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad01ff29..e23b571e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## Unreleased: +## Unreleased + +**Features**: + +- Add HTTP retry with exponential backoff. ([#1520](https://github.com/getsentry/sentry-native/pull/1520)) **Fixes**: diff --git a/CMakeLists.txt b/CMakeLists.txt index 1799e7446..5a3ee3c3c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -420,6 +420,14 @@ endif() include(CheckTypeSize) check_type_size("long" CMAKE_SIZEOF_LONG) +if(LINUX) + include(CheckSymbolExists) + check_symbol_exists(copy_file_range "unistd.h" HAVE_COPY_FILE_RANGE) + if(HAVE_COPY_FILE_RANGE) + target_compile_definitions(sentry PRIVATE SENTRY_HAVE_COPY_FILE_RANGE) + endif() +endif() + # https://gitlab.kitware.com/cmake/cmake/issues/18393 if(SENTRY_BUILD_SHARED_LIBS) if(APPLE) diff --git a/examples/example.c b/examples/example.c index 8fc7db7b3..1988e8351 100644 --- a/examples/example.c +++ b/examples/example.c @@ -524,6 +524,25 @@ main(int argc, char **argv) sentry_options_add_attachment(options, "./CMakeCache.txt"); } + if (has_arg(argc, argv, "large-attachment")) { + const char *large_file = ".sentry-large-attachment"; + FILE *f = fopen(large_file, "wb"); + if (f) { + // 100 MB = TUS upload threshold + char zeros[4096]; + memset(zeros, 0, sizeof(zeros)); + size_t remaining = 100 * 1024 * 1024; + while (remaining > 0) { + size_t chunk + = remaining < sizeof(zeros) ? remaining : sizeof(zeros); + fwrite(zeros, 1, chunk, f); + remaining -= chunk; + } + fclose(f); + sentry_options_add_attachment(options, large_file); + } + } + if (has_arg(argc, argv, "stdout")) { sentry_options_set_transport( options, sentry_transport_new(print_envelope)); @@ -659,6 +678,9 @@ main(int argc, char **argv) sentry_options_set_cache_max_age(options, 5 * 24 * 60 * 60); // 5 days sentry_options_set_cache_max_items(options, 5); } + if (has_arg(argc, argv, "no-http-retry")) { + sentry_options_set_http_retry(options, false); + } if (has_arg(argc, argv, "enable-metrics")) { sentry_options_set_enable_metrics(options, true); @@ -940,6 +962,10 @@ main(int argc, char **argv) sentry_reinstall_backend(); } + if (has_arg(argc, argv, "flush")) { + sentry_flush(10000); + } + if (has_arg(argc, argv, "sleep")) { sleep_s(10); } diff --git a/include/sentry.h b/include/sentry.h index 25b472aaa..f20c8848d 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -946,6 +946,24 @@ SENTRY_API void sentry_transport_set_shutdown_func( sentry_transport_t *transport, int (*shutdown_func)(uint64_t timeout, void *state)); +/** + * Retries sending all pending envelopes in the transport's retry queue, + * e.g. when coming back online. Only applicable for HTTP transports. + * + * Note: The SDK automatically retries failed envelopes on next application + * startup. This function allows manual triggering of pending retries at + * runtime. Each envelope is retried up to 6 times. If all attempts are + * exhausted during intermittent connectivity, events will be discarded + * (or moved to cache if enabled via sentry_options_set_cache_keep). + * + * Warning: This function has no rate limiting - it will immediately + * attempt to send all pending envelopes. Calling this repeatedly during + * extended network outages may exhaust retry attempts that might have + * succeeded with the SDK's built-in exponential backoff. + */ +SENTRY_EXPERIMENTAL_API void sentry_transport_retry( + sentry_transport_t *transport); + /** * Generic way to free transport. */ @@ -1476,12 +1494,13 @@ SENTRY_API int sentry_options_get_symbolize_stacktraces( const sentry_options_t *opts); /** - * Enables or disables storing envelopes in a persistent cache. + * Enables or disables storing failed envelopes in a persistent cache. + * + * When enabled, envelopes that fail to send are written to a `cache/` + * subdirectory within the database directory. The cache is cleared on startup + * based on the cache_max_items, cache_max_size, and cache_max_age options. * - * When enabled, envelopes are written to a `cache/` subdirectory within the - * database directory and retained regardless of send success or failure. - * The cache is cleared on startup based on the cache_max_items, cache_max_size, - * and cache_max_age options. + * Only applicable for HTTP transports. * * Disabled by default. */ @@ -2258,6 +2277,18 @@ SENTRY_EXPERIMENTAL_API void sentry_options_set_enable_logs( SENTRY_EXPERIMENTAL_API int sentry_options_get_enable_logs( const sentry_options_t *opts); +/** + * Enables or disables HTTP retry with exponential backoff for network failures. + * + * Only applicable for HTTP transports. + * + * Enabled by default. + */ +SENTRY_EXPERIMENTAL_API void sentry_options_set_http_retry( + sentry_options_t *opts, int enabled); +SENTRY_EXPERIMENTAL_API int sentry_options_get_http_retry( + const sentry_options_t *opts); + /** * Enables or disables custom attributes parsing for structured logging. * diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 218a9b012..326fd3ceb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -34,6 +34,8 @@ sentry_target_sources_cwd(sentry sentry_process.h sentry_ratelimiter.c sentry_ratelimiter.h + sentry_retry.c + sentry_retry.h sentry_ringbuffer.c sentry_ringbuffer.h sentry_sampling_context.h diff --git a/src/backends/sentry_backend_breakpad.cpp b/src/backends/sentry_backend_breakpad.cpp index 537f58b3c..a894cab42 100644 --- a/src/backends/sentry_backend_breakpad.cpp +++ b/src/backends/sentry_backend_breakpad.cpp @@ -178,6 +178,16 @@ breakpad_backend_callback(const google_breakpad::MinidumpDescriptor &descriptor, sentry__envelope_item_set_header(item, "filename", sentry_value_new_string(sentry__path_filename(dump_path))); + } else { + sentry_uuid_t event_id + = sentry__envelope_get_event_id(envelope); + sentry_attachment_t tmp; + memset(&tmp, 0, sizeof(tmp)); + tmp.path = dump_path; + tmp.type = MINIDUMP; + tmp.next = nullptr; + sentry__cache_external_attachments( + options->run->cache_path, &event_id, &tmp, nullptr); } if (options->attach_screenshot) { diff --git a/src/backends/sentry_backend_crashpad.cpp b/src/backends/sentry_backend_crashpad.cpp index 9815bdb42..29b90662d 100644 --- a/src/backends/sentry_backend_crashpad.cpp +++ b/src/backends/sentry_backend_crashpad.cpp @@ -529,6 +529,11 @@ report_to_envelope(const crashpad::CrashReportDatabase::Report &report, if (sentry__envelope_add_event(envelope, event)) { sentry__envelope_add_attachments(envelope, attachments); + sentry_uuid_t event_id = sentry__envelope_get_event_id(envelope); + if (options->run) { + sentry__cache_external_attachments(options->run->cache_path, + &event_id, attachments, options->run->run_path); + } } else { sentry_value_decref(event); sentry_envelope_free(envelope); @@ -565,11 +570,9 @@ process_completed_reports( SENTRY_DEBUGF("caching %zu completed reports", reports.size()); - sentry_path_t *cache_dir - = sentry__path_join_str(options->database_path, "cache"); - if (!cache_dir || sentry__path_create_dir_all(cache_dir) != 0) { + sentry_path_t *cache_dir = options->run->cache_path; + if (sentry__path_create_dir_all(cache_dir) != 0) { SENTRY_WARN("failed to create cache dir"); - sentry__path_free(cache_dir); return; } @@ -593,8 +596,6 @@ process_completed_reports( sentry__path_free(out_path); sentry_envelope_free(envelope); } - - sentry__path_free(cache_dir); } static int diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 53aa0af54..650244df5 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1156,8 +1156,9 @@ process_ucontext_deferred(const sentry_ucontext_t *uctx, } TEST_CRASH_POINT("before_capture"); if (should_handle) { + sentry_uuid_t event_id; sentry_envelope_t *envelope = sentry__prepare_event(options, event, - NULL, !options->on_crash_func && !skip_hooks, NULL); + &event_id, !options->on_crash_func && !skip_hooks, NULL); // TODO(tracing): Revisit when investigating transaction flushing // during hard crashes. diff --git a/src/path/sentry_path.c b/src/path/sentry_path.c index 4bdbe6234..3ef291745 100644 --- a/src/path/sentry_path.c +++ b/src/path/sentry_path.c @@ -79,6 +79,19 @@ sentry__path_remove_all(const sentry_path_t *path) return sentry__path_remove(path); } +size_t +sentry__path_get_dir_size(const sentry_path_t *path) +{ + size_t total = 0; + sentry_pathiter_t *iter = sentry__path_iter_directory(path); + const sentry_path_t *entry; + while (iter && (entry = sentry__pathiter_next(iter)) != NULL) { + total += sentry__path_get_size(entry); + } + sentry__pathiter_free(iter); + return total; +} + sentry_filelock_t * sentry__filelock_new(sentry_path_t *path) { diff --git a/src/path/sentry_path_unix.c b/src/path/sentry_path_unix.c index 4b1069f1b..752025bf1 100644 --- a/src/path/sentry_path_unix.c +++ b/src/path/sentry_path_unix.c @@ -21,6 +21,7 @@ #include #ifdef SENTRY_PLATFORM_DARWIN +# include # include #endif @@ -333,6 +334,71 @@ sentry__path_rename(const sentry_path_t *src, const sentry_path_t *dst) return status == 0 ? 0 : 1; } +int +sentry__path_copy(const sentry_path_t *src, const sentry_path_t *dst) +{ +#ifdef SENTRY_PLATFORM_DARWIN + return copyfile(src->path, dst->path, NULL, COPYFILE_DATA) == 0 ? 0 : 1; +#else + int src_fd = open(src->path, O_RDONLY); + if (src_fd < 0) { + return 1; + } + int dst_fd = open(dst->path, O_WRONLY | O_CREAT | O_TRUNC, + S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH); + if (dst_fd < 0) { + close(src_fd); + return 1; + } + + int rv = 0; + +# ifdef SENTRY_HAVE_COPY_FILE_RANGE + while (true) { + ssize_t n + = copy_file_range(src_fd, NULL, dst_fd, NULL, SIZE_MAX / 2, 0); + if (n > 0) { + continue; + } else if (n == 0) { + goto done; + } else if (errno == EAGAIN || errno == EINTR) { + continue; + } else if (errno == ENOSYS || errno == EXDEV || errno == EOPNOTSUPP + || errno == EINVAL) { + break; + } else { + rv = 1; + goto done; + } + } +# endif + + { + char buf[16384]; + while (true) { + ssize_t n = read(src_fd, buf, sizeof(buf)); + if (n < 0 && (errno == EAGAIN || errno == EINTR)) { + continue; + } else if (n <= 0) { + rv = n < 0; + break; + } + if (write_loop(dst_fd, buf, (size_t)n) != 0) { + rv = 1; + break; + } + } + } + +# ifdef SENTRY_HAVE_COPY_FILE_RANGE +done: +# endif + close(src_fd); + close(dst_fd); + return rv; +#endif +} + int sentry__path_create_dir_all(const sentry_path_t *path) { diff --git a/src/path/sentry_path_windows.c b/src/path/sentry_path_windows.c index 7f49e082a..5ccda3301 100644 --- a/src/path/sentry_path_windows.c +++ b/src/path/sentry_path_windows.c @@ -524,6 +524,15 @@ sentry__path_rename(const sentry_path_t *src, const sentry_path_t *dst) return MoveFileExW(src_w, dst_w, MOVEFILE_REPLACE_EXISTING) ? 0 : 1; } +int +sentry__path_copy(const sentry_path_t *src, const sentry_path_t *dst) +{ + if (!src->path_w || !dst->path_w) { + return 1; + } + return CopyFileW(src->path_w, dst->path_w, FALSE) ? 0 : 1; +} + int sentry__path_create_dir_all(const sentry_path_t *path) { diff --git a/src/sentry_attachment.c b/src/sentry_attachment.c index 4873d0a8d..68231c1d1 100644 --- a/src/sentry_attachment.c +++ b/src/sentry_attachment.c @@ -258,3 +258,32 @@ sentry__attachments_extend( attachments_ptr, attachment_clone(it), it->type, it->content_type); } } + +size_t +sentry__attachment_get_size(const sentry_attachment_t *attachment) +{ + return attachment->buf ? attachment->buf_len + : sentry__path_get_size(attachment->path); +} + +const char * +sentry__attachment_get_filename(const sentry_attachment_t *attachment) +{ + return sentry__path_filename( + attachment->filename ? attachment->filename : attachment->path); +} + +const char * +sentry__attachment_type_to_string(sentry_attachment_type_t attachment_type) +{ + switch (attachment_type) { + case ATTACHMENT: + return "event.attachment"; + case MINIDUMP: + return "event.minidump"; + case VIEW_HIERARCHY: + return "event.view_hierarchy"; + default: + return "event.attachment"; + } +} diff --git a/src/sentry_attachment.h b/src/sentry_attachment.h index 553a33bed..a211573e9 100644 --- a/src/sentry_attachment.h +++ b/src/sentry_attachment.h @@ -5,6 +5,8 @@ #include "sentry_path.h" +#define SENTRY_LARGE_ATTACHMENT_SIZE (100 * 1024 * 1024) // 100 MB + /** * The attachment_type. */ @@ -40,6 +42,24 @@ struct sentry_attachment_s { sentry_attachment_t *next; // Linked list pointer }; +/** + * Returns the size in bytes of the attachment's data (buffer length or file + * size). + */ +size_t sentry__attachment_get_size(const sentry_attachment_t *attachment); + +/** + * Returns true if the attachment should be cached as an external file. + * Minidumps are always external; other attachments are external when they + * exceed the large attachment size threshold. + */ +static inline bool +sentry__attachment_is_external(const sentry_attachment_t *att) +{ + return att->type == MINIDUMP + || sentry__attachment_get_size(att) >= SENTRY_LARGE_ATTACHMENT_SIZE; +} + /** * Creates a new file attachment. Takes ownership of `path`. */ @@ -89,4 +109,18 @@ void sentry__attachments_remove( void sentry__attachments_extend( sentry_attachment_t **attachments_ptr, sentry_attachment_t *attachments); +/** + * Returns the filename string for the attachment (basename of `filename` if + * set, otherwise basename of `path`). + */ +const char *sentry__attachment_get_filename( + const sentry_attachment_t *attachment); + +/** + * Returns the Sentry envelope attachment_type string for the given type, + * e.g. "event.attachment", "event.minidump", "event.view_hierarchy". + */ +const char *sentry__attachment_type_to_string( + sentry_attachment_type_t attachment_type); + #endif diff --git a/src/sentry_core.c b/src/sentry_core.c index 3b4b38bfb..d3996bb07 100644 --- a/src/sentry_core.c +++ b/src/sentry_core.c @@ -292,8 +292,10 @@ sentry_init(sentry_options_t *options) backend->prune_database_func(backend); } - if (options->cache_keep) { - sentry__cleanup_cache(options); + if (options->cache_keep || options->http_retry) { + if (!sentry__transport_submit_cleanup(options->transport, options)) { + sentry__cleanup_cache(options); + } } if (options->auto_session_tracking) { @@ -725,12 +727,12 @@ sentry__prepare_event(const sentry_options_t *options, sentry_value_t event, } SENTRY_WITH_SCOPE (scope) { - if (all_attachments) { - // all attachments merged from multiple scopes - sentry__envelope_add_attachments(envelope, all_attachments); - } else { - // only global scope has attachments - sentry__envelope_add_attachments(envelope, scope->attachments); + const sentry_attachment_t *atts + = all_attachments ? all_attachments : scope->attachments; + sentry__envelope_add_attachments(envelope, atts); + if (options->run) { + sentry__cache_external_attachments(options->run->cache_path, + event_id, atts, options->run->run_path); } } @@ -817,6 +819,13 @@ prepare_user_feedback(sentry_value_t user_feedback, sentry_hint_t *hint) if (hint && hint->attachments) { sentry__envelope_add_attachments(envelope, hint->attachments); + sentry_uuid_t event_id = sentry__envelope_get_event_id(envelope); + SENTRY_WITH_OPTIONS (options) { + if (options->run) { + sentry__cache_external_attachments(options->run->cache_path, + &event_id, hint->attachments, options->run->run_path); + } + } } return envelope; @@ -1751,18 +1760,30 @@ sentry_capture_minidump_n(const char *path, size_t path_len) } else { // the minidump is added as an attachment, with the type // `event.minidump` + size_t dump_size = sentry__path_get_size(dump_path); + bool is_large = dump_size >= SENTRY_LARGE_ATTACHMENT_SIZE; sentry_envelope_item_t *item = sentry__envelope_add_from_path( envelope, dump_path, "attachment"); - if (!item) { - sentry_envelope_free(envelope); - } else { + if (item) { sentry__envelope_item_set_header(item, "attachment_type", sentry_value_new_string("event.minidump")); - sentry__envelope_item_set_header(item, "filename", sentry_value_new_string(sentry__path_filename(dump_path))); + } else if (is_large && options->run) { + sentry_attachment_t tmp; + memset(&tmp, 0, sizeof(tmp)); + tmp.path = dump_path; + tmp.type = MINIDUMP; + tmp.next = NULL; + sentry__cache_external_attachments( + options->run->cache_path, &event_id, &tmp, NULL); + } else { + sentry_envelope_free(envelope); + envelope = NULL; + } + if (envelope) { sentry__capture_envelope(options->transport, envelope); SENTRY_INFOF( diff --git a/src/sentry_database.c b/src/sentry_database.c index 45f8b8eb1..481c3256c 100644 --- a/src/sentry_database.c +++ b/src/sentry_database.c @@ -1,9 +1,13 @@ #include "sentry_database.h" #include "sentry_alloc.h" +#include "sentry_attachment.h" #include "sentry_envelope.h" #include "sentry_json.h" #include "sentry_options.h" #include "sentry_session.h" +#include "sentry_sync.h" +#include "sentry_transport.h" +#include "sentry_utils.h" #include "sentry_uuid.h" #include #include @@ -50,19 +54,32 @@ sentry__run_new(const sentry_path_t *database_path) return NULL; } + // `/cache` + sentry_path_t *cache_path = sentry__path_join_str(database_path, "cache"); + if (!cache_path) { + sentry__path_free(run_path); + sentry__path_free(lock_path); + sentry__path_free(session_path); + sentry__path_free(external_path); + return NULL; + } + sentry_run_t *run = SENTRY_MAKE(sentry_run_t); if (!run) { sentry__path_free(run_path); sentry__path_free(session_path); sentry__path_free(lock_path); sentry__path_free(external_path); + sentry__path_free(cache_path); return NULL; } + run->refcount = 1; run->uuid = uuid; run->run_path = run_path; run->session_path = session_path; run->external_path = external_path; + run->cache_path = cache_path; run->lock = sentry__filelock_new(lock_path); if (!run->lock) { goto error; @@ -80,6 +97,15 @@ sentry__run_new(const sentry_path_t *database_path) return NULL; } +sentry_run_t * +sentry__run_incref(sentry_run_t *run) +{ + if (run) { + sentry__atomic_fetch_and_add(&run->refcount, 1); + } + return run; +} + void sentry__run_clean(sentry_run_t *run) { @@ -90,18 +116,20 @@ sentry__run_clean(sentry_run_t *run) void sentry__run_free(sentry_run_t *run) { - if (!run) { + if (!run || sentry__atomic_fetch_and_add(&run->refcount, -1) != 1) { return; } sentry__path_free(run->run_path); sentry__path_free(run->session_path); sentry__path_free(run->external_path); + sentry__path_free(run->cache_path); sentry__filelock_free(run->lock); sentry_free(run); } static bool -write_envelope(const sentry_path_t *path, const sentry_envelope_t *envelope) +write_envelope(const sentry_path_t *path, const sentry_envelope_t *envelope, + int retry_count) { sentry_uuid_t event_id = sentry__envelope_get_event_id(envelope); @@ -111,13 +139,18 @@ write_envelope(const sentry_path_t *path, const sentry_envelope_t *envelope) event_id = sentry_uuid_new_v4(); } - char *envelope_filename = sentry__uuid_as_filename(&event_id, ".envelope"); - if (!envelope_filename) { - return false; + char uuid[37]; + sentry_uuid_as_string(&event_id, uuid); + + char filename[128]; + if (retry_count >= 0) { + snprintf(filename, sizeof(filename), "%" PRIu64 "-%02d-%.36s.envelope", + sentry__usec_time() / 1000, retry_count, uuid); + } else { + snprintf(filename, sizeof(filename), "%.36s.envelope", uuid); } - sentry_path_t *output_path = sentry__path_join_str(path, envelope_filename); - sentry_free(envelope_filename); + sentry_path_t *output_path = sentry__path_join_str(path, filename); if (!output_path) { return false; } @@ -132,11 +165,110 @@ write_envelope(const sentry_path_t *path, const sentry_envelope_t *envelope) return true; } +void +sentry__cache_external_attachments(const sentry_path_t *cache_path, + const sentry_uuid_t *event_id, const sentry_attachment_t *attachments, + const sentry_path_t *run_path) +{ + if (!cache_path || !event_id || !attachments) { + return; + } + + char uuid_str[37]; + sentry_uuid_as_string(event_id, uuid_str); + + sentry_path_t *event_dir = sentry__path_join_str(cache_path, uuid_str); + if (!event_dir) { + return; + } + + sentry_value_t refs = sentry_value_new_list(); + bool any_cached = false; + + for (const sentry_attachment_t *att = attachments; att; att = att->next) { + size_t file_size = sentry__attachment_get_size(att); + if (!sentry__attachment_is_external(att)) { + continue; + } + + if (!any_cached) { + if (sentry__path_create_dir_all(event_dir) != 0) { + sentry__path_free(event_dir); + sentry_value_decref(refs); + return; + } + any_cached = true; + } + + const char *filename = sentry__attachment_get_filename(att); + sentry_path_t *dst = sentry__path_join_str(event_dir, filename); + if (!dst) { + continue; + } + + int rv; + if (att->buf) { + rv = sentry__path_write_buffer(dst, att->buf, att->buf_len); + } else { + sentry_path_t *src_dir = sentry__path_dir(att->path); + bool is_run_owned + = run_path && src_dir && sentry__path_eq(src_dir, run_path); + sentry__path_free(src_dir); + rv = is_run_owned ? sentry__path_rename(att->path, dst) + : sentry__path_copy(att->path, dst); + } + sentry__path_free(dst); + + if (rv != 0) { + continue; + } + + sentry_value_t obj = sentry_value_new_object(); + sentry_value_set_by_key( + obj, "filename", sentry_value_new_string(filename)); + if (att->content_type) { + sentry_value_set_by_key(obj, "content_type", + sentry_value_new_string(att->content_type)); + } + if (att->type != ATTACHMENT) { + sentry_value_set_by_key(obj, "attachment_type", + sentry_value_new_string( + sentry__attachment_type_to_string(att->type))); + } + sentry_value_set_by_key(obj, "attachment_length", + sentry_value_new_uint64((uint64_t)file_size)); + sentry_value_append(refs, obj); + } + + if (sentry_value_get_length(refs) > 0) { + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + if (jw) { + sentry__jsonwriter_write_value(jw, refs); + size_t buf_len = 0; + char *buf = sentry__jsonwriter_into_string(jw, &buf_len); + if (buf) { + sentry_path_t *refs_path = sentry__path_join_str( + event_dir, "__sentry-attachments.json"); + if (refs_path) { + int rv = sentry__path_write_buffer(refs_path, buf, buf_len); + if (rv != 0) { + SENTRY_WARN("writing __sentry-attachments.json failed"); + } + sentry__path_free(refs_path); + } + sentry_free(buf); + } + } + } + sentry_value_decref(refs); + sentry__path_free(event_dir); +} + bool sentry__run_write_envelope( const sentry_run_t *run, const sentry_envelope_t *envelope) { - return write_envelope(run->run_path, envelope); + return write_envelope(run->run_path, envelope, -1); } bool @@ -148,7 +280,102 @@ sentry__run_write_external( return false; } - return write_envelope(run->external_path, envelope); + return write_envelope(run->external_path, envelope, -1); +} + +bool +sentry__run_write_cache( + const sentry_run_t *run, const sentry_envelope_t *envelope, int retry_count) +{ + if (sentry__path_create_dir_all(run->cache_path) != 0) { + SENTRY_ERRORF("mkdir failed: \"%s\"", run->cache_path->path); + return false; + } + + return write_envelope(run->cache_path, envelope, retry_count); +} + +bool +sentry__parse_cache_filename(const char *filename, uint64_t *ts_out, + int *count_out, const char **uuid_out) +{ + // Minimum retry filename: --.envelope (49+ chars). + // Cache filenames are exactly 45 chars (.envelope). + if (strlen(filename) <= 45) { + return false; + } + + char *end; + uint64_t ts = strtoull(filename, &end, 10); + if (*end != '-') { + return false; + } + + const char *count_str = end + 1; + long count = strtol(count_str, &end, 10); + if (*end != '-' || count < 0) { + return false; + } + + const char *uuid = end + 1; + size_t tail_len = strlen(uuid); + // 36 chars UUID (with dashes) + ".envelope" + if (tail_len != 36 + 9 || strcmp(uuid + 36, ".envelope") != 0) { + return false; + } + + *ts_out = ts; + *count_out = (int)count; + *uuid_out = uuid; + return true; +} + +sentry_path_t * +sentry__run_make_cache_path( + const sentry_run_t *run, uint64_t ts, int count, const char *uuid) +{ + char filename[128]; + if (count >= 0) { + snprintf(filename, sizeof(filename), "%" PRIu64 "-%02d-%.36s.envelope", + ts, count, uuid); + } else { + snprintf(filename, sizeof(filename), "%.36s.envelope", uuid); + } + return sentry__path_join_str(run->cache_path, filename); +} + +bool +sentry__run_move_cache( + const sentry_run_t *run, const sentry_path_t *src, int retry_count) +{ + if (sentry__path_create_dir_all(run->cache_path) != 0) { + SENTRY_ERRORF("mkdir failed: \"%s\"", run->cache_path->path); + return false; + } + + const char *src_name = sentry__path_filename(src); + uint64_t parsed_ts; + int parsed_count; + const char *parsed_uuid; + const char *cache_name = sentry__parse_cache_filename(src_name, &parsed_ts, + &parsed_count, &parsed_uuid) + ? parsed_uuid + : src_name; + + sentry_path_t *dst_path = sentry__run_make_cache_path( + run, sentry__usec_time() / 1000, retry_count, cache_name); + if (!dst_path) { + return false; + } + + int rv = sentry__path_rename(src, dst_path); + sentry__path_free(dst_path); + if (rv != 0) { + SENTRY_WARNF("failed to cache envelope \"%s\"", src_name); + return false; + } + + return true; } bool @@ -239,14 +466,6 @@ sentry__process_old_runs(const sentry_options_t *options, uint64_t last_crash) continue; } - sentry_path_t *cache_dir = NULL; - if (options->cache_keep) { - cache_dir = sentry__path_join_str(options->database_path, "cache"); - if (cache_dir) { - sentry__path_create_dir_all(cache_dir); - } - } - sentry_pathiter_t *run_iter = sentry__path_iter_directory(run_dir); const sentry_path_t *file; while (run_iter && (file = sentry__pathiter_next(run_iter)) != NULL) { @@ -291,25 +510,12 @@ sentry__process_old_runs(const sentry_options_t *options, uint64_t last_crash) } else if (sentry__path_ends_with(file, ".envelope")) { sentry_envelope_t *envelope = sentry__envelope_from_path(file); sentry__capture_envelope(options->transport, envelope); - - if (cache_dir) { - sentry_path_t *cached_file = sentry__path_join_str( - cache_dir, sentry__path_filename(file)); - if (!cached_file - || sentry__path_rename(file, cached_file) != 0) { - SENTRY_WARNF("failed to cache envelope \"%s\"", - sentry__path_filename(file)); - } - sentry__path_free(cached_file); - continue; - } } sentry__path_remove(file); } sentry__pathiter_free(run_iter); - sentry__path_free(cache_dir); sentry__path_remove_all(run_dir); sentry__filelock_free(lock); } @@ -347,6 +553,25 @@ compare_cache_entries_newest_first(const void *a, const void *b) return 0; } +static sentry_path_t * +envelope_attachment_dir(const sentry_path_t *cache_dir, const char *filename) +{ + uint64_t ts; + int count; + const char *uuid; + if (!sentry__parse_cache_filename(filename, &ts, &count, &uuid)) { + size_t len = strlen(filename); + if (len != 45 || strcmp(filename + 36, ".envelope") != 0) { + return NULL; + } + uuid = filename; + } + char uuid_buf[37]; + memcpy(uuid_buf, uuid, 36); + uuid_buf[36] = '\0'; + return sentry__path_join_str(cache_dir, uuid_buf); +} + void sentry__cleanup_cache(const sentry_options_t *options) { @@ -397,6 +622,17 @@ sentry__cleanup_cache(const sentry_options_t *options) } entries[entries_count].mtime = sentry__path_get_mtime(entry); entries[entries_count].size = sentry__path_get_size(entry); + + const char *fname = sentry__path_filename(entry); + if (fname) { + sentry_path_t *att_dir = envelope_attachment_dir(cache_dir, fname); + if (att_dir) { + entries[entries_count].size + += sentry__path_get_dir_size(att_dir); + sentry__path_free(att_dir); + } + } + entries_count++; } sentry__pathiter_free(iter); @@ -433,6 +669,15 @@ sentry__cleanup_cache(const sentry_options_t *options) } if (should_prune) { + const char *fname = sentry__path_filename(entries[i].path); + if (fname) { + sentry_path_t *att_dir + = envelope_attachment_dir(cache_dir, fname); + if (att_dir) { + sentry__path_remove_all(att_dir); + sentry__path_free(att_dir); + } + } sentry__path_remove_all(entries[i].path); } sentry__path_free(entries[i].path); diff --git a/src/sentry_database.h b/src/sentry_database.h index c3fee8bc0..574845992 100644 --- a/src/sentry_database.h +++ b/src/sentry_database.h @@ -3,6 +3,7 @@ #include "sentry_boot.h" +#include "sentry_attachment.h" #include "sentry_path.h" #include "sentry_session.h" @@ -11,7 +12,9 @@ typedef struct sentry_run_s { sentry_path_t *run_path; sentry_path_t *session_path; sentry_path_t *external_path; + sentry_path_t *cache_path; sentry_filelock_t *lock; + long refcount; } sentry_run_t; /** @@ -22,6 +25,11 @@ typedef struct sentry_run_s { */ sentry_run_t *sentry__run_new(const sentry_path_t *database_path); +/** + * Increment the refcount and return the run pointer. + */ +sentry_run_t *sentry__run_incref(sentry_run_t *run); + /** * This will clean up all the files belonging to this run. */ @@ -63,6 +71,30 @@ bool sentry__run_write_session( */ bool sentry__run_clear_session(const sentry_run_t *run); +/** + * This will serialize and write the given envelope to disk into the cache + * directory. When retry_count >= 0 the filename uses retry format + * `--.envelope`, otherwise `.envelope`. + */ +bool sentry__run_write_cache(const sentry_run_t *run, + const sentry_envelope_t *envelope, int retry_count); + +/** + * Moves a file into the cache directory. When retry_count >= 0 the + * destination uses retry format `--.envelope`, + * otherwise the original filename is preserved. + */ +bool sentry__run_move_cache( + const sentry_run_t *run, const sentry_path_t *src, int retry_count); + +/** + * Builds a cache path. When count >= 0 the result is + * `/cache/--.envelope`, otherwise + * `/cache/.envelope`. + */ +sentry_path_t *sentry__run_make_cache_path( + const sentry_run_t *run, uint64_t ts, int count, const char *uuid); + /** * This function is essential to send crash reports from previous runs of the * program. @@ -78,6 +110,13 @@ bool sentry__run_clear_session(const sentry_run_t *run); void sentry__process_old_runs( const sentry_options_t *options, uint64_t last_crash); +/** + * Parses a retry cache filename: `--.envelope`. + * Returns false for plain cache filenames (`.envelope`). + */ +bool sentry__parse_cache_filename(const char *filename, uint64_t *ts_out, + int *count_out, const char **uuid_out); + /** * Cleans up the cache based on options.cache_max_items, * options.cache_max_size and options.cache_max_age. @@ -100,4 +139,15 @@ bool sentry__has_crash_marker(const sentry_options_t *options); */ bool sentry__clear_crash_marker(const sentry_options_t *options); +/** + * Cache external attachments (e.g. minidumps) to + * `//` and write `__sentry-attachments.json` metadata. + * + * When `run_path` is non-NULL and a file attachment's parent directory + * matches it, the file is renamed instead of copied. + */ +void sentry__cache_external_attachments(const sentry_path_t *cache_path, + const sentry_uuid_t *event_id, const sentry_attachment_t *attachments, + const sentry_path_t *run_path); + #endif diff --git a/src/sentry_envelope.c b/src/sentry_envelope.c index 27a836765..fa35b88a9 100644 --- a/src/sentry_envelope.c +++ b/src/sentry_envelope.c @@ -1,5 +1,6 @@ #include "sentry_envelope.h" #include "sentry_alloc.h" +#include "sentry_attachment.h" #include "sentry_core.h" #include "sentry_json.h" #include "sentry_options.h" @@ -7,6 +8,7 @@ #include "sentry_ratelimiter.h" #include "sentry_scope.h" #include "sentry_string.h" +#include "sentry_sync.h" #include "sentry_transport.h" #include "sentry_value.h" #include @@ -21,7 +23,20 @@ struct sentry_envelope_item_s { sentry_envelope_item_t *next; }; +static long +next_tag(void) +{ + static volatile long counter = 0; + long tag = sentry__atomic_fetch_and_add(&counter, 1); + if (tag == 0) { + // skip 0-sentinel on overflow + tag = sentry__atomic_fetch_and_add(&counter, 1); + } + return tag; +} + struct sentry_envelope_s { + long tag; bool is_raw; union { struct { @@ -199,6 +214,7 @@ sentry__envelope_new_with_dsn(const sentry_dsn_t *dsn) return NULL; } + rv->tag = next_tag(); rv->is_raw = false; rv->contents.items.first_item = NULL; rv->contents.items.last_item = NULL; @@ -229,6 +245,7 @@ sentry__envelope_from_path(const sentry_path_t *path) return NULL; } + envelope->tag = next_tag(); envelope->is_raw = true; envelope->contents.raw.payload = buf; envelope->contents.raw.payload_len = buf_len; @@ -236,6 +253,12 @@ sentry__envelope_from_path(const sentry_path_t *path) return envelope; } +long +sentry__envelope_get_tag(const sentry_envelope_t *envelope) +{ + return envelope->tag; +} + sentry_uuid_t sentry__envelope_get_event_id(const sentry_envelope_t *envelope) { @@ -583,20 +606,146 @@ sentry__envelope_add_session( envelope, payload, payload_len, "session"); } -static const char * -str_from_attachment_type(sentry_attachment_type_t attachment_type) +static bool +append_raw_attachment_ref(sentry_envelope_t *envelope, const char *location, + const char *filename, const char *content_type, const char *attachment_type, + sentry_value_t attachment_length) +{ + if (!envelope || !envelope->is_raw || !location) { + return false; + } + + // Build payload: {"location":"..."} + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + if (!jw) { + return false; + } + sentry_value_t payload_obj = sentry_value_new_object(); + sentry_value_set_by_key( + payload_obj, "location", sentry_value_new_string(location)); + if (content_type) { + sentry_value_set_by_key( + payload_obj, "content_type", sentry_value_new_string(content_type)); + } + sentry__jsonwriter_write_value(jw, payload_obj); + sentry_value_decref(payload_obj); + size_t payload_len = 0; + char *payload_buf = sentry__jsonwriter_into_string(jw, &payload_len); + if (!payload_buf) { + return false; + } + + // Build item header + jw = sentry__jsonwriter_new_sb(NULL); + if (!jw) { + sentry_free(payload_buf); + return false; + } + sentry_value_t headers = sentry_value_new_object(); + sentry_value_set_by_key( + headers, "type", sentry_value_new_string("attachment")); + sentry_value_set_by_key(headers, "content_type", + sentry_value_new_string("application/vnd.sentry.attachment-ref+json")); + sentry_value_set_by_key( + headers, "length", sentry_value_new_int32((int32_t)payload_len)); + if (filename) { + sentry_value_set_by_key( + headers, "filename", sentry_value_new_string(filename)); + } + if (attachment_type) { + sentry_value_set_by_key(headers, "attachment_type", + sentry_value_new_string(attachment_type)); + } + sentry_value_set_by_key(headers, "attachment_length", attachment_length); + sentry__jsonwriter_write_value(jw, headers); + sentry_value_decref(headers); + size_t header_len = 0; + char *header_buf = sentry__jsonwriter_into_string(jw, &header_len); + if (!header_buf) { + sentry_free(payload_buf); + return false; + } + + // Append: \n
\n + size_t old_len = envelope->contents.raw.payload_len; + size_t new_len = old_len + 1 + header_len + 1 + payload_len; + char *new_payload = sentry_malloc(new_len); + if (!new_payload) { + sentry_free(header_buf); + sentry_free(payload_buf); + return false; + } + + char *p = new_payload; + memcpy(p, envelope->contents.raw.payload, old_len); + p += old_len; + *p++ = '\n'; + memcpy(p, header_buf, header_len); + p += header_len; + *p++ = '\n'; + memcpy(p, payload_buf, payload_len); + + sentry_free(envelope->contents.raw.payload); + envelope->contents.raw.payload = new_payload; + envelope->contents.raw.payload_len = new_len; + + sentry_free(header_buf); + sentry_free(payload_buf); + return true; +} + +sentry_envelope_item_t * +sentry__envelope_add_attachment_ref(sentry_envelope_t *envelope, + const char *location, const char *filename, const char *content_type, + const char *attachment_type, sentry_value_t attachment_length) { - switch (attachment_type) { - case ATTACHMENT: - return "event.attachment"; - case MINIDUMP: - return "event.minidump"; - case VIEW_HIERARCHY: - return "event.view_hierarchy"; - default: - UNREACHABLE("Unknown attachment type"); - return "event.attachment"; + if (envelope && envelope->is_raw) { + append_raw_attachment_ref(envelope, location, filename, content_type, + attachment_type, attachment_length); + return NULL; + } + sentry_envelope_item_t *item = envelope_add_item(envelope); + if (!item) { + return NULL; } + sentry__envelope_item_set_header( + item, "type", sentry_value_new_string("attachment")); + sentry__envelope_item_set_header(item, "content_type", + sentry_value_new_string("application/vnd.sentry.attachment-ref+json")); + if (filename) { + sentry__envelope_item_set_header( + item, "filename", sentry_value_new_string(filename)); + } + if (attachment_type) { + sentry__envelope_item_set_header( + item, "attachment_type", sentry_value_new_string(attachment_type)); + } + sentry__envelope_item_set_header( + item, "attachment_length", attachment_length); + + if (location || content_type) { + sentry_value_t obj = sentry_value_new_object(); + if (location) { + sentry_value_set_by_key( + obj, "location", sentry_value_new_string(location)); + } + if (content_type) { + sentry_value_set_by_key( + obj, "content_type", sentry_value_new_string(content_type)); + } + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + sentry__jsonwriter_write_value(jw, obj); + sentry_value_decref(obj); + size_t payload_len = 0; + char *payload = sentry__jsonwriter_into_string(jw, &payload_len); + sentry_free(item->payload); + item->payload = payload; + item->payload_len = payload_len; + sentry__envelope_item_set_header( + item, "length", sentry_value_new_int32((int32_t)payload_len)); + } + + return item; } sentry_envelope_item_t * @@ -607,6 +756,10 @@ sentry__envelope_add_attachment( return NULL; } + if (sentry__attachment_is_external(attachment)) { + return NULL; + } + sentry_envelope_item_t *item = NULL; if (attachment->buf) { item = sentry__envelope_add_from_buffer( @@ -621,15 +774,14 @@ sentry__envelope_add_attachment( if (attachment->type != ATTACHMENT) { // don't need to set the default sentry__envelope_item_set_header(item, "attachment_type", sentry_value_new_string( - str_from_attachment_type(attachment->type))); + sentry__attachment_type_to_string(attachment->type))); } if (attachment->content_type) { sentry__envelope_item_set_header(item, "content_type", sentry_value_new_string(attachment->content_type)); } sentry__envelope_item_set_header(item, "filename", - sentry_value_new_string(sentry__path_filename( - attachment->filename ? attachment->filename : attachment->path))); + sentry_value_new_string(sentry__attachment_get_filename(attachment))); return item; } @@ -666,6 +818,10 @@ sentry__envelope_add_from_path( if (!envelope) { return NULL; } + size_t file_size = sentry__path_get_size(path); + if (file_size >= SENTRY_LARGE_ATTACHMENT_SIZE) { + return NULL; + } size_t buf_len; char *buf = sentry__path_read_to_buffer(path, &buf_len); if (!buf) { @@ -676,6 +832,79 @@ sentry__envelope_add_from_path( return envelope_add_from_owned_buffer(envelope, buf, buf_len, type); } +bool +sentry__envelope_append_raw_attachment(sentry_envelope_t *envelope, + const sentry_path_t *path, const char *filename, + const char *attachment_type, const char *content_type) +{ + if (!envelope || !envelope->is_raw || !path) { + return false; + } + + size_t file_len; + char *file_buf = sentry__path_read_to_buffer(path, &file_len); + if (!file_buf) { + return false; + } + + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + if (!jw) { + sentry_free(file_buf); + return false; + } + sentry_value_t headers = sentry_value_new_object(); + sentry_value_set_by_key( + headers, "type", sentry_value_new_string("attachment")); + sentry_value_set_by_key( + headers, "length", sentry_value_new_int32((int32_t)file_len)); + if (filename) { + sentry_value_set_by_key( + headers, "filename", sentry_value_new_string(filename)); + } + if (attachment_type) { + sentry_value_set_by_key(headers, "attachment_type", + sentry_value_new_string(attachment_type)); + } + if (content_type) { + sentry_value_set_by_key( + headers, "content_type", sentry_value_new_string(content_type)); + } + sentry__jsonwriter_write_value(jw, headers); + sentry_value_decref(headers); + size_t header_len = 0; + char *header_buf = sentry__jsonwriter_into_string(jw, &header_len); + if (!header_buf) { + sentry_free(file_buf); + return false; + } + + size_t old_len = envelope->contents.raw.payload_len; + size_t new_len = old_len + 1 + header_len + 1 + file_len; + char *new_payload = sentry_malloc(new_len); + if (!new_payload) { + sentry_free(header_buf); + sentry_free(file_buf); + return false; + } + + char *p = new_payload; + memcpy(p, envelope->contents.raw.payload, old_len); + p += old_len; + *p++ = '\n'; + memcpy(p, header_buf, header_len); + p += header_len; + *p++ = '\n'; + memcpy(p, file_buf, file_len); + + sentry_free(envelope->contents.raw.payload); + envelope->contents.raw.payload = new_payload; + envelope->contents.raw.payload_len = new_len; + + sentry_free(header_buf); + sentry_free(file_buf); + return true; +} + static void sentry__envelope_serialize_headers_into_stringbuilder( const sentry_envelope_t *envelope, sentry_stringbuilder_t *sb) @@ -856,7 +1085,11 @@ sentry_envelope_deserialize(const char *buf, size_t buf_len) return NULL; } - sentry_envelope_t *envelope = sentry__envelope_new(); + // Use sentry__envelope_new_with_dsn(NULL) instead of sentry__envelope_new() + // because the DSN is part of the serialized headers and will be restored by + // deserialization. This avoids acquiring the options lock, which would + // deadlock if called from a bgworker thread during shutdown. + sentry_envelope_t *envelope = sentry__envelope_new_with_dsn(NULL); if (!envelope) { goto fail; } diff --git a/src/sentry_envelope.h b/src/sentry_envelope.h index f0e9bbc73..202f4ff58 100644 --- a/src/sentry_envelope.h +++ b/src/sentry_envelope.h @@ -31,6 +31,11 @@ sentry_envelope_t *sentry__envelope_new_with_dsn(const sentry_dsn_t *dsn); */ sentry_envelope_t *sentry__envelope_from_path(const sentry_path_t *path); +/** + * Returns a unique non-zero tag assigned at envelope creation. + */ +long sentry__envelope_get_tag(const sentry_envelope_t *envelope); + /** * This returns the UUID of the event associated with this envelope. * If there is no event inside this envelope, the empty nil UUID will be @@ -92,6 +97,17 @@ sentry_envelope_item_t *sentry__envelope_add_session( sentry_envelope_item_t *sentry__envelope_add_attachment( sentry_envelope_t *envelope, const sentry_attachment_t *attachment); +/** + * Add an attachment-ref item to this envelope. + * + * Builds a JSON payload containing `location` and/or `content_type` (omitting + * keys when NULL), and sets the standard attachment-ref item headers. + */ +sentry_envelope_item_t *sentry__envelope_add_attachment_ref( + sentry_envelope_t *envelope, const char *location, const char *filename, + const char *content_type, const char *attachment_type, + sentry_value_t attachment_length); + /** * Add attachments to this envelope. */ @@ -105,6 +121,14 @@ void sentry__envelope_add_attachments( sentry_envelope_item_t *sentry__envelope_add_from_path( sentry_envelope_t *envelope, const sentry_path_t *path, const char *type); +/** + * Append an attachment item to a raw envelope by reading `path` and + * building the item header/payload bytes directly into the raw buffer. + */ +bool sentry__envelope_append_raw_attachment(sentry_envelope_t *envelope, + const sentry_path_t *path, const char *filename, + const char *attachment_type, const char *content_type); + /** * This will add the given buffer as a new envelope item of type `type`. */ diff --git a/src/sentry_options.c b/src/sentry_options.c index d9b4e05d2..43e4ed467 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -82,6 +82,7 @@ sentry_options_new(void) opts->crash_reporting_mode = SENTRY_CRASH_REPORTING_MODE_NATIVE_WITH_MINIDUMP; // Default: best of // both worlds + opts->http_retry = true; return opts; } @@ -876,6 +877,18 @@ sentry_options_set_handler_strategy( #endif // SENTRY_PLATFORM_LINUX +void +sentry_options_set_http_retry(sentry_options_t *opts, int enabled) +{ + opts->http_retry = !!enabled; +} + +int +sentry_options_get_http_retry(const sentry_options_t *opts) +{ + return opts->http_retry; +} + void sentry_options_set_propagate_traceparent( sentry_options_t *opts, int propagate_traceparent) diff --git a/src/sentry_options.h b/src/sentry_options.h index 50aeb057b..064c0d1fa 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -78,6 +78,7 @@ struct sentry_options_s { bool enable_metrics; sentry_before_send_metric_function_t before_send_metric_func; void *before_send_metric_data; + bool http_retry; /* everything from here on down are options which are stored here but not exposed through the options API */ diff --git a/src/sentry_path.h b/src/sentry_path.h index 484a6f531..4ef1b1248 100644 --- a/src/sentry_path.h +++ b/src/sentry_path.h @@ -159,6 +159,12 @@ int sentry__path_remove_all(const sentry_path_t *path); */ int sentry__path_rename(const sentry_path_t *src, const sentry_path_t *dst); +/** + * Copy the file from `src` to `dst`. + * Returns 0 on success. + */ +int sentry__path_copy(const sentry_path_t *src, const sentry_path_t *dst); + /** * This will create the directory referred to by `path`, and any non-existing * parent directory. @@ -177,6 +183,12 @@ int sentry__path_touch(const sentry_path_t *path); */ size_t sentry__path_get_size(const sentry_path_t *path); +/** + * This will return the total size of all files in the directory at `path`, + * or 0 if the directory does not exist or is empty. + */ +size_t sentry__path_get_dir_size(const sentry_path_t *path); + /** * This will return the last modification time of the file at `path`, or 0 on * failure. diff --git a/src/sentry_retry.c b/src/sentry_retry.c new file mode 100644 index 000000000..a63e22921 --- /dev/null +++ b/src/sentry_retry.c @@ -0,0 +1,378 @@ +#include "sentry_retry.h" +#include "sentry_alloc.h" +#include "sentry_database.h" +#include "sentry_envelope.h" +#include "sentry_logger.h" +#include "sentry_options.h" +#include "sentry_utils.h" + +#include +#include + +#define SENTRY_RETRY_ATTEMPTS 6 +#define SENTRY_RETRY_INTERVAL (15 * 60 * 1000) +#define SENTRY_RETRY_THROTTLE 100 + +typedef enum { + SENTRY_RETRY_STARTUP = 0, + SENTRY_RETRY_RUNNING = 1, + SENTRY_RETRY_SEALED = 2 +} sentry_retry_state_t; + +typedef enum { + SENTRY_POLL_IDLE = 0, + SENTRY_POLL_SCHEDULED = 1, + SENTRY_POLL_SHUTDOWN = 2 +} sentry_poll_state_t; + +struct sentry_retry_s { + sentry_run_t *run; + bool cache_keep; + uint64_t startup_time; + volatile long state; + volatile long scheduled; + sentry_bgworker_t *bgworker; + sentry_retry_send_func_t send_cb; + void *send_data; + sentry_mutex_t sealed_lock; + long sealed_tag; +}; + +sentry_retry_t * +sentry__retry_new(const sentry_options_t *options) +{ + sentry_retry_t *retry = SENTRY_MAKE(sentry_retry_t); + if (!retry) { + return NULL; + } + memset(retry, 0, sizeof(sentry_retry_t)); + sentry__mutex_init(&retry->sealed_lock); + retry->run = sentry__run_incref(options->run); + retry->cache_keep = options->cache_keep; + retry->startup_time = sentry__usec_time() / 1000; + return retry; +} + +void +sentry__retry_free(sentry_retry_t *retry) +{ + if (!retry) { + return; + } + sentry__mutex_free(&retry->sealed_lock); + sentry__run_free(retry->run); + sentry_free(retry); +} + +uint64_t +sentry__retry_backoff(int count) +{ + // TODO: consider adding jitter and shortening the poll interval to spread + // out retries when multiple envelopes (esp. large attachments) pile up. + return (uint64_t)SENTRY_RETRY_INTERVAL << MIN(MAX(count, 0), 5); +} + +typedef struct { + sentry_path_t *path; + uint64_t ts; + int count; + char uuid[37]; +} retry_item_t; + +static int +compare_retry_items(const void *a, const void *b) +{ + const retry_item_t *ia = a; + const retry_item_t *ib = b; + if (ia->ts != ib->ts) { + return ia->ts < ib->ts ? -1 : 1; + } + if (ia->count != ib->count) { + return ia->count - ib->count; + } + return strcmp(ia->uuid, ib->uuid); +} + +static void +remove_attachment_dir(const sentry_retry_t *retry, const retry_item_t *item) +{ + sentry_path_t *att_dir + = sentry__path_join_str(retry->run->cache_path, item->uuid); + if (att_dir) { + sentry__path_remove_all(att_dir); + sentry__path_free(att_dir); + } +} + +static bool +handle_result(sentry_retry_t *retry, const retry_item_t *item, int status_code) +{ + // Only network failures (status_code < 0) trigger retries. HTTP responses + // including 5xx (500, 502, 503, 504) are discarded: + // https://develop.sentry.dev/sdk/foundations/transport/offline-caching/#dealing-with-network-failures + + // network failure with retries remaining: bump count & re-enqueue + if (item->count + 1 < SENTRY_RETRY_ATTEMPTS && status_code < 0) { + sentry_path_t *new_path = sentry__run_make_cache_path(retry->run, + sentry__usec_time() / 1000, item->count + 1, item->uuid); + if (new_path) { + if (sentry__path_rename(item->path, new_path) != 0) { + SENTRY_WARNF( + "failed to rename retry envelope \"%s\"", item->path->path); + } + sentry__path_free(new_path); + } + return true; + } + + bool exhausted = item->count + 1 >= SENTRY_RETRY_ATTEMPTS; + + // network failure with retries exhausted + if (exhausted && status_code < 0) { + if (retry->cache_keep) { + SENTRY_WARNF("max retries (%d) reached, moving envelope to cache", + SENTRY_RETRY_ATTEMPTS); + } else { + SENTRY_WARNF("max retries (%d) reached, discarding envelope", + SENTRY_RETRY_ATTEMPTS); + } + } + + // cache on last attempt + if (exhausted && retry->cache_keep) { + if (!sentry__run_move_cache(retry->run, item->path, -1)) { + sentry__path_remove(item->path); + remove_attachment_dir(retry, item); + } + return false; + } + + sentry__path_remove(item->path); + remove_attachment_dir(retry, item); + return false; +} + +size_t +sentry__retry_send(sentry_retry_t *retry, uint64_t before, + sentry_retry_send_func_t send_cb, void *data) +{ + sentry_pathiter_t *piter + = sentry__path_iter_directory(retry->run->cache_path); + if (!piter) { + return 0; + } + + size_t item_cap = 16; + retry_item_t *items = sentry_malloc(item_cap * sizeof(retry_item_t)); + if (!items) { + sentry__pathiter_free(piter); + return 0; + } + + size_t total = 0; + size_t eligible = 0; + uint64_t now = before > 0 ? 0 : sentry__usec_time() / 1000; + + const sentry_path_t *p; + while ((p = sentry__pathiter_next(piter)) != NULL) { + const char *fname = sentry__path_filename(p); + uint64_t ts; + int count; + const char *uuid; + if (!sentry__parse_cache_filename(fname, &ts, &count, &uuid)) { + continue; + } + if (before > 0 && ts >= before) { + continue; + } + total++; + if (!before + && (now < ts || (now - ts) < sentry__retry_backoff(count))) { + continue; + } + if (eligible == item_cap) { + item_cap *= 2; + retry_item_t *tmp = sentry_malloc(item_cap * sizeof(retry_item_t)); + if (!tmp) { + break; + } + memcpy(tmp, items, eligible * sizeof(retry_item_t)); + sentry_free(items); + items = tmp; + } + retry_item_t *item = &items[eligible]; + item->path = sentry__path_clone(p); + if (!item->path) { + break; + } + item->ts = ts; + item->count = count; + memcpy(item->uuid, uuid, 36); + item->uuid[36] = '\0'; + eligible++; + } + sentry__pathiter_free(piter); + + if (eligible > 1) { + qsort(items, eligible, sizeof(retry_item_t), compare_retry_items); + } + + for (size_t i = 0; i < eligible; i++) { + sentry_envelope_t *envelope = sentry__envelope_from_path(items[i].path); + if (!envelope) { + sentry__path_remove(items[i].path); + total--; + } else { + SENTRY_DEBUGF("retrying envelope (%d/%d)", items[i].count + 1, + SENTRY_RETRY_ATTEMPTS); + int status_code = send_cb(envelope, items[i].uuid, data); + sentry_envelope_free(envelope); + if (!handle_result(retry, &items[i], status_code)) { + total--; + } + // stop on network failure to avoid wasting time on a dead + // connection; remaining envelopes stay untouched for later + if (status_code < 0) { + break; + } + } + } + + for (size_t i = 0; i < eligible; i++) { + sentry__path_free(items[i].path); + } + sentry_free(items); + return total; +} + +static void +retry_poll_task(void *_retry, void *_state) +{ + (void)_state; + sentry_retry_t *retry = _retry; + uint64_t before + = sentry__atomic_fetch(&retry->state) == SENTRY_RETRY_STARTUP + ? retry->startup_time + : 0; + // CAS instead of unconditional store to preserve SENTRY_POLL_SHUTDOWN + sentry__atomic_compare_swap( + &retry->scheduled, SENTRY_POLL_SCHEDULED, SENTRY_POLL_IDLE); + if (sentry__retry_send(retry, before, retry->send_cb, retry->send_data) + && sentry__atomic_compare_swap( + &retry->scheduled, SENTRY_POLL_IDLE, SENTRY_POLL_SCHEDULED)) { + sentry__bgworker_submit_delayed(retry->bgworker, retry_poll_task, NULL, + retry, SENTRY_RETRY_INTERVAL); + } + // subsequent polls use backoff instead of the startup time filter + sentry__atomic_compare_swap( + &retry->state, SENTRY_RETRY_STARTUP, SENTRY_RETRY_RUNNING); +} + +void +sentry__retry_start(sentry_retry_t *retry, sentry_bgworker_t *bgworker, + sentry_retry_send_func_t send_cb, void *send_data) +{ + retry->bgworker = bgworker; + retry->send_cb = send_cb; + retry->send_data = send_data; + sentry__atomic_store(&retry->scheduled, SENTRY_POLL_SCHEDULED); + sentry__bgworker_submit_delayed( + bgworker, retry_poll_task, NULL, retry, SENTRY_RETRY_THROTTLE); +} + +static void +retry_flush_task(void *_retry, void *_state) +{ + (void)_state; + sentry_retry_t *retry = _retry; + if (sentry__atomic_compare_swap( + &retry->state, SENTRY_RETRY_STARTUP, SENTRY_RETRY_RUNNING)) { + sentry__retry_send(retry, UINT64_MAX, retry->send_cb, retry->send_data); + } +} + +static bool +drop_task_cb(void *_data, void *_ctx) +{ + (void)_data; + (void)_ctx; + return true; +} + +void +sentry__retry_shutdown(sentry_retry_t *retry) +{ + if (retry) { + // drop the delayed poll and prevent retry_poll_task from re-arming + sentry__bgworker_foreach_matching( + retry->bgworker, retry_poll_task, drop_task_cb, NULL); + sentry__atomic_store(&retry->scheduled, SENTRY_POLL_SHUTDOWN); + sentry__bgworker_submit(retry->bgworker, retry_flush_task, NULL, retry); + } +} + +static bool +retry_dump_cb(void *_envelope, void *_retry) +{ + sentry_retry_t *retry = (sentry_retry_t *)_retry; + sentry_envelope_t *envelope = (sentry_envelope_t *)_envelope; + if (sentry__envelope_get_tag(envelope) != retry->sealed_tag) { + sentry__run_write_cache(retry->run, envelope, 0); + } else { + retry->sealed_tag = 0; + } + return true; +} + +void +sentry__retry_dump_queue( + sentry_retry_t *retry, sentry_task_exec_func_t task_func) +{ + if (retry) { + // prevent duplicate writes from a still-running detached worker + sentry__mutex_lock(&retry->sealed_lock); + sentry__atomic_store(&retry->state, SENTRY_RETRY_SEALED); + sentry__mutex_unlock(&retry->sealed_lock); + + sentry__bgworker_foreach_matching( + retry->bgworker, task_func, retry_dump_cb, retry); + } +} + +static void +retry_trigger_task(void *_retry, void *_state) +{ + (void)_state; + sentry_retry_t *retry = _retry; + sentry__retry_send(retry, UINT64_MAX, retry->send_cb, retry->send_data); +} + +void +sentry__retry_trigger(sentry_retry_t *retry) +{ + sentry__bgworker_submit(retry->bgworker, retry_trigger_task, NULL, retry); +} + +void +sentry__retry_enqueue(sentry_retry_t *retry, const sentry_envelope_t *envelope) +{ + sentry__mutex_lock(&retry->sealed_lock); + if (sentry__atomic_fetch(&retry->state) == SENTRY_RETRY_SEALED) { + sentry__mutex_unlock(&retry->sealed_lock); + return; + } + if (!sentry__run_write_cache(retry->run, envelope, 0)) { + sentry__mutex_unlock(&retry->sealed_lock); + return; + } + retry->sealed_tag = sentry__envelope_get_tag(envelope); + sentry__mutex_unlock(&retry->sealed_lock); + + sentry__atomic_compare_swap( + &retry->state, SENTRY_RETRY_STARTUP, SENTRY_RETRY_RUNNING); + if (sentry__atomic_compare_swap( + &retry->scheduled, SENTRY_POLL_IDLE, SENTRY_POLL_SCHEDULED)) { + sentry__bgworker_submit_delayed(retry->bgworker, retry_poll_task, NULL, + retry, SENTRY_RETRY_INTERVAL); + } +} diff --git a/src/sentry_retry.h b/src/sentry_retry.h new file mode 100644 index 000000000..fe9aefa6d --- /dev/null +++ b/src/sentry_retry.h @@ -0,0 +1,57 @@ +#ifndef SENTRY_RETRY_H_INCLUDED +#define SENTRY_RETRY_H_INCLUDED + +#include "sentry_boot.h" +#include "sentry_path.h" +#include "sentry_sync.h" + +typedef struct sentry_retry_s sentry_retry_t; + +typedef int (*sentry_retry_send_func_t)( + sentry_envelope_t *envelope, const char *uuid, void *data); + +sentry_retry_t *sentry__retry_new(const sentry_options_t *options); +void sentry__retry_free(sentry_retry_t *retry); + +/** + * Schedules retry polling on `bgworker` using `send_cb`. + */ +void sentry__retry_start(sentry_retry_t *retry, sentry_bgworker_t *bgworker, + sentry_retry_send_func_t send_cb, void *send_data); + +/** + * Prepares retry for shutdown: drops pending polls and submits a flush task. + */ +void sentry__retry_shutdown(sentry_retry_t *retry); + +/** + * Dumps queued envelopes to the retry dir and seals against further writes. + */ +void sentry__retry_dump_queue( + sentry_retry_t *retry, sentry_task_exec_func_t task_func); + +/** + * Writes a failed envelope to the retry dir and schedules a delayed poll. + */ +void sentry__retry_enqueue( + sentry_retry_t *retry, const sentry_envelope_t *envelope); + +/** + * Sends eligible retry files via `send_cb`. `before > 0`: send files with + * ts < before (startup). `before == 0`: use backoff. Returns remaining file + * count for controlling polling. + */ +size_t sentry__retry_send(sentry_retry_t *retry, uint64_t before, + sentry_retry_send_func_t send_cb, void *data); + +/** + * Exponential backoff: 15m, 30m, 1h, 2h, 4h, 8h, 8h, ... (capped at 8h). + */ +uint64_t sentry__retry_backoff(int count); + +/** + * Submits a delayed retry poll task on the background worker. + */ +void sentry__retry_trigger(sentry_retry_t *retry); + +#endif diff --git a/src/sentry_sync.c b/src/sentry_sync.c index c0d903f03..bf6c9a9d8 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -425,7 +425,8 @@ shutdown_task(void *task_data, void *UNUSED(state)) } int -sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) +sentry__bgworker_shutdown_cb(sentry_bgworker_t *bgw, uint64_t timeout, + void (*on_timeout)(void *), void *on_timeout_data) { if (!sentry__atomic_fetch(&bgw->running)) { SENTRY_WARN("trying to shut down non-running thread"); @@ -441,12 +442,23 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) while (true) { uint64_t now = sentry__monotonic_time(); if (now > started && now - started > timeout) { - sentry__atomic_store(&bgw->running, 0); - sentry__thread_detach(bgw->thread_id); - sentry__mutex_unlock(&bgw->task_lock); - SENTRY_WARN( - "background thread failed to shut down cleanly within timeout"); - return 1; + if (on_timeout) { + // fire on_timeout to cancel the ongoing task, and give the + // worker an extra loop cycle up to 250ms to handle the + // cancellation + sentry__mutex_unlock(&bgw->task_lock); + on_timeout(on_timeout_data); + on_timeout = NULL; + sentry__mutex_lock(&bgw->task_lock); + // fall through to !running check below + } else { + sentry__atomic_store(&bgw->running, 0); + sentry__thread_detach(bgw->thread_id); + sentry__mutex_unlock(&bgw->task_lock); + SENTRY_WARN("background thread failed to shut down cleanly " + "within timeout"); + return 1; + } } if (!sentry__atomic_fetch(&bgw->running)) { diff --git a/src/sentry_sync.h b/src/sentry_sync.h index eab47cd59..d4f6001e7 100644 --- a/src/sentry_sync.h +++ b/src/sentry_sync.h @@ -469,8 +469,20 @@ int sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout); /** * This will try to shut down the background worker thread, with a `timeout`. * Returns 0 on success. + * + * The `_cb` variant accepts an `on_timeout` callback that is invoked when + * the timeout expires, just before detaching the thread. This gives the + * caller a chance to unblock the worker (e.g. by closing transport handles) + * so it can finish in-flight work. */ -int sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout); +int sentry__bgworker_shutdown_cb(sentry_bgworker_t *bgw, uint64_t timeout, + void (*on_timeout)(void *), void *on_timeout_data); + +static inline int +sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) +{ + return sentry__bgworker_shutdown_cb(bgw, timeout, NULL, NULL); +} /** * This will set a preferable thread name for background worker. diff --git a/src/sentry_transport.c b/src/sentry_transport.c index 6b63c5783..cc9542293 100644 --- a/src/sentry_transport.c +++ b/src/sentry_transport.c @@ -10,6 +10,8 @@ struct sentry_transport_s { int (*flush_func)(uint64_t timeout, void *state); void (*free_func)(void *state); size_t (*dump_func)(sentry_run_t *run, void *state); + void (*retry_func)(void *state); + void (*cleanup_func)(const sentry_options_t *options, void *state); void *state; bool running; }; @@ -92,7 +94,7 @@ sentry__transport_startup( int sentry__transport_flush(sentry_transport_t *transport, uint64_t timeout) { - if (transport->flush_func && transport->running) { + if (transport && transport->flush_func && transport->running) { SENTRY_DEBUG("flushing transport"); return transport->flush_func(timeout, transport->state); } @@ -147,3 +149,36 @@ sentry__transport_get_state(sentry_transport_t *transport) { return transport ? transport->state : NULL; } + +void +sentry_transport_retry(sentry_transport_t *transport) +{ + if (transport && transport->retry_func) { + transport->retry_func(transport->state); + } +} + +void +sentry__transport_set_retry_func( + sentry_transport_t *transport, void (*retry_func)(void *state)) +{ + transport->retry_func = retry_func; +} + +void +sentry__transport_set_cleanup_func(sentry_transport_t *transport, + void (*cleanup_func)(const sentry_options_t *options, void *state)) +{ + transport->cleanup_func = cleanup_func; +} + +bool +sentry__transport_submit_cleanup( + sentry_transport_t *transport, const sentry_options_t *options) +{ + if (transport && transport->cleanup_func && transport->running) { + transport->cleanup_func(options, transport->state); + return true; + } + return false; +} diff --git a/src/sentry_transport.h b/src/sentry_transport.h index 036233284..a4ce8197e 100644 --- a/src/sentry_transport.h +++ b/src/sentry_transport.h @@ -57,4 +57,25 @@ size_t sentry__transport_dump_queue( void *sentry__transport_get_state(sentry_transport_t *transport); +void sentry__transport_set_retry_func( + sentry_transport_t *transport, void (*retry_func)(void *state)); + +/** + * Sets the cleanup function of the transport. + * + * This function submits cache cleanup as a task on the transport's background + * worker, so it runs after any pending send tasks from process_old_runs. + */ +void sentry__transport_set_cleanup_func(sentry_transport_t *transport, + void (*cleanup_func)(const sentry_options_t *options, void *state)); + +/** + * Submits cache cleanup to the transport's background worker. + * + * Returns true if cleanup was submitted, false if the transport does not + * support async cleanup (caller should run cleanup synchronously). + */ +bool sentry__transport_submit_cleanup( + sentry_transport_t *transport, const sentry_options_t *options); + #endif diff --git a/src/sentry_utils.c b/src/sentry_utils.c index 6de8d80d3..da678d3d4 100644 --- a/src/sentry_utils.c +++ b/src/sentry_utils.c @@ -389,6 +389,37 @@ sentry__dsn_get_envelope_url(const sentry_dsn_t *dsn) return sentry__stringbuilder_into_string(&sb); } +char * +sentry__dsn_get_upload_url(const sentry_dsn_t *dsn) +{ + if (!dsn || !dsn->is_valid) { + return NULL; + } + sentry_stringbuilder_t sb; + init_string_builder_for_url(&sb, dsn); + sentry__stringbuilder_append(&sb, "/upload/"); + return sentry__stringbuilder_into_string(&sb); +} + +char * +sentry__dsn_resolve_url(const sentry_dsn_t *dsn, const char *path) +{ + if (!dsn || !dsn->is_valid || !path) { + return NULL; + } + sentry_stringbuilder_t sb; + sentry__stringbuilder_init(&sb); + if (path[0] == '/') { + sentry__stringbuilder_append(&sb, dsn->is_secure ? "https" : "http"); + sentry__stringbuilder_append(&sb, "://"); + sentry__stringbuilder_append(&sb, dsn->host); + sentry__stringbuilder_append_char(&sb, ':'); + sentry__stringbuilder_append_int64(&sb, (int64_t)dsn->port); + } + sentry__stringbuilder_append(&sb, path); + return sentry__stringbuilder_into_string(&sb); +} + char * sentry__dsn_get_minidump_url(const sentry_dsn_t *dsn, const char *user_agent) { diff --git a/src/sentry_utils.h b/src/sentry_utils.h index 75ea87d4c..95b3ac2f1 100644 --- a/src/sentry_utils.h +++ b/src/sentry_utils.h @@ -110,6 +110,18 @@ char *sentry__dsn_get_auth_header( */ char *sentry__dsn_get_envelope_url(const sentry_dsn_t *dsn); +/** + * Returns the TUS upload endpoint url as a newly allocated string. + */ +char *sentry__dsn_get_upload_url(const sentry_dsn_t *dsn); + +/** + * Resolves a possibly relative URL against the DSN's origin. + * If the path starts with '/', the DSN's scheme, host, and port are prepended. + * Returns a newly allocated string. + */ +char *sentry__dsn_resolve_url(const sentry_dsn_t *dsn, const char *path); + /** * Returns the minidump endpoint url used for uploads done by the out-of-process * crashpad backend as a newly allocated string. diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index 24b1ba566..64407df97 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -1,11 +1,15 @@ #include "sentry_http_transport.h" #include "sentry_alloc.h" +#include "sentry_attachment.h" #include "sentry_database.h" #include "sentry_envelope.h" #include "sentry_options.h" #include "sentry_ratelimiter.h" +#include "sentry_retry.h" #include "sentry_string.h" #include "sentry_transport.h" +#include "sentry_utils.h" +#include "sentry_value.h" #ifdef SENTRY_TRANSPORT_COMPRESSION # include "zlib.h" @@ -14,6 +18,8 @@ #include #define ENVELOPE_MIME "application/x-sentry-envelope" +#define TUS_MIME "application/offset+octet-stream" +#define TUS_MAX_HTTP_HEADERS 4 #ifdef SENTRY_TRANSPORT_COMPRESSION # define MAX_HTTP_HEADERS 4 #else @@ -23,12 +29,16 @@ typedef struct { sentry_dsn_t *dsn; char *user_agent; + sentry_run_t *run; sentry_rate_limiter_t *ratelimiter; void *client; void (*free_client)(void *); int (*start_client)(void *, const sentry_options_t *); sentry_http_send_func_t send_func; void (*shutdown_client)(void *client); + sentry_retry_t *retry; + bool cache_keep; + bool has_tus; } http_transport_state_t; #ifdef SENTRY_TRANSPORT_COMPRESSION @@ -136,6 +146,7 @@ sentry__prepare_http_request(sentry_envelope_t *envelope, req->method = "POST"; req->url = sentry__dsn_get_envelope_url(dsn); + req->body_path = NULL; sentry_prepared_http_header_t *h; h = &req->headers[req->headers_len++]; @@ -179,9 +190,390 @@ sentry__prepared_http_request_free(sentry_prepared_http_request_t *req) if (req->body_owned) { sentry_free(req->body); } + sentry__path_free(req->body_path); sentry_free(req); } +static sentry_prepared_http_request_t * +prepare_tus_request_common( + size_t upload_size, const sentry_dsn_t *dsn, const char *user_agent) +{ + if (!dsn || !dsn->is_valid) { + 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) * TUS_MAX_HTTP_HEADERS); + if (!req->headers) { + sentry_free(req); + return NULL; + } + req->headers_len = 0; + + req->method = "POST"; + req->url = sentry__dsn_get_upload_url(dsn); + + 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"); + + h = &req->headers[req->headers_len++]; + h->key = "upload-length"; + h->value = sentry__uint64_to_string((uint64_t)upload_size); + + return req; +} + +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) +{ + if (!location || !path) { + 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) * TUS_MAX_HTTP_HEADERS); + if (!req->headers) { + sentry_free(req); + return NULL; + } + req->headers_len = 0; + + req->method = "PATCH"; + req->url = sentry__string_clone(location); + req->body_path = sentry__path_clone(path); + req->body_len = file_size; + + 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"); + + h = &req->headers[req->headers_len++]; + h->key = "content-type"; + h->value = sentry__string_clone(TUS_MIME); + + h = &req->headers[req->headers_len++]; + h->key = "upload-offset"; + h->value = sentry__string_clone("0"); + + return req; +} + +static void +http_response_cleanup(sentry_http_response_t *resp) +{ + sentry_free(resp->retry_after); + sentry_free(resp->x_sentry_rate_limits); + sentry_free(resp->location); +} + +static int +http_send_request( + http_transport_state_t *state, sentry_prepared_http_request_t *req) +{ + sentry_http_response_t resp; + memset(&resp, 0, sizeof(resp)); + + if (!state->send_func(state->client, req, &resp)) { + http_response_cleanup(&resp); + return -1; + } + + if (resp.x_sentry_rate_limits) { + sentry__rate_limiter_update_from_header( + state->ratelimiter, resp.x_sentry_rate_limits); + } else if (resp.retry_after) { + sentry__rate_limiter_update_from_http_retry_after( + state->ratelimiter, resp.retry_after); + } else if (resp.status_code == 429) { + sentry__rate_limiter_update_from_429(state->ratelimiter); + } + + http_response_cleanup(&resp); + return resp.status_code; +} + +static bool +tus_upload_ref(http_transport_state_t *state, const sentry_path_t *att_dir, + sentry_value_t entry, sentry_envelope_t *tus_envelope) +{ + const char *filename + = sentry_value_as_string(sentry_value_get_by_key(entry, "filename")); + if (!filename || *filename == '\0') { + return false; + } + + sentry_path_t *att_file = sentry__path_join_str(att_dir, filename); + size_t file_size = att_file ? sentry__path_get_size(att_file) : 0; + if (!att_file || file_size == 0) { + sentry__path_free(att_file); + return false; + } + + // Step 1: TUS creation request (POST, no body) + sentry_prepared_http_request_t *req + = prepare_tus_request_common(file_size, state->dsn, state->user_agent); + if (!req) { + sentry__path_free(att_file); + return false; + } + + sentry_http_response_t resp; + memset(&resp, 0, sizeof(resp)); + bool ok = state->send_func(state->client, req, &resp); + sentry__prepared_http_request_free(req); + + if (!ok) { + sentry__path_free(att_file); + http_response_cleanup(&resp); + return false; + } + + if (resp.status_code == 404) { + state->has_tus = false; + SENTRY_WARN("TUS upload returned 404, disabling TUS"); + sentry__path_free(att_file); + http_response_cleanup(&resp); + return false; + } + + if (resp.status_code != 201 || !resp.location) { + sentry__path_free(att_file); + http_response_cleanup(&resp); + return false; + } + + // Step 2: TUS upload request (PATCH with file body) + char *location = sentry__dsn_resolve_url(state->dsn, resp.location); + http_response_cleanup(&resp); + req = prepare_tus_upload_request( + location, att_file, file_size, state->dsn, state->user_agent); + + if (!req) { + sentry__path_free(att_file); + sentry_free(location); + return false; + } + + memset(&resp, 0, sizeof(resp)); + ok = state->send_func(state->client, req, &resp); + sentry__prepared_http_request_free(req); + http_response_cleanup(&resp); + + if (!ok || resp.status_code != 204) { + sentry__path_free(att_file); + sentry_free(location); + return false; + } + + const char *ref_ct = sentry_value_as_string( + sentry_value_get_by_key(entry, "content_type")); + const char *att_type = sentry_value_as_string( + sentry_value_get_by_key(entry, "attachment_type")); + sentry_value_t att_length + = sentry_value_get_by_key(entry, "attachment_length"); + sentry_value_incref(att_length); + sentry__envelope_add_attachment_ref(tus_envelope, location, filename, + *ref_ct ? ref_ct : NULL, *att_type ? att_type : NULL, att_length); + + sentry__path_remove(att_file); + sentry__path_free(att_file); + sentry_free(location); + return true; +} + +static bool +inline_cached_attachment(const sentry_path_t *att_dir, sentry_value_t entry, + sentry_envelope_t *envelope) +{ + const char *filename + = sentry_value_as_string(sentry_value_get_by_key(entry, "filename")); + if (!filename || *filename == '\0') { + return false; + } + + const char *att_type = sentry_value_as_string( + sentry_value_get_by_key(entry, "attachment_type")); + const char *ref_ct = sentry_value_as_string( + sentry_value_get_by_key(entry, "content_type")); + + sentry_path_t *att_file = sentry__path_join_str(att_dir, filename); + if (!att_file) { + return false; + } + + // Try structured envelope first, fall back to raw append + sentry_envelope_item_t *item + = sentry__envelope_add_from_path(envelope, att_file, "attachment"); + if (item) { + if (*att_type) { + sentry__envelope_item_set_header( + item, "attachment_type", sentry_value_new_string(att_type)); + } + if (*ref_ct) { + sentry__envelope_item_set_header( + item, "content_type", sentry_value_new_string(ref_ct)); + } + sentry__envelope_item_set_header( + item, "filename", sentry_value_new_string(filename)); + } else if (!sentry__envelope_append_raw_attachment(envelope, att_file, + filename, *att_type ? att_type : NULL, + *ref_ct ? ref_ct : NULL)) { + sentry__path_free(att_file); + return false; + } + + sentry__path_free(att_file); + return true; +} + +static void +resolve_and_send_external_attachments(http_transport_state_t *state, + const char *uuid, sentry_envelope_t *envelope) +{ + if (!uuid || !state->run) { + return; + } + + sentry_path_t *att_dir + = sentry__path_join_str(state->run->cache_path, uuid); + if (!att_dir || !sentry__path_is_dir(att_dir)) { + sentry__path_free(att_dir); + return; + } + + sentry_path_t *refs_path + = sentry__path_join_str(att_dir, "__sentry-attachments.json"); + if (!refs_path) { + sentry__path_free(att_dir); + return; + } + if (!sentry__path_is_file(refs_path)) { + sentry__path_free(refs_path); + sentry__path_free(att_dir); + return; + } + + size_t buf_len = 0; + char *buf = sentry__path_read_to_buffer(refs_path, &buf_len); + if (!buf) { + sentry__path_free(refs_path); + sentry__path_free(att_dir); + return; + } + sentry_value_t refs = sentry__value_from_json(buf, buf_len); + sentry_free(buf); + if (sentry_value_get_type(refs) != SENTRY_VALUE_TYPE_LIST) { + sentry_value_decref(refs); + sentry__path_free(refs_path); + sentry__path_free(att_dir); + return; + } + + size_t count = sentry_value_get_length(refs); + for (size_t i = 0; i < count; i++) { + sentry_value_t entry = sentry_value_get_by_index(refs, i); + + const char *filename = sentry_value_as_string( + sentry_value_get_by_key(entry, "filename")); + if (!filename || *filename == '\0') { + continue; + } + sentry_path_t *att_file = sentry__path_join_str(att_dir, filename); + size_t file_size = att_file ? sentry__path_get_size(att_file) : 0; + sentry__path_free(att_file); + + if (file_size >= SENTRY_LARGE_ATTACHMENT_SIZE && state->has_tus) { + tus_upload_ref(state, att_dir, entry, envelope); + if (!state->has_tus) { + break; + } + } else { + inline_cached_attachment(att_dir, entry, envelope); + } + } + + sentry_value_decref(refs); + + sentry__path_free(refs_path); + sentry__path_free(att_dir); +} + +static void +cleanup_external_attachments(const sentry_run_t *run, const char *uuid) +{ + if (!uuid || !run) { + return; + } + sentry_path_t *att_dir = sentry__path_join_str(run->cache_path, uuid); + if (att_dir) { + sentry__path_remove_all(att_dir); + sentry__path_free(att_dir); + } +} + +static int +http_send_envelope(http_transport_state_t *state, sentry_envelope_t *envelope, + const char *uuid) +{ + char uuid_buf[37]; + if (!uuid) { + sentry_uuid_t event_id = sentry__envelope_get_event_id(envelope); + if (!sentry_uuid_is_nil(&event_id)) { + sentry_uuid_as_string(&event_id, uuid_buf); + uuid = uuid_buf; + } + } + + resolve_and_send_external_attachments(state, uuid, envelope); + + sentry_prepared_http_request_t *req = sentry__prepare_http_request( + envelope, state->dsn, state->ratelimiter, state->user_agent); + if (!req) { + return 0; + } + int status_code = http_send_request(state, req); + sentry__prepared_http_request_free(req); + + if (status_code >= 0) { + cleanup_external_attachments(state->run, uuid); + } + + return status_code; +} + +static int +retry_send_cb(sentry_envelope_t *envelope, const char *uuid, void *_state) +{ + http_transport_state_t *state = _state; + return http_send_envelope(state, envelope, uuid); +} + static void http_transport_state_free(void *_state) { @@ -191,7 +583,9 @@ http_transport_state_free(void *_state) } sentry__dsn_decref(state->dsn); sentry_free(state->user_agent); + sentry__run_free(state->run); sentry__rate_limiter_free(state->ratelimiter); + sentry__retry_free(state->retry); sentry_free(state); } @@ -201,29 +595,21 @@ http_send_task(void *_envelope, void *_state) sentry_envelope_t *envelope = _envelope; http_transport_state_t *state = _state; - sentry_prepared_http_request_t *req = sentry__prepare_http_request( - envelope, state->dsn, state->ratelimiter, state->user_agent); - if (!req) { - return; + int status_code = http_send_envelope(state, envelope, NULL); + if (status_code < 0 && state->retry) { + sentry__retry_enqueue(state->retry, envelope); + } else if (status_code < 0 && state->cache_keep) { + sentry__run_write_cache(state->run, envelope, -1); } +} - sentry_http_response_t resp; - memset(&resp, 0, sizeof(resp)); - - if (state->send_func(state->client, req, &resp)) { - if (resp.x_sentry_rate_limits) { - sentry__rate_limiter_update_from_header( - state->ratelimiter, resp.x_sentry_rate_limits); - } else if (resp.retry_after) { - sentry__rate_limiter_update_from_http_retry_after( - state->ratelimiter, resp.retry_after); - } else if (resp.status_code == 429) { - sentry__rate_limiter_update_from_429(state->ratelimiter); - } +static void +http_transport_shutdown_timeout(void *_state) +{ + http_transport_state_t *state = _state; + if (state->shutdown_client) { + state->shutdown_client(state->client); } - sentry_free(resp.retry_after); - sentry_free(resp.x_sentry_rate_limits); - sentry__prepared_http_request_free(req); } static int @@ -236,6 +622,8 @@ http_transport_start(const sentry_options_t *options, void *transport_state) state->dsn = sentry__dsn_incref(options->dsn); state->user_agent = sentry__string_clone(options->user_agent); + state->cache_keep = options->cache_keep; + state->run = sentry__run_incref(options->run); if (state->start_client) { int rv = state->start_client(state->client, options); @@ -244,7 +632,23 @@ http_transport_start(const sentry_options_t *options, void *transport_state) } } - return sentry__bgworker_start(bgworker); + int rv = sentry__bgworker_start(bgworker); + if (rv != 0) { + return rv; + } + + if (options->http_retry) { + state->retry = sentry__retry_new(options); + if (state->retry) { + sentry__retry_start(state->retry, bgworker, retry_send_cb, state); + } else { + // cannot retry, clear retry_func so envelopes get cached instead of + // dropped + sentry__transport_set_retry_func(options->transport, NULL); + } + } + + return 0; } static int @@ -260,9 +664,12 @@ http_transport_shutdown(uint64_t timeout, void *transport_state) sentry_bgworker_t *bgworker = transport_state; http_transport_state_t *state = sentry__bgworker_get_state(bgworker); - int rv = sentry__bgworker_shutdown(bgworker, timeout); - if (rv != 0 && state->shutdown_client) { - state->shutdown_client(state->client); + sentry__retry_shutdown(state->retry); + + int rv = sentry__bgworker_shutdown_cb( + bgworker, timeout, http_transport_shutdown_timeout, state); + if (rv != 0) { + sentry__retry_dump_queue(state->retry, http_send_task); } return rv; } @@ -298,6 +705,34 @@ http_transport_get_state(sentry_transport_t *transport) return sentry__bgworker_get_state(bgworker); } +static void +http_transport_retry(void *transport_state) +{ + sentry_bgworker_t *bgworker = transport_state; + http_transport_state_t *state = sentry__bgworker_get_state(bgworker); + if (state->retry) { + sentry__retry_trigger(state->retry); + } +} + +static void +http_cleanup_cache_task(void *task_data, void *_state) +{ + (void)_state; + sentry_options_t *options = task_data; + sentry__cleanup_cache(options); +} + +static void +http_transport_submit_cleanup( + const sentry_options_t *options, void *transport_state) +{ + sentry_bgworker_t *bgworker = transport_state; + sentry__bgworker_submit(bgworker, http_cleanup_cache_task, + (void (*)(void *))sentry_options_free, + sentry__options_incref((sentry_options_t *)options)); +} + sentry_transport_t * sentry__http_transport_new(void *client, sentry_http_send_func_t send_func) { @@ -307,6 +742,7 @@ sentry__http_transport_new(void *client, sentry_http_send_func_t send_func) } memset(state, 0, sizeof(http_transport_state_t)); state->ratelimiter = sentry__rate_limiter_new(); + state->has_tus = true; state->client = client; state->send_func = send_func; @@ -331,6 +767,9 @@ sentry__http_transport_new(void *client, sentry_http_send_func_t send_func) sentry_transport_set_flush_func(transport, http_transport_flush); sentry_transport_set_shutdown_func(transport, http_transport_shutdown); sentry__transport_set_dump_func(transport, http_dump_queue); + sentry__transport_set_retry_func(transport, http_transport_retry); + sentry__transport_set_cleanup_func( + transport, http_transport_submit_cleanup); return transport; } @@ -362,4 +801,20 @@ sentry__http_transport_get_bgworker(sentry_transport_t *transport) { return sentry__transport_get_state(transport); } + +sentry_prepared_http_request_t * +sentry__prepare_tus_create_request( + size_t file_size, const sentry_dsn_t *dsn, const char *user_agent) +{ + return prepare_tus_request_common(file_size, dsn, 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) +{ + return prepare_tus_upload_request( + location, path, file_size, dsn, user_agent); +} #endif diff --git a/src/transports/sentry_http_transport.h b/src/transports/sentry_http_transport.h index 30493d564..3f81d7dd3 100644 --- a/src/transports/sentry_http_transport.h +++ b/src/transports/sentry_http_transport.h @@ -2,6 +2,7 @@ #define SENTRY_HTTP_TRANSPORT_H_INCLUDED #include "sentry_boot.h" +#include "sentry_path.h" #include "sentry_ratelimiter.h" #include "sentry_sync.h" #include "sentry_transport.h" @@ -19,6 +20,7 @@ typedef struct sentry_prepared_http_request_s { char *body; size_t body_len; bool body_owned; + sentry_path_t *body_path; } sentry_prepared_http_request_t; sentry_prepared_http_request_t *sentry__prepare_http_request( @@ -31,6 +33,7 @@ typedef struct { int status_code; char *retry_after; char *x_sentry_rate_limits; + char *location; } sentry_http_response_t; typedef bool (*sentry_http_send_func_t)(void *client, @@ -52,6 +55,11 @@ void sentry__http_transport_set_shutdown_client( #ifdef SENTRY_UNITTEST void *sentry__http_transport_get_bgworker(sentry_transport_t *transport); +sentry_prepared_http_request_t *sentry__prepare_tus_create_request( + size_t file_size, const sentry_dsn_t *dsn, 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); #endif #endif diff --git a/src/transports/sentry_http_transport_curl.c b/src/transports/sentry_http_transport_curl.c index b0c967f5c..499ae2877 100644 --- a/src/transports/sentry_http_transport_curl.c +++ b/src/transports/sentry_http_transport_curl.c @@ -3,12 +3,15 @@ #include "sentry_envelope.h" #include "sentry_http_transport.h" #include "sentry_options.h" +#include "sentry_slice.h" #include "sentry_string.h" +#include "sentry_sync.h" #include "sentry_transport.h" #include "sentry_utils.h" #include #include +#include #include #ifdef SENTRY_PLATFORM_NX @@ -20,6 +23,7 @@ typedef struct { char *proxy; char *ca_certs; bool debug; + long shutdown; #ifdef SENTRY_PLATFORM_NX void *nx_state; #endif @@ -117,6 +121,32 @@ curl_client_start(void *_client, const sentry_options_t *options) return 0; } +static void +curl_client_shutdown(void *_client) +{ + curl_client_t *client = _client; + sentry__atomic_store(&client->shutdown, 1); +} + +static int +progress_callback(void *clientp, curl_off_t UNUSED(dltotal), + curl_off_t UNUSED(dlnow), curl_off_t ultotal, curl_off_t ulnow) +{ + curl_client_t *client = clientp; + if (sentry__atomic_fetch(&client->shutdown)) { + return 1; + } + if (ultotal > 0) { + double speed = 0; + curl_easy_getinfo(client->curl_handle, CURLINFO_SPEED_UPLOAD, &speed); + SENTRY_DEBUGF("upload progress: %.1f / %.1f MB (%.0f%%, %.1f MB/s)", + (double)ulnow / (1024.0 * 1024.0), + (double)ultotal / (1024.0 * 1024.0), + (double)ulnow / (double)ultotal * 100.0, speed / (1024.0 * 1024.0)); + } + return 0; +} + static size_t swallow_data( char *UNUSED(ptr), size_t size, size_t nmemb, void *UNUSED(userdata)) @@ -124,6 +154,64 @@ swallow_data( return size * nmemb; } +static int +debug_function(CURL *UNUSED(handle), curl_infotype type, char *data, + size_t size, void *UNUSED(userdata)) +{ + const char *prefix; + switch (type) { + case CURLINFO_TEXT: + prefix = "* "; + break; + case CURLINFO_HEADER_OUT: + prefix = "> "; + break; + case CURLINFO_HEADER_IN: + prefix = "< "; + break; + case CURLINFO_DATA_OUT: { + size_t len = size; + while (len > 0 && (data[len - 1] == '\n' || data[len - 1] == '\r')) { + len--; + } + if (len >= 2 && data[0] == '{' && data[len - 1] == '}') { + fprintf( + stderr, "=> Send (%zu bytes): %.*s\n", size, (int)len, data); + } + return 0; + } + case CURLINFO_DATA_IN: { + size_t len = size; + while (len > 0 && (data[len - 1] == '\n' || data[len - 1] == '\r')) { + len--; + } + if (len >= 2 && data[0] == '{' && data[len - 1] == '}') { + fprintf( + stderr, "<= Recv (%zu bytes): %.*s\n", size, (int)len, data); + } else { + fprintf(stderr, "<= Recv (%zu bytes)\n", size); + } + return 0; + } + default: + return 0; + } + + const char *pos = data; + const char *end = data + size; + while (pos < end) { + const char *eol = memchr(pos, '\n', (size_t)(end - pos)); + if (eol) { + fprintf(stderr, "%s%.*s\n", prefix, (int)(eol - pos), pos); + pos = eol + 1; + } else { + fprintf(stderr, "%s%.*s\n", prefix, (int)(end - pos), pos); + break; + } + } + return 0; +} + static size_t header_callback(char *buffer, size_t size, size_t nitems, void *userdata) { @@ -138,10 +226,14 @@ header_callback(char *buffer, size_t size, size_t nitems, void *userdata) if (sep) { *sep = '\0'; sentry__string_ascii_lower(header); + sentry_slice_t value + = sentry__slice_trim(sentry__slice_from_str(sep + 1)); if (sentry__string_eq(header, "retry-after")) { - info->retry_after = sentry__string_clone(sep + 1); + info->retry_after = sentry__slice_to_owned(value); } else if (sentry__string_eq(header, "x-sentry-rate-limits")) { - info->x_sentry_rate_limits = sentry__string_clone(sep + 1); + info->x_sentry_rate_limits = sentry__slice_to_owned(value); + } else if (sentry__string_eq(header, "location")) { + info->location = sentry__slice_to_owned(value); } } @@ -149,6 +241,12 @@ header_callback(char *buffer, size_t size, size_t nitems, void *userdata) return bytes; } +static size_t +file_read_callback(char *buffer, size_t size, size_t nitems, void *userdata) +{ + return fread(buffer, size, nitems, (FILE *)userdata); +} + static bool curl_send_task(void *_client, sentry_prepared_http_request_t *req, sentry_http_response_t *resp) @@ -178,17 +276,41 @@ curl_send_task(void *_client, sentry_prepared_http_request_t *req, curl_easy_reset(curl); if (client->debug) { curl_easy_setopt(curl, CURLOPT_VERBOSE, 1); + curl_easy_setopt(curl, CURLOPT_DEBUGFUNCTION, debug_function); curl_easy_setopt(curl, CURLOPT_WRITEDATA, stderr); // CURLOPT_WRITEFUNCTION will `fwrite` by default } else { curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, swallow_data); } curl_easy_setopt(curl, CURLOPT_URL, req->url); - curl_easy_setopt(curl, CURLOPT_POST, (long)1); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req->body); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)req->body_len); curl_easy_setopt(curl, CURLOPT_USERAGENT, SENTRY_SDK_USER_AGENT); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 15000L); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_callback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, client); + + FILE *body_file = NULL; + if (req->body_path) { + body_file = fopen(req->body_path->path, "rb"); + if (!body_file) { + SENTRY_WARN("failed to open body_path for upload"); + curl_slist_free_all(headers); + return false; + } + curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, req->method); + curl_easy_setopt(curl, CURLOPT_READFUNCTION, file_read_callback); + curl_easy_setopt(curl, CURLOPT_READDATA, body_file); + curl_easy_setopt( + curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)req->body_len); + } else if (req->body) { + curl_easy_setopt(curl, CURLOPT_POST, (long)1); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req->body); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)req->body_len); + } else { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, req->method); + } char error_buf[CURL_ERROR_SIZE]; error_buf[0] = 0; @@ -232,6 +354,9 @@ curl_send_task(void *_client, sentry_prepared_http_request_t *req, } } + if (body_file) { + fclose(body_file); + } curl_slist_free_all(headers); return rv == CURLE_OK; } @@ -253,5 +378,6 @@ sentry__transport_new_default(void) } sentry__http_transport_set_free_client(transport, curl_client_free); sentry__http_transport_set_start_client(transport, curl_client_start); + sentry__http_transport_set_shutdown_client(transport, curl_client_shutdown); return transport; } diff --git a/src/transports/sentry_http_transport_winhttp.c b/src/transports/sentry_http_transport_winhttp.c index 0997d3562..559a3c016 100644 --- a/src/transports/sentry_http_transport_winhttp.c +++ b/src/transports/sentry_http_transport_winhttp.c @@ -134,6 +134,9 @@ winhttp_client_start(void *_client, const sentry_options_t *opts) return 1; } + // 15s resolve/connect, 30s send/receive (WinHTTP defaults) + WinHttpSetTimeouts(client->session, 15000, 15000, 30000, 30000); + return 0; } @@ -154,9 +157,9 @@ winhttp_client_shutdown(void *_client) WinHttpCloseHandle(client->session); client->session = NULL; } - if (client->request) { - WinHttpCloseHandle(client->request); - client->request = NULL; + HINTERNET request = InterlockedExchangePointer(&client->request, NULL); + if (request) { + WinHttpCloseHandle(request); } } @@ -241,9 +244,45 @@ winhttp_send_task(void *_client, sentry_prepared_http_request_t *req, client->proxy_password, 0); } - if ((result = WinHttpSendRequest(client->request, headers, (DWORD)-1, - (LPVOID)req->body, (DWORD)req->body_len, (DWORD)req->body_len, - 0))) { + if (req->body_path) { + HANDLE hFile = CreateFileW(req->body_path->path_w, GENERIC_READ, + FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) { + SENTRY_WARN("failed to open body_path for upload"); + goto exit; + } + + result = WinHttpSendRequest(client->request, headers, (DWORD)-1, NULL, + 0, (DWORD)req->body_len, 0); + if (result) { + char chunk[65536]; + DWORD bytes_read = 0; + while (ReadFile(hFile, chunk, sizeof(chunk), &bytes_read, NULL) + && bytes_read > 0) { + DWORD bytes_written = 0; + if (!WinHttpWriteData( + client->request, chunk, bytes_read, &bytes_written)) { + SENTRY_WARNF("`WinHttpWriteData` failed with code `%d`", + GetLastError()); + result = false; + break; + } + } + } else { + SENTRY_WARNF( + "`WinHttpSendRequest` failed with code `%d`", GetLastError()); + } + CloseHandle(hFile); + } else { + result = WinHttpSendRequest(client->request, headers, (DWORD)-1, + (LPVOID)req->body, (DWORD)req->body_len, (DWORD)req->body_len, 0); + if (!result) { + SENTRY_WARNF( + "`WinHttpSendRequest` failed with code `%d`", GetLastError()); + } + } + + if (result) { WinHttpReceiveResponse(client->request, NULL); if (client->debug) { @@ -294,18 +333,20 @@ winhttp_send_task(void *_client, sentry_prepared_http_request_t *req, WINHTTP_NO_HEADER_INDEX)) { resp->retry_after = sentry__string_from_wstr(buf); } - } else { - SENTRY_WARNF( - "`WinHttpSendRequest` failed with code `%d`", GetLastError()); + + buf_size = sizeof(buf); + if (WinHttpQueryHeaders(client->request, WINHTTP_QUERY_CUSTOM, + L"location", buf, &buf_size, WINHTTP_NO_HEADER_INDEX)) { + resp->location = sentry__string_from_wstr(buf); + } } uint64_t now = sentry__monotonic_time(); SENTRY_DEBUGF("request handled in %llums", now - started); -exit: - if (client->request) { - HINTERNET request = client->request; - client->request = NULL; +exit:; + HINTERNET request = InterlockedExchangePointer(&client->request, NULL); + if (request) { WinHttpCloseHandle(request); } sentry_free(url); diff --git a/tests/__init__.py b/tests/__init__.py index cf84e8c42..362a30394 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -127,13 +127,13 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs): if not is_pipe: sys.stdout.buffer.write(child.stdout) if expect_failure: - assert ( - child.returncode != 0 - ), f"command unexpectedly successful: {exe} {" ".join(args)}" + assert child.returncode != 0, ( + f"command unexpectedly successful: {exe} {" ".join(args)}" + ) else: - assert ( - child.returncode == 0 - ), f"command failed unexpectedly: {exe} {" ".join(args)}" + assert child.returncode == 0, ( + f"command failed unexpectedly: {exe} {" ".join(args)}" + ) if check and child.returncode: raise subprocess.CalledProcessError( child.returncode, child.args, output=child.stdout, stderr=child.stderr @@ -173,13 +173,13 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs): try: result = subprocess.run([*cmd, *args], cwd=cwd, env=env, check=check, **kwargs) if expect_failure: - assert ( - result.returncode != 0 - ), f"command unexpectedly successful: {cmd} {" ".join(args)}" + assert result.returncode != 0, ( + f"command unexpectedly successful: {cmd} {" ".join(args)}" + ) else: - assert ( - result.returncode == 0 - ), f"command failed unexpectedly: {cmd} {" ".join(args)}" + assert result.returncode == 0, ( + f"command failed unexpectedly: {cmd} {" ".join(args)}" + ) return result except subprocess.CalledProcessError: raise pytest.fail.Exception( @@ -343,15 +343,20 @@ def deserialize_from( headers = json.loads(line) length = headers["length"] payload = f.read(length) - if headers.get("type") in [ - "event", - "feedback", - "session", - "transaction", - "user_report", - "log", - "trace_metric", - ]: + if ( + headers.get("type") + in [ + "event", + "feedback", + "session", + "transaction", + "user_report", + "log", + "trace_metric", + ] + or headers.get("content_type") + == "application/vnd.sentry.attachment-ref+json" + ): rv = cls(headers=headers, payload=PayloadRef(json=json.loads(payload))) else: rv = cls(headers=headers, payload=payload) diff --git a/tests/test_integration_cache.py b/tests/test_integration_cache.py index aff10fa9f..f704e302f 100644 --- a/tests/test_integration_cache.py +++ b/tests/test_integration_cache.py @@ -3,9 +3,14 @@ import pytest from . import run -from .conditions import has_breakpad, has_files +from .conditions import has_breakpad, has_files, has_http -pytestmark = pytest.mark.skipif(not has_files, reason="tests need local filesystem") +pytestmark = [ + pytest.mark.skipif(not has_files, reason="tests need local filesystem"), + pytest.mark.skipif(not has_http, reason="tests need http transport"), +] + +unreachable_dsn = "http://uiaeosnrtdy@127.0.0.1:19999/123456" @pytest.mark.parametrize("cache_keep", [True, False]) @@ -22,10 +27,9 @@ ], ) def test_cache_keep(cmake, backend, cache_keep): - tmp_path = cmake( - ["sentry_example"], {"SENTRY_BACKEND": backend, "SENTRY_TRANSPORT": "none"} - ) + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) # capture run( @@ -33,6 +37,7 @@ def test_cache_keep(cmake, backend, cache_keep): "sentry_example", ["log", "crash"] + (["cache-keep"] if cache_keep else []), expect_failure=True, + env=env, ) assert not cache_dir.exists() or len(list(cache_dir.glob("*.envelope"))) == 0 @@ -42,6 +47,7 @@ def test_cache_keep(cmake, backend, cache_keep): tmp_path, "sentry_example", ["log", "no-setup"] + (["cache-keep"] if cache_keep else []), + env=env, ) assert cache_dir.exists() or cache_keep is False @@ -63,34 +69,42 @@ def test_cache_keep(cmake, backend, cache_keep): ], ) def test_cache_max_size(cmake, backend): - tmp_path = cmake( - ["sentry_example"], {"SENTRY_BACKEND": backend, "SENTRY_TRANSPORT": "none"} - ) + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) - # 5 x 2mb for i in range(5): run( tmp_path, "sentry_example", ["log", "cache-keep", "crash"], expect_failure=True, + env=env, ) - if cache_dir.exists(): - cache_files = list(cache_dir.glob("*.envelope")) - for f in cache_files: - with open(f, "r+b") as file: - file.truncate(2 * 1024 * 1024) - + # flush + cache run( tmp_path, "sentry_example", - ["log", "cache-keep", "no-setup"], + ["log", "no-http-retry", "cache-keep", "flush", "no-setup"], + env=env, ) - # max 4mb + # 5 x 2mb assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + for f in cache_files: + with open(f, "r+b") as file: + file.truncate(2 * 1024 * 1024) + + # max 4mb + run( + tmp_path, + "sentry_example", + ["log", "no-http-retry", "cache-keep", "no-setup"], + env=env, + ) + cache_files = list(cache_dir.glob("*.envelope")) assert len(cache_files) <= 2 assert sum(f.stat().st_size for f in cache_files) <= 4 * 1024 * 1024 @@ -109,19 +123,27 @@ def test_cache_max_size(cmake, backend): ], ) def test_cache_max_age(cmake, backend): - tmp_path = cmake( - ["sentry_example"], {"SENTRY_BACKEND": backend, "SENTRY_TRANSPORT": "none"} - ) + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) for i in range(5): run( tmp_path, "sentry_example", - ["log", "cache-keep", "crash"], + ["log", "no-http-retry", "cache-keep", "crash"], expect_failure=True, + env=env, ) + # flush + cache + run( + tmp_path, + "sentry_example", + ["log", "no-http-retry", "cache-keep", "flush", "no-setup"], + env=env, + ) + # 2,4,6,8,10 days old assert cache_dir.exists() cache_files = list(cache_dir.glob("*.envelope")) @@ -129,16 +151,16 @@ def test_cache_max_age(cmake, backend): mtime = time.time() - ((i + 1) * 2 * 24 * 60 * 60) os.utime(str(f), (mtime, mtime)) - # 0 days old + # cleanup (max 5 days) run( tmp_path, "sentry_example", - ["log", "cache-keep", "no-setup"], + ["log", "no-http-retry", "cache-keep", "no-setup"], + env=env, ) - # max 5 days cache_files = list(cache_dir.glob("*.envelope")) - assert len(cache_files) == 3 + assert len(cache_files) == 2 for f in cache_files: assert time.time() - f.stat().st_mtime <= 5 * 24 * 60 * 60 @@ -156,10 +178,9 @@ def test_cache_max_age(cmake, backend): ], ) def test_cache_max_items(cmake, backend): - tmp_path = cmake( - ["sentry_example"], {"SENTRY_BACKEND": backend, "SENTRY_TRANSPORT": "none"} - ) + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) for i in range(6): run( @@ -167,15 +188,74 @@ def test_cache_max_items(cmake, backend): "sentry_example", ["log", "cache-keep", "crash"], expect_failure=True, + env=env, ) + # flush + cache run( tmp_path, "sentry_example", - ["log", "cache-keep", "no-setup"], + ["log", "cache-keep", "flush", "no-setup"], + env=env, ) # max 5 items assert cache_dir.exists() cache_files = list(cache_dir.glob("*.envelope")) assert len(cache_files) == 5 + + +@pytest.mark.parametrize( + "backend", + [ + "inproc", + pytest.param( + "breakpad", + marks=pytest.mark.skipif( + not has_breakpad, reason="breakpad backend not available" + ), + ), + ], +) +def test_cache_max_items_with_retry(cmake, backend): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + # Create cache files via crash+restart cycles + for i in range(4): + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "crash"], + expect_failure=True, + env=env, + ) + + # flush + cache + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "flush", "no-setup"], + env=env, + ) + + # Pre-populate cache/ with retry-format envelope files + cache_dir.mkdir(parents=True, exist_ok=True) + for i in range(4): + ts = int(time.time() * 1000) + f = cache_dir / f"{ts}-00-00000000-0000-0000-0000-{i:012x}.envelope" + f.write_text("dummy envelope content") + + # Trigger sentry_init which runs cleanup + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # max 5 items total in cache/ + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) <= 5 diff --git a/tests/test_integration_crashpad.py b/tests/test_integration_crashpad.py index 3db4b4931..da9639790 100644 --- a/tests/test_integration_crashpad.py +++ b/tests/test_integration_crashpad.py @@ -826,7 +826,10 @@ def test_crashpad_cache_keep(cmake, httpserver, cache_keep): envelope = Envelope.deserialize_from(f) assert "dsn" in envelope.headers assert_meta(envelope, integration="crashpad") - assert_minidump(envelope) + # minidump is cached as an external file, not inline in the envelope + att_dirs = [d for d in cache_dir.iterdir() if d.is_dir()] + assert len(att_dirs) > 0, "expected external minidump dir" + assert list(att_dirs[0].glob("__sentry-attachments.json")) def test_crashpad_cache_max_size(cmake, httpserver): @@ -964,3 +967,50 @@ def test_crashpad_cache_max_age(cmake, httpserver): assert len(cache_files) == 3 for f in cache_files: assert time.time() - f.stat().st_mtime <= 5 * 24 * 60 * 60 + + +def test_crashpad_cache_external_minidump(cmake, httpserver): + """ + Crashpad crash report is converted to a cached envelope at startup. + The minidump is cached as an external file in cache// and + resolved into the envelope at send time. + """ + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") + + # First run: crash — crashpad handler uploads minidump directly + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "crash"], + expect_failure=True, + env=env, + ) + assert waiting.result + + # Second run: process_completed_reports converts crashpad report to + # cached envelope + external minidump in cache/ + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + # Verify cached envelope exists with external minidump dir + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + + att_dirs = [d for d in cache_dir.iterdir() if d.is_dir()] + assert len(att_dirs) > 0, "expected external attachment dir in cache" + att_files = [ + f for f in att_dirs[0].iterdir() if f.name != "__sentry-attachments.json" + ] + refs_files = list(att_dirs[0].glob("__sentry-attachments.json")) + assert len(refs_files) == 1, "expected __sentry-attachments.json" + assert len(att_files) > 0, "expected minidump file in attachment dir" diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index ec008315a..56040f9cc 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -59,6 +59,8 @@ def get_asan_crash_env(env): pytestmark = pytest.mark.skipif(not has_http, reason="tests need http") +unreachable_dsn = "http://uiaeosnrtdy@127.0.0.1:19999/123456" + # fmt: off auth_header = ( f"Sentry sentry_key=uiaeosnrtdy, sentry_version=7, sentry_client=sentry.native/{SENTRY_VERSION}" @@ -838,3 +840,414 @@ def test_native_crash_http(cmake, httpserver): assert_minidump(envelope) assert_breadcrumb(envelope) assert_attachment(envelope) + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_on_network_error(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "inproc"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + # unreachable port triggers CURLE_COULDNT_CONNECT + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "capture-event"], + env=env_unreachable, + ) + + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-00-" in str(cache_files[0].name) + envelope_uuid = cache_files[0].stem[-36:] + + # retry on next run with working server + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env_reachable, + ) + assert waiting.result + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + assert envelope.headers["event_id"] == envelope_uuid + assert_meta(envelope, integration="inproc") + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_multiple_attempts(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "inproc"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run(tmp_path, "sentry_example", ["log", "capture-event"], env=env) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-00-" in str(cache_files[0].name) + envelope_uuid = cache_files[0].stem[-36:] + envelope = Envelope.deserialize(cache_files[0].read_bytes()) + assert envelope.headers["event_id"] == envelope_uuid + + run(tmp_path, "sentry_example", ["log", "no-setup"], env=env) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-01-" in str(cache_files[0].name) + assert cache_files[0].stem[-36:] == envelope_uuid + + run(tmp_path, "sentry_example", ["log", "no-setup"], env=env) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-02-" in str(cache_files[0].name) + assert cache_files[0].stem[-36:] == envelope_uuid + + # exhaust remaining retries (max 6) + for i in range(4): + run(tmp_path, "sentry_example", ["log", "no-setup"], env=env) + + # discarded after max retries (cache_keep not enabled) + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_with_cache_keep(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "inproc"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "capture-event"], + env=env_unreachable, + ) + + assert cache_dir.exists() + assert len(list(cache_dir.glob("*.envelope"))) == 1 + + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env_reachable, + ) + assert waiting.result + + assert len(list(cache_dir.glob("*.envelope"))) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_cache_keep_max_attempts(cmake): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "capture-event"], + env=env, + ) + + assert cache_dir.exists() + assert len(list(cache_dir.glob("*.envelope"))) == 1 + + for _ in range(5): + run( + tmp_path, + "sentry_example", + ["log", "cache-keep", "no-setup"], + env=env, + ) + + assert cache_dir.exists() + assert len(list(cache_dir.glob("*.envelope"))) == 1 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_http_error_discards_envelope(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "inproc"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( + "Internal Server Error", status=500 + ) + + with httpserver.wait(timeout=10) as waiting: + run(tmp_path, "sentry_example", ["log", "capture-event"], env=env) + assert waiting.result + + # HTTP errors discard, not retry + cache_files = list(cache_dir.glob("*.envelope")) if cache_dir.exists() else [] + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_rate_limit_discards_envelope(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "inproc"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( + "Rate Limited", status=429, headers={"retry-after": "60"} + ) + + with httpserver.wait(timeout=10) as waiting: + run(tmp_path, "sentry_example", ["log", "capture-event"], env=env) + assert waiting.result + + # 429 discards, not retry + cache_files = list(cache_dir.glob("*.envelope")) if cache_dir.exists() else [] + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_multiple_success(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "capture-multiple"], + env=env_unreachable, + ) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 10 + + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + for _ in range(10): + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data( + "OK" + ) + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env_reachable, + ) + assert waiting.result + + assert len(httpserver.log) == 10 + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_multiple_network_error(cmake): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "capture-multiple"], + env=env, + ) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 10 + + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env, + ) + + # first envelope retried and bumped, rest untouched (stop on failure) + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 10 + assert len([f for f in cache_files if "-00-" in f.name]) == 9 + assert len([f for f in cache_files if "-01-" in f.name]) == 1 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_multiple_rate_limit(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "capture-multiple"], + env=env_unreachable, + ) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 10 + + # rate limit response followed by discards for the rest (rate limiter + # kicks in after the first 429) + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_request("/api/123456/envelope/").respond_with_data( + "Rate Limited", status=429, headers={"retry-after": "60"} + ) + + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env_reachable, + ) + + # first envelope gets 429, rest are discarded by rate limiter + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif(not has_files, reason="test needs a local filesystem") +def test_http_retry_session_on_network_error(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) + cache_dir = tmp_path.joinpath(".sentry-native/cache") + + env_unreachable = dict(os.environ, SENTRY_DSN=unreachable_dsn) + + run( + tmp_path, + "sentry_example", + ["log", "start-session"], + env=env_unreachable, + ) + + assert cache_dir.exists() + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-00-" in str(cache_files[0].name) + envelope_uuid = cache_files[0].stem[-36:] + + # second and third attempts still fail — envelope gets renamed each time + run(tmp_path, "sentry_example", ["log", "no-setup"], env=env_unreachable) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-01-" in str(cache_files[0].name) + assert cache_files[0].stem[-36:] == envelope_uuid + + run(tmp_path, "sentry_example", ["log", "no-setup"], env=env_unreachable) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 1 + assert "-02-" in str(cache_files[0].name) + assert cache_files[0].stem[-36:] == envelope_uuid + + # succeed on fourth attempt + env_reachable = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env_reachable, + ) + assert waiting.result + + assert len(httpserver.log) == 1 + envelope = Envelope.deserialize(httpserver.log[0][0].get_data()) + assert_session(envelope, {"init": True, "status": "exited", "errors": 0}) + + cache_files = list(cache_dir.glob("*.envelope")) + assert len(cache_files) == 0 + + +@pytest.mark.skipif( + not has_breakpad, + reason="test needs breakpad backend", +) +def test_breakpad_cache_external_minidump(cmake, httpserver): + """ + Breakpad crash produces a minidump that is cached externally. + On restart, the minidump is resolved from cache and included in the + sent envelope. + """ + tmp_path = cmake( + ["sentry_example"], + {"SENTRY_BACKEND": "breakpad", "SENTRY_TRANSPORT_COMPRESSION": "Off"}, + ) + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # First run: crash + run( + tmp_path, + "sentry_example", + ["log", "start-session", "crash"], + expect_failure=True, + env=get_asan_crash_env(env), + ) + + # Verify minidump was cached externally in cache// + cache_dir = os.path.join(tmp_path, ".sentry-native", "cache") + if os.path.isdir(cache_dir): + att_dirs = [ + d + for d in os.listdir(cache_dir) + if os.path.isdir(os.path.join(cache_dir, d)) + ] + if att_dirs: + att_dir = os.path.join(cache_dir, att_dirs[0]) + refs_files = [ + f for f in os.listdir(att_dir) if f == "__sentry-attachments.json" + ] + assert len(refs_files) > 0, "expected __sentry-attachments.json" + + # Second run: restart picks up crash envelope and resolves external attachments + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env, + ) + + assert len(httpserver.log) >= 1 + + # Find the crash envelope with a minidump + crash_envelope = None + for entry in httpserver.log: + body = entry[0].get_data() + envelope = Envelope.deserialize(body) + for item in envelope: + if item.headers.get("attachment_type") == "event.minidump": + crash_envelope = envelope + break + + assert crash_envelope is not None, "expected envelope with minidump" + assert_breakpad_crash(crash_envelope) diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py new file mode 100644 index 000000000..0707aea8b --- /dev/null +++ b/tests/test_integration_tus.py @@ -0,0 +1,312 @@ +import json +import os + +import pytest + +from . import ( + make_dsn, + run, + Envelope, + SENTRY_VERSION, +) +from .conditions import has_http + +pytestmark = pytest.mark.skipif(not has_http, reason="tests need http") + +# fmt: off +auth_header = ( + f"Sentry sentry_key=uiaeosnrtdy, sentry_version=7, sentry_client=sentry.native/{SENTRY_VERSION}" +) +# fmt: on + + +def test_tus_upload_large_attachment(cmake, httpserver): + tmp_path = cmake( + ["sentry_example"], + {"SENTRY_BACKEND": "none", "SENTRY_TRANSPORT_COMPRESSION": "Off"}, + ) + + upload_uri = "/api/123456/upload/abc123def456789/" + upload_qs = "length=104857600&signature=xyz" + location = httpserver.url_for(upload_uri) + "?" + upload_qs + + # TUS creation request (POST, no body) -> 201 + Location + httpserver.expect_oneshot_request( + "/api/123456/upload/", + headers={"tus-resumable": "1.0.0"}, + ).respond_with_data( + "OK", + status=201, + headers={"Location": location}, + ) + + # TUS upload request (PATCH with body) -> 204 + httpserver.expect_oneshot_request( + upload_uri, + method="PATCH", + headers={"tus-resumable": "1.0.0"}, + query_string=upload_qs, + ).respond_with_data("", status=204) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "no-setup", "large-attachment", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 3 + + # Find the create, upload, and envelope requests + create_req = None + upload_req = None + envelope_req = None + for entry in httpserver.log: + req = entry[0] + if req.path == "/api/123456/upload/" and req.method == "POST": + create_req = req + elif upload_uri in req.path and req.method == "PATCH": + upload_req = req + elif "/envelope/" in req.path: + envelope_req = req + + assert create_req is not None + assert upload_req is not None + assert envelope_req is not None + + # Verify TUS creation request headers + assert create_req.headers.get("tus-resumable") == "1.0.0" + upload_length = create_req.headers.get("upload-length") + assert upload_length is not None + assert int(upload_length) == 100 * 1024 * 1024 + + # Verify TUS upload request headers + assert upload_req.headers.get("tus-resumable") == "1.0.0" + assert upload_req.headers.get("content-type") == "application/offset+octet-stream" + assert upload_req.headers.get("upload-offset") == "0" + + # The envelope contains the event and the attachment-ref + body = envelope_req.get_data() + envelope = Envelope.deserialize(body) + attachment_ref = None + for item in envelope: + if ( + item.headers.get("content_type") + == "application/vnd.sentry.attachment-ref+json" + ): + if hasattr(item.payload, "json") and "location" in item.payload.json: + attachment_ref = item + break + + assert attachment_ref is not None + assert attachment_ref.payload.json["location"] == location + assert attachment_ref.headers.get("attachment_length") == 100 * 1024 * 1024 + + # large attachment files should be cleaned up after send + cache_dir = os.path.join(tmp_path, ".sentry-native", "cache") + if os.path.isdir(cache_dir): + subdirs = [ + d + for d in os.listdir(cache_dir) + if os.path.isdir(os.path.join(cache_dir, d)) + ] + assert subdirs == [] + + +def test_tus_upload_404_disables(cmake, httpserver): + tmp_path = cmake( + ["sentry_example"], + {"SENTRY_BACKEND": "none", "SENTRY_TRANSPORT_COMPRESSION": "Off"}, + ) + + httpserver.expect_oneshot_request( + "/api/123456/upload/", + ).respond_with_data("Not Found", status=404) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "no-setup", "large-attachment", "capture-event"], + env=env, + ) + + assert len(httpserver.log) == 2 + + # Find the envelope request + envelope_req = None + for entry in httpserver.log: + req = entry[0] + if "/envelope/" in req.path: + envelope_req = req + + assert envelope_req is not None + + # TUS 404: attachment-refs were cached but not resolved, so envelope has + # no attachment-ref items + body = envelope_req.get_data() + envelope = Envelope.deserialize(body) + for item in envelope: + assert ( + item.headers.get("content_type") + != "application/vnd.sentry.attachment-ref+json" + ) + + +def test_tus_crash_restart(cmake, httpserver): + tmp_path = cmake( + ["sentry_example"], + {"SENTRY_BACKEND": "inproc", "SENTRY_TRANSPORT_COMPRESSION": "Off"}, + ) + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + # First run: crash with large attachment (no server expectations needed) + run( + tmp_path, + "sentry_example", + ["log", "large-attachment", "crash"], + expect_failure=True, + env=env, + ) + + # Verify large attachment was persisted to disk in cache// + cache_dir = os.path.join(tmp_path, ".sentry-native", "cache") + assert os.path.isdir(cache_dir) + att_dirs = [ + d for d in os.listdir(cache_dir) if os.path.isdir(os.path.join(cache_dir, d)) + ] + assert len(att_dirs) > 0 + att_dir = os.path.join(cache_dir, att_dirs[0]) + att_files = [ + f + for f in os.listdir(att_dir) + if not f.endswith(".json") and os.path.isfile(os.path.join(att_dir, f)) + ] + refs_files = [f for f in os.listdir(att_dir) if f == "__sentry-attachments.json"] + assert len(att_files) > 0 + assert len(refs_files) > 0 + att_size = os.path.getsize(os.path.join(att_dir, att_files[0])) + assert att_size >= 100 * 1024 * 1024 + + upload_uri = "/api/123456/upload/abc123def456789/" + upload_qs = "length=104857600&signature=xyz" + location = httpserver.url_for(upload_uri) + "?" + upload_qs + + # Second run: restart picks up crash and uploads via TUS + httpserver.expect_oneshot_request( + "/api/123456/upload/", + headers={"tus-resumable": "1.0.0"}, + ).respond_with_data( + "OK", + status=201, + headers={"Location": location}, + ) + + httpserver.expect_oneshot_request( + upload_uri, + method="PATCH", + headers={"tus-resumable": "1.0.0"}, + query_string=upload_qs, + ).respond_with_data("", status=204) + + httpserver.expect_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=env, + ) + + assert len(httpserver.log) == 3 + + create_req = None + upload_req = None + envelope_req = None + for entry in httpserver.log: + req = entry[0] + if req.path == "/api/123456/upload/" and req.method == "POST": + create_req = req + elif upload_uri in req.path and req.method == "PATCH": + upload_req = req + elif "/envelope/" in req.path: + envelope_req = req + + assert create_req is not None + assert upload_req is not None + assert envelope_req is not None + + # Verify TUS creation request headers + assert create_req.headers.get("tus-resumable") == "1.0.0" + assert int(create_req.headers.get("upload-length")) == 100 * 1024 * 1024 + + # Verify TUS upload request headers + assert upload_req.headers.get("tus-resumable") == "1.0.0" + assert upload_req.headers.get("content-type") == "application/offset+octet-stream" + assert upload_req.headers.get("upload-offset") == "0" + + # The envelope contains the event and the attachment-ref + body = envelope_req.get_data() + envelope = Envelope.deserialize(body) + attachment_ref = None + for item in envelope: + if ( + item.headers.get("content_type") + == "application/vnd.sentry.attachment-ref+json" + ): + if hasattr(item.payload, "json") and "location" in item.payload.json: + attachment_ref = item + break + + assert attachment_ref is not None + assert attachment_ref.payload.json["location"] == location + assert attachment_ref.headers.get("attachment_length") == 100 * 1024 * 1024 + + # Large attachment dirs should be cleaned up after send + remaining_dirs = [ + d for d in os.listdir(cache_dir) if os.path.isdir(os.path.join(cache_dir, d)) + ] + assert remaining_dirs == [] + + +def test_small_attachment_no_tus(cmake, httpserver): + tmp_path = cmake( + ["sentry_example"], + {"SENTRY_BACKEND": "none", "SENTRY_TRANSPORT_COMPRESSION": "Off"}, + ) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + + run( + tmp_path, + "sentry_example", + ["log", "no-setup", "attachment", "capture-event"], + env=env, + ) + + # Only 1 request - no TUS upload for small attachments + assert len(httpserver.log) == 1 + req = httpserver.log[0][0] + assert "/envelope/" in req.path diff --git a/tests/test_unit.py b/tests/test_unit.py index c38fffb91..daa571257 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -5,7 +5,7 @@ def test_unit(cmake, unittest): - if unittest in ["basic_transport_thread_name"]: + if unittest in ["basic_transport_thread_name", "cache_keep"]: pytest.skip("excluded from unit test-suite") cwd = cmake( ["sentry_test_unit"], @@ -33,7 +33,7 @@ def test_unit_transport(cmake, unittest): def test_unit_with_test_path(cmake, unittest): - if unittest in ["basic_transport_thread_name"]: + if unittest in ["basic_transport_thread_name", "cache_keep"]: pytest.skip("excluded from unit test-suite") cwd = cmake( ["sentry_test_unit"], diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index fd16affca..3b3036259 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(sentry_test_unit test_path.c test_process.c test_ratelimiter.c + test_retry.c test_ringbuffer.c test_sampling.c test_scope.c diff --git a/tests/unit/test_cache.c b/tests/unit/test_cache.c index e161340bc..9cb4e045f 100644 --- a/tests/unit/test_cache.c +++ b/tests/unit/test_cache.c @@ -44,8 +44,10 @@ SENTRY_TEST(cache_keep) SKIP_TEST(); #endif SENTRY_TEST_OPTIONS_NEW(options); + TEST_ASSERT(!!options->transport); sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); sentry_options_set_cache_keep(options, true); + sentry_options_set_http_retry(options, false); sentry_init(options); sentry_path_t *cache_path @@ -80,6 +82,7 @@ SENTRY_TEST(cache_keep) TEST_ASSERT(!sentry__path_is_file(cached_envelope_path)); sentry__process_old_runs(options, 0); + sentry_flush(5000); TEST_ASSERT(!sentry__path_is_file(old_envelope_path)); TEST_ASSERT(sentry__path_is_file(cached_envelope_path)); @@ -243,6 +246,69 @@ SENTRY_TEST(cache_max_items) sentry_close(); } +SENTRY_TEST(cache_max_items_with_retry) +{ +#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS) + SKIP_TEST(); +#endif + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_cache_keep(options, true); + sentry_options_set_cache_max_items(options, 7); + sentry_init(options); + + sentry_path_t *cache_path + = sentry__path_join_str(options->database_path, "cache"); + TEST_ASSERT(!!cache_path); + TEST_ASSERT(sentry__path_remove_all(cache_path) == 0); + TEST_ASSERT(sentry__path_create_dir_all(cache_path) == 0); + + time_t now = time(NULL); + + // 5 cache-format files: 1,3,5,7,9 min old + for (int i = 0; i < 5; i++) { + sentry_uuid_t event_id = sentry_uuid_new_v4(); + char *filename = sentry__uuid_as_filename(&event_id, ".envelope"); + TEST_ASSERT(!!filename); + sentry_path_t *filepath = sentry__path_join_str(cache_path, filename); + sentry_free(filename); + + TEST_ASSERT(sentry__path_touch(filepath) == 0); + TEST_ASSERT(set_file_mtime(filepath, now - ((i * 2 + 1) * 60)) == 0); + sentry__path_free(filepath); + } + + // 5 retry-format files: 0,2,4,6,8 min old + for (int i = 0; i < 5; i++) { + sentry_uuid_t event_id = sentry_uuid_new_v4(); + char uuid[37]; + sentry_uuid_as_string(&event_id, uuid); + char filename[128]; + snprintf(filename, sizeof(filename), "%" PRIu64 "-00-%.36s.envelope", + (uint64_t)now, uuid); + sentry_path_t *filepath = sentry__path_join_str(cache_path, filename); + + TEST_ASSERT(sentry__path_touch(filepath) == 0); + TEST_ASSERT(set_file_mtime(filepath, now - (i * 2 * 60)) == 0); + sentry__path_free(filepath); + } + + sentry__cleanup_cache(options); + + int total_count = 0; + sentry_pathiter_t *iter = sentry__path_iter_directory(cache_path); + const sentry_path_t *entry; + while (iter && (entry = sentry__pathiter_next(iter)) != NULL) { + total_count++; + } + sentry__pathiter_free(iter); + + TEST_CHECK_INT_EQUAL(total_count, 7); + + sentry__path_free(cache_path); + sentry_close(); +} + SENTRY_TEST(cache_max_size_and_age) { #if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS) diff --git a/tests/unit/test_envelopes.c b/tests/unit/test_envelopes.c index ba160a29e..7b53cb5d4 100644 --- a/tests/unit/test_envelopes.c +++ b/tests/unit/test_envelopes.c @@ -1,6 +1,10 @@ +#include "sentry_attachment.h" +#include "sentry_database.h" #include "sentry_envelope.h" #include "sentry_json.h" +#include "sentry_options.h" #include "sentry_path.h" +#include "sentry_string.h" #include "sentry_testsupport.h" #include "sentry_utils.h" #include "sentry_value.h" @@ -722,6 +726,477 @@ SENTRY_TEST(deserialize_envelope_empty) test_deserialize_envelope_empty(buf, buf_len - 1); } +SENTRY_TEST(tus_upload_url) +{ + SENTRY_TEST_DSN_NEW_DEFAULT(dsn); + + char *url = sentry__dsn_get_upload_url(dsn); + TEST_CHECK_STRING_EQUAL(url, "https://sentry.invalid:443/api/42/upload/"); + sentry_free(url); + sentry__dsn_decref(dsn); + + TEST_CHECK(!sentry__dsn_get_upload_url(NULL)); +} + +SENTRY_TEST(tus_request_preparation) +{ + SENTRY_TEST_DSN_NEW_DEFAULT(dsn); + + // Test creation request (POST, no body) + sentry_prepared_http_request_t *req + = sentry__prepare_tus_create_request(9, dsn, NULL); + TEST_CHECK(!!req); + TEST_CHECK_STRING_EQUAL(req->method, "POST"); + TEST_CHECK_STRING_EQUAL( + req->url, "https://sentry.invalid:443/api/42/upload/"); + TEST_CHECK(!req->body_path); + TEST_CHECK_INT_EQUAL(req->body_len, 0); + TEST_CHECK(!req->body); + + bool has_tus_resumable = false; + bool has_upload_length = false; + bool has_content_type = false; + for (size_t i = 0; i < req->headers_len; i++) { + if (strcmp(req->headers[i].key, "tus-resumable") == 0) { + TEST_CHECK_STRING_EQUAL(req->headers[i].value, "1.0.0"); + has_tus_resumable = true; + } + if (strcmp(req->headers[i].key, "upload-length") == 0) { + TEST_CHECK_STRING_EQUAL(req->headers[i].value, "9"); + has_upload_length = true; + } + if (strcmp(req->headers[i].key, "content-type") == 0) { + has_content_type = true; + } + } + TEST_CHECK(has_tus_resumable); + TEST_CHECK(has_upload_length); + TEST_CHECK(!has_content_type); + + sentry__prepared_http_request_free(req); + + // Test upload request (PATCH with body) + const char *test_file_str = SENTRY_TEST_PATH_PREFIX "sentry_test_tus_file"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + TEST_CHECK_INT_EQUAL( + sentry__path_write_buffer(test_file_path, "test-data", 9), 0); + + const char *location = "https://sentry.invalid/api/42/upload/abc123/"; + req = sentry__prepare_tus_upload_request( + location, test_file_path, 9, dsn, NULL); + TEST_CHECK(!!req); + TEST_CHECK_STRING_EQUAL(req->method, "PATCH"); + TEST_CHECK_STRING_EQUAL(req->url, location); + TEST_CHECK(!!req->body_path); + TEST_CHECK_INT_EQUAL(req->body_len, 9); + TEST_CHECK(!req->body); + + has_tus_resumable = false; + has_content_type = false; + bool has_upload_offset = false; + for (size_t i = 0; i < req->headers_len; i++) { + if (strcmp(req->headers[i].key, "tus-resumable") == 0) { + TEST_CHECK_STRING_EQUAL(req->headers[i].value, "1.0.0"); + has_tus_resumable = true; + } + if (strcmp(req->headers[i].key, "content-type") == 0) { + TEST_CHECK_STRING_EQUAL( + req->headers[i].value, "application/offset+octet-stream"); + has_content_type = true; + } + if (strcmp(req->headers[i].key, "upload-offset") == 0) { + TEST_CHECK_STRING_EQUAL(req->headers[i].value, "0"); + has_upload_offset = true; + } + } + TEST_CHECK(has_tus_resumable); + TEST_CHECK(has_content_type); + TEST_CHECK(has_upload_offset); + + sentry__prepared_http_request_free(req); + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); + sentry__dsn_decref(dsn); +} + +SENTRY_TEST(attachment_ref_creation) +{ + const char *test_file_str + = SENTRY_TEST_PATH_PREFIX "sentry_test_attachment_ref"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + + // Small file: should be added to envelope + { + sentry_envelope_t *envelope = sentry__envelope_new(); + char small_data[] = "small"; + TEST_CHECK_INT_EQUAL(sentry__path_write_buffer(test_file_path, + small_data, sizeof(small_data) - 1), + 0); + + sentry_attachment_t *attachment + = sentry__attachment_from_path(sentry__path_clone(test_file_path)); + sentry_envelope_item_t *item + = sentry__envelope_add_attachment(envelope, attachment); + + TEST_CHECK(!!item); + TEST_CHECK_INT_EQUAL(sentry__envelope_get_item_count(envelope), 1); + size_t payload_len = 0; + TEST_CHECK_STRING_EQUAL( + sentry__envelope_item_get_payload(item, &payload_len), "small"); + + sentry__attachment_free(attachment); + sentry_envelope_free(envelope); + } + + // Large file (>= threshold): should be skipped by envelope + { + sentry_envelope_t *envelope = sentry__envelope_new(); + size_t large_size = 100 * 1024 * 1024; + FILE *f = fopen(test_file_str, "wb"); + TEST_CHECK(!!f); + fseek(f, (long)(large_size - 1), SEEK_SET); + fputc(0, f); + fclose(f); + + sentry_attachment_t *attachment + = sentry__attachment_from_path(sentry__path_clone(test_file_path)); + sentry_envelope_item_t *item + = sentry__envelope_add_attachment(envelope, attachment); + + TEST_CHECK(!item); + TEST_CHECK_INT_EQUAL(sentry__envelope_get_item_count(envelope), 0); + + sentry__attachment_free(attachment); + sentry_envelope_free(envelope); + } + + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); +} + +SENTRY_TEST(attachment_ref_from_path) +{ + const char *test_file_str + = SENTRY_TEST_PATH_PREFIX "sentry_test_attachment_ref_from_path"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + + // Small file: should be added to envelope + { + sentry_envelope_t *envelope = sentry__envelope_new(); + char small_data[] = "small"; + TEST_CHECK_INT_EQUAL(sentry__path_write_buffer(test_file_path, + small_data, sizeof(small_data) - 1), + 0); + + sentry_envelope_item_t *item = sentry__envelope_add_from_path( + envelope, test_file_path, "attachment"); + + TEST_CHECK(!!item); + TEST_CHECK_INT_EQUAL(sentry__envelope_get_item_count(envelope), 1); + size_t payload_len = 0; + TEST_CHECK_STRING_EQUAL( + sentry__envelope_item_get_payload(item, &payload_len), "small"); + + sentry_envelope_free(envelope); + } + + // Large file (>= threshold): should return NULL + { + sentry_envelope_t *envelope = sentry__envelope_new(); + size_t large_size = 100 * 1024 * 1024; + FILE *f = fopen(test_file_str, "wb"); + TEST_CHECK(!!f); + fseek(f, (long)(large_size - 1), SEEK_SET); + fputc(0, f); + fclose(f); + + sentry_envelope_item_t *item = sentry__envelope_add_from_path( + envelope, test_file_path, "attachment"); + + TEST_CHECK(!item); + TEST_CHECK_INT_EQUAL(sentry__envelope_get_item_count(envelope), 0); + + sentry_envelope_free(envelope); + } + + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); +} + +SENTRY_TEST(attachment_ref_copy) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_init(options); + + sentry_uuid_t event_id + = sentry_uuid_from_string("c993afb6-b4ac-48a6-b61b-2558e601d65d"); + + const char *test_file_str + = SENTRY_TEST_PATH_PREFIX "sentry_test_minidump.dmp"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + FILE *f = fopen(test_file_str, "wb"); + TEST_CHECK(!!f); + fputs("minidump_data", f); + fclose(f); + + sentry_attachment_t *attachment + = sentry__attachment_from_path(sentry__path_clone(test_file_path)); + attachment->type = MINIDUMP; + sentry_attachment_set_content_type(attachment, "application/x-dmp"); + + // cache_external_attachments copies the file (not under run_path) + sentry_path_t *db_path = NULL; + SENTRY_WITH_OPTIONS (opts) { + db_path = sentry__path_clone(opts->database_path); + sentry__cache_external_attachments( + opts->run->cache_path, &event_id, attachment, NULL); + } + + // original file still exists (copied, not moved) + TEST_CHECK(sentry__path_is_file(test_file_path)); + + // attachment file exists in cache dir + sentry_path_t *cache_dir = sentry__path_join_str(db_path, "cache"); + sentry_path_t *event_dir = sentry__path_join_str( + cache_dir, "c993afb6-b4ac-48a6-b61b-2558e601d65d"); + sentry__path_free(cache_dir); + sentry_path_t *att_file + = sentry__path_join_str(event_dir, "sentry_test_minidump.dmp"); + TEST_CHECK(sentry__path_is_file(att_file)); + + // __sentry-attachments.json exists with correct metadata + sentry_path_t *refs_path + = sentry__path_join_str(event_dir, "__sentry-attachments.json"); + TEST_CHECK(sentry__path_is_file(refs_path)); + + size_t refs_len = 0; + char *refs_buf = sentry__path_read_to_buffer(refs_path, &refs_len); + TEST_CHECK(!!refs_buf); + if (refs_buf) { + sentry_value_t refs = sentry__value_from_json(refs_buf, refs_len); + sentry_free(refs_buf); + TEST_CHECK(sentry_value_get_type(refs) == SENTRY_VALUE_TYPE_LIST); + TEST_CHECK(sentry_value_get_length(refs) == 1); + sentry_value_t ref_entry = sentry_value_get_by_index(refs, 0); + TEST_CHECK_STRING_EQUAL(sentry_value_as_string(sentry_value_get_by_key( + ref_entry, "filename")), + "sentry_test_minidump.dmp"); + TEST_CHECK_STRING_EQUAL(sentry_value_as_string(sentry_value_get_by_key( + ref_entry, "content_type")), + "application/x-dmp"); + TEST_CHECK_STRING_EQUAL(sentry_value_as_string(sentry_value_get_by_key( + ref_entry, "attachment_type")), + "event.minidump"); + sentry_value_decref(refs); + } + + sentry__path_free(refs_path); + sentry__path_free(att_file); + sentry__path_free(event_dir); + sentry__path_free(db_path); + sentry__attachment_free(attachment); + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); + sentry_close(); +} + +SENTRY_TEST(attachment_ref_move) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_init(options); + + sentry_uuid_t event_id + = sentry_uuid_from_string("c993afb6-b4ac-48a6-b61b-2558e601d65d"); + + // create minidump file inside the run directory (SDK-owned) + sentry_path_t *run_path = NULL; + sentry_path_t *db_path = NULL; + SENTRY_WITH_OPTIONS (opts) { + run_path = sentry__path_clone(opts->run->run_path); + db_path = sentry__path_clone(opts->database_path); + } + TEST_CHECK(!!run_path); + sentry_path_t *src_path + = sentry__path_join_str(run_path, "test_minidump.dmp"); + +#ifdef SENTRY_PLATFORM_WINDOWS + FILE *f = _wfopen(src_path->path_w, L"wb"); +#else + FILE *f = fopen(src_path->path, "wb"); +#endif + TEST_CHECK(!!f); + fputs("minidump_data", f); + fclose(f); + + sentry_attachment_t *attachment + = sentry__attachment_from_path(sentry__path_clone(src_path)); + attachment->type = MINIDUMP; + + // cache with run_path → file is renamed (moved) + SENTRY_WITH_OPTIONS (opts) { + sentry__cache_external_attachments( + opts->run->cache_path, &event_id, attachment, run_path); + } + + // run-dir file is gone (renamed) + TEST_CHECK(!sentry__path_is_file(src_path)); + + // attachment file moved to cache dir + sentry_path_t *cache_dir = sentry__path_join_str(db_path, "cache"); + sentry_path_t *event_dir = sentry__path_join_str( + cache_dir, "c993afb6-b4ac-48a6-b61b-2558e601d65d"); + sentry__path_free(cache_dir); + sentry_path_t *att_file + = sentry__path_join_str(event_dir, "test_minidump.dmp"); + TEST_CHECK(sentry__path_is_file(att_file)); + + // __sentry-attachments.json exists + sentry_path_t *refs_path + = sentry__path_join_str(event_dir, "__sentry-attachments.json"); + TEST_CHECK(sentry__path_is_file(refs_path)); + + sentry__path_free(refs_path); + sentry__path_free(att_file); + sentry__path_free(event_dir); + sentry__path_free(db_path); + sentry__attachment_free(attachment); + sentry__path_free(src_path); + sentry__path_free(run_path); + sentry_close(); +} + +static void +send_restore_envelope(sentry_envelope_t *envelope, void *data) +{ + int *called = data; + *called += 1; + sentry_envelope_free(envelope); +} + +SENTRY_TEST(attachment_ref_restore) +{ +#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS) + SKIP_TEST(); +#endif + const char *db_str = SENTRY_TEST_PATH_PREFIX ".sentry-native"; + + sentry_uuid_t event_id + = sentry_uuid_from_string("c993afb6-b4ac-48a6-b61b-2558e601d65d"); + + // set up old run dir with a stripped envelope (no attachment-ref items) + sentry_path_t *db_path = sentry__path_from_str(db_str); + sentry_path_t *old_run_path = sentry__path_join_str(db_path, "old.run"); + TEST_ASSERT(sentry__path_create_dir_all(old_run_path) == 0); + + // build attachment + __sentry-attachments.json in db/cache// + sentry_path_t *cache_dir = sentry__path_join_str(db_path, "cache"); + sentry_path_t *event_att_dir = sentry__path_join_str( + cache_dir, "c993afb6-b4ac-48a6-b61b-2558e601d65d"); + sentry__path_free(cache_dir); + TEST_ASSERT(sentry__path_create_dir_all(event_att_dir) == 0); + + sentry_path_t *att_file + = sentry__path_join_str(event_att_dir, "test_minidump.dmp"); + const char *minidump_data = "minidump_data"; + size_t minidump_size = strlen(minidump_data); + FILE *f = fopen(att_file->path, "wb"); + TEST_ASSERT(!!f); + fputs(minidump_data, f); + fclose(f); + + // write __sentry-attachments.json + sentry_value_t refs = sentry_value_new_list(); + sentry_value_t ref_obj = sentry_value_new_object(); + sentry_value_set_by_key( + ref_obj, "filename", sentry_value_new_string("test_minidump.dmp")); + sentry_value_set_by_key( + ref_obj, "attachment_type", sentry_value_new_string("event.minidump")); + sentry_value_set_by_key(ref_obj, "attachment_length", + sentry_value_new_uint64((uint64_t)minidump_size)); + sentry_value_append(refs, ref_obj); + sentry_jsonwriter_t *jw = sentry__jsonwriter_new_sb(NULL); + sentry__jsonwriter_write_value(jw, refs); + size_t refs_buf_len = 0; + char *refs_buf = sentry__jsonwriter_into_string(jw, &refs_buf_len); + sentry_value_decref(refs); + sentry_path_t *refs_path + = sentry__path_join_str(event_att_dir, "__sentry-attachments.json"); + TEST_ASSERT( + sentry__path_write_buffer(refs_path, refs_buf, refs_buf_len) == 0); + sentry_free(refs_buf); + + // build envelope with only the event (attachment-refs stripped at persist) + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry_value_t event = sentry_value_new_object(); + sentry_value_set_by_key( + event, "event_id", sentry__value_new_uuid(&event_id)); + sentry__envelope_add_event(envelope, event); + TEST_ASSERT(sentry__envelope_get_item_count(envelope) == 1); + + // write stripped envelope to old run dir + sentry_path_t *envelope_path = sentry__path_join_str( + old_run_path, "c993afb6-b4ac-48a6-b61b-2558e601d65d.envelope"); + TEST_ASSERT(sentry_envelope_write_to_path(envelope, envelope_path) == 0); + sentry_envelope_free(envelope); + + // init sentry with function transport — process_old_runs runs during init + int called = 0; + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_transport_t *transport = sentry_transport_new(send_restore_envelope); + sentry_transport_set_state(transport, &called); + sentry_options_set_transport(options, transport); + sentry_init(options); + + // transport callback should have been called with the raw envelope + TEST_CHECK(called >= 1); + + // attachment + __sentry-attachments.json remain in cache for transport to + // resolve via TUS + TEST_CHECK(sentry__path_is_file(att_file)); + TEST_CHECK(sentry__path_is_file(refs_path)); + + sentry__path_free(envelope_path); + sentry__path_free(refs_path); + sentry__path_free(att_file); + sentry__path_free(event_att_dir); + sentry__path_free(old_run_path); + sentry__path_free(db_path); + sentry_close(); +} + +SENTRY_TEST(attachment_ref_raw_content_type) +{ + const char *test_file_str = SENTRY_TEST_PATH_PREFIX "sentry_test_raw_ref"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + + // write a minimal envelope to disk so we can load it as raw + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry_value_t event = sentry_value_new_object(); + sentry__envelope_add_event(envelope, event); + TEST_ASSERT(sentry_envelope_write_to_path(envelope, test_file_path) == 0); + sentry_envelope_free(envelope); + + // load as raw and append an attachment-ref with content_type + sentry_envelope_t *raw = sentry__envelope_from_path(test_file_path); + TEST_ASSERT(!!raw); + sentry__envelope_add_attachment_ref(raw, "https://up.example.com/abc", + "dump.dmp", "application/x-dmp", "event.minidump", + sentry_value_new_int32(42)); + + size_t size = 0; + char *serialized = sentry_envelope_serialize(raw, &size); + TEST_CHECK(!!serialized); + TEST_CHECK(!!strstr(serialized, "\"content_type\":\"application/x-dmp\"")); + sentry_free(serialized); + + sentry_envelope_free(raw); + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); +} + SENTRY_TEST(deserialize_envelope_invalid) { TEST_CHECK(!sentry_envelope_deserialize("", 0)); @@ -735,3 +1210,48 @@ SENTRY_TEST(deserialize_envelope_invalid) snprintf(buf, sizeof(buf), "{}\n{\"length\":%zu}\n", SIZE_MAX); TEST_CHECK(!sentry_envelope_deserialize(buf, strlen(buf))); } + +static bool +tus_mock_send(void *client, sentry_prepared_http_request_t *req, + sentry_http_response_t *resp) +{ + (void)client; + (void)req; + resp->status_code = 201; + resp->location = sentry__string_clone("https://sentry.invalid/upload/abc"); + return true; +} + +SENTRY_TEST(tus_file_attachment_preserves_original) +{ + const char *test_file_str + = SENTRY_TEST_PATH_PREFIX "sentry_test_tus_preserve"; + sentry_path_t *test_file_path = sentry__path_from_str(test_file_str); + + size_t large_size = 100 * 1024 * 1024; + FILE *f = fopen(test_file_str, "wb"); + TEST_CHECK(!!f); + fseek(f, (long)(large_size - 1), SEEK_SET); + fputc(0, f); + fclose(f); + + sentry_transport_t *transport + = sentry__http_transport_new(NULL, tus_mock_send); + TEST_CHECK(!!transport); + + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_transport(options, transport); + sentry_options_add_attachment(options, test_file_str); + sentry_init(options); + + sentry_capture_event( + sentry_value_new_message_event(SENTRY_LEVEL_INFO, NULL, "test")); + + sentry_close(); + + TEST_CHECK(sentry__path_is_file(test_file_path)); + + sentry__path_remove(test_file_path); + sentry__path_free(test_file_path); +} diff --git a/tests/unit/test_path.c b/tests/unit/test_path.c index 1013f6093..a2e7989a1 100644 --- a/tests/unit/test_path.c +++ b/tests/unit/test_path.c @@ -339,3 +339,82 @@ SENTRY_TEST(path_rename) sentry__path_free(dst); sentry__path_free(src); } + +SENTRY_TEST(path_dir_size) +{ + sentry_path_t *dir + = sentry__path_from_str(SENTRY_TEST_PATH_PREFIX ".dir-size"); + TEST_ASSERT(!!dir); + + sentry__path_remove_all(dir); + + TEST_CHECK(sentry__path_get_dir_size(dir) == 0); + + sentry__path_create_dir_all(dir); + + sentry_path_t *file_a = sentry__path_join_str(dir, "a.txt"); + TEST_ASSERT(!!file_a); + sentry__path_write_buffer(file_a, "hello", 5); + + sentry_path_t *file_b = sentry__path_join_str(dir, "b.txt"); + TEST_ASSERT(!!file_b); + sentry__path_write_buffer(file_b, "world!", 6); + + TEST_CHECK(sentry__path_get_dir_size(dir) == 11); + + TEST_CHECK(sentry__path_get_dir_size(file_a) == 0); + + sentry__path_remove_all(dir); + sentry__path_free(file_b); + sentry__path_free(file_a); + sentry__path_free(dir); +} + +SENTRY_TEST(path_copy) +{ +#if defined(SENTRY_PLATFORM_NX) + SKIP_TEST(); +#endif + sentry_path_t *src + = sentry__path_from_str(SENTRY_TEST_PATH_PREFIX ".copy-src"); + TEST_ASSERT(!!src); + sentry_path_t *dst + = sentry__path_from_str(SENTRY_TEST_PATH_PREFIX ".copy-dst"); + TEST_ASSERT(!!dst); + + // cleanup + sentry__path_remove_all(src); + sentry__path_remove_all(dst); + + // copy file with content preserved + sentry__path_write_buffer(src, "hello", 5); + TEST_CHECK(sentry__path_copy(src, dst) == 0); + TEST_CHECK(sentry__path_is_file(src)); + TEST_CHECK(sentry__path_is_file(dst)); + size_t len = 0; + char *buf = sentry__path_read_to_buffer(dst, &len); + TEST_ASSERT(!!buf); + TEST_CHECK(len == 5); + TEST_CHECK(memcmp(buf, "hello", 5) == 0); + sentry_free(buf); + sentry__path_remove(dst); + + // overwrite existing dst + sentry__path_write_buffer(dst, "dst-data", 8); + TEST_CHECK(sentry__path_copy(src, dst) == 0); + TEST_CHECK(sentry__path_is_file(src)); + buf = sentry__path_read_to_buffer(dst, &len); + TEST_ASSERT(!!buf); + TEST_CHECK(len == 5); + TEST_CHECK(memcmp(buf, "hello", 5) == 0); + sentry_free(buf); + sentry__path_remove(dst); + + // copy nonexistent src + sentry__path_remove(src); + TEST_CHECK(sentry__path_copy(src, dst) != 0); + + sentry__path_remove_all(dst); + sentry__path_free(dst); + sentry__path_free(src); +} diff --git a/tests/unit/test_retry.c b/tests/unit/test_retry.c new file mode 100644 index 000000000..736a2e590 --- /dev/null +++ b/tests/unit/test_retry.c @@ -0,0 +1,494 @@ +#include "sentry_core.h" +#include "sentry_database.h" +#include "sentry_envelope.h" +#include "sentry_options.h" +#include "sentry_path.h" +#include "sentry_retry.h" +#include "sentry_scope.h" +#include "sentry_session.h" +#include "sentry_testsupport.h" +#include "sentry_transport.h" +#include "sentry_utils.h" +#include "sentry_uuid.h" + +#include + +static int +count_envelope_files(const sentry_path_t *dir) +{ + int count = 0; + sentry_pathiter_t *iter = sentry__path_iter_directory(dir); + const sentry_path_t *file; + while (iter && (file = sentry__pathiter_next(iter)) != NULL) { + if (sentry__path_ends_with(file, ".envelope")) { + count++; + } + } + sentry__pathiter_free(iter); + return count; +} + +static int +find_envelope_attempt(const sentry_path_t *dir) +{ + sentry_pathiter_t *iter = sentry__path_iter_directory(dir); + const sentry_path_t *file; + while (iter && (file = sentry__pathiter_next(iter)) != NULL) { + if (!sentry__path_ends_with(file, ".envelope")) { + continue; + } + const char *name = sentry__path_filename(file); + uint64_t ts; + int attempt; + const char *uuid; + if (sentry__parse_cache_filename(name, &ts, &attempt, &uuid)) { + sentry__pathiter_free(iter); + return attempt; + } + } + sentry__pathiter_free(iter); + return -1; +} + +static void +write_retry_file(const sentry_run_t *run, uint64_t timestamp, int retry_count, + const sentry_uuid_t *event_id) +{ + sentry_envelope_t *envelope = sentry__envelope_new(); + sentry_value_t event = sentry__value_new_event_with_id(event_id); + sentry__envelope_add_event(envelope, event); + + char uuid[37]; + sentry_uuid_as_string(event_id, uuid); + + sentry_path_t *path + = sentry__run_make_cache_path(run, timestamp, retry_count, uuid); + (void)sentry_envelope_write_to_path(envelope, path); + sentry__path_free(path); + sentry_envelope_free(envelope); +} + +typedef struct { + int status_code; + size_t count; +} retry_test_ctx_t; + +static int +test_send_cb(sentry_envelope_t *envelope, const char *uuid, void *_ctx) +{ + (void)envelope; + (void)uuid; + retry_test_ctx_t *ctx = _ctx; + ctx->count++; + return ctx->status_code; +} + +SENTRY_TEST(retry_filename) +{ + uint64_t ts; + int count; + const char *uuid; + + TEST_CHECK(sentry__parse_cache_filename( + "1234567890-00-abcdefab-1234-5678-9abc-def012345678.envelope", &ts, + &count, &uuid)); + TEST_CHECK_UINT64_EQUAL(ts, 1234567890); + TEST_CHECK_INT_EQUAL(count, 0); + TEST_CHECK(strncmp(uuid, "abcdefab-1234-5678-9abc-def012345678", 36) == 0); + + TEST_CHECK(sentry__parse_cache_filename( + "999-04-abcdefab-1234-5678-9abc-def012345678.envelope", &ts, &count, + &uuid)); + TEST_CHECK_UINT64_EQUAL(ts, 999); + TEST_CHECK_INT_EQUAL(count, 4); + + // negative count + TEST_CHECK(!sentry__parse_cache_filename( + "123--01-abcdefab-1234-5678-9abc-def012345678.envelope", &ts, &count, + &uuid)); + + // cache filename (no timestamp/count) + TEST_CHECK(!sentry__parse_cache_filename( + "abcdefab-1234-5678-9abc-def012345678.envelope", &ts, &count, &uuid)); + + // missing .envelope suffix + TEST_CHECK(!sentry__parse_cache_filename( + "123-00-abcdefab-1234-5678-9abc-def012345678.txt", &ts, &count, &uuid)); +} + +SENTRY_TEST(retry_make_cache_path) +{ +#if defined(SENTRY_PLATFORM_NX) || defined(SENTRY_PLATFORM_PS) + SKIP_TEST(); +#endif + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + const char *uuid = "abcdefab-1234-5678-9abc-def012345678"; + + // count >= 0 → retry format + sentry_path_t *path + = sentry__run_make_cache_path(options->run, 1000, 2, uuid); + TEST_CHECK_STRING_EQUAL(sentry__path_filename(path), + "1000-02-abcdefab-1234-5678-9abc-def012345678.envelope"); + sentry__path_free(path); + + // count < 0 → cache format + path = sentry__run_make_cache_path(options->run, 0, -1, uuid); + TEST_CHECK_STRING_EQUAL(sentry__path_filename(path), + "abcdefab-1234-5678-9abc-def012345678.envelope"); + sentry__path_free(path); + + sentry_close(); +} + +SENTRY_TEST(retry_throttle) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + sentry__path_remove_all(options->run->cache_path); + sentry__path_create_dir_all(options->run->cache_path); + + uint64_t old_ts + = sentry__usec_time() / 1000 - 10 * sentry__retry_backoff(0); + sentry_uuid_t ids[4]; + for (int i = 0; i < 4; i++) { + ids[i] = sentry_uuid_new_v4(); + write_retry_file(options->run, old_ts, 0, &ids[i]); + } + + TEST_CHECK_INT_EQUAL(count_envelope_files(options->run->cache_path), 4); + + retry_test_ctx_t ctx = { 200, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 4); + TEST_CHECK_INT_EQUAL(count_envelope_files(options->run->cache_path), 0); + + sentry__retry_free(retry); + sentry_close(); +} + +SENTRY_TEST(retry_skew) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + sentry__path_remove_all(options->run->cache_path); + sentry__path_create_dir_all(options->run->cache_path); + + // future timestamp simulates clock moving backward + uint64_t future_ts = sentry__usec_time() / 1000 + 1000000; + sentry_uuid_t event_id = sentry_uuid_new_v4(); + write_retry_file(options->run, future_ts, 0, &event_id); + + retry_test_ctx_t ctx = { 200, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + + // item should NOT be processed due to backoff (clock backward) + TEST_CHECK_INT_EQUAL(ctx.count, 0); + + sentry__retry_free(retry); + sentry_close(); +} + +SENTRY_TEST(retry_result) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + const sentry_path_t *cache_path = options->run->cache_path; + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + + uint64_t old_ts + = sentry__usec_time() / 1000 - 10 * sentry__retry_backoff(0); + sentry_uuid_t event_id = sentry_uuid_new_v4(); + + // 1. Success (200) → removes + write_retry_file(options->run, old_ts, 0, &event_id); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 0); + + retry_test_ctx_t ctx = { 200, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0); + + // 2. Rate limited (429) → removes + write_retry_file(options->run, old_ts, 0, &event_id); + ctx = (retry_test_ctx_t) { 429, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0); + + // 3. Discard (0) → removes + write_retry_file(options->run, old_ts, 0, &event_id); + ctx = (retry_test_ctx_t) { 0, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0); + + // 4. Network error → bumps count + write_retry_file(options->run, old_ts, 0, &event_id); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 0); + + ctx = (retry_test_ctx_t) { -1, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 1); + + // 5. Network error at last attempt → removed + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + uint64_t very_old_ts + = sentry__usec_time() / 1000 - 2 * sentry__retry_backoff(5); + write_retry_file(options->run, very_old_ts, 5, &event_id); + ctx = (retry_test_ctx_t) { -1, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0); + + sentry__retry_free(retry); + sentry_close(); +} + +SENTRY_TEST(retry_session) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_release(options, "test@1.0.0"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + sentry__path_remove_all(options->run->cache_path); + sentry__path_create_dir_all(options->run->cache_path); + + sentry_session_t *session = NULL; + SENTRY_WITH_SCOPE (scope) { + session = sentry__session_new(scope); + } + TEST_ASSERT(!!session); + sentry_envelope_t *envelope = sentry__envelope_new(); + TEST_ASSERT(!!envelope); + sentry__envelope_add_session(envelope, session); + + TEST_CHECK(sentry__run_write_cache(options->run, envelope, 0)); + TEST_CHECK_INT_EQUAL(count_envelope_files(options->run->cache_path), 1); + + sentry_envelope_free(envelope); + sentry__session_free(session); + sentry__retry_free(retry); + sentry_close(); +} + +SENTRY_TEST(retry_cache) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_options_set_cache_keep(options, 1); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + const sentry_path_t *cache_path = options->run->cache_path; + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + + uint64_t old_ts = sentry__usec_time() / 1000 - 2 * sentry__retry_backoff(5); + sentry_uuid_t event_id = sentry_uuid_new_v4(); + write_retry_file(options->run, old_ts, 5, &event_id); + + char uuid_str[37]; + sentry_uuid_as_string(&event_id, uuid_str); + char cache_name[46]; + snprintf(cache_name, sizeof(cache_name), "%.36s.envelope", uuid_str); + sentry_path_t *cached = sentry__path_join_str(cache_path, cache_name); + + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1); + TEST_CHECK(!sentry__path_is_file(cached)); + + // Network error on a file at count=5 with max_retries=6 → renames to + // cache format (.envelope) + retry_test_ctx_t ctx = { -1, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1); + TEST_CHECK(sentry__path_is_file(cached)); + + // Success on a file at count=5 → also renames to cache format + // (cache_keep preserves all envelopes regardless of send outcome) + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + write_retry_file(options->run, old_ts, 5, &event_id); + TEST_CHECK(!sentry__path_is_file(cached)); + + ctx = (retry_test_ctx_t) { 200, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 1); + TEST_CHECK(sentry__path_is_file(cached)); + + sentry__retry_free(retry); + sentry__path_free(cached); + sentry_close(); +} + +static int retry_func_calls = 0; + +static void +mock_retry_func(void *state) +{ + (void)state; + retry_func_calls++; +} + +static void +noop_send(sentry_envelope_t *envelope, void *state) +{ + (void)state; + sentry_envelope_free(envelope); +} + +SENTRY_TEST(transport_retry) +{ + // no retry_func → no-op + sentry_transport_t *transport = sentry_transport_new(noop_send); + retry_func_calls = 0; + sentry_transport_retry(transport); + TEST_CHECK_INT_EQUAL(retry_func_calls, 0); + + // with retry_func → calls it + sentry__transport_set_retry_func(transport, mock_retry_func); + sentry_transport_retry(transport); + TEST_CHECK_INT_EQUAL(retry_func_calls, 1); + + // NULL transport → no-op + sentry_transport_retry(NULL); + TEST_CHECK_INT_EQUAL(retry_func_calls, 1); + + sentry_transport_free(transport); +} + +SENTRY_TEST(retry_backoff) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + const sentry_path_t *cache_path = options->run->cache_path; + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + + uint64_t base = sentry__retry_backoff(0); + uint64_t ref = sentry__usec_time() / 1000 - 10 * base; + + // retry 0: 10*base old, eligible (backoff=base) + sentry_uuid_t id1 = sentry_uuid_new_v4(); + write_retry_file(options->run, ref, 0, &id1); + + // retry 1: 1*base old, not yet eligible (backoff=2*base) + sentry_uuid_t id2 = sentry_uuid_new_v4(); + write_retry_file(options->run, ref + 9 * base, 1, &id2); + + // retry 1: 10*base old, eligible (backoff=2*base) + sentry_uuid_t id3 = sentry_uuid_new_v4(); + write_retry_file(options->run, ref, 1, &id3); + + // retry 2: 2*base old, not eligible (backoff=4*base) + sentry_uuid_t id4 = sentry_uuid_new_v4(); + write_retry_file(options->run, ref + 8 * base, 2, &id4); + + // With backoff: only eligible ones (id1 and id3) are sent + retry_test_ctx_t ctx = { 200, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 2); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 2); + + // Startup scan (no backoff check): remaining 2 files are sent + ctx = (retry_test_ctx_t) { 200, 0 }; + sentry__retry_send(retry, UINT64_MAX, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 2); + TEST_CHECK_INT_EQUAL(count_envelope_files(cache_path), 0); + + // Verify backoff calculation + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(0), base); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(1), base * 2); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(2), base * 4); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(3), base * 8); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(4), base * 16); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(5), base * 32); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(6), base * 32); + TEST_CHECK_UINT64_EQUAL(sentry__retry_backoff(-1), base); + + sentry__retry_free(retry); + sentry_close(); +} + +SENTRY_TEST(retry_trigger) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_options_set_http_retry(options, false); + sentry_init(options); + + sentry_retry_t *retry = sentry__retry_new(options); + TEST_ASSERT(!!retry); + + const sentry_path_t *cache_path = options->run->cache_path; + sentry__path_remove_all(cache_path); + sentry__path_create_dir_all(cache_path); + + uint64_t old_ts + = sentry__usec_time() / 1000 - 10 * sentry__retry_backoff(0); + sentry_uuid_t event_id = sentry_uuid_new_v4(); + write_retry_file(options->run, old_ts, 0, &event_id); + + // UINT64_MAX (trigger mode) bypasses backoff: bumps count + retry_test_ctx_t ctx = { -1, 0 }; + sentry__retry_send(retry, UINT64_MAX, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 1); + + // second call: bumps again because UINT64_MAX skips backoff + ctx = (retry_test_ctx_t) { -1, 0 }; + sentry__retry_send(retry, UINT64_MAX, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 1); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 2); + + // before=0 (poll mode) respects backoff: item is skipped + ctx = (retry_test_ctx_t) { -1, 0 }; + sentry__retry_send(retry, 0, test_send_cb, &ctx); + TEST_CHECK_INT_EQUAL(ctx.count, 0); + TEST_CHECK_INT_EQUAL(find_envelope_attempt(cache_path), 2); + + sentry__retry_free(retry); + sentry_close(); +} diff --git a/tests/unit/test_utils.c b/tests/unit/test_utils.c index 4a95cef4b..406844cde 100644 --- a/tests/unit/test_utils.c +++ b/tests/unit/test_utils.c @@ -275,6 +275,28 @@ SENTRY_TEST(dsn_store_url_custom_agent) sentry__dsn_decref(dsn); } +SENTRY_TEST(dsn_resolve_url) +{ + SENTRY_TEST_DSN_NEW(dsn, "https://key@sentry.io/42"); + char *url; + + // relative path gets origin prepended + url = sentry__dsn_resolve_url(dsn, "/api/42/upload/abc123/"); + TEST_CHECK_STRING_EQUAL(url, "https://sentry.io:443/api/42/upload/abc123/"); + sentry_free(url); + + // absolute URL passes through + url = sentry__dsn_resolve_url(dsn, "https://other.host/path"); + TEST_CHECK_STRING_EQUAL(url, "https://other.host/path"); + sentry_free(url); + + // NULL inputs + TEST_CHECK(!sentry__dsn_resolve_url(NULL, "/path")); + TEST_CHECK(!sentry__dsn_resolve_url(dsn, NULL)); + + sentry__dsn_decref(dsn); +} + SENTRY_TEST(page_allocator) { #if !defined(SENTRY_PLATFORM_UNIX) || defined(SENTRY_PLATFORM_PS) diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 9c5abab0f..918f18d2f 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -2,6 +2,12 @@ XX(assert_sdk_name) XX(assert_sdk_user_agent) XX(assert_sdk_version) XX(attachment_properties) +XX(attachment_ref_copy) +XX(attachment_ref_creation) +XX(attachment_ref_from_path) +XX(attachment_ref_move) +XX(attachment_ref_raw_content_type) +XX(attachment_ref_restore) XX(attachments_add_dedupe) XX(attachments_add_remove) XX(attachments_bytes) @@ -43,6 +49,7 @@ XX(build_id_parser) XX(cache_keep) XX(cache_max_age) XX(cache_max_items) +XX(cache_max_items_with_retry) XX(cache_max_size) XX(cache_max_size_and_age) XX(capture_minidump_basic) @@ -85,6 +92,7 @@ XX(dsn_parsing_project_id_with_path_prefix) XX(dsn_parsing_project_id_without_path) XX(dsn_parsing_too_long_org_length) XX(dsn_parsing_zero_id_org) +XX(dsn_resolve_url) XX(dsn_store_url_custom_agent) XX(dsn_store_url_with_path) XX(dsn_store_url_without_path) @@ -171,7 +179,9 @@ XX(os_releases_snapshot) XX(overflow_spans) XX(page_allocator) XX(path_basics) +XX(path_copy) XX(path_current_exe) +XX(path_dir_size) XX(path_directory) XX(path_from_str_n_wo_null_termination) XX(path_from_str_null) @@ -191,6 +201,15 @@ XX(read_envelope_from_file) XX(read_write_envelope_to_file_null) XX(read_write_envelope_to_invalid_path) XX(recursive_paths) +XX(retry_backoff) +XX(retry_cache) +XX(retry_filename) +XX(retry_make_cache_path) +XX(retry_result) +XX(retry_session) +XX(retry_skew) +XX(retry_throttle) +XX(retry_trigger) XX(ringbuffer_append) XX(ringbuffer_append_invalid_decref_value) XX(ringbuffer_append_null_decref_value) @@ -240,8 +259,12 @@ XX(traceparent_header_disabled_by_default) XX(traceparent_header_generation) XX(transaction_name_backfill_on_finish) XX(transactions_skip_before_send) +XX(transport_retry) XX(transport_sampling_transactions) XX(transport_sampling_transactions_set_trace) +XX(tus_file_attachment_preserves_original) +XX(tus_request_preparation) +XX(tus_upload_url) XX(txn_data) XX(txn_data_n) XX(txn_name)