[Blazor] DefaultInMemoryCircuitPersistenceProvider never disposes the retention CancellationTokenSource when a persist is rejected at capacity
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
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
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
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di dotnet/aspnetcore
-
area-infrastructure
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
dotnet/aspnetcore#70524 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
[OpenTelemetry] Hosting replaces an Activity.DisplayName that the app set, when a route matchesApertaarea-hosting Regression
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
dotnet/aspnetcore#70516 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
[SSE] ServerSentEventsResult.WrapEvents is missing [EnumeratorCancellation], so RequestAborted never reaches the app's generator on the IAsyncEnumerable<T> overloadForse già presa @bharatprajapati-coder l’ha presa oggi. Apertaarea-minimal
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
dotnet/aspnetcore#70419 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
FieldIdentifier.Create throws ArgumentException when an indexer argument is a captured long, Guid or other non-int value typeForse già presa @Yuvan111 l’ha presa 1 giorno fa. Apertaarea-blazor NativeAOT
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
dotnet/aspnetcore#70406 · 1 commento · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
dotnet/aspnetcore#70370 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di dotnet/aspnetcore
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
ReubenBond/fast-caspaxos#18 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 79/100
openrewrite/rewrite#9835 ·
I maintainer di solito rispondono entro 1 giorno
-
Same-repo PRs never get the required appveyor/pr checkForse già presa Una pull request collegata a questa issue è aperta o già unita. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 74/100
activescott/lessmsi#309 · 3 commenti ·
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 92/100
corvus-dotnet/Corvus.JsonSchema#1068 ·
I maintainer di solito rispondono entro 1 giorno
-
Aurora: the wavetable stack is drawn magnified and cut off on a scaled Windows display (125 %)Apertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno