diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs
index 217be3c5f120..3a4f9c2b44d0 100644
--- a/src/BloomExe/Program.cs
+++ b/src/BloomExe/Program.cs
@@ -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;
+ ///
+ /// 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.
+ ///
+ 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 =>
+ 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)
+ );
+ }
+
+ ///
+ /// True for the exception types that represent a benign socket/IO abort or cancellation
+ /// (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.
+ ///
+ 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()
{
@@ -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);
diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs
index afea45f47f3a..f4c68c636f68 100644
--- a/src/BloomTests/ProgramTests.cs
+++ b/src/BloomTests/ProgramTests.cs
@@ -1,5 +1,7 @@
using Bloom;
using NUnit.Framework;
+using Sentry;
+using Sentry.Protocol;
namespace BloomTests
{
@@ -132,5 +134,152 @@ out var errorMessage
Assert.That(Program.StartupLabel, Is.Null);
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);
+ }
}
}