From ba469f7962f48b6de1c3f3560cc72087286fae8e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 14:08:10 +0200 Subject: [PATCH 1/5] fix(core): Snapshot feature flags before merging Concurrent writes can change a CopyOnWriteArrayList backing array between merged() reading its size and indexed entries, crashing event capture. Snapshot each scope buffer before merging so indexed reads use a stable view. Add a regression test that verifies writer progress during merging. Refs JAVA-704 Co-Authored-By: Claude --- .../featureflags/FeatureFlagBuffer.java | 8 +-- .../featureflags/FeatureFlagBufferTest.kt | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index fc696b5948f..c8a0c03ccb4 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -135,13 +135,13 @@ public void clear() { final @Nullable FeatureFlagBuffer isolationBuffer, final @Nullable FeatureFlagBuffer currentBuffer) { - // Capture references to avoid inconsistencies from concurrent modifications + // Capture snapshots to avoid inconsistencies from concurrent modifications final @Nullable CopyOnWriteArrayList globalFlags = - globalBuffer == null ? null : globalBuffer.flags; + globalBuffer == null ? null : new CopyOnWriteArrayList<>(globalBuffer.flags); final @Nullable CopyOnWriteArrayList isolationFlags = - isolationBuffer == null ? null : isolationBuffer.flags; + isolationBuffer == null ? null : new CopyOnWriteArrayList<>(isolationBuffer.flags); final @Nullable CopyOnWriteArrayList currentFlags = - currentBuffer == null ? null : currentBuffer.flags; + currentBuffer == null ? null : new CopyOnWriteArrayList<>(currentBuffer.flags); final int globalSize = globalFlags == null ? 0 : globalFlags.size(); final int isolationSize = isolationFlags == null ? 0 : isolationFlags.size(); diff --git a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt index 8ec18ce02b8..531d2631b43 100644 --- a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt @@ -1,6 +1,12 @@ package io.sentry.featureflags +import com.google.common.truth.Truth.assertThat import io.sentry.SentryOptions +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -347,4 +353,48 @@ class FeatureFlagBufferTest { val featureFlags = buffer.featureFlags assertNotNull(featureFlags) } + + @Test + fun `merging is safe while another thread adds flags`() { + val options = SentryOptions().also { it.maxFeatureFlags = 100 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + + repeat(options.maxFeatureFlags) { globalBuffer.add("initial$it", true) } + + val stop = AtomicBoolean(false) + val writerFailure = AtomicReference(null) + val writerOperations = AtomicInteger() + val writerStarted = CountDownLatch(1) + val writer = Thread { + try { + var i = 0 + while (!stop.get()) { + globalBuffer.add("flag${i++ % 150}", true) + writerOperations.incrementAndGet() + writerStarted.countDown() + } + } catch (e: Exception) { + writerFailure.set(e) + } + } + + writer.start() + try { + assertThat(writerStarted.await(5, TimeUnit.SECONDS)).isTrue() + val writerOperationsBeforeMerging = writerOperations.get() + + repeat(10_000) { + FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer).featureFlags + } + + assertThat(writerOperations.get()).isGreaterThan(writerOperationsBeforeMerging) + } finally { + stop.set(true) + writer.join() + } + + assertThat(writerFailure.get()).isNull() + } } From 323bfd9028c8bef92afb553e80dc3c45c80fe130 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 14:10:05 +0200 Subject: [PATCH 2/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e1a8a906e..f01d585ae0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Prevent crashes when feature flags are merged during concurrent scope updates ([#5994](https://github.com/getsentry/sentry-java/pull/5994)) - Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888)) - Prevent a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965)) From 04cd9f87b988abafdfb123e745aab3f1318f77d6 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 14:36:57 +0200 Subject: [PATCH 3/5] ref(core): Make feature flag buffer reference final The buffer mutates one CopyOnWriteArrayList wrapper and never replaces the field after construction. Mark the reference final while relying on the list's own memory visibility guarantees for its contents. Refs JAVA-704 Co-Authored-By: Claude --- .../src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index c8a0c03ccb4..1eaaa33da02 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -27,7 +27,7 @@ @ApiStatus.Internal public final class FeatureFlagBuffer implements IFeatureFlagBuffer { - private volatile @NotNull CopyOnWriteArrayList flags; + private final @NotNull CopyOnWriteArrayList flags; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private int maxSize; From 7095093a699fd06c03519126423177e1d94b42b1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 15:14:20 +0200 Subject: [PATCH 4/5] test(core): Reduce feature flag concurrency test iterations Use 1,000 merge iterations to reduce CI time while retaining regression coverage. The old implementation reproduced the merge crash in 20 out of 20 clean local runs at this iteration count. Refs JAVA-704 Co-Authored-By: Claude --- .../test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt index 531d2631b43..69ccbc9841b 100644 --- a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt @@ -385,7 +385,7 @@ class FeatureFlagBufferTest { assertThat(writerStarted.await(5, TimeUnit.SECONDS)).isTrue() val writerOperationsBeforeMerging = writerOperations.get() - repeat(10_000) { + repeat(1_000) { FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer).featureFlags } From fcdc8ba48dc7269d4f47c6da9fd8c6dc44664765 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 15:55:33 +0200 Subject: [PATCH 5/5] docs(core): Explain feature flag snapshot optimization Document why merge snapshots pass CopyOnWriteArrayList instances directly to the collection constructor and how runtimes can avoid copying elements. Co-Authored-By: Claude --- .../main/java/io/sentry/featureflags/FeatureFlagBuffer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 1eaaa33da02..2eccde29556 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -135,7 +135,9 @@ public void clear() { final @Nullable FeatureFlagBuffer isolationBuffer, final @Nullable FeatureFlagBuffer currentBuffer) { - // Capture snapshots to avoid inconsistencies from concurrent modifications + // Capture structurally stable snapshots before indexed traversal. Passing a + // CopyOnWriteArrayList directly allows its collection constructor to reuse the immutable + // backing array instead of copying the elements on runtimes that support this optimization. final @Nullable CopyOnWriteArrayList globalFlags = globalBuffer == null ? null : new CopyOnWriteArrayList<>(globalBuffer.flags); final @Nullable CopyOnWriteArrayList isolationFlags =