Files
kandev/apps/backend/internal/agentctl/server/api/lsp.go
T

509 lines
14 KiB
Go

package api
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/lsp/protocol"
tools "github.com/kandev/kandev/internal/tools/installer"
"go.uber.org/zap"
)
const (
lspCloseBinaryNotFound = 4001
lspCloseInstallFailed = 4003
lspCloseServerExited = 4006
lspCloseAutoInstallUnsupported = 4007
lspCloseStartFailed = 4008
lspCloseTransportFailed = 4009
lspLanguageTypeScript = "typescript"
lspLanguagePython = "python"
lspLanguageGo = "go"
lspLanguageRust = "rust"
lspLanguageKey = "language"
lspStatusKey = "status"
lspStatusInstalling = "installing"
lspStatusInstalled = "installed"
lspStatusInstallFailed = "install_failed"
lspStatusReady = "ready"
lspWorkspacePathJSONKey = "workspacePath"
lspWorkspaceURIJSONKey = "workspaceUri"
lspRepoSubpathsJSONKey = "repoSubpaths"
lspStdinWriteTimeout = 30 * time.Second
lspWebSocketWriteTimeout = 5 * time.Second
)
var errLSPStdinWriteTimeout = errors.New("LSP stdin write timed out")
type lspServerProcess struct {
id string
stdin io.WriteCloser
stdout io.ReadCloser
done <-chan struct{}
// forwarderDone keeps caller-owned stdout open until its sole reader exits.
forwarderDone <-chan struct{}
}
type lspClientRead struct {
message []byte
err error
}
type lspInstallerRegistry interface {
BinaryPath(language string) (string, error)
StrategyFor(language string) (tools.Strategy, error)
}
type lspInstallCoordinator struct {
mu sync.Mutex
active map[string]chan struct{}
}
func newLSPInstallCoordinator() *lspInstallCoordinator {
return &lspInstallCoordinator{active: make(map[string]chan struct{})}
}
func (c *lspInstallCoordinator) run(ctx context.Context, key string, install func() (string, error)) (string, error) {
for {
if err := ctx.Err(); err != nil {
return "", err
}
c.mu.Lock()
wait, busy := c.active[key]
if !busy {
wait = make(chan struct{})
c.active[key] = wait
c.mu.Unlock()
defer c.release(key, wait)
return install()
}
c.mu.Unlock()
select {
case <-wait:
case <-ctx.Done():
return "", ctx.Err()
}
}
}
func (c *lspInstallCoordinator) release(key string, completed chan struct{}) {
c.mu.Lock()
if c.active[key] == completed {
delete(c.active, key)
close(completed)
}
c.mu.Unlock()
}
// lspInstallMutationKey groups installers by the files they can rewrite.
// npm updates prefix-wide metadata, while Go and release installs only
// collide when they publish the same binary target.
func lspInstallMutationKey(language string) string {
binary, _ := installer.LspCommand(language)
switch language {
case lspLanguageTypeScript, lspLanguagePython:
return "npm-prefix"
case lspLanguageGo:
return "go:" + binary
case lspLanguageRust:
return "release:" + binary
default:
return "language:" + language
}
}
var sharedLSPInstallCoordinator = newLSPInstallCoordinator()
func (s *Server) handleLSPStreamWS(c *gin.Context) {
language := c.Query("language")
if language == "" {
c.JSON(http.StatusBadRequest, gin.H{errKey: "language query parameter is required"})
return
}
if !installer.IsSupported(language) {
c.JSON(http.StatusBadRequest, gin.H{errKey: fmt.Sprintf("unsupported language: %s", language)})
return
}
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("LSP: failed to upgrade WebSocket", zap.Error(err))
return
}
conn.SetReadLimit(protocol.MaxMessageBytes)
releaseFencing := closeOnCredentialInvalidation(c, conn)
defer releaseFencing()
binaryPath, err := s.lspInstaller.BinaryPath(language)
if err != nil {
autoInstall := lspAutoInstallRequested(c)
if !installer.CanAutoInstall(language) {
_ = writeLSPMessage(conn, websocket.CloseMessage, websocket.FormatCloseMessage(
lspCloseAutoInstallUnsupported,
"auto-install unsupported on task host",
))
_ = conn.Close()
return
}
s.handleLSPBinaryNotFound(
c.Request.Context(),
conn,
language,
autoInstall,
err,
)
return
}
s.handleLSPBridge(conn, language, binaryPath, nil)
}
func lspAutoInstallRequested(c *gin.Context) bool {
value := c.Query("autoInstall")
return value == "1" || strings.EqualFold(value, "true")
}
func (s *Server) handleLSPBinaryNotFound(ctx context.Context, conn *websocket.Conn, language string, autoInstall bool, binaryErr error) {
if !autoInstall {
_ = writeLSPMessage(conn, websocket.CloseMessage, websocket.FormatCloseMessage(lspCloseBinaryNotFound, binaryErr.Error()))
_ = conn.Close()
return
}
if err := writeLSPJSONMessage(conn, map[string]string{lspStatusKey: lspStatusInstalling, lspLanguageKey: language}); err != nil {
s.logger.Warn("failed to send LSP installing status", zap.String("language", language), zap.Error(err))
_ = conn.Close()
return
}
installCtx, cancelInstall := context.WithCancel(ctx)
firstClientRead := readFirstLSPClientMessage(conn, cancelInstall)
binaryPath, err := s.awaitOrInstallLSP(installCtx, language)
cancelInstall()
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, process.ErrManagerStopping) {
s.logger.Debug("LSP auto-install canceled", zap.String("language", language))
_ = writeLSPMessage(conn, websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, ""))
_ = conn.Close()
return
}
s.logger.Error("LSP auto-install failed", zap.String("language", language), zap.Error(err))
if writeErr := writeLSPJSONMessage(conn, map[string]string{lspStatusKey: lspStatusInstallFailed, lspLanguageKey: language, errKey: err.Error()}); writeErr != nil {
s.logger.Warn("failed to send LSP install failure status", zap.String("language", language), zap.Error(writeErr))
}
_ = writeLSPMessage(conn, websocket.CloseMessage, websocket.FormatCloseMessage(lspCloseInstallFailed, "install failed"))
_ = conn.Close()
return
}
if err := writeLSPJSONMessage(conn, map[string]string{lspStatusKey: lspStatusInstalled, lspLanguageKey: language}); err != nil {
s.logger.Warn("failed to send LSP installed status", zap.String("language", language), zap.Error(err))
}
s.handleLSPBridge(conn, language, binaryPath, firstClientRead)
}
func readFirstLSPClientMessage(conn *websocket.Conn, cancel context.CancelFunc) <-chan lspClientRead {
result := make(chan lspClientRead, 1)
go func() {
_, message, err := conn.ReadMessage()
cancel()
result <- lspClientRead{message: message, err: err}
}()
return result
}
func (s *Server) handleLSPBridge(
conn *websocket.Conn,
language string,
binaryPath string,
firstClientRead <-chan lspClientRead,
) {
server, err := s.startLSPServer(language, binaryPath)
if err != nil {
s.logger.Error("LSP: failed to start language server", zap.String("language", language), zap.Error(err))
_ = writeLSPMessage(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(lspCloseStartFailed, ""),
)
_ = conn.Close()
return
}
ready := map[string]any{
lspStatusKey: lspStatusReady,
lspWorkspacePathJSONKey: s.cfg.WorkDir,
lspWorkspaceURIJSONKey: workspaceFileURI(s.cfg.WorkDir),
lspRepoSubpathsJSONKey: s.procMgr.RepoSubpaths(),
}
if err := writeLSPJSONMessage(conn, ready); err != nil {
s.stopLSPServer(server)
_ = conn.Close()
return
}
s.runLSPBridge(conn, language, server, firstClientRead)
}
// workspaceFileURI converts the task host's native workspace path into the
// canonical file URI consumed by both the language server and browser editor.
// Detecting drive and UNC forms here keeps path semantics on the task host
// instead of accidentally applying the browser machine's operating system.
func workspaceFileURI(path string) string {
normalized := path
isDrivePath := len(path) >= 3 && path[1] == ':' && (path[2] == '\\' || path[2] == '/')
isUNCPath := strings.HasPrefix(path, `\\`)
if isDrivePath || isUNCPath {
normalized = strings.ReplaceAll(path, `\`, "/")
}
if strings.HasPrefix(normalized, "//") {
parts := strings.Split(strings.TrimPrefix(normalized, "//"), "/")
if len(parts) >= 2 && parts[0] != "" {
return strictFileURL(parts[0], "/"+strings.Join(parts[1:], "/"), false)
}
}
isDriveURI := len(normalized) >= 3 && normalized[1] == ':' && normalized[2] == '/'
if isDriveURI {
normalized = "/" + normalized
}
return strictFileURL("", normalized, isDriveURI)
}
func strictFileURL(host, filePath string, preserveDriveColon bool) string {
segments := strings.Split(filePath, "/")
encoded := make([]string, len(segments))
for i, segment := range segments {
encoded[i] = strictURISegment(segment)
}
if preserveDriveColon && len(segments) > 1 {
encoded[1] = segments[1]
}
return (&url.URL{
Scheme: "file",
Host: host,
Path: filePath,
RawPath: strings.Join(encoded, "/"),
}).String()
}
func strictURISegment(segment string) string {
const hex = "0123456789ABCDEF"
var encoded strings.Builder
for _, value := range []byte(segment) {
if (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') || strings.ContainsRune("-._~", rune(value)) {
encoded.WriteByte(value)
continue
}
encoded.WriteByte('%')
encoded.WriteByte(hex[value>>4])
encoded.WriteByte(hex[value&0x0f])
}
return encoded.String()
}
func (s *Server) startLSPServer(language, binaryPath string) (*lspServerProcess, error) {
binary, args := installer.LspCommand(language)
if binaryPath != "" {
binary = binaryPath
}
sessionID := s.cfg.SessionID
if sessionID == "" {
sessionID = s.cfg.InstanceID
}
if sessionID == "" {
sessionID = "lsp"
}
proc, err := s.procMgr.StartPipedProcess(process.PipedStartRequest{
SessionID: sessionID,
Kind: types.ProcessKindCustom,
ScriptName: "lsp-" + language,
Command: binary,
Args: args,
WorkingDir: s.cfg.WorkDir,
})
if err != nil {
return nil, fmt.Errorf("failed to start %s: %w", binary, err)
}
return &lspServerProcess{id: proc.ID, stdin: proc.Stdin, stdout: proc.Stdout, done: proc.Done}, nil
}
func (s *Server) stopLSPServer(server *lspServerProcess) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.stopLSPServerWithContext(ctx, server)
}
func (s *Server) stopLSPServerWithContext(ctx context.Context, server *lspServerProcess) {
_ = server.stdin.Close()
if err := s.procMgr.StopProcess(ctx, process.StopProcessRequest{ProcessID: server.id}); err != nil {
s.logger.Debug("LSP process already stopped", zap.String("process_id", server.id), zap.Error(err))
}
timedOut := false
select {
case <-server.done:
case <-ctx.Done():
timedOut = true
s.logger.Warn("timed out waiting for LSP process teardown", zap.String("process_id", server.id))
}
if timedOut {
_ = server.stdout.Close()
}
if server.forwarderDone != nil {
<-server.forwarderDone
}
if !timedOut {
_ = server.stdout.Close()
}
}
func (s *Server) runLSPBridge(
conn *websocket.Conn,
language string,
server *lspServerProcess,
firstClientRead <-chan lspClientRead,
) {
done := make(chan struct{})
server.forwarderDone = done
go func() {
defer close(done)
defer func() { _ = conn.Close() }()
defer func() { _ = server.stdin.Close() }()
reader := bufio.NewReader(server.stdout)
for {
msg, err := protocol.ReadMessage(reader)
if err != nil {
if err != io.EOF {
s.logger.Debug("LSP stdout read error", zap.String("language", language), zap.Error(err))
}
closeCode := lspCloseTransportFailed
select {
case <-server.done:
closeCode = lspCloseServerExited
case <-time.After(100 * time.Millisecond):
}
_ = writeLSPMessage(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(closeCode, ""),
)
return
}
if wErr := writeLSPMessage(conn, websocket.TextMessage, msg); wErr != nil {
s.logger.Debug("LSP WebSocket write error", zap.String("language", language), zap.Error(wErr))
return
}
}
}()
for {
var msg []byte
var err error
if firstClientRead != nil {
result := <-firstClientRead
firstClientRead = nil
msg, err = result.message, result.err
} else {
_, msg, err = conn.ReadMessage()
}
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
s.logger.Debug("LSP WebSocket read error", zap.String("language", language), zap.Error(err))
}
break
}
if err := writeLSPStdinFrame(server.stdin, msg); err != nil {
s.logger.Debug("LSP stdin write error", zap.String("language", language), zap.Error(err))
break
}
}
_ = conn.Close()
s.stopLSPServer(server)
}
func writeLSPStdinFrame(stdin io.WriteCloser, msg []byte) error {
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
frame := make([]byte, 0, len(header)+len(msg))
frame = append(frame, header...)
frame = append(frame, msg...)
return writeLSPStdinWithTimeout(stdin, frame, lspStdinWriteTimeout)
}
func writeLSPStdinWithTimeout(stdin io.WriteCloser, data []byte, timeout time.Duration) error {
closeResult := make(chan error, 1)
timer := time.AfterFunc(timeout, func() {
closeResult <- stdin.Close()
})
written, writeErr := stdin.Write(data)
if !timer.Stop() {
return errors.Join(errLSPStdinWriteTimeout, writeErr, <-closeResult)
}
if writeErr != nil {
return writeErr
}
if written != len(data) {
return io.ErrShortWrite
}
return nil
}
func writeLSPMessage(conn *websocket.Conn, messageType int, data []byte) error {
if err := conn.SetWriteDeadline(time.Now().Add(lspWebSocketWriteTimeout)); err != nil {
return err
}
return conn.WriteMessage(messageType, data)
}
func (s *Server) awaitOrInstallLSP(ctx context.Context, language string) (string, error) {
installCtx, release, err := s.procMgr.BeginOwnedOperation(ctx)
if err != nil {
return "", err
}
defer release()
return sharedLSPInstallCoordinator.run(installCtx, lspInstallMutationKey(language), func() (string, error) {
if binaryPath, err := s.lspInstaller.BinaryPath(language); err == nil {
return binaryPath, nil
}
strategy, err := s.lspInstaller.StrategyFor(language)
if err != nil {
return "", err
}
result, err := strategy.Install(installCtx)
if err != nil {
return "", err
}
return result.BinaryPath, nil
})
}
func writeLSPJSONMessage(conn *websocket.Conn, data any) error {
msg, err := json.Marshal(data)
if err != nil {
return err
}
return writeLSPMessage(conn, websocket.TextMessage, msg)
}