From 7cd6af0c5f7276934d305a11a8fb2dac07fe296d Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 9 Sep 2026 12:46:04 -0700 Subject: [PATCH] Fix content mapper LSP race --- tsc/internal/project/compilerhost.go | 18 ++-- tsc/internal/project/contentmapper_test.go | 82 +++++++++++++++++++ .../project/projectcollectionbuilder.go | 10 +-- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/tsc/internal/project/compilerhost.go b/tsc/internal/project/compilerhost.go index cba0bebfc5c2a..4ae36a8f642ec 100644 --- a/tsc/internal/project/compilerhost.go +++ b/tsc/internal/project/compilerhost.go @@ -120,18 +120,18 @@ func (c *compilerHost) GetContentMappedSourceFiles(parseOptions ast.SourceFilePa if c.builder.client != nil { diagnosticLocale = c.builder.client.GetLocale() } - c.ensureContentMapperProject() - if c.contentMapperProject == nil { + project := c.ContentMapperProject() + if project == nil { return contentmapper.SourceFiles{}, contentmapper.ErrProjectUnavailable } - identity, err := c.contentMapperProject.Identity(mapper) + identity, err := project.Identity(mapper) if err != nil { return contentmapper.SourceFiles{}, contentmapper.NewTransformError(contentmapper.TransformErrorKindProject, err) } transformIdentity := xxh3.Hash128([]byte(identity)) key := contentMappedParseCacheKey(parseOptions, fh.Hash(), transformIdentity, diagnosticLocale) files, err := c.builder.contentMappedParseCache.AcquireOrError(key, func() (contentmapper.SourceFiles, error) { - files, transformErr := contentmapper.TransformAndParse(parseOptions, fh.Content(), mapper, c.contentMapperProject) + files, transformErr := contentmapper.TransformAndParse(parseOptions, fh.Content(), mapper, project) if transformErr != nil { return contentmapper.SourceFiles{}, transformErr } @@ -153,21 +153,21 @@ func (c *compilerHost) GetContentMappedSourceFiles(parseOptions ast.SourceFilePa return files, err } -func (c *compilerHost) ensureContentMapperProject() { +func (c *compilerHost) ContentMapperProject() contentmapper.Project { c.contentMapperOnce.Do(func() { - if c.builder.contentMapperHost == nil { + if c.builder == nil || c.builder.contentMapperHost == nil { return } commandLine := c.project.getCommandLineWithTypingsFiles() + if len(commandLine.ContentMappers()) == 0 { + return + } c.contentMapperProject = c.builder.contentMapperHost.Project(contentmapper.ProjectSpec{ ConfigFileName: commandLine.ConfigName(), Mappers: commandLine.ContentMappers(), CompilerOptions: commandLine.CompilerOptions(), }) }) -} - -func (c *compilerHost) ContentMapperProject() contentmapper.Project { return c.contentMapperProject } diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index f598cf0f0e533..f886f89fa5b8c 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -50,6 +50,88 @@ func (p *recordingContentMapperProcess) Close() error { return p.ReadWriteCloser.Close() } +func TestContentMapperProjectWithoutMappedFiles(t *testing.T) { + t.Parallel() + for _, hasMapper := range []bool{false, true} { + t.Run(fmt.Sprintf("hasMapper=%t", hasMapper), func(t *testing.T) { + t.Parallel() + config := `{"compilerOptions": {"noLib": true}}` + if hasMapper { + config = `{ + "compilerOptions": { "noLib": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }` + } + files := map[string]any{ + "/home/project/tsconfig.json": config, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/main.ts": "export {};", + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + RunExternalCode: true, + }, nil) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + init.Spawner = spawner + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + languageService, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + // Access after freezing must not try to initialize using the cleared builder. + program := languageService.GetProgram() + mapperProject := program.ContentMapperProject() + assert.Equal(t, mapperProject != nil, hasMapper) + assert.Equal(t, program.ContentMapperProject(), mapperProject) + assert.Equal(t, spawner.spawns.Load(), int32(0)) + }) + } +} + +func TestContentMapperParallelFileLoading(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/project/tsconfig.json": `{ + "compilerOptions": { "target": "es2020", "noLib": true }, + "contentMappers": [ { "package": "mapper", "extensions": [".box"] } ] + }`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.TransformingMapper), + "/home/project/main.ts": "export {};", + } + // Parallel parsing reads the mapper project identity while another file initializes it. + const fileCount = 32 + for i := range fileCount { + files[fmt.Sprintf("/home/project/file%d.box", i)] = "export const version = #{target};\n" + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + RunExternalCode: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + session := project.NewSession(init) + defer session.Close() + + ctx := context.Background() + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + languageService, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") + assert.NilError(t, err) + mapperProject := languageService.GetProgram().ContentMapperProject() + assert.Assert(t, mapperProject != nil) + assert.Equal(t, languageService.GetProgram().ContentMapperProject(), mapperProject) + for i := range fileCount { + fileName := fmt.Sprintf("/home/project/file%d.box", i) + file := languageService.GetProgram().GetSourceFile(fileName) + assert.Assert(t, file != nil, "expected %s to be loaded", fileName) + assert.Equal(t, file.Text(), "const __VERSION = \"1.0.0\";\nexport const version = 7;\n") + } +} + func TestContentMapperInProject(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 347b69caf7a1a..1a6f852cab4cd 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -1297,12 +1297,10 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo if slices.ContainsFunc(result.Program.SourceFiles(), func(file *ast.SourceFile) bool { return file.ContentMapper() != "" }) { - project.host.ensureContentMapperProject() - } - contentMapperProject := project.host.ContentMapperProject() - if contentMapperProject != nil { - dynamicWatchedFiles, _ := contentMapperProject.WatchedFiles() - watchedFiles = append(watchedFiles, dynamicWatchedFiles...) + if contentMapperProject := project.host.ContentMapperProject(); contentMapperProject != nil { + dynamicWatchedFiles, _ := contentMapperProject.WatchedFiles() + watchedFiles = append(watchedFiles, dynamicWatchedFiles...) + } } slices.Sort(watchedFiles) watchedFiles = slices.Compact(watchedFiles)