diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index ec6183d79ec8f..d5e55fc74be8f 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -83,7 +83,7 @@ func (s *StdioServer) Run(ctx context.Context) error { fs = callbackFS } - projectSession := project.NewSession(&project.SessionInit{ + sessionInit := &project.SessionInit{ BackgroundCtx: ctx, Logger: nil, // TODO: Add logging support FS: fs, @@ -95,9 +95,9 @@ func (s *StdioServer) Run(ctx context.Context) error { RunExternalCode: s.options.RunExternalCode, }, Spawner: s.options.ContentMapperSpawner, - }) + } - session := NewSession(projectSession, &SessionOptions{ + session := NewStandaloneSession(sessionInit, &SessionOptions{ UseBinaryResponses: !s.options.Async, // Only msgpack uses binary responses }) defer session.Close() diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 27e79de817ef6..a25a6ed2b754c 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -380,7 +380,15 @@ func (sd *snapshotData) registerSignature(projectID ProjectID, sig *checker.Sign // symbol and type registries for maintaining object identity. type Session struct { id string + snapshotHost *project.SnapshotHost + withLocale func(context.Context) context.Context projectSession *project.Session + // compatibilitySnapshot is the standalone API session's canonical snapshot. + // It preserves the legacy linear updateSnapshot behavior. + compatibilitySnapshot *project.Snapshot + compatibilityMu sync.Mutex + + closeOnce sync.Once // This is set to true when using MessagePackProtocol. useBinaryResponses bool @@ -403,9 +411,9 @@ type Session struct { latestSnapshot SnapshotID // openProjects and openFiles track the projects and files this session - // currently holds open in the project session's API state. The session holds - // at most one ref per project/file (opens are idempotent), so it can release - // exactly those refs on Close and never send a close for a ref it doesn't hold. + // currently holds open in the API snapshot state. The session holds at most + // one ref per project/file (opens are idempotent), so it can release exactly + // those refs on Close and never send a close for a ref it doesn't hold. // Guarded by updateMu. openProjects collections.Set[tspath.Path] openFiles collections.Set[tspath.Path] @@ -432,13 +440,31 @@ type SessionOptions struct { UseBinaryResponses bool } -// NewSession creates a new API session with the given project session. -func NewSession(projectSession *project.Session, options *SessionOptions) *Session { +// NewLSPSession creates a new API session with the given project session. +func NewLSPSession(projectSession *project.Session, options *SessionOptions) *Session { + s := newSession(projectSession.SnapshotHost, projectSession.WithCurrentLocale, options) + s.projectSession = projectSession + return s +} + +// NewStandaloneSession creates an API session with an independently owned snapshot host. +func NewStandaloneSession(init *project.SessionInit, options *SessionOptions) *Session { + snapshotHost := project.NewSnapshotHost(init) + s := newSession(snapshotHost, nil, options) + s.compatibilitySnapshot = snapshotHost.NewStandaloneRootSnapshot() + return s +} + +func newSession(snapshotHost *project.SnapshotHost, withLocale func(context.Context) context.Context, options *SessionOptions) *Session { id := sessionIDCounter.Add(1) + if withLocale == nil { + withLocale = func(ctx context.Context) context.Context { return ctx } + } s := &Session{ - id: formatSessionID(id), - projectSession: projectSession, - snapshots: make(map[SnapshotID]*snapshotData), + id: formatSessionID(id), + snapshotHost: snapshotHost, + withLocale: withLocale, + snapshots: make(map[SnapshotID]*snapshotData), } if options != nil { s.useBinaryResponses = options.UseBinaryResponses @@ -451,9 +477,31 @@ func (s *Session) ID() string { return s.id } -// ProjectSession returns the underlying project session. -func (s *Session) ProjectSession() *project.Session { - return s.projectSession +func (s *Session) currentDirectory() string { + return s.snapshotHost.GetCurrentDirectory() +} + +func (s *Session) useCaseSensitiveFileNames() bool { + return s.snapshotHost.FS().UseCaseSensitiveFileNames() +} + +func (s *Session) apiUpdate( + ctx context.Context, + fileChanges project.FileChangeSummary, + apiRequest *project.APISnapshotRequest, +) (*project.Snapshot, error) { + if s.projectSession != nil { + return s.projectSession.APIUpdate(ctx, fileChanges, apiRequest) + } + + s.compatibilityMu.Lock() + defer s.compatibilityMu.Unlock() + oldSnapshot := s.compatibilitySnapshot + snapshot, err := s.snapshotHost.CloneSnapshot(ctx, oldSnapshot, fileChanges, apiRequest) + s.snapshotHost.RetainSnapshot(snapshot) + s.compatibilitySnapshot = snapshot + oldSnapshot.Deref() + return snapshot, err } // snapshotHandle creates a snapshot handle from a snapshot's ID. @@ -494,7 +542,7 @@ func (s *Session) releaseSnapshot(handle SnapshotID) error { sd.refCount-- if sd.refCount <= 0 { delete(s.snapshots, handle) - sd.snapshot.Deref(s.projectSession) + sd.snapshot.Deref() } s.snapshotsMu.Unlock() return nil @@ -594,6 +642,9 @@ func (s *Session) setupLanguageService(snapshot *project.Snapshot, program *comp // HandleRequest implements Handler. func (s *Session) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { + if s != nil && s.withLocale != nil { + ctx = s.withLocale(ctx) + } // Handle simple methods that don't need param parsing switch method { case "echo": @@ -962,8 +1013,8 @@ func (s *Session) HandleNotification(ctx context.Context, method string, params func (s *Session) handleInitialize(ctx context.Context) (*InitializeResponse, error) { return &InitializeResponse{ - UseCaseSensitiveFileNames: s.projectSession.FS().UseCaseSensitiveFileNames(), - CurrentDirectory: s.projectSession.GetCurrentDirectory(), + UseCaseSensitiveFileNames: s.useCaseSensitiveFileNames(), + CurrentDirectory: s.currentDirectory(), }, nil } @@ -987,7 +1038,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh // Open projects: only take a new ref for projects we aren't already holding open. var openedProjects []tspath.Path for _, p := range params.OpenProjects { - configFileName := p.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + configFileName := p.ToAbsoluteFileName(s.currentDirectory()) configPath := s.toPath(configFileName) if s.openProjects.Has(configPath) { continue @@ -1002,7 +1053,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh // Close projects: only release a ref we currently hold. var closedProjects []tspath.Path for _, p := range params.CloseProjects { - configPath := s.toPath(p.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory())) + configPath := s.toPath(p.ToAbsoluteFileName(s.currentDirectory())) if !s.openProjects.Has(configPath) { continue } @@ -1017,7 +1068,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh // held by at most one API ref from this session. var openedFiles []tspath.Path for _, f := range params.OpenFiles { - uri := f.ToURI(s.projectSession.GetCurrentDirectory()) + uri := f.ToURI(s.currentDirectory()) path := s.toPath(uri.FileName()) if s.openFiles.Has(path) { continue @@ -1032,7 +1083,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh // Close files: only release a ref we currently hold. var closedFiles []tspath.Path for _, f := range params.CloseFiles { - path := s.toPath(f.ToURI(s.projectSession.GetCurrentDirectory()).FileName()) + path := s.toPath(f.ToURI(s.currentDirectory()).FileName()) if !s.openFiles.Has(path) { continue } @@ -1047,10 +1098,10 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh // files opened by the API are up to date. For an API connected to an LSP server, // this brings the API state up to date with the LSP state and ensures projects // the API cares about are ready to be queried. - snapshot, err := s.projectSession.APIUpdate(ctx, fileChanges, apiRequest) + snapshot, err := s.apiUpdate(ctx, fileChanges, apiRequest) if err != nil { // APIUpdate returns a ref'd snapshot even on error; release it. - snapshot.Deref(s.projectSession) + snapshot.Deref() return nil, fmt.Errorf("%w: failed to update snapshot: %w", ErrClientError, err) } @@ -1078,7 +1129,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if exists { // Same snapshot already stored — release the caller's ref since // the stored snapshot already has one, and bump the API refcount. - snapshot.Deref(s.projectSession) + snapshot.Deref() sd.refCount++ } else { sd = &snapshotData{ @@ -1127,9 +1178,9 @@ func (s *Session) handleUpdateTemporarySnapshot(ctx context.Context, params *Upd } defer func() { _ = s.releaseSnapshot(params.Snapshot) }() - uri := params.File.ToURI(s.projectSession.GetCurrentDirectory()) + uri := params.File.ToURI(s.currentDirectory()) - snapshot, err := s.projectSession.APIUpdateTemporary(ctx, baseSD.snapshot, uri, params.NewText) + snapshot, err := s.snapshotHost.CloneSnapshotWithTemporaryFile(ctx, baseSD.snapshot, uri, params.NewText) if err != nil { return nil, fmt.Errorf("%w: failed to update temporary snapshot: %w", ErrClientError, err) } @@ -1138,7 +1189,7 @@ func (s *Session) handleUpdateTemporarySnapshot(ctx context.Context, params *Upd s.snapshotsMu.Lock() sd, exists := s.snapshots[handle] if exists { - snapshot.Deref(s.projectSession) + snapshot.Deref() sd.refCount++ } else { sd = &snapshotData{ @@ -1180,7 +1231,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram rootFileNames := make([]string, len(params.RootFiles)) for i, rootFile := range params.RootFiles { - rootFileNames[i] = rootFile.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + rootFileNames[i] = rootFile.ToAbsoluteFileName(s.currentDirectory()) } var oldSnapshot *project.Snapshot @@ -1200,19 +1251,31 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } } - snapshot := s.projectSession.APICreateProgram( + baseSnapshot := oldSnapshot + fileChanges := s.toFileChangeSummary(params.FileChanges) + if baseSnapshot == nil { + var err error + baseSnapshot, err = s.apiUpdate(ctx, fileChanges, nil) + if err != nil { + baseSnapshot.Deref() + return nil, fmt.Errorf("%w: failed to update snapshot: %w", ErrClientError, err) + } + defer baseSnapshot.Deref() + fileChanges = project.FileChangeSummary{} + } + snapshot := s.snapshotHost.CloneSnapshotForProgram( ctx, + baseSnapshot, rootFileNames, ¶ms.CreateProgramOptions.CompilerOptions, params.CreateProgramOptions.ProjectReferences, core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), - oldSnapshot, oldProject, - s.toFileChangeSummary(params.FileChanges), + fileChanges, ) project := snapshot.ProjectCollection.InferredProject() if project == nil { - snapshot.Deref(s.projectSession) + snapshot.Deref() return nil, fmt.Errorf("%w: failed to create synthetic project", ErrClientError) } @@ -1220,7 +1283,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram s.snapshotsMu.Lock() if sd, exists := s.snapshots[handle]; exists { // Same snapshot already stored: use the existing retained ref and only bump API refcount. - snapshot.Deref(s.projectSession) + snapshot.Deref() sd.refCount++ } else { sd = &snapshotData{ @@ -1262,7 +1325,7 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return nil, err } - uri := params.File.ToURI(s.projectSession.GetCurrentDirectory()) + uri := params.File.ToURI(s.currentDirectory()) proj := sd.snapshot.GetDefaultProject(uri) if proj == nil { return nil, nil @@ -1273,13 +1336,13 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge // handleParseCommandLine parses command-line arguments. func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseCommandLineParams) (*ConfigFileResponse, error) { - return NewConfigFileResponse(tsoptions.ParseCommandLine(params.CommandLine, s.projectSession)), nil + return NewConfigFileResponse(tsoptions.ParseCommandLine(params.CommandLine, s.snapshotHost)), nil } // handleReadConfigFile reads and parses a JSON configuration file. func (s *Session) handleReadConfigFile(ctx context.Context, params *ReadConfigFileParams) (*ReadConfigFileResponse, error) { - configFileName := params.File.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) - configFileContent, ok := s.projectSession.FS().ReadFile(configFileName) + configFileName := params.File.ToAbsoluteFileName(s.currentDirectory()) + configFileContent, ok := s.snapshotHost.FS().ReadFile(configFileName) if !ok { return &ReadConfigFileResponse{ Config: map[string]any{}, @@ -1308,15 +1371,15 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * var basePath string var configFileName string if params.ConfigDirectory != nil { - basePath = tspath.GetNormalizedAbsolutePath(*params.ConfigDirectory, s.projectSession.GetCurrentDirectory()) + basePath = tspath.GetNormalizedAbsolutePath(*params.ConfigDirectory, s.currentDirectory()) } else { - configFileName = params.ConfigFileName.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + configFileName = params.ConfigFileName.ToAbsoluteFileName(s.currentDirectory()) basePath = tspath.GetDirectoryPath(configFileName) } parsedCommandLine := tsoptions.ParseJsonConfigFileContent( jsonValueToAny(params.JSON), - s.projectSession, + s.snapshotHost, basePath, nil, /*existingOptions*/ configFileName, @@ -1328,8 +1391,8 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * // handleParseConfigFile parses a tsconfig.json file and returns its contents. func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfigFileParams) (*ConfigFileResponse, error) { - configFileName := params.File.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) - configFileContent, ok := s.projectSession.FS().ReadFile(configFileName) + configFileName := params.File.ToAbsoluteFileName(s.currentDirectory()) + configFileContent, ok := s.snapshotHost.FS().ReadFile(configFileName) if !ok { return nil, fmt.Errorf("%w: could not read file %q", ErrClientError, configFileName) } @@ -1342,7 +1405,7 @@ func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfig ) parsedCommandLine := tsoptions.ParseJsonSourceFileConfigFileContent( tsConfigSourceFile, - s.projectSession, + s.snapshotHost, configDir, nil, /*existingOptions*/ nil, /*existingOptionsRaw*/ @@ -1358,8 +1421,8 @@ func (s *Session) handleTranspile(ctx context.Context, params *TranspileParams, } func (s *Session) handleTranspileFromFile(ctx context.Context, params *TranspileFromFileParams, declaration bool) (*TranspileOutputResponse, error) { - fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.projectSession.GetCurrentDirectory()) - input, ok := s.projectSession.FS().ReadFile(fileName) + fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.currentDirectory()) + input, ok := s.snapshotHost.FS().ReadFile(fileName) if !ok { return nil, fmt.Errorf("%w: could not read file %q", ErrClientError, fileName) } @@ -2100,8 +2163,11 @@ func (s *Session) handleGetImportAdderEdits(ctx context.Context, params *GetImpo userPreferences := workingSnapshot.UserPreferences() if registry := workingSnapshot.AutoImportRegistry(); registry == nil || !registry.IsPreparedForImportingFile(sourceFile.FileName(), projectPath, userPreferences) { - preparedSnapshot := s.projectSession.GetSnapshotWithAutoImports(ctx, workingSnapshot, params.File.ToURI(s.projectSession.GetCurrentDirectory())) - defer preparedSnapshot.Deref(s.projectSession) + preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, workingSnapshot, params.File.ToURI(s.currentDirectory()), nil) + if s.projectSession != nil { + s.projectSession.TryAdoptSnapshotInBackground(workingSnapshot, preparedSnapshot) + } + defer preparedSnapshot.Deref() workingSnapshot = preparedSnapshot proj := workingSnapshot.ProjectCollection.GetProjectByPath(projectPath) @@ -2758,7 +2824,7 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp return nil, err } options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { - return s.projectSession.FS().WriteFile(fileName, text) + return s.snapshotHost.FS().WriteFile(fileName, text) } result, err := emitProgram(ctx, program, options) if err != nil { @@ -3714,21 +3780,29 @@ func computeSnapshotChanges(prev *project.Snapshot, next *project.Snapshot) *Sna // Close closes the session and releases all active snapshots, // regardless of their ref counts. func (s *Session) Close() { - s.releaseOpenRefs() + s.closeOnce.Do(func() { + s.releaseOpenRefs() - s.snapshotsMu.Lock() - defer s.snapshotsMu.Unlock() - for handle, sd := range s.snapshots { - sd.snapshot.Deref(s.projectSession) - delete(s.snapshots, handle) - } + s.snapshotsMu.Lock() + for handle, sd := range s.snapshots { + sd.snapshot.Deref() + delete(s.snapshots, handle) + } + s.snapshotsMu.Unlock() + + if s.projectSession == nil { + if s.compatibilitySnapshot != nil { + s.compatibilitySnapshot.Deref() + s.compatibilitySnapshot = nil + } + s.snapshotHost.Close() + } + }) } -// releaseOpenRefs releases every project and file ref this session is holding open -// in the project session. This keeps the API's ref counts balanced when an API -// session is shut down while sharing a longer-lived project session (e.g. one -// backing an LSP server), so API-opened projects and files aren't leaked. Only -// refs the session currently holds are closed, so it never over-releases. +// releaseOpenRefs releases every project and file ref this session is holding +// open in a shared project session. Standalone sessions release the entire +// compatibility snapshot when they close, so there is no shared state to update. func (s *Session) releaseOpenRefs() { s.updateMu.Lock() defer s.updateMu.Unlock() @@ -3744,9 +3818,14 @@ func (s *Session) releaseOpenRefs() { if s.openFiles.Len() > 0 { apiRequest.CloseFiles = s.openFiles.Clone() } - snapshot, err := s.projectSession.APIUpdate(context.Background(), project.FileChangeSummary{}, apiRequest) + if s.projectSession == nil { + s.openProjects.Clear() + s.openFiles.Clear() + return + } + snapshot, err := s.projectSession.APIUpdate(s.withLocale(context.Background()), project.FileChangeSummary{}, apiRequest) // APIUpdate returns a ref'd snapshot even on error; always release it. - snapshot.Deref(s.projectSession) + snapshot.Deref() if err != nil { return } @@ -3761,7 +3840,7 @@ func formatSessionID(id uint64) string { // toPath converts a file name to a normalized path. func (s *Session) toPath(fileName string) tspath.Path { - return tspath.ToPath(fileName, s.projectSession.GetCurrentDirectory(), s.projectSession.FS().UseCaseSensitiveFileNames()) + return tspath.ToPath(fileName, s.currentDirectory(), s.useCaseSensitiveFileNames()) } // toFileChangeSummary converts API file changes to a project.FileChangeSummary. @@ -3775,7 +3854,7 @@ func (s *Session) toFileChangeSummary(changes *APIFileChanges) project.FileChang summary.IncludesWatchChangeOutsideNodeModules = true return summary } - cwd := s.projectSession.GetCurrentDirectory() + cwd := s.currentDirectory() for _, doc := range changes.Changed { uri := doc.ToURI(cwd) summary.Changed.Add(uri) @@ -4029,8 +4108,11 @@ func (s *Session) handleGetCompletionsAtPosition(ctx context.Context, params *Ge } result, err := run(sd.snapshot, program) if errors.Is(err, ls.ErrNeedsAutoImports) { - preparedSnapshot := s.projectSession.GetSnapshotWithAutoImports(ctx, sd.snapshot, params.File.ToURI(s.projectSession.GetCurrentDirectory())) - defer preparedSnapshot.Deref(s.projectSession) + preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, sd.snapshot, params.File.ToURI(s.currentDirectory()), nil) + if s.projectSession != nil { + s.projectSession.TryAdoptSnapshotInBackground(sd.snapshot, preparedSnapshot) + } + defer preparedSnapshot.Deref() if err = ctx.Err(); err != nil { return nil, err } diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index dec66df698832..19696cf0bff98 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -5,12 +5,51 @@ import ( "testing" "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" ) +func TestStandaloneSessionUsesSnapshotHostWithoutProjectSession(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const configFileName = "/home/projects/p/tsconfig.json" + init, _ := projecttestutil.GetSessionInitOptions(map[string]any{ + configFileName: `{ "compilerOptions": { "strict": true } }`, + "/home/projects/p/src/index.ts": `export const x = 1;`, + }, nil, &projecttestutil.TypingsInstallerOptions{}) + session := NewStandaloneSession(init, nil) + defer session.Close() + + firstResponse, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenFiles: []DocumentIdentifier{{FileName: "/home/projects/p/src/index.ts"}}, + }) + assert.NilError(t, err) + assert.Equal(t, firstResponse.Snapshot, SnapshotID(1)) + assert.Equal(t, len(firstResponse.Projects), 1) + + response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, response.Snapshot, SnapshotID(2)) + + programResponse, err := session.handleCreateProgram(context.Background(), &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: "/home/projects/p/src/index.ts"}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, programResponse.Project != nil) + assert.Equal(t, programResponse.Snapshot, SnapshotID(4)) +} + // TestSessionTracksAndReleasesAPIRefs verifies that an API session holds at most // one ref per opened project/file (opens are idempotent) and releases exactly // those refs when the session is closed, so it never leaks or over-releases refs @@ -30,7 +69,8 @@ func TestSessionTracksAndReleasesAPIRefs(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) + assert.Assert(t, session.compatibilitySnapshot == nil) _, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ OpenProjects: []DocumentIdentifier{{FileName: configFileName}}, @@ -63,7 +103,7 @@ func TestSessionTracksAndReleasesAPIRefs(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() _, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ @@ -98,7 +138,7 @@ func TestSessionTracksAndReleasesAPIRefs(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) _, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ OpenFiles: []DocumentIdentifier{{FileName: fileName}}, @@ -151,7 +191,7 @@ func TestSessionTracksAndReleasesAPIRefs(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() // Open via a relative path; it should be tracked under the absolute path @@ -230,7 +270,7 @@ func TestUpdateSnapshotResponseSkipsUnloadedAncestorProject(t *testing.T) { assert.Assert(t, ancestorProject != nil) assert.Assert(t, ancestorProject.CommandLine == nil) - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ diff --git a/tsc/internal/api/session_completion_test.go b/tsc/internal/api/session_completion_test.go index c58be89173662..2fd3683b70377 100644 --- a/tsc/internal/api/session_completion_test.go +++ b/tsc/internal/api/session_completion_test.go @@ -38,7 +38,7 @@ func TestCompletionSymbolTypeIsResolvable(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() snapshotResp, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ @@ -110,7 +110,7 @@ func TestCompletionOnInferredProject(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() snapshotResp, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ @@ -156,7 +156,7 @@ func TestCompletionRetriesWithAutoImports(t *testing.T) { IncludeCompletionsForImportStatements: core.TSTrue, }) - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() snapshotResp, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index be053641bef9f..2511bdaf11779 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -21,7 +21,7 @@ func TestCreateProgram(t *testing.T) { }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -46,6 +46,7 @@ func TestCreateProgram(t *testing.T) { }) assert.NilError(t, err) assert.Assert(t, response.Snapshot != baseResponse.Snapshot) + assert.Equal(t, response.Snapshot, SnapshotID(4)) assert.Equal(t, session.latestSnapshot, baseResponse.Snapshot) assert.Assert(t, response.Project != nil) assert.DeepEqual(t, response.Project.RootFiles, []string{fileName}) @@ -118,7 +119,7 @@ func TestCreateProgramWithNoRootFiles(t *testing.T) { projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() response, err := session.handleCreateProgram(context.Background(), &CreateProgramParams{ @@ -138,6 +139,24 @@ func TestCreateProgramWithNoRootFiles(t *testing.T) { assert.Equal(t, len(project.Program.GetSourceFiles()), 0) } +func TestCreateProgramFileChangesRequireOldProgram(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + + session := NewLSPSession(projectSession, nil) + defer session.Close() + + _, err := session.handleCreateProgram(context.Background(), &CreateProgramParams{ + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + FileChanges: &APIFileChanges{InvalidateAll: true}, + }) + assert.ErrorContains(t, err, "fileChanges requires an oldProgram") +} + func TestCreateProgramRemovesAllRootFiles(t *testing.T) { t.Parallel() @@ -147,7 +166,7 @@ func TestCreateProgramRemovesAllRootFiles(t *testing.T) { }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -196,7 +215,7 @@ func TestCreateProgramPreservesRootFileOrder(t *testing.T) { }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -236,7 +255,7 @@ func TestCreateProgramReusesProgram(t *testing.T) { }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -315,7 +334,7 @@ func TestCreateProgramProjectReferencesAndReuse(t *testing.T) { }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() libReference := &core.ProjectReference{Path: libConfigName, OriginalPath: libConfigName} @@ -391,7 +410,7 @@ func TestCreateProgramFromConfiguredProgramDoesNotRetainOtherProjects(t *testing }) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() diff --git a/tsc/internal/api/session_temporary_test.go b/tsc/internal/api/session_temporary_test.go index 829abfe160ae1..c198c4c137ad9 100644 --- a/tsc/internal/api/session_temporary_test.go +++ b/tsc/internal/api/session_temporary_test.go @@ -30,7 +30,7 @@ func TestUpdateTemporarySnapshot(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -114,7 +114,7 @@ func TestUpdateTemporarySnapshotAddsUnopenedFile(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -147,7 +147,7 @@ func TestUpdateTemporarySnapshotRejectsUnsupportedExtension(t *testing.T) { projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() @@ -176,7 +176,7 @@ func TestUpdateTemporarySnapshotUsesClientSnapshotAsBase(t *testing.T) { } projectSession, _ := projecttestutil.Setup(files) defer projectSession.Close() - session := NewSession(projectSession, nil) + session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index 6094f8c1cd372..c1739bb891c4f 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -32,6 +32,7 @@ type AsyncConn struct { pendingMu sync.Mutex terminal error writeMu sync.Mutex + handlers sync.WaitGroup } // NewAsyncConn creates a new async connection with the given transport and handler. @@ -64,7 +65,12 @@ func (c *AsyncConn) SetCollectTiming(enabled bool) { // Run starts processing messages on the connection. // It blocks until the context is cancelled or an error occurs. func (c *AsyncConn) Run(ctx context.Context) (err error) { - defer func() { c.closePendingCalls(err) }() + handlerCtx, cancelHandlers := context.WithCancel(ctx) + defer func() { + c.closePendingCalls(err) + cancelHandlers() + c.handlers.Wait() + }() for { if ctx.Err() != nil { return ctx.Err() @@ -81,9 +87,13 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { if msg.IsResponse() { c.handleResponse(msg) } else if msg.IsRequest() { - go c.handleRequest(ctx, msg) + c.handlers.Go(func() { + c.handleRequest(handlerCtx, msg) + }) } else if msg.IsNotification() { - go c.handleNotification(ctx, msg) + c.handlers.Go(func() { + c.handleNotification(handlerCtx, msg) + }) } } } diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index f5269216a2383..01a68a34d42e5 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/jsonrpc" "gotest.tools/v3/assert" ) @@ -23,6 +24,114 @@ func (noOpHandler) HandleNotification(context.Context, string, json.Value) error return nil } +type queuedProtocol struct { + messages []*ipc.Message +} + +func (p *queuedProtocol) ReadMessage() (*ipc.Message, error) { + if len(p.messages) == 0 { + return nil, io.EOF + } + message := p.messages[0] + p.messages = p.messages[1:] + return message, nil +} + +func (p *queuedProtocol) WriteRequest(*jsonrpc.ID, string, any) error { + return nil +} + +func (p *queuedProtocol) WriteNotification(string, any) error { + return nil +} + +func (p *queuedProtocol) WriteResponse(*jsonrpc.ID, any) error { + return nil +} + +func (p *queuedProtocol) WriteError(*jsonrpc.ID, *jsonrpc.ResponseError) error { + return nil +} + +type blockingHandler struct { + started chan struct{} + release chan struct{} +} + +func (h *blockingHandler) HandleRequest(context.Context, string, json.Value) (any, error) { + h.started <- struct{}{} + <-h.release + return nil, nil +} + +func (h *blockingHandler) HandleNotification(context.Context, string, json.Value) error { + h.started <- struct{}{} + <-h.release + return nil +} + +type contextHandler struct{} + +func (contextHandler) HandleRequest(ctx context.Context, _ string, _ json.Value) (any, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func (contextHandler) HandleNotification(ctx context.Context, _ string, _ json.Value) error { + <-ctx.Done() + return ctx.Err() +} + +func TestAsyncConnRunWaitsForHandlers(t *testing.T) { + t.Parallel() + + id := jsonrpc.NewIDString("1") + protocol := &queuedProtocol{messages: []*ipc.Message{ + {ID: id, Method: "request"}, + {Method: "notification"}, + }} + handler := &blockingHandler{ + started: make(chan struct{}, 2), + release: make(chan struct{}), + } + conn := ipc.NewAsyncConnWithProtocol(nil, protocol, handler) + + runDone := make(chan error, 1) + go func() { runDone <- conn.Run(t.Context()) }() + + <-handler.started + <-handler.started + runReturned := false + select { + case <-runDone: + runReturned = true + default: + runReturned = false + } + assert.Assert(t, !runReturned, "Run returned while handlers were active") + + close(handler.release) + assert.NilError(t, <-runDone) +} + +func TestAsyncConnRunCancelsHandlersOnEOF(t *testing.T) { + t.Parallel() + + id := jsonrpc.NewIDString("1") + protocol := &queuedProtocol{messages: []*ipc.Message{{ID: id, Method: "request"}}} + conn := ipc.NewAsyncConnWithProtocol(nil, protocol, contextHandler{}) + + runDone := make(chan error, 1) + go func() { runDone <- conn.Run(t.Context()) }() + + select { + case err := <-runDone: + assert.NilError(t, err) + case <-time.After(time.Second): + t.Fatal("Run did not cancel active handlers after EOF") + } +} + func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { t.Parallel() client, server := net.Pipe() diff --git a/tsc/internal/locale/locale.go b/tsc/internal/locale/locale.go index 10a8fcf91ef09..305b9b4822c0b 100644 --- a/tsc/internal/locale/locale.go +++ b/tsc/internal/locale/locale.go @@ -28,6 +28,11 @@ func FromContext(ctx context.Context) Locale { return locale } +func HasLocale(ctx context.Context) bool { + _, ok := ctx.Value(contextKey(0)).(Locale) + return ok +} + func Parse(localeStr string) (locale Locale, ok bool) { // Parse gracefully fails. tag, err := language.Parse(localeStr) diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index c850f4a2ad474..f23a7fcadcd3c 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -2288,7 +2288,7 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto } var apiSession *api.Session - apiSession = api.NewSession(s.session, nil) + apiSession = api.NewLSPSession(s.session, nil) // Use provided pipe path or generate a unique one var pipePath string diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 52d7eeb5fcfd3..05c3ca026fc53 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -2,12 +2,6 @@ package project import ( "context" - "fmt" - "maps" - - "github.com/microsoft/TypeScript/tsc/internal/ast" - "github.com/microsoft/TypeScript/tsc/internal/core" - "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" ) // APIUpdate creates a new snapshot incorporating the given file changes and the @@ -33,78 +27,9 @@ func (s *Session) APIUpdate(ctx context.Context, apiFileChanges FileChangeSummar return newSnapshot, newSnapshot.apiError } -// APIUpdateTemporary creates a snapshot that layers a temporary in-memory content -// override for a file on top of baseSnapshot. -// The caller must retain baseSnapshot for the duration of this call. -// An error is returned if the file name does not have a recognized script extension. -// On success, the returned snapshot carries a single reference (the clone ref); -// the caller must call snapshot.Deref(s) when done. -func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri, newText string) (*Snapshot, error) { - path := uri.Path(baseSnapshot.UseCaseSensitiveFileNames()) - - overlays := maps.Clone(baseSnapshot.fs.overlays) - version := int32(0) - var fileChanges FileChangeSummary - existing := overlays[path] - var scriptKind core.ScriptKind - if existing != nil { - version = existing.Version() + 1 - scriptKind = existing.Kind() - fileChanges.Changed.Add(uri) - } else { - scriptKind = core.GetScriptKindFromFileName(uri.FileName()) - if scriptKind == core.ScriptKindUnknown { - return nil, fmt.Errorf("unsupported file extension: %s", uri.FileName()) - } - fileChanges.Opened = uri - } - overlays[path] = newOverlay(uri.FileName(), newText, version, scriptKind) - - newSnapshot := baseSnapshot.Clone(ctx, SnapshotChange{ - fileChanges: fileChanges, - ResourceRequest: ResourceRequest{ - Documents: []lsproto.DocumentUri{uri}, - }, - }, overlays, s) - return newSnapshot, nil -} - -// APICreateProgram creates an isolated snapshot containing one synthetic project. -// Without an old snapshot it starts from the underlying filesystem; otherwise it -// derives from oldSnapshot and applies fileChanges. -func (s *Session) APICreateProgram( - ctx context.Context, - rootFileNames []string, - options *core.CompilerOptions, - projectReferences []*core.ProjectReference, - configFileParsingDiagnostics []*ast.Diagnostic, - oldSnapshot *Snapshot, - oldProject *Project, - fileChanges FileChangeSummary, -) *Snapshot { - if oldSnapshot != nil { - return oldSnapshot.cloneForProgram( - ctx, - rootFileNames, - options, - projectReferences, - configFileParsingDiagnostics, - oldProject, - fileChanges, - s, - ) - } - - snapshot, _ := s.APIUpdate(ctx, fileChanges, nil) - defer snapshot.Deref(s) - return snapshot.cloneForProgram( - ctx, - rootFileNames, - options, - projectReferences, - configFileParsingDiagnostics, - nil, - fileChanges, - s, - ) +// TryAdoptSnapshotInBackground retains a derived snapshot and attempts to adopt it +// as the session's current snapshot without blocking the caller. +func (s *Session) TryAdoptSnapshotInBackground(baseSnapshot, newSnapshot *Snapshot) { + s.RetainSnapshot(newSnapshot) + s.tryAdoptSnapshotChangeInBackground(baseSnapshot, newSnapshot) } diff --git a/tsc/internal/project/compilerhost.go b/tsc/internal/project/compilerhost.go index cba0bebfc5c2a..2865033ddb4e3 100644 --- a/tsc/internal/project/compilerhost.go +++ b/tsc/internal/project/compilerhost.go @@ -116,10 +116,7 @@ func (c *compilerHost) GetContentMappedSourceFiles(parseOptions ast.SourceFilePa if fh == nil { return contentmapper.SourceFiles{}, nil } - diagnosticLocale := locale.Default - if c.builder.client != nil { - diagnosticLocale = c.builder.client.GetLocale() - } + diagnosticLocale := locale.FromContext(c.builder.ctx) c.ensureContentMapperProject() if c.contentMapperProject == nil { return contentmapper.SourceFiles{}, contentmapper.ErrProjectUnavailable diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index f598cf0f0e533..4869337ecd801 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -430,9 +430,13 @@ func TestContentMapperLocaleChange(t *testing.T) { session := project.NewSession(init) defer session.Close() - session.DidOpenFile(context.Background(), "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) - _, err := session.GetLanguageService(context.Background(), "file:///home/project/main.ts") + ctx := locale.WithLocale(context.Background(), locale.Default) + localeReads := len(utils.Client().GetLocaleCalls()) + session.DidOpenFile(ctx, "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(ctx, "file:///home/project/main.ts") assert.NilError(t, err) + // Snapshot adoption reads the current locale for its background work; project construction should not. + assert.Equal(t, len(utils.Client().GetLocaleCalls()), localeReads+1) assert.Equal(t, spawner.spawns.Load(), int32(1)) preferences := session.Config() diff --git a/tsc/internal/project/refcountcache_test.go b/tsc/internal/project/refcountcache_test.go index 0b43a78014ce4..9af1a6eab689d 100644 --- a/tsc/internal/project/refcountcache_test.go +++ b/tsc/internal/project/refcountcache_test.go @@ -468,7 +468,7 @@ func TestRefCountingCaches(t *testing.T) { ResourceRequest: ResourceRequest{ Documents: []lsproto.DocumentUri{uri}, }, - }, baseSnapshot.fs.overlays, session) + }, baseSnapshot.fs.overlays, nil) project := clone.GetDefaultProject(uri) assert.Assert(t, project != nil) @@ -484,7 +484,7 @@ func TestRefCountingCaches(t *testing.T) { assert.Assert(t, ok) assert.Equal(t, len(extendedConfigEntry.owners), 1) - clone.Deref(session) + clone.Deref() _, ok = session.parseCache.entries.Load(mainKey) assert.Assert(t, !ok) @@ -517,21 +517,21 @@ func TestRefCountingCaches(t *testing.T) { OpenProjects: collections.NewSetFromItems(appConfigPath), }) assert.NilError(t, err) - defer baseSnapshot.Deref(session) + defer baseSnapshot.Deref() appProject := baseSnapshot.ProjectCollection.GetProjectByPath(baseSnapshot.toPath(appConfigPath)) assert.Assert(t, appProject != nil) - programSnapshot := session.APICreateProgram( + programSnapshot := session.CloneSnapshotForProgram( ctx, + baseSnapshot, appProject.CommandLine.FileNames(), appProject.CommandLine.CompilerOptions(), appProject.CommandLine.ProjectReferences(), appProject.CommandLine.Errors, - baseSnapshot, appProject, FileChangeSummary{}, ) - defer programSnapshot.Deref(session) + defer programSnapshot.Deref() programProject := programSnapshot.ProjectCollection.InferredProject() assert.Assert(t, programProject != nil) assert.Assert(t, programProject.Program == appProject.Program) @@ -550,17 +550,17 @@ func TestRefCountingCaches(t *testing.T) { assert.NilError(t, session.fs.fs.WriteFile(libBaseConfigPath, `{"compilerOptions":{"composite":true,"noLib":true,"strict":true}}`)) var fileChanges FileChangeSummary fileChanges.Changed.Add(lsproto.DocumentUri("file://" + libBaseConfigPath)) - updatedProgramSnapshot := session.APICreateProgram( + updatedProgramSnapshot := session.CloneSnapshotForProgram( ctx, + programSnapshot, programProject.CommandLine.FileNames(), programProject.CommandLine.CompilerOptions(), programProject.CommandLine.ProjectReferences(), programProject.CommandLine.Errors, - programSnapshot, programProject, fileChanges, ) - defer updatedProgramSnapshot.Deref(session) + defer updatedProgramSnapshot.Deref() updatedProgramProject := updatedProgramSnapshot.ProjectCollection.InferredProject() assert.Assert(t, updatedProgramProject != nil) assert.Assert(t, updatedProgramProject.Program != programProject.Program) diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index f682a0b5bfbce..545287e54af33 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -102,21 +102,18 @@ type SessionInit struct { // next, it diffs them and updates file watchers and Automatic Type // Acquisition (ATA) state accordingly. type Session struct { - backgroundCtx context.Context + *SnapshotHost options *SessionOptions - startTime time.Time + logger logging.Logger + backgroundCtx context.Context toPath func(string) tspath.Path client Client - logger logging.Logger + startTime time.Time npmExecutor ata.NpmExecutor - // contentMapperHost drives configured content mappers for all projects in the session. It is nil unless - // the workspace is trusted (RunExternalCode) and a spawner is available. It is shared so - // projects that use the same mapper share a single process, and is closed when the session ends. - contentMapperHost contentmapper.Host + fs *overlayFS // contentMapperTimings is the cumulative host snapshot at the most recent session snapshot adoption. contentMapperTimings contentmapper.Timings contentMapperTimingsMu sync.Mutex - fs *overlayFS // registeredContentMapperSnapshotID is the ID of the newest snapshot whose registration has been // applied. Registration runs from background tasks that may finish out of order, so @@ -126,18 +123,6 @@ type Session struct { registeredContentMapperSnapshotID uint64 contentMapperRegistrationMu sync.Mutex - // parseCache is the ref-counted cache of source files used when - // creating programs during snapshot cloning. - parseCache *ParseCache - contentMappedParseCache *ContentMappedParseCache - // extendedConfigCache is the ref-counted cache of tsconfig ASTs - // that are used in the "extends" of another tsconfig. - extendedConfigCache *ExtendedConfigCache - // programCounter counts how many snapshots reference a program. - // When a program is no longer referenced, its source files are - // released from the parseCache. - programCounter *programCounter - // read-only after initialization initialUserPreferences lsutil.UserPreferences // current preferences @@ -146,11 +131,6 @@ type Session struct { typingsInstaller *ata.TypingsInstaller backgroundQueue *background.Queue - // snapshotID is the counter for snapshot IDs. It does not necessarily - // equal the `snapshot.ID`. It is stored on Session instead of globally - // so IDs are predictable in tests. - snapshotID atomic.Uint64 - // snapshot is the current immutable state of all projects. snapshot *Snapshot snapshotMu sync.RWMutex @@ -228,68 +208,25 @@ func newContentMapperHost(init *SessionInit) contentmapper.Host { } func NewSession(init *SessionInit) *Session { - currentDirectory := init.Options.CurrentDirectory - useCaseSensitiveFileNames := init.FS.UseCaseSensitiveFileNames() - toPath := func(fileName string) tspath.Path { - return tspath.ToPath(fileName, currentDirectory, useCaseSensitiveFileNames) - } - overlayFS := newOverlayFS(init.FS, make(map[tspath.Path]*Overlay), init.Options.PositionEncoding, toPath) - parseCache := init.ParseCache - if parseCache == nil { - parseCache = NewParseCache(RefCountCacheOptions{}) - } - contentMappedParseCache := init.ContentMappedParseCache - if contentMappedParseCache == nil { - contentMappedParseCache = NewContentMappedParseCache(RefCountCacheOptions{}) - } - extendedConfigCache := NewExtendedConfigCache() - + snapshotHost := NewSnapshotHost(init) sessionLogger := init.Logger if sessionLogger == nil { sessionLogger = logging.NewNopLogger() } session := &Session{ - backgroundCtx: init.BackgroundCtx, - options: init.Options, - toPath: toPath, - client: init.Client, - logger: sessionLogger, - npmExecutor: init.NpmExecutor, - contentMapperHost: newContentMapperHost(init), - fs: overlayFS, - parseCache: parseCache, - contentMappedParseCache: contentMappedParseCache, - extendedConfigCache: extendedConfigCache, - programCounter: &programCounter{}, - backgroundQueue: background.NewQueue(), - startTime: time.Now(), - snapshot: NewSnapshot( - uint64(0), - &SnapshotFS{ - toPath: toPath, - fs: init.FS, - }, - init.Options, - &ConfigFileRegistry{}, - nil, - lsutil.NewDefaultUserPreferences(), - nil, - NewWatchedFiles( - "auto-import", - lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, - lsproto.GetClientCapabilities(init.BackgroundCtx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, - func(nodeModulesDirs map[tspath.Path]string) PatternsAndIgnored { - patterns := make([]string, 0, len(nodeModulesDirs)) - for _, dir := range nodeModulesDirs { - patterns = append(patterns, getRecursiveGlobPattern(dir)) - } - slices.Sort(patterns) - return PatternsAndIgnored{ - patternsInsideWorkspace: patterns, - } - }, - ), - toPath, + SnapshotHost: snapshotHost, + options: init.Options, + logger: sessionLogger, + backgroundCtx: init.BackgroundCtx, + toPath: snapshotHost.toPath, + client: init.Client, + npmExecutor: init.NpmExecutor, + fs: newOverlayFS(snapshotHost.fs, make(map[tspath.Path]*Overlay), init.Options.PositionEncoding, snapshotHost.toPath), + backgroundQueue: background.NewQueue(), + startTime: time.Now(), + snapshot: snapshotHost.newRootSnapshot( + 0, + lsproto.GetClientCapabilities(init.BackgroundCtx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, ), initialUserPreferences: lsutil.NewDefaultUserPreferences(), workspaceUserPreferences: lsutil.NewDefaultUserPreferences(), @@ -303,8 +240,8 @@ func NewSession(init *SessionInit) *Session { ThrottleLimit: 5, }, session) } - if session.contentMapperHost != nil { - session.contentMapperTimings = session.contentMapperHost.Timings() + if snapshotHost.contentMapperHost != nil { + session.contentMapperTimings = snapshotHost.contentMapperHost.Timings() } return session @@ -328,10 +265,10 @@ func (s *Session) Config() lsutil.UserPreferences { } func (s *Session) backgroundContext() context.Context { - return s.withCurrentLocale(s.backgroundCtx) + return s.WithCurrentLocale(s.backgroundCtx) } -func (s *Session) withCurrentLocale(ctx context.Context) context.Context { +func (s *Session) WithCurrentLocale(ctx context.Context) context.Context { if s.client == nil { return ctx } @@ -1168,7 +1105,7 @@ func (s *Session) getSnapshotAndDefaultProject(ctx context.Context, uri lsproto. project := snapshot.GetDefaultProject(uri) if project == nil { if callerRef { - snapshot.Deref(s) + snapshot.Deref() } if file := snapshot.GetFile(uri.FileName()); file != nil && file.Kind() == core.ScriptKindUnknown { return nil, nil, nil, fmt.Errorf("%w: no project found for URI %s", ErrNoProjectForUnknownScriptKind, uri) @@ -1272,7 +1209,7 @@ func (s *Session) WithSnapshotLoadingProjectTree( ResourceRequest{ProjectTree: &ProjectTreeRequest{requestedProjectTrees}}, true, /*callerRef*/ ) - defer snapshot.Deref(s) + defer snapshot.Deref() fn(snapshot) } @@ -1286,7 +1223,7 @@ func (s *Session) WithSnapshotForDocument( ResourceRequest{Documents: []lsproto.DocumentUri{uri}}, true, /*callerRef*/ ) - defer snapshot.Deref(s) + defer snapshot.Deref() fn(snapshot) } @@ -1328,11 +1265,11 @@ func (s *Session) WithLanguageServiceAndSnapshot( } asyncWork, err := fn(languageService, snapshot) if err != nil || asyncWork == nil { - snapshot.Deref(s) + snapshot.Deref() return nil, err } return func() error { - defer snapshot.Deref(s) + defer snapshot.Deref() return asyncWork() }, nil } @@ -1342,46 +1279,20 @@ func (s *Session) WithLanguageServiceAndSnapshot( // The cloned snapshot will be adopted as the session's current snapshot in the background // if other changes haven't been adopted in the meantime. func (s *Session) GetLanguageServiceWithAutoImports(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri) (*ls.LanguageService, error) { - newSnapshot := s.cloneWithAutoImports(ctx, baseSnapshot, uri, false /*callerRef*/) + newSnapshot := s.CloneSnapshotWithAutoImports(ctx, baseSnapshot, uri, s.logger) project := newSnapshot.GetDefaultProject(uri) if project == nil { // Clone's initial ref (1) is released since we won't use this snapshot. - newSnapshot.Deref(s) + newSnapshot.Deref() return nil, fmt.Errorf("no project found for URI %s", uri) } - s.adoptSnapshotChangeInBackground(baseSnapshot, newSnapshot) + s.tryAdoptSnapshotChangeInBackground(baseSnapshot, newSnapshot) return ls.NewLanguageService(project.configFilePath, project.GetProgram(), newSnapshot, uri.FileName()), nil } -// GetSnapshotWithAutoImports clones the given snapshot with auto-import -// preparation for the given URI, without flushing pending file changes. -// The returned snapshot is ref'd for the caller, which must call Deref when done. -// The cloned snapshot will also be adopted as the session's current snapshot in -// the background if other changes haven't been adopted in the meantime. -func (s *Session) GetSnapshotWithAutoImports(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri) *Snapshot { - newSnapshot := s.cloneWithAutoImports(ctx, baseSnapshot, uri, true /*callerRef*/) - s.adoptSnapshotChangeInBackground(baseSnapshot, newSnapshot) - return newSnapshot -} - -func (s *Session) cloneWithAutoImports(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri, callerRef bool) *Snapshot { - change := SnapshotChange{ - reason: UpdateReasonRequestedLanguageServiceWithAutoImports, - ResourceRequest: ResourceRequest{ - Documents: []lsproto.DocumentUri{uri}, - AutoImports: uri, - }, - } - newSnapshot := baseSnapshot.Clone(ctx, change, baseSnapshot.fs.overlays, s) - if callerRef { - newSnapshot.ref() - } - return newSnapshot -} - -func (s *Session) adoptSnapshotChangeInBackground(baseSnapshot, newSnapshot *Snapshot) { +func (s *Session) tryAdoptSnapshotChangeInBackground(baseSnapshot, newSnapshot *Snapshot) { // The clone's initial ref (1) is transferred to adoptSnapshotChange, // which will either promote it as the session's current snapshot or // release it if the session has moved on. @@ -1401,12 +1312,14 @@ func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) { // Session hasn't moved on; adopt the new snapshot. The clone's initial // ref is transferred to become the session's ref for its current snapshot. s.snapshot = newSnapshot - oldSnapshot.Deref(s) + oldSnapshot.Deref() contentMapperTimings := s.takeContentMapperTimingDelta() s.snapshotMu.Unlock() if s.options.LoggingEnabled { s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) - s.logger.Log(newSnapshot.builderLogs.String()) + if newSnapshot.builderLogs != nil { + s.logger.Log(newSnapshot.builderLogs.String()) + } s.logContentMapperTimings(contentMapperTimings) } } else { @@ -1416,13 +1329,15 @@ func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) { s.snapshotMu.Unlock() if s.options.LoggingEnabled { s.logger.Logf("Discarded snapshot %d (parent %d); session has moved on to snapshot %d", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) - if logs := newSnapshot.builderLogs.String(); logs != "" { - s.logger.Logf("--- Discarded snapshot %d builder logs (NOT adopted) ---", newSnapshot.id) - s.logger.Log(logs) - s.logger.Logf("--- End discarded snapshot %d builder logs ---", newSnapshot.id) + if newSnapshot.builderLogs != nil { + if logs := newSnapshot.builderLogs.String(); logs != "" { + s.logger.Logf("--- Discarded snapshot %d builder logs (NOT adopted) ---", newSnapshot.id) + s.logger.Log(logs) + s.logger.Logf("--- End discarded snapshot %d builder logs ---", newSnapshot.id) + } } } - newSnapshot.Deref(s) + newSnapshot.Deref() } } @@ -1433,7 +1348,7 @@ func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]* // updateSnapshotRef is like UpdateSnapshot but returns the created snapshot // with an extra reference for the caller. The ref is taken atomically with // the snapshot assignment under snapshotMu, so the snapshot is guaranteed -// to be alive when returned. The caller must call snapshot.Deref(s) when done. +// to be alive when returned. The caller must call snapshot.Deref() when done. func (s *Session) updateSnapshotRef(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange) *Snapshot { return s.updateSnapshot(ctx, overlays, change, true) } @@ -1441,7 +1356,11 @@ func (s *Session) updateSnapshotRef(ctx context.Context, overlays map[tspath.Pat func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange, callerRef bool) *Snapshot { s.snapshotMu.Lock() oldSnapshot := s.snapshot - newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s) + if !locale.HasLocale(ctx) { + ctx = s.WithCurrentLocale(ctx) + } + change.client = s.client + newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s.logger) s.snapshot = newSnapshot if callerRef { newSnapshot.ref() @@ -1452,7 +1371,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* // clone ref (1) is transferred to become the session's ref for its current // snapshot. Other holders (e.g. active handlers) keep the old snapshot alive // via their own refs until they complete. - oldSnapshot.Deref(s) + oldSnapshot.Deref() contentMapperTimings = s.takeContentMapperTimingDelta() } s.snapshotMu.Unlock() @@ -1467,7 +1386,9 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { if s.options.LoggingEnabled { s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) - s.logger.Log(newSnapshot.builderLogs.String()) + if newSnapshot.builderLogs != nil { + s.logger.Log(newSnapshot.builderLogs.String()) + } s.logProjectChanges(oldSnapshot, newSnapshot) s.logContentMapperTimings(contentMapperTimings) s.logger.Log("") @@ -1761,9 +1682,7 @@ func (s *Session) Close() { // Cancel periodic performance telemetry s.stopPerformanceTelemetry() s.backgroundQueue.Close() - if s.contentMapperHost != nil { - _ = s.contentMapperHost.Close() - } + s.SnapshotHost.Close() } func (s *Session) flushChanges(ctx context.Context) (FileChangeSummary, map[tspath.Path]*Overlay, map[tspath.Path]*ATAStateChange, *lsutil.UserPreferences) { @@ -2007,7 +1926,7 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath if s.Config().EnableValidation.IsFalse() { diagnostics = nil } - ctx = s.withCurrentLocale(ctx) + ctx = s.WithCurrentLocale(ctx) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) @@ -2040,7 +1959,7 @@ func (s *Session) publishGlobalDiagnostics(ctx context.Context) { snapshot := s.snapshot snapshot.ref() s.snapshotMu.RUnlock() - defer snapshot.Deref(s) + defer snapshot.Deref() for _, project := range snapshot.ProjectCollection.Projects() { if project.Kind != KindConfigured || project.checkerPool == nil { @@ -2160,20 +2079,21 @@ func (s *Session) warmAutoImportCache(ctx context.Context, change SnapshotChange if !newSnapshot.tryRef() { return } - defer newSnapshot.Deref(s) + defer newSnapshot.Deref() warmChange := SnapshotChange{ reason: UpdateReasonRequestedLanguageServiceWithAutoImports, + client: s.client, ResourceRequest: ResourceRequest{ Documents: []lsproto.DocumentUri{changedFile}, AutoImports: changedFile, }, } - clonedSnapshot := newSnapshot.Clone(warmCtx, warmChange, newSnapshot.fs.overlays, s) + clonedSnapshot := newSnapshot.Clone(warmCtx, warmChange, newSnapshot.fs.overlays, s.logger) // If cancelled during clone, discard the incomplete result. if warmCtx.Err() != nil { - clonedSnapshot.Deref(s) + clonedSnapshot.Deref() return } diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 97b2961d3f5c8..37800279caeec 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -27,15 +27,12 @@ import ( ) type Snapshot struct { + host *SnapshotHost id uint64 parentId uint64 refCount atomic.Int32 - // Session options are immutable for the server lifetime, - // so can be a pointer. - sessionOptions *SessionOptions - toPath func(fileName string) tspath.Path - converters *lsconv.Converters + converters *lsconv.Converters // Immutable state, cloned between snapshots fs *SnapshotFS @@ -77,35 +74,29 @@ func (s *Snapshot) contentMapperWatchState() ([]string, *collections.Set[tspath. return s.contentMapperExtensions, s.contentMapperWatchedFiles } -// NewSnapshot initializes a snapshot with refCount 1. -// The caller is responsible for calling Deref when done. -func NewSnapshot( +func (host *SnapshotHost) newSnapshot( id uint64, fs *SnapshotFS, - sessionOptions *SessionOptions, configFileRegistry *ConfigFileRegistry, compilerOptionsForInferredProjects *core.CompilerOptions, userPreferences lsutil.UserPreferences, autoImports *autoimport.Registry, autoImportsWatch *WatchedFiles[map[tspath.Path]string], - toPath func(fileName string) tspath.Path, ) *Snapshot { s := &Snapshot{ - id: id, - - sessionOptions: sessionOptions, - toPath: toPath, + host: host, + id: id, fs: fs, ConfigFileRegistry: configFileRegistry, - ProjectCollection: &ProjectCollection{toPath: toPath, openFiles: openFilePaths(fs.overlays)}, + ProjectCollection: &ProjectCollection{toPath: host.toPath, openFiles: openFilePaths(fs.overlays)}, compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, userPreferences: userPreferences, AutoImports: autoImports, autoImportsWatch: autoImportsWatch, } s.refCount.Store(1) - s.converters = lsconv.NewConverters(s.sessionOptions.PositionEncoding, s.LSPLineMap) + s.converters = lsconv.NewConverters(host.options.PositionEncoding, s.LSPLineMap) return s } @@ -119,14 +110,15 @@ func (s *Snapshot) cloneForProgram( configFileParsingDiagnostics []*ast.Diagnostic, oldProject *Project, fileChanges FileChangeSummary, - session *Session, + sessionLogger logging.Logger, ) *Snapshot { + store := s.host var logger *logging.LogTree - if session.options.LoggingEnabled { + if store.options.LoggingEnabled && sessionLogger != nil { defer func() { if r := recover(); r != nil { - session.logger.Log(logger.String()) + sessionLogger.Log(logger.String()) panic(r) } }() @@ -134,10 +126,10 @@ func (s *Snapshot) cloneForProgram( } start := time.Now() - fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, store.options.PositionEncoding, store.toPath) fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) - newSnapshotID := session.snapshotID.Add(1) + newSnapshotID := store.nextSnapshotID() projectCollectionBuilder := newProjectCollectionBuilder( ctx, newSnapshotID, @@ -148,13 +140,13 @@ func (s *Snapshot) cloneForProgram( compilerOptions, s.inferredProjectContentMappers, s.inferredProjectContentMapperExtensions, - s.sessionOptions, + store.options, s.ConfigFileRegistry.customConfigFileName, - session.parseCache, - session.contentMappedParseCache, - session.extendedConfigCache, - session.contentMapperHost, - session.client, + store.parseCache, + store.contentMappedParseCache, + store.extendedConfigCache, + store.contentMapperHost, + nil, ) projectCollectionBuilder.seedInferredProjectForProgram(oldProject, logger) @@ -199,21 +191,19 @@ func (s *Snapshot) cloneForProgram( removedFiles++ return true }) - if session.options.LoggingEnabled { + if logger != nil { logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart)) } snapshotFS, _ := fs.Finalize() - newSnapshot := NewSnapshot( + newSnapshot := store.newSnapshot( newSnapshotID, snapshotFS, - s.sessionOptions, newConfigFileRegistry, compilerOptions, s.userPreferences, nil, nil, - s.toPath, ) newSnapshot.parentId = s.id newSnapshot.ProjectCollection = newProjectCollection @@ -224,7 +214,7 @@ func (s *Snapshot) cloneForProgram( for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { - session.programCounter.Ref(project.Program) + store.programCounter.Ref(project.Program) if project.ProgramLastUpdate == newSnapshotID { project.host.freeze(snapshotFS, newConfigFileRegistry) } @@ -234,7 +224,7 @@ func (s *Snapshot) cloneForProgram( for _, config := range newSnapshot.ConfigFileRegistry.configs { if config.commandLine != nil && config.commandLine.ConfigFile != nil { for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles { - session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id) + store.extendedConfigCache.AddOwner(store.toPath(file), newSnapshot.id) } } } @@ -245,6 +235,39 @@ func (s *Snapshot) cloneForProgram( return newSnapshot } +func (s *Snapshot) cloneWithTemporaryFile( + ctx context.Context, + uri lsproto.DocumentUri, + newText string, +) (*Snapshot, error) { + path := uri.Path(s.UseCaseSensitiveFileNames()) + + overlays := maps.Clone(s.fs.overlays) + version := int32(0) + var fileChanges FileChangeSummary + existing := overlays[path] + var scriptKind core.ScriptKind + if existing != nil { + version = existing.Version() + 1 + scriptKind = existing.Kind() + fileChanges.Changed.Add(uri) + } else { + scriptKind = core.GetScriptKindFromFileName(uri.FileName()) + if scriptKind == core.ScriptKindUnknown { + return nil, fmt.Errorf("unsupported file extension: %s", uri.FileName()) + } + fileChanges.Opened = uri + } + overlays[path] = newOverlay(uri.FileName(), newText, version, scriptKind) + + return s.Clone(ctx, SnapshotChange{ + fileChanges: fileChanges, + ResourceRequest: ResourceRequest{ + Documents: []lsproto.DocumentUri{uri}, + }, + }, overlays, nil), nil +} + func (s *Snapshot) processFileChanges( fs *snapshotFSBuilder, fileChanges FileChangeSummary, @@ -298,7 +321,7 @@ func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project { func (s *Snapshot) GetProjectsContainingFile(uri lsproto.DocumentUri) []ls.Project { fileName := uri.FileName() - path := s.toPath(fileName) + path := s.host.toPath(fileName) // TODO!! sheetal may be change this to handle symlinks!! return s.ProjectCollection.GetProjectsContainingFile(path) } @@ -341,6 +364,10 @@ func (s *Snapshot) ID() uint64 { return s.id } +func (s *Snapshot) toPath(fileName string) tspath.Path { + return s.host.toPath(fileName) +} + func (s *Snapshot) UseCaseSensitiveFileNames() bool { return s.fs.fs.UseCaseSensitiveFileNames() } @@ -430,6 +457,7 @@ type SnapshotChange struct { // ataChanges contains ATA-related changes to apply to projects in the new snapshot. ataChanges map[tspath.Path]*ATAStateChange apiRequest *APISnapshotRequest + client Client // cleanDiskCache triggers cleaning of cached disk files not referenced by any open project. cleanDiskCache bool } @@ -450,21 +478,22 @@ func (s *Snapshot) Clone( ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*Overlay, - session *Session, + sessionLogger logging.Logger, ) *Snapshot { + store := s.host var logger *logging.LogTree // Print in-progress logs immediately if cloning fails - if session.options.LoggingEnabled { + if store.options.LoggingEnabled && sessionLogger != nil { defer func() { if r := recover(); r != nil { - session.logger.Log(logger.String()) + sessionLogger.Log(logger.String()) panic(r) } }() } - if session.options.LoggingEnabled { + if store.options.LoggingEnabled && sessionLogger != nil { logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d", s.id)) getDetails := func() string { details := "" @@ -515,7 +544,7 @@ func (s *Snapshot) Clone( inferredContentMappers = change.contentMapperContributions.Mappers inferredContentMapperExtensions = change.contentMapperContributions.Extensions } - fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fs := newSnapshotFSBuilder(store.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, store.options.PositionEncoding, store.toPath) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects @@ -529,7 +558,7 @@ func (s *Snapshot) Clone( customConfigFileName = change.newConfig.CustomConfigFileName } - newSnapshotID := session.snapshotID.Add(1) + newSnapshotID := store.nextSnapshotID() projectCollectionBuilder := newProjectCollectionBuilder( ctx, newSnapshotID, @@ -540,13 +569,13 @@ func (s *Snapshot) Clone( compilerOptionsForInferredProjects, inferredContentMappers, inferredContentMapperExtensions, - s.sessionOptions, + store.options, customConfigFileName, - session.parseCache, - session.contentMappedParseCache, - session.extendedConfigCache, - session.contentMapperHost, - session.client, + store.parseCache, + store.contentMappedParseCache, + store.extendedConfigCache, + store.contentMapperHost, + change.client, ) if len(change.ataChanges) != 0 { @@ -628,7 +657,7 @@ func (s *Snapshot) Clone( removedFiles++ return true }) - if session.options.LoggingEnabled { + if logger != nil { logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart)) } } @@ -641,10 +670,10 @@ func (s *Snapshot) Clone( autoImportHost := newAutoImportRegistryCloneHost( projectCollection, - session.parseCache, + store.parseCache, fs, - s.sessionOptions.CurrentDirectory, - s.toPath, + store.options.CurrentDirectory, + store.toPath, ) openFiles := make(map[tspath.Path]string, len(overlays)) for path, overlay := range overlays { @@ -656,7 +685,7 @@ func (s *Snapshot) Clone( } oldAutoImports := s.AutoImports if oldAutoImports == nil { - oldAutoImports = autoimport.NewRegistry(s.toPath, s.userPreferences) + oldAutoImports = autoimport.NewRegistry(store.toPath, s.userPreferences) } var autoImportsWatch *WatchedFiles[map[tspath.Path]string] autoImports, err := oldAutoImports.Clone(ctx, autoimport.RegistryChange{ @@ -673,16 +702,14 @@ func (s *Snapshot) Clone( } snapshotFS, _ := fs.Finalize() - newSnapshot := NewSnapshot( + newSnapshot := store.newSnapshot( newSnapshotID, snapshotFS, - s.sessionOptions, nil, compilerOptionsForInferredProjects, config, autoImports, autoImportsWatch, - s.toPath, ) newSnapshot.parentId = s.id newSnapshot.ProjectCollection = projectCollection @@ -694,7 +721,7 @@ func (s *Snapshot) Clone( for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { - session.programCounter.Ref(project.Program) + store.programCounter.Ref(project.Program) if project.ProgramLastUpdate == newSnapshotID { // If the program was updated during this clone, the project and its host are new // and still retain references to the builder. Freezing clears the builder reference @@ -715,7 +742,7 @@ func (s *Snapshot) Clone( for _, config := range newSnapshot.ConfigFileRegistry.configs { if config.commandLine != nil && config.commandLine.ConfigFile != nil { for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles { - session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id) + store.extendedConfigCache.AddOwner(store.toPath(file), newSnapshot.id) } } } @@ -728,8 +755,7 @@ func (s *Snapshot) Clone( // ref increments the snapshot's reference count, preventing it from being // disposed until a corresponding Deref is called. The snapshot must still -// be alive (refCount > 0) when ref is called. Only the project Session -// should call ref(), and it should be done while holding session.snapshotMu. +// be alive (refCount > 0) when ref is called. func (s *Snapshot) ref() { if s.refCount.Add(1) <= 1 { panic(fmt.Sprintf("snapshot %d: ref on disposed snapshot, parentId=%d", s.id, s.parentId)) @@ -752,20 +778,21 @@ func (s *Snapshot) tryRef() bool { } // Deref decrements the snapshot's reference count. When the count reaches -// zero, the snapshot is disposed and its resources are released. -func (s *Snapshot) Deref(session *Session) { +// zero, the snapshot is disposed and its store-owned resources are released. +func (s *Snapshot) Deref() { rc := s.refCount.Add(-1) if rc < 0 { panic(fmt.Sprintf("snapshot %d: ref count below zero, parentId=%d", s.id, s.parentId)) } if rc == 0 { - s.dispose(session) + s.dispose() } } -func (s *Snapshot) dispose(session *Session) { +func (s *Snapshot) dispose() { + store := s.host for _, project := range s.ProjectCollection.Projects() { - if project.Program != nil && session.programCounter.Deref(project.Program) { + if project.Program != nil && store.programCounter.Deref(project.Program) { if contentMapperProject := project.Program.ContentMapperProject(); contentMapperProject != nil { _ = contentMapperProject.Close() } @@ -779,18 +806,18 @@ func (s *Snapshot) dispose(session *Session) { for _, file := range project.Program.SourceFiles() { if !file.IsContentMapperFailureStub() && !file.IsContentMapperSupplemental() { if file.ContentMapper() != "" { - session.contentMappedParseCache.Deref(contentMappedParseCacheKeyForFile(file)) + store.contentMappedParseCache.Deref(contentMappedParseCacheKeyForFile(file)) } else { - session.parseCache.Deref(parseCacheKeyForFile(file)) + store.parseCache.Deref(parseCacheKeyForFile(file)) } } } for _, file := range project.Program.DuplicateSourceFiles() { if !file.IsContentMapperFailureStub { if file.ContentMapper != "" { - session.contentMappedParseCache.Deref(contentMappedParseCacheKeyForDuplicate(file)) + store.contentMappedParseCache.Deref(contentMappedParseCacheKeyForDuplicate(file)) } else { - session.parseCache.Deref(parseCacheKeyForDuplicate(file)) + store.parseCache.Deref(parseCacheKeyForDuplicate(file)) } } } @@ -799,7 +826,7 @@ func (s *Snapshot) dispose(session *Session) { for _, config := range s.ConfigFileRegistry.configs { if config.commandLine != nil { for _, file := range config.commandLine.ExtendedSourceFiles() { - session.extendedConfigCache.Release(session.toPath(file), s.id) + store.extendedConfigCache.Release(store.toPath(file), s.id) } } } diff --git a/tsc/internal/project/snapshot_test.go b/tsc/internal/project/snapshot_test.go index 0002a086bef49..e1782e138c0d9 100644 --- a/tsc/internal/project/snapshot_test.go +++ b/tsc/internal/project/snapshot_test.go @@ -35,6 +35,20 @@ func TestSnapshot(t *testing.T) { return session } + t.Run("temporary file can be added to an empty root snapshot", func(t *testing.T) { + t.Parallel() + session := setup(map[string]any{}) + defer session.Close() + + baseSnapshot := session.Snapshot() + uri := lsproto.DocumentUri("file:///temporary.ts") + snapshot, err := session.CloneSnapshotWithTemporaryFile(context.Background(), baseSnapshot, uri, "export const value = 1;") + assert.NilError(t, err) + defer snapshot.Deref() + + assert.Equal(t, snapshot.GetFile(uri.FileName()).Content(), "export const value = 1;") + }) + t.Run("compilerHost gets frozen with snapshot's FS only once", func(t *testing.T) { t.Parallel() files := map[string]any{ @@ -228,8 +242,9 @@ func TestSnapshot(t *testing.T) { assert.NilError(t, err) baseSnapshot := session.Snapshot() - preparedSnapshot := session.GetSnapshotWithAutoImports(ctx, baseSnapshot, uri) - defer preparedSnapshot.Deref(session) + preparedSnapshot := session.SnapshotHost.CloneSnapshotWithAutoImports(ctx, baseSnapshot, uri, nil) + session.TryAdoptSnapshotInBackground(baseSnapshot, preparedSnapshot) + defer preparedSnapshot.Deref() session.WaitForBackgroundTasks() assert.Equal(t, session.Snapshot(), preparedSnapshot) diff --git a/tsc/internal/project/snapshothost.go b/tsc/internal/project/snapshothost.go new file mode 100644 index 0000000000000..2a3f068f1f37c --- /dev/null +++ b/tsc/internal/project/snapshothost.go @@ -0,0 +1,184 @@ +package project + +import ( + "context" + "slices" + "sync/atomic" + + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/contentmapper" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project/logging" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// SnapshotHost owns the services shared by a collection of immutable snapshots. +type SnapshotHost struct { + options *SessionOptions + toPath func(string) tspath.Path + fs vfs.FS + + parseCache *ParseCache + contentMappedParseCache *ContentMappedParseCache + extendedConfigCache *ExtendedConfigCache + programCounter *programCounter + contentMapperHost contentmapper.Host + + snapshotID atomic.Uint64 +} + +func (s *SnapshotHost) nextSnapshotID() uint64 { + return s.snapshotID.Add(1) +} + +func NewSnapshotHost(init *SessionInit) *SnapshotHost { + currentDirectory := init.Options.CurrentDirectory + useCaseSensitiveFileNames := init.FS.UseCaseSensitiveFileNames() + toPath := func(fileName string) tspath.Path { + return tspath.ToPath(fileName, currentDirectory, useCaseSensitiveFileNames) + } + parseCache := init.ParseCache + if parseCache == nil { + parseCache = NewParseCache(RefCountCacheOptions{}) + } + contentMappedParseCache := init.ContentMappedParseCache + if contentMappedParseCache == nil { + contentMappedParseCache = NewContentMappedParseCache(RefCountCacheOptions{}) + } + + return &SnapshotHost{ + options: init.Options, + toPath: toPath, + fs: init.FS, + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: NewExtendedConfigCache(), + programCounter: &programCounter{}, + contentMapperHost: newContentMapperHost(init), + } +} + +// NewStandaloneRootSnapshot creates the compatibility root for a standalone API session. +func (s *SnapshotHost) NewStandaloneRootSnapshot() *Snapshot { + return s.newRootSnapshot(0, false) +} + +// RetainSnapshot adds a reference to a snapshot owned by this host. +func (s *SnapshotHost) RetainSnapshot(snapshot *Snapshot) { + snapshot.ref() +} + +// CloneSnapshot derives a snapshot from baseSnapshot without adopting it as any +// canonical session state or performing session side effects. +func (s *SnapshotHost) CloneSnapshot( + ctx context.Context, + baseSnapshot *Snapshot, + fileChanges FileChangeSummary, + apiRequest *APISnapshotRequest, +) (*Snapshot, error) { + snapshot := s.update(ctx, baseSnapshot, SnapshotChange{ + apiRequest: apiRequest, + fileChanges: fileChanges, + }) + return snapshot, snapshot.apiError +} + +// update derives a snapshot from baseSnapshot without adopting it as any +// canonical session state or performing session side effects. +func (s *SnapshotHost) update(ctx context.Context, baseSnapshot *Snapshot, change SnapshotChange) *Snapshot { + return baseSnapshot.Clone(ctx, change, baseSnapshot.fs.overlays, nil) +} + +// CloneSnapshotWithTemporaryFile derives a snapshot with a temporary file content override. +func (s *SnapshotHost) CloneSnapshotWithTemporaryFile( + ctx context.Context, + baseSnapshot *Snapshot, + uri lsproto.DocumentUri, + newText string, +) (*Snapshot, error) { + return baseSnapshot.cloneWithTemporaryFile(ctx, uri, newText) +} + +// CloneSnapshotForProgram derives an isolated snapshot containing one synthetic +// project. The base snapshot is not adopted as canonical state. +func (s *SnapshotHost) CloneSnapshotForProgram( + ctx context.Context, + baseSnapshot *Snapshot, + rootFileNames []string, + options *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + oldProject *Project, + fileChanges FileChangeSummary, +) *Snapshot { + return baseSnapshot.cloneForProgram( + ctx, + rootFileNames, + options, + projectReferences, + configFileParsingDiagnostics, + oldProject, + fileChanges, + nil, + ) +} + +// CloneSnapshotWithAutoImports derives a snapshot with auto-import preparation without +// adopting the clone in the background. +func (s *SnapshotHost) CloneSnapshotWithAutoImports(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri, logger logging.Logger) *Snapshot { + change := SnapshotChange{ + reason: UpdateReasonRequestedLanguageServiceWithAutoImports, + ResourceRequest: ResourceRequest{ + Documents: []lsproto.DocumentUri{uri}, + AutoImports: uri, + }, + } + return baseSnapshot.Clone(ctx, change, baseSnapshot.fs.overlays, logger) +} + +func (s *SnapshotHost) newRootSnapshot(id uint64, relativePatternSupport bool) *Snapshot { + return s.newSnapshot( + id, + &SnapshotFS{ + toPath: s.toPath, + fs: s.fs, + overlays: make(map[tspath.Path]*Overlay), + }, + &ConfigFileRegistry{}, + nil, + lsutil.NewDefaultUserPreferences(), + nil, + NewWatchedFiles( + "auto-import", + lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, + relativePatternSupport, + func(nodeModulesDirs map[tspath.Path]string) PatternsAndIgnored { + patterns := make([]string, 0, len(nodeModulesDirs)) + for _, dir := range nodeModulesDirs { + patterns = append(patterns, getRecursiveGlobPattern(dir)) + } + slices.Sort(patterns) + return PatternsAndIgnored{ + patternsInsideWorkspace: patterns, + } + }, + ), + ) +} + +func (s *SnapshotHost) FS() vfs.FS { + return s.fs +} + +func (s *SnapshotHost) GetCurrentDirectory() string { + return s.options.CurrentDirectory +} + +func (s *SnapshotHost) Close() { + if s.contentMapperHost != nil { + _ = s.contentMapperHost.Close() + } +}