feat: enhance completion service with session management and language support

- Introduced session management using Redis for tracking active sessions.
- Added session claiming and releasing functionality in the completion manager.
- Enhanced HTTP and WebSocket completion endpoints to support multiple languages.
- Implemented request timeout and maximum body size configurations for API routes.
- Updated client-side code to handle session IDs and language parameters in completion requests.
- Improved error handling for unsupported languages and session conflicts.
- Added tests for the completion manager to ensure proper session handling and cleanup.
This commit is contained in:
2026-02-15 16:22:01 +08:00
parent 23decb8687
commit 57afb90bc0
14 changed files with 1334 additions and 138 deletions

View File

@@ -0,0 +1,314 @@
package completion
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
)
var ErrUnsupportedLanguage = errors.New("unsupported language")
var ErrTooManySessions = errors.New("too many active lsp sessions")
type RuntimeClient interface {
Client
Close() error
}
type SessionRegistry interface {
ClaimSession(ctx context.Context, language, sessionID string) (ownerID string, ownerEndpoint string, err error)
ReleaseSession(ctx context.Context, language, sessionID string) error
Close() error
}
type LanguageServerSpec struct {
Language string
LanguageID string
Command string
Args []string
}
type ClientFactory func(ctx context.Context, spec LanguageServerSpec, workspaceDir string) (RuntimeClient, error)
type ManagerConfig struct {
WorkspaceDir string
MaxSessions int
SessionTTL time.Duration
CleanupInterval time.Duration
InstanceID string
Registry SessionRegistry
}
type Manager struct {
mu sync.Mutex
config ManagerConfig
specByLang map[string]LanguageServerSpec
sessions map[string]*managedSession
newClient ClientFactory
stopCh chan struct{}
stoppedOnce sync.Once
}
type managedSession struct {
key string
sessionID string
language string
service *Service
client RuntimeClient
lastUsed time.Time
createdAt time.Time
}
type ErrSessionOwnedByOtherInstance struct {
OwnerID string
OwnerEndpoint string
}
func (e *ErrSessionOwnedByOtherInstance) Error() string {
if e.OwnerEndpoint != "" {
return fmt.Sprintf("session owned by another instance: %s (%s)", e.OwnerID, e.OwnerEndpoint)
}
return fmt.Sprintf("session owned by another instance: %s", e.OwnerID)
}
func NewManager(config ManagerConfig, specs []LanguageServerSpec, factory ClientFactory) *Manager {
if config.MaxSessions <= 0 {
config.MaxSessions = 256
}
if config.SessionTTL <= 0 {
config.SessionTTL = 20 * time.Minute
}
if config.CleanupInterval <= 0 {
config.CleanupInterval = 2 * time.Minute
}
if strings.TrimSpace(config.InstanceID) == "" {
config.InstanceID = "instance-local"
}
specByLang := make(map[string]LanguageServerSpec)
for _, spec := range specs {
key := normalizeLanguage(spec.Language)
if key == "" {
continue
}
specByLang[key] = spec
}
m := &Manager{
config: config,
specByLang: specByLang,
sessions: make(map[string]*managedSession),
newClient: factory,
stopCh: make(chan struct{}),
}
go m.cleanupLoop()
return m
}
func (m *Manager) Complete(ctx context.Context, req Request) (Response, error) {
language := normalizeLanguage(req.Language)
if language == "" {
return Response{}, ErrInvalidRequest
}
spec, ok := m.specByLang[language]
if !ok {
return Response{}, fmt.Errorf("%w: %s", ErrUnsupportedLanguage, req.Language)
}
sessionKey := buildSessionKey(language, req.SessionID)
sessionID := normalizeSessionID(req.SessionID)
if m.config.Registry != nil {
ownerID, ownerEndpoint, err := m.config.Registry.ClaimSession(ctx, language, sessionID)
if err != nil {
return Response{}, err
}
if ownerID != m.config.InstanceID {
return Response{}, &ErrSessionOwnedByOtherInstance{
OwnerID: ownerID,
OwnerEndpoint: ownerEndpoint,
}
}
}
session, err := m.getOrCreateSession(ctx, sessionKey, sessionID, spec)
if err != nil {
return Response{}, err
}
resp, err := session.service.Complete(ctx, req)
if err != nil {
return Response{}, err
}
m.mu.Lock()
if current, ok := m.sessions[sessionKey]; ok {
current.lastUsed = time.Now()
}
m.mu.Unlock()
return resp, nil
}
func (m *Manager) ActiveSessions() map[string]int {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[string]int)
for _, session := range m.sessions {
out[session.language]++
}
return out
}
func (m *Manager) Close() error {
m.stoppedOnce.Do(func() {
close(m.stopCh)
m.mu.Lock()
defer m.mu.Unlock()
for key, session := range m.sessions {
if m.config.Registry != nil {
_ = m.config.Registry.ReleaseSession(context.Background(), session.language, session.sessionID)
}
_ = session.client.Close()
delete(m.sessions, key)
}
if m.config.Registry != nil {
_ = m.config.Registry.Close()
}
})
return nil
}
func (m *Manager) cleanupLoop() {
ticker := time.NewTicker(m.config.CleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.cleanupIdleSessions()
case <-m.stopCh:
return
}
}
}
func (m *Manager) cleanupIdleSessions() {
cutoff := time.Now().Add(-m.config.SessionTTL)
m.mu.Lock()
defer m.mu.Unlock()
for key, session := range m.sessions {
if session.lastUsed.After(cutoff) {
continue
}
if m.config.Registry != nil {
_ = m.config.Registry.ReleaseSession(context.Background(), session.language, session.sessionID)
}
_ = session.client.Close()
delete(m.sessions, key)
}
}
func (m *Manager) getOrCreateSession(
ctx context.Context,
sessionKey string,
sessionID string,
spec LanguageServerSpec,
) (*managedSession, error) {
m.mu.Lock()
if existing, ok := m.sessions[sessionKey]; ok {
existing.lastUsed = time.Now()
m.mu.Unlock()
return existing, nil
}
if len(m.sessions) >= m.config.MaxSessions {
if !m.evictLeastRecentlyUsedLocked() {
m.mu.Unlock()
return nil, ErrTooManySessions
}
}
m.mu.Unlock()
client, err := m.newClient(ctx, spec, m.config.WorkspaceDir)
if err != nil {
return nil, err
}
now := time.Now()
newSession := &managedSession{
key: sessionKey,
sessionID: sessionID,
language: normalizeLanguage(spec.Language),
service: NewService(client),
client: client,
lastUsed: now,
createdAt: now,
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[sessionKey]; ok {
_ = client.Close()
existing.lastUsed = now
return existing, nil
}
m.sessions[sessionKey] = newSession
return newSession, nil
}
func (m *Manager) evictLeastRecentlyUsedLocked() bool {
if len(m.sessions) == 0 {
return false
}
keys := make([]string, 0, len(m.sessions))
for key := range m.sessions {
keys = append(keys, key)
}
slices.SortFunc(keys, func(a, b string) int {
as := m.sessions[a]
bs := m.sessions[b]
if as.lastUsed.Before(bs.lastUsed) {
return -1
}
if as.lastUsed.After(bs.lastUsed) {
return 1
}
return strings.Compare(as.key, bs.key)
})
victimKey := keys[0]
victim := m.sessions[victimKey]
if m.config.Registry != nil {
_ = m.config.Registry.ReleaseSession(context.Background(), victim.language, victim.sessionID)
}
_ = victim.client.Close()
delete(m.sessions, victimKey)
return true
}
func buildSessionKey(language, sessionID string) string {
return language + ":" + normalizeSessionID(sessionID)
}
func normalizeSessionID(sessionID string) string {
sid := strings.TrimSpace(sessionID)
if sid == "" {
return "default"
}
return sid
}
func normalizeLanguage(language string) string {
return strings.ToLower(strings.TrimSpace(language))
}