Description
The add -> maybeSchedule fast path reads the hasScheduled flag outside scheduleLock, while the running flush resets that flag inside scheduleLock after a separate queue.isEmpty() check. This is a check-then-act TOCTOU: an event offered by a producer thread in the window after the flush reads isEmpty() == true but before it writes hasScheduled = false is neither observed by the flush (not rescheduled) nor able to schedule itself (the fast path sees hasScheduled still true). The event is left queued with hasScheduled == false and no pending flush. Affects both LoggerBatchProcessor and MetricsBatchProcessor (byte-identical scheduling logic).
Root cause
sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java: maybeSchedule reads hasScheduled outside the lock; flush() re-checks isEmpty() then sets hasScheduled = false inside the lock.
sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java: same structure.
The stranded event is only sent on a subsequent add() or an explicit flush()/close(). On Android this is partly masked because AndroidLoggerBatchProcessor/AndroidMetricsBatchProcessor force a flush on onBackground().
Note: reproduced as a deterministic interleaving of the state machine (same volatile flag, ReentrantLock, ConcurrentLinkedQueue), not a live Android run.
Suggested fix
Make the flag reset and queue re-check safe against the lock-free producer path: after draining, set hasScheduled = false first and then re-check queue.isEmpty() under the lock (rescheduling if non-empty), or remove the lock-free fast path so maybeSchedule always consults the queue under scheduleLock.
Description
The
add -> maybeSchedulefast path reads thehasScheduledflag outsidescheduleLock, while the running flush resets that flag insidescheduleLockafter a separatequeue.isEmpty()check. This is a check-then-act TOCTOU: an event offered by a producer thread in the window after the flush readsisEmpty() == truebut before it writeshasScheduled = falseis neither observed by the flush (not rescheduled) nor able to schedule itself (the fast path seeshasScheduledstilltrue). The event is left queued withhasScheduled == falseand no pending flush. Affects bothLoggerBatchProcessorandMetricsBatchProcessor(byte-identical scheduling logic).Root cause
sentry/src/main/java/io/sentry/logger/LoggerBatchProcessor.java:maybeSchedulereadshasScheduledoutside the lock;flush()re-checksisEmpty()then setshasScheduled = falseinside the lock.sentry/src/main/java/io/sentry/metrics/MetricsBatchProcessor.java: same structure.The stranded event is only sent on a subsequent
add()or an explicitflush()/close(). On Android this is partly masked becauseAndroidLoggerBatchProcessor/AndroidMetricsBatchProcessorforce a flush ononBackground().Note: reproduced as a deterministic interleaving of the state machine (same volatile flag,
ReentrantLock,ConcurrentLinkedQueue), not a live Android run.Suggested fix
Make the flag reset and queue re-check safe against the lock-free producer path: after draining, set
hasScheduled = falsefirst and then re-checkqueue.isEmpty()under the lock (rescheduling if non-empty), or remove the lock-free fast path somaybeSchedulealways consults the queue underscheduleLock.