Skip to content

CaptureBlockingCalls leaks one TaskBlockingListener (EventListener) per request — every await in the process degrades linearly with total requests served #5378

Description

@gerardfontmora

Package

Sentry.AspNetCore

.NET Flavor

.NET

.NET Version

10.0

OS

Linux

OS Version

No response

Development Environment

Visual Studio v18.x

Other Error Monitoring Solution

No

Other Error Monitoring Solution Name

No response

SDK Version

6.6.0

Self-Hosted Sentry Version

No response

Workload Versions

Workload version: 10.0.300-manifests.2c758974

UseSentry or SentrySdk.Init call

builder.WebHost.UseSentry(options =>
{
options.Dsn = ""; // from configuration
options.Environment = builder.Environment.EnvironmentName;
options.Release = "
"; // from configuration

options.MinimumEventLevel = LogLevel.Error;

options.StackTraceMode = StackTraceMode.Enhanced;
options.AttachStacktrace = true;

options.TracesSampleRate = 0.2;

// This is the option that triggers the leak. It was our default; we have
// since flipped it to false as the workaround.
options.CaptureBlockingCalls = true;

options.SendDefaultPii = true;
options.MaxRequestBodySize = RequestSize.Medium;
options.SetBeforeSend((SentryEvent evt, SentryHint _) =>
{
    evt.ServerName = null;
    return evt;
});

});

Steps to Reproduce

  1. ASP.NET Core app with UseSentry() and options.CaptureBlockingCalls = true.
  2. Let it serve requests — even just a Docker healthcheck hitting a trivial endpoint every 10s is enough.
  3. Watch per-request latency, background CPU, and managed heap grow with uptime. Restarting the process resets everything.

Expected Result

CaptureBlockingCalls should allocate its TaskBlockingListener once per process, with constant overhead.

Actual Result

One TaskBlockingListener is created per HTTP request and never disposed, so the runtime's global EventListener list grows unboundedly and every await in the process pays a dispatch loop proportional to the total number of requests served since startup.

Why it happens:

  • SentryMiddleware implements IMiddleware and is registered with AddTransient<SentryMiddleware>(), so DI constructs a new instance for every request.

  • With CaptureBlockingCalls = true, the middleware constructor runs:

    if (_options.CaptureBlockingCalls)
    {
        _monitor = new BlockingMonitor(_getHub, _options);
        _detectBlockingSyncCtx = new DetectBlockingSynchronizationContext(_monitor);
        _listener = new TaskBlockingListener(_monitor);
    }
  • TaskBlockingListener derives from System.Diagnostics.Tracing.EventListener. Its base constructor registers it in the runtime's global listener chain, and it calls EnableEvents(TplEventSource, Verbose, keywords: 3). Nothing ever calls Dispose() on it (the middleware is transient and holds it in a field).

  • Net effect: the listener list grows by one per request, TplEventSource stays enabled forever, and EventSource.DispatchToAllListeners walks the whole chain on every task-wait event of every await in the process — not just inside requests.

Observed impact (production, two services, effectively idle — only a 10s healthcheck):

  • p95 latency of a trivial health endpoint grew from ~5 ms to ~150 ms over 5 days (~45k requests → ~45k leaked listeners), growing linearly with request count. The two services degraded at exactly the same rate because they share the same healthcheck-driven request rate.
  • dotnet-trace (thread-time profile) on both processes shows 96–97% of all CPU time inside EventSource.DispatchToAllListeners, reached from every await (Kestrel middleware pipeline, HttpClient, timers, background workers).
  • Steadily growing background CPU (one service reached ~26% of a core while idle) and steadily growing managed heap (leaked listeners are GC roots).
  • Restarting the container resets latency/CPU/heap, after which the degradation immediately begins accumulating again.

Suggested fix: create the TaskBlockingListener (and BlockingMonitor) once per process — e.g. resolve them as singletons from DI or lazily initialize a static instance — rather than in the constructor of a transient middleware; or dispose the listener when the middleware instance is released. The per-request DetectBlockingSynchronizationContext swap can stay as is.

Metadata

Metadata

Labels

.NETPull requests that update .net codeBugSomething isn't working

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions