[Blazor] DefaultInMemoryCircuitPersistenceProvider never disposes the retention CancellationTokenSource when a persist is rejected at capacity

Aperta Adatta ai principianti
#70,418 1 commento 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@Vinoth2562000 ci sta già lavorando.

Dal 11/11/2026.

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
77/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
csharp
Ambito
backend

Direzione di ricerca

Leggi src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs, concentrandoti su PersistCore, OnEntryEvicted e DisposeTokenSource. Verifica come la capacità e l’espulsione anticipata raggiungono il callback, poi esamina il percorso di ripristino esistente che elimina la sorgente del token. Il risultato è raggiunto quando le voci rifiutate o espulse in anticipo eliminano la propria sorgente e il percorso Removed rimane sicuro; aggiungi o aggiorna test mirati per questi casi.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

area-blazor
Is there an existing issue for this?

I searched and did not find one. DisposeTokenSource and CircuitPersistenceProvider CancellationTokenSource both return 0 results (control: "circuit persistence" returns 56, so the search is live). The nearby issues are about different problems: #64607 and #67453 concern resume failing once the retention period has elapsed, and #64228 / #62901 concern reconnect after a server restart. #62373 and #60494 are the original design proposals.

Describe the bug

DefaultInMemoryCircuitPersistenceProvider.PersistCore creates a CancellationTokenSource with a 2-hour timer before it attempts the cache insert. When the insert does not take — which happens once PersistedCircuitInMemoryMaxRetained (default 1000) is reached — nothing disposes that CancellationTokenSource. It is left holding a live TimerQueueTimer, which roots it in the global timer queue, so the GC cannot reclaim it until the timer fires up to two hours later.

This is not a hypothetical path. The persisted-circuit cache is fed by ordinary traffic, not only by explicit PauseCircuit calls: DisconnectedCircuitMaxRetained defaults to 100, so once more than 100 users have closed a tab, the disconnected-circuit cache evicts into PauseAndDisposeCircuitEntry → PersistCircuitAsync. An app with sustained disconnect churn reaches the 1000-entry cap in normal operation, and from that point every persist leaks one CancellationTokenSource plus one timer.

All line numbers are release/11.0 at ce27599a, file src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs. The same code is present on release/10.0 (the file exists there and ComponentHub.ResumeCircuit/PauseCircuit are at :281/:445), so this is not .NET 11-specific.

The source is created unconditionally, then the insert is attempted:

private void PersistCore(CircuitId circuitId, PersistedCircuitState persistedCircuitState)
{
    var cancellationTokenSource = new CancellationTokenSource(_options.PersistedCircuitInMemoryRetentionPeriod); // :58
    var options = new MemoryCacheEntryOptions
    {
        Size = 1,
        PostEvictionCallbacks = { PostEvictionCallback },
        ExpirationTokens = { new CancellationChangeToken(cancellationTokenSource.Token) },
    };
    ...
    _persistedCircuits.Set(circuitId.Secret, persistedCircuitEntry, options);   // :73
}

MemoryCache drops the incoming entry when the cache is at SizeLimit (Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs, the else branch of SetEntry):

else
{
    entry.SetExpired(EvictionReason.Capacity);
    TriggerOvercapacityCompaction();
    entry.InvokeEvictionCallbacks();
    ...
}

Note AttachTokens() is called only on the entryAdded path a few lines above, so for a rejected entry the expiration token was never even registered — the only thing keeping the CancellationTokenSource alive is its own timer.

The eviction callback logs and returns:

private void OnEntryEvicted(object key, object value, EvictionReason reason, object state)   // :76
{
    switch (reason)
    {
        case EvictionReason.Expired:
        case EvictionReason.TokenExpired:
        case EvictionReason.Capacity:
            var persistedCircuitEntry = (PersistedCircuitEntry)value;
            Log.CircuitStateEvicted(_logger, persistedCircuitEntry.CircuitId, reason);   // :87
            break;

        case EvictionReason.Removed:
            return;
        ...
    }
}

DisposeTokenSource (:129) is reachable from exactly one place — RestoreCore, which calls it at :120, i.e. only when a circuit is successfully resumed.

Why the ordinary expiry path is fine, and this one is not

Worth stating because it narrows the bug: an entry that simply lives out its 2 hours leaks nothing. CancellationTokenSource.TransitionToCancellationRequested() disposes its own timer when it fires, so the entry expires, the callbacks run, and the whole graph becomes collectible. The missing Dispose() only matters when the source never gets to fire on schedule — i.e. when the insert was rejected at capacity, or the entry was evicted early by compaction.

The neighbouring cache already does this correctly

CircuitRegistry.PauseAndDisposeCircuitEntry (:286) does dispose the CancellationTokenSource in its eviction path for the DisconnectedCircuits cache. Same pattern, two caches, one disposes on eviction and the other does not — which is what suggests this is an oversight rather than a deliberate choice.

To reproduce, and what I measured

Stock dotnet new blazor --interactivity Server, unmodified, running Release on .NET 11.0.0-rc.3.26522.148. A driver opens a hub connection, calls StartCircuit with the descriptor and application state taken from the prerendered page, optionally calls PauseCircuit, then disconnects. 1200 iterations per arm.

Counts are live objects after a forced gen2 GC (dotnet-gcdump, which collects before capturing), taken after waiting out the 3-minute DisconnectedCircuitRetentionPeriod so that no short-lived disconnected-circuit timers remain in the count:

arm PersistedCircuitEntry TimerQueueTimer CancellationTokenSource CircuitHost
StartCircuit + disconnect 991 1,201 1,232 0
StartCircuit + PauseCircuit + disconnect 996 1,201 1,232 0
StartCircuit + PauseCircuit + ResumeCircuit (control) 0 1 32 0

The arithmetic is the point:

  • arm 1: 1200 persists − 991 retained = 209 over capacity; timers in excess of live entries = 1201 − 991 = 210
  • arm 2: 1200 persists − 996 retained = 204 over capacity; excess timers = 1201 − 996 = 205

The resume arm is the control that makes this meaningful: identical load, but every circuit is resumed so RestoreCore runs and disposes each source, and it ends at 1 timer rather than ~1,200. Without that arm, "1,201 timers" could just be what a busy server looks like.

CircuitHost reaching 0 in every arm confirms circuits themselves are disposed correctly and are not contributing to these counts.

A first run taken without forcing a GC showed ~3,400 CancellationTokenSource, but 51.8 MB of that heap was already Free; those numbers measured allocations rather than retention and I discarded them.

Impact

Modest and bounded, so I want to be accurate rather than alarming: each leaked pair is roughly 144 bytes (48 for the source, 96 for the timer) plus one slot in the global timer queue, and it is reclaimed when the timer fires, so the ceiling is the leak rate times two hours. The PersistedCircuitState itself is not retained — CacheEntry.SetExpired calls DetachTokens(), which disposes the registrations — so this does not pin circuit state.

What makes it worth reporting is that it is unbounded in count while the thing it accompanies is carefully bounded: the cache caps retained entries at 1000, but there is no cap on how many orphaned timers can accumulate alongside it.

Suggested fix

OnEntryEvicted already receives the entry as value and already switches on the reason. The Expired / TokenExpired / Capacity arm can call the existing DisposeTokenSource(entry) before logging. The Removed arm must keep its early return, since RestoreCore has already disposed the source on that path.

(Unrelated minor note while in this switch: EvictionReason.Replaced falls to default: and hits Debug.Fail. It is a no-op in Release, but Set on an existing key would reach it.)

I have not opened a PR; happy to do so if a maintainer indicates the shape they prefer.

.NET Version

11.0.0-rc.3.26522.148 (measured). Code also present on release/10.0.

Anything else?

Found while reading circuit persistence lifecycle on release/11.0. Analysis was done with the help of Claude Code; every line reference and every number above was verified by hand against the source and reproduced on a running app.

Lingua principale
C#
Stelle
38.5k
Fork
12.3k
Merge medio
3g 2h
PR unite (30g)
283

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di dotnet/aspnetcore

Tutte le issue di dotnet/aspnetcore

Issue simili

Altre issue su C#

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.