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
86 changes: 83 additions & 3 deletions src/BloomExe/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,80 @@ private static bool IsSameActualLanguage(string code1, string code2)
// Only the token owner may release it and run Bloom's global temp cleanup on exit.
private static bool _ownsSingleInstanceToken;

/// <summary>
/// Decides whether a Sentry event is the benign "unobserved Task socket/IO abort" noise
/// that we want to drop rather than report. Used by the BeforeSend filter installed in
/// SetUpErrorHandling.
///
/// Background: the Sentry .NET SDK auto-subscribes to TaskScheduler.UnobservedTaskException.
/// Fleck (our WebSocket library, see BloomWebSocketServer) does fire-and-forget socket.Send()
/// calls whose faulted Tasks are never observed; when a socket aborts (mostly at app shutdown)
/// the finalizer thread surfaces a benign SocketException/IOException and Sentry reports it.
/// An earlier IsAvailable-check mitigation (BL-11124) did not stop it, so tens of thousands of
/// these events accumulated with zero user impact (Sentry BLOOM-DESKTOP-EQ4 / -E4J / -E9K).
///
/// We drop ONLY this exact pattern - an event carrying the UnobservedTaskException mechanism
/// whose exception chain contains a benign socket/IO abort type - so genuine unobserved-Task
/// bugs are still reported. The decision is carried by exception TYPE plus the mechanism, never
/// by message text: the message ("The I/O operation has been aborted...") is localized by the
/// user's OS language, which is why it showed up as several separate Sentry issues.
/// </summary>
internal static bool IsBenignUnobservedTaskSocketNoise(SentryEvent sentryEvent)
{
var exceptions = sentryEvent.SentryExceptions?.ToList();
if (exceptions == null || exceptions.Count == 0)
return false;

// Must have come through the TaskScheduler.UnobservedTaskException integration.
// Any (not All) is required here: Sentry attaches the mechanism only to the outer
// AggregateException wrapper, never to the inner exceptions in the chain, so
// requiring it on every entry would mean the filter never matched anything.
var isUnobservedTask = exceptions.Any(e =>
Comment thread
hatton marked this conversation as resolved.
e.Mechanism?.Type == "UnobservedTaskException"
);
if (!isUnobservedTask)
return false;

// ...and the underlying fault must be a benign socket/IO abort (match on type, not
// text). At least one benign abort must be present, and nothing else may be in the
// chain except the AggregateException wrapper the unobserved-Task path adds: an
// AggregateException can aggregate several faults, and if a real bug is mixed in
// with the socket noise we still want the report.
if (!exceptions.Any(e => IsBenignSocketAbortExceptionType(e.Type)))
return false;
return exceptions.All(e =>
e.Type == "System.AggregateException" || IsBenignSocketAbortExceptionType(e.Type)
);
}

/// <summary>
/// True for the exception types that represent a benign socket/IO abort or cancellation
Comment thread
hatton marked this conversation as resolved.
/// (as opposed to something we would actually want to know about). Matched by full type
/// name so it is independent of the OS-localized exception message.
/// </summary>
private static bool IsBenignSocketAbortExceptionType(string exceptionTypeName)
{
switch (exceptionTypeName)
{
// The observed noise is a SocketException, but the abort surfaces from the socket
// stack as an IOException just as often (an IOException frequently wraps the
// SocketException), and a shutdown race can instead cancel the send. All three are
// benign teardown outcomes of an *unobserved* fire-and-forget socket.Send(); this
// predicate only ever runs for events already carrying the UnobservedTaskException
// mechanism (see IsBenignUnobservedTaskSocketNoise), which is what keeps it from
// suppressing these same types when they arrive through a normal, observed path.
// The trade-off is accepted deliberately: an unobserved-Task IOException/cancellation
// is treated as noise here rather than reported.
case "System.Net.Sockets.SocketException":
case "System.IO.IOException":
case "System.OperationCanceledException":
case "System.Threading.Tasks.TaskCanceledException":
return true;
default:
return false;
}
}

/// ------------------------------------------------------------------------------------
internal static void SetUpErrorHandling()
{
Expand All @@ -2146,9 +2220,15 @@ internal static void SetUpErrorHandling()
{
try
{
_sentry = SentrySdk.Init(
"https://bba22972ad6b4c2ab03a056f549cc23d@o1009031.ingest.sentry.io/5983534"
);
_sentry = SentrySdk.Init(options =>
{
options.Dsn =
"https://bba22972ad6b4c2ab03a056f549cc23d@o1009031.ingest.sentry.io/5983534";
// Screen out the benign unobserved-Task socket/IO abort noise
// (Sentry BLOOM-DESKTOP-EQ4/E4J/E9K). See IsBenignUnobservedTaskSocketNoise.
options.BeforeSend = sentryEvent =>
IsBenignUnobservedTaskSocketNoise(sentryEvent) ? null : sentryEvent;
});
SentrySdk.ConfigureScope(scope =>
{
scope.SetExtra("channel", ApplicationUpdateSupport.ChannelName);
Expand Down
149 changes: 149 additions & 0 deletions src/BloomTests/ProgramTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Bloom;
using NUnit.Framework;
using Sentry;
using Sentry.Protocol;

namespace BloomTests
{
Expand Down Expand Up @@ -132,5 +134,152 @@ out var errorMessage
Assert.That(Program.StartupLabel, Is.Null);
Comment thread
hatton marked this conversation as resolved.
Assert.That(remainingArgs, Is.Empty);
}

// --- IsBenignUnobservedTaskSocketNoise: the Sentry BeforeSend filter for
// BLOOM-DESKTOP-EQ4 / -E4J / -E9K ---

private static SentryException MakeException(
string type,
string value = null,
string mechanismType = null
)
{
var exception = new SentryException { Type = type, Value = value };
if (mechanismType != null)
exception.Mechanism = new Mechanism { Type = mechanismType, Handled = false };
return exception;
}

private static SentryEvent MakeEvent(params SentryException[] exceptions)
{
return new SentryEvent { SentryExceptions = exceptions };
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_DropsUnobservedSocketAbort()
{
// The shape Sentry actually serialized for BLOOM-DESKTOP-EQ4: an inner SocketException,
// wrapped in an AggregateException carrying the UnobservedTaskException mechanism.
var sentryEvent = MakeEvent(
MakeException(
"System.Net.Sockets.SocketException",
"The I/O operation has been aborted because of either a thread exit or an application request."
),
MakeException(
"System.AggregateException",
"A Task's exception(s) were not observed...",
"UnobservedTaskException"
)
);

// Sanity check the setup before asserting the method's behavior.
Assert.That(
sentryEvent.SentryExceptions,
Is.Not.Empty,
"test setup should produce an exception chain"
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.True);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_DropsUnobservedIoAbort()
{
var sentryEvent = MakeEvent(
MakeException("System.IO.IOException", "aborted"),
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.True);
}

[TestCase("System.OperationCanceledException")]
[TestCase("System.Threading.Tasks.TaskCanceledException")]
public void IsBenignUnobservedTaskSocketNoise_DropsUnobservedCancellation(
string cancellationType
)
{
// A shutdown race can cancel the fire-and-forget send instead of aborting the socket;
// that is still benign teardown noise, so both cancellation types are dropped.
var sentryEvent = MakeEvent(
MakeException(cancellationType, "A task was canceled."),
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.True);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_DropsRegardlessOfLocalizedMessage()
{
// The message is localized by the user's OS (this is the Spanish variant,
// BLOOM-DESKTOP-E4J). The filter must decide on TYPE + mechanism, not text.
var sentryEvent = MakeEvent(
MakeException(
"System.Net.Sockets.SocketException",
"Se ha anulado la operacion de E/S debido a la salida del subproceso o a una solicitud de la aplicacion."
),
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.True);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_KeepsUnobservedTaskThatIsNotSocketNoise()
{
// A genuine unobserved-Task bug (not a socket/IO abort) must still be reported.
var sentryEvent = MakeEvent(
MakeException("System.NullReferenceException", "Object reference not set..."),
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.False);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_KeepsMixedChainContainingRealBug()
{
// An AggregateException can aggregate several faults. If a genuine bug is mixed in
// with the benign socket noise, the whole event must still be reported; one benign
// entry in the chain is not license to drop everything alongside it.
var sentryEvent = MakeEvent(
MakeException("System.NullReferenceException", "Object reference not set..."),
MakeException("System.Net.Sockets.SocketException", "aborted"),
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.False);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_KeepsBareAggregateExceptionWithNoBenignInner()
{
// An unobserved-Task event whose chain is only the AggregateException wrapper carries
// no evidence of socket noise, so it must be reported (guards the All from passing
// vacuously).
var sentryEvent = MakeEvent(
MakeException("System.AggregateException", null, "UnobservedTaskException")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.False);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_KeepsSocketExceptionWithoutUnobservedMechanism()
{
// A SocketException reported through some other (e.g. handled) path is not this noise.
var sentryEvent = MakeEvent(
MakeException("System.Net.Sockets.SocketException", "connection reset")
);

Assert.That(Program.IsBenignUnobservedTaskSocketNoise(sentryEvent), Is.False);
}

[Test]
public void IsBenignUnobservedTaskSocketNoise_KeepsEventWithNoExceptions()
{
Assert.That(Program.IsBenignUnobservedTaskSocketNoise(new SentryEvent()), Is.False);
}
}
}