Skip to content

Commit d937272

Browse files
authored
fix(flags): Fix bug where concurrent accesses to the flags property could raise a RunTime error (#4034)
On error the SDK deep copies the flag buffer. If the SDK is receiving flags at the same time, the buffer copy can potentially raise a RunTime error. To fix this we guard the FlagBuffer with a lock. Fixes: https://sentry.sentry.io/issues/6286673308/?project=1
1 parent 3217cca commit d937272

File tree

2 files changed

+65
-5
lines changed

2 files changed

+65
-5
lines changed

sentry_sdk/feature_flags.py

+31-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import copy
12
import sentry_sdk
23
from sentry_sdk._lru_cache import LRUCache
4+
from threading import Lock
35

4-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
57

68
if TYPE_CHECKING:
79
from typing import TypedDict
@@ -16,20 +18,44 @@ class FlagBuffer:
1618

1719
def __init__(self, capacity):
1820
# type: (int) -> None
19-
self.buffer = LRUCache(capacity)
2021
self.capacity = capacity
22+
self.lock = Lock()
23+
24+
# Buffer is private. The name is mangled to discourage use. If you use this attribute
25+
# directly you're on your own!
26+
self.__buffer = LRUCache(capacity)
2127

2228
def clear(self):
2329
# type: () -> None
24-
self.buffer = LRUCache(self.capacity)
30+
self.__buffer = LRUCache(self.capacity)
31+
32+
def __deepcopy__(self, memo):
33+
# type: (dict[int, Any]) -> FlagBuffer
34+
with self.lock:
35+
buffer = FlagBuffer(self.capacity)
36+
buffer.__buffer = copy.deepcopy(self.__buffer, memo)
37+
return buffer
2538

2639
def get(self):
2740
# type: () -> list[FlagData]
28-
return [{"flag": key, "result": value} for key, value in self.buffer.get_all()]
41+
with self.lock:
42+
return [
43+
{"flag": key, "result": value} for key, value in self.__buffer.get_all()
44+
]
2945

3046
def set(self, flag, result):
3147
# type: (str, bool) -> None
32-
self.buffer.set(flag, result)
48+
if isinstance(result, FlagBuffer):
49+
# If someone were to insert `self` into `self` this would create a circular dependency
50+
# on the lock. This is of course a deadlock. However, this is far outside the expected
51+
# usage of this class. We guard against it here for completeness and to document this
52+
# expected failure mode.
53+
raise ValueError(
54+
"FlagBuffer instances can not be inserted into the dictionary."
55+
)
56+
57+
with self.lock:
58+
self.__buffer.set(flag, result)
3359

3460

3561
def add_feature_flag(flag, result):

tests/test_feature_flags.py

+34
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import concurrent.futures as cf
22
import sys
3+
import copy
4+
import threading
35

46
import pytest
57

@@ -167,3 +169,35 @@ def test_flag_tracking():
167169
{"flag": "e", "result": False},
168170
{"flag": "f", "result": False},
169171
]
172+
173+
174+
def test_flag_buffer_concurrent_access():
175+
buffer = FlagBuffer(capacity=100)
176+
error_occurred = False
177+
178+
def writer():
179+
for i in range(1_000_000):
180+
buffer.set(f"key_{i}", True)
181+
182+
def reader():
183+
nonlocal error_occurred
184+
185+
try:
186+
for _ in range(1000):
187+
copy.deepcopy(buffer)
188+
except RuntimeError:
189+
error_occurred = True
190+
191+
writer_thread = threading.Thread(target=writer)
192+
reader_thread = threading.Thread(target=reader)
193+
194+
writer_thread.start()
195+
reader_thread.start()
196+
197+
writer_thread.join(timeout=5)
198+
reader_thread.join(timeout=5)
199+
200+
# This should always be false. If this ever fails we know we have concurrent access to a
201+
# shared resource. When deepcopying we should have exclusive access to the underlying
202+
# memory.
203+
assert error_occurred is False

0 commit comments

Comments
 (0)