Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
@ApiStatus.Internal
public final class FeatureFlagBuffer implements IFeatureFlagBuffer {

private volatile @NotNull CopyOnWriteArrayList<FeatureFlagEntry> flags;
private final @NotNull CopyOnWriteArrayList<FeatureFlagEntry> flags;
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
private int maxSize;

Expand Down Expand Up @@ -135,13 +135,15 @@ public void clear() {
final @Nullable FeatureFlagBuffer isolationBuffer,
final @Nullable FeatureFlagBuffer currentBuffer) {

// Capture references 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<FeatureFlagEntry> globalFlags =
globalBuffer == null ? null : globalBuffer.flags;
globalBuffer == null ? null : new CopyOnWriteArrayList<>(globalBuffer.flags);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this need to be a CopyOnWriteArrayList ? could it be Collections.unmodifiableList or just an ArrayList ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We make use of the optimization in modern JDK / Android:

    public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] es;
        if (c.getClass() == CopyOnWriteArrayList.class)
            es = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            es = c.toArray();
            if (c.getClass() != java.util.ArrayList.class)
                es = Arrays.copyOf(es, es.length, Object[].class);
        }
        setArray(es);
    }

This avoids copying the underlying array and instead reuses it until the next modification.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah gotcha. I think we should add a comment about that for future readers of the codebase since this is somewhat unexpected here since we never modify the list.

final @Nullable CopyOnWriteArrayList<FeatureFlagEntry> isolationFlags =
isolationBuffer == null ? null : isolationBuffer.flags;
isolationBuffer == null ? null : new CopyOnWriteArrayList<>(isolationBuffer.flags);
final @Nullable CopyOnWriteArrayList<FeatureFlagEntry> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Throwable?>(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(1_000) {
FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer).featureFlags
}

assertThat(writerOperations.get()).isGreaterThan(writerOperationsBeforeMerging)
} finally {
stop.set(true)
writer.join()
}

assertThat(writerFailure.get()).isNull()
}
}
Loading