Skip to content

Commit e99c3d1

Browse files
fix: memory leak when profiling is enabled (#5133)
* fix: memory leak when profiling is enabled fixes: #5113 fixes: #4721 * Replaced remaining uses of ConcurrentBag<T> * Replaced last remaining usage of ConcurrentQueue<T> * Fixed SqlListener test This was passing accidentally before because ConcurrentBag typically followed a LIFO model for adding/removing items. The SQL listener test had a bug that the LIFO mask would hide. The test did `OrderByDescending(x => x.StartTimestamp).First()` to get the "newest" span, but this fails whenever two spans share the same timestamp — which is common on Windows due to ~15ms clock resolution. Making ConcurrentBagLite LIFO would make the test pass again accidentally, because the newer span would happen to enumerate first and survive the stable sort. But the brittleness is still there — if the spans ever get equal timestamps in a different order (e.g., a background thread adds one), the test breaks again.
1 parent a5a70c6 commit e99c3d1

9 files changed

Lines changed: 252 additions & 61 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
namespace Sentry.Internal;
2+
3+
/// <summary>
4+
/// A minimal replacement for <see cref="ConcurrentBag{T}"/>.
5+
///
6+
/// We're using this to avoid the same class of memory leak that <see cref="ConcurrentQueueLite{T}"/>
7+
/// was introduced to avoid. See https://github.com/getsentry/sentry-dotnet/issues/5113
8+
/// </summary>
9+
internal class ConcurrentBagLite<T> : IReadOnlyCollection<T>
10+
{
11+
private readonly List<T> _items;
12+
13+
public ConcurrentBagLite()
14+
{
15+
_items = new List<T>();
16+
}
17+
18+
public ConcurrentBagLite(IEnumerable<T> collection)
19+
{
20+
_items = new List<T>(collection);
21+
}
22+
23+
public void Add(T item)
24+
{
25+
lock (_items)
26+
{
27+
_items.Add(item);
28+
}
29+
}
30+
31+
public int Count
32+
{
33+
get
34+
{
35+
lock (_items)
36+
{
37+
return _items.Count;
38+
}
39+
}
40+
}
41+
42+
public bool IsEmpty => Count == 0;
43+
44+
public void Clear()
45+
{
46+
lock (_items)
47+
{
48+
_items.Clear();
49+
}
50+
}
51+
52+
public T[] ToArray()
53+
{
54+
lock (_items)
55+
{
56+
return _items.ToArray();
57+
}
58+
}
59+
60+
public IEnumerator<T> GetEnumerator()
61+
{
62+
// Return a snapshot to avoid holding the lock during iteration
63+
// and to prevent InvalidOperationException if the collection is modified.
64+
T[] snapshot;
65+
lock (_items)
66+
{
67+
snapshot = _items.ToArray();
68+
}
69+
return ((IEnumerable<T>)snapshot).GetEnumerator();
70+
}
71+
72+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
73+
}

src/Sentry/Internal/ConcurrentQueueLite.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace Sentry.Internal;
66
/// We're using this due to a memory leak that happens when using ConcurrentQueue in the BackgroundWorker.
77
/// See https://github.com/getsentry/sentry-dotnet/issues/2516
88
/// </summary>
9-
internal class ConcurrentQueueLite<T>
9+
internal class ConcurrentQueueLite<T> : IReadOnlyCollection<T>
1010
{
1111
private readonly List<T> _queue = new();
1212

@@ -74,4 +74,18 @@ public T[] ToArray()
7474
return _queue.ToArray();
7575
}
7676
}
77+
78+
public IEnumerator<T> GetEnumerator()
79+
{
80+
// Return a snapshot to avoid holding the lock during iteration
81+
// and to prevent InvalidOperationException if the collection is modified.
82+
T[] snapshot;
83+
lock (_queue)
84+
{
85+
snapshot = _queue.ToArray();
86+
}
87+
return ((IEnumerable<T>)snapshot).GetEnumerator();
88+
}
89+
90+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
7791
}

src/Sentry/Internal/UnsampledTransaction.cs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ internal sealed class UnsampledTransaction : NoOpTransaction
1212
// Although it's a little bit wasteful to create separate individual class instances here when all we're going to
1313
// report to sentry is the span count (in the client report), SDK users may refer to things like
1414
// `ITransaction.Spans.Count`, so we create an actual collection
15-
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
16-
private readonly ConcurrentBag<ISpan> _spans = [];
17-
#else
18-
private ConcurrentBag<ISpan> _spans = [];
19-
#endif
15+
private readonly ConcurrentBagLite<ISpan> _spans = new();
2016

2117
private readonly IHub _hub;
2218
private readonly ITransactionContext _context;
@@ -117,10 +113,6 @@ public ISpan StartChild(string operation, SpanId spanId)
117113

118114
private void ReleaseSpans()
119115
{
120-
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
121116
_spans.Clear();
122-
#else
123-
_spans = [];
124-
#endif
125117
}
126118
}

src/Sentry/Scope.cs

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -51,29 +51,29 @@ internal SentryId LastEventId
5151
/// </summary>
5252
internal bool HasEvaluated => _hasEvaluated;
5353

54-
private readonly Lazy<ConcurrentBag<ISentryEventExceptionProcessor>> _lazyExceptionProcessors =
54+
private readonly Lazy<ConcurrentBagLite<ISentryEventExceptionProcessor>> _lazyExceptionProcessors =
5555
new(LazyThreadSafetyMode.PublicationOnly);
5656

5757
/// <summary>
5858
/// A list of exception processors.
5959
/// </summary>
60-
internal ConcurrentBag<ISentryEventExceptionProcessor> ExceptionProcessors => _lazyExceptionProcessors.Value;
60+
internal ConcurrentBagLite<ISentryEventExceptionProcessor> ExceptionProcessors => _lazyExceptionProcessors.Value;
6161

62-
private readonly Lazy<ConcurrentBag<ISentryEventProcessor>> _lazyEventProcessors =
62+
private readonly Lazy<ConcurrentBagLite<ISentryEventProcessor>> _lazyEventProcessors =
6363
new(LazyThreadSafetyMode.PublicationOnly);
6464

65-
private readonly Lazy<ConcurrentBag<ISentryTransactionProcessor>> _lazyTransactionProcessors =
65+
private readonly Lazy<ConcurrentBagLite<ISentryTransactionProcessor>> _lazyTransactionProcessors =
6666
new(LazyThreadSafetyMode.PublicationOnly);
6767

6868
/// <summary>
6969
/// A list of event processors.
7070
/// </summary>
71-
internal ConcurrentBag<ISentryEventProcessor> EventProcessors => _lazyEventProcessors.Value;
71+
internal ConcurrentBagLite<ISentryEventProcessor> EventProcessors => _lazyEventProcessors.Value;
7272

7373
/// <summary>
7474
/// A list of event processors.
7575
/// </summary>
76-
internal ConcurrentBag<ISentryTransactionProcessor> TransactionProcessors => _lazyTransactionProcessors.Value;
76+
internal ConcurrentBagLite<ISentryTransactionProcessor> TransactionProcessors => _lazyTransactionProcessors.Value;
7777

7878
/// <summary>
7979
/// An event that fires when the scope evaluates.
@@ -259,11 +259,7 @@ public ITransactionTracer? Transaction
259259
/// <inheritdoc />
260260
public IReadOnlyList<string> Fingerprint { get; set; } = Array.Empty<string>();
261261

262-
#if NETSTANDARD2_0 || NETFRAMEWORK
263-
private ConcurrentQueue<Breadcrumb> _breadcrumbs = new();
264-
#else
265-
private readonly ConcurrentQueue<Breadcrumb> _breadcrumbs = new();
266-
#endif
262+
private readonly ConcurrentQueueLite<Breadcrumb> _breadcrumbs = new();
267263

268264
/// <inheritdoc />
269265
public IReadOnlyCollection<Breadcrumb> Breadcrumbs => _breadcrumbs;
@@ -278,11 +274,7 @@ public ITransactionTracer? Transaction
278274
/// <inheritdoc />
279275
public IReadOnlyDictionary<string, string> Tags => _tags;
280276

281-
#if NETSTANDARD2_0 || NETFRAMEWORK
282-
private ConcurrentBag<SentryAttachment> _attachments = new();
283-
#else
284-
private readonly ConcurrentBag<SentryAttachment> _attachments = new();
285-
#endif
277+
private readonly ConcurrentBagLite<SentryAttachment> _attachments = new();
286278

287279
/// <summary>
288280
/// Attachments.
@@ -435,11 +427,7 @@ public void Clear()
435427
/// </summary>
436428
public void ClearAttachments()
437429
{
438-
#if NETSTANDARD2_0 || NETFRAMEWORK
439-
Interlocked.Exchange(ref _attachments, new());
440-
#else
441430
_attachments.Clear();
442-
#endif
443431
if (Options.EnableScopeSync)
444432
{
445433
Options.ScopeObserver?.ClearAttachments();
@@ -451,12 +439,7 @@ public void ClearAttachments()
451439
/// </summary>
452440
public void ClearBreadcrumbs()
453441
{
454-
#if NETSTANDARD2_0 || NETFRAMEWORK
455-
// No Clear method on ConcurrentQueue for these target frameworks
456-
Interlocked.Exchange(ref _breadcrumbs, new());
457-
#else
458442
_breadcrumbs.Clear();
459-
#endif
460443
}
461444

462445
/// <summary>

src/Sentry/SdkVersion.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Sentry.Extensibility;
2+
using Sentry.Internal;
23
using Sentry.Internal.Extensions;
34
using Sentry.Reflection;
45

@@ -19,8 +20,8 @@ public sealed class SdkVersion : ISentryJsonSerializable
1920

2021
internal static SdkVersion Instance => InstanceLazy.Value;
2122

22-
internal ConcurrentBag<SentryPackage> InternalPackages { get; set; } = new();
23-
internal ConcurrentBag<string> Integrations { get; set; } = new();
23+
internal ConcurrentBagLite<SentryPackage> InternalPackages { get; set; } = new();
24+
internal ConcurrentBagLite<string> Integrations { get; set; } = new();
2425

2526
/// <summary>
2627
/// SDK packages.
@@ -104,8 +105,8 @@ public static SdkVersion FromJson(JsonElement json)
104105

105106
return new SdkVersion
106107
{
107-
InternalPackages = new ConcurrentBag<SentryPackage>(packages),
108-
Integrations = new ConcurrentBag<string>(integrations),
108+
InternalPackages = new ConcurrentBagLite<SentryPackage>(packages),
109+
Integrations = new ConcurrentBagLite<string>(integrations),
109110
Name = name,
110111
Version = version
111112
};

src/Sentry/TransactionTracer.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public IReadOnlyList<string> Fingerprint
148148
set => _fingerprint = value;
149149
}
150150

151-
private readonly ConcurrentBag<Breadcrumb> _breadcrumbs = new();
151+
private readonly ConcurrentBagLite<Breadcrumb> _breadcrumbs = new();
152152

153153
/// <inheritdoc />
154154
public IReadOnlyCollection<Breadcrumb> Breadcrumbs => _breadcrumbs;
@@ -165,11 +165,7 @@ public IReadOnlyList<string> Fingerprint
165165
/// <inheritdoc />
166166
public IReadOnlyDictionary<string, string> Tags => _tags;
167167

168-
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
169-
private readonly ConcurrentBag<ISpan> _spans = new();
170-
#else
171-
private ConcurrentBag<ISpan> _spans = new();
172-
#endif
168+
private readonly ConcurrentBagLite<ISpan> _spans = new();
173169

174170
/// <inheritdoc />
175171
public IReadOnlyCollection<ISpan> Spans => _spans;
@@ -428,11 +424,7 @@ public string? Origin
428424

429425
private void ReleaseSpans()
430426
{
431-
#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
432427
_spans.Clear();
433-
#else
434-
_spans = new ConcurrentBag<ISpan>();
435-
#endif
436428
_activeSpanTracker.Clear();
437429
}
438430

test/Sentry.DiagnosticSource.Tests/SentrySqlListenerTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,10 @@ public void OnNext_KnownKey_GetSpanInvoked(string key, bool addConnectionSpan)
120120
}));
121121

122122
// Assert
123-
var spans = fixture.Spans.Where(s => s.Operation != "abc");
123+
var spans = fixture.Spans.Where(s => s.Operation != "abc").ToList();
124124
Assert.NotEmpty(spans);
125125

126-
var firstSpan = fixture.Spans.OrderByDescending(x => x.StartTimestamp).First();
127-
Assert.True(GetValidator(key)(firstSpan));
126+
Assert.True(GetValidator(key)(spans[0]));
128127
}
129128

130129
[Theory]

0 commit comments

Comments
 (0)