Skip to content

Commit 8bb9e11

Browse files
cursoragentCursor Agent
andcommitted
Fix idle transaction finish race conditions
Co-Authored-By: Cursor Agent <agent@noreply.example.com>
1 parent b9e4ff1 commit 8bb9e11

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

‎src/Sentry/TransactionTracer.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ private void OnIdleTimeout()
287287
if (shouldDiscard)
288288
{
289289
_options?.LogDebug("Idle transaction '{0}' has no child spans. Discarding.", SpanId);
290+
EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
290291
_idleTimer?.Dispose();
291292
_hub.ConfigureScope(static (scope, tracer) => scope.ResetTransaction(tracer), this);
292293
return;
@@ -378,7 +379,15 @@ internal void ChildSpanFinished()
378379
// Only restart the idle timer when there are no more active (unfinished) child spans
379380
if (_activeSpanTracker.PeekActive() == null)
380381
{
381-
_idleTimer?.Start(_idleTimeout.Value);
382+
try
383+
{
384+
_idleTimer?.Start(_idleTimeout.Value);
385+
}
386+
catch (ObjectDisposedException)
387+
{
388+
// Finish() may dispose the timer concurrently between the _hasFinished check and Start().
389+
// Swallow the exception — the transaction is already finishing.
390+
}
382391
}
383392
}
384393

‎test/Sentry.Tests/TransactionTracerTests.cs‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ public class TransactionTracerTests
66
{
77
private static readonly TimeSpan AnyTimeout = TimeSpan.FromSeconds(30);
88

9+
private sealed class ThrowOnDisposedTimer : ISentryTimer
10+
{
11+
private bool _isDisposed;
12+
13+
public ThrowOnDisposedTimer(Action _)
14+
{
15+
}
16+
17+
public void Start(TimeSpan timeout)
18+
{
19+
if (_isDisposed)
20+
{
21+
throw new ObjectDisposedException(nameof(ThrowOnDisposedTimer));
22+
}
23+
}
24+
25+
public void Cancel()
26+
{
27+
}
28+
29+
public void Dispose() => _isDisposed = true;
30+
}
31+
932
private static (TransactionTracer transaction, MockTimer timer) CreateIdleTransaction(
1033
IHub hub, string name = "name", string op = "op")
1134
{
@@ -122,13 +145,39 @@ public void IdleTimeout_NoChildSpans_TransactionIsDiscarded()
122145
{
123146
// Given an auto-generated UI event transaction with no child spans
124147
var hub = Substitute.For<IHub>();
125-
var (_, timer) = CreateIdleTransaction(hub);
148+
var (transaction, timer) = CreateIdleTransaction(hub);
126149

127150
// When the idleTimeout fires
128151
timer.Fire();
129152

130153
// Then the SDK discards the transaction (does not capture it)
131154
hub.DidNotReceive().CaptureTransaction(Arg.Any<SentryTransaction>());
155+
transaction.IsFinished.Should().BeTrue();
156+
transaction.EndTimestamp.Should().NotBeNull();
157+
}
158+
159+
[Fact]
160+
public void ChildSpanFinished_DisposedIdleTimer_DoesNotThrow()
161+
{
162+
// Given an idle transaction whose timer has been disposed after the finished-state check
163+
var hub = Substitute.For<IHub>();
164+
ThrowOnDisposedTimer? disposedTimer = null;
165+
var transaction = new TransactionTracer(
166+
hub,
167+
new TransactionContext("name", "op"),
168+
idleTimeout: AnyTimeout,
169+
timerFactory: callback =>
170+
{
171+
disposedTimer = new ThrowOnDisposedTimer(callback);
172+
return disposedTimer;
173+
});
174+
disposedTimer!.Dispose();
175+
176+
// When ChildSpanFinished attempts to restart the disposed timer
177+
var action = () => transaction.ChildSpanFinished();
178+
179+
// Then no ObjectDisposedException escapes
180+
action.Should().NotThrow();
132181
}
133182

134183
[Fact]

0 commit comments

Comments
 (0)