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

1296 lines
48 KiB
Go

package project
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/microsoft/typescript-go/internal/bundled"
"github.com/microsoft/typescript-go/internal/checker"
"github.com/microsoft/typescript-go/internal/compiler"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/project/logging"
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
"gotest.tools/v3/assert"
)
func setupCheckerPoolSession(t *testing.T, opts CheckerPoolOptions) (*Session, *checkerPool) {
t.Helper()
if !bundled.Embedded {
t.Skip("bundled files are not embedded")
}
files := map[string]any{
"/src/tsconfig.json": `{ "compilerOptions": { "noLib": true } }`,
"/src/index.ts": "export const x: number = 1;",
}
fs := bundled.WrapFS(vfstest.FromMap(files, false))
session := NewSession(&SessionInit{
BackgroundCtx: context.Background(),
Options: &SessionOptions{
CurrentDirectory: "/",
DefaultLibraryPath: bundled.LibPath(),
PositionEncoding: lsproto.PositionEncodingKindUTF8,
WatchEnabled: false,
LoggingEnabled: true,
CheckerPoolOptions: opts,
},
FS: fs,
Logger: logging.NewTestLogger(),
})
session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, "export const x: number = 1;", lsproto.LanguageKindTypeScript)
snapshot := session.Snapshot()
project := snapshot.ProjectCollection.ConfiguredProject("/src/tsconfig.json")
assert.Assert(t, project != nil, "expected configured project")
assert.Assert(t, project.checkerPool != nil, "expected checker pool")
return session, project.checkerPool
}
// newTestCheckerPool creates a checker pool inside the current goroutine context
// (suitable for use inside synctest.Test) using the given program.
func newTestCheckerPool(program *compiler.Program, opts CheckerPoolOptions) *checkerPool {
return newCheckerPool(opts, program, func(string) {})
}
func TestCheckerPoolDiagnosticsRouting(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Diagnostics requests should get checker at index 0.
ctx := core.WithRequestID(context.Background(), "diag-req-1")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
assert.Assert(t, pool.checkers[0] == c, "diagnostics should use checker index 0")
release()
}
func TestCheckerPoolQueryRouting(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Query requests should get a checker at index > 0.
ctx := core.WithRequestID(context.Background(), "query-req-1")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
// Verify it's not the diagnostics checker slot.
assert.Assert(t, pool.checkers[0] != c, "query should not use checker index 0")
release()
}
func TestCheckerPoolRequestAffinity(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctx = core.WithRequestID(ctx, "req-affinity")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
// First call acquires.
c1, release1 := pool.GetChecker(ctx, nil)
// Second call with same request ID while still held returns same checker (noop release).
c2, release2 := pool.GetChecker(ctx, nil)
release2()
release1()
assert.Assert(t, c1 == c2, "same request ID should return the same checker while held")
// After release, same request should still get the same checker (cross-release affinity).
c3, release3 := pool.GetChecker(ctx, nil)
release3()
assert.Assert(t, c1 == c3, "same request ID should return the same checker after release")
}
func TestCheckerPoolIdleCleanup(t *testing.T) {
t.Parallel()
// Get a real program to use for checker creation, then test the pool
// with fake time via synctest.
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 5 * time.Second})
// Create a checker via a diagnostics request.
ctx := core.WithRequestID(context.Background(), "diag-cleanup")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
// Create a query checker as well.
ctx2 := core.WithRequestID(context.Background(), "query-cleanup")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil)
release2()
synctest.Wait()
// Both checkers should exist.
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] != nil, "diagnostics checker should exist")
var queryIdx int
for i := 1; i < len(pool.checkers); i++ {
if pool.checkers[i] != nil {
queryIdx = i
break
}
}
assert.Assert(t, queryIdx > 0, "query checker should exist")
pool.mu.Unlock()
// Advance past idle timeout.
time.Sleep(5 * time.Second)
synctest.Wait()
// After cleanup, both checkers should be disposed.
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] == nil, "diagnostics checker should be disposed after idle timeout")
assert.Assert(t, pool.checkers[queryIdx] == nil, "query checker should be disposed after idle timeout")
pool.mu.Unlock()
})
}
func TestCheckerPoolFileAssociationCleanup(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
sourceFile := program.GetSourceFile("/src/index.ts")
assert.Assert(t, sourceFile != nil)
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 5 * time.Second})
// Create a query checker with file affinity.
ctx := core.WithRequestID(context.Background(), "file-assoc-req")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, sourceFile)
assert.Assert(t, c != nil)
release()
synctest.Wait()
// File association should exist.
pool.mu.Lock()
_, hasAssoc := pool.fileAssociations[sourceFile]
pool.mu.Unlock()
assert.Assert(t, hasAssoc, "file should have a checker association")
// Advance past idle timeout.
time.Sleep(5 * time.Second)
synctest.Wait()
// File association should be cleared.
pool.mu.Lock()
_, hasAssoc = pool.fileAssociations[sourceFile]
pool.mu.Unlock()
assert.Assert(t, !hasAssoc, "file association should be cleared after checker disposal")
})
}
func TestCheckerPoolMinCheckers(t *testing.T) {
t.Parallel()
// Requesting maxCheckers=1 should be clamped to 2.
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 1, IdleTimeout: 10 * time.Second})
assert.Equal(t, pool.opts.MaxCheckers, 2)
assert.Equal(t, len(pool.checkers), 2)
}
func TestCheckerPoolDefaultIdleTimeout(t *testing.T) {
t.Parallel()
// Zero idle timeout should default to 30s.
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4})
assert.Equal(t, pool.opts.IdleTimeout, 30*time.Second)
}
func TestCheckerPoolQueryContention(t *testing.T) {
t.Parallel()
// maxCheckers=2 means 1 diagnostics + 1 query checker slot.
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 30 * time.Second})
// Acquire the only query checker slot.
ctx1 := core.WithRequestID(context.Background(), "query-hold")
ctx1 = core.WithCheckerLifetime(ctx1, core.CheckerLifetimeTemporary)
c1, release1 := pool.GetChecker(ctx1, nil)
assert.Assert(t, c1 != nil)
// A second query request from a different request ID should block.
var c2Got atomic.Bool
go func() {
ctx2 := core.WithRequestID(context.Background(), "query-wait")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
_ = c2 // verified via c2Got flag
c2Got.Store(c2 != nil)
release2()
}()
// Wait for goroutine to reach the semaphore send.
synctest.Wait()
assert.Assert(t, !c2Got.Load(), "second query should be blocked while first holds the checker")
// Release the first checker — second should unblock.
release1()
synctest.Wait()
assert.Assert(t, c2Got.Load(), "second query should have acquired the checker after release")
})
}
func TestCheckerPoolDiagnosticsContention(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 30 * time.Second})
// Acquire the diagnostics checker.
ctx1 := core.WithRequestID(context.Background(), "diag-hold")
ctx1 = core.WithCheckerLifetime(ctx1, core.CheckerLifetimeDiagnostics)
c1, release1 := pool.GetChecker(ctx1, nil)
assert.Assert(t, c1 != nil)
// A second diagnostics request should block since there's only one diag checker.
var c2Got atomic.Bool
go func() {
ctx2 := core.WithRequestID(context.Background(), "diag-wait")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeDiagnostics)
c2, release2 := pool.GetChecker(ctx2, nil)
_ = c2 // verified via c2Got flag
c2Got.Store(c2 != nil)
release2()
}()
synctest.Wait()
assert.Assert(t, !c2Got.Load(), "second diagnostics request should be blocked")
// A query request should NOT be blocked (separate slot).
ctx3 := core.WithRequestID(context.Background(), "query-concurrent")
ctx3 = core.WithCheckerLifetime(ctx3, core.CheckerLifetimeTemporary)
c3, release3 := pool.GetChecker(ctx3, nil)
assert.Assert(t, c3 != nil)
assert.Assert(t, c3 != c1, "query checker should be different from diagnostics checker")
release3()
// Release the diagnostics checker — second diag request should unblock.
release1()
synctest.Wait()
assert.Assert(t, c2Got.Load(), "second diagnostics request should have acquired the checker after release")
})
}
func TestCheckerPoolCanceledCheckerDisposal(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
sourceFile := program.GetSourceFile("/src/index.ts")
assert.Assert(t, sourceFile != nil)
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Acquire a query checker and cancel it.
ctx := core.WithRequestID(context.Background(), "cancel-test")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
c.GetDiagnostics(canceledCtx, sourceFile)
assert.Assert(t, c.WasCanceled())
// Release should dispose the canceled checker.
release()
synctest.Wait()
// Next request should get a fresh checker.
ctx2 := core.WithRequestID(context.Background(), "after-cancel")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != c, "should get a new checker, not the canceled one")
release2()
})
}
func TestCheckerPoolRequestAssociationCleanupOnDisposal(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 5 * time.Second})
// Create a query checker with a request association.
reqCtx, reqCancel := context.WithCancel(context.Background())
defer reqCancel()
ctx := core.WithRequestID(reqCtx, "assoc-cleanup-req")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
// Cancel the checker to trigger disposal on release.
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
sourceFile := program.GetSourceFile("/src/index.ts")
c.GetDiagnostics(canceledCtx, sourceFile)
assert.Assert(t, c.WasCanceled())
release()
synctest.Wait()
// Request association should be cleared after checker disposal.
pool.mu.Lock()
_, hasReqAssoc := pool.requestAssociations["assoc-cleanup-req"]
pool.mu.Unlock()
assert.Assert(t, !hasReqAssoc, "request association should be cleared after checker disposal")
})
}
func TestCheckerPoolRequestAssociationCleanupOnContextDone(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Create a cancellable context to simulate request lifecycle.
reqCtx, reqCancel := context.WithCancel(context.Background())
ctx := core.WithRequestID(reqCtx, "ctx-cleanup-req")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
// Association should still exist after release.
pool.mu.Lock()
_, hasAssoc := pool.requestAssociations["ctx-cleanup-req"]
pool.mu.Unlock()
assert.Assert(t, hasAssoc, "request association should persist after release")
// Cancel the request context — association should be cleaned up.
reqCancel()
synctest.Wait()
pool.mu.Lock()
_, hasAssoc = pool.requestAssociations["ctx-cleanup-req"]
pool.mu.Unlock()
assert.Assert(t, !hasAssoc, "request association should be cleaned up after context cancellation")
})
}
func TestCheckerPoolDiagnosticsRecreatedAfterIdleDisposal(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 5 * time.Second})
// Create and release diagnostics checker.
ctx := core.WithRequestID(context.Background(), "diag-recreate-1")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c1, release1 := pool.GetChecker(ctx, nil)
assert.Assert(t, c1 != nil)
release1()
synctest.Wait()
// Advance past idle timeout — diagnostics checker should be disposed.
time.Sleep(5 * time.Second)
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] == nil, "diagnostics checker should be disposed")
pool.mu.Unlock()
// Request diagnostics checker again — should get a fresh one.
ctx2 := core.WithRequestID(context.Background(), "diag-recreate-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeDiagnostics)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil, "diagnostics checker should be re-created")
assert.Assert(t, c2 != c1, "should be a new checker instance")
release2()
})
}
func TestCheckerPoolCrossReleaseAffinityWithContention(t *testing.T) {
t.Parallel()
// maxCheckers=2: 1 diagnostics + 1 query slot.
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 30 * time.Second})
reqCtx, reqCancel := context.WithCancel(context.Background())
defer reqCancel()
// Request A acquires the only query slot.
ctxA := core.WithRequestID(reqCtx, "req-A")
ctxA = core.WithCheckerLifetime(ctxA, core.CheckerLifetimeTemporary)
cA, releaseA := pool.GetChecker(ctxA, nil)
assert.Assert(t, cA != nil)
releaseA()
synctest.Wait()
// Request B takes the query slot while A is released.
ctxB := core.WithRequestID(context.Background(), "req-B")
ctxB = core.WithCheckerLifetime(ctxB, core.CheckerLifetimeTemporary)
cB, releaseB := pool.GetChecker(ctxB, nil)
assert.Assert(t, cB != nil)
// Request A reacquires — should block because B holds the slot.
var reacquired atomic.Bool
cA2Ch := make(chan *checker.Checker, 1)
go func() {
c, release := pool.GetChecker(ctxA, nil)
cA2Ch <- c
reacquired.Store(true)
release()
}()
synctest.Wait()
assert.Assert(t, !reacquired.Load(), "request A should block while B holds the slot")
// Release B — A should unblock and get the same checker.
releaseB()
synctest.Wait()
assert.Assert(t, reacquired.Load(), "request A should unblock after B releases")
select {
case cA2 := <-cA2Ch:
assert.Assert(t, cA2 == cA, "request A should get the same checker on reacquire")
case <-t.Context().Done():
t.Fatal("timed out waiting for reacquired checker")
}
})
}
func TestCheckerPoolLifetimeMismatchIgnoresAssociation(t *testing.T) {
t.Parallel()
// Verify that if a request first uses a diagnostics checker, then switches
// to a temporary lifetime (or vice versa), the stale association is ignored
// rather than returning a checker from the wrong category.
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
reqCtx, reqCancel := context.WithCancel(context.Background())
defer reqCancel()
// Acquire a diagnostics checker with request ID "mixed".
ctxDiag := core.WithRequestID(reqCtx, "mixed")
ctxDiag = core.WithCheckerLifetime(ctxDiag, core.CheckerLifetimeDiagnostics)
cDiag, releaseDiag := pool.GetChecker(ctxDiag, nil)
assert.Assert(t, cDiag != nil)
assert.Assert(t, pool.checkers[0] == cDiag, "diagnostics checker should be at index 0")
releaseDiag()
synctest.Wait()
// Now use the same request ID but with query purpose.
// The old association points to index 0 (diagnostics), which should
// be rejected — the returned checker must be a query checker (index > 0).
ctxQuery := core.WithRequestID(reqCtx, "mixed")
ctxQuery = core.WithCheckerLifetime(ctxQuery, core.CheckerLifetimeTemporary)
cQuery, releaseQuery := pool.GetChecker(ctxQuery, nil)
assert.Assert(t, cQuery != nil)
assert.Assert(t, cQuery != cDiag, "query should not reuse the diagnostics checker")
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] != cQuery, "query checker should not be at diagnostics index 0")
pool.mu.Unlock()
releaseQuery()
})
}
func TestCheckerPoolNoRequestID(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Calls without a request ID should still work (e.g., callhierarchy uses context.Background()).
ctx := context.Background()
c1, release1 := pool.GetChecker(ctx, nil)
assert.Assert(t, c1 != nil)
release1()
c2, release2 := pool.GetChecker(ctx, nil)
assert.Assert(t, c2 != nil)
release2()
// Without request ID, no affinity guarantee — just verify it doesn't crash.
}
func TestCheckerPoolDiagnosticsCrossReleaseAffinity(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
reqCtx, reqCancel := context.WithCancel(context.Background())
defer reqCancel()
ctx := core.WithRequestID(reqCtx, "diag-affinity")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c1, release1 := pool.GetChecker(ctx, nil)
assert.Assert(t, c1 != nil)
assert.Assert(t, pool.checkers[0] == c1, "should be the diagnostics checker")
release1()
synctest.Wait()
// Same request reacquiring diagnostics should get the same checker.
c2, release2 := pool.GetChecker(ctx, nil)
assert.Assert(t, c2 == c1, "same diagnostics request should get the same checker after release")
release2()
})
}
func TestCheckerPoolDiscardKeepsIdleCheckers(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Create both a diagnostics and a query checker.
ctx1 := core.WithRequestID(context.Background(), "obs-diag")
ctx1 = core.WithCheckerLifetime(ctx1, core.CheckerLifetimeDiagnostics)
c1, release1 := pool.GetChecker(ctx1, nil)
assert.Assert(t, c1 != nil)
release1()
synctest.Wait()
ctx2 := core.WithRequestID(context.Background(), "obs-query")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil)
release2()
synctest.Wait()
// Both checkers should exist before Discard.
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] != nil, "diagnostics checker should exist")
pool.mu.Unlock()
// Discard should keep idle checkers alive (they may be referenced by
// API type handles) and just stop the cleanup timer.
pool.Discard()
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] == c1, "diagnostics checker should survive Discard")
hasQuery := false
for i := 1; i < len(pool.checkers); i++ {
if pool.checkers[i] == c2 {
hasQuery = true
break
}
}
assert.Assert(t, hasQuery, "query checker should survive Discard")
assert.Assert(t, pool.cleanupTimer == nil, "cleanup timer should be stopped after Discard")
pool.mu.Unlock()
// Even after a long wait, checkers should not be disposed (no timer running).
time.Sleep(60 * time.Second)
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] == c1, "diagnostics checker should persist indefinitely on discarded pool")
pool.mu.Unlock()
})
}
func TestCheckerPoolDiscardHeldCheckerSurvivesRelease(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Acquire a checker and hold it.
ctx := core.WithRequestID(context.Background(), "held-obs")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
// Find which slot it's in.
pool.mu.Lock()
var heldIndex int
for i := 1; i < len(pool.checkers); i++ {
if pool.checkers[i] == c {
heldIndex = i
break
}
}
pool.mu.Unlock()
assert.Assert(t, heldIndex > 0, "should find the held checker")
// Discard while checker is held — should NOT dispose it.
pool.Discard()
pool.mu.Lock()
assert.Assert(t, pool.checkers[heldIndex] == c, "held checker should survive Discard")
pool.mu.Unlock()
// Release — checker should remain alive on a discarded pool.
release()
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[heldIndex] == c, "checker should persist after release on discarded pool")
pool.mu.Unlock()
// Even after a long wait, checker persists (no cleanup timer running).
time.Sleep(60 * time.Second)
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[heldIndex] == c, "checker should persist indefinitely on discarded pool")
pool.mu.Unlock()
})
}
func TestCheckerPoolDiscardStillFunctional(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
pool.Discard()
// Pool should still work — GetChecker should create a fresh checker.
ctx := core.WithRequestID(context.Background(), "post-obs")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil, "should still create checkers after Discard")
// Find the slot.
pool.mu.Lock()
var idx int
for i := 1; i < len(pool.checkers); i++ {
if pool.checkers[i] == c {
idx = i
break
}
}
pool.mu.Unlock()
assert.Assert(t, idx > 0, "checker should be in a query slot")
// Release — checker should persist on discarded pool (no cleanup timer).
release()
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[idx] == c, "checker should persist after release on discarded pool")
pool.mu.Unlock()
// Re-acquire — should get the same checker back.
ctx2 := core.WithRequestID(context.Background(), "post-obs-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 == c, "should get the same checker on discarded pool")
release2()
})
}
func TestCheckerPoolDiagnosticsCheckerStableIdentity(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Acquire the diagnostics checker.
ctx := core.WithRequestID(context.Background(), "diag-stable-1")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c1, release1 := pool.GetChecker(ctx, nil)
assert.Assert(t, c1 != nil, "diagnostics checker should be created")
release1()
synctest.Wait()
// Re-acquire before idle timeout — should be the same instance.
ctx2 := core.WithRequestID(context.Background(), "diag-stable-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeDiagnostics)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 == c1, "diagnostics checker should be the same instance before idle timeout")
release2()
})
}
func TestCheckerPoolDiagnosticsCheckerSurvivesDiscard(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Create the diagnostics checker.
ctx := core.WithRequestID(context.Background(), "diag-discard")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
pool.Discard()
// Diagnostics checker should survive Discard.
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] == c, "diagnostics checker should survive Discard")
pool.mu.Unlock()
// Should still be acquirable and be the same instance.
ctx2 := core.WithRequestID(context.Background(), "diag-discard-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeDiagnostics)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 == c, "diagnostics checker identity should be stable after Discard")
release2()
})
}
func TestCheckerPoolDiagnosticsCheckerIndependentFromQuery(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Acquire diagnostics and query checkers.
diagCtx := core.WithRequestID(context.Background(), "diag-indep")
diagCtx = core.WithCheckerLifetime(diagCtx, core.CheckerLifetimeDiagnostics)
queryCtx := core.WithRequestID(context.Background(), "query-indep")
queryCtx = core.WithCheckerLifetime(queryCtx, core.CheckerLifetimeTemporary)
diagC, diagRelease := pool.GetChecker(diagCtx, nil)
queryC, queryRelease := pool.GetChecker(queryCtx, nil)
// They should be different checker instances.
assert.Assert(t, diagC != queryC, "diagnostics and query checkers should be different")
diagRelease()
queryRelease()
})
}
func TestCheckerPoolAPICheckerStableIdentity(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
ctx := core.WithCheckerLifetime(context.Background(), core.CheckerLifetimeAPI)
c1, release1 := pool.GetChecker(ctx, nil)
assert.Assert(t, c1 != nil)
release1()
c2, release2 := pool.GetChecker(ctx, nil)
assert.Assert(t, c2 == c1, "API checker should be the same instance")
release2()
// Should survive idle timeout.
time.Sleep(60 * time.Second)
synctest.Wait()
c3, release3 := pool.GetChecker(ctx, nil)
assert.Assert(t, c3 == c1, "API checker should survive idle timeout")
release3()
})
}
func TestCheckerPoolAPICheckerSurvivesDiscard(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
ctx := core.WithCheckerLifetime(context.Background(), core.CheckerLifetimeAPI)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
pool.Discard()
pool.mu.Lock()
assert.Assert(t, pool.persistentChecker == c, "API checker should survive Discard")
pool.mu.Unlock()
c2, release2 := pool.GetChecker(ctx, nil)
assert.Assert(t, c2 == c, "API checker identity should be stable after Discard")
release2()
})
}
func TestCheckerPoolAllThreeIndependent(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
dedCtx := core.WithRequestID(context.Background(), "ded-req")
dedCtx = core.WithCheckerLifetime(dedCtx, core.CheckerLifetimeDiagnostics)
tmpCtx := core.WithRequestID(context.Background(), "tmp-req")
tmpCtx = core.WithCheckerLifetime(tmpCtx, core.CheckerLifetimeTemporary)
perCtx := core.WithCheckerLifetime(context.Background(), core.CheckerLifetimeAPI)
dedC, dedRelease := pool.GetChecker(dedCtx, nil)
tmpC, tmpRelease := pool.GetChecker(tmpCtx, nil)
perC, perRelease := pool.GetChecker(perCtx, nil)
assert.Assert(t, dedC != tmpC, "diagnostics and temporary should be different")
assert.Assert(t, dedC != perC, "diagnostics and API should be different")
assert.Assert(t, tmpC != perC, "temporary and API should be different")
dedRelease()
tmpRelease()
perRelease()
})
}
func TestCheckerPoolFileAffinity(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
sourceFile := program.GetSourceFile("/src/index.ts")
assert.Assert(t, sourceFile != nil)
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// First query with a file should create a checker and associate it.
ctx1 := core.WithRequestID(context.Background(), "file-aff-1")
ctx1 = core.WithCheckerLifetime(ctx1, core.CheckerLifetimeTemporary)
c1, release1 := pool.GetChecker(ctx1, sourceFile)
assert.Assert(t, c1 != nil)
release1()
synctest.Wait()
// Second query with the same file (different request) should get the same checker via file affinity.
ctx2 := core.WithRequestID(context.Background(), "file-aff-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, sourceFile)
assert.Assert(t, c2 != nil)
assert.Assert(t, c2 == c1, "same file should return the same checker via file affinity")
release2()
})
}
func TestCheckerPoolMultipleConcurrentQueryCheckers(t *testing.T) {
t.Parallel()
// maxCheckers=4: 1 diagnostics + 3 query slots.
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Acquire 3 query checkers concurrently (all slots).
ctx1 := core.WithRequestID(context.Background(), "multi-q-1")
ctx1 = core.WithCheckerLifetime(ctx1, core.CheckerLifetimeTemporary)
c1, release1 := pool.GetChecker(ctx1, nil)
assert.Assert(t, c1 != nil)
ctx2 := core.WithRequestID(context.Background(), "multi-q-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil)
ctx3 := core.WithRequestID(context.Background(), "multi-q-3")
ctx3 = core.WithCheckerLifetime(ctx3, core.CheckerLifetimeTemporary)
c3, release3 := pool.GetChecker(ctx3, nil)
assert.Assert(t, c3 != nil)
// All three should be distinct checkers.
assert.Assert(t, c1 != c2, "concurrent query checkers should be distinct (1 vs 2)")
assert.Assert(t, c1 != c3, "concurrent query checkers should be distinct (1 vs 3)")
assert.Assert(t, c2 != c3, "concurrent query checkers should be distinct (2 vs 3)")
// None should be the diagnostics checker at index 0.
pool.mu.Lock()
assert.Assert(t, pool.checkers[0] != c1 && pool.checkers[0] != c2 && pool.checkers[0] != c3,
"query checkers should not occupy the diagnostics slot")
pool.mu.Unlock()
// A 4th query request should block since all 3 slots are full.
var c4Got atomic.Bool
go func() {
ctx4 := core.WithRequestID(context.Background(), "multi-q-4")
ctx4 = core.WithCheckerLifetime(ctx4, core.CheckerLifetimeTemporary)
c4, release4 := pool.GetChecker(ctx4, nil)
_ = c4 // verified via c4Got flag
c4Got.Store(c4 != nil)
release4()
}()
synctest.Wait()
assert.Assert(t, !c4Got.Load(), "4th query should block when all 3 query slots are held")
release1()
synctest.Wait()
assert.Assert(t, c4Got.Load(), "4th query should unblock after one slot is released")
release2()
release3()
})
}
func TestCheckerPoolDoubleReleaseSafe(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ctx := core.WithRequestID(context.Background(), "double-release")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
// First release should work normally.
release()
// Second release should be a no-op (sync.OnceFunc).
release()
// Pool should still be functional after double release.
ctx2 := core.WithRequestID(context.Background(), "after-double")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil)
release2()
}
func TestCheckerPoolDefaultMaxCheckers(t *testing.T) {
t.Parallel()
// Zero MaxCheckers should default to 4.
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 0, IdleTimeout: 10 * time.Second})
assert.Equal(t, pool.opts.MaxCheckers, 4)
assert.Equal(t, len(pool.checkers), 4)
assert.Equal(t, cap(pool.querySem), 3, "querySem capacity should be MaxCheckers-1")
}
func TestCheckerPoolStaggeredIdleCleanup(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Acquire checker A and hold it.
ctxA := core.WithRequestID(context.Background(), "stagger-A")
ctxA = core.WithCheckerLifetime(ctxA, core.CheckerLifetimeTemporary)
cA, releaseA := pool.GetChecker(ctxA, nil)
assert.Assert(t, cA != nil)
// While A is held, acquire a second checker B.
ctxB := core.WithRequestID(context.Background(), "stagger-B")
ctxB = core.WithCheckerLifetime(ctxB, core.CheckerLifetimeTemporary)
cB, releaseB := pool.GetChecker(ctxB, nil)
assert.Assert(t, cB != nil)
assert.Assert(t, cB != cA, "B should be a different checker since A is held")
// Find their indices.
pool.mu.Lock()
var idxA, idxB int
for i := 1; i < len(pool.checkers); i++ {
if pool.checkers[i] == cA {
idxA = i
}
if pool.checkers[i] == cB {
idxB = i
}
}
pool.mu.Unlock()
assert.Assert(t, idxA > 0)
assert.Assert(t, idxB > 0)
// Release A first. Timer is set for t=10.
releaseA()
synctest.Wait()
// Release B 6 seconds later. Timer is reset for t=16.
time.Sleep(6 * time.Second)
releaseB()
synctest.Wait()
// At t < 16, both should still exist (timer hasn't fired).
pool.mu.Lock()
assert.Assert(t, pool.checkers[idxA] != nil, "checker A should still exist before timer fires")
assert.Assert(t, pool.checkers[idxB] != nil, "checker B should still exist before timer fires")
pool.mu.Unlock()
// Advance past t=16 (when the timer fires). Both should be disposed
// because A has been idle 16s and B has been idle 10s.
time.Sleep(11 * time.Second)
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.checkers[idxA] == nil, "checker A should be disposed after timer fires")
assert.Assert(t, pool.checkers[idxB] == nil, "checker B should be disposed after timer fires")
pool.mu.Unlock()
})
}
func TestCheckerPoolDiscardIdempotent(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 2, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// Create a checker so there's something to discard.
ctx := core.WithRequestID(context.Background(), "idem-q")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
// First discard should keep idle checkers alive.
pool.Discard()
pool.mu.Lock()
hasChecker := false
for _, c := range pool.checkers {
if c != nil {
hasChecker = true
break
}
}
pool.mu.Unlock()
assert.Assert(t, hasChecker, "first Discard should keep idle checkers alive")
// Second discard should be a no-op (no panic, no state corruption).
pool.Discard()
// Pool should still be functional after double Discard.
ctx2 := core.WithRequestID(context.Background(), "post-idem")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, nil)
assert.Assert(t, c2 != nil, "pool should still work after double Discard")
release2()
})
}
func TestCheckerPoolGetGlobalDiagnosticsEmpty(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Before any checker is used, global diagnostics should be empty.
diags := pool.GetGlobalDiagnostics()
assert.Equal(t, len(diags), 0, "global diagnostics should be empty initially")
}
func TestCheckerPoolTakeNewGlobalDiagnostics(t *testing.T) {
t.Parallel()
_, pool := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
// Initially, no new globals.
assert.Assert(t, !pool.TakeNewGlobalDiagnostics(), "should report no new globals initially")
// Use a checker and trigger diagnostics, then release to run the merge.
ctx := core.WithRequestID(context.Background(), "global-diag-req")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
sourceFile := pool.program.GetSourceFile("/src/index.ts")
c, release := pool.GetChecker(ctx, sourceFile)
assert.Assert(t, c != nil)
c.GetDiagnostics(ctx, sourceFile)
release()
// Whether globals were produced depends on the program, but the flag
// should reflect the merge result.
firstTake := pool.TakeNewGlobalDiagnostics()
// After taking, a second call should always return false (flag is reset).
assert.Assert(t, !pool.TakeNewGlobalDiagnostics(), "TakeNewGlobalDiagnostics should reset after first call")
// Releasing the same checker again with the same state should not set the flag.
ctx2 := core.WithRequestID(context.Background(), "global-diag-req-2")
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
c2, release2 := pool.GetChecker(ctx2, sourceFile)
assert.Assert(t, c2 != nil)
c2.GetDiagnostics(ctx2, sourceFile)
release2()
// If first call produced globals, the count is now stable, so no new change.
// If first call produced no globals, still no change.
_ = firstTake
assert.Assert(t, !pool.TakeNewGlobalDiagnostics(), "should not report new globals when checker state is unchanged")
}
func TestCheckerPoolAPICheckerDisposedOnCancel(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
sourceFile := program.GetSourceFile("/src/index.ts")
assert.Assert(t, sourceFile != nil)
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
ctx := core.WithCheckerLifetime(context.Background(), core.CheckerLifetimeAPI)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
// Cancel the API checker.
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
c.GetDiagnostics(canceledCtx, sourceFile)
assert.Assert(t, c.WasCanceled())
// Releasing a canceled API checker must drop it so it isn't reused.
release()
synctest.Wait()
pool.mu.Lock()
assert.Assert(t, pool.persistentChecker == nil, "canceled API checker should be dropped on release")
pool.mu.Unlock()
// Next API acquisition gets a fresh, usable checker rather than panicking.
c2, release2 := pool.GetChecker(ctx, nil)
assert.Assert(t, c2 != c, "should get a fresh API checker after cancellation")
release2()
})
}
func TestCheckerPoolNonCancelableContextNoAffinity(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
// A context that carries a request ID but can never be canceled
// (ctx.Done() == nil) must not register a request association, since
// there would be no way to clean it up.
ctx := core.WithRequestID(context.Background(), "uncancelable-req")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
assert.Assert(t, ctx.Done() == nil, "test precondition: context must be non-cancelable")
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
pool.mu.Lock()
assert.Equal(t, len(pool.requestAssociations), 0, "non-cancelable context must not grow requestAssociations")
pool.mu.Unlock()
})
}
func TestCheckerPoolCleanupAfterDiscardIsNoop(t *testing.T) {
t.Parallel()
session, _ := setupCheckerPoolSession(t, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 10 * time.Second})
ls, err := session.GetLanguageService(context.Background(), "file:///src/index.ts")
assert.NilError(t, err)
program := ls.GetProgram()
synctest.Test(t, func(t *testing.T) {
pool := newTestCheckerPool(program, CheckerPoolOptions{MaxCheckers: 4, IdleTimeout: 30 * time.Second})
ctx := core.WithRequestID(context.Background(), "discard-cleanup")
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
c, release := pool.GetChecker(ctx, nil)
assert.Assert(t, c != nil)
release()
synctest.Wait()
pool.Discard()
// Simulate the timer callback firing after Discard() (Stop() does not
// guarantee the callback won't run). It must be a no-op and must not
// re-arm the cleanup timer, which would keep the discarded pool alive.
pool.cleanupIdleCheckers()
pool.mu.Lock()
assert.Assert(t, pool.cleanupTimer == nil, "cleanup must not reschedule a timer on a discarded pool")
hasChecker := false
for _, cc := range pool.checkers {
if cc != nil {
hasChecker = true
}
}
assert.Assert(t, hasChecker, "idle checkers must survive cleanup on a discarded pool")
pool.mu.Unlock()
})
}