Skip to content

Reuse diagnostics checker for semantic tokens of checked files - #65411

Draft
Andrea Cappuccio (hood) wants to merge 2 commits into
microsoft:mainfrom
hood:perf/semantic-tokens
Draft

Andrea Cappuccio (hood) wants to merge 2 commits into
microsoft:mainfrom
hood:perf/semantic-tokens

Conversation

@hood

Copy link
Copy Markdown
Contributor

Fixes #65386

Semantic tokens used to run on a cold query checker and re-infer basically every variable, parameter and property type in the file, the same work diagnostics had just done on the diagnostics checker. With this PR, once a diagnostics request has fully checked a file and is done, semantic tokens for that file borrow the idle diagnostics checker instead.

The rules are kinda strict so the diagnostics checker's walk order doesn't change:

  • it's only lent out if it's idle (non-blocking diagSem), the file is fully checked (typeChecked + unusedChecked, not cancelled) and no diagnostics request for that file is still in flight (they acquire it three times: semantic, suggestion, declaration);
  • otherwise semantic tokens get a query checker exactly like today, and they never wait.

That ordering is what avoids stuff like #51661, where a query asked for a type before the normal walk and you'd get an order-dependent false error in the editor.

VSCode pulls diagnostics right on change (diagnosticPullOptions.onChange) and only asks for semantic tokens ≥300 ms later (REQUEST_MIN_DELAY), so in practice the reuse path is the common one.

recharts src/cartesian/Line.tsx, 120 keystrokes, 3 runs main this PR
diagnostics then semantic tokens: semanticTokens/full median 41.3–41.7 ms 2.88–2.91 ms (−93%)
same: textDocument/diagnostic median 52.6–53.2 ms 52.4–52.9 ms
same: process CPU 14.5–14.7 s 8.6 s (−41%)
both sent together: keystroke median / CPU 53–55 ms / 14.7 s 54 ms / 14.7 s (falls back, no change)

Tests:

  • new TestCheckerPoolCheckedFileQuery covers no reuse before the file is checked, no reuse while the diagnostics request is still in flight, reuse after, and fallback while the checker is lent out. It fails if the in-flight guard gets removed.
  • semantic tokens + diagnostics for Line.tsx and three other open files (TS2883 type printing, unused suggestions, the transient errors you get mid-typing) are byte-identical to main across 600 responses, both sequential and concurrent.
  • go test ./..., npx hereby lint and npx hereby check:format pass.

One thing I'm not 100% sure about: after the file is checked, classification still creates a few types/symbols on the diagnostics checker (~6 types and 32 symbols per request here, looks like throwaway re-checks of expressions). I didn't see any diagnostics change, but if keeping that checker totally free of query work is a hard requirement this approach doesn't fit, so happy to go another way.

AI disclosure: I polished my solution, my breakdown of it and -most importantly- did most of the measurements with help from an AI agent. I've gone 2x through everything and I'll handle any follow-up myself ofc.

Semantic tokens ran on a cold query checker and re-inferred every
variable, parameter and property type that diagnostics had just computed.
Once a diagnostics request has fully checked a file and finished, lend the
idle diagnostics checker to semantic token requests for that file instead.
They never wait for it and never run before the file is checked, so the
diagnostics checker's walk order is unchanged.
Copilot AI balanced review requested due to automatic review settings November 9, 2026 20:10
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Nov 9, 2026
@typescript-automation typescript-automation Bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Nov 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical request-affinity tracking gap allows checker reuse before supplemental-file declaration diagnostics finish, and semantic-token reuse still lacks integration regression coverage.

2 open issues
What changed in this PR

This PR addresses #65386 by letting semantic-token requests reuse an idle diagnostics checker for fully checked files, reducing repeated type inference while preserving query-checker fallback.

Changes:

  • Adds a checked-file query lifetime for full and range semantic tokens.
  • Gates checker reuse on completed checks, availability, and in-flight diagnostics tracking.
  • Adds pool tests covering reuse and fallback behavior.
File Description
tsc/​internal/​project/​checkerpool.go Implements guarded diagnostics-checker lending and request tracking.
tsc/​internal/​project/​checkerpool_test.go Tests checker reuse eligibility and fallback.
tsc/​internal/​ls/​semantictokens.go Requests checked-file reuse for full and range tokens.
tsc/​internal/​core/​context.go Defines the checked-file query lifetime.
tsc/​internal/​checker/​exports.go Exposes whether a file has completed checking without cancellation.

Review effort: Balanced

Comment thread tsc/internal/project/checkerpool.go Outdated
Comment on lines +226 to +227
if file != nil {
p.diagnosticsRequests[requestID] = file

@hood Andrea Cappuccio (hood) Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch clanker, the affinity path returned before recording anything, so only the canonical file was tracked. Instead of tracking files per request I made it stricter: the pool now keeps the set of in-flight diagnostics request IDs (cleared by the same ctx AfterFunc as requestAssociations) and won't lend the checker at all while any of them is in flight. That covers supplemental files for free and kinda simplifies things, also reverts the getDiagnosticsChecker signature change.

TestCheckerPoolCheckedFileQuery now checks two files under one diagnostics request and asserts neither can borrow the checker until the request is done (fails if the guard is removed). Fixed in 9afed7b.

}

func (l *LanguageService) ProvideSemanticTokens(ctx context.Context, documentURI lsproto.DocumentUri) (lsproto.SemanticTokensResponse, error) {
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeCheckedFileQuery)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestSemanticTokensReuseCheckedDiagnosticsChecker in 9afed7b. It runs the #51661 repro through the real session + language service: semantic tokens on a query checker, then diagnostics, then semantic tokens again. It asserts the second request actually took the lending path (hooked on the pool's log, so it can't pass by falling back), that the tokens are identical to the query-checker ones, and that diagnostics are still empty and unchanged afterwards.

Range tokens go through the exact same GetChecker path, so I didn't duplicate the test for them tbh, happy to add it if you prefer.

@jakebailey

Jake Bailey (jakebailey) commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

This is generally unsafe due to ordering issues, hence diag checkers being separated.

Somewhat what I'm trying to address once and for all in #65064

Track in-flight diagnostics requests by ID instead of by first file, so
supplemental files checked under the same request are covered too, and
add an end-to-end test that semantic tokens reuse the checker without
changing tokens or diagnostics.
@hood

Copy link
Copy Markdown
Contributor Author

Yeah that's fair, tbh I couldn't fully convince myself either: even with the guard, classification still creates a handful of types/symbols on the borrowed checker (~6/32 per request on recharts' Line.tsx), and those could run before another file's check. So it's only "safe" for the cases I diffed, not in general.

Converting this to a draft and parking it behind #65064 (happy to just close it if you prefer). If order independence lands, the payoff is roughly: in VS Code's request order, semantic tokens go 41 → 3 ms per request and ~41% less CPU while typing, since today each edit type-checks the file twice.

If it's useful, I've got a small LSP typing harness (real keystrokes on a real repo, pulls diagnostics/semantic tokens/completions per key, dumps responses for byte-identity diffs): https://gist.github.com/hood/506e87027c83bbec1b44c1f687273349. Happy to run it against #65064 to help shake out ordering differences.

@hood
Andrea Cappuccio (hood) marked this pull request as draft November 9, 2026 20:39
@jakebailey Jake Bailey (jakebailey) added the Domain: Performance Performance related label Nov 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Domain: Performance Performance related For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Semantic tokens re-check files on a cold query checker after diagnostics already checked them

3 participants