From 36ed5e4ff237f719d0cea666201c367bbb52d746 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 18 Jul 2026 19:06:37 -0600 Subject: [PATCH 1/3] Filter benign unobserved-Task socket noise from Sentry (BL-16573) 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 ~71k events (0 users, 90d) piled up across three locale-split Sentry issues: BLOOM-DESKTOP-EQ4 / -E4J / -E9K. Switch SentrySdk.Init to the Action overload (same DSN) and add a narrowly-scoped BeforeSend filter that drops ONLY this pattern: an event carrying the UnobservedTaskException mechanism whose exception chain contains a benign socket/IO abort type. The decision is carried by exception TYPE plus the mechanism, never by message text, so it is locale-independent and covers all three issues (and future language variants) at once. Genuine unobserved-Task bugs are still reported; we deliberately do NOT disable the integration. The filter predicate is a testable internal static method with unit tests in BloomTests exercising matching (dropped), the localized-message variant, and non-matching (passed through) events. Co-Authored-By: Claude Fable 5 --- src/BloomExe/Program.cs | 66 ++++++++++++++++++++- src/BloomTests/ProgramTests.cs | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index 217be3c5f120..508e6cc1d5b9 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -2136,6 +2136,60 @@ 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. + 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). + return exceptions.Any(e => 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) + { + 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 +2200,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..7119082f841b 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,108 @@ 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); + } + + [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_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); + } } } From c81bb65b2c8b26a65f00189b5586a310540e34a2 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 18 Jul 2026 19:18:56 -0600 Subject: [PATCH 2/3] Address review: test cancellation branches, document benign-type set (BL-16573) Greptile P2 feedback on the Sentry unobserved-task filter: - Add coverage for the OperationCanceledException / TaskCanceledException branches of IsBenignSocketAbortExceptionType (they had no test). - Explain in a code comment why IOException and the cancellation types are screened alongside SocketException, and that the trade-off (an unobserved-Task IOException/cancellation is treated as noise) is deliberate. The predicate only runs for events already carrying the UnobservedTaskException mechanism, so it never suppresses these types on a normal observed path. No behavior change; the type set is unchanged. Co-Authored-By: Claude Fable 5 --- src/BloomExe/Program.cs | 9 +++++++++ src/BloomTests/ProgramTests.cs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index 508e6cc1d5b9..88efca42fcf9 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -2180,6 +2180,15 @@ 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": diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index 7119082f841b..5adedcf165c1 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -193,6 +193,22 @@ public void IsBenignUnobservedTaskSocketNoise_DropsUnobservedIoAbort() 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() { From 3a6330806676f93aae46250f5edf5f95ec88a0ac Mon Sep 17 00:00:00 2001 From: John Thomson Date: Thu, 23 Jul 2026 15:50:47 -0500 Subject: [PATCH 3/3] Require all chained exceptions benign before dropping Sentry event (BL-16573) The unobserved-Task filter previously dropped an event if ANY exception in the chain was a benign socket-abort type, which could suppress a real bug aggregated alongside socket noise. Now at least one benign abort must be present AND every exception in the chain must be benign (or the AggregateException wrapper). Also documents why the mechanism check must remain Any. Adds tests for the mixed-chain and bare-wrapper cases. Co-Authored-By: Claude Fable 5 --- src/BloomExe/Program.cs | 15 +++++++++++++-- src/BloomTests/ProgramTests.cs | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index 88efca42fcf9..3a4f9c2b44d0 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -2161,14 +2161,25 @@ internal static bool IsBenignUnobservedTaskSocketNoise(SentryEvent sentryEvent) 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). - return exceptions.Any(e => IsBenignSocketAbortExceptionType(e.Type)); + // ...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) + ); } /// diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index 5adedcf165c1..f4c68c636f68 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -237,6 +237,34 @@ public void IsBenignUnobservedTaskSocketNoise_KeepsUnobservedTaskThatIsNotSocket 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() {