Files
kjol/tools/tsgo/internal/project/refcountcache_test.go
2026-07-09 16:50:43 -04:00

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)
})
})
}