Skip to content

Commit b85298b

Browse files
authored
Enforce exclusive checker acquisition (#64543)
1 parent e8c1ac1 commit b85298b

11 files changed

Lines changed: 246 additions & 77 deletions

‎tsc/internal/compiler/checkerpool.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
// request-scoped lifetime and reclamation. It returns a checker and a release
1818
// function that must be called when the caller is done with the checker.
1919
// The returned checker must not be accessed concurrently; each acquisition is exclusive.
20+
// Acquisitions are not reentrant, even when they share a request ID. Callers must
21+
// pass an already acquired checker to nested operations instead of acquiring again.
2022
// If file is non-nil, the pool may use it as an affinity hint to return the same
2123
// checker for the same file across calls.
2224
type CheckerPool interface {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package fourslash_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/microsoft/TypeScript/tsc/internal/core"
7+
"github.com/microsoft/TypeScript/tsc/internal/ls/lsutil"
8+
"github.com/microsoft/TypeScript/tsc/internal/testutil"
9+
"github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest"
10+
)
11+
12+
func TestContentMapperInlayHintsReleaseEachRange(t *testing.T) {
13+
t.Parallel()
14+
defer testutil.RecoverAndFail(t, "Panic on fourslash test")
15+
// The mapper emits the script verbatim followed by:
16+
//
17+
// function __render() {
18+
// void (value);
19+
// void (value);
20+
// void (value);
21+
// void (value);
22+
// }
23+
// export default {};
24+
//
25+
// The script and four template identifiers map to five disjoint virtual ranges.
26+
// Each range must release its checker before processing the next range.
27+
f, done := newContentMapperFourslash(t, `// @Filename: /app.vue
28+
<script>
29+
const value = () => 1;
30+
</script>
31+
{{value}}
32+
{{value}}
33+
{{value}}
34+
{{value}}
35+
`, contentmappertest.ComponentMapper, ".vue")
36+
defer done()
37+
38+
f.GoToFile(t, "/app.vue")
39+
f.VerifyBaselineInlayHints(t, nil, &lsutil.UserPreferences{
40+
InlayHints: lsutil.InlayHintsPreferences{IncludeInlayVariableTypeHints: core.TSTrue},
41+
})
42+
}

‎tsc/internal/ls/codeactions_fixclassincorrectlyimplementsinterface.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func getCodeActionsToFixClassIncorrectlyImplementsInterface(context context.Cont
6767
}
6868

6969
func getAllCodeActionsToFixClassIncorrectlyImplementsInterface(context context.Context, fixContext *CodeFixContext) (*CombinedCodeActions, error) {
70+
allDiags := getAllDiagnostics(context, fixContext.Program, fixContext.SourceFile)
71+
7072
typeChecker, done := fixContext.Program.GetTypeCheckerForFile(context, fixContext.SourceFile)
7173
defer done()
7274

@@ -78,7 +80,7 @@ func getAllCodeActionsToFixClassIncorrectlyImplementsInterface(context context.C
7880

7981
seenClassDeclarations := collections.Set[*ast.Node]{}
8082

81-
for _, diag := range getAllDiagnostics(context, fixContext.Program, fixContext.SourceFile) {
83+
for _, diag := range allDiags {
8284
if isFixableDiagnostic(diag, fixClassIncorrectlyImplementsInterfaceErrorCodes) {
8385
classDeclaration := getClass(fixContext.SourceFile, core.NewTextRange(diag.Pos(), diag.End()))
8486
if classDeclaration == nil {

‎tsc/internal/ls/codeactions_fixmissingtypeannotation.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ func getIsolatedDeclarationsCodeActions(ctx context.Context, fixContext *CodeFix
126126
}
127127

128128
func getAllIsolatedDeclarationsCodeActions(ctx context.Context, fixContext *CodeFixContext) (*CombinedCodeActions, error) {
129+
allDiags := getAllDiagnostics(ctx, fixContext.Program, fixContext.SourceFile)
130+
129131
ch, done := fixContext.Program.GetTypeCheckerForFile(ctx, fixContext.SourceFile)
130132
defer done()
131133

@@ -141,7 +143,6 @@ func getAllIsolatedDeclarationsCodeActions(ctx context.Context, fixContext *Code
141143
typePrintMode: typePrintModeFull,
142144
}
143145

144-
allDiags := getAllDiagnostics(ctx, fixContext.Program, fixContext.SourceFile)
145146
for _, diag := range allDiags {
146147
if isFixableDiagnostic(diag, isolatedDeclarationsFixErrorCodes) {
147148
span := core.NewTextRange(diag.Loc().Pos(), diag.Loc().End())

‎tsc/internal/ls/codeactions_importfixes.go‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ type fixInfo struct {
6161
}
6262

6363
func getImportCodeActions(ctx context.Context, fixContext *CodeFixContext) ([]*CodeAction, error) {
64-
info, err := getFixInfos(ctx, fixContext, fixContext.ErrorCode, fixContext.Span.Pos())
64+
ch, done := fixContext.Program.GetTypeChecker(ctx)
65+
defer done()
66+
67+
info, err := getFixInfos(ch, fixContext, fixContext.ErrorCode, fixContext.Span.Pos())
6568
if err != nil {
6669
return nil, err
6770
}
@@ -133,7 +136,7 @@ func getAllImportCodeActions(ctx context.Context, fixContext *CodeFixContext) (*
133136
)
134137

135138
for _, diag := range importDiags {
136-
if err := addImportFromDiagnostic(ctx, importAdder, diag, fixContext); err != nil {
139+
if err := addImportFromDiagnostic(ch, importAdder, diag, fixContext); err != nil {
137140
return nil, err
138141
}
139142
}
@@ -149,7 +152,7 @@ func getAllImportCodeActions(ctx context.Context, fixContext *CodeFixContext) (*
149152
}
150153

151154
// addImportFromDiagnostic finds the best import fix for a diagnostic and adds it to the adder.
152-
func addImportFromDiagnostic(ctx context.Context, importAdder autoimport.ImportAdder, diag *ast.Diagnostic, fixContext *CodeFixContext) error {
155+
func addImportFromDiagnostic(ch *checker.Checker, importAdder autoimport.ImportAdder, diag *ast.Diagnostic, fixContext *CodeFixContext) error {
153156
diagFixContext := &CodeFixContext{
154157
SourceFile: fixContext.SourceFile,
155158
Span: core.NewTextRange(diag.Pos(), diag.End()),
@@ -158,7 +161,7 @@ func addImportFromDiagnostic(ctx context.Context, importAdder autoimport.ImportA
158161
LS: fixContext.LS,
159162
}
160163

161-
infos, err := getFixInfos(ctx, diagFixContext, diag.Code(), diag.Pos())
164+
infos, err := getFixInfos(ch, diagFixContext, diag.Code(), diag.Pos())
162165
if err != nil {
163166
return err
164167
}
@@ -168,7 +171,7 @@ func addImportFromDiagnostic(ctx context.Context, importAdder autoimport.ImportA
168171
return nil
169172
}
170173

171-
func getFixInfos(ctx context.Context, fixContext *CodeFixContext, errorCode int32, pos int) ([]*fixInfo, error) {
174+
func getFixInfos(ch *checker.Checker, fixContext *CodeFixContext, errorCode int32, pos int) ([]*fixInfo, error) {
172175
// Can't compute import fixes for dynamic/untitled files since they don't have real file paths
173176
if tspath.IsDynamicFileName(fixContext.SourceFile.FileName()) {
174177
return nil, nil
@@ -179,9 +182,6 @@ func getFixInfos(ctx context.Context, fixContext *CodeFixContext, errorCode int3
179182
return nil, nil
180183
}
181184

182-
ch, done := fixContext.Program.GetTypeChecker(ctx)
183-
defer done()
184-
185185
var view *autoimport.View
186186
var info []*fixInfo
187187

‎tsc/internal/ls/findallreferences.go‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,7 +1287,7 @@ func (l *LanguageService) getReferencedSymbolsForNode(ctx context.Context, posit
12871287
}
12881288

12891289
if moduleSymbol := checker.GetMergedSymbol(resolvedRef.file.Symbol); moduleSymbol != nil {
1290-
return l.getReferencedSymbolsForModule(ctx, program, moduleSymbol /*excludeImportTypeOfExportEquals*/, false, sourceFiles, sourceFilesSet)
1290+
return l.getReferencedSymbolsForModule(checker, program, moduleSymbol /*excludeImportTypeOfExportEquals*/, false, sourceFiles, sourceFilesSet)
12911291
}
12921292

12931293
// !!! not implemented
@@ -1336,7 +1336,7 @@ func (l *LanguageService) getReferencedSymbolsForNode(ctx context.Context, posit
13361336
if symbol.Parent == nil {
13371337
return nil
13381338
}
1339-
return l.getReferencedSymbolsForModule(ctx, program, symbol.Parent, false /*excludeImportTypeOfExportEquals*/, sourceFiles, sourceFilesSet)
1339+
return l.getReferencedSymbolsForModule(checker, program, symbol.Parent, false /*excludeImportTypeOfExportEquals*/, sourceFiles, sourceFilesSet)
13401340
}
13411341

13421342
moduleReferences := l.getReferencedSymbolsForModuleIfDeclaredBySourceFile(ctx, symbol, program, sourceFiles, checker, options, sourceFilesSet)
@@ -1410,7 +1410,7 @@ func (l *LanguageService) getReferencedSymbolsForModuleIfDeclaredBySourceFile(ct
14101410
}
14111411
exportEquals := symbol.Exports[ast.InternalSymbolNameExportEquals]
14121412
// If exportEquals != nil, we're about to add references to `import("mod")` anyway, so don't double-count them.
1413-
moduleReferences := l.getReferencedSymbolsForModule(ctx, program, symbol, exportEquals != nil, sourceFiles, sourceFilesSet)
1413+
moduleReferences := l.getReferencedSymbolsForModule(checker, program, symbol, exportEquals != nil, sourceFiles, sourceFilesSet)
14141414
if exportEquals == nil || exportEquals.Flags&ast.SymbolFlagsAlias == 0 || !sourceFilesSet.Has(moduleSourceFileName) {
14151415
return moduleReferences
14161416
}
@@ -1732,12 +1732,9 @@ func getMergedAliasedSymbolOfNamespaceExportDeclaration(node *ast.Node, symbol *
17321732
return nil
17331733
}
17341734

1735-
func (l *LanguageService) getReferencedSymbolsForModule(ctx context.Context, program *compiler.Program, symbol *ast.Symbol, excludeImportTypeOfExportEquals bool, sourceFiles []*ast.SourceFile, sourceFilesSet *collections.Set[string]) []*SymbolAndEntries {
1735+
func (l *LanguageService) getReferencedSymbolsForModule(checker *checker.Checker, program *compiler.Program, symbol *ast.Symbol, excludeImportTypeOfExportEquals bool, sourceFiles []*ast.SourceFile, sourceFilesSet *collections.Set[string]) []*SymbolAndEntries {
17361736
debug.Assert(symbol.ValueDeclaration != nil)
17371737

1738-
checker, done := program.GetTypeChecker(ctx)
1739-
defer done()
1740-
17411738
moduleRefs := findModuleReferences(program, sourceFiles, symbol, checker)
17421739
references := core.MapNonNil(moduleRefs, func(reference ModuleReference) *ReferenceEntry {
17431740
switch reference.kind {

‎tsc/internal/ls/inlay_hints.go‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,22 @@ func (l *LanguageService) ProvideInlayHint(
3838
mappedRanges := l.converters.FromLSPRangeIntersectingForSourceFile(file, params.Range, spanmap.FeatureInlayHints)
3939
result := make([]*lsproto.InlayHint, 0, len(mappedRanges))
4040
for _, mapped := range mappedRanges {
41-
projection := mapped.Script
42-
checker, done := program.GetTypeCheckerForFile(ctx, projection)
43-
defer done()
44-
inlayHintState := &inlayHintState{
45-
ctx: ctx,
46-
span: mapped.Span,
47-
preferences: inlayHintPreferences,
48-
quotePreference: quotePreference,
49-
file: projection,
50-
checker: checker,
51-
converters: l.converters,
52-
}
53-
inlayHintState.visit(projection.AsNode())
54-
result = append(result, inlayHintState.result...)
41+
func() {
42+
projection := mapped.Script
43+
checker, done := program.GetTypeCheckerForFile(ctx, projection)
44+
defer done()
45+
inlayHintState := &inlayHintState{
46+
ctx: ctx,
47+
span: mapped.Span,
48+
preferences: inlayHintPreferences,
49+
quotePreference: quotePreference,
50+
file: projection,
51+
checker: checker,
52+
converters: l.converters,
53+
}
54+
inlayHintState.visit(projection.AsNode())
55+
result = append(result, inlayHintState.result...)
56+
}()
5557
}
5658
return lsproto.InlayHintsOrNull{InlayHints: &result}, nil
5759
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package lsp_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"testing"
8+
9+
"github.com/microsoft/TypeScript/tsc/internal/bundled"
10+
"github.com/microsoft/TypeScript/tsc/internal/lsp"
11+
"github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto"
12+
"github.com/microsoft/TypeScript/tsc/internal/testutil/lsptestutil"
13+
"github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest"
14+
"gotest.tools/v3/assert"
15+
)
16+
17+
func TestFlakyDiagnosticTrackingParallelEmit(t *testing.T) {
18+
t.Parallel()
19+
if !bundled.Embedded {
20+
t.Skip("bundled files are not embedded")
21+
}
22+
23+
for _, noEmitOnError := range []bool{false, true} {
24+
t.Run(fmt.Sprintf("noEmitOnError=%t", noEmitOnError), func(t *testing.T) {
25+
t.Parallel()
26+
files := map[string]string{
27+
"/src/tsconfig.json": fmt.Sprintf(`{
28+
"compilerOptions": { "strict": true, "declaration": true, "noEmitOnError": %t, "outDir": "out" }
29+
}`, noEmitOnError),
30+
"/src/a.ts": `export function box<T>(value: T) { return { value }; }`,
31+
"/src/b.ts": `import { box } from "./a"; export const b = box("b");`,
32+
"/src/c.ts": `import { box } from "./a"; export const c = box(1);`,
33+
}
34+
client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{
35+
Err: io.Discard,
36+
Cwd: "/src",
37+
FS: bundled.WrapFS(vfstest.FromMap(files, false)),
38+
DefaultLibraryPath: bundled.LibPath(),
39+
}, func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage {
40+
switch req.Method {
41+
case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability, lsproto.MethodWindowWorkDoneProgressCreate:
42+
return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}}
43+
default:
44+
return nil
45+
}
46+
})
47+
t.Cleanup(func() { _ = closeClient() })
48+
49+
msg, _, ok := client.SendRequest(t, lsproto.InitializeInfo, &lsproto.InitializeParams{
50+
Capabilities: &lsproto.ClientCapabilities{},
51+
InitializationOptions: &lsproto.InitializationOptionsOrNull{
52+
InitializationOptions: &lsproto.InitializationOptions{TrackFlakyDiagnostics: new(lsproto.DiagnosticFlakeLogLevelPanic)},
53+
},
54+
})
55+
assert.Assert(t, ok && msg.AsResponse().Error == nil, "initialize failed")
56+
client.SendNotification(t, lsproto.InitializedInfo, &lsproto.InitializedParams{})
57+
<-client.Server.InitComplete()
58+
59+
uri := lsproto.DocumentUri("file:///src/a.ts")
60+
client.SendNotification(t, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{
61+
TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: lsproto.LanguageKindTypeScript, Text: files["/src/a.ts"]},
62+
})
63+
msg, diagnostics, ok := client.SendRequest(t, lsproto.TextDocumentDiagnosticInfo, &lsproto.DocumentDiagnosticParams{
64+
TextDocument: lsproto.TextDocumentIdentifier{Uri: uri},
65+
})
66+
assert.Assert(t, ok && msg.AsResponse().Error == nil, "diagnostics request failed")
67+
assert.Assert(t, diagnostics.FullDocumentDiagnosticReport != nil)
68+
assert.Equal(t, len(diagnostics.FullDocumentDiagnosticReport.Items), 0)
69+
})
70+
}
71+
}

‎tsc/internal/project/checkerpool.go‎

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -142,76 +142,50 @@ func (p *checkerPool) GetChecker(ctx context.Context, file *ast.SourceFile) (*ch
142142
}
143143
}
144144

145-
// tryReacquireForRequest checks whether the given request already has an
146-
// associated checker. If so, it either returns the checker directly (still held)
147-
// or reacquires it by claiming a semaphore slot. The caller must provide the
145+
// tryReacquireForRequest claims a semaphore slot, then checks whether the given
146+
// request has an idle associated checker. The caller must provide the
148147
// appropriate semaphore channel and indicate whether this is a diagnostics
149148
// request (isDiag). If the associated checker is in the wrong category
150149
// (e.g. a diagnostics index for a query request), the association is deleted
151150
// and normal acquisition proceeds.
152151
//
153-
// Returns (checker, release, true) if the request was served (either still held
154-
// or reclaimed). Returns (nil, nil, false) if the caller must proceed with
152+
// Request affinity is only a preference for an idle checker, not permission to
153+
// reuse a held checker: concurrent acquisitions can share the same request ID.
154+
// Returns (checker, release, true) if the checker was reclaimed.
155+
// Returns (nil, nil, false) if the caller must proceed with
155156
// normal acquisition — in this case, a semaphore slot has already been claimed.
156157
// Must NOT be called with p.mu held.
157158
func (p *checkerPool) tryReacquireForRequest(requestID string, sem chan<- struct{}, isDiag bool) (*checker.Checker, func(), bool) {
159+
sem <- struct{}{}
158160
if requestID == "" {
159-
sem <- struct{}{}
160161
return nil, nil, false
161162
}
162163

163164
p.mu.Lock()
165+
defer p.mu.Unlock()
164166
index, ok := p.requestAssociations[requestID]
165167
if !ok {
166-
p.mu.Unlock()
167-
sem <- struct{}{}
168168
return nil, nil, false
169169
}
170170

171171
// Validate that the associated index matches the expected category.
172172
// Index 0 is for diagnostics; indices 1+ are for queries.
173173
if (isDiag && index != 0) || (!isDiag && index == 0) {
174174
delete(p.requestAssociations, requestID)
175-
p.mu.Unlock()
176-
sem <- struct{}{}
177175
return nil, nil, false
178176
}
179177

180178
c := p.checkers[index]
181179
if c == nil {
182180
delete(p.requestAssociations, requestID)
183-
p.mu.Unlock()
184-
sem <- struct{}{}
185181
return nil, nil, false
186182
}
187183

188-
held := p.heldBy[index]
189-
if held == requestID {
190-
// Same request, checker still held — return without claiming a slot.
191-
p.mu.Unlock()
192-
return c, noop, true
193-
}
194-
195-
if held == "" {
196-
// Same request reacquiring after release — need a semaphore slot.
197-
p.mu.Unlock()
198-
sem <- struct{}{}
199-
p.mu.Lock()
200-
// Re-check: checker may have been disposed while waiting for the slot.
201-
if cc := p.checkers[index]; cc == c && p.heldBy[index] == "" {
202-
p.heldBy[index] = requestID
203-
p.mu.Unlock()
204-
return c, p.createRelease(requestID, index, c), true
205-
}
206-
p.mu.Unlock()
207-
// Checker was replaced/disposed while waiting for the slot.
208-
// The slot is still claimed; the caller will use it for normal acquisition.
209-
return nil, nil, false
184+
if p.heldBy[index] == "" {
185+
p.heldBy[index] = requestID
186+
return c, p.createRelease(requestID, index, c), true
210187
}
211188

212-
// Checker held by another request — claim a slot normally.
213-
p.mu.Unlock()
214-
sem <- struct{}{}
215189
return nil, nil, false
216190
}
217191

@@ -529,5 +503,3 @@ func (p *checkerPool) Discard() {
529503
p.cleanupTimer = nil
530504
}
531505
}
532-
533-
func noop() {}

0 commit comments

Comments
 (0)