Skip to content

Fix #491: cache C# syntax trees to eliminate repeated full-document parses - #492

Merged
clrudolphi merged 1 commit into
masterfrom
perf/491-shared-csharp-syntax-tree-cache
Aug 26, 2026
Merged

Fix #491: cache C# syntax trees to eliminate repeated full-document parses#492
clrudolphi merged 1 commit into
masterfrom
perf/491-shared-csharp-syntax-tree-cache

Conversation

@clrudolphi

Copy link
Copy Markdown
Collaborator

🤔 What's changed?

  • Added ICSharpSyntaxTreeCache, a shared, thread-safe, MRU-capped cache for parsed C# syntax trees, and wired it into ScenarioTestTargetResolver and CSharpAttributeLiteralResolver. Both previously called CSharpSyntaxTree.ParseText(File.ReadAllText(...)) on every single invocation, re-parsing the same generated .feature.cs/step-definition files from scratch on every reqnroll/resolveTestTargets or rename request.
  • Added RunTestCodeLensResultCache to de-duplicate concurrent calls to RunTestCodeLensService.GetTargetsAsync for the same file. That method resolves every scenario in a .feature file, but was being invoked independently by the tagger and by every visible Scenario line's own out-of-process CodeLens data point — on a large file that meant N+1 concurrent full-document walks, slow enough that individual callers hit VS's own CodeLens timeout and the lens got stuck on "Loading data...". Cancellation is deliberately decoupled per caller (via AsyncLazy<T>) so one caller giving up never aborts the shared computation for the others.
  • Changed the Run CodeLens title to "▶ Run Scenario" / "▶ Run Scenarios" (singular vs. plural for Scenario Outlines), which required threading a new Detail field through GherkinDocumentSymbolService/GherkinNavigationBarSymbolService (Scenario vs. Scenario Outline can't be told apart from Kind alone) and a new IsScenarioOutline flag through RunTestTargetEntry.
  • Added logging across the previously-uninstrumented OOP-to-in-process CodeLens callback boundary (RunTestCodeLensCallbackListener, RunTestCodeLensDataPointProvider, RunTestCodeLensDataPoint) — this is what surfaced the VS-timeout root cause behind the result-cache fix above.
  • Fixed LspInterceptingPipe so owned-RPC request/response traffic (used for the extension's own shutdown handshake, etc.) is run through the send/receive interceptors, closing a blind spot in LspInspectorLogger where that traffic was previously invisible.
  • Updated docs/LSP-IDE-Support-Architecture.md and docs/Test-Runner-Integration-Design.md to reflect the new cache and the corrected cost/fix note.

⚡️ What's your motivation?

Fixes #491. On the VeryLargeFeature stress-corpus solution (~1,300+ scenarios), a single .feature tab pegged a CPU core and Run Scenario CodeLens never resolved. Root-caused via live VS Experimental-instance testing plus runtime log analysis:

  1. Every reqnroll/resolveTestTargets call re-parsed the whole generated .feature.cs file from disk with no cache — confirmed via before/after log measurements taking it from ~153ms to ~3.6ms per call.
  2. Fixing (1) exposed a second bottleneck: with N visible Scenario CodeLens lines each independently triggering their own full-document walk, the combined load still took long enough (~26-30s) that VS's own per-data-point CodeLens timeout fired before the walk finished, throwing OperationCanceledException and leaving the lens stuck.

Verified fixed via two full live VS Experimental-instance test runs against the same corpus: inlay hints in a few seconds, Run Scenario CodeLens resolving in ~30s (a single shared walk instead of N concurrent ones), label correctly pluralized, and the Run popup/test execution working end to end. No OperationCanceledException in either run's logs.

🏷️ What kind of change is this?

  • 🐛 Bug fix (non-breaking change which fixes a defect)

🧩 Area(s) touched

  • LSP server (src/LSP)
  • Visual Studio extension (src/VisualStudio)

♻️ Anything particular you want feedback on?

RunTestCodeLensResultCache's cancellation model: each caller's own token only governs how long that caller waits (backed by AsyncLazy<T>'s documented contract), never the underlying shared computation — a 60s independent timeout bounds the computation itself instead. Flagging this in case there's a simpler pattern preferred for this kind of shared/de-duplicated async work elsewhere in the codebase.

📋 Checklist:

  • I've changed the behaviour of the code
    • I have added/updated tests to cover my changes.
  • My change alters as-built behaviour described in a design doc under docs/
    • I have updated the relevant doc accordingly (see docs/AsBuilt-Reconciliation-Reminder.md).
  • Users should know about my change

🤖 Generated with Claude Code

…arses

Add a shared, thread-safe ICSharpSyntaxTreeCache and wire it into
ScenarioTestTargetResolver and CSharpAttributeLiteralResolver, eliminating
the redundant CSharpSyntaxTree.ParseText(File.ReadAllText(...)) on every
resolveTestTargets/rename call. On the VeryLargeFeature stress corpus this
took reqnroll/resolveTestTargets from ~153ms to ~3.6ms per call.

That fix exposed a second bottleneck: RunTestCodeLensService.GetTargetsAsync
resolves an entire feature file's worth of scenarios, but was invoked
independently by the tagger and by every visible Scenario line's own
out-of-process CodeLens data point. On a large file, N+1 concurrent full-
document walks competed for the LSP server, slow enough that individual
callers hit VS's own CodeLens timeout and the lens got stuck on "Loading
data...". Add RunTestCodeLensResultCache to de-duplicate concurrent callers
for the same file into one shared computation, with per-caller cancellation
(AsyncLazy) so one caller giving up never aborts the shared work for others.

Also:
- Change the Run CodeLens title to "Run Scenario"/"Run Scenarios" (singular
  vs. plural for Scenario Outlines), threading a new Detail field through
  GherkinDocumentSymbolService/GherkinNavigationBarSymbolService and a new
  IsScenarioOutline flag through RunTestTargetEntry.
- Add logging across the previously-uninstrumented OOP-to-in-process
  CodeLens callback boundary (RunTestCodeLensCallbackListener,
  RunTestCodeLensDataPointProvider, RunTestCodeLensDataPoint), which is what
  surfaced the timeout root cause above.
- Fix LspInterceptingPipe so owned-RPC request/response traffic is run
  through the send/receive interceptors, closing a blind spot in
  LspInspectorLogger for that traffic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@clrudolphi
clrudolphi merged commit 661df59 into master Aug 26, 2026
17 checks passed
@clrudolphi
clrudolphi deleted the perf/491-shared-csharp-syntax-tree-cache branch August 26, 2026 19:50
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.

Run CodeLens target resolution does 1 unbatched round-trip + full re-parse per scenario on large feature files

1 participant