Skip to content

[5.0] Drop [ThreadStatic] walker cache and allocate walkers on demand - #1814

Merged
josefpihrt merged 8 commits into
mainfrom
feature/walker-pooling-cleanup
Aug 16, 2026
Merged

[5.0] Drop [ThreadStatic] walker cache and allocate walkers on demand#1814
josefpihrt merged 8 commits into
mainfrom
feature/walker-pooling-cleanup

Conversation

@josefpihrt

@josefpihrt josefpihrt commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

The hand-rolled [ThreadStatic] _cachedInstance + GetInstance()/Free() pattern was repeated on 23 types. Reuse safety rested entirely on Free manually nulling every field, guarded only by Debug.Assert (a no-op in release); four call sites had no try/finally; and none of the collection-owning walkers capped the buffer they retained per thread.

This PR deletes that cache. Walkers are constructed when needed. Reuse setters became constructor parameters or read-only properties, which removes the stale-state class of bugs. RefactoringFlagsCache is deleted. The defensive walker.ContainsContinueStatement = false; in UseForStatementInsteadOfWhileStatementAnalyzer is gone, since the field can no longer arrive dirty.

Both StringBuilderCache copies are left alone. They already have a size guard, and one is vendored Orang code.

A shared ObjectPool<T> was prototyped for the eight buffer-owning walkers, then dropped: those allocations sit next to semantic-model work, and the pool protocol (reset ordering, copy-safe dispose, async/ref struct) was more machinery than the savings justified. If a regression shows up, ContainsLocalOrParameterReferenceWalker is the first place to look — it has 10 call sites, four of them inside loops in InlineLocalVariableAnalyzer.

Two bugs the pooling was masking

  • RCS1187 reported inconsistently. UseConstantInsteadOfFieldWalker.CanBeConvertedToConstant had no initializer, so a fresh walker started at false, making ShouldVisit false and suppressing the diagnostic, while a recycled walker started at the true that Free had reset it to and reported normally. Whether RCS1187 fired therefore depended on whether the thread had run the analyzer before. The property now defaults to true, matching what Free always restored. The visible effect is that a static readonly field is now consistently reported when the containing type has a static constructor that does not assign it — the same answer the analyzer already gave when there was no static constructor at all. Covered by a new test and a ChangeLog entry.
  • ConvertWhileToForRefactoring double-freed a walker. It called Free(walker) and then returned from inside the try, so the finally freed the same instance again, handing one walker to two future callers. Dropping pooling there removes it.

Test plan

  • Full solution builds clean (dotnet build src/Roslynator.sln --no-incremental), 0 warnings, 0 errors
  • Analyzers.Tests: 2236 passed (on the pooling iteration); after dropping pooling, related suites passed (RCS1187, unused member/parameter, make member readonly, mark const, use async await, remove redundant async await)
  • Refactorings.Tests (472), CodeFixes.Tests (110), CSharp.Tests (15), Formatting.Analyzers.Tests (573), CodeAnalysis.Analyzers.Tests (52), Core.Tests, CSharp.Workspaces.Tests (34) — all passed on the pooling iteration; RemoveAsyncAwait refactoring tests passed after dropping pooling
  • No [ThreadStatic] _cachedInstance remains outside the two StringBuilderCache files

Made with Cursor

Josef Pihrt and others added 3 commits August 15, 2026 00:57
The hand-rolled GetInstance/Free pattern was repeated on 23 types, and its
reuse safety rested entirely on Free manually nulling every field, guarded
only by Debug.Assert. Four call sites had no try/finally at all, and none of
the collection-owning walkers capped the buffer they retained per thread.

Pooling is kept only where the pooled object owns a growable buffer. Those
eight types now implement IResettable and rent through ObjectPool<T>, whose
PooledObject<T> makes the release unconditional via using, and whose
CanBeCached check drops an instance whose collection outgrew
ObjectPool.MaxCachedBufferSize instead of pinning it for the life of the
thread. The remaining 15 walkers take their state through constructors, so
they can no longer arrive dirty.

This also fixes RCS1187, which was reporting inconsistently: a fresh
UseConstantInsteadOfFieldWalker started with CanBeConvertedToConstant set to
false, suppressing the diagnostic, while a recycled one started with the
value Free had reset it to and reported normally.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@josefpihrt josefpihrt changed the title Replace [ThreadStatic] walker cache with a shared object pool [5.0] Replace [ThreadStatic] walker cache with a shared object pool Aug 15, 2026
Josef Pihrt and others added 4 commits August 16, 2026 15:36
CanBeCached was checking List.Count after a balanced visit (always ~0), so
oversized walkers were never discarded; use Capacity instead. Make
PooledObject.Dispose idempotent, and align UnusedMemberWalker with Initialize.

Co-authored-by: Cursor <cursoragent@cursor.com>
…eset

PooledObject is now a ref struct so it cannot be stored in a field. Reset
returns whether to cache, Free is a no-op on null or an already-cached
instance, and RemoveAsyncAwaitAnalysis takes the walker out of the rental
instead of embedding PooledObject.

Co-authored-by: Cursor <cursoragent@cursor.com>
The ThreadStatic cache and the ObjectPool that replaced it were more
protocol than the allocations were worth next to semantic-model work.
Walkers take their state in constructors; StringBuilderCache is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
@josefpihrt josefpihrt changed the title [5.0] Replace [ThreadStatic] walker cache with a shared object pool [5.0] Drop [ThreadStatic] walker cache and allocate walkers on demand Aug 16, 2026
dotnet format --severity info failed RCS1250 on target-typed new().

Co-authored-by: Cursor <cursoragent@cursor.com>
@josefpihrt
josefpihrt merged commit 69e2580 into main Aug 16, 2026
17 checks passed
@josefpihrt
josefpihrt deleted the feature/walker-pooling-cleanup branch August 16, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant