Skip to content

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

Open

Description

@hood

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

Summary: after every edit, the language server type-checks the edited file twice. Diagnostics check it on the dedicated diagnostics checker. Semantic tokens then re-infer most of the same types on a fresh query checker, because reclassifyByType calls GetTypeAtLocation for every variable, property and parameter (ls/semantictokens.go:421). On a real component file that's ~40 ms of extra CPU per semantic tokens request. I'd like to know whether reusing the diagnostics checker, once it has finished with the file, fits the checker pool design.

Measurements (main @ f797316, Apple M1 Pro, recharts src/cartesian/Line.tsx: 1,093 lines in a 658-file program; 120 keystrokes, 3 runs)

Request median
textDocument/diagnostic 52.6–53.2 ms
textDocument/semanticTokens/full on a cold query checker 41.3–41.7 ms
the same request again on the now-warm query checker ~2 ms

About 28 ms of each semantic tokens request is first-touch type inference: destructured props, const initializers that are calls, large annotation types. It's all local to the file, so I couldn't find a per-token shortcut that keeps the classification identical.

Why reuse would usually apply: VS Code pulls diagnostics as soon as a document changes (diagnosticPullOptions.onChange: true in packages/vscode-typescript/src/client.ts). It requests semantic tokens at least 300 ms after a content change (REQUEST_MIN_DELAY = 300 in vscode's documentSemanticTokens.ts). So by the time semantic tokens arrive, the diagnostics checker has normally finished with the file.

What I tried (branch: hood/TypeScript perf/semantic-tokens, +116/−5)

The pool lends the diagnostics checker to a semantic tokens request only when all of these hold:

  • the checker is idle (non-blocking diagSem);
  • it has fully checked the file (type check and unused check, not cancelled);
  • no diagnostics request for that file is still in flight.

The in-flight guard is needed because a diagnostics request uses the checker three times (semantic, suggestion and declaration diagnostics) and releases it in between. In every other case the request gets a query checker exactly as today, and it never waits. So queries never run on the diagnostics checker before or during a file's diagnostics, and the order in which the checker walks the file is unchanged. That ordering is what prevents bugs like #51661, where a query asked for a type before the normal walk and the result was an order-dependent false implicit-any in the editor.

recharts Line.tsx, 120 keystrokes, 3 runs main branch
Diagnostics, then semantic tokens: semantic tokens median 41.3–41.7 ms 2.88–2.91 ms
Same: diagnostics median 52.6–53.2 ms 52.4–52.9 ms
Same: process CPU 14.5–14.7 s 8.6 s (−41%)
Both requests sent at once: keystroke median / CPU 53–55 ms / 14.7 s 54 ms / 14.7 s (falls back, no change)

Correctness so far:

  • Semantic tokens and diagnostics for Line.tsx and three other open files are byte-identical to main across 600 responses, both sequential and concurrent. That includes TS2883 errors that print types, unused-variable suggestions, and the transient errors you get mid-typing.
  • hereby test, lint and check:format pass.
  • The new pool test fails if the in-flight guard is removed.

The part I'm not sure about: after the file is checked, the classification queries still create a few types and symbols on the diagnostics checker (~6 types and 32 symbols per request here). Most likely these are throwaway re-checks of expressions. I saw no change in any diagnostics, but I can't prove that holds for every program. If keeping that checker completely free of query work is a hard requirement, this approach doesn't fit, and I'd be glad to hear whether another direction would be welcome.

I worked on this with help from an AI coding assistant (Claude Code). I've reviewed the change, and I'll handle any follow-up myself.

Activity

  1. RyanCavanaugh commented on Nov 9, 2026

    @RyanCavanaugh
    Member

    This isn't safe in principle due to order sensitivity issues in the checker combined with cross-file operations.

    A minimal example looks like this:

    // a.ts
    export { value } from "./b";
    
    // b.ts
    type Foo<T extends "true", B> = { "true": Foo<T, Foo<T, B>> }[T];
    export let value: Foo<"true", {}>;

    Testing this against the provided PR:

    ┌─────────────────────────┬────────────────────────────────────────────┬─────────────────┐
    │ Request                 │ Without PR                                 │ With PR         │
    ├─────────────────────────┼────────────────────────────────────────────┼─────────────────┤
    │ Diagnostics for A       │ No errors                                  │ No errors       │
    ├─────────────────────────┼────────────────────────────────────────────┼─────────────────┤
    │ Semantic tokens for A   │ Completes                                  │ Completes       │
    ├─────────────────────────┼────────────────────────────────────────────┼─────────────────┤
    │ Diagnostics for B       │ TS5114: infinitely circular instantiations │ No errors       │
    ├─────────────────────────┼────────────────────────────────────────────┼─────────────────┤
    │ Diagnostics for B again │ TS5114                                     │ Still no errors │
    └─────────────────────────┴────────────────────────────────────────────┴─────────────────┘
    

    Once we get all the checker order sensitivities removed (this is a long work in progress) we can start doing much more aggressive re-use, but not until then.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions