Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 3 additions & 1 deletion src/Sentry.Azure.Functions.Worker/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

# Reason: Azure Functions worker doesn't set SynchronizationContext but Durable Functions does and required affinity.
# (https://github.com/Azure/azure-functions-dotnet-worker/issues/1520)
dotnet_diagnostic.CA2007.severity = none
dotnet_diagnostic.CA2007.severity = none

dotnet_style_namespace_declaration_with_usings_placement = prefer_outside_namespace
6 changes: 4 additions & 2 deletions src/Sentry.EntityFramework/SentryDatabaseLogging.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
using Sentry.Internal;

namespace Sentry.EntityFramework;

/// <summary>
/// Sentry Database Logger
/// </summary>
internal static class SentryDatabaseLogging
{
private static int Init;
private static InterlockedBoolean Init;

internal static SentryCommandInterceptor? UseBreadcrumbs(
IQueryLogger? queryLogger = null,
bool initOnce = true,
IDiagnosticLogger? diagnosticLogger = null)
{
if (initOnce && Interlocked.Exchange(ref Init, 1) != 0)
if (initOnce && Init.Exchange(true))
Copy link

Choose a reason for hiding this comment

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

Bug: Initialization Check Logic Reversed

The initOnce check's logic is inverted. The original condition returned true if already initialized, but the new Init.Exchange(true) returns the opposite boolean value needed for the condition. This breaks the intended "init once" behavior, causing initialization to always proceed instead of returning early when already initialized.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

...huh??

I just made a quick test case as a sanity check. It enumerates the possible values for initOnce and Init, and then compares the results of the before & after expressions:

00 False False
01 False False
10 False False
11 True True

Column 1: x and y taking the place of initOnce and Init
Column 2: y as int: x && Interlocked.Exchange(ref y, 1) != 0;
Column 3: y as bool: x && Interlocked.Exchange(ref y, true);

And, the body of the if statement isn't where initialization happens. Rather, it enters the if in the situation where it shouldn't repeat initialization. The condition will only evaluate to true when the caller asked for initOnce semantics and a previous call already put true into Init.

I think the AI has the wrong end of the stick on this one. 🙂

Or am I missing something really obvious?? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just got to wondering whether it knew something about the InterlockedBoolean.Exchange wrapper that I didn't. Automated testing of it notwithstanding, maybe it didn't return the same value?? So I copied InterlockedBoolean.cs into my test app and added a column for y as InterlockedBoolean: x && y.Exchange(true).

00 False False False
01 False False False
10 False False False
11 True True True

¯\_(ツ)_/¯

Copy link
Member

@Flash0ver Flash0ver Oct 10, 2025

Choose a reason for hiding this comment

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

The original condition returned true if already initialized, but the new Init.Exchange(true) returns the opposite boolean value needed for the condition.

I believe you're right.
Init.Exchange(true) returns the original value, which is false the first time, so the early-out is not entered,
but subsequent invocations are returning the now original value of true entering the "was already executed"-path.

Sounds correct to me, too, with the code as-is.

{
diagnosticLogger?.LogWarning("{0}.{1} was already executed.",
nameof(SentryDatabaseLogging), nameof(UseBreadcrumbs));
Expand Down
15 changes: 4 additions & 11 deletions src/Sentry.Profiling/SampleProfilerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,12 @@ private SampleProfilerSession(SentryStopwatch stopwatch, EventPipeSession sessio

public TraceLog TraceLog => EventSource.TraceLog;

// default is false, set 1 for true.
private static int _throwOnNextStartupForTests = 0;
private static InterlockedBoolean _throwOnNextStartupForTests = false;

internal static bool ThrowOnNextStartupForTests
{
get { return Interlocked.CompareExchange(ref _throwOnNextStartupForTests, 1, 1) == 1; }
set
{
if (value)
Interlocked.CompareExchange(ref _throwOnNextStartupForTests, 1, 0);
else
Interlocked.CompareExchange(ref _throwOnNextStartupForTests, 0, 1);
}
get { return _throwOnNextStartupForTests; }
set { _throwOnNextStartupForTests.Exchange(value); }
}

public static SampleProfilerSession StartNew(IDiagnosticLogger? logger = null)
Expand All @@ -77,7 +70,7 @@ public static SampleProfilerSession StartNew(IDiagnosticLogger? logger = null)
{
var client = new DiagnosticsClient(Environment.ProcessId);

if (Interlocked.CompareExchange(ref _throwOnNextStartupForTests, 0, 1) == 1)
if (_throwOnNextStartupForTests.CompareExchange(false, true) == true)
{
throw new Exception("Test exception");
}
Expand Down
15 changes: 6 additions & 9 deletions src/Sentry.Profiling/SamplingTransactionProfilerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ namespace Sentry.Profiling;
internal class SamplingTransactionProfilerFactory : IDisposable, ITransactionProfilerFactory
{
// We only allow a single profile so let's keep track of the current status.
internal int _inProgress = FALSE;
internal InterlockedBoolean _inProgress = false;

// Whether the session startup took longer than the given timeout.
internal bool StartupTimedOut { get; }

private const int TRUE = 1;
private const int FALSE = 0;

// Stop profiling after the given number of milliseconds.
private const int TIME_LIMIT_MS = 30_000;

Expand Down Expand Up @@ -50,20 +47,20 @@ public SamplingTransactionProfilerFactory(SentryOptions options, TimeSpan startu
public ITransactionProfiler? Start(ITransactionTracer _, CancellationToken cancellationToken)
{
// Start a profiler if one wasn't running yet.
if (!_errorLogged && Interlocked.Exchange(ref _inProgress, TRUE) == FALSE)
if (!_errorLogged && !_inProgress.Exchange(true))
{
if (!_sessionTask.IsCompleted)
{
_options.LogWarning("Cannot start a sampling profiler, the session hasn't started yet.");
_inProgress = FALSE;
_inProgress = false;
return null;
}

if (!_sessionTask.IsCompletedSuccessfully)
{
_options.LogWarning("Cannot start a sampling profiler because the session startup has failed. This is a permanent error and no future transactions will be sampled.");
_errorLogged = true;
_inProgress = FALSE;
_inProgress = false;
return null;
}

Expand All @@ -72,13 +69,13 @@ public SamplingTransactionProfilerFactory(SentryOptions options, TimeSpan startu
{
return new SamplingTransactionProfiler(_options, _sessionTask.Result, TIME_LIMIT_MS, cancellationToken)
{
OnFinish = () => _inProgress = FALSE
OnFinish = () => _inProgress = false
};
}
catch (Exception e)
{
_options.LogError(e, "Failed to start a profiler session.");
_inProgress = FALSE;
_inProgress = false;
}
}
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ internal sealed class DetectBlockingSynchronizationContext : SynchronizationCont

internal int _isSuppressed;

internal void Suppress() => Interlocked.Exchange(ref _isSuppressed, _isSuppressed + 1);
internal void Restore() => Interlocked.Exchange(ref _isSuppressed, _isSuppressed - 1);
internal void Suppress() => Interlocked.Increment(ref _isSuppressed);
internal void Restore() => Interlocked.Decrement(ref _isSuppressed);

public DetectBlockingSynchronizationContext(IBlockingMonitor monitor)
{
Expand Down
11 changes: 6 additions & 5 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@ internal class Hub : IHub, IDisposable
private readonly MemoryMonitor? _memoryMonitor;
#endif

private int _isPersistedSessionRecovered;
private InterlockedBoolean _isPersistedSessionRecovered;

// Internal for testability
internal ConditionalWeakTable<Exception, ISpan> ExceptionToSpanMap { get; } = new();

internal IInternalScopeManager ScopeManager { get; }

private int _isEnabled = 1;
public bool IsEnabled => _isEnabled == 1;
private InterlockedBoolean _isEnabled = true;

public bool IsEnabled => _isEnabled;

internal SentryOptions Options => _options;

Expand Down Expand Up @@ -356,7 +357,7 @@ public TransactionContext ContinueTrace(
public void StartSession()
{
// Attempt to recover persisted session left over from previous run
if (Interlocked.Exchange(ref _isPersistedSessionRecovered, 1) != 1)
if (_isPersistedSessionRecovered.Exchange(true) != true)
{
try
{
Expand Down Expand Up @@ -835,7 +836,7 @@ public void Dispose()
{
_options.LogInfo("Disposing the Hub.");

if (Interlocked.Exchange(ref _isEnabled, 0) != 1)
if (!_isEnabled.Exchange(false))
{
return;
}
Expand Down
54 changes: 54 additions & 0 deletions src/Sentry/Internal/InterlockedBoolean.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#if NET9_0_OR_GREATER
using TBool = System.Boolean;
#else
using TBool = System.Int32;
#endif

namespace Sentry.Internal;

internal struct InterlockedBoolean
{
private volatile TBool _value;

[Browsable(false)]
internal TBool ValueForTests => _value;

#if NET9_0_OR_GREATER
private const TBool True = true;
private const TBool False = false;
#else
private const TBool True = 1;
private const TBool False = 0;
#endif

public InterlockedBoolean() { }

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public InterlockedBoolean(bool value) { _value = value ? True : False; }

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static implicit operator bool(InterlockedBoolean @this) => (@this._value != False);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static implicit operator InterlockedBoolean(bool @this) => new InterlockedBoolean(@this);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Exchange(bool newValue)
{
TBool localNewValue = newValue ? True : False;

TBool localReturnValue = Interlocked.Exchange(ref _value, localNewValue);

return (localReturnValue != False);
}
Comment on lines +35 to +42
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: potentially more efficient implementation ... candidate for a follow-up PR

I am wondering if we can make the implementation for NET9_0_OR_GREATER more efficient by removing TBool localNewValue = newValue ? True : False; which is only required for older TFMs.

Something like:

// ...
#if NET9_0_OR_GREATER
    internal volatile bool _value;
#else
    internal volatile int _value;
#endif
// ...
public bool Exchange(bool newValue)
{
#if NET9_0_OR_GREATER
    return Interlocked.Exchange(ref _value, newValue);
#else
    int localNewValue = newValue ? 1 : 0;
    int localReturnValue = Interlocked.Exchange(ref _value, localNewValue);
    return (localReturnValue != 0);
#endif
}
// ...

This way we wouldn't need any TBool alias anymore, which in my opinion becomes a bit more readable,
and the NET9_0_OR_GREATER implementation should be every so slightly more efficient (unless the JIT sees through it and optimized it).

However, I am quite happy to having this as a follow-up PR, if you'd like ... as the current implementation is functional and this change is about style and performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not able to predict exactly what it'll do, but I actually wouldn't be surprised if the JIT's code optimization just treated the same storage as bool and int contextually. I'm pretty sure in the ABI, a parameter on the call stack is always machine word-aligned, so on x86 the bool is internally an Int32 and on x64 the bool is an Int64. The optimizer might notice this and simply pass the memory address of the parameter on the stack in as the ref int for Interlocked.Exchange. I wouldn't depend on this, of course, but just to say that there might not really be a hugely noticeable speed difference.

Now I'm curious 🙂 I might do some poking just to see.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, I took a peek at what the JIT is doing.

In Debug mode, it's doing exactly what's written, no special efficiency.

Source code:

        TBool localNewValue = newValue ? True : False;

Compiled code:

// Load the parameter value as a 32-bit value, but then explicitly zero-extend the least significant byte
00007FFE80D61C39  mov         eax,dword ptr [rbp+68h]  
00007FFE80D61C3C  movzx       eax,al  
// EAX is now either 0 or 1 based on the bool value of newValue. Branch based on whether it is zero or not.
00007FFE80D61C3F  test        eax,eax  
00007FFE80D61C41  jnz         Sentry.Internal.InterlockedBoolean.Exchange(Boolean)+03Ah (07FFE80D61C4Ah)  
// - It is zero, write the value of False, which is also bool false, into temporary storage
00007FFE80D61C43  xor         eax,eax  
00007FFE80D61C45  mov         dword ptr [rbp+30h],eax  
00007FFE80D61C48  jmp         Sentry.Internal.InterlockedBoolean.Exchange(Boolean)+041h (07FFE80D61C51h)  
// - It is non-zero, write the value of True, which is bool true, into temporary storage
00007FFE80D61C4A  mov         dword ptr [rbp+30h],1  
// Now, just as when we read the parameter value, read the temporary storage as a 32-bit value, but then explicitly zero-extend the least significant byte (totally unnecessary 🙂)
00007FFE80D61C51  mov         eax,dword ptr [rbp+30h]  
00007FFE80D61C54  movzx       eax,al  
// Write the result to localNewValue
00007FFE80D61C57  mov         dword ptr [rbp+3Ch],eax  

So that's kind of disappointing but also not a huge surprise. 🙂

Interestingly, the return value isn't quite so horrifically inefficient:

Source code, in which localReturnValue is a bool because I'm running in .NET 9:

        return (localReturnValue != False);

Compiled code:

// Load the value of localReturnValue
00007FFE80D61C79  mov         eax,dword ptr [rbp+38h]  
// Write it to temporary storage
00007FFE80D61C7C  mov         dword ptr [rbp+34h],eax  
00007FFE80D61C7F  nop  
// Load the temporary storage into EAX in preparation for RET (totally unnecessary 🙂 it's already there)
00007FFE80D61C80  mov         eax,dword ptr [rbp+34h]  

So, in the return code, it recognized that boolValue != false is just boolValue, but in the lead-in, it didn't recognize that boolValue ? true : false is just boolValue.

Anyway, that's the Debug profile, no optimization. Now for Release.

Source code:

        TBool localNewValue = newValue ? True : False;

Compiled code:

// With the fastcall calling convention, the this argument is in ECX and the parameter newValue is in EDX. Stash in temporary storage.
00007FFE6B3C7B53  mov         dword ptr [rbp+18h],edx 
// Similar to the Debug profile, load the value as a 32-bit word and then zero-extend. 
00007FFE6B3C7B56  mov         eax,dword ptr [rbp+18h]  
00007FFE6B3C7B59  movzx       eax,al  
// Test if it is zero or not.
00007FFE6B3C7B5C  test        eax,eax  
// No branching, ma! Set AL to 1 or 0 based on whether the bool value was true or false.
00007FFE6B3C7B5E  setnz       al  
// Zero-extend, just in case (this clears the top bytes of EAX), then store it in localNewValue
00007FFE6B3C7B61  movzx       eax,al  
00007FFE6B3C7B64  mov         dword ptr [rbp-4],eax  

As for the return, well, that gets interesting :-) The Interlocked.Exchange actually got treated as an intrinsic, and the result, which was computed into ECX, was then copied directly to EAX. The metadata didn't even attribute a byte range to the return line at all.

Source code:

        TBool localReturnValue = Interlocked.Exchange(ref _value, localNewValue);

        return (localReturnValue != False);

Compiled code:

// Load the memory address of _value
00007FFE6B3C7B67  mov         rax,qword ptr [rbp+10h]  
// This opcode is weird, possibly a bug in the JIT? It compares the byte at this memory address _with the least significant byte of the memory address_
00007FFE6B3C7B6B  cmp         byte ptr [rax],al  
// Load the memory address of _value again (unnecessary)
00007FFE6B3C7B6D  mov         rax,qword ptr [rbp+10h]  
// Load the value of localNewValue into ECX
00007FFE6B3C7B71  mov         ecx,dword ptr [rbp-4]  
// Interlocked.Exchange, a single instruction :-)
00007FFE6B3C7B74  xchg        cl,byte ptr [rax]  
// XCHG has swapped the byte in _value with the CL register. Now we zero-extend, turning it into a 32-bit representation of the bool value.
00007FFE6B3C7B76  movzx       ecx,cl  
// Store in EAX for return. All done.
00007FFE6B3C7B79  mov         eax,ecx  

So how does that compare against just a direct call to Interlocked.Exchange, if the target framework supports it?

Source code:

        return Interlocked.Exchange(ref _value, newValue);

Compiled code:

// Move the fastcall parameter from EDX into temporary storage
00007FFF5D766C78  mov         dword ptr [rbp+18h],edx  
// Load the memory address of _value
00007FFF5D766C7B  mov         rax,qword ptr [rbp+10h]  
// This opcode is weird, possibly a bug in the JIT? It compares the byte at this memory address _with the least significant byte of the memory address_
00007FFF5D766C7F  cmp         byte ptr [rax],al  
// Load the memory address of _value again (unnecessary)
00007FFF5D766C81  mov         rax,qword ptr [rbp+10h]  
// Load the value of localNewValue into ECX
00007FFF5D766C85  mov         ecx,dword ptr [rbp+18h]  
00007FFF5D766C88  movzx       ecx,cl  
// Interlocked.Exchange, a single instruction :-)
00007FFF5D766C8B  xchg        cl,byte ptr [rax]  
// XCHG has swapped the byte in _value with the CL register. Now we zero-extend, turning it into a 32-bit representation of the bool value.
00007FFF5D766C8D  movzx       ecx,cl  
// Store in EAX for return. All done.
00007FFF5D766C90  mov         eax,ecx  

So basically identical to the tail end of the generic implementation.

It's not perfectly-optimized, but it's pretty good.

What does this mean for performance?

It means the difference between being able to do myInterlockedBoolean.Exchange(value) 254 million times per second and only being able to do 251 million calls per second.

It appears that that extra lead-up is almost entirely inconsequential. I made a program to benchmark it, and very consistently, the "slow" path is only 1.2% slower than the "fast" path.

I'll polish up the benchmark code and throw it up into a repo somewhere, because maybe I made a silly mistake somewhere. 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Even in Debug mode, the "slow" version is only about 11% slower than the "fast" version, ~158 million calls per second compared to ~140 million calls per second.

Here's the benchmark:

https://github.com/logiclrd/InterlockedExchangePerformanceTest


[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool CompareExchange(bool value, bool comparand)
{
TBool localValue = value ? True : False;
TBool localComparand = comparand ? True : False;

TBool localReturnValue = Interlocked.CompareExchange(ref _value, localValue, localComparand);

return (localReturnValue != False);
}
}
15 changes: 9 additions & 6 deletions src/Sentry/Threading/ScopedCountdownLock.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Sentry.Internal;

namespace Sentry.Threading;

/// <summary>
Expand All @@ -13,12 +15,13 @@ namespace Sentry.Threading;
internal sealed class ScopedCountdownLock : IDisposable
{
private readonly CountdownEvent _event;
private volatile int _isEngaged;

private InterlockedBoolean _isEngaged;

internal ScopedCountdownLock()
{
_event = new CountdownEvent(1);
_isEngaged = 0;
_isEngaged = false;
}

/// <summary>
Expand All @@ -31,13 +34,13 @@ internal ScopedCountdownLock()
/// Gets the number of remaining <see cref="CounterScope"/> required to exit in order to set/signal the event while a <see cref="LockScope"/> is active.
/// When <see langword="0"/> and while a <see cref="LockScope"/> is active, no more <see cref="CounterScope"/> can be entered.
/// </summary>
internal int Count => _isEngaged == 1 ? _event.CurrentCount : _event.CurrentCount - 1;
internal int Count => _isEngaged ? _event.CurrentCount : _event.CurrentCount - 1;

/// <summary>
/// Returns <see langword="true"/> when a <see cref="LockScope"/> is active and the event can be set/signaled by <see cref="Count"/> reaching <see langword="0"/>.
/// Returns <see langword="false"/> when the <see cref="Count"/> can only reach the initial count of <see langword="1"/> when no <see cref="CounterScope"/> is active any longer.
/// </summary>
internal bool IsEngaged => _isEngaged == 1;
internal bool IsEngaged => _isEngaged;

/// <summary>
/// No <see cref="CounterScope"/> will be entered when the <see cref="Count"/> has reached <see langword="0"/>, or while the lock is engaged via an active <see cref="LockScope"/>.
Expand Down Expand Up @@ -79,7 +82,7 @@ private void ExitCounterScope()
/// </remarks>
internal LockScope TryEnterLockScope()
{
if (Interlocked.CompareExchange(ref _isEngaged, 1, 0) == 0)
if (_isEngaged.CompareExchange(true, false) == false)
{
Debug.Assert(_event.CurrentCount >= 1);
_ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all entered 'CounterScope' instances have exited
Expand All @@ -94,7 +97,7 @@ private void ExitLockScope()
Debug.Assert(_event.IsSet);
_event.Reset(); // reset the signaled event to the initial count of 1, so that new 'CounterScope' instances can be entered again

if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) != 1)
if (_isEngaged.CompareExchange(false, true) != true)
{
Debug.Fail("The Lock should have not been disengaged without being engaged first.");
}
Expand Down
7 changes: 4 additions & 3 deletions src/Sentry/TransactionTracer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ public class TransactionTracer : IBaseTracer, ITransactionTracer
private readonly IHub _hub;
private readonly SentryOptions? _options;
private readonly Timer? _idleTimer;
private long _cancelIdleTimeout;
private readonly SentryStopwatch _stopwatch = SentryStopwatch.StartNew();

private InterlockedBoolean _cancelIdleTimeout;

private readonly Instrumenter _instrumenter = Instrumenter.Sentry;

bool IBaseTracer.IsOtelInstrumenter => _instrumenter == Instrumenter.OpenTelemetry;
Expand Down Expand Up @@ -247,7 +248,7 @@ internal TransactionTracer(IHub hub, ITransactionContext context, TimeSpan? idle
// Set idle timer only if an idle timeout has been provided directly
if (idleTimeout.HasValue)
{
_cancelIdleTimeout = 1; // Timer will be cancelled once, atomically setting this back to 0
_cancelIdleTimeout = true; // Timer will be cancelled once, atomically setting this back to false
_idleTimer = new Timer(state =>
{
if (state is not TransactionTracer transactionTracer)
Expand Down Expand Up @@ -362,7 +363,7 @@ public void Clear()
public void Finish()
{
_options?.LogDebug("Attempting to finish Transaction {0}.", SpanId);
if (Interlocked.Exchange(ref _cancelIdleTimeout, 0) == 1)
if (_cancelIdleTimeout.Exchange(false) == true)
{
_options?.LogDebug("Disposing of idle timer for Transaction {0}.", SpanId);
_idleTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
Expand Down
Loading
Loading