Files
MonocoEditor-With-Lsp-Backend/backend/cmd/server/main.go
meowrain 57afb90bc0 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.
2026-02-15 16:22:01 +08:00

309 lines
7.8 KiB
Go

package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"os/user"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/gin-gonic/gin"
"monica-go-completion-backend/internal/api"
"monica-go-completion-backend/internal/cluster"
"monica-go-completion-backend/internal/completion"
"monica-go-completion-backend/internal/lsp"
)
var requestIDSeed atomic.Int64
type config struct {
Port string
WorkspaceDir string
AllowOrigin string
APIToken string
RequestTimeout time.Duration
MaxBodyBytes int64
SessionTTL time.Duration
CleanupInterval time.Duration
MaxSessions int
InstanceID string
InstanceURL string
EnableRedis bool
RedisAddr string
RedisPassword string
RedisDB int
RedisKeyPrefix string
InstanceTTL time.Duration
Heartbeat time.Duration
Servers []completion.LanguageServerSpec
}
func main() {
cfg := loadConfig()
var registry completion.SessionRegistry
if cfg.EnableRedis {
var err error
registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{
Addr: cfg.RedisAddr,
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
KeyPrefix: cfg.RedisKeyPrefix,
InstanceID: cfg.InstanceID,
InstanceEndpoint: cfg.InstanceURL,
SessionTTL: cfg.SessionTTL,
InstanceTTL: cfg.InstanceTTL,
HeartbeatInterval: cfg.Heartbeat,
})
if err != nil {
log.Fatalf("create redis registry failed: %v", err)
}
}
manager := completion.NewManager(completion.ManagerConfig{
WorkspaceDir: cfg.WorkspaceDir,
MaxSessions: cfg.MaxSessions,
SessionTTL: cfg.SessionTTL,
CleanupInterval: cfg.CleanupInterval,
InstanceID: cfg.InstanceID,
Registry: registry,
}, cfg.Servers, func(ctx context.Context, spec completion.LanguageServerSpec, workspaceDir string) (completion.RuntimeClient, error) {
return lsp.NewClient(ctx, lsp.Config{
Command: spec.Command,
Args: spec.Args,
RootPath: workspaceDir,
LanguageID: spec.LanguageID,
ClientName: "monica-lsp-gateway",
})
})
defer func() {
_ = manager.Close()
}()
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
router.Use(requestIDMiddleware())
router.Use(corsMiddleware(cfg.AllowOrigin))
router.Use(apiTokenMiddleware(cfg.APIToken))
api.RegisterRoutes(router, manager, api.RouteOptions{
RequestTimeout: cfg.RequestTimeout,
MaxBodyBytes: cfg.MaxBodyBytes,
})
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf(
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t",
cfg.Port,
cfg.WorkspaceDir,
cfg.InstanceID,
cfg.EnableRedis,
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server failed: %v", err)
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown failed: %v", err)
}
if err := manager.Close(); err != nil {
log.Printf("lsp manager close failed: %v", err)
}
}
func loadConfig() config {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
return config{
Port: getenv("PORT", "8080"),
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")),
RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second),
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20),
SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute),
CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute),
MaxSessions: getenvInt("MAX_SESSIONS", 256),
InstanceID: getenv("INSTANCE_ID", defaultInstanceID()),
InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")),
EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true),
RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"),
RedisPassword: getenv("REDIS_PASSWORD", ""),
RedisDB: getenvInt("REDIS_DB", 1),
RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"),
InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second),
Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second),
Servers: []completion.LanguageServerSpec{
{
Language: "go",
LanguageID: "go",
Command: getenv("GO_LSP_COMMAND", "gopls"),
Args: getenvArgs("GO_LSP_ARGS", ""),
},
{
Language: "javascript",
LanguageID: "javascript",
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"),
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"),
},
{
Language: "typescript",
LanguageID: "typescript",
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"),
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"),
},
},
}
}
func defaultInstanceID() string {
host, err := os.Hostname()
if err != nil || strings.TrimSpace(host) == "" {
host = "unknown-host"
}
userName := "unknown-user"
if u, err := user.Current(); err == nil && strings.TrimSpace(u.Username) != "" {
userName = strings.ReplaceAll(u.Username, "\\", "-")
}
return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid())
}
func getenv(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
return v
}
func getenvArgs(key, fallback string) []string {
value := getenv(key, fallback)
if value == "" {
return nil
}
return strings.Fields(value)
}
func getenvInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := strconv.Atoi(v)
if err == nil {
return parsed
}
return fallback
}
func getenvInt64(key string, fallback int64) int64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err == nil {
return parsed
}
return fallback
}
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
parsed, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return parsed
}
func getenvBool(key string, fallback bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" {
return fallback
}
switch v {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", allowOrigin)
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-API-Key")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func apiTokenMiddleware(token string) gin.HandlerFunc {
required := strings.TrimSpace(token)
if required == "" {
return func(c *gin.Context) {
c.Next()
}
}
return func(c *gin.Context) {
provided := strings.TrimSpace(c.GetHeader("X-API-Key"))
if provided == required {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
}
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
if rid == "" {
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
}
c.Header("X-Request-Id", rid)
c.Set("requestId", rid)
c.Next()
}
}