package project import ( "context" "fmt" "slices" "strings" "sync" "sync/atomic" "time" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/ast" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/compiler" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/core" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/ls" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/lsp/lsproto" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/ata" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/background" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/logging" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs" ) type UpdateReason int const ( UpdateReasonUnknown UpdateReason = iota UpdateReasonDidOpenFile UpdateReasonDidChangeCompilerOptionsForInferredProjects UpdateReasonRequestedLanguageServicePendingChanges UpdateReasonRequestedLanguageServiceProjectNotLoaded UpdateReasonRequestedLanguageServiceProjectDirty ) // SessionOptions are the immutable initialization options for a session. // Snapshots may reference them as a pointer since they never change. type SessionOptions struct { CurrentDirectory string DefaultLibraryPath string TypingsLocation string PositionEncoding lsproto.PositionEncodingKind WatchEnabled bool LoggingEnabled bool DebounceDelay time.Duration } type SessionInit struct { Options *SessionOptions FS vfs.FS Client Client Logger logging.Logger NpmExecutor ata.NpmExecutor ParseCache *ParseCache } // Session manages the state of an LSP session. It receives textDocument // events and requests for LanguageService objects from the LPS server // and processes them into immutable snapshots as the data source for // LanguageServices. When Session transitions from one snapshot to the // next, it diffs them and updates file watchers and Automatic Type // Acquisition (ATA) state accordingly. type Session struct { options *SessionOptions toPath func(string) tspath.Path client Client logger logging.Logger npmExecutor ata.NpmExecutor fs *overlayFS // parseCache is the ref-counted cache of source files used when // creating programs during snapshot cloning. parseCache *ParseCache // 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 compilerOptionsForInferredProjects *core.CompilerOptions 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 // pendingFileChanges are accumulated from textDocument/* events delivered // by the LSP server through DidOpenFile(), DidChangeFile(), etc. They are // applied to the next snapshot update. pendingFileChanges []FileChange pendingFileChangesMu sync.Mutex // pendingATAChanges are produced by Automatic Type Acquisition (ATA) // installations and applied to the next snapshot update. pendingATAChanges map[tspath.Path]*ATAStateChange pendingATAChangesMu sync.Mutex // diagnosticsRefreshCancel is the cancelation function for a scheduled // diagnostics refresh. Diagnostics refreshes are scheduled and debounced // after file watch changes and ATA updates. diagnosticsRefreshCancel context.CancelFunc diagnosticsRefreshMu sync.Mutex // watches tracks the current watch globs and how many individual WatchedFiles // are using each glob. watches map[fileSystemWatcherKey]*fileSystemWatcherValue watchesMu sync.Mutex } 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 = &ParseCache{} } extendedConfigCache := &extendedConfigCache{} session := &Session{ options: init.Options, toPath: toPath, client: init.Client, logger: init.Logger, npmExecutor: init.NpmExecutor, fs: overlayFS, parseCache: parseCache, extendedConfigCache: extendedConfigCache, programCounter: &programCounter{}, backgroundQueue: background.NewQueue(), snapshotID: atomic.Uint64{}, snapshot: NewSnapshot( uint64(0), &snapshotFS{ toPath: toPath, fs: init.FS, }, init.Options, parseCache, extendedConfigCache, &ConfigFileRegistry{}, nil, toPath, ), pendingATAChanges: make(map[tspath.Path]*ATAStateChange), watches: make(map[fileSystemWatcherKey]*fileSystemWatcherValue), } if init.Options.TypingsLocation != "" && init.NpmExecutor != nil { session.typingsInstaller = ata.NewTypingsInstaller(&ata.TypingsInstallerOptions{ TypingsLocation: init.Options.TypingsLocation, ThrottleLimit: 5, }, session) } return session } // FS implements module.ResolutionHost func (s *Session) FS() vfs.FS { return s.fs.fs } // GetCurrentDirectory implements module.ResolutionHost func (s *Session) GetCurrentDirectory() string { return s.options.CurrentDirectory } // Trace implements module.ResolutionHost func (s *Session) Trace(msg string) { panic("ATA module resolution should not use tracing") } func (s *Session) DidOpenFile(ctx context.Context, uri lsproto.DocumentUri, version int32, content string, languageKind lsproto.LanguageKind) { s.cancelDiagnosticsRefresh() s.pendingFileChangesMu.Lock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindOpen, URI: uri, Version: version, Content: content, LanguageKind: languageKind, }) changes, overlays := s.flushChangesLocked(ctx) s.pendingFileChangesMu.Unlock() s.UpdateSnapshot(ctx, overlays, SnapshotChange{ reason: UpdateReasonDidOpenFile, fileChanges: changes, requestedURIs: []lsproto.DocumentUri{uri}, }) } func (s *Session) DidCloseFile(ctx context.Context, uri lsproto.DocumentUri) { s.cancelDiagnosticsRefresh() s.pendingFileChangesMu.Lock() defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindClose, URI: uri, Hash: s.fs.getFile(uri.FileName()).Hash(), }) } func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, version int32, changes []lsproto.TextDocumentContentChangePartialOrWholeDocument) { s.cancelDiagnosticsRefresh() s.pendingFileChangesMu.Lock() defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindChange, URI: uri, Version: version, Changes: changes, }) } func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) { s.cancelDiagnosticsRefresh() s.pendingFileChangesMu.Lock() defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindSave, URI: uri, }) } func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.FileEvent) { fileChanges := make([]FileChange, 0, len(changes)) for _, change := range changes { var kind FileChangeKind switch change.Type { case lsproto.FileChangeTypeCreated: kind = FileChangeKindWatchCreate case lsproto.FileChangeTypeChanged: kind = FileChangeKindWatchChange case lsproto.FileChangeTypeDeleted: kind = FileChangeKindWatchDelete default: continue // Ignore unknown change types. } fileChanges = append(fileChanges, FileChange{ Kind: kind, URI: change.Uri, }) } s.pendingFileChangesMu.Lock() s.pendingFileChanges = append(s.pendingFileChanges, fileChanges...) s.pendingFileChangesMu.Unlock() // Schedule a debounced diagnostics refresh s.ScheduleDiagnosticsRefresh() } func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) { s.compilerOptionsForInferredProjects = options s.UpdateSnapshot(ctx, s.fs.Overlays(), SnapshotChange{ reason: UpdateReasonDidChangeCompilerOptionsForInferredProjects, compilerOptionsForInferredProjects: options, }) } func (s *Session) ScheduleDiagnosticsRefresh() { s.diagnosticsRefreshMu.Lock() defer s.diagnosticsRefreshMu.Unlock() // Cancel any existing scheduled diagnostics refresh if s.diagnosticsRefreshCancel != nil { s.diagnosticsRefreshCancel() s.logger.Log("Delaying scheduled diagnostics refresh...") } else { s.logger.Log("Scheduling new diagnostics refresh...") } // Create a new cancellable context for the debounce task debounceCtx, cancel := context.WithCancel(context.Background()) s.diagnosticsRefreshCancel = cancel // Enqueue the debounced diagnostics refresh s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) { // Sleep for the debounce delay select { case <-time.After(s.options.DebounceDelay): // Delay completed, proceed with refresh case <-ctx.Done(): // Context was cancelled, newer events arrived return } // Clear the cancel function since we're about to execute the refresh s.diagnosticsRefreshMu.Lock() s.diagnosticsRefreshCancel = nil s.diagnosticsRefreshMu.Unlock() if s.options.LoggingEnabled { s.logger.Log("Running scheduled diagnostics refresh") } if err := s.client.RefreshDiagnostics(context.Background()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing diagnostics: %v", err) } }) } func (s *Session) cancelDiagnosticsRefresh() { s.diagnosticsRefreshMu.Lock() defer s.diagnosticsRefreshMu.Unlock() if s.diagnosticsRefreshCancel != nil { s.diagnosticsRefreshCancel() s.logger.Log("Canceled scheduled diagnostics refresh") s.diagnosticsRefreshCancel = nil } } func (s *Session) Snapshot() (*Snapshot, func()) { s.snapshotMu.RLock() defer s.snapshotMu.RUnlock() snapshot := s.snapshot snapshot.Ref() return snapshot, func() { if snapshot.Deref() { // The session itself accounts for one reference to the snapshot, and it derefs // in UpdateSnapshot while holding the snapshotMu lock, so the only way to end // up here is for an external caller to release the snapshot after the session // has already dereferenced it and moved to a new snapshot. In other words, we // can assume that `snapshot != s.snapshot`, and therefor there's no way for // anyone else to acquire a reference to this snapshot again. snapshot.dispose(s) } } } func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUri) (*ls.LanguageService, error) { var snapshot *Snapshot fileChanges, overlays, ataChanges := s.flushChanges(ctx) updateSnapshot := !fileChanges.IsEmpty() || len(ataChanges) > 0 if updateSnapshot { // If there are pending file changes, we need to update the snapshot. // Sending the requested URI ensures that the project for this URI is loaded. snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{ reason: UpdateReasonRequestedLanguageServicePendingChanges, fileChanges: fileChanges, ataChanges: ataChanges, requestedURIs: []lsproto.DocumentUri{uri}, }) } else { // If there are no pending file changes, we can try to use the current snapshot. s.snapshotMu.RLock() snapshot = s.snapshot s.snapshotMu.RUnlock() } project := snapshot.GetDefaultProject(uri) if project == nil && !updateSnapshot || project != nil && project.dirty { // The current snapshot does not have an up to date project for the URI, // so we need to update the snapshot to ensure the project is loaded. // !!! Allow multiple projects to update in parallel snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{ reason: core.IfElse(project == nil, UpdateReasonRequestedLanguageServiceProjectNotLoaded, UpdateReasonRequestedLanguageServiceProjectDirty), requestedURIs: []lsproto.DocumentUri{uri}, }) project = snapshot.GetDefaultProject(uri) } if project == nil { return nil, fmt.Errorf("no project found for URI %s", uri) } return ls.NewLanguageService(project.GetProgram(), snapshot), nil } func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*overlay, change SnapshotChange) *Snapshot { s.snapshotMu.Lock() oldSnapshot := s.snapshot newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s) s.snapshot = newSnapshot s.snapshotMu.Unlock() shouldDispose := newSnapshot != oldSnapshot && oldSnapshot.Deref() if shouldDispose { oldSnapshot.dispose(s) } // Enqueue ATA updates if needed if s.typingsInstaller != nil { s.triggerATAForUpdatedProjects(newSnapshot) } // Enqueue logging, watch updates, and diagnostic refresh tasks s.backgroundQueue.Enqueue(context.Background(), func(ctx context.Context) { if s.options.LoggingEnabled { s.logger.Write(newSnapshot.builderLogs.String()) s.logProjectChanges(oldSnapshot, newSnapshot) s.logger.Write("") } if s.options.WatchEnabled { if err := s.updateWatches(oldSnapshot, newSnapshot); err != nil && s.options.LoggingEnabled { s.logger.Log(err) } } }) return newSnapshot } // WaitForBackgroundTasks waits for all background tasks to complete. // This is intended to be used only for testing purposes. func (s *Session) WaitForBackgroundTasks() { s.backgroundQueue.Wait() } func updateWatch[T any](ctx context.Context, session *Session, logger logging.Logger, oldWatcher, newWatcher *WatchedFiles[T]) []error { var errors []error session.watchesMu.Lock() defer session.watchesMu.Unlock() if newWatcher != nil { if id, watchers, ignored := newWatcher.Watchers(); len(watchers) > 0 { var newWatchers collections.OrderedMap[WatcherID, *lsproto.FileSystemWatcher] for i, watcher := range watchers { key := toFileSystemWatcherKey(watcher) value := session.watches[key] globId := WatcherID(fmt.Sprintf("%s.%d", id, i)) if value == nil { value = &fileSystemWatcherValue{id: globId} session.watches[key] = value } value.count++ if value.count == 1 { newWatchers.Set(globId, watcher) } } for id, watcher := range newWatchers.Entries() { if err := session.client.WatchFiles(ctx, id, []*lsproto.FileSystemWatcher{watcher}); err != nil { errors = append(errors, err) } else if logger != nil { if oldWatcher == nil { logger.Log(fmt.Sprintf("Added new watch: %s", id)) } else { logger.Log(fmt.Sprintf("Updated watch: %s", id)) } logger.Log("\t" + *watcher.GlobPattern.Pattern) logger.Log("") } } if len(ignored) > 0 { logger.Logf("%d paths ineligible for watching", len(ignored)) if logger.IsVerbose() { for path := range ignored { logger.Log("\t" + path) } } } } } if oldWatcher != nil { if _, watchers, _ := oldWatcher.Watchers(); len(watchers) > 0 { var removedWatchers []WatcherID for _, watcher := range watchers { key := toFileSystemWatcherKey(watcher) value := session.watches[key] if value == nil { continue } if value.count <= 1 { delete(session.watches, key) removedWatchers = append(removedWatchers, value.id) } else { value.count-- } } for _, id := range removedWatchers { if err := session.client.UnwatchFiles(ctx, id); err != nil { errors = append(errors, err) } else if logger != nil && newWatcher == nil { logger.Log(fmt.Sprintf("Removed watch: %s", id)) } } } } return errors } func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error { var errors []error start := time.Now() ctx := context.Background() core.DiffMapsFunc( oldSnapshot.ConfigFileRegistry.configs, newSnapshot.ConfigFileRegistry.configs, func(a, b *configFileEntry) bool { return a.rootFilesWatch.ID() == b.rootFilesWatch.ID() }, func(_ tspath.Path, addedEntry *configFileEntry) { errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedEntry.rootFilesWatch)...) }, func(_ tspath.Path, removedEntry *configFileEntry) { errors = append(errors, updateWatch(ctx, s, s.logger, removedEntry.rootFilesWatch, nil)...) }, func(_ tspath.Path, oldEntry, newEntry *configFileEntry) { errors = append(errors, updateWatch(ctx, s, s.logger, oldEntry.rootFilesWatch, newEntry.rootFilesWatch)...) }, ) collections.DiffOrderedMaps( oldSnapshot.ProjectCollection.ProjectsByPath(), newSnapshot.ProjectCollection.ProjectsByPath(), func(_ tspath.Path, addedProject *Project) { errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.programFilesWatch)...) errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.affectingLocationsWatch)...) errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.failedLookupsWatch)...) errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.typingsWatch)...) }, func(_ tspath.Path, removedProject *Project) { errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.programFilesWatch, nil)...) errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.affectingLocationsWatch, nil)...) errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.failedLookupsWatch, nil)...) errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.typingsWatch, nil)...) }, func(_ tspath.Path, oldProject, newProject *Project) { if oldProject.programFilesWatch.ID() != newProject.programFilesWatch.ID() { errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.programFilesWatch, newProject.programFilesWatch)...) } if oldProject.affectingLocationsWatch.ID() != newProject.affectingLocationsWatch.ID() { errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.affectingLocationsWatch, newProject.affectingLocationsWatch)...) } if oldProject.failedLookupsWatch.ID() != newProject.failedLookupsWatch.ID() { errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.failedLookupsWatch, newProject.failedLookupsWatch)...) } if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.typingsWatch, newProject.typingsWatch)...) } }, ) if len(errors) > 0 { return fmt.Errorf("errors updating watches: %v", errors) } else if s.options.LoggingEnabled { s.logger.Log(fmt.Sprintf("Updated watches in %v", time.Since(start))) } return nil } func (s *Session) Close() { // Cancel any pending diagnostics refresh s.cancelDiagnosticsRefresh() s.backgroundQueue.Close() } func (s *Session) flushChanges(ctx context.Context) (FileChangeSummary, map[tspath.Path]*overlay, map[tspath.Path]*ATAStateChange) { s.pendingFileChangesMu.Lock() defer s.pendingFileChangesMu.Unlock() s.pendingATAChangesMu.Lock() defer s.pendingATAChangesMu.Unlock() pendingATAChanges := s.pendingATAChanges s.pendingATAChanges = make(map[tspath.Path]*ATAStateChange) fileChanges, overlays := s.flushChangesLocked(ctx) return fileChanges, overlays, pendingATAChanges } // flushChangesLocked should only be called with s.pendingFileChangesMu held. func (s *Session) flushChangesLocked(ctx context.Context) (FileChangeSummary, map[tspath.Path]*overlay) { if len(s.pendingFileChanges) == 0 { return FileChangeSummary{}, s.fs.Overlays() } start := time.Now() changes, overlays := s.fs.processChanges(s.pendingFileChanges) if s.options.LoggingEnabled { s.logger.Log(fmt.Sprintf("Processed %d file changes in %v", len(s.pendingFileChanges), time.Since(start))) } s.pendingFileChanges = nil return changes, overlays } // logProjectChanges logs information about projects that have changed between snapshots func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot) { var loggedProjectChanges bool logProject := func(project *Project) { var builder strings.Builder project.print(s.logger.IsVerbose() /*writeFileNames*/, s.logger.IsVerbose() /*writeFileExplanation*/, &builder) s.logger.Log(builder.String()) loggedProjectChanges = true } collections.DiffOrderedMaps( oldSnapshot.ProjectCollection.ProjectsByPath(), newSnapshot.ProjectCollection.ProjectsByPath(), func(path tspath.Path, addedProject *Project) { // New project added logProject(addedProject) }, func(path tspath.Path, removedProject *Project) { // Project removed s.logger.Logf("\nProject '%s' removed\n%s", removedProject.Name(), hr) }, func(path tspath.Path, oldProject, newProject *Project) { // Project updated if newProject.ProgramUpdateKind == ProgramUpdateKindNewFiles { logProject(newProject) } }, ) if loggedProjectChanges || s.logger.IsVerbose() { s.logCacheStats(newSnapshot) } } func (s *Session) logCacheStats(snapshot *Snapshot) { var parseCacheSize int var programCount int var extendedConfigCount int if s.logger.IsVerbose() { s.parseCache.entries.Range(func(_ parseCacheKey, _ *parseCacheEntry) bool { parseCacheSize++ return true }) s.programCounter.refs.Range(func(_ *compiler.Program, _ *atomic.Int32) bool { programCount++ return true }) s.extendedConfigCache.entries.Range(func(_ tspath.Path, _ *extendedConfigCacheEntry) bool { extendedConfigCount++ return true }) } s.logger.Write("\n======== Cache Statistics ========") s.logger.Logf("Open file count: %6d", len(snapshot.fs.overlays)) s.logger.Logf("Cached disk files: %6d", len(snapshot.fs.diskFiles)) s.logger.Logf("Project count: %6d", len(snapshot.ProjectCollection.Projects())) s.logger.Logf("Config count: %6d", len(snapshot.ConfigFileRegistry.configs)) if s.logger.IsVerbose() { s.logger.Logf("Parse cache size: %6d", parseCacheSize) s.logger.Logf("Program count: %6d", programCount) s.logger.Logf("Extended config cache size: %6d", extendedConfigCount) } } func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { return s.npmExecutor.NpmInstall(cwd, npmInstallArgs) } func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { s.backgroundQueue.Enqueue(context.Background(), func(ctx context.Context) { var logTree *logging.LogTree if s.options.LoggingEnabled { logTree = logging.NewLogTree("Triggering ATA for project " + project.Name()) } typingsInfo := project.ComputeTypingsInfo() request := &ata.TypingsInstallRequest{ ProjectID: project.configFilePath, TypingsInfo: &typingsInfo, FileNames: core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }), ProjectRootPath: project.currentDirectory, CompilerOptions: project.CommandLine.CompilerOptions(), CurrentDirectory: s.options.CurrentDirectory, GetScriptKind: core.GetScriptKindFromFileName, FS: s.fs.fs, Logger: logTree, } if result, err := s.typingsInstaller.InstallTypings(request); err != nil && logTree != nil { s.logger.Log(fmt.Sprintf("ATA installation failed for project %s: %v", project.Name(), err)) s.logger.Log(logTree.String()) } else { if !slices.Equal(result.TypingsFiles, project.typingsFiles) { s.pendingATAChangesMu.Lock() defer s.pendingATAChangesMu.Unlock() s.pendingATAChanges[project.configFilePath] = &ATAStateChange{ TypingsInfo: &typingsInfo, TypingsFiles: result.TypingsFiles, TypingsFilesToWatch: result.FilesToWatch, Logs: logTree, } s.ScheduleDiagnosticsRefresh() } } }) } } }