421 lines
20 KiB
Go
421 lines
20 KiB
Go
package project
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/microsoft/typescript-go/internal/ast"
|
|
"github.com/microsoft/typescript-go/internal/bundled"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
|
|
"gotest.tools/v3/assert"
|
|
)
|
|
|
|
func TestRefCountingCaches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !bundled.Embedded {
|
|
t.Skip("bundled files are not embedded")
|
|
}
|
|
|
|
setup := func(files map[string]any) *Session {
|
|
fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/))
|
|
session := NewSession(&SessionInit{
|
|
BackgroundCtx: context.Background(),
|
|
Options: &SessionOptions{
|
|
CurrentDirectory: "/",
|
|
DefaultLibraryPath: bundled.LibPath(),
|
|
TypingsLocation: "/home/src/Library/Caches/typescript",
|
|
PositionEncoding: lsproto.PositionEncodingKindUTF8,
|
|
WatchEnabled: false,
|
|
LoggingEnabled: false,
|
|
},
|
|
FS: fs,
|
|
})
|
|
return session
|
|
}
|
|
|
|
t.Run("parseCache", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := map[string]any{
|
|
"/user/username/projects/myproject/src/main.ts": "const x = 1;",
|
|
"/user/username/projects/myproject/src/utils.ts": "export function util() {}",
|
|
}
|
|
|
|
t.Run("reuse unchanged file", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := setup(files)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 1, files["/user/username/projects/myproject/src/utils.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
snapshot := session.Snapshot()
|
|
program := snapshot.ProjectCollection.InferredProject().Program
|
|
main := program.GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
utils := program.GetSourceFile("/user/username/projects/myproject/src/utils.ts")
|
|
mainEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
utilsEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(utils.ParseOptions(), utils.Hash, utils.ScriptKind))
|
|
assert.Equal(t, mainEntry.refCount, 1)
|
|
assert.Equal(t, utilsEntry.refCount, 1)
|
|
|
|
session.DidChangeFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{
|
|
Partial: &lsproto.TextDocumentContentChangePartial{
|
|
Range: lsproto.Range{
|
|
Start: lsproto.Position{Line: 0, Character: 0},
|
|
End: lsproto.Position{Line: 0, Character: 12},
|
|
},
|
|
Text: "const x = 2;",
|
|
},
|
|
},
|
|
})
|
|
ls, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/myproject/src/main.ts")
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
newMain := ls.GetProgram().GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
newMainEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(newMain.ParseOptions(), newMain.Hash, newMain.ScriptKind))
|
|
assert.Assert(t, newMain != main)
|
|
assert.Assert(t, newMainEntry != mainEntry)
|
|
assert.Equal(t, ls.GetProgram().GetSourceFile("/user/username/projects/myproject/src/utils.ts"), utils)
|
|
// Old snapshot is deref'd immediately when replaced by UpdateSnapshot,
|
|
// so old mainEntry is already disposed and utils refCount is already 1.
|
|
assert.Equal(t, mainEntry.refCount, 0)
|
|
assert.Equal(t, newMainEntry.refCount, 1)
|
|
assert.Equal(t, utilsEntry.refCount, 1)
|
|
})
|
|
|
|
t.Run("release file on close", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := setup(files)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 1, files["/user/username/projects/myproject/src/utils.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
snapshot := session.Snapshot()
|
|
program := snapshot.ProjectCollection.InferredProject().Program
|
|
main := program.GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
utils := program.GetSourceFile("/user/username/projects/myproject/src/utils.ts")
|
|
mainEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
utilsEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(utils.ParseOptions(), utils.Hash, utils.ScriptKind))
|
|
assert.Equal(t, mainEntry.refCount, 1)
|
|
assert.Equal(t, utilsEntry.refCount, 1)
|
|
|
|
session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts")
|
|
_, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/myproject/src/utils.ts")
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
assert.Equal(t, utilsEntry.refCount, 1)
|
|
assert.Equal(t, mainEntry.refCount, 0)
|
|
mainEntry, ok := session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
assert.Equal(t, ok, false)
|
|
})
|
|
|
|
t.Run("unchanged program does not over-ref", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// When a program is reused across snapshots without changes, we should
|
|
// not accumulate extra refs. The ref count should stay at 1 per source file
|
|
// until the program is finally disposed.
|
|
session := setup(files)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 1, files["/user/username/projects/myproject/src/utils.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
// Get first snapshot and capture the program/entries
|
|
snapshot1 := session.Snapshot()
|
|
program1 := snapshot1.ProjectCollection.InferredProject().Program
|
|
main := program1.GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
mainEntry, _ := session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
assert.Equal(t, mainEntry.refCount, 1, "initial refCount should be 1")
|
|
|
|
// Change utils.ts to trigger a new snapshot, but main.ts stays the same
|
|
// so main's source file should be reused.
|
|
session.DidChangeFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{
|
|
Partial: &lsproto.TextDocumentContentChangePartial{
|
|
Range: lsproto.Range{
|
|
Start: lsproto.Position{Line: 0, Character: 0},
|
|
End: lsproto.Position{Line: 0, Character: 25},
|
|
},
|
|
Text: "export function util2() {}",
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get second snapshot - main.ts should be reused (program is new but shares source files)
|
|
ls, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/myproject/src/main.ts")
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
program2 := ls.GetProgram()
|
|
main2 := program2.GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
assert.Equal(t, main, main2, "main.ts source file should be reused")
|
|
|
|
// main.ts refCount should be 1: the old snapshot was immediately deref'd
|
|
// when replaced, so only the new snapshot holds a ref.
|
|
mainEntry, _ = session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
assert.Equal(t, mainEntry.refCount, 1, "refCount should be 1 (only new snapshot)")
|
|
|
|
// Close files to trigger cleanup
|
|
session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts")
|
|
session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts")
|
|
session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
// Entry should now be gone (refCount 0, deleted)
|
|
mainEntry, ok := session.parseCache.entries.Load(NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind))
|
|
if ok {
|
|
t.Logf("Entry still exists with refCount=%d", mainEntry.refCount)
|
|
}
|
|
assert.Assert(t, !ok, "entry should be deleted after program is disposed")
|
|
})
|
|
|
|
t.Run("fallback rebuild does not double-ref changed file", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testFiles := map[string]any{
|
|
"/user/username/projects/myproject/src/main.ts": "const x = 1;",
|
|
"/user/username/projects/myproject/src/utils.ts": "export const util = 1;",
|
|
}
|
|
session := setup(testFiles)
|
|
mainURI := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts")
|
|
session.DidOpenFile(context.Background(), mainURI, 1, testFiles["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
_, err := session.GetLanguageService(context.Background(), mainURI)
|
|
assert.NilError(t, err)
|
|
|
|
session.DidChangeFile(context.Background(), mainURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{
|
|
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
|
|
Text: "import { util } from \"./utils\";\nconst x = util;",
|
|
},
|
|
},
|
|
})
|
|
|
|
lsAfter, err := session.GetLanguageService(context.Background(), mainURI)
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
project := session.Snapshot().ProjectCollection.InferredProject()
|
|
assert.Assert(t, project != nil)
|
|
assert.Equal(t, project.ProgramUpdateKind, ProgramUpdateKindNewFiles)
|
|
|
|
main := lsAfter.GetProgram().GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
mainKey := NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind)
|
|
mainEntry, ok := session.parseCache.entries.Load(mainKey)
|
|
assert.Assert(t, ok)
|
|
assert.Equal(t, mainEntry.refCount, 1)
|
|
|
|
session.DidCloseFile(context.Background(), mainURI)
|
|
session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
_, ok = session.parseCache.entries.Load(mainKey)
|
|
assert.Assert(t, !ok)
|
|
})
|
|
|
|
t.Run("case-only duplicate loads are released on dispose", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testFiles := map[string]any{
|
|
"/user/username/projects/myproject/src/main.ts": "import { util as a } from \"./utils\";\nimport { util as b } from \"./UTILS\";\nconst x = a + b;",
|
|
"/user/username/projects/myproject/src/utils.ts": "export const util = 1;",
|
|
}
|
|
session := setup(testFiles)
|
|
mainURI := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts")
|
|
session.DidOpenFile(context.Background(), mainURI, 1, testFiles["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
ls, err := session.GetLanguageService(context.Background(), mainURI)
|
|
assert.NilError(t, err)
|
|
|
|
var projectEntries int
|
|
session.parseCache.entries.Range(func(key ParseCacheKey, _ *refCountCacheEntry[*ast.SourceFile]) bool {
|
|
if strings.HasPrefix(key.FileName, "/user/username/projects/myproject/src/") {
|
|
projectEntries++
|
|
}
|
|
return true
|
|
})
|
|
assert.Equal(t, projectEntries, 3)
|
|
|
|
utils := ls.GetProgram().GetSourceFile("/user/username/projects/myproject/src/utils.ts")
|
|
assert.Assert(t, utils != nil)
|
|
|
|
session.DidCloseFile(context.Background(), mainURI)
|
|
session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
projectEntries = 0
|
|
session.parseCache.entries.Range(func(key ParseCacheKey, _ *refCountCacheEntry[*ast.SourceFile]) bool {
|
|
if strings.HasPrefix(key.FileName, "/user/username/projects/myproject/src/") {
|
|
projectEntries++
|
|
}
|
|
return true
|
|
})
|
|
assert.Equal(t, projectEntries, 0)
|
|
})
|
|
|
|
t.Run("case-only duplicate imported from multiple files is refcounted once", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A file reached through a case-only-different file name from more than one
|
|
// import site is parsed and acquired in the parse cache exactly once (same-casing
|
|
// loads dedupe), but it must also be recorded as a duplicate exactly once.
|
|
// Recording it once per import site would release it from the parse cache more
|
|
// times than it was acquired, deleting the live entry out from under a program
|
|
// that still references it and panicking the next time it is ref'd during a clone.
|
|
testFiles := map[string]any{
|
|
// entry.ts imports the canonical casing first, then pulls in a.ts and b.ts,
|
|
// which both import the same file through an upper-cased name.
|
|
"/user/username/projects/myproject/src/entry.ts": "import { dep } from './sub/dep';\nimport './a';\nimport './b';\nexport const e = dep;",
|
|
"/user/username/projects/myproject/src/a.ts": "import { dep } from './sub/DEP';\nexport const a = dep;",
|
|
"/user/username/projects/myproject/src/b.ts": "import { dep } from './sub/DEP';\nexport const b = dep;",
|
|
"/user/username/projects/myproject/src/sub/dep.ts": "export const dep = 1;",
|
|
"/user/username/projects/myproject/src/c.ts": "export const c = 1;",
|
|
}
|
|
session := setup(testFiles)
|
|
entryURI := lsproto.DocumentUri("file:///user/username/projects/myproject/src/entry.ts")
|
|
session.DidOpenFile(context.Background(), entryURI, 1, testFiles["/user/username/projects/myproject/src/entry.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
|
|
ls, err := session.GetLanguageService(context.Background(), entryURI)
|
|
assert.NilError(t, err)
|
|
|
|
// The upper-cased name is recorded as a duplicate, and it should appear exactly once.
|
|
program := ls.GetProgram()
|
|
var dupKeys []ParseCacheKey
|
|
for _, dup := range program.DuplicateSourceFiles() {
|
|
if strings.HasSuffix(dup.ParseOptions.FileName, "/sub/DEP.ts") {
|
|
dupKeys = append(dupKeys, NewParseCacheKey(dup.ParseOptions, dup.Hash, dup.ScriptKind))
|
|
}
|
|
}
|
|
assert.Equal(t, len(dupKeys), 1, "case-only duplicate should be recorded exactly once")
|
|
dupEntry, ok := session.parseCache.entries.Load(dupKeys[0])
|
|
assert.Assert(t, ok, "duplicate entry should exist in the parse cache")
|
|
assert.Equal(t, dupEntry.refCount, 1)
|
|
|
|
// Force a full program rebuild (adding an import changes the file's module
|
|
// structure). The old snapshot is disposed, releasing each of its source and
|
|
// duplicate files exactly once. If the duplicate were recorded twice, the
|
|
// shared cache entry would be released to zero and deleted here even though
|
|
// the new program still references it.
|
|
session.DidChangeFile(context.Background(), entryURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{
|
|
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
|
|
Text: "import { dep } from \"./sub/dep\";\nimport \"./a\";\nimport \"./b\";\nimport \"./c\";\nexport const e = dep;",
|
|
},
|
|
},
|
|
})
|
|
lsAfterRebuild, err := session.GetLanguageService(context.Background(), entryURI)
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
// Every parse-cache key referenced by the live program must still exist.
|
|
rebuiltProgram := lsAfterRebuild.GetProgram()
|
|
assertKeyAlive := func(key ParseCacheKey) {
|
|
_, alive := session.parseCache.entries.Load(key)
|
|
assert.Assert(t, alive, "live program references a deleted parse-cache entry: %s", key.FileName)
|
|
}
|
|
for _, file := range rebuiltProgram.SourceFiles() {
|
|
assertKeyAlive(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind))
|
|
}
|
|
for _, dup := range rebuiltProgram.DuplicateSourceFiles() {
|
|
assertKeyAlive(NewParseCacheKey(dup.ParseOptions, dup.Hash, dup.ScriptKind))
|
|
}
|
|
|
|
// An incremental (clone) update re-references the duplicate files; this must
|
|
// not panic with "cache entry not found".
|
|
session.DidChangeFile(context.Background(), entryURI, 3, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
|
|
{
|
|
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{
|
|
Text: "import { dep } from './sub/dep';\nimport './a';\nimport './b';\nimport './c';\nexport const e = dep + 0;",
|
|
},
|
|
},
|
|
})
|
|
_, err = session.GetLanguageService(context.Background(), entryURI)
|
|
assert.NilError(t, err)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
// Closing the project releases everything cleanly.
|
|
// (The configured project is not disposed until another file in another project is opened,
|
|
// so we open an untitled file to trigger that.)
|
|
session.DidCloseFile(context.Background(), entryURI)
|
|
session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript)
|
|
session.WaitForBackgroundTasks()
|
|
|
|
projectEntries := 0
|
|
session.parseCache.entries.Range(func(key ParseCacheKey, _ *refCountCacheEntry[*ast.SourceFile]) bool {
|
|
if strings.HasPrefix(key.FileName, "/user/username/projects/myproject/src/") {
|
|
projectEntries++
|
|
}
|
|
return true
|
|
})
|
|
assert.Equal(t, projectEntries, 0)
|
|
})
|
|
})
|
|
|
|
t.Run("extendedConfigCache", func(t *testing.T) {
|
|
files := map[string]any{
|
|
"/user/username/projects/myproject/tsconfig.json": `{
|
|
"extends": "./tsconfig.base.json"
|
|
}`,
|
|
"/user/username/projects/myproject/tsconfig.base.json": `{
|
|
"compilerOptions": {}
|
|
}`,
|
|
"/user/username/projects/myproject/src/main.ts": "const x = 1;",
|
|
}
|
|
|
|
t.Run("release extended configs with project close", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := setup(files)
|
|
session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript)
|
|
snapshot := session.Snapshot()
|
|
config := snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json")
|
|
assert.Equal(t, config.ExtendedSourceFiles()[0], "/user/username/projects/myproject/tsconfig.base.json")
|
|
extendedConfigEntry, _ := session.extendedConfigCache.entries.Load("/user/username/projects/myproject/tsconfig.base.json")
|
|
assert.Equal(t, len(extendedConfigEntry.owners), 1)
|
|
|
|
session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts")
|
|
session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript)
|
|
session.WaitForBackgroundTasks()
|
|
_, ok := session.extendedConfigCache.entries.Load("/user/username/projects/myproject/tsconfig.base.json")
|
|
assert.Equal(t, ok, false)
|
|
})
|
|
|
|
t.Run("release cache entries for unretained clone", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := setup(files)
|
|
uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts")
|
|
baseSnapshot := session.Snapshot()
|
|
extendedConfigPath := tspath.Path("/user/username/projects/myproject/tsconfig.base.json")
|
|
clone := baseSnapshot.Clone(context.Background(), SnapshotChange{
|
|
reason: UpdateReasonRequestedLanguageServiceProjectNotLoaded,
|
|
ResourceRequest: ResourceRequest{
|
|
Documents: []lsproto.DocumentUri{uri},
|
|
},
|
|
}, baseSnapshot.fs.overlays, session)
|
|
|
|
project := clone.GetDefaultProject(uri)
|
|
assert.Assert(t, project != nil)
|
|
assert.Equal(t, project.ProgramLastUpdate, clone.id)
|
|
|
|
main := project.Program.GetSourceFile("/user/username/projects/myproject/src/main.ts")
|
|
mainKey := NewParseCacheKey(main.ParseOptions(), main.Hash, main.ScriptKind)
|
|
mainEntry, ok := session.parseCache.entries.Load(mainKey)
|
|
assert.Assert(t, ok)
|
|
assert.Equal(t, mainEntry.refCount, 1)
|
|
|
|
extendedConfigEntry, ok := session.extendedConfigCache.entries.Load(extendedConfigPath)
|
|
assert.Assert(t, ok)
|
|
assert.Equal(t, len(extendedConfigEntry.owners), 1)
|
|
|
|
clone.Deref(session)
|
|
|
|
_, ok = session.parseCache.entries.Load(mainKey)
|
|
assert.Assert(t, !ok)
|
|
|
|
_, ok = session.extendedConfigCache.entries.Load(extendedConfigPath)
|
|
assert.Assert(t, !ok)
|
|
})
|
|
})
|
|
}
|