524 lines
16 KiB
Go
524 lines
16 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
gorillaws "github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
|
"github.com/kandev/kandev/internal/agentruntime"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/lsp/installer"
|
|
"github.com/kandev/kandev/internal/lsp/protocol"
|
|
"github.com/kandev/kandev/internal/user/models"
|
|
)
|
|
|
|
// Custom WebSocket close codes for LSP connections.
|
|
const (
|
|
lspCloseBinaryNotFound = 4001
|
|
lspCloseSessionNotFound = 4002
|
|
lspCloseInstallFailed = 4003
|
|
lspCloseUnsupportedExecutor = 4004
|
|
lspCloseCapacityExceeded = 4005
|
|
lspCloseStreamError = 4006
|
|
lspCloseRuntimeStopped = 4010
|
|
lspCloseUnsupportedCloseText = "LSP is only supported for local_pc and local_docker tasks in this release"
|
|
lspCloseSessionNotFoundText = "session not found"
|
|
lspProxyWriteTimeout = 10 * time.Second
|
|
)
|
|
|
|
type lspMessageWriter interface {
|
|
SetWriteDeadline(time.Time) error
|
|
WriteMessage(int, []byte) error
|
|
}
|
|
|
|
// LSPUserService provides user settings for the LSP handler.
|
|
type LSPUserService interface {
|
|
GetUserSettings(ctx context.Context) (*models.UserSettings, error)
|
|
}
|
|
|
|
type lspLifecycleManager interface {
|
|
CheckSessionAccess(ctx context.Context, sessionID string) error
|
|
ResolveSessionRuntime(ctx context.Context, sessionID string) (agentruntime.Runtime, error)
|
|
GetOrEnsureExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error)
|
|
}
|
|
|
|
var (
|
|
errLSPUnsupportedExecutor = errors.New("unsupported LSP executor")
|
|
errLSPCapacityExceeded = errors.New("active LSP connection cap exceeded")
|
|
)
|
|
|
|
// LSPHandler handles browser-facing LSP WebSocket connections.
|
|
// The backend owns session/runtime policy and proxies raw LSP traffic to the
|
|
// task host's agentctl instance, where the language server process runs.
|
|
type LSPHandler struct {
|
|
lifecycleMgr lspLifecycleManager
|
|
userService LSPUserService
|
|
capacity *lspCapacityLimiter
|
|
logger *logger.Logger
|
|
continuityEnabled bool
|
|
leases *lspLeaseManager
|
|
sessionFence func(string) func()
|
|
settingsSub bus.Subscription
|
|
}
|
|
|
|
// EnableContinuity installs the runtime-owned lease manager. The session
|
|
// fence serializes lease admission with idle execution reclaim.
|
|
func (h *LSPHandler) EnableContinuity(acquireFence func(string) func(), eventBus bus.EventBus) {
|
|
h.continuityEnabled = true
|
|
h.sessionFence = acquireFence
|
|
h.leases = newLSPLeaseManager(h.capacity.max, h.logger)
|
|
if eventBus == nil {
|
|
return
|
|
}
|
|
subscription, err := eventBus.Subscribe(events.UserSettingsUpdated, h.handleUserSettingsUpdated)
|
|
if err != nil {
|
|
h.logger.Warn("failed to subscribe LSP leases to user settings updates", zap.Error(err))
|
|
return
|
|
}
|
|
h.settingsSub = subscription
|
|
}
|
|
|
|
// Close releases retained language servers and the settings subscription.
|
|
func (h *LSPHandler) Close() error {
|
|
if h.settingsSub != nil && h.settingsSub.IsValid() {
|
|
_ = h.settingsSub.Unsubscribe()
|
|
}
|
|
if h.leases != nil {
|
|
h.leases.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var lspUpgrader = gorillaws.Upgrader{
|
|
ReadBufferSize: 8192,
|
|
WriteBufferSize: 8192,
|
|
CheckOrigin: checkWebSocketOrigin,
|
|
}
|
|
|
|
// NewLSPHandler creates a new LSPHandler.
|
|
func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, log *logger.Logger, configuredMax ...int) *LSPHandler {
|
|
capacity := newLSPCapacityLimiterFromEnv()
|
|
if len(configuredMax) > 0 {
|
|
capacity = newLSPCapacityLimiter(configuredMax[0])
|
|
}
|
|
return &LSPHandler{
|
|
lifecycleMgr: lifecycleMgr,
|
|
userService: userService,
|
|
capacity: capacity,
|
|
logger: log.WithFields(zap.String("component", "lsp_handler")),
|
|
}
|
|
}
|
|
|
|
// HandleLSPConnection handles WebSocket connections at /lsp/:sessionId?language=...
|
|
func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
|
|
sessionID := c.Param("sessionId")
|
|
if sessionID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"})
|
|
return
|
|
}
|
|
|
|
language := c.Query("language")
|
|
if language == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "language query parameter is required"})
|
|
return
|
|
}
|
|
if !installer.IsSupported(language) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unsupported language: %s", language)})
|
|
return
|
|
}
|
|
if h.continuityEnabled {
|
|
h.handleContinuityConnection(c, sessionID, language)
|
|
return
|
|
}
|
|
|
|
h.logger.Info("LSP WebSocket connection request",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language))
|
|
|
|
execution, runtimeName, err := h.resolveLSPExecution(c.Request.Context(), sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, errLSPCapacityExceeded) {
|
|
h.logger.Info("LSP: capacity exceeded",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language))
|
|
h.closeWithCode(c, lspCloseCapacityExceeded, errLSPCapacityExceeded.Error())
|
|
return
|
|
}
|
|
if errors.Is(err, errLSPUnsupportedExecutor) {
|
|
h.logger.Info("LSP: unsupported runtime",
|
|
zap.String("session_id", sessionID),
|
|
zap.Stringer("runtime", runtimeName))
|
|
h.closeWithCode(c, lspCloseUnsupportedExecutor, lspCloseUnsupportedCloseText)
|
|
return
|
|
}
|
|
h.logger.Warn("LSP: session not found in lifecycle manager",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(err))
|
|
h.closeWithCode(c, lspCloseSessionNotFound, lspCloseSessionNotFoundText)
|
|
return
|
|
}
|
|
defer h.capacity.Release()
|
|
agentctlClient, releaseClient := execution.AcquireAgentCtlClient()
|
|
if agentctlClient == nil {
|
|
releaseClient()
|
|
h.logger.Warn("LSP: execution has no agentctl client", zap.String("session_id", sessionID))
|
|
h.closeWithCode(c, lspCloseSessionNotFound, "agentctl unavailable")
|
|
return
|
|
}
|
|
browserConn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if upgradeErr != nil {
|
|
releaseClient()
|
|
h.logger.Error("LSP: failed to upgrade to WebSocket",
|
|
zap.String("session_id", sessionID),
|
|
zap.Error(upgradeErr))
|
|
return
|
|
}
|
|
browserConn.SetReadLimit(protocol.MaxMessageBytes)
|
|
|
|
autoInstall := h.shouldAutoInstall(c.Request.Context(), language)
|
|
upstreamConn, resp, dialErr := agentctlClient.DialLSP(c.Request.Context(), language, autoInstall)
|
|
releaseClient()
|
|
if dialErr != nil {
|
|
status := 0
|
|
if resp != nil {
|
|
status = resp.StatusCode
|
|
}
|
|
h.logger.Warn("LSP: failed to connect to agentctl LSP stream",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language),
|
|
zap.Int("status", status),
|
|
zap.Error(dialErr))
|
|
closeLSPConnWithCode(browserConn, lspCloseSessionNotFound, "failed to connect to task host LSP stream")
|
|
return
|
|
}
|
|
upstreamConn.SetReadLimit(protocol.MaxMessageBytes)
|
|
defer func() { _ = upstreamConn.Close() }()
|
|
|
|
h.proxyLSPConnections(browserConn, upstreamConn, sessionID, language)
|
|
}
|
|
|
|
func (h *LSPHandler) handleContinuityConnection(c *gin.Context, sessionID, language string) {
|
|
releaseFence := func() {}
|
|
fenceHeld := true
|
|
if h.sessionFence != nil {
|
|
releaseFence = h.sessionFence(sessionID)
|
|
}
|
|
defer func() {
|
|
if fenceHeld {
|
|
releaseFence()
|
|
}
|
|
}()
|
|
execution, closeCode, closeMessage := h.resolveContinuityExecution(c.Request.Context(), sessionID)
|
|
if closeCode != 0 {
|
|
h.closeWithCode(c, closeCode, closeMessage)
|
|
return
|
|
}
|
|
userID, configuration, autoInstall := h.continuityUserSettings(c.Request.Context(), language)
|
|
|
|
browserConn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
h.logger.Error("LSP: failed to upgrade to WebSocket", zap.String("session_id", sessionID), zap.Error(err))
|
|
return
|
|
}
|
|
browserConn.SetReadLimit(protocol.MaxMessageBytes)
|
|
lease, generation, _, err := h.leases.attachOrCreate(
|
|
c.Request.Context(),
|
|
lspLeaseExecutionFromAgentExecution(execution),
|
|
language,
|
|
userID,
|
|
c.Query("leaseId"),
|
|
configuration,
|
|
autoInstall,
|
|
browserConn,
|
|
)
|
|
if err != nil {
|
|
code, message := lspLeaseAdmissionClose(err)
|
|
closeLSPConnWithCode(browserConn, code, message)
|
|
return
|
|
}
|
|
// Admission is now represented by the live lease pin. Do not hold the
|
|
// session lifecycle fence for the lifetime of the browser socket.
|
|
releaseFence()
|
|
fenceHeld = false
|
|
|
|
h.serveContinuityConnection(browserConn, lease, generation, sessionID, language)
|
|
}
|
|
|
|
func (h *LSPHandler) resolveContinuityExecution(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
) (*lifecycle.AgentExecution, int, string) {
|
|
if h.lifecycleMgr == nil || h.leases == nil {
|
|
return nil, lspCloseSessionNotFound, "LSP lease manager unavailable"
|
|
}
|
|
if h.lifecycleMgr.CheckSessionAccess(ctx, sessionID) != nil {
|
|
return nil, lspCloseSessionNotFound, lspCloseSessionNotFoundText
|
|
}
|
|
runtimeName, err := h.lifecycleMgr.ResolveSessionRuntime(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, lspCloseSessionNotFound, lspCloseSessionNotFoundText
|
|
}
|
|
if !lspRuntimeSupported(runtimeName) {
|
|
return nil, lspCloseUnsupportedExecutor, lspCloseUnsupportedCloseText
|
|
}
|
|
execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID)
|
|
if err != nil || execution == nil || execution.SessionID != sessionID {
|
|
return nil, lspCloseSessionNotFound, "agentctl unavailable"
|
|
}
|
|
if !lspRuntimeSupported(execution.RuntimeName) {
|
|
return nil, lspCloseUnsupportedExecutor, lspCloseUnsupportedCloseText
|
|
}
|
|
return execution, 0, ""
|
|
}
|
|
|
|
func (h *LSPHandler) continuityUserSettings(
|
|
ctx context.Context,
|
|
language string,
|
|
) (string, map[string]any, bool) {
|
|
userID := ""
|
|
if identity, ok := authn.IdentityFromContext(ctx); ok {
|
|
userID = identity.UserID
|
|
}
|
|
if h.userService == nil {
|
|
return userID, make(map[string]any), false
|
|
}
|
|
settings, err := h.userService.GetUserSettings(ctx)
|
|
if err != nil || settings == nil {
|
|
if err != nil {
|
|
h.logger.Debug("LSP: failed to load settings for a retained lease", zap.Error(err))
|
|
}
|
|
return userID, make(map[string]any), false
|
|
}
|
|
if userID == "" {
|
|
userID = settings.UserID
|
|
}
|
|
configuration := make(map[string]any)
|
|
for key, value := range settings.LspServerConfigs[language] {
|
|
configuration[key] = value
|
|
}
|
|
return userID, configuration, h.settingsAllowAutoInstall(settings, language)
|
|
}
|
|
|
|
func lspLeaseAdmissionClose(err error) (int, string) {
|
|
if errors.Is(err, errLSPCapacityExceeded) {
|
|
return lspCloseCapacityExceeded, errLSPCapacityExceeded.Error()
|
|
}
|
|
return lspCloseSessionNotFound, "failed to connect to task host LSP stream"
|
|
}
|
|
|
|
func (h *LSPHandler) serveContinuityConnection(
|
|
browserConn *gorillaws.Conn,
|
|
lease *lspLease,
|
|
generation uint64,
|
|
sessionID, language string,
|
|
) {
|
|
defer lease.detach(generation)
|
|
for {
|
|
messageType, message, readErr := browserConn.ReadMessage()
|
|
if readErr != nil {
|
|
return
|
|
}
|
|
if messageType != gorillaws.TextMessage {
|
|
continue
|
|
}
|
|
if err := lease.handleBrowserMessage(generation, message); err != nil {
|
|
h.logger.Debug("LSP browser frame failed", zap.String("session_id", sessionID), zap.String("language", language), zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func lspLeaseExecutionFromAgentExecution(execution *lifecycle.AgentExecution) lspLeaseExecution {
|
|
if execution == nil {
|
|
return lspLeaseExecution{}
|
|
}
|
|
return lspLeaseExecution{
|
|
ID: execution.ID,
|
|
SessionID: execution.SessionID,
|
|
TaskID: execution.TaskID,
|
|
acquireAgentCtlClient: func() (lspLeaseAgentCtlClient, func()) {
|
|
client, release := execution.AcquireAgentCtlClient()
|
|
return client, release
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *LSPHandler) resolveLSPExecution(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
) (*lifecycle.AgentExecution, agentruntime.Runtime, error) {
|
|
runtimeName, err := h.lifecycleMgr.ResolveSessionRuntime(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, runtimeName, err
|
|
}
|
|
if !lspRuntimeSupported(runtimeName) {
|
|
return nil, runtimeName, errLSPUnsupportedExecutor
|
|
}
|
|
if !h.capacity.TryAcquire() {
|
|
return nil, runtimeName, errLSPCapacityExceeded
|
|
}
|
|
releaseCapacity := true
|
|
defer func() {
|
|
if releaseCapacity {
|
|
h.capacity.Release()
|
|
}
|
|
}()
|
|
execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID)
|
|
if err != nil {
|
|
return nil, runtimeName, err
|
|
}
|
|
if execution == nil {
|
|
return nil, runtimeName, errors.New("lifecycle manager returned no execution")
|
|
}
|
|
if !lspRuntimeSupported(execution.RuntimeName) {
|
|
return nil, execution.RuntimeName, errLSPUnsupportedExecutor
|
|
}
|
|
releaseCapacity = false
|
|
return execution, execution.RuntimeName, nil
|
|
}
|
|
|
|
func lspRuntimeSupported(runtimeName agentruntime.Runtime) bool {
|
|
return runtimeName == agentruntime.RuntimeStandalone || runtimeName == agentruntime.RuntimeDocker
|
|
}
|
|
|
|
func (h *LSPHandler) shouldAutoInstall(ctx context.Context, language string) bool {
|
|
if h.userService == nil || !installer.SupportsAutoInstall(language) {
|
|
return false
|
|
}
|
|
settings, err := h.userService.GetUserSettings(ctx)
|
|
if err != nil || settings == nil {
|
|
if err != nil {
|
|
h.logger.Debug("LSP: failed to load user settings for auto-install", zap.Error(err))
|
|
}
|
|
return false
|
|
}
|
|
return h.settingsAllowAutoInstall(settings, language)
|
|
}
|
|
|
|
func (h *LSPHandler) settingsAllowAutoInstall(settings *models.UserSettings, language string) bool {
|
|
if settings == nil || !installer.SupportsAutoInstall(language) {
|
|
return false
|
|
}
|
|
for _, lang := range settings.LspAutoInstallLanguages {
|
|
if lang == language {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// closeWithCode upgrades the WebSocket and immediately closes it with the given code.
|
|
func (h *LSPHandler) closeWithCode(c *gin.Context, code int, text string) {
|
|
conn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(err))
|
|
return
|
|
}
|
|
closeLSPConnWithCode(conn, code, text)
|
|
}
|
|
|
|
func (h *LSPHandler) proxyLSPConnections(browserConn, upstreamConn *gorillaws.Conn, sessionID, language string) {
|
|
var closeOnce sync.Once
|
|
closeBoth := func() {
|
|
closeOnce.Do(func() {
|
|
_ = browserConn.Close()
|
|
_ = upstreamConn.Close()
|
|
})
|
|
}
|
|
|
|
done := make(chan struct{}, 2)
|
|
go func() {
|
|
h.copyLSPMessages("agentctl->browser", upstreamConn, browserConn, sessionID, language)
|
|
done <- struct{}{}
|
|
}()
|
|
go func() {
|
|
h.copyLSPMessages("browser->agentctl", browserConn, upstreamConn, sessionID, language)
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
<-done
|
|
closeBoth()
|
|
<-done
|
|
|
|
h.logger.Info("LSP: connection closed",
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language))
|
|
}
|
|
|
|
func (h *LSPHandler) copyLSPMessages(
|
|
direction string,
|
|
src *gorillaws.Conn,
|
|
dst lspMessageWriter,
|
|
sessionID, language string,
|
|
) {
|
|
for {
|
|
messageType, msg, err := src.ReadMessage()
|
|
if err != nil {
|
|
h.forwardLSPClose(dst, err)
|
|
if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) {
|
|
h.logger.Debug("LSP proxy read error",
|
|
zap.String("direction", direction),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language),
|
|
zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
if err := writeLSPProxyMessage(dst, messageType, msg); err != nil {
|
|
h.logger.Debug("LSP proxy write error",
|
|
zap.String("direction", direction),
|
|
zap.String("session_id", sessionID),
|
|
zap.String("language", language),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *LSPHandler) forwardLSPClose(dst lspMessageWriter, err error) {
|
|
if closeErr, ok := err.(*gorillaws.CloseError); ok {
|
|
if closeErr.Code == gorillaws.CloseNoStatusReceived || closeErr.Code == gorillaws.CloseAbnormalClosure {
|
|
_ = writeLSPProxyMessage(
|
|
dst,
|
|
gorillaws.CloseMessage,
|
|
gorillaws.FormatCloseMessage(lspCloseTransport, "LSP transport failed"),
|
|
)
|
|
return
|
|
}
|
|
_ = writeLSPProxyMessage(
|
|
dst,
|
|
gorillaws.CloseMessage,
|
|
gorillaws.FormatCloseMessage(closeErr.Code, closeErr.Text),
|
|
)
|
|
return
|
|
}
|
|
_ = writeLSPProxyMessage(
|
|
dst,
|
|
gorillaws.CloseMessage,
|
|
gorillaws.FormatCloseMessage(lspCloseStreamError, "LSP stream closed"),
|
|
)
|
|
}
|
|
|
|
func writeLSPProxyMessage(dst lspMessageWriter, messageType int, payload []byte) error {
|
|
if err := dst.SetWriteDeadline(time.Now().Add(lspProxyWriteTimeout)); err != nil {
|
|
return err
|
|
}
|
|
return dst.WriteMessage(messageType, payload)
|
|
}
|
|
|
|
func closeLSPConnWithCode(conn *gorillaws.Conn, code int, text string) {
|
|
_ = writeLSPProxyMessage(conn, gorillaws.CloseMessage, gorillaws.FormatCloseMessage(code, text))
|
|
_ = conn.Close()
|
|
}
|