1296 lines
48 KiB
Go
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()
|
|
})
|
|
}
|