From 5f48f12656682db1da919b95df17864d4a175c59 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 5 Feb 2026 21:50:19 +0100 Subject: [PATCH 01/20] feat(sync): add delayed task submission for throttling Add sentry__bgworker_submit_delayed() to defer task execution by a given number of milliseconds. Tasks are sorted by readiness time using monotonic timestamps, so a ready delayed task is not bypassed by a later-submitted immediate task. On shutdown, tasks that exceed the deadline (started + timeout) are pruned while the rest execute normally. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 81 +++++++++++++++++- src/sentry_sync.h | 12 +++ tests/unit/test_sync.c | 190 +++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 4 + 4 files changed, 283 insertions(+), 4 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 7766a6970..1dc49498f 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -125,6 +125,7 @@ struct sentry_bgworker_task_s; typedef struct sentry_bgworker_task_s { struct sentry_bgworker_task_s *next_task; long refcount; + uint64_t execute_after; sentry_task_exec_func_t exec_func; void (*cleanup_func)(void *task_data); void *task_data; @@ -260,6 +261,16 @@ worker_thread(void *data) continue; } + // wait for a delayed task, wake up to new submissions + { + uint64_t now = sentry__monotonic_time(); + if (now < task->execute_after) { + sentry__cond_wait_timeout(&bgw->submit_signal, &bgw->task_lock, + (uint32_t)(task->execute_after - now)); + continue; + } + } + sentry__task_incref(task); sentry__mutex_unlock(&bgw->task_lock); @@ -396,6 +407,30 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) uint64_t started = sentry__monotonic_time(); sentry__mutex_lock(&bgw->task_lock); + + // prune delayed tasks that exceed the shutdown timeout + sentry_bgworker_task_t *prev = NULL; + sentry_bgworker_task_t *cur = bgw->first_task; + while (cur) { + if (cur->execute_after > started + timeout) { + if (prev) { + prev->next_task = NULL; + bgw->last_task = prev; + } else { + bgw->first_task = NULL; + bgw->last_task = NULL; + } + while (cur) { + sentry_bgworker_task_t *next = cur->next_task; + sentry__task_decref(cur); + cur = next; + } + break; + } + prev = cur; + cur = cur->next_task; + } + while (true) { if (sentry__bgworker_is_done(bgw)) { sentry__mutex_unlock(&bgw->task_lock); @@ -422,6 +457,15 @@ int sentry__bgworker_submit(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data) +{ + return sentry__bgworker_submit_delayed( + bgw, exec_func, cleanup_func, task_data, 0); +} + +int +sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, + sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), + void *task_data, uint64_t delay_ms) { sentry_bgworker_task_t *task = SENTRY_MAKE(sentry_bgworker_task_t); if (!task) { @@ -432,19 +476,48 @@ sentry__bgworker_submit(sentry_bgworker_t *bgw, } task->next_task = NULL; task->refcount = 1; + task->execute_after = sentry__monotonic_time() + delay_ms; task->exec_func = exec_func; task->cleanup_func = cleanup_func; task->task_data = task_data; - SENTRY_DEBUG("submitting task to background worker thread"); + if (delay_ms > 0) { + SENTRY_DEBUGF("submitting %" PRIu64 + " ms delayed task to background worker thread", + delay_ms); + } else { + SENTRY_DEBUG("submitting task to background worker thread"); + } sentry__mutex_lock(&bgw->task_lock); + if (!bgw->first_task) { + // empty queue bgw->first_task = task; - } - if (bgw->last_task) { + bgw->last_task = task; + } else if (bgw->last_task->execute_after <= task->execute_after) { + // append last (common fast path for FIFO immediates) bgw->last_task->next_task = task; + bgw->last_task = task; + } else { + // insert sorted by execute_after + sentry_bgworker_task_t *prev = NULL; + sentry_bgworker_task_t *cur = bgw->first_task; + while (cur && cur->execute_after <= task->execute_after) { + prev = cur; + cur = cur->next_task; + } + + task->next_task = cur; + if (prev) { + prev->next_task = task; + } else { + bgw->first_task = task; + } + if (!task->next_task) { + bgw->last_task = task; + } } - bgw->last_task = task; + sentry__cond_wake(&bgw->submit_signal); sentry__mutex_unlock(&bgw->task_lock); diff --git a/src/sentry_sync.h b/src/sentry_sync.h index 5516443b9..d0999660f 100644 --- a/src/sentry_sync.h +++ b/src/sentry_sync.h @@ -478,6 +478,18 @@ int sentry__bgworker_submit(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data); +/** + * This will submit a new delayed task to the background thread. + * + * Execution is deferred by `delay_ms` milliseconds. + * + * Takes ownership of `data`, freeing it using the provided `cleanup_func`. + * Returns 0 on success. + */ +int sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, + sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), + void *task_data, uint64_t delay_ms); + /** * This function will iterate through all the current tasks of the worker * thread, and will call the `callback` function for each task with a matching diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 795595554..aea39102b 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -1,13 +1,16 @@ #include "sentry_core.h" #include "sentry_sync.h" #include "sentry_testsupport.h" +#include "sentry_utils.h" #ifdef SENTRY_PLATFORM_WINDOWS # include # define sleep_s(SECONDS) Sleep((SECONDS) * 1000) +# define sleep_ms(MS) Sleep(MS) #else # include # define sleep_s(SECONDS) sleep(SECONDS) +# define sleep_ms(MS) usleep((MS) * 1000) #endif struct task_state { @@ -167,3 +170,190 @@ SENTRY_TEST(bgworker_flush) TEST_CHECK_INT_EQUAL(shutdown, 0); sentry__bgworker_decref(bgw); } + +static sentry_cond_t blocker_signal; +#ifdef SENTRY__MUTEX_INIT_DYN +SENTRY__MUTEX_INIT_DYN(blocker_lock) +#else +static sentry_mutex_t blocker_lock = SENTRY__MUTEX_INIT; +#endif +static bool blocker_released; + +static void +blocker_task(void *UNUSED(data), void *UNUSED(state)) +{ + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__mutex_lock(&blocker_lock); + while (!blocker_released) { + sentry__cond_wait_timeout(&blocker_signal, &blocker_lock, 100); + } + sentry__mutex_unlock(&blocker_lock); +} + +struct order_state { + int order[10]; + int count; +}; + +static void +record_order_task(void *data, void *_state) +{ + struct order_state *state = (struct order_state *)_state; + state->order[state->count++] = (int)(size_t)data; +} + +SENTRY_TEST(bgworker_task_delay) +{ + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + uint64_t before = sentry__monotonic_time(); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)1, 50); + + sentry__bgworker_start(bgw); + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 500), 0); + uint64_t after = sentry__monotonic_time(); + + TEST_CHECK_INT_EQUAL(os.count, 1); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK(after - before >= 50); + + sentry__bgworker_decref(bgw); +} + +SENTRY_TEST(bgworker_delayed_tasks) +{ + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // all tasks sorted by execute_after: immediate (0) first, then delayed + // by deadline + // + // queue after each submit: + // i(1) + // i(1) d100(3) + // i(1) i(6) d100(3) + // i(1) i(6) d50(2) d100(3) + // i(1) i(6) i(7) d50(2) d100(3) + // i(1) i(6) i(7) d50(2) d100(3) d200(5) + // i(1) i(6) i(7) d50(2) d100(3) d150(4) d200(5) + // i(1) i(6) i(7) i(8) d50(2) d100(3) d150(4) d200(5) + // i(1) i(6) i(7) i(8) d50(2) d75(9) d100(3) d150(4) d200(5) + // i(1) i(6) i(7) i(8) i(10) d50(2) d75(9) d100(3) d150(4) d200(5) + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)1); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)3, 100); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)6); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)2, 50); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)7); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)5, 200); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)4, 150); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)8); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)9, 75); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)10); + + sentry__bgworker_start(bgw); + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + + // all tasks execute: immediate first, then delayed in deadline order + TEST_CHECK_INT_EQUAL(os.count, 10); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 6); + TEST_CHECK_INT_EQUAL(os.order[2], 7); + TEST_CHECK_INT_EQUAL(os.order[3], 8); + TEST_CHECK_INT_EQUAL(os.order[4], 10); + TEST_CHECK_INT_EQUAL(os.order[5], 2); + TEST_CHECK_INT_EQUAL(os.order[6], 9); + TEST_CHECK_INT_EQUAL(os.order[7], 3); + TEST_CHECK_INT_EQUAL(os.order[8], 4); + TEST_CHECK_INT_EQUAL(os.order[9], 5); + + sentry__bgworker_decref(bgw); +} + +SENTRY_TEST(bgworker_delayed_priority) +{ + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__cond_init(&blocker_signal); + blocker_released = false; + + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // blocker holds the worker busy + sentry__bgworker_submit(bgw, blocker_task, NULL, NULL); + // delayed task queued behind the blocker + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)1, 50); + + sentry__bgworker_start(bgw); + + // wait for the delayed task to become ready + sleep_ms(100); + + // submit an immediate task — should NOT bypass the ready delayed task + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); + + // release the blocker + sentry__mutex_lock(&blocker_lock); + blocker_released = true; + sentry__cond_wake(&blocker_signal); + sentry__mutex_unlock(&blocker_lock); + + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + + TEST_CHECK_INT_EQUAL(os.count, 2); + TEST_CHECK_INT_EQUAL(os.order[0], 1); // delayed (was ready first) + TEST_CHECK_INT_EQUAL(os.order[1], 2); // immediate (submitted later) + + sentry__bgworker_decref(bgw); +} + +SENTRY_TEST(bgworker_delayed_shutdown) +{ + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // immediate tasks + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)1); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)3); + + // short delay fits within shutdown deadline + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)4, 50); + + // long delay exceeds shutdown deadline and should be pruned + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)5, 5000); + sentry__bgworker_submit_delayed( + bgw, record_order_task, NULL, (void *)6, 5000); + + sentry__bgworker_start(bgw); + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 1000), 0); + + TEST_CHECK_INT_EQUAL(os.count, 4); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 2); + TEST_CHECK_INT_EQUAL(os.order[2], 3); + TEST_CHECK_INT_EQUAL(os.order[3], 4); + + sentry__bgworker_decref(bgw); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 0a5cb2e9d..5a512fb17 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -24,7 +24,11 @@ XX(basic_tracing_context) XX(basic_transaction) XX(basic_transport_thread_name) XX(basic_write_envelope_to_file) +XX(bgworker_delayed_priority) +XX(bgworker_delayed_shutdown) +XX(bgworker_delayed_tasks) XX(bgworker_flush) +XX(bgworker_task_delay) XX(breadcrumb_without_type_or_message_still_valid) XX(build_id_parser) XX(cache_keep) From 6d9846251da462de896b3b2fae0e88c99ebf10ee Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 6 Feb 2026 13:30:40 +0100 Subject: [PATCH 02/20] Make flush delay-aware so it waits for delayed tasks The flush marker now uses the last task's deadline (capped at the flush timeout) so it sorts after all current tasks including delayed ones. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 16 ++++++++++++++-- tests/unit/test_sync.c | 6 ++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 1dc49498f..dbe2e612e 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -361,11 +361,23 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) sentry__cond_init(&flush_task->signal); sentry__mutex_init(&flush_task->lock); + // flush potential delayed tasks up until the timeout + uint64_t delay_ms = 0; + uint64_t before = sentry__monotonic_time(); + sentry__mutex_lock(&bgw->task_lock); + if (bgw->last_task && bgw->last_task->execute_after > before) { + delay_ms = bgw->last_task->execute_after - before; + if (delay_ms > timeout) { + delay_ms = timeout; + } + } + sentry__mutex_unlock(&bgw->task_lock); + sentry__mutex_lock(&flush_task->lock); /* submit the task that triggers our condvar once it runs */ - sentry__bgworker_submit(bgw, sentry__flush_task, - (void (*)(void *))sentry__flush_task_decref, flush_task); + sentry__bgworker_submit_delayed(bgw, sentry__flush_task, + (void (*)(void *))sentry__flush_task_decref, flush_task, delay_ms); uint64_t started = sentry__monotonic_time(); bool was_flushed = false; diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index aea39102b..decda1996 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -215,13 +215,14 @@ SENTRY_TEST(bgworker_task_delay) bgw, record_order_task, NULL, (void *)1, 50); sentry__bgworker_start(bgw); - TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 500), 0); + TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 500), 0); uint64_t after = sentry__monotonic_time(); TEST_CHECK_INT_EQUAL(os.count, 1); TEST_CHECK_INT_EQUAL(os.order[0], 1); TEST_CHECK(after - before >= 50); + sentry__bgworker_shutdown(bgw, 500); sentry__bgworker_decref(bgw); } @@ -264,7 +265,7 @@ SENTRY_TEST(bgworker_delayed_tasks) sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)10); sentry__bgworker_start(bgw); - TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 5000), 0); // all tasks execute: immediate first, then delayed in deadline order TEST_CHECK_INT_EQUAL(os.count, 10); @@ -279,6 +280,7 @@ SENTRY_TEST(bgworker_delayed_tasks) TEST_CHECK_INT_EQUAL(os.order[8], 4); TEST_CHECK_INT_EQUAL(os.order[9], 5); + sentry__bgworker_shutdown(bgw, 500); sentry__bgworker_decref(bgw); } From a399d2ae4c5c662d796835c0651149b07487c62a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 6 Feb 2026 14:37:09 +0100 Subject: [PATCH 03/20] Add sentry__bgworker_submit_at for absolute timestamps Extract the core submission logic into submit_at which takes an absolute execute_after time. submit and submit_delayed become thin wrappers. Use submit_at in bgworker_delayed_tasks to pin all tasks to a single base timestamp, making the test ordering deterministic regardless of OS preemption between submissions. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 26 ++++++++++++++++---------- src/sentry_sync.h | 17 ++++++++--------- tests/unit/test_sync.c | 36 +++++++++++++++++++++--------------- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index dbe2e612e..01afc6253 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -470,14 +470,27 @@ sentry__bgworker_submit(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data) { - return sentry__bgworker_submit_delayed( - bgw, exec_func, cleanup_func, task_data, 0); + SENTRY_DEBUG("submitting task to background worker thread"); + return sentry__bgworker_submit_at( + bgw, exec_func, cleanup_func, task_data, sentry__monotonic_time()); } int sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data, uint64_t delay_ms) +{ + SENTRY_DEBUGF("submitting %" PRIu64 + " ms delayed task to background worker thread", + delay_ms); + return sentry__bgworker_submit_at(bgw, exec_func, cleanup_func, task_data, + sentry__monotonic_time() + delay_ms); +} + +int +sentry__bgworker_submit_at(sentry_bgworker_t *bgw, + sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), + void *task_data, uint64_t execute_after) { sentry_bgworker_task_t *task = SENTRY_MAKE(sentry_bgworker_task_t); if (!task) { @@ -488,18 +501,11 @@ sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, } task->next_task = NULL; task->refcount = 1; - task->execute_after = sentry__monotonic_time() + delay_ms; + task->execute_after = execute_after; task->exec_func = exec_func; task->cleanup_func = cleanup_func; task->task_data = task_data; - if (delay_ms > 0) { - SENTRY_DEBUGF("submitting %" PRIu64 - " ms delayed task to background worker thread", - delay_ms); - } else { - SENTRY_DEBUG("submitting task to background worker thread"); - } sentry__mutex_lock(&bgw->task_lock); if (!bgw->first_task) { diff --git a/src/sentry_sync.h b/src/sentry_sync.h index d0999660f..faa314e0a 100644 --- a/src/sentry_sync.h +++ b/src/sentry_sync.h @@ -471,24 +471,23 @@ const char *sentry__bgworker_get_thread_name(sentry_bgworker_t *bgw); /** * This will submit a new task to the background thread. * + * The `_delayed` variant delays execution by the specified delay in + * milliseconds, and the `_at` variant executes after the specified monotonic + * timestamp. The latter is mostly useful for testing to ensure deterministic + * ordering of tasks regardless of OS preemption between submissions. + * * Takes ownership of `data`, freeing it using the provided `cleanup_func`. * Returns 0 on success. */ int sentry__bgworker_submit(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data); - -/** - * This will submit a new delayed task to the background thread. - * - * Execution is deferred by `delay_ms` milliseconds. - * - * Takes ownership of `data`, freeing it using the provided `cleanup_func`. - * Returns 0 on success. - */ int sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), void *task_data, uint64_t delay_ms); +int sentry__bgworker_submit_at(sentry_bgworker_t *bgw, + sentry_task_exec_func_t exec_func, void (*cleanup_func)(void *task_data), + void *task_data, uint64_t execute_after); /** * This function will iterate through all the current tasks of the worker diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index decda1996..3a2cbcdbb 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -234,6 +234,12 @@ SENTRY_TEST(bgworker_delayed_tasks) sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); TEST_ASSERT(!!bgw); + // submit_at with a fixed base so ordering is deterministic regardless + // of OS preemption between submissions (submit_delayed reads the clock + // per call, so a pause between calls could shift execute_after values + // and change the expected sort order) + uint64_t base = sentry__monotonic_time(); + // all tasks sorted by execute_after: immediate (0) first, then delayed // by deadline // @@ -248,21 +254,21 @@ SENTRY_TEST(bgworker_delayed_tasks) // i(1) i(6) i(7) i(8) d50(2) d100(3) d150(4) d200(5) // i(1) i(6) i(7) i(8) d50(2) d75(9) d100(3) d150(4) d200(5) // i(1) i(6) i(7) i(8) i(10) d50(2) d75(9) d100(3) d150(4) d200(5) - sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)1); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)3, 100); - sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)6); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)2, 50); - sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)7); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)5, 200); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)4, 150); - sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)8); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)9, 75); - sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)10); + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)1, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)3, base + 100); + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)6, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)2, base + 50); + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)7, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)5, base + 200); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)4, base + 150); + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)8, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)9, base + 75); + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)10, base); sentry__bgworker_start(bgw); TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 5000), 0); From b97d337ad70047b9e45ecc67fe18da6671f829a2 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Feb 2026 17:56:55 +0100 Subject: [PATCH 04/20] fix(sync): discard pending delayed tasks on shutdown Move delayed task cleanup from the shutdown pre-prune pass into the worker thread itself. When the worker encounters a delayed task that is not yet ready and shutdown has been signaled, it discards all remaining tasks. This catches tasks submitted during execution that the one-time pre-prune in shutdown could not see. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 34 ++++++++++------------------------ tests/unit/test_sync.c | 7 ++----- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 01afc6253..e63f998e8 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -265,6 +265,16 @@ worker_thread(void *data) { uint64_t now = sentry__monotonic_time(); if (now < task->execute_after) { + // discard delayed tasks submitted after shutdown pruning + if (!sentry__atomic_fetch(&bgw->running)) { + while (bgw->first_task) { + sentry_bgworker_task_t *t = bgw->first_task; + bgw->first_task = t->next_task; + sentry__task_decref(t); + } + bgw->last_task = NULL; + continue; + } sentry__cond_wait_timeout(&bgw->submit_signal, &bgw->task_lock, (uint32_t)(task->execute_after - now)); continue; @@ -419,30 +429,6 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) uint64_t started = sentry__monotonic_time(); sentry__mutex_lock(&bgw->task_lock); - - // prune delayed tasks that exceed the shutdown timeout - sentry_bgworker_task_t *prev = NULL; - sentry_bgworker_task_t *cur = bgw->first_task; - while (cur) { - if (cur->execute_after > started + timeout) { - if (prev) { - prev->next_task = NULL; - bgw->last_task = prev; - } else { - bgw->first_task = NULL; - bgw->last_task = NULL; - } - while (cur) { - sentry_bgworker_task_t *next = cur->next_task; - sentry__task_decref(cur); - cur = next; - } - break; - } - prev = cur; - cur = cur->next_task; - } - while (true) { if (sentry__bgworker_is_done(bgw)) { sentry__mutex_unlock(&bgw->task_lock); diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 3a2cbcdbb..fdfb96352 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -344,11 +344,9 @@ SENTRY_TEST(bgworker_delayed_shutdown) sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)3); - // short delay fits within shutdown deadline + // delayed tasks are discarded on shutdown unless already ready sentry__bgworker_submit_delayed( bgw, record_order_task, NULL, (void *)4, 50); - - // long delay exceeds shutdown deadline and should be pruned sentry__bgworker_submit_delayed( bgw, record_order_task, NULL, (void *)5, 5000); sentry__bgworker_submit_delayed( @@ -357,11 +355,10 @@ SENTRY_TEST(bgworker_delayed_shutdown) sentry__bgworker_start(bgw); TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 1000), 0); - TEST_CHECK_INT_EQUAL(os.count, 4); + TEST_CHECK_INT_EQUAL(os.count, 3); TEST_CHECK_INT_EQUAL(os.order[0], 1); TEST_CHECK_INT_EQUAL(os.order[1], 2); TEST_CHECK_INT_EQUAL(os.order[2], 3); - TEST_CHECK_INT_EQUAL(os.order[3], 4); sentry__bgworker_decref(bgw); } From c28a807e8be844dc2f481936208ff10dd5b62faa Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Feb 2026 18:30:37 +0100 Subject: [PATCH 05/20] fix(sync): let is_done handle delayed tasks on shutdown Instead of explicitly discarding delayed tasks in worker_thread, teach sentry__bgworker_is_done to treat pending delayed tasks as done when !running. Remaining tasks are cleaned up by decref. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 14 +++----------- tests/unit/test_sync.c | 14 +++++++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index e63f998e8..049272d11 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -226,7 +226,9 @@ sentry__bgworker_get_state(sentry_bgworker_t *bgw) static bool sentry__bgworker_is_done(sentry_bgworker_t *bgw) { - return !bgw->first_task && !sentry__atomic_fetch(&bgw->running); + return (!bgw->first_task + || sentry__monotonic_time() < bgw->first_task->execute_after) + && !sentry__atomic_fetch(&bgw->running); } SENTRY_THREAD_FN @@ -265,16 +267,6 @@ worker_thread(void *data) { uint64_t now = sentry__monotonic_time(); if (now < task->execute_after) { - // discard delayed tasks submitted after shutdown pruning - if (!sentry__atomic_fetch(&bgw->running)) { - while (bgw->first_task) { - sentry_bgworker_task_t *t = bgw->first_task; - bgw->first_task = t->next_task; - sentry__task_decref(t); - } - bgw->last_task = NULL; - continue; - } sentry__cond_wait_timeout(&bgw->submit_signal, &bgw->task_lock, (uint32_t)(task->execute_after - now)); continue; diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index fdfb96352..27e2b93d0 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -344,13 +344,13 @@ SENTRY_TEST(bgworker_delayed_shutdown) sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)3); - // delayed tasks are discarded on shutdown unless already ready - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)4, 50); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)5, 5000); - sentry__bgworker_submit_delayed( - bgw, record_order_task, NULL, (void *)6, 5000); + // pending delayed tasks are discarded on shutdown + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)4, UINT64_MAX); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)5, UINT64_MAX); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)6, UINT64_MAX); sentry__bgworker_start(bgw); TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 1000), 0); From 3e19e39947c00c6d24ed4b93dd38b3ad40b623f9 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sun, 15 Feb 2026 13:19:04 +0100 Subject: [PATCH 06/20] test(sync): add delayed task tests for insert-at-head and cleanup Co-Authored-By: Claude Opus 4.6 --- tests/unit/test_sync.c | 66 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 2 ++ 2 files changed, 68 insertions(+) diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 27e2b93d0..ade6885f7 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -171,6 +171,17 @@ SENTRY_TEST(bgworker_flush) sentry__bgworker_decref(bgw); } +static void +noop_task(void *UNUSED(data), void *UNUSED(state)) +{ +} + +static void +incr_cleanup(void *data) +{ + (*(int *)data)++; +} + static sentry_cond_t blocker_signal; #ifdef SENTRY__MUTEX_INIT_DYN SENTRY__MUTEX_INIT_DYN(blocker_lock) @@ -331,6 +342,61 @@ SENTRY_TEST(bgworker_delayed_priority) sentry__bgworker_decref(bgw); } +SENTRY_TEST(bgworker_delayed_head) +{ + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + uint64_t base = sentry__monotonic_time(); + + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)1, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)2, base + 1); + // earlier than first_task -> triggers insert-before-head + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)3, base - 1); + + sentry__bgworker_start(bgw); + TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 5000), 0); + + TEST_CHECK_INT_EQUAL(os.count, 3); + TEST_CHECK_INT_EQUAL(os.order[0], 3); + TEST_CHECK_INT_EQUAL(os.order[1], 1); + TEST_CHECK_INT_EQUAL(os.order[2], 2); + + sentry__bgworker_shutdown(bgw, 500); + sentry__bgworker_decref(bgw); +} + +SENTRY_TEST(bgworker_delayed_cleanup) +{ + int cleaned = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(NULL, NULL); + TEST_ASSERT(!!bgw); + + // immediate tasks (cleanup after execution) + sentry__bgworker_submit(bgw, noop_task, incr_cleanup, &cleaned); + sentry__bgworker_submit(bgw, noop_task, incr_cleanup, &cleaned); + + // far-future delayed tasks (discarded on shutdown, cleanup in decref) + sentry__bgworker_submit_at( + bgw, noop_task, incr_cleanup, &cleaned, UINT64_MAX); + sentry__bgworker_submit_at( + bgw, noop_task, incr_cleanup, &cleaned, UINT64_MAX); + sentry__bgworker_submit_at( + bgw, noop_task, incr_cleanup, &cleaned, UINT64_MAX); + + sentry__bgworker_start(bgw); + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 1000), 0); + sentry__bgworker_decref(bgw); + + TEST_CHECK_INT_EQUAL(cleaned, 5); +} + SENTRY_TEST(bgworker_delayed_shutdown) { struct order_state os; diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 5a512fb17..c92750467 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -24,6 +24,8 @@ XX(basic_tracing_context) XX(basic_transaction) XX(basic_transport_thread_name) XX(basic_write_envelope_to_file) +XX(bgworker_delayed_cleanup) +XX(bgworker_delayed_head) XX(bgworker_delayed_priority) XX(bgworker_delayed_shutdown) XX(bgworker_delayed_tasks) From 90295b8011f38cc9b9db12294f3c86b11c62557d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Feb 2026 18:58:10 +0100 Subject: [PATCH 07/20] fix(bgworker): skip far-future delayed tasks in flush delay calculation When a delayed task's deadline exceeds the flush timeout, don't use it to delay the flush sentinel. Such tasks cannot complete within the timeout anyway, and capping to the timeout caused the sentinel to race with the caller's deadline. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 9 ++++----- tests/unit/test_sync.c | 22 ++++++++++++++++++++++ tests/unit/tests.inc | 1 + 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 049272d11..a6a4dd677 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -363,15 +363,14 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) sentry__cond_init(&flush_task->signal); sentry__mutex_init(&flush_task->lock); - // flush potential delayed tasks up until the timeout + // place the flush sentinel after the last task due within the timeout; + // tasks delayed beyond the timeout cannot complete in time anyway uint64_t delay_ms = 0; uint64_t before = sentry__monotonic_time(); sentry__mutex_lock(&bgw->task_lock); - if (bgw->last_task && bgw->last_task->execute_after > before) { + if (bgw->last_task && bgw->last_task->execute_after > before + && bgw->last_task->execute_after - before <= timeout) { delay_ms = bgw->last_task->execute_after - before; - if (delay_ms > timeout) { - delay_ms = timeout; - } } sentry__mutex_unlock(&bgw->task_lock); diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index ade6885f7..d7c1f6198 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -171,6 +171,28 @@ SENTRY_TEST(bgworker_flush) sentry__bgworker_decref(bgw); } +SENTRY_TEST(bgworker_delayed_flush) +{ + struct task_state ts; + ts.executed = 0; + ts.running = true; + + sentry_bgworker_t *bgw = sentry__bgworker_new(NULL, NULL); + TEST_ASSERT(!!bgw); + + sentry__bgworker_submit(bgw, task_func, NULL, &ts); + sentry__bgworker_submit_at(bgw, task_func, NULL, &ts, UINT64_MAX); + + sentry__bgworker_start(bgw); + + // flush succeeds after the immediate task; the far-future task is skipped + TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 2000), 0); + TEST_CHECK_INT_EQUAL(ts.executed, 1); + + sentry__bgworker_shutdown(bgw, 500); + sentry__bgworker_decref(bgw); +} + static void noop_task(void *UNUSED(data), void *UNUSED(state)) { diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index c92750467..8e77bf43c 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -25,6 +25,7 @@ XX(basic_transaction) XX(basic_transport_thread_name) XX(basic_write_envelope_to_file) XX(bgworker_delayed_cleanup) +XX(bgworker_delayed_flush) XX(bgworker_delayed_head) XX(bgworker_delayed_priority) XX(bgworker_delayed_shutdown) From ba36aa79b6f9394fffc7208d6c91082b2b467292 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Feb 2026 20:31:22 +0100 Subject: [PATCH 08/20] fix(bgworker): walk queue to find last eligible task in flush The previous fix only checked last_task, so a far-future tail caused delay_ms=0 which skipped eligible delayed tasks earlier in the queue. Walk from first_task to find the last task due within the timeout. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 9 +++++--- tests/unit/test_sync.c | 51 ++++++++++++++++++++++++------------------ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index a6a4dd677..86980981e 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -367,10 +367,13 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) // tasks delayed beyond the timeout cannot complete in time anyway uint64_t delay_ms = 0; uint64_t before = sentry__monotonic_time(); + uint64_t deadline = before + timeout; sentry__mutex_lock(&bgw->task_lock); - if (bgw->last_task && bgw->last_task->execute_after > before - && bgw->last_task->execute_after - before <= timeout) { - delay_ms = bgw->last_task->execute_after - before; + for (sentry_bgworker_task_t *t = bgw->first_task; + t && t->execute_after <= deadline; t = t->next_task) { + if (t->execute_after > before) { + delay_ms = t->execute_after - before; + } } sentry__mutex_unlock(&bgw->task_lock); diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index d7c1f6198..64d741cb2 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -171,28 +171,6 @@ SENTRY_TEST(bgworker_flush) sentry__bgworker_decref(bgw); } -SENTRY_TEST(bgworker_delayed_flush) -{ - struct task_state ts; - ts.executed = 0; - ts.running = true; - - sentry_bgworker_t *bgw = sentry__bgworker_new(NULL, NULL); - TEST_ASSERT(!!bgw); - - sentry__bgworker_submit(bgw, task_func, NULL, &ts); - sentry__bgworker_submit_at(bgw, task_func, NULL, &ts, UINT64_MAX); - - sentry__bgworker_start(bgw); - - // flush succeeds after the immediate task; the far-future task is skipped - TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 2000), 0); - TEST_CHECK_INT_EQUAL(ts.executed, 1); - - sentry__bgworker_shutdown(bgw, 500); - sentry__bgworker_decref(bgw); -} - static void noop_task(void *UNUSED(data), void *UNUSED(state)) { @@ -259,6 +237,35 @@ SENTRY_TEST(bgworker_task_delay) sentry__bgworker_decref(bgw); } +SENTRY_TEST(bgworker_delayed_flush) +{ + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + uint64_t base = sentry__monotonic_time(); + + // immediate + eligible delayed + far-future delayed + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)1, base); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)2, base + 50); + sentry__bgworker_submit_at( + bgw, record_order_task, NULL, (void *)3, UINT64_MAX); + + sentry__bgworker_start(bgw); + + // flush covers the immediate and the 50ms task but skips the far-future one + TEST_CHECK_INT_EQUAL(sentry__bgworker_flush(bgw, 2000), 0); + TEST_CHECK_INT_EQUAL(os.count, 2); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 2); + + sentry__bgworker_shutdown(bgw, 500); + sentry__bgworker_decref(bgw); +} + SENTRY_TEST(bgworker_delayed_tasks) { struct order_state os; From b8270960528ddc4308aac7a0a3559917edda603d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Feb 2026 20:36:53 +0100 Subject: [PATCH 09/20] fix(bgworker): prevent unsigned wraparound in delayed task deadline Large delay_ms values could wrap execute_after into the past, causing immediate execution. Use saturating addition capped at UINT64_MAX. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 13 ++++++++++--- tests/unit/test_sync.c | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 86980981e..3248193bc 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -121,6 +121,12 @@ thread_setname(sentry_threadid_t thread_id, const char *thread_name) * `done` *from* the worker signaling that it will close down and can be joined. */ +static uint64_t +add_saturate(uint64_t a, uint64_t b) +{ + return b <= UINT64_MAX - a ? a + b : UINT64_MAX; +} + struct sentry_bgworker_task_s; typedef struct sentry_bgworker_task_s { struct sentry_bgworker_task_s *next_task; @@ -367,7 +373,7 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) // tasks delayed beyond the timeout cannot complete in time anyway uint64_t delay_ms = 0; uint64_t before = sentry__monotonic_time(); - uint64_t deadline = before + timeout; + uint64_t deadline = add_saturate(before, timeout); sentry__mutex_lock(&bgw->task_lock); for (sentry_bgworker_task_t *t = bgw->first_task; t && t->execute_after <= deadline; t = t->next_task) { @@ -463,8 +469,9 @@ sentry__bgworker_submit_delayed(sentry_bgworker_t *bgw, SENTRY_DEBUGF("submitting %" PRIu64 " ms delayed task to background worker thread", delay_ms); - return sentry__bgworker_submit_at(bgw, exec_func, cleanup_func, task_data, - sentry__monotonic_time() + delay_ms); + uint64_t execute_after = add_saturate(sentry__monotonic_time(), delay_ms); + return sentry__bgworker_submit_at( + bgw, exec_func, cleanup_func, task_data, execute_after); } int diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 64d741cb2..ac3d7abee 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -251,7 +251,7 @@ SENTRY_TEST(bgworker_delayed_flush) sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)1, base); sentry__bgworker_submit_at( bgw, record_order_task, NULL, (void *)2, base + 50); - sentry__bgworker_submit_at( + sentry__bgworker_submit_delayed( bgw, record_order_task, NULL, (void *)3, UINT64_MAX); sentry__bgworker_start(bgw); From 6ee3c9f3f5118eb6b305cc91fb44fe3dbb168d31 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Feb 2026 21:09:31 +0100 Subject: [PATCH 10/20] fix(bgworker): prevent head insertion from re-executing current task submit_at sorted insertion could insert before first_task while it was executing without the lock held, causing the worker loop to skip the pop and re-execute the task on the next iteration. Track current_task so sorted insertion starts after it. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 10 ++++++--- tests/unit/test_sync.c | 50 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 1 + 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 3248193bc..fa92775b9 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -162,6 +162,7 @@ struct sentry_bgworker_s { sentry_mutex_t task_lock; sentry_bgworker_task_t *first_task; sentry_bgworker_task_t *last_task; + sentry_bgworker_task_t *current_task; void *state; void (*free_state)(void *state); long refcount; @@ -280,6 +281,7 @@ worker_thread(void *data) } sentry__task_incref(task); + bgw->current_task = task; sentry__mutex_unlock(&bgw->task_lock); SENTRY_DEBUG("executing task on worker thread"); @@ -293,6 +295,7 @@ worker_thread(void *data) // if not, we pop it and `decref` again, removing the _is inside // list_ refcount. sentry__mutex_lock(&bgw->task_lock); + bgw->current_task = NULL; if (bgw->first_task == task) { bgw->first_task = task->next_task; if (task == bgw->last_task) { @@ -504,9 +507,10 @@ sentry__bgworker_submit_at(sentry_bgworker_t *bgw, bgw->last_task->next_task = task; bgw->last_task = task; } else { - // insert sorted by execute_after - sentry_bgworker_task_t *prev = NULL; - sentry_bgworker_task_t *cur = bgw->first_task; + // insert sorted by execute_after; skip past current_task which + // may be executing without the lock held + sentry_bgworker_task_t *prev = bgw->current_task; + sentry_bgworker_task_t *cur = prev ? prev->next_task : bgw->first_task; while (cur && cur->execute_after <= task->execute_after) { prev = cur; cur = cur->next_task; diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index ac3d7abee..9a2fe2952 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -371,6 +371,56 @@ SENTRY_TEST(bgworker_delayed_priority) sentry__bgworker_decref(bgw); } +static void +blocking_record_task(void *data, void *_state) +{ + struct order_state *state = (struct order_state *)_state; + state->order[state->count++] = (int)(size_t)data; + + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__mutex_lock(&blocker_lock); + while (!blocker_released) { + sentry__cond_wait_timeout(&blocker_signal, &blocker_lock, 100); + } + sentry__mutex_unlock(&blocker_lock); +} + +SENTRY_TEST(bgworker_delayed_current) +{ + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__cond_init(&blocker_signal); + blocker_released = false; + + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // head task that blocks and records execution + sentry__bgworker_submit(bgw, blocking_record_task, NULL, (void *)1); + + sentry__bgworker_start(bgw); + sleep_ms(100); + + // submit_at(0) would insert before head without the current_task guard + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)2, 0); + + sentry__mutex_lock(&blocker_lock); + blocker_released = true; + sentry__cond_wake(&blocker_signal); + sentry__mutex_unlock(&blocker_lock); + + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + + // head task must not be re-executed + TEST_CHECK_INT_EQUAL(os.count, 2); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 2); + + sentry__bgworker_decref(bgw); +} + SENTRY_TEST(bgworker_delayed_head) { struct order_state os; diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 8e77bf43c..dae57591e 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -25,6 +25,7 @@ XX(basic_transaction) XX(basic_transport_thread_name) XX(basic_write_envelope_to_file) XX(bgworker_delayed_cleanup) +XX(bgworker_delayed_current) XX(bgworker_delayed_flush) XX(bgworker_delayed_head) XX(bgworker_delayed_priority) From bfbfe364e8fd5815080cd213eafd401e625a05cf Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Feb 2026 21:11:41 +0100 Subject: [PATCH 11/20] fix(bgworker): use absolute timestamp for flush sentinel scheduling submit_delayed reads the clock again internally, so the sentinel ended up scheduled later than intended. Use submit_at with the absolute timestamp to eliminate drift between queue scan and submit. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index fa92775b9..df1fdae81 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -374,14 +374,14 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) // place the flush sentinel after the last task due within the timeout; // tasks delayed beyond the timeout cannot complete in time anyway - uint64_t delay_ms = 0; uint64_t before = sentry__monotonic_time(); uint64_t deadline = add_saturate(before, timeout); + uint64_t execute_after = before; sentry__mutex_lock(&bgw->task_lock); for (sentry_bgworker_task_t *t = bgw->first_task; t && t->execute_after <= deadline; t = t->next_task) { - if (t->execute_after > before) { - delay_ms = t->execute_after - before; + if (t->execute_after > execute_after) { + execute_after = t->execute_after; } } sentry__mutex_unlock(&bgw->task_lock); @@ -389,8 +389,8 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) sentry__mutex_lock(&flush_task->lock); /* submit the task that triggers our condvar once it runs */ - sentry__bgworker_submit_delayed(bgw, sentry__flush_task, - (void (*)(void *))sentry__flush_task_decref, flush_task, delay_ms); + sentry__bgworker_submit_at(bgw, sentry__flush_task, + (void (*)(void *))sentry__flush_task_decref, flush_task, execute_after); uint64_t started = sentry__monotonic_time(); bool was_flushed = false; From c24c7f88e398c05e7eea2e7ed344fc253d975cd0 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 07:48:47 +0100 Subject: [PATCH 12/20] fix(bgworker): update current_task->next_task in foreach_matching When foreach_matching removes the task following current_task, the next_task pointer becomes dangling. Keep it in sync so submit_at does not dereference freed memory. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index df1fdae81..a2745188d 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -557,6 +557,9 @@ sentry__bgworker_foreach_matching(sentry_bgworker_t *bgw, } else { bgw->first_task = next_task; } + if (bgw->current_task && bgw->current_task->next_task == task) { + bgw->current_task->next_task = next_task; + } sentry__task_decref(task); dropped++; } else { From 1adbbd28cc97a265060bc82425626e612a013344 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 08:08:22 +0100 Subject: [PATCH 13/20] fix(bgworker): clear current_task when foreach_matching drops it When foreach_matching removes current_task from the queue, submit_at would still use it as insertion predecessor, orphaning the new task. Clear current_task so submit_at falls back to first_task. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index a2745188d..ab5ba8c65 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -557,7 +557,10 @@ sentry__bgworker_foreach_matching(sentry_bgworker_t *bgw, } else { bgw->first_task = next_task; } - if (bgw->current_task && bgw->current_task->next_task == task) { + if (bgw->current_task == task) { + bgw->current_task = NULL; + } else if (bgw->current_task + && bgw->current_task->next_task == task) { bgw->current_task->next_task = next_task; } sentry__task_decref(task); From 499c76b9e83af318345ac5aaaa2c50f3be1d8308 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 08:33:32 +0100 Subject: [PATCH 14/20] fix(bgworker): use stable shutdown completion check bgworker_shutdown used sentry__bgworker_is_done() which includes a time check: sentry__monotonic_time() < first_task->execute_after. This condition can flip from true to false as wall time advances past execute_after. The worker thread exits when is_done is momentarily true, but by the time shutdown re-checks after the done_signal wake, time has crossed the threshold and is_done returns false. Shutdown then keeps polling until timeout, reporting failure even though the worker already exited cleanly. The full is_done check (task queue + time + running flag) is only needed by the worker thread to decide when to stop processing. Shutdown just needs to know whether the worker has exited, which is exactly what the running flag tells it. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index ab5ba8c65..b809eebfa 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -433,7 +433,7 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) uint64_t started = sentry__monotonic_time(); sentry__mutex_lock(&bgw->task_lock); while (true) { - if (sentry__bgworker_is_done(bgw)) { + if (!sentry__atomic_fetch(&bgw->running)) { sentry__mutex_unlock(&bgw->task_lock); sentry__thread_join(bgw->thread_id); return 0; From e8302007b40bf450ae6a58559423ad31dd70684d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 09:04:40 +0100 Subject: [PATCH 15/20] fix(bgworker): check timeout before join in shutdown The !running check was placed before the timeout check, so thread_join could block indefinitely and bypass the requested timeout. Move the timeout check first so shutdown always detaches if the deadline has passed, and only joins within the timeout window. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index b809eebfa..eb24c1013 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -433,12 +433,6 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) uint64_t started = sentry__monotonic_time(); sentry__mutex_lock(&bgw->task_lock); while (true) { - if (!sentry__atomic_fetch(&bgw->running)) { - sentry__mutex_unlock(&bgw->task_lock); - sentry__thread_join(bgw->thread_id); - return 0; - } - uint64_t now = sentry__monotonic_time(); if (now > started && now - started > timeout) { sentry__atomic_store(&bgw->running, 0); @@ -449,6 +443,12 @@ sentry__bgworker_shutdown(sentry_bgworker_t *bgw, uint64_t timeout) return 1; } + if (!sentry__atomic_fetch(&bgw->running)) { + sentry__mutex_unlock(&bgw->task_lock); + sentry__thread_join(bgw->thread_id); + return 0; + } + // this will implicitly release the lock, and re-acquire on wake sentry__cond_wait_timeout(&bgw->done_signal, &bgw->task_lock, 250); } From e25efacf54fde2829381816deaf95bbb47da5571 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 09:21:45 +0100 Subject: [PATCH 16/20] test(bgworker): verify submit after foreach drops current_task Exercises the fix from 1adbbd28: foreach_matching clears current_task when it drops the executing task, so a subsequent submit_at links from first_task instead of a stale pointer. Co-Authored-By: Claude Opus 4.6 --- tests/unit/test_sync.c | 41 +++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 1 + 2 files changed, 42 insertions(+) diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 9a2fe2952..285aee453 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -450,6 +450,47 @@ SENTRY_TEST(bgworker_delayed_head) sentry__bgworker_decref(bgw); } +SENTRY_TEST(bgworker_delayed_drop_current) +{ + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__cond_init(&blocker_signal); + blocker_released = false; + + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // A blocks the worker; B is queued behind + sentry__bgworker_submit(bgw, blocking_record_task, NULL, (void *)1); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); + + sentry__bgworker_start(bgw); + sleep_ms(100); + + // drop the currently executing task A + sentry__bgworker_foreach_matching( + bgw, blocking_record_task, drop_lessthan, (void *)2); + + // without the fix, this links to stale current_task + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)3, 0); + + sentry__mutex_lock(&blocker_lock); + blocker_released = true; + sentry__cond_wake(&blocker_signal); + sentry__mutex_unlock(&blocker_lock); + + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + + TEST_CHECK_INT_EQUAL(os.count, 3); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 3); + TEST_CHECK_INT_EQUAL(os.order[2], 2); + + sentry__bgworker_decref(bgw); +} + SENTRY_TEST(bgworker_delayed_cleanup) { int cleaned = 0; diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index dae57591e..8feee7196 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -26,6 +26,7 @@ XX(basic_transport_thread_name) XX(basic_write_envelope_to_file) XX(bgworker_delayed_cleanup) XX(bgworker_delayed_current) +XX(bgworker_delayed_drop_current) XX(bgworker_delayed_flush) XX(bgworker_delayed_head) XX(bgworker_delayed_priority) From b686fda1c4d0fc869e54bed2b75102c5ed0dbeb4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 09:22:22 +0100 Subject: [PATCH 17/20] test(bgworker): verify submit after foreach drops next task Exercises the fix from c24c7f88: foreach_matching updates current_task->next_task when it drops the successor, so a subsequent submit_at walks valid pointers during insertion. Co-Authored-By: Claude Opus 4.6 --- tests/unit/test_sync.c | 42 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/tests.inc | 1 + 2 files changed, 43 insertions(+) diff --git a/tests/unit/test_sync.c b/tests/unit/test_sync.c index 285aee453..66c49cdd6 100644 --- a/tests/unit/test_sync.c +++ b/tests/unit/test_sync.c @@ -491,6 +491,48 @@ SENTRY_TEST(bgworker_delayed_drop_current) sentry__bgworker_decref(bgw); } +SENTRY_TEST(bgworker_delayed_drop_next) +{ + SENTRY__MUTEX_INIT_DYN_ONCE(blocker_lock); + sentry__cond_init(&blocker_signal); + blocker_released = false; + + struct order_state os; + os.count = 0; + + sentry_bgworker_t *bgw = sentry__bgworker_new(&os, NULL); + TEST_ASSERT(!!bgw); + + // A blocks the worker; B and C are queued behind + sentry__bgworker_submit(bgw, blocking_record_task, NULL, (void *)1); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)2); + sentry__bgworker_submit(bgw, record_order_task, NULL, (void *)3); + + sentry__bgworker_start(bgw); + sleep_ms(100); + + // drop B (next after current_task A) + sentry__bgworker_foreach_matching( + bgw, record_order_task, drop_lessthan, (void *)3); + + // walks from current_task->next_task which must be valid + sentry__bgworker_submit_at(bgw, record_order_task, NULL, (void *)4, 0); + + sentry__mutex_lock(&blocker_lock); + blocker_released = true; + sentry__cond_wake(&blocker_signal); + sentry__mutex_unlock(&blocker_lock); + + TEST_CHECK_INT_EQUAL(sentry__bgworker_shutdown(bgw, 5000), 0); + + TEST_CHECK_INT_EQUAL(os.count, 3); + TEST_CHECK_INT_EQUAL(os.order[0], 1); + TEST_CHECK_INT_EQUAL(os.order[1], 4); + TEST_CHECK_INT_EQUAL(os.order[2], 3); + + sentry__bgworker_decref(bgw); +} + SENTRY_TEST(bgworker_delayed_cleanup) { int cleaned = 0; diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 8feee7196..5642d247a 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -27,6 +27,7 @@ XX(basic_write_envelope_to_file) XX(bgworker_delayed_cleanup) XX(bgworker_delayed_current) XX(bgworker_delayed_drop_current) +XX(bgworker_delayed_drop_next) XX(bgworker_delayed_flush) XX(bgworker_delayed_head) XX(bgworker_delayed_priority) From a8081eb083daa6a76ff094c3740325a59f2b2fc3 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 11:18:18 +0100 Subject: [PATCH 18/20] docs(sync): add docstring to add_saturate helper Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index eb24c1013..25e847f62 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -121,6 +121,9 @@ thread_setname(sentry_threadid_t thread_id, const char *thread_name) * `done` *from* the worker signaling that it will close down and can be joined. */ +/** + * Overflow-safe addition that clamps to UINT64_MAX instead of wrapping. + */ static uint64_t add_saturate(uint64_t a, uint64_t b) { From 4969b038dede04a39dc27c8b94535085a634c4b5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 11:17:00 +0100 Subject: [PATCH 19/20] fix(bgworker): skip current_task in flush deadline scan submit_at inserts after current_task, which can leave the head temporarily unsorted. Start the scan from the sorted tail. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 25e847f62..57ff98353 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -381,7 +381,8 @@ sentry__bgworker_flush(sentry_bgworker_t *bgw, uint64_t timeout) uint64_t deadline = add_saturate(before, timeout); uint64_t execute_after = before; sentry__mutex_lock(&bgw->task_lock); - for (sentry_bgworker_task_t *t = bgw->first_task; + for (sentry_bgworker_task_t *t + = bgw->current_task ? bgw->current_task->next_task : bgw->first_task; t && t->execute_after <= deadline; t = t->next_task) { if (t->execute_after > execute_after) { execute_after = t->execute_after; From ba02fbb1c61f4288c89f27bf2c77f2107c0a32a4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Feb 2026 11:42:10 +0100 Subject: [PATCH 20/20] fix(bgworker): clamp delayed task wait to prevent uint32 wrapping Large delays that exceed UINT32_MAX wrap on cast, causing unnecessary wakeups. Clamp to UINT32_MAX so the worker sleeps the maximum duration instead of busy-looping. Co-Authored-By: Claude Opus 4.6 --- src/sentry_sync.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry_sync.c b/src/sentry_sync.c index 57ff98353..079274311 100644 --- a/src/sentry_sync.c +++ b/src/sentry_sync.c @@ -278,7 +278,7 @@ worker_thread(void *data) uint64_t now = sentry__monotonic_time(); if (now < task->execute_after) { sentry__cond_wait_timeout(&bgw->submit_signal, &bgw->task_lock, - (uint32_t)(task->execute_after - now)); + (uint32_t)MIN(task->execute_after - now, UINT32_MAX)); continue; } }