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); + } } }