feat: keep language servers alive across browser reconnects (#3899)

This commit is contained in:
Carlos Florêncio
2026-09-24 13:34:03 +01:00
committed by GitHub
parent dca3ee3484
commit a78261f304
88 changed files with 6856 additions and 413 deletions
+1
View File
@@ -171,6 +171,7 @@ history and remains immutable.
### Observability
- In dev mode (`KANDEV_MOCK_AGENT=true` or `debug.pprofEnabled`), `/debug/vars` exposes the stdlib expvar handler. Office provider-routing metrics live under `routing_*` (route attempts, fallbacks, parked runs, provider degraded/recovered counters). The metrics are also still emitted as structured `routing.metric.*` zap logs for human debugging.
- Task-owned browser LSP continuity exposes `lsp_lease_active`, `lsp_lease_detached`, `lsp_lease_evicted_total`, and `lsp_lease_released_total`. Release reasons are the closed set `stop`, `editor_idle`, `detached_timeout`, `capacity`, `server_exit`, `runtime_stop`, and `backend_stop`; lease, task, session, execution, user, and language identifiers are never metric labels. The feature is restart-required and disabled in every shipped profile.
- ADR 0015's step-completion-signal telemetry lives under `workflow_*`: `workflow_step_completion_signal_received_total` (`internal/workflow/signalmetrics/`), labelled by `source` and `agent_type`, counts accepted `step_complete_kandev` signals. Its separate `workflow_step_completion_signal_fallback_used_total` counter is labelled by `agent_type` and counts manual fallback uses. The fallback button does not have a production increment site yet, so the counter remains zero until that UI ships.
- Office stall detection (REQ-OFFICE-STALL-VISIBILITY) lives under `office_stall_*`: `office_stall_stranded_signal_total` (labelled by `gate`, which names which of the two watchdog gate sites saw it), `office_stall_decision_waiting_total`, and `office_stall_detector_skipped_total` (labelled by `reason`, so a detector that fails closed is visible rather than silent). Detection only: these counters never accompany a transition, a synthesized decision, or a queued run, because Office tasks are surfaced and never reclaimed. Also emitted as structured zap logs.
- Office session-termination suppression (REQ-OFFICE-SESSION-TERM) lives under `office_session_term_suppressed_total`, labelled by `reason` (one of `task_reassigned`, `participant_removed`, `participant_seat_claimed`) and `outcome` (`runner` or `seat` when the agent still holds that capacity, `read_failed` when the capacity read itself failed). It counts guarded terminations that left a session live, so a suppression is distinguishable from a path that never ran. Both dimensions are closed sets; no task, agent, step or session identifier is ever a label. Also emitted as structured zap logs.
+1 -1
View File
@@ -62,7 +62,7 @@ apps/backend/
│ │ # costs, dashboard, infra, labels, onboarding, projects, repository, runtime,
│ │ # routines, routing, scheduler, service, shared, skills, workspaces)
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── gateway/ # WebSocket gateway, including task-owned LSP lease lifecycle
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
│ ├── githubauth/ # Shared GitHub credential-broker environment contract
│ ├── common/ # Shared utilities, config, logger
@@ -29,6 +29,7 @@ const (
lspCloseServerExited = 4006
lspCloseAutoInstallUnsupported = 4007
lspCloseStartFailed = 4008
lspCloseTransportFailed = 4009
lspLanguageTypeScript = "typescript"
lspLanguagePython = "python"
@@ -395,10 +396,16 @@ func (s *Server) runLSPBridge(
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(lspCloseServerExited, ""),
websocket.FormatCloseMessage(closeCode, ""),
)
return
}
@@ -272,6 +272,40 @@ func TestRunLSPBridgeUsesCategoricalCloseWhenServerExits(t *testing.T) {
}
}
func TestRunLSPBridgeUsesTransportCloseWhenProcessMayStillBeRunning(t *testing.T) {
server := newTestServer(t)
processDone := make(chan struct{})
lspProcess := &lspServerProcess{
id: "live-server-with-broken-output",
stdin: discardLSPWriteCloser{},
stdout: io.NopCloser(strings.NewReader("")),
done: processDone,
}
httpServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
conn, err := server.upgrader.Upgrade(writer, request, nil)
if err == nil {
server.runLSPBridge(conn, "kotlin", lspProcess, nil)
}
}))
t.Cleanup(httpServer.Close)
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(httpServer.URL, "http"), nil)
if err != nil {
t.Fatalf("dial bridge: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err = conn.ReadMessage()
closeErr, ok := err.(*websocket.CloseError)
if !ok {
t.Fatalf("transport failure = %T %v, want WebSocket close", err, err)
}
if closeErr.Code != 4009 {
t.Fatalf("transport close = %d, want 4009", closeErr.Code)
}
close(processDone)
}
func TestStopLSPServerTimeoutClosesStdoutBeforeJoiningForwarder(t *testing.T) {
server := newTestServer(t)
stdout := newOrderedLSPReadCloser()
@@ -121,6 +121,8 @@ func provideGateway(
authSvc *auth.Service,
dataDir string,
registerCleanup func(func() error),
lspContinuityEnabled bool,
acquireSessionFence func(string) func(),
lspMaxConnections ...int,
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) {
gateway, err := gateways.Provide(log)
@@ -147,6 +149,13 @@ func provideGateway(
if lifecycleMgr != nil {
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspMaxConnections...)
if lspContinuityEnabled {
gateway.LSPHandler.EnableContinuity(acquireSessionFence, eventBus)
orchestratorSvc.SetLSPLeaseLifecycle(gateway.LSPHandler)
if registerCleanup != nil {
registerCleanup(gateway.LSPHandler.Close)
}
}
gateway.SetVscodeProxy(lifecycleMgr)
gateway.SetPortProxy(lifecycleMgr)
gateway.SetPortTunnel(lifecycleMgr)
+2
View File
@@ -1068,6 +1068,8 @@ func startGatewayAndServe(
services.Auth,
cfg.ResolvedHomeDir(),
func(fn func() error) { addCleanup(fn) },
cfg.Features.LSPBrowserContinuity,
orchestratorSvc.AcquireSessionLifecycleFence,
cfg.Limits.LSPMaxConnections,
)
if terminalSvc != nil {
@@ -119,7 +119,7 @@ var startupCatalog = []CatalogEntry{
{Key: "credentials.file", EnvVars: []string{"KANDEV_CREDENTIALS_FILE"}, Owner: "credentials", Default: ""},
{Key: "limits.ghMaxConcurrent", EnvVars: []string{"KANDEV_GH_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "8"},
{Key: "limits.gitMaxConcurrent", EnvVars: []string{"KANDEV_GIT_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "12"},
{Key: "limits.lspMaxConnections", EnvVars: []string{"KANDEV_LSP_MAX_CONNECTIONS"}, Owner: "LSP gateway", Default: "8"},
{Key: "limits.lspMaxConnections", EnvVars: []string{"KANDEV_LSP_MAX_CONNECTIONS"}, Owner: "LSP gateway", Default: "8", Description: "Maximum active LSP server leases, including leases detached from a browser."},
{Key: "messageQueue.maxPerSession", EnvVars: []string{"KANDEV_QUEUE_MAX_PER_SESSION"}, Owner: "message queue", Default: "10"},
{Key: "agentctl.idleTimeout", EnvVars: []string{"KANDEV_ACP_IDLE_TIMEOUT"}, Owner: "agentctl", Default: "1h"},
{Key: "agentctl.idleReaperInterval", EnvVars: []string{"KANDEV_ACP_IDLE_REAPER_INTERVAL"}, Owner: "agentctl", Default: "1m"},
@@ -20,6 +20,7 @@ func TestConfigurationCatalogIsComplete(t *testing.T) {
"tasks.preparationTimeout",
"credentials.file",
"limits.ghMaxConcurrent",
"limits.lspMaxConnections",
"messageQueue.maxPerSession",
"agentctl.notificationQueueCapacity",
"launcher.noBrowser",
@@ -28,6 +29,10 @@ func TestConfigurationCatalogIsComplete(t *testing.T) {
t.Errorf("catalog is missing %q", key)
}
}
entry, ok := CatalogEntryForKey("limits.lspMaxConnections")
if !ok || !strings.Contains(entry.Description, "including leases detached from a browser") {
t.Fatalf("LSP capacity description = %q, want detached leases included", entry.Description)
}
}
func TestConfigurationCatalogMatchesAuditedEnvironmentInventory(t *testing.T) {
@@ -488,6 +488,10 @@ type OfficeConfig struct {
//
// See docs/decisions/0007-runtime-feature-flags.md for the pattern and rollout policy.
type FeaturesConfig struct {
// LSPBrowserContinuity gates runtime-owned language-server leases that stay
// connected across browser attachment loss. Off in every embedded profile.
LSPBrowserContinuity bool `mapstructure:"lsp_browser_continuity" json:"lspBrowserContinuity"`
// Office gates the autonomous-agent feature: backend service construction,
// HTTP/WS route registration, and frontend nav/route visibility.
Office bool `mapstructure:"office" json:"office"`
@@ -10,11 +10,14 @@ import (
"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"
@@ -28,7 +31,9 @@ const (
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
)
@@ -43,6 +48,7 @@ type LSPUserService interface {
}
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)
}
@@ -56,10 +62,42 @@ var (
// 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
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{
@@ -99,6 +137,10 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
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),
@@ -123,7 +165,7 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
h.logger.Warn("LSP: session not found in lifecycle manager",
zap.String("session_id", sessionID),
zap.Error(err))
h.closeWithCode(c, lspCloseSessionNotFound, "session not found")
h.closeWithCode(c, lspCloseSessionNotFound, lspCloseSessionNotFoundText)
return
}
defer h.capacity.Release()
@@ -166,6 +208,152 @@ func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
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,
@@ -215,6 +403,13 @@ func (h *LSPHandler) shouldAutoInstall(ctx context.Context, language string) boo
}
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
@@ -293,6 +488,14 @@ func (h *LSPHandler) copyLSPMessages(
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,
@@ -16,6 +16,7 @@ import (
gorillaws "github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/lsp/protocol"
"github.com/kandev/kandev/internal/user/models"
@@ -23,6 +24,7 @@ import (
type staticLSPUserService struct {
settings *models.UserSettings
err error
}
type recordingLSPLifecycleManager struct {
@@ -31,6 +33,11 @@ type recordingLSPLifecycleManager struct {
resolveErr error
ensureErr error
ensureCalls int
accessErr error
}
func (m *recordingLSPLifecycleManager) CheckSessionAccess(context.Context, string) error {
return m.accessErr
}
func (m *recordingLSPLifecycleManager) ResolveSessionRuntime(context.Context, string) (agentruntime.Runtime, error) {
@@ -62,7 +69,36 @@ func (w *recordingLSPMessageWriter) WriteMessage(messageType int, payload []byte
}
func (s staticLSPUserService) GetUserSettings(context.Context) (*models.UserSettings, error) {
return s.settings, nil
return s.settings, s.err
}
func TestContinuityUserSettingsFailureKeepsAuthenticatedOwner(t *testing.T) {
handler := &LSPHandler{userService: staticLSPUserService{err: errors.New("settings unavailable")}, logger: testLogger()}
ctx := authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-1"})
userID, configuration, autoInstall := handler.continuityUserSettings(ctx, "go")
if userID != "user-1" {
t.Fatalf("settings owner = %q, want authenticated user", userID)
}
if len(configuration) != 0 || autoInstall {
t.Fatalf("settings fallback = (%#v, %v), want empty configuration and auto-install disabled", configuration, autoInstall)
}
}
func TestResolveContinuityExecutionRequiresSessionAccess(t *testing.T) {
manager := &recordingLSPLifecycleManager{
runtimeName: agentruntime.RuntimeStandalone,
execution: &lifecycle.AgentExecution{ID: "execution-1", SessionID: "session-1"},
accessErr: errors.New("not authorized"),
}
handler := &LSPHandler{lifecycleMgr: manager, leases: newLSPLeaseManager(1, testLogger())}
execution, closeCode, _ := handler.resolveContinuityExecution(context.Background(), "session-1")
if execution != nil || closeCode != lspCloseSessionNotFound {
t.Fatalf("continuity execution = (%v, %d), want access denied", execution, closeCode)
}
if manager.ensureCalls != 0 {
t.Fatalf("GetOrEnsureExecution calls = %d, want none after access denial", manager.ensureCalls)
}
}
func TestReadLSPMessage_Valid(t *testing.T) {
@@ -0,0 +1,784 @@
package websocket
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/lsp/protocol"
"go.uber.org/zap"
)
const (
lspLeaseSnapshotLimit = 4 << 20
lspLeaseDialTimeout = 15 * time.Second
lspLeaseCloseTimeout = 5 * time.Second
lspLeaseClientRequestTimeout = 30 * time.Second
lspLeaseMaxPendingClientRequests = 256
lspLeaseMaxClientRequestIDBytes = 256
lspLeaseMaxPendingClientRequestBytes = 64 << 10
lspLeaseDetachedTimeout = time.Hour
lspCloseTransport = 4009
)
const (
lspLeaseReleaseStop = "stop"
lspLeaseReleaseEditorIdle = "editor_idle"
lspLeaseReleaseDetachedTimeout = "detached_timeout"
lspLeaseReleaseCapacity = "capacity"
lspLeaseReleaseServerExit = "server_exit"
lspLeaseReleaseRuntimeStop = "runtime_stop"
lspLeaseReleaseBackendStop = "backend_stop"
)
type lspLeaseManager struct {
mu sync.Mutex
admissionMu sync.Mutex
leases map[string]*lspLease
max int
detachedTimeout time.Duration
closed bool
logger *logger.Logger
}
type lspLeaseAgentCtlClient interface {
DialLSP(context.Context, string, bool) (*websocket.Conn, *http.Response, error)
}
type lspLeaseExecution struct {
ID string
SessionID string
TaskID string
acquireAgentCtlClient func() (lspLeaseAgentCtlClient, func())
}
type lspLease struct {
mu sync.Mutex
manager *lspLeaseManager
id string
sessionID string
taskID string
executionID string
userID string
language string
upstream *websocket.Conn
upstreamWriteMu sync.Mutex
browserWriteMu sync.Mutex
browser *websocket.Conn
generation uint64
detachedAt time.Time
expiryTimer *time.Timer
closed bool
readDone chan struct{}
ready bool
readyStatus map[string]any
workspacePath string
workspaceURI string
repoSubpaths []string
initializeResult []byte
initializeServerID []byte
initializeResponse []byte
initializeWaiter *lspLeaseInitializeWaiter
initializedReceived bool
serverCapabilities []byte
registrations map[string][]byte
progressTokens map[string][]byte
progress map[string][]byte
configuration map[string]any
configurationSnapshotBytes int
documentVersions map[string]int64
documentVersionsSnapshotBytes int
registrationSnapshotBytes int
progressTokenSnapshotBytes int
progressSnapshotBytes int
openDocuments map[string]int64
synchronizedDocs map[string]bool
clientRequests map[string]lspLeaseClientRequest
pendingClientRequestBytes int
clientRequestTimeout time.Duration
serverRequests map[string]lspLeaseServerRequest
brokerRequests map[string]chan jsonRPCResponse
requestCounter uint64
resumedGeneration bool
}
type lspLeaseClientRequest struct {
serverID json.RawMessage
generation uint64
clientID []byte
byteSize int
timer *time.Timer
}
type lspLeaseInitializeWaiter struct {
generation uint64
clientID []byte
}
type lspLeaseServerRequest struct {
serverID []byte
generation uint64
method string
}
func newLSPLeaseManager(max int, log *logger.Logger) *lspLeaseManager {
if max <= 0 {
max = defaultLSPMaxConnections
}
return &lspLeaseManager{leases: make(map[string]*lspLease), max: max, detachedTimeout: lspLeaseDetachedTimeout, logger: log.WithFields(zap.String("component", "lsp_lease_manager"))}
}
func (m *lspLeaseManager) attachOrCreate(
ctx context.Context,
execution lspLeaseExecution,
language, userID, leaseHint string,
configuration map[string]any,
autoInstall bool,
browser *websocket.Conn,
) (*lspLease, uint64, bool, error) {
m.admissionMu.Lock()
defer m.admissionMu.Unlock()
if execution.ID == "" {
return nil, 0, false, errors.New("LSP execution has no stable identity")
}
m.expireDetachedLeases()
if err := m.retireReplacedLeases(execution); err != nil {
return nil, 0, false, err
}
if lease := m.detachedCandidate(execution, language, userID, leaseHint); lease != nil {
return m.reattachLease(lease, language, configuration, browser)
}
if err := m.ensureCapacity(); err != nil {
return nil, 0, false, err
}
return m.createLease(ctx, execution, language, userID, configuration, autoInstall, browser)
}
func (m *lspLeaseManager) retireReplacedLeases(execution lspLeaseExecution) error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return errors.New("LSP lease manager is closed")
}
var stale []*lspLease
for _, lease := range m.leases {
if lease.sessionID == execution.SessionID && lease.executionID != execution.ID {
stale = append(stale, lease)
}
}
m.mu.Unlock()
for _, lease := range stale {
lease.terminate(lspCloseTransport, "execution replaced", lspLeaseReleaseRuntimeStop)
lease.waitForClose(lspLeaseCloseTimeout)
}
return nil
}
func (m *lspLeaseManager) reattachLease(
lease *lspLease,
language string,
configuration map[string]any,
browser *websocket.Conn,
) (*lspLease, uint64, bool, error) {
if err := lease.updateConfiguration(configuration, true); err != nil {
lease.terminate(lspCloseTransport, "LSP state exceeded its bound", lspLeaseReleaseRuntimeStop)
return nil, 0, false, err
}
generation, resumed, err := lease.attach(browser)
if err != nil {
return nil, 0, false, err
}
m.logger.Debug("reattached language server lease", zap.String("language", language))
return lease, generation, resumed, nil
}
func (m *lspLeaseManager) createLease(
ctx context.Context,
execution lspLeaseExecution,
language, userID string,
configuration map[string]any,
autoInstall bool,
browser *websocket.Conn,
) (*lspLease, uint64, bool, error) {
if execution.acquireAgentCtlClient == nil {
return nil, 0, false, errors.New("agentctl unavailable")
}
client, releaseClient := execution.acquireAgentCtlClient()
if releaseClient == nil {
releaseClient = func() {}
}
if client == nil {
releaseClient()
return nil, 0, false, errors.New("agentctl unavailable")
}
dialCtx, cancel := context.WithTimeout(ctx, lspLeaseDialTimeout)
upstream, response, err := client.DialLSP(dialCtx, language, autoInstall)
cancel()
releaseClient()
if err != nil {
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
return nil, 0, false, err
}
upstream.SetReadLimit(protocol.MaxMessageBytes)
lease := newLSPLease(m, execution, language, userID, configuration, upstream)
if err := m.add(lease); err != nil {
_ = upstream.Close()
return nil, 0, false, err
}
generation, _, err := lease.attach(browser)
if err != nil {
lease.terminate(lspCloseTransport, "browser attachment failed", lspLeaseReleaseRuntimeStop)
return nil, 0, false, err
}
go lease.readUpstream()
m.logger.Debug("created language server lease", zap.String("language", language))
return lease, generation, false, nil
}
func newLSPLease(
manager *lspLeaseManager,
execution lspLeaseExecution,
language, userID string,
configuration map[string]any,
upstream *websocket.Conn,
) *lspLease {
lease := &lspLease{
manager: manager,
id: uuid.NewString(),
sessionID: execution.SessionID,
taskID: execution.TaskID,
executionID: execution.ID,
userID: userID,
language: language,
upstream: upstream,
readDone: make(chan struct{}),
registrations: make(map[string][]byte),
progressTokens: make(map[string][]byte),
progress: make(map[string][]byte),
configuration: cloneStringAnyMap(configuration),
documentVersions: make(map[string]int64),
openDocuments: make(map[string]int64),
synchronizedDocs: make(map[string]bool),
clientRequests: make(map[string]lspLeaseClientRequest),
serverRequests: make(map[string]lspLeaseServerRequest),
brokerRequests: make(map[string]chan jsonRPCResponse),
clientRequestTimeout: lspLeaseClientRequestTimeout,
}
if encoded, err := json.Marshal(lease.configuration); err == nil {
lease.configurationSnapshotBytes = len(encoded)
}
return lease
}
func (m *lspLeaseManager) detachedCandidate(
execution lspLeaseExecution,
language, userID, leaseHint string,
) *lspLease {
m.mu.Lock()
defer m.mu.Unlock()
var candidates []*lspLease
for _, lease := range m.leases {
if lease.sessionID != execution.SessionID || lease.executionID != execution.ID || lease.language != language || lease.userID != userID {
continue
}
if leaseHint != "" && lease.id == leaseHint {
if lease.isDetached() {
return lease
}
// A copied tab may inherit an attached tab's hint. Give it a new
// independent lease instead of claiming a different detached window.
return nil
}
if leaseHint != "" {
continue
}
if !lease.isDetached() {
continue
}
candidates = append(candidates, lease)
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].detachedTime().After(candidates[j].detachedTime())
})
if len(candidates) == 0 {
return nil
}
return candidates[0]
}
func (m *lspLeaseManager) ensureCapacity() error {
m.expireDetachedLeases()
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return errors.New("LSP lease manager is closed")
}
if len(m.leases) < m.max {
m.mu.Unlock()
return nil
}
var oldest *lspLease
for _, lease := range m.leases {
if !lease.isDetached() {
continue
}
if oldest == nil || lease.detachedTime().Before(oldest.detachedTime()) {
oldest = lease
}
}
m.mu.Unlock()
if oldest == nil {
return errLSPCapacityExceeded
}
oldest.terminate(lspCloseTransport, "language server lease evicted at capacity", lspLeaseReleaseCapacity)
oldest.waitForClose(lspLeaseCloseTimeout)
return nil
}
func (m *lspLeaseManager) expireDetachedLeases() {
m.mu.Lock()
leases := make([]*lspLease, 0, len(m.leases))
for _, lease := range m.leases {
leases = append(leases, lease)
}
m.mu.Unlock()
for _, lease := range leases {
lease.expireDetached(lease.detachedTime())
}
}
func (m *lspLeaseManager) add(lease *lspLease) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return errors.New("LSP lease manager is closed")
}
m.leases[lease.id] = lease
m.updateMetricsLocked()
return nil
}
func (m *lspLeaseManager) remove(lease *lspLease, reason string) {
m.mu.Lock()
if current := m.leases[lease.id]; current == lease {
delete(m.leases, lease.id)
m.updateMetricsLocked()
lspLeaseReleasedTotal.Add(reason, 1)
if reason == lspLeaseReleaseCapacity {
lspLeaseEvictedTotal.Add(1)
}
}
m.mu.Unlock()
}
func (m *lspLeaseManager) attachmentChanged() {
m.mu.Lock()
m.updateMetricsLocked()
m.mu.Unlock()
}
func (m *lspLeaseManager) updateMetricsLocked() {
active := int64(len(m.leases))
detached := int64(0)
for _, lease := range m.leases {
if lease.isDetached() {
detached++
}
}
lspLeaseActiveGauge.Set(active)
lspLeaseDetachedGauge.Set(detached)
}
func (m *lspLeaseManager) hasActiveSession(sessionID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, lease := range m.leases {
if lease.sessionID == sessionID && !lease.isClosed() {
return true
}
}
return false
}
func (m *lspLeaseManager) hasActiveExecution(executionID string) bool {
if executionID == "" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
for _, lease := range m.leases {
if lease.executionID == executionID && !lease.isClosed() {
return true
}
}
return false
}
func (m *lspLeaseManager) stopSession(sessionID string) {
m.stopMatching(func(lease *lspLease) bool { return lease.sessionID == sessionID }, lspLeaseReleaseRuntimeStop)
}
func (m *lspLeaseManager) stopTask(taskID string) {
m.stopMatching(func(lease *lspLease) bool { return lease.taskID == taskID }, lspLeaseReleaseRuntimeStop)
}
func (m *lspLeaseManager) stopExecution(executionID string) {
m.stopMatching(func(lease *lspLease) bool { return lease.executionID == executionID }, lspLeaseReleaseRuntimeStop)
}
func (m *lspLeaseManager) updateUserConfiguration(userID string, configurations map[string]map[string]any) {
if userID == "" {
return
}
m.mu.Lock()
leases := make([]*lspLease, 0)
for _, lease := range m.leases {
if lease.userID == userID {
leases = append(leases, lease)
}
}
m.mu.Unlock()
for _, lease := range leases {
if err := lease.updateConfiguration(configurations[lease.language], true); err != nil {
lease.terminate(lspCloseTransport, "language server configuration exceeded its bound", lspLeaseReleaseRuntimeStop)
}
}
}
func (m *lspLeaseManager) stopMatching(match func(*lspLease) bool, reason string) {
m.mu.Lock()
leases := make([]*lspLease, 0)
for _, lease := range m.leases {
if match(lease) {
leases = append(leases, lease)
}
}
m.mu.Unlock()
for _, lease := range leases {
lease.terminate(lspCloseRuntimeStopped, "task runtime stopped", reason)
}
}
func (m *lspLeaseManager) Close() {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.closed = true
leases := make([]*lspLease, 0, len(m.leases))
for _, lease := range m.leases {
leases = append(leases, lease)
}
m.mu.Unlock()
for _, lease := range leases {
lease.terminate(websocket.CloseGoingAway, "backend is shutting down", lspLeaseReleaseBackendStop)
}
deadline := time.Now().Add(lspLeaseCloseTimeout)
for _, lease := range leases {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
lease.waitForClose(remaining)
}
}
func (l *lspLease) attach(browser *websocket.Conn) (uint64, bool, error) {
l.mu.Lock()
if l.closed {
l.mu.Unlock()
return 0, false, errors.New("LSP lease has ended")
}
if l.browser != nil {
l.mu.Unlock()
return 0, false, errors.New("LSP lease is already attached")
}
l.generation++
generation := l.generation
l.browser = browser
if l.expiryTimer != nil {
l.expiryTimer.Stop()
l.expiryTimer = nil
}
l.detachedAt = time.Time{}
resumed := len(l.initializeResult) > 0
l.resumedGeneration = resumed
ready, status := l.ready, cloneAnyMap(l.readyStatus)
l.mu.Unlock()
l.manager.attachmentChanged()
if ready {
if err := l.writeBrowser(generation, l.readyHandshake(resumed)); err != nil {
l.detach(generation)
return generation, resumed, err
}
} else if status != nil {
if err := l.writeBrowser(generation, status); err != nil {
l.detach(generation)
return generation, resumed, err
}
}
return generation, resumed, nil
}
func (l *lspLease) detach(generation uint64) {
// Serialize the generation change with browser-originated upstream writes.
// This prevents a frame already read from a stale tab from landing after
// the lease has detached or been claimed by another tab.
l.upstreamWriteMu.Lock()
defer l.upstreamWriteMu.Unlock()
l.mu.Lock()
if l.closed || l.browser == nil || l.generation != generation {
l.mu.Unlock()
return
}
browser := l.browser
l.browser = nil
l.detachedAt = time.Now()
detachedAt := l.detachedAt
l.expiryTimer = time.AfterFunc(l.manager.detachedTimeout, func() {
l.manager.admissionMu.Lock()
defer l.manager.admissionMu.Unlock()
l.expireDetached(detachedAt)
})
documents := make([]string, 0, len(l.openDocuments))
for uri := range l.openDocuments {
documents = append(documents, uri)
l.synchronizedDocs[uri] = false
}
clear(l.openDocuments)
clientRequests := make([]lspLeaseClientRequest, 0)
for key, request := range l.clientRequests {
if request.generation == generation {
if removed, ok := l.removeClientRequestLocked(key); ok {
clientRequests = append(clientRequests, removed)
}
}
}
serverRequests := make([]lspLeaseServerRequest, 0)
for key, request := range l.serverRequests {
if request.generation == generation {
serverRequests = append(serverRequests, request)
delete(l.serverRequests, key)
}
}
l.mu.Unlock()
_ = browser.Close()
for _, request := range clientRequests {
_ = l.writeUpstreamLocked(jsonRPCNotification("$/cancelRequest", map[string]any{"id": request.serverID}))
}
for _, request := range serverRequests {
_ = l.writeUpstreamLocked(jsonRPCErrorResponseRaw(request.serverID, -32800, "request cancelled because the browser detached"))
}
for _, uri := range documents {
_ = l.writeUpstreamLocked(jsonRPCNotification("textDocument/didClose", map[string]any{"textDocument": map[string]any{"uri": uri}}))
}
l.manager.attachmentChanged()
l.manager.logger.Debug("detached language server lease", zap.String("language", l.language))
}
func (l *lspLease) isDetached() bool {
l.mu.Lock()
defer l.mu.Unlock()
return !l.closed && l.browser == nil
}
func (l *lspLease) isClosed() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.closed
}
func (l *lspLease) detachedTime() time.Time {
l.mu.Lock()
defer l.mu.Unlock()
return l.detachedAt
}
func (l *lspLease) readUpstream() {
defer close(l.readDone)
for {
messageType, message, err := l.upstream.ReadMessage()
if err != nil {
code, text, reason := classifyLSPUpstreamClose(err)
l.terminate(code, text, reason)
return
}
if messageType != websocket.TextMessage {
continue
}
if err := l.handleUpstreamMessage(message); err != nil {
l.manager.logger.Warn("LSP lease broker rejected upstream frame", zap.String("language", l.language), zap.Error(err))
l.terminate(lspCloseTransport, "language server protocol state exceeded its bound", lspLeaseReleaseRuntimeStop)
return
}
}
}
func classifyLSPUpstreamClose(err error) (int, string, string) {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
if closeErr.Code == 4006 {
return 4006, closeErr.Text, lspLeaseReleaseServerExit
}
if closeErr.Code != websocket.CloseNoStatusReceived && closeErr.Code != websocket.CloseAbnormalClosure {
return closeErr.Code, closeErr.Text, lspLeaseReleaseRuntimeStop
}
}
return lspCloseTransport, "LSP transport failed", lspLeaseReleaseRuntimeStop
}
func (l *lspLease) terminate(code int, text, reason string) {
l.terminateIfDetachedAt(code, text, reason, time.Time{})
}
func (l *lspLease) expireDetached(detachedAt time.Time) {
if detachedAt.IsZero() {
return
}
l.terminateIfDetachedAt(lspCloseTransport, "detached language server lease expired", lspLeaseReleaseDetachedTimeout, detachedAt)
}
func (l *lspLease) terminateIfDetachedAt(code int, text, reason string, detachedAt time.Time) {
l.mu.Lock()
if l.closed || (!detachedAt.IsZero() && (l.browser != nil || !l.detachedAt.Equal(detachedAt) || time.Since(detachedAt) < l.manager.detachedTimeout)) {
l.mu.Unlock()
return
}
l.closed = true
if l.expiryTimer != nil {
l.expiryTimer.Stop()
l.expiryTimer = nil
}
browser := l.browser
l.browser = nil
l.openDocuments = make(map[string]int64)
l.synchronizedDocs = make(map[string]bool)
for key := range l.clientRequests {
l.removeClientRequestLocked(key)
}
l.clientRequests = make(map[string]lspLeaseClientRequest)
l.pendingClientRequestBytes = 0
l.serverRequests = make(map[string]lspLeaseServerRequest)
l.mu.Unlock()
if browser != nil {
l.browserWriteMu.Lock()
closeLSPConnWithCode(browser, code, text)
l.browserWriteMu.Unlock()
}
if l.upstream != nil {
_ = l.upstream.Close()
}
l.manager.remove(l, reason)
}
func (l *lspLease) waitForClose(timeout time.Duration) {
if timeout <= 0 {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-l.readDone:
case <-timer.C:
}
}
func (l *lspLease) writeUpstream(message []byte) error {
l.upstreamWriteMu.Lock()
defer l.upstreamWriteMu.Unlock()
return l.writeUpstreamLocked(message)
}
func (l *lspLease) writeUpstreamForGeneration(generation uint64, message []byte) error {
l.upstreamWriteMu.Lock()
defer l.upstreamWriteMu.Unlock()
l.mu.Lock()
active := !l.closed && l.browser != nil && l.generation == generation
l.mu.Unlock()
if !active {
return nil
}
return l.writeUpstreamLocked(message)
}
func (l *lspLease) writeUpstreamLocked(message []byte) error {
if err := l.upstream.SetWriteDeadline(time.Now().Add(lspProxyWriteTimeout)); err != nil {
return err
}
return l.upstream.WriteMessage(websocket.TextMessage, message)
}
func (l *lspLease) isActiveGeneration(generation uint64) bool {
l.mu.Lock()
defer l.mu.Unlock()
return !l.closed && l.browser != nil && l.generation == generation
}
func (l *lspLease) writeBrowserOrDetach(generation uint64, value any) error {
if err := l.writeBrowser(generation, value); err != nil {
l.detach(generation)
}
return nil
}
func (l *lspLease) writeBrowser(generation uint64, value any) error {
message, err := marshalBoundedJSON(value, protocol.MaxMessageBytes)
if err != nil {
return err
}
l.browserWriteMu.Lock()
defer l.browserWriteMu.Unlock()
l.mu.Lock()
conn := l.browser
current := !l.closed && conn != nil && l.generation == generation
l.mu.Unlock()
if !current {
return errors.New("browser attachment generation is no longer active")
}
if err := conn.SetWriteDeadline(time.Now().Add(lspProxyWriteTimeout)); err != nil {
return err
}
return conn.WriteMessage(websocket.TextMessage, message)
}
func (h *LSPHandler) HasActiveLSPLease(sessionID string) bool {
return h.leases != nil && h.leases.hasActiveSession(sessionID)
}
func (h *LSPHandler) HasActiveLSPLeaseForExecution(executionID string) bool {
return h.leases != nil && h.leases.hasActiveExecution(executionID)
}
func (h *LSPHandler) StopLSPLeasesForSession(sessionID string) {
if h.leases != nil {
h.leases.stopSession(sessionID)
}
}
func (h *LSPHandler) StopLSPLeasesForTask(taskID string) {
if h.leases != nil {
h.leases.stopTask(taskID)
}
}
func (h *LSPHandler) StopLSPLeasesForExecution(executionID string) {
if h.leases != nil {
h.leases.stopExecution(executionID)
}
}
@@ -0,0 +1,86 @@
package websocket
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
const lspShutdownTimeout = 3 * time.Second
func (l *lspLease) gracefulRelease(generation uint64, reason, requestID string) error {
l.mu.Lock()
requests := make([]lspLeaseClientRequest, 0, len(l.clientRequests))
for key, request := range l.clientRequests {
if request.generation == generation {
requests = append(requests, request)
delete(l.clientRequests, key)
}
}
l.mu.Unlock()
for _, request := range requests {
_ = l.writeBrowser(generation, jsonRPCErrorResponseRaw(request.clientID, -32800, "request cancelled because the language server is stopping"))
}
ctx, cancel := context.WithTimeout(context.Background(), lspShutdownTimeout)
defer cancel()
if _, err := l.sendBrokerRequest(ctx, "shutdown", nil); err != nil {
l.manager.logger.Debug("LSP shutdown request did not complete before release", zap.String("language", l.language), zap.Error(err))
}
_ = l.writeUpstream(jsonRPCNotification("exit", nil))
if err := l.writeBrowser(generation, map[string]any{
lspControlField: lspControlKind,
"action": lspControlAck,
"requestId": requestID,
"reason": reason,
}); err != nil {
return err
}
code := websocket.CloseNormalClosure
text := "language server stopped"
if reason == lspLeaseReleaseEditorIdle {
text = "language server released after editor idle"
}
l.terminate(code, text, reason)
return nil
}
func (l *lspLease) sendBrokerRequest(ctx context.Context, method string, params any) (jsonRPCResponse, error) {
l.mu.Lock()
if l.closed {
l.mu.Unlock()
return jsonRPCResponse{}, errors.New("LSP lease has ended")
}
l.requestCounter++
id := jsonRPCStringID(fmt.Sprintf("kandev:broker:%d", l.requestCounter))
key := jsonRPCIDKey(id)
response := make(chan jsonRPCResponse, 1)
l.brokerRequests[key] = response
l.mu.Unlock()
message, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
if err != nil {
return jsonRPCResponse{}, err
}
if err := l.writeUpstream(message); err != nil {
l.mu.Lock()
delete(l.brokerRequests, key)
l.mu.Unlock()
return jsonRPCResponse{}, err
}
select {
case result := <-response:
if len(result.Error) > 0 {
return result, errors.New(string(result.Error))
}
return result, nil
case <-ctx.Done():
l.mu.Lock()
delete(l.brokerRequests, key)
l.mu.Unlock()
return jsonRPCResponse{}, ctx.Err()
}
}
@@ -0,0 +1,91 @@
package websocket
import (
"testing"
"time"
)
// @covers AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.10
func TestLSPLeaseAdmissionReleasesExpiredDetachedLeaseBelowCapacity(t *testing.T) {
manager := newLSPLeaseManager(2, testLogger())
lease := newTestLSPLease(manager)
lease.detachedAt = time.Now().Add(-time.Hour - time.Second)
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
if err := manager.ensureCapacity(); err != nil {
t.Fatal(err)
}
if manager.hasActiveSession(lease.sessionID) {
t.Fatal("expired detached lease still pins its task host")
}
}
func TestLSPLeaseDetachedDeadlineReleasesLease(t *testing.T) {
manager := newLSPLeaseManager(2, testLogger())
manager.detachedTimeout = 20 * time.Millisecond
lease := newTestLSPLease(manager)
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
browser, _ := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
lease.detach(generation)
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
deadline := time.After(time.Second)
for {
manager.mu.Lock()
remaining := len(manager.leases)
manager.mu.Unlock()
if remaining == 0 {
break
}
select {
case <-ticker.C:
case <-deadline:
t.Fatal("detached lease did not expire")
}
}
if manager.hasActiveSession(lease.sessionID) {
t.Fatal("expired lease still pins its task host")
}
}
func TestLSPLeaseOldDetachedDeadlineCannotCloseReattachedLease(t *testing.T) {
manager := newLSPLeaseManager(2, testLogger())
lease := newTestLSPLease(manager)
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
first, _ := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
lease.detach(generation)
oldDetachment := lease.detachedTime()
second, _ := newLSPTestWebSocketPair(t)
secondGeneration, _, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
manager.detachedTimeout = 0 // Make the old deadline due without waiting an hour.
lease.expireDetached(oldDetachment)
if lease.isClosed() {
t.Fatal("old deadline closed a reattached lease")
}
manager.detachedTimeout = time.Hour
lease.detach(secondGeneration)
manager.detachedTimeout = 0
lease.expireDetached(oldDetachment)
if lease.isClosed() {
t.Fatal("old deadline closed a newly detached lease")
}
lease.terminate(lspCloseTransport, "test cleanup", lspLeaseReleaseStop)
}
@@ -0,0 +1,76 @@
package websocket
import (
"encoding/json"
"fmt"
"strings"
)
func replaceJSONRPCID(original []byte, id json.RawMessage) ([]byte, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(original, &raw); err != nil {
return nil, err
}
raw["id"] = append([]byte(nil), id...)
return json.Marshal(raw)
}
func jsonRPCIDKey(id json.RawMessage) string {
return strings.TrimSpace(string(id))
}
func jsonRPCStringID(id string) json.RawMessage {
encoded, _ := json.Marshal(id)
return encoded
}
func jsonRPCNotification(method string, params any) []byte {
message, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
return message
}
func jsonRPCResultResponse(id json.RawMessage, result any) []byte {
encoded, _ := json.Marshal(result)
message, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": json.RawMessage(encoded)})
return message
}
func jsonRPCErrorResponseRaw(id json.RawMessage, code int, message string) []byte {
response, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": message},
})
return response
}
func marshalBoundedJSON(value any, limit int) ([]byte, error) {
message, err := json.Marshal(value)
if err != nil {
return nil, err
}
if len(message) > limit {
return nil, fmt.Errorf("JSON frame is %d bytes; maximum is %d", len(message), limit)
}
return message, nil
}
func cloneStringAnyMap(input map[string]any) map[string]any {
if input == nil {
return make(map[string]any)
}
encoded, err := json.Marshal(input)
if err != nil {
return make(map[string]any)
}
var output map[string]any
if err := json.Unmarshal(encoded, &output); err != nil || output == nil {
return make(map[string]any)
}
return output
}
func cloneAnyMap(input map[string]any) map[string]any {
if input == nil {
return nil
}
return cloneStringAnyMap(input)
}
@@ -0,0 +1,10 @@
package websocket
import "expvar"
var (
lspLeaseActiveGauge = expvar.NewInt("lsp_lease_active")
lspLeaseDetachedGauge = expvar.NewInt("lsp_lease_detached")
lspLeaseEvictedTotal = expvar.NewInt("lsp_lease_evicted_total")
lspLeaseReleasedTotal = expvar.NewMap("lsp_lease_released_total")
)
@@ -0,0 +1,137 @@
package websocket
import (
"net"
"net/http"
"sync"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
)
func TestLSPDetachCleanupPrecedesSuccessorDocumentWrites(t *testing.T) {
manager := newLSPLeaseManager(2, testLogger())
lease := newTestLSPLease(manager)
upstream, upstreamPeer := newLSPTestWebSocketPair(t)
upstreamFrames := observeLSPFrames(t, upstreamPeer)
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.initializeResult = []byte(`{"capabilities":{}}`)
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
closeEntered := make(chan struct{})
releaseClose := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseClose) }) }
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
wrapped := &lspBlockingCloseListener{Listener: listener, entered: closeEntered, release: releaseClose}
connections := make(chan *gorillaws.Conn, 2)
httpServer := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := lspUpgrader.Upgrade(w, r, nil)
if err == nil {
connections <- conn
}
})}
go func() { _ = httpServer.Serve(wrapped) }()
var first, firstPeer, second, secondPeer *gorillaws.Conn
t.Cleanup(func() {
release()
if first != nil {
_ = first.Close()
}
if firstPeer != nil {
_ = firstPeer.Close()
}
if second != nil {
_ = second.Close()
}
if secondPeer != nil {
_ = secondPeer.Close()
}
_ = upstream.Close()
_ = httpServer.Close()
_ = listener.Close()
})
dialBrowser := func() (*gorillaws.Conn, *gorillaws.Conn) {
t.Helper()
client, response, err := gorillaws.DefaultDialer.Dial("ws://"+listener.Addr().String(), nil)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
t.Fatalf("dial test browser: %v", err)
}
var server *gorillaws.Conn
select {
case server = <-connections:
case <-time.After(wsTestTimeout):
t.Fatal("test browser did not connect")
}
return server, client
}
first, firstPeer = dialBrowser()
firstGeneration, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, firstPeer)
if err := lease.handleBrowserMessage(firstGeneration, []byte(`{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///old.go","languageId":"go","version":1,"text":"package old"}}}`)); err != nil {
t.Fatal(err)
}
if message := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames)); string(message["method"]) != `"textDocument/didOpen"` {
t.Fatalf("first upstream document frame = %s, want didOpen", message["method"])
}
detachDone := make(chan struct{})
go func() {
lease.detach(firstGeneration)
close(detachDone)
}()
select {
case <-closeEntered:
case <-time.After(wsTestTimeout):
t.Fatal("detach did not enter its controlled browser-close barrier")
}
second, secondPeer = dialBrowser()
secondGeneration, _, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, secondPeer)
writeDone := make(chan error, 1)
go func() {
writeDone <- lease.handleBrowserMessage(secondGeneration, []byte(`{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///new.go","languageId":"go","version":1,"text":"package current"}}}`))
}()
select {
case frame := <-upstreamFrames:
message := decodeLSPJSONRPCMessage(t, frame)
release()
<-detachDone
t.Fatalf("successor frame %s overtook old detach cleanup", message["method"])
case <-time.After(50 * time.Millisecond):
}
release()
select {
case <-detachDone:
case <-time.After(wsTestTimeout):
t.Fatal("detach did not finish after its close barrier released")
}
if err := <-writeDone; err != nil {
t.Fatal(err)
}
closed := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
opened := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
if string(closed["method"]) != `"textDocument/didClose"` || string(opened["method"]) != `"textDocument/didOpen"` {
t.Fatalf("upstream order = %s then %s, want old didClose then successor didOpen", closed["method"], opened["method"])
}
}
@@ -0,0 +1,41 @@
package websocket
import "encoding/json"
func mergeProgressReport(previous, report, reportValue json.RawMessage) []byte {
var prior struct {
Value map[string]json.RawMessage `json:"value"`
}
if json.Unmarshal(previous, &prior) != nil || len(prior.Value) == 0 {
return append([]byte(nil), report...)
}
merged := make(map[string]json.RawMessage, len(prior.Value)+4)
for key, value := range prior.Value {
merged[key] = append([]byte(nil), value...)
}
var latest map[string]json.RawMessage
if json.Unmarshal(reportValue, &latest) != nil {
return append([]byte(nil), report...)
}
for key, value := range latest {
if key != "kind" {
merged[key] = append([]byte(nil), value...)
}
}
merged["kind"] = json.RawMessage(`"begin"`)
var envelope map[string]json.RawMessage
if json.Unmarshal(previous, &envelope) != nil {
return append([]byte(nil), report...)
}
encodedValue, err := json.Marshal(merged)
if err != nil {
return append([]byte(nil), report...)
}
envelope["value"] = encodedValue
encoded, err := json.Marshal(envelope)
if err != nil {
return append([]byte(nil), report...)
}
return encoded
}
@@ -0,0 +1,773 @@
package websocket
import (
"encoding/json"
"errors"
"fmt"
)
const (
lspControlKind = "lsp"
lspControlField = "kandev"
lspControlAttachSync = "attachmentReady"
lspControlRelease = "release"
lspControlAck = "released"
lspLeaseStatusReady = "ready"
lspMethodInitialize = "initialize"
lspMethodDidOpen = "textDocument/didOpen"
lspMethodDidChange = "textDocument/didChange"
lspMethodDidClose = "textDocument/didClose"
)
type jsonRPCMessage struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
}
type jsonRPCResponse struct {
ID json.RawMessage `json:"id"`
Result json.RawMessage `json:"result"`
Error json.RawMessage `json:"error"`
}
type lspControlMessage struct {
Kind string `json:"kandev"`
Action string `json:"action"`
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
func (l *lspLease) handleUpstreamMessage(message []byte) error {
var status struct {
Status string `json:"status"`
WorkspacePath string `json:"workspacePath"`
WorkspaceURI string `json:"workspaceUri"`
RepositorySubpaths []string `json:"repoSubpaths"`
}
if json.Unmarshal(message, &status) == nil && status.Status != "" {
return l.handleTaskHostStatus(message, status.Status, status.WorkspacePath, status.WorkspaceURI, status.RepositorySubpaths)
}
var rpc jsonRPCMessage
if err := json.Unmarshal(message, &rpc); err != nil || rpc.JSONRPC != "2.0" {
return fmt.Errorf("invalid upstream JSON-RPC frame")
}
if rpc.Method != "" && len(rpc.ID) != 0 {
return l.handleServerRequest(rpc, message)
}
if rpc.Method == "" && len(rpc.ID) != 0 {
return l.handleServerResponse(rpc, message)
}
if rpc.Method != "" {
return l.handleServerNotification(rpc, message)
}
return fmt.Errorf("unrecognized JSON-RPC frame")
}
func (l *lspLease) handleTaskHostStatus(raw []byte, status, workspacePath, workspaceURI string, subpaths []string) error {
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil {
return err
}
l.mu.Lock()
l.readyStatus = cloneAnyMap(value)
if status == lspLeaseStatusReady {
l.ready = true
l.workspacePath = workspacePath
l.workspaceURI = workspaceURI
l.repoSubpaths = append([]string(nil), subpaths...)
}
if err := l.checkSnapshotLimitLocked(); err != nil {
l.mu.Unlock()
return err
}
generation, attached := l.generation, l.browser != nil
resumed := len(l.initializeResult) > 0
l.mu.Unlock()
if !attached {
return nil
}
if status == lspLeaseStatusReady {
return l.writeBrowserOrDetach(generation, l.readyHandshake(resumed))
}
return l.writeBrowserOrDetach(generation, value)
}
func (l *lspLease) readyHandshake(resumed bool) map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
ready := cloneAnyMap(l.readyStatus)
if ready == nil {
ready = make(map[string]any)
}
ready["status"] = lspLeaseStatusReady
ready["leaseId"] = l.id
ready["resumed"] = resumed
ready["initialized"] = l.initializedReceived
ready["workspacePath"] = l.workspacePath
ready["workspaceUri"] = l.workspaceURI
ready["repoSubpaths"] = append([]string(nil), l.repoSubpaths...)
ready["registrations"] = l.rawMapValues(l.registrations)
ready["progressTokens"] = l.rawMapValues(l.progressTokens)
ready["progress"] = l.rawMapValues(l.progress)
if len(l.serverCapabilities) > 0 {
var capabilities any
if json.Unmarshal(l.serverCapabilities, &capabilities) == nil {
ready["capabilities"] = capabilities
}
}
return ready
}
func (l *lspLease) rawMapValues(values map[string][]byte) []any {
result := make([]any, 0, len(values))
for _, raw := range values {
var value any
if json.Unmarshal(raw, &value) == nil {
result = append(result, value)
}
}
return result
}
func (l *lspLease) handleServerRequest(rpc jsonRPCMessage, original []byte) error {
brokered := false
switch rpc.Method {
case "workspace/configuration":
return l.answerWorkspaceConfiguration(rpc.ID, rpc.Params)
case "client/registerCapability":
brokered = true
if err := l.updateRegistrations(rpc.Method, rpc.Params); err != nil {
return err
}
case "client/unregisterCapability":
brokered = true
if err := l.updateRegistrations(rpc.Method, rpc.Params); err != nil {
return err
}
case "window/workDoneProgress/create":
brokered = true
if err := l.addProgressToken(rpc.Params); err != nil {
return err
}
}
l.mu.Lock()
if err := l.checkSnapshotLimitLocked(); err != nil {
l.mu.Unlock()
return err
}
generation, browser := l.generation, l.browser
if browser == nil {
l.mu.Unlock()
if brokered {
return l.writeUpstream(jsonRPCResultResponse(rpc.ID, nil))
}
return l.writeUpstream(jsonRPCErrorResponseRaw(rpc.ID, -32601, "method unavailable while the editor is detached"))
}
l.requestCounter++
forwardedID := jsonRPCStringID(fmt.Sprintf("kandev:server:%d", l.requestCounter))
key := jsonRPCIDKey(forwardedID)
l.serverRequests[key] = lspLeaseServerRequest{serverID: append([]byte(nil), rpc.ID...), generation: generation, method: rpc.Method}
l.mu.Unlock()
forwarded, err := replaceJSONRPCID(original, forwardedID)
if err != nil {
return err
}
if err = l.writeBrowser(generation, json.RawMessage(forwarded)); err != nil {
l.detach(generation)
return nil
}
return nil
}
func (l *lspLease) answerWorkspaceConfiguration(id, params json.RawMessage) error {
var request struct {
Items []struct {
Section string `json:"section"`
} `json:"items"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &request); err != nil {
return l.writeUpstream(jsonRPCErrorResponseRaw(id, -32602, "invalid workspace/configuration parameters"))
}
}
l.mu.Lock()
configuration := cloneStringAnyMap(l.configuration)
l.mu.Unlock()
result := make([]any, len(request.Items))
for index := range result {
result[index] = configurationSection(configuration, request.Items[index].Section, l.language)
}
return l.writeUpstream(jsonRPCResultResponse(id, result))
}
func (l *lspLease) handleServerResponse(rpc jsonRPCMessage, original []byte) error {
key := jsonRPCIDKey(rpc.ID)
if response := l.takeBrokerResponse(key); response != nil {
response <- jsonRPCResponse{ID: append([]byte(nil), rpc.ID...), Result: append([]byte(nil), rpc.Result...), Error: append([]byte(nil), rpc.Error...)}
return nil
}
if handled, err := l.handleInitializeResponse(key, rpc, original); handled {
return err
}
return l.handleClientResponse(key, original)
}
func (l *lspLease) takeBrokerResponse(key string) chan jsonRPCResponse {
l.mu.Lock()
defer l.mu.Unlock()
if responseCh, ok := l.brokerRequests[key]; ok {
delete(l.brokerRequests, key)
return responseCh
}
return nil
}
func (l *lspLease) handleInitializeResponse(
key string,
rpc jsonRPCMessage,
original []byte,
) (bool, error) {
l.mu.Lock()
if len(l.initializeServerID) == 0 || jsonRPCIDKey(l.initializeServerID) != key {
l.mu.Unlock()
return false, nil
}
initializeFailed := len(rpc.Error) > 0 && string(rpc.Error) != "null"
if initializeFailed {
l.initializeResponse = nil
l.initializeResult = nil
l.serverCapabilities = nil
} else {
l.initializeResponse = append(l.initializeResponse[:0], original...)
}
l.initializeServerID = nil
waiter := l.initializeWaiter
l.initializeWaiter = nil
if !initializeFailed && len(rpc.Result) > 0 {
l.initializeResult = append(l.initializeResult[:0], rpc.Result...)
var result struct {
Capabilities json.RawMessage `json:"capabilities"`
}
if json.Unmarshal(rpc.Result, &result) == nil {
l.serverCapabilities = append(l.serverCapabilities[:0], result.Capabilities...)
}
}
if err := l.checkSnapshotLimitLocked(); err != nil {
l.mu.Unlock()
return true, err
}
l.mu.Unlock()
if waiter == nil || !l.isActiveGeneration(waiter.generation) {
return true, nil
}
forwarded, err := replaceJSONRPCID(original, waiter.clientID)
if err != nil {
return true, err
}
return true, l.writeBrowserOrDetach(waiter.generation, json.RawMessage(forwarded))
}
func (l *lspLease) handleClientResponse(key string, original []byte) error {
l.mu.Lock()
var pendingID string
var pending lspLeaseClientRequest
for clientID, request := range l.clientRequests {
if jsonRPCIDKey(request.serverID) == key {
pendingID, pending = clientID, request
l.removeClientRequestLocked(clientID)
break
}
}
if pendingID == "" {
l.mu.Unlock()
return nil
}
attached := l.browser != nil && l.generation == pending.generation
generation := l.generation
l.mu.Unlock()
if !attached {
return nil
}
forwarded, err := replaceJSONRPCID(original, pending.clientID)
if err != nil {
return err
}
return l.writeBrowserOrDetach(generation, json.RawMessage(forwarded))
}
func (l *lspLease) handleServerNotification(rpc jsonRPCMessage, original []byte) error {
switch rpc.Method {
case "$/progress":
if err := l.updateProgress(rpc.Params); err != nil {
return err
}
case "textDocument/publishDiagnostics":
allowed, err := l.acceptDiagnostics(rpc.Params)
if err != nil || !allowed {
return err
}
}
l.mu.Lock()
generation, attached := l.generation, l.browser != nil
l.mu.Unlock()
if !attached {
return nil
}
if err := l.writeBrowser(generation, json.RawMessage(original)); err != nil {
l.detach(generation)
return nil
}
return nil
}
func (l *lspLease) handleBrowserMessage(generation uint64, message []byte) error {
var control lspControlMessage
if json.Unmarshal(message, &control) == nil && control.Kind == lspControlKind {
return l.handleControl(generation, control)
}
if !l.isActiveGeneration(generation) {
return nil
}
var rpc jsonRPCMessage
if err := json.Unmarshal(message, &rpc); err != nil || rpc.JSONRPC != "2.0" {
return errors.New("invalid browser JSON-RPC frame")
}
if rpc.Method == "" && len(rpc.ID) > 0 {
return l.handleBrowserResponse(generation, rpc, message)
}
if rpc.Method == "" {
return nil
}
if handled, err := l.handleBrowserMethod(generation, rpc, message); handled {
return err
}
forwarded, active, err := l.rewriteBrowserDocument(generation, rpc, message)
if err != nil || !active {
return err
}
if len(rpc.ID) == 0 {
return l.writeUpstreamForGeneration(generation, forwarded)
}
return l.forwardBrowserRequest(generation, rpc, forwarded)
}
func (l *lspLease) handleBrowserMethod(
generation uint64,
rpc jsonRPCMessage,
original []byte,
) (bool, error) {
switch rpc.Method {
case lspMethodInitialize:
return true, l.handleInitializeRequest(generation, rpc, original)
case "initialized":
return true, l.handleInitializedNotification(generation, original)
case "workspace/didChangeConfiguration":
var params struct {
Settings map[string]any `json:"settings"`
}
if json.Unmarshal(rpc.Params, &params) == nil && params.Settings != nil {
return true, l.updateConfiguration(params.Settings, true)
}
case "$/cancelRequest":
return true, l.forwardCancellation(generation, rpc, original)
}
return false, nil
}
func (l *lspLease) rewriteBrowserDocument(
generation uint64,
rpc jsonRPCMessage,
original []byte,
) ([]byte, bool, error) {
switch rpc.Method {
case lspMethodDidOpen, lspMethodDidChange, lspMethodDidClose:
return l.rewriteDocumentVersionForGeneration(generation, rpc.Method, original)
default:
return append([]byte(nil), original...), true, nil
}
}
func (l *lspLease) forwardBrowserRequest(
generation uint64,
rpc jsonRPCMessage,
message []byte,
) error {
l.mu.Lock()
if l.closed || l.generation != generation || l.browser == nil {
l.mu.Unlock()
return nil
}
l.requestCounter++
serverID := jsonRPCStringID(fmt.Sprintf("kandev:client:%d", l.requestCounter))
clientKey := jsonRPCIDKey(rpc.ID)
if err := l.addClientRequestLocked(generation, rpc.ID, serverID); err != nil {
l.mu.Unlock()
return err
}
if err := l.checkSnapshotLimitLocked(); err != nil {
l.removeClientRequestLocked(clientKey)
l.mu.Unlock()
return err
}
l.mu.Unlock()
forwarded, err := replaceJSONRPCID(message, serverID)
if err != nil {
return err
}
if err = l.writeUpstreamForGeneration(generation, forwarded); err != nil {
l.mu.Lock()
l.removeClientRequestLocked(clientKey)
l.mu.Unlock()
}
return err
}
func (l *lspLease) handleBrowserResponse(generation uint64, rpc jsonRPCMessage, original []byte) error {
key := jsonRPCIDKey(rpc.ID)
l.mu.Lock()
pending, ok := l.serverRequests[key]
if !ok || pending.generation != generation || l.generation != generation || l.browser == nil {
l.mu.Unlock()
return nil
}
delete(l.serverRequests, key)
l.mu.Unlock()
forwarded, err := replaceJSONRPCID(original, pending.serverID)
if err != nil {
return err
}
return l.writeUpstreamForGeneration(generation, forwarded)
}
func (l *lspLease) handleInitializeRequest(generation uint64, rpc jsonRPCMessage, original []byte) error {
if len(rpc.ID) == 0 {
return errors.New("initialize request has no ID")
}
l.upstreamWriteMu.Lock()
l.mu.Lock()
if l.closed || l.browser == nil || l.generation != generation {
l.mu.Unlock()
l.upstreamWriteMu.Unlock()
return nil
}
if len(l.initializeResponse) > 0 {
response := append([]byte(nil), l.initializeResponse...)
l.mu.Unlock()
l.upstreamWriteMu.Unlock()
forwarded, err := replaceJSONRPCID(response, rpc.ID)
if err != nil {
return err
}
return l.writeBrowserOrDetach(generation, json.RawMessage(forwarded))
}
if len(l.initializeServerID) > 0 {
l.initializeWaiter = &lspLeaseInitializeWaiter{generation: generation, clientID: append([]byte(nil), rpc.ID...)}
l.mu.Unlock()
l.upstreamWriteMu.Unlock()
return nil
}
l.requestCounter++
serverID := jsonRPCStringID(fmt.Sprintf("kandev:initialize:%d", l.requestCounter))
l.initializeServerID = append([]byte(nil), serverID...)
l.initializeWaiter = &lspLeaseInitializeWaiter{generation: generation, clientID: append([]byte(nil), rpc.ID...)}
var params struct {
WorkDoneToken json.RawMessage `json:"workDoneToken"`
}
if len(rpc.Params) > 0 && json.Unmarshal(rpc.Params, &params) == nil && len(params.WorkDoneToken) > 0 {
storeSnapshotByteSlice(l.progressTokens, jsonRPCIDKey(params.WorkDoneToken), params.WorkDoneToken, &l.progressTokenSnapshotBytes)
}
if err := l.checkSnapshotLimitLocked(); err != nil {
l.initializeServerID = nil
l.initializeWaiter = nil
l.mu.Unlock()
l.upstreamWriteMu.Unlock()
return err
}
l.mu.Unlock()
forwarded, err := replaceJSONRPCID(original, serverID)
if err == nil {
err = l.writeUpstreamLocked(forwarded)
}
l.upstreamWriteMu.Unlock()
return err
}
func (l *lspLease) handleInitializedNotification(generation uint64, message []byte) error {
l.upstreamWriteMu.Lock()
defer l.upstreamWriteMu.Unlock()
l.mu.Lock()
if l.closed || l.browser == nil || l.generation != generation || l.initializedReceived {
l.mu.Unlock()
return nil
}
l.mu.Unlock()
if err := l.writeUpstreamLocked(message); err != nil {
return err
}
l.mu.Lock()
if !l.closed && l.browser != nil && l.generation == generation {
l.initializedReceived = true
}
l.mu.Unlock()
return nil
}
func (l *lspLease) forwardCancellation(generation uint64, rpc jsonRPCMessage, original []byte) error {
var params struct {
ID json.RawMessage `json:"id"`
}
if err := json.Unmarshal(rpc.Params, &params); err != nil || len(params.ID) == 0 {
return nil
}
key := jsonRPCIDKey(params.ID)
l.mu.Lock()
pending, ok := l.clientRequests[key]
if ok && pending.generation == generation {
pending, ok = l.removeClientRequestLocked(key)
}
l.mu.Unlock()
if !ok || pending.generation != generation {
return nil
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(original, &raw); err != nil {
return err
}
var cancelParams map[string]json.RawMessage
if err := json.Unmarshal(raw["params"], &cancelParams); err != nil {
return err
}
cancelParams["id"] = append([]byte(nil), pending.serverID...)
encoded, err := json.Marshal(cancelParams)
if err != nil {
return err
}
raw["params"] = encoded
forwarded, err := json.Marshal(raw)
if err != nil {
return err
}
return l.writeUpstreamForGeneration(generation, forwarded)
}
func (l *lspLease) rewriteDocumentVersion(method string, original []byte) ([]byte, error) {
message, _, err := l.rewriteDocumentVersionInternal(0, method, original, false)
return message, err
}
func (l *lspLease) rewriteDocumentVersionForGeneration(generation uint64, method string, original []byte) ([]byte, bool, error) {
return l.rewriteDocumentVersionInternal(generation, method, original, true)
}
func (l *lspLease) rewriteDocumentVersionInternal(
generation uint64,
method string,
original []byte,
checkGeneration bool,
) ([]byte, bool, error) {
raw, params, document, uri, err := decodeDocumentSyncMessage(original)
if err != nil {
return nil, false, err
}
l.mu.Lock()
if checkGeneration && (l.closed || l.browser == nil || l.generation != generation) {
l.mu.Unlock()
return nil, false, nil
}
switch method {
case lspMethodDidOpen, lspMethodDidChange:
previous := l.documentVersions[uri]
version := previous + 1
if previous > 0 {
l.documentVersionsSnapshotBytes -= snapshotDocumentVersionEntrySize(uri, previous)
}
l.documentVersions[uri] = version
l.documentVersionsSnapshotBytes += snapshotDocumentVersionEntrySize(uri, version)
l.openDocuments[uri] = version
l.synchronizedDocs[uri] = true
encodedVersion, _ := json.Marshal(version)
document["version"] = encodedVersion
case lspMethodDidClose:
delete(l.openDocuments, uri)
delete(l.synchronizedDocs, uri)
}
if err := l.checkSnapshotLimitLocked(); err != nil {
l.mu.Unlock()
return nil, false, err
}
l.mu.Unlock()
encodedDocument, err := json.Marshal(document)
if err != nil {
return nil, false, err
}
params["textDocument"] = encodedDocument
encodedParams, err := json.Marshal(params)
if err != nil {
return nil, false, err
}
raw["params"] = encodedParams
message, err := json.Marshal(raw)
return message, true, err
}
func decodeDocumentSyncMessage(
original []byte,
) (map[string]json.RawMessage, map[string]json.RawMessage, map[string]json.RawMessage, string, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(original, &raw); err != nil {
return nil, nil, nil, "", err
}
var params map[string]json.RawMessage
if err := json.Unmarshal(raw["params"], &params); err != nil {
return nil, nil, nil, "", err
}
var document map[string]json.RawMessage
if err := json.Unmarshal(params["textDocument"], &document); err != nil {
return nil, nil, nil, "", err
}
var uri string
if err := json.Unmarshal(document["uri"], &uri); err != nil || uri == "" {
return nil, nil, nil, "", errors.New("document synchronization has no URI")
}
return raw, params, document, uri, nil
}
func (l *lspLease) acceptDiagnostics(params json.RawMessage) (bool, error) {
var diagnostic struct {
URI string `json:"uri"`
Version *int64 `json:"version"`
}
if err := json.Unmarshal(params, &diagnostic); err != nil {
return false, err
}
l.mu.Lock()
defer l.mu.Unlock()
current, open := l.openDocuments[diagnostic.URI]
if !open || !l.synchronizedDocs[diagnostic.URI] {
return false, nil
}
if diagnostic.Version == nil {
return !l.resumedGeneration, nil
}
return *diagnostic.Version == current, nil
}
func (l *lspLease) updateRegistrations(method string, params json.RawMessage) error {
var payload map[string]json.RawMessage
if err := json.Unmarshal(params, &payload); err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
if method == "client/registerCapability" {
var registrations []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(payload["registrations"], &registrations); err != nil {
return err
}
var rawItems []json.RawMessage
if err := json.Unmarshal(payload["registrations"], &rawItems); err != nil {
return err
}
for index, registration := range registrations {
if registration.ID != "" && index < len(rawItems) {
storeSnapshotByteSlice(l.registrations, registration.ID, rawItems[index], &l.registrationSnapshotBytes)
}
}
return l.checkSnapshotLimitLocked()
}
items := payload["unregistrations"]
if len(items) == 0 {
items = payload["unregisterations"]
}
var unregistrations []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(items, &unregistrations); err != nil {
return err
}
for _, registration := range unregistrations {
deleteSnapshotByteSlice(l.registrations, registration.ID, &l.registrationSnapshotBytes)
}
return l.checkSnapshotLimitLocked()
}
func (l *lspLease) addProgressToken(params json.RawMessage) error {
var payload struct {
Token json.RawMessage `json:"token"`
}
if err := json.Unmarshal(params, &payload); err != nil || len(payload.Token) == 0 {
return errors.New("progress creation request has no token")
}
l.mu.Lock()
defer l.mu.Unlock()
storeSnapshotByteSlice(l.progressTokens, jsonRPCIDKey(payload.Token), payload.Token, &l.progressTokenSnapshotBytes)
return l.checkSnapshotLimitLocked()
}
func (l *lspLease) updateProgress(params json.RawMessage) error {
var payload struct {
Token json.RawMessage `json:"token"`
Value json.RawMessage `json:"value"`
}
if err := json.Unmarshal(params, &payload); err != nil || len(payload.Token) == 0 || len(payload.Value) == 0 {
return nil
}
key := jsonRPCIDKey(payload.Token)
var value struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(payload.Value, &value)
l.mu.Lock()
defer l.mu.Unlock()
if _, registered := l.progressTokens[key]; !registered {
return nil
}
switch value.Kind {
case "end":
deleteSnapshotByteSlice(l.progress, key, &l.progressSnapshotBytes)
case "report":
storeSnapshotByteSlice(l.progress, key, mergeProgressReport(l.progress[key], params, payload.Value), &l.progressSnapshotBytes)
default:
storeSnapshotByteSlice(l.progress, key, params, &l.progressSnapshotBytes)
}
return l.checkSnapshotLimitLocked()
}
func (l *lspLease) handleControl(generation uint64, control lspControlMessage) error {
l.mu.Lock()
active := !l.closed && l.browser != nil && l.generation == generation
if active && control.Action == lspControlAttachSync {
for uri := range l.openDocuments {
l.synchronizedDocs[uri] = true
}
}
l.mu.Unlock()
if !active {
return nil
}
if control.Action == lspControlAttachSync {
return l.writeBrowser(generation, map[string]any{
lspControlField: lspControlKind,
"action": lspControlAttachSync,
"requestId": control.RequestID,
})
}
if control.Action != lspControlRelease {
return errors.New("unknown LSP broker control action")
}
reason := control.Reason
if reason != lspLeaseReleaseEditorIdle {
reason = lspLeaseReleaseStop
}
if err := l.gracefulRelease(generation, reason, control.RequestID); err != nil {
return err
}
return nil
}
@@ -0,0 +1,74 @@
package websocket
import (
"bytes"
"encoding/json"
"errors"
"time"
)
func (l *lspLease) addClientRequestLocked(
generation uint64,
clientID []byte,
serverID []byte,
) error {
if len(clientID) == 0 || len(clientID) > lspLeaseMaxClientRequestIDBytes {
return errors.New("LSP request ID exceeds its limit")
}
key := jsonRPCIDKey(clientID)
if _, exists := l.clientRequests[key]; exists {
return errors.New("duplicate pending LSP request ID")
}
if len(l.clientRequests) >= lspLeaseMaxPendingClientRequests {
return errors.New("too many pending LSP requests")
}
size := len(key) + len(clientID) + len(serverID) + 64
if l.pendingClientRequestBytes+size > lspLeaseMaxPendingClientRequestBytes {
return errors.New("pending LSP request state exceeds its limit")
}
request := lspLeaseClientRequest{
serverID: append([]byte(nil), serverID...),
generation: generation,
clientID: append([]byte(nil), clientID...),
byteSize: size,
}
request.timer = time.AfterFunc(l.clientRequestTimeout, func() {
l.expireClientRequest(key, generation, serverID)
})
l.clientRequests[key] = request
l.pendingClientRequestBytes += size
return nil
}
func (l *lspLease) removeClientRequestLocked(key string) (lspLeaseClientRequest, bool) {
request, ok := l.clientRequests[key]
if !ok {
return lspLeaseClientRequest{}, false
}
delete(l.clientRequests, key)
if request.timer != nil {
request.timer.Stop()
}
l.pendingClientRequestBytes -= request.byteSize
if l.pendingClientRequestBytes < 0 {
l.pendingClientRequestBytes = 0
}
return request, true
}
func (l *lspLease) expireClientRequest(key string, generation uint64, serverID []byte) {
l.mu.Lock()
request, ok := l.clientRequests[key]
if !ok || request.generation != generation || !bytes.Equal(request.serverID, serverID) {
l.mu.Unlock()
return
}
request, _ = l.removeClientRequestLocked(key)
attached := !l.closed && l.browser != nil && l.generation == generation
l.mu.Unlock()
if !attached {
return
}
_ = l.writeUpstreamForGeneration(generation, jsonRPCNotification("$/cancelRequest", map[string]any{"id": request.serverID}))
_ = l.writeBrowserOrDetach(generation, json.RawMessage(jsonRPCErrorResponseRaw(request.clientID, -32800, "language server request timed out")))
}
@@ -0,0 +1,142 @@
package websocket
import (
"fmt"
"strings"
"testing"
"time"
)
func TestLSPPendingClientRequestsAreBounded(t *testing.T) {
upstream, _ := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.clientRequestTimeout = time.Hour
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
for id := 0; id < lspLeaseMaxPendingClientRequests; id++ {
message := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"workspace/symbol","params":{"query":"x"}}`, id))
if err := lease.handleBrowserMessage(generation, message); err != nil {
t.Fatalf("forward request %d: %v", id, err)
}
}
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","id":999,"method":"workspace/symbol","params":{"query":"overflow"}}`)); err == nil {
t.Fatal("request above the pending count limit was accepted")
}
if got := len(lease.clientRequests); got != lspLeaseMaxPendingClientRequests {
t.Fatalf("pending requests = %d, want limit %d", got, lspLeaseMaxPendingClientRequests)
}
if lease.pendingClientRequestBytes > lspLeaseMaxPendingClientRequestBytes {
t.Fatalf("pending request bytes = %d, exceeds limit %d", lease.pendingClientRequestBytes, lspLeaseMaxPendingClientRequestBytes)
}
lease.detach(generation)
}
func TestLSPPendingClientRequestIDSizeIsBounded(t *testing.T) {
upstream, _ := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.clientRequestTimeout = time.Hour
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
id := `"` + strings.Repeat("x", lspLeaseMaxClientRequestIDBytes) + `"`
message := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"method":"workspace/symbol","params":{"query":"x"}}`, id))
if err := lease.handleBrowserMessage(generation, message); err == nil {
t.Fatal("request with an oversized ID was accepted")
}
if got := len(lease.clientRequests); got != 0 {
t.Fatalf("oversized ID created %d pending requests, want none", got)
}
lease.detach(generation)
}
func TestLSPPendingClientRequestExpiresWhenServerDoesNotRespond(t *testing.T) {
upstream, upstreamPeer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.clientRequestTimeout = 20 * time.Millisecond
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","id":7,"method":"workspace/symbol","params":{"query":"x"}}`)); err != nil {
t.Fatal(err)
}
if request := readLSPJSONRPCMessage(t, upstreamPeer); string(request["method"]) != `"workspace/symbol"` {
t.Fatalf("upstream request method = %s, want workspace/symbol", request["method"])
}
deadline := time.Now().Add(wsTestTimeout)
for time.Now().Before(deadline) {
lease.mu.Lock()
pending := len(lease.clientRequests)
lease.mu.Unlock()
if pending == 0 {
break
}
time.Sleep(time.Millisecond)
}
lease.mu.Lock()
pending := len(lease.clientRequests)
lease.mu.Unlock()
if pending != 0 {
t.Fatalf("pending requests after timeout = %d, want zero", pending)
}
response := readLSPJSONRPCMessage(t, browserPeer)
if string(response["id"]) != "7" || len(response["error"]) == 0 {
t.Fatalf("timeout response = %#v, want an error for request 7", response)
}
cancel := readLSPJSONRPCMessage(t, upstreamPeer)
if string(cancel["method"]) != `"$/cancelRequest"` {
t.Fatalf("timeout upstream frame = %s, want cancelRequest", cancel["method"])
}
lease.detach(generation)
}
func TestLSPPendingClientRequestResponseReleasesBoundedState(t *testing.T) {
upstream, upstreamPeer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.clientRequestTimeout = time.Hour
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","id":"query-1","method":"workspace/symbol","params":{"query":"x"}}`)); err != nil {
t.Fatal(err)
}
request := readLSPJSONRPCMessage(t, upstreamPeer)
response := []byte(`{"jsonrpc":"2.0","id":` + string(request["id"]) + `,"result":[]}`)
if err := lease.handleServerResponse(jsonRPCMessage{ID: request["id"], Result: []byte(`[]`)}, response); err != nil {
t.Fatal(err)
}
if forwarded := readLSPJSONRPCMessage(t, browserPeer); string(forwarded["id"]) != `"query-1"` {
t.Fatalf("response client ID = %s, want query-1", forwarded["id"])
}
if len(lease.clientRequests) != 0 || lease.pendingClientRequestBytes != 0 {
t.Fatalf("pending state remains after response: count=%d bytes=%d", len(lease.clientRequests), lease.pendingClientRequestBytes)
}
lease.detach(generation)
}
@@ -0,0 +1,84 @@
package websocket
import (
"bytes"
"context"
"encoding/json"
"strings"
"github.com/kandev/kandev/internal/events/bus"
)
func (h *LSPHandler) handleUserSettingsUpdated(_ context.Context, event *bus.Event) error {
if h.leases == nil || event == nil {
return nil
}
userID, payload := routingUserIDAndPayload(event.Data)
if userID == "" {
return nil
}
encoded, err := json.Marshal(payload["lsp_server_configs"])
if err != nil {
return nil
}
var configurations map[string]map[string]any
if err := json.Unmarshal(encoded, &configurations); err != nil || configurations == nil {
configurations = make(map[string]map[string]any)
}
h.leases.updateUserConfiguration(userID, configurations)
return nil
}
func configurationSection(configuration map[string]any, section, language string) any {
if section == "" {
return configuration
}
if section == language {
return configuration
}
if prefix := language + "."; language != "" && strings.HasPrefix(section, prefix) {
section = strings.TrimPrefix(section, prefix)
}
var value any = configuration
for _, part := range strings.Split(section, ".") {
current, ok := value.(map[string]any)
if !ok {
return nil
}
value, ok = current[part]
if !ok {
return nil
}
}
return value
}
func (l *lspLease) updateConfiguration(configuration map[string]any, notify bool) error {
configuration = cloneStringAnyMap(configuration)
encoded, err := json.Marshal(configuration)
if err != nil {
return err
}
l.mu.Lock()
current, _ := json.Marshal(l.configuration)
ready, closed := l.ready, l.closed
if bytes.Equal(current, encoded) {
l.mu.Unlock()
return l.notifyConfiguration(configuration, notify, ready, closed)
}
l.configuration = configuration
l.configurationSnapshotBytes = len(encoded)
if err := l.checkSnapshotLimitLocked(); err != nil {
l.mu.Unlock()
return err
}
l.mu.Unlock()
return l.notifyConfiguration(configuration, notify, ready, closed)
}
func (l *lspLease) notifyConfiguration(configuration map[string]any, notify, ready, closed bool) error {
if notify && ready && !closed {
return l.writeUpstream(jsonRPCNotification("workspace/didChangeConfiguration", map[string]any{"settings": configuration}))
}
return nil
}
@@ -0,0 +1,55 @@
package websocket
import (
"encoding/base64"
"fmt"
"strconv"
)
const lspLeaseSnapshotFixedOverhead = 1024
func (l *lspLease) checkSnapshotLimitLocked() error {
size := lspLeaseSnapshotFixedOverhead + len(l.initializeResult) + len(l.initializeServerID) + len(l.initializeResponse)
size += l.configurationSnapshotBytes + l.documentVersionsSnapshotBytes
size += l.registrationSnapshotBytes + l.progressTokenSnapshotBytes + l.progressSnapshotBytes
size += boundedJSONStringSize(l.workspacePath) + boundedJSONStringSize(l.workspaceURI)
for _, path := range l.repoSubpaths {
size += boundedJSONStringSize(path) + 1
}
if l.initializeWaiter != nil {
size += base64.StdEncoding.EncodedLen(len(l.initializeWaiter.clientID)) + 128
}
size += len(strconv.Itoa(len(l.clientRequests))) + len(strconv.Itoa(l.pendingClientRequestBytes))
if size > lspLeaseSnapshotLimit {
return fmt.Errorf("LSP resume state is at least %d bytes; maximum is %d", size, lspLeaseSnapshotLimit)
}
return nil
}
func boundedJSONStringSize(value string) int {
return len(value)*6 + 2
}
func snapshotByteSliceMapEntrySize(key string, value []byte) int {
return boundedJSONStringSize(key) + base64.StdEncoding.EncodedLen(len(value)) + 4
}
func snapshotDocumentVersionEntrySize(uri string, version int64) int {
return boundedJSONStringSize(uri) + len(strconv.FormatInt(version, 10)) + 2
}
func storeSnapshotByteSlice(values map[string][]byte, key string, value []byte, size *int) {
if previous, exists := values[key]; exists {
*size -= snapshotByteSliceMapEntrySize(key, previous)
}
stored := append([]byte(nil), value...)
values[key] = stored
*size += snapshotByteSliceMapEntrySize(key, stored)
}
func deleteSnapshotByteSlice(values map[string][]byte, key string, size *int) {
if previous, exists := values[key]; exists {
*size -= snapshotByteSliceMapEntrySize(key, previous)
delete(values, key)
}
}
@@ -0,0 +1,105 @@
package websocket
import (
"encoding/json"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
)
func TestLSPLeaseManagerEvictsOnlyDetachedLeaseAtCapacity(t *testing.T) {
t.Run("evicts detached", func(t *testing.T) {
manager := newLSPLeaseManager(1, testLogger())
lease := newTestLSPLease(manager)
lease.detachedAt = time.Now().Add(-time.Minute)
close(lease.readDone)
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
if err := manager.ensureCapacity(); err != nil {
t.Fatalf("ensureCapacity() error = %v", err)
}
if got := len(manager.leases); got != 0 {
t.Fatalf("lease count after eviction = %d, want 0", got)
}
})
t.Run("rejects when every lease is attached", func(t *testing.T) {
manager := newLSPLeaseManager(1, testLogger())
lease := newTestLSPLease(manager)
lease.browser = &gorillaws.Conn{}
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
if err := manager.ensureCapacity(); err != errLSPCapacityExceeded {
t.Fatalf("ensureCapacity() error = %v, want %v", err, errLSPCapacityExceeded)
}
if lease.isClosed() {
t.Fatal("attached lease was closed to make room")
}
})
}
func TestLSPLeaseProgressSnapshotRetainsBeginAndLatestReport(t *testing.T) {
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.progressTokens[jsonRPCIDKey([]byte(`"scan"`))] = []byte(`"scan"`)
if err := lease.updateProgress(json.RawMessage(`{"token":"scan","value":{"kind":"begin","title":"Indexing","percentage":0}}`)); err != nil {
t.Fatal(err)
}
if err := lease.updateProgress(json.RawMessage(`{"token":"scan","value":{"kind":"report","message":"Loading packages","percentage":42}}`)); err != nil {
t.Fatal(err)
}
var snapshot struct {
Token string `json:"token"`
Value struct {
Kind string `json:"kind"`
Title string `json:"title"`
Message string `json:"message"`
Percentage int `json:"percentage"`
} `json:"value"`
}
if err := json.Unmarshal(lease.progress[jsonRPCIDKey([]byte(`"scan"`))], &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.Value.Kind != "begin" || snapshot.Value.Title != "Indexing" || snapshot.Value.Message != "Loading packages" || snapshot.Value.Percentage != 42 {
t.Fatalf("progress snapshot = %+v, want the begin title with latest report state", snapshot)
}
}
func TestLSPLeaseSnapshotSizeCountersTrackDynamicState(t *testing.T) {
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
register := json.RawMessage(`{"registrations":[{"id":"completion","method":"textDocument/completion"}]}`)
if err := lease.updateRegistrations("client/registerCapability", register); err != nil {
t.Fatal(err)
}
if lease.registrationSnapshotBytes == 0 {
t.Fatal("registration size was not added to the snapshot counter")
}
if err := lease.updateRegistrations("client/unregisterCapability", json.RawMessage(`{"unregisterations":[{"id":"completion"}]}`)); err != nil {
t.Fatal(err)
}
if lease.registrationSnapshotBytes != 0 {
t.Fatalf("registration bytes after unregister = %d, want zero", lease.registrationSnapshotBytes)
}
if err := lease.addProgressToken(json.RawMessage(`{"token":"index"}`)); err != nil {
t.Fatal(err)
}
if lease.progressTokenSnapshotBytes == 0 {
t.Fatal("progress token size was not added to the snapshot counter")
}
if err := lease.updateProgress(json.RawMessage(`{"token":"index","value":{"kind":"begin","title":"Index"}}`)); err != nil {
t.Fatal(err)
}
if lease.progressSnapshotBytes == 0 {
t.Fatal("progress size was not added to the snapshot counter")
}
if err := lease.updateProgress(json.RawMessage(`{"token":"index","value":{"kind":"end"}}`)); err != nil {
t.Fatal(err)
}
if lease.progressSnapshotBytes != 0 {
t.Fatalf("progress bytes after end = %d, want zero", lease.progressSnapshotBytes)
}
}
@@ -0,0 +1,735 @@
package websocket
import (
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentruntime"
)
func newLSPTestWebSocketPair(t *testing.T) (*gorillaws.Conn, *gorillaws.Conn) {
t.Helper()
connected := make(chan *gorillaws.Conn, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := lspUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
connected <- conn
}))
t.Cleanup(server.Close)
client, response, err := gorillaws.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
t.Fatalf("dial websocket test pair: %v", err)
}
t.Cleanup(func() { _ = client.Close() })
var peer *gorillaws.Conn
select {
case peer = <-connected:
case <-time.After(wsTestTimeout):
t.Fatal("websocket peer did not connect")
}
t.Cleanup(func() { _ = peer.Close() })
return peer, client
}
func newTestLSPLease(manager *lspLeaseManager) *lspLease {
return newLSPLease(manager, lspLeaseExecution{
ID: "execution-1", SessionID: "session-1", TaskID: "task-1",
}, "go", "user-1", map[string]any{}, nil)
}
func TestLSPLeaseManagerHonorsAttachedLeaseHint(t *testing.T) {
manager := newLSPLeaseManager(3, testLogger())
execution := lspLeaseExecution{ID: "execution-1", SessionID: "session-1"}
attached := newTestLSPLease(manager)
attached.id = "attached-lease"
attached.browser = &gorillaws.Conn{}
detached := newTestLSPLease(manager)
detached.id = "other-detached-lease"
if err := manager.add(attached); err != nil {
t.Fatal(err)
}
if err := manager.add(detached); err != nil {
t.Fatal(err)
}
if got := manager.detachedCandidate(execution, "go", "user-1", attached.id); got != nil {
t.Fatalf("attached lease hint selected a different lease: %q", got.id)
}
if got := manager.detachedCandidate(execution, "go", "user-1", detached.id); got != detached {
t.Fatalf("detached lease hint selected %v, want %q", got, detached.id)
}
if got := manager.detachedCandidate(execution, "go", "user-1", ""); got != detached {
t.Fatalf("unhinted candidate = %v, want %q", got, detached.id)
}
}
func TestLSPLeaseManagerDoesNotReuseDetachedLeaseAcrossUsers(t *testing.T) {
manager := newLSPLeaseManager(3, testLogger())
execution := lspLeaseExecution{ID: "execution-1", SessionID: "session-1"}
lease := newTestLSPLease(manager)
lease.configuration = map[string]any{"gopls": map[string]any{"buildFlags": []any{"-tags=user-one"}}}
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
if got := manager.detachedCandidate(execution, "go", "user-2", ""); got != nil {
t.Fatalf("user 2 selected user 1's detached lease %q", got.id)
}
if got := manager.detachedCandidate(execution, "go", "user-1", ""); got != lease {
t.Fatalf("user 1 candidate = %v, want their detached lease", got)
}
}
func TestLSPTaskStopUsesTerminalBrowserCloseCode(t *testing.T) {
manager := newLSPLeaseManager(2, testLogger())
lease := newTestLSPLease(manager)
browser, browserPeer := newLSPTestWebSocketPair(t)
lease.browser = browser
lease.generation = 1
if err := manager.add(lease); err != nil {
t.Fatal(err)
}
manager.stopTask("task-1")
if err := browserPeer.SetReadDeadline(time.Now().Add(wsTestTimeout)); err != nil {
t.Fatal(err)
}
_, _, err := browserPeer.ReadMessage()
var closeErr *gorillaws.CloseError
if !errors.As(err, &closeErr) || closeErr.Code != lspCloseRuntimeStopped {
t.Fatalf("task stop close error = %v, want terminal code %d", err, lspCloseRuntimeStopped)
}
}
func TestLSPLeaseOldGenerationDetachCannotDetachSuccessor(t *testing.T) {
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
first, _ := newLSPTestWebSocketPair(t)
firstGeneration, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
lease.detach(firstGeneration)
second, _ := newLSPTestWebSocketPair(t)
secondGeneration, _, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
lease.detach(firstGeneration)
if !lease.isActiveGeneration(secondGeneration) {
t.Fatal("stale first-handler cleanup detached its successor generation")
}
}
func TestLSPLeaseTerminationWaitsForBrowserWrite(t *testing.T) {
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
browser, client := newLSPTestWebSocketPair(t)
lease.browser = browser
lease.generation = 1
lease.browserWriteMu.Lock()
terminated := make(chan struct{})
go func() {
lease.terminate(lspCloseTransport, "test termination", lspLeaseReleaseRuntimeStop)
close(terminated)
}()
deadline := time.Now().Add(wsTestTimeout)
for !lease.isClosed() && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if !lease.isClosed() {
t.Fatal("termination did not begin")
}
select {
case <-terminated:
t.Fatal("termination closed the browser while a browser write was in progress")
default:
}
lease.browserWriteMu.Unlock()
select {
case <-terminated:
case <-time.After(wsTestTimeout):
t.Fatal("termination did not finish after the browser write completed")
}
if err := client.SetReadDeadline(time.Now().Add(wsTestTimeout)); err != nil {
t.Fatal(err)
}
_, _, err := client.ReadMessage()
var closeErr *gorillaws.CloseError
if !errors.As(err, &closeErr) || closeErr.Code != lspCloseTransport {
t.Fatalf("browser close error = %v, want code %d", err, lspCloseTransport)
}
}
func TestLSPWorkspaceConfigurationPreservesLanguageSections(t *testing.T) {
upstream, peer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.language = "go"
lease.configuration = map[string]any{
"gopls": map[string]any{"buildFlags": []any{"-tags=integration"}},
"compiler": map[string]any{"jvmTarget": "21"},
}
params := json.RawMessage(`{"items":[{"section":""},{"section":"go"},{"section":"go.gopls"},{"section":"gopls"},{"section":"compiler.jvmTarget"},{"section":"missing"}]}`)
if err := lease.answerWorkspaceConfiguration(json.RawMessage(`9`), params); err != nil {
t.Fatal(err)
}
response := readJSONRPCResponse(t, peer)
var values []any
if err := json.Unmarshal(response.Result, &values); err != nil {
t.Fatal(err)
}
if len(values) != 6 {
t.Fatalf("configuration response length = %d, want 6", len(values))
}
if values[0] == nil || values[1] == nil || values[2] == nil || values[3] == nil {
t.Fatalf("language and normalized sections must resolve: %#v", values)
}
if values[4] != "21" || values[5] != nil {
t.Fatalf("nested and missing sections = %#v, want 21 and null", values[4:])
}
}
func TestLSPInitialConfigurationChangeIsForwardedWhenSnapshotMatches(t *testing.T) {
upstream, upstreamPeer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.configuration = map[string]any{"gopls": map[string]any{"buildFlags": []any{"-tags=integration"}}}
if err := lease.manager.add(lease); err != nil {
t.Fatal(err)
}
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{"gopls":{"buildFlags":["-tags=integration"]}}}}`)); err != nil {
t.Fatal(err)
}
message := readLSPJSONRPCMessage(t, upstreamPeer)
if string(message["method"]) != `"workspace/didChangeConfiguration"` {
t.Fatalf("first upstream configuration notification = %s, want didChangeConfiguration", message["method"])
}
}
func TestLSPFailedInitializeResponseIsNotReplayed(t *testing.T) {
upstream, upstreamPeer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
browser, browserPeer := newLSPTestWebSocketPair(t)
generation, _, err := lease.attach(browser)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, browserPeer)
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
first := readLSPJSONRPCMessage(t, upstreamPeer)
failedResponse := []byte(`{"jsonrpc":"2.0","id":` + string(first["id"]) + `,"error":{"code":-32002,"message":"initialize failed"}}`)
if err := lease.handleServerResponse(jsonRPCMessage{ID: first["id"], Error: json.RawMessage(`{"code":-32002,"message":"initialize failed"}`)}, failedResponse); err != nil {
t.Fatal(err)
}
if response := readJSONRPCResponse(t, browserPeer); len(response.Error) == 0 {
t.Fatalf("initialize response error = %s, want failure", response.Error)
}
if len(lease.initializeResponse) != 0 {
t.Fatalf("failed initialize response was cached: %s", lease.initializeResponse)
}
if err := lease.handleBrowserMessage(generation, []byte(`{"jsonrpc":"2.0","id":2,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
second := readLSPJSONRPCMessage(t, upstreamPeer)
if string(second["method"]) != `"initialize"` {
t.Fatalf("retry upstream method = %s, want a fresh initialize request", second["method"])
}
}
func TestLSPDynamicRegistrationUpdatesWhileAttachedAndAfterResume(t *testing.T) {
upstream, server := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
lease.initializeResult = []byte(`{"capabilities":{}}`)
first, firstPeer := newLSPTestWebSocketPair(t)
firstGeneration, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, firstPeer)
register := jsonRPCMessage{
JSONRPC: "2.0", ID: json.RawMessage(`17`), Method: "client/registerCapability",
Params: json.RawMessage(`{"registrations":[{"id":"hover-1","method":"textDocument/hover","registerOptions":{"documentSelector":[{"language":"go"}]}}]}`),
}
registerRaw := []byte(`{"jsonrpc":"2.0","id":17,"method":"client/registerCapability","params":{"registrations":[{"id":"hover-1","method":"textDocument/hover","registerOptions":{"documentSelector":[{"language":"go"}]}}]}}`)
if err := lease.handleServerRequest(register, registerRaw); err != nil {
t.Fatal(err)
}
forwarded := readLSPJSONRPCMessage(t, firstPeer)
var forwardedID json.RawMessage
if err := json.Unmarshal(forwarded["id"], &forwardedID); err != nil {
t.Fatal(err)
}
responseRaw := []byte(`{"jsonrpc":"2.0","id":` + string(forwardedID) + `,"result":null}`)
if err := lease.handleBrowserResponse(firstGeneration, jsonRPCMessage{ID: forwardedID}, responseRaw); err != nil {
t.Fatal(err)
}
if response := readJSONRPCResponse(t, server); string(response.ID) != `17` {
t.Fatalf("registration response id = %s, want 17", response.ID)
}
lease.detach(firstGeneration)
second, secondPeer := newLSPTestWebSocketPair(t)
secondGeneration, resumed, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
if !resumed {
t.Fatal("reattachment did not use retained initialize state")
}
handshake := readLSPLeaseStatus(t, secondPeer)
registrations, ok := handshake["registrations"].([]any)
if !ok || len(registrations) != 1 {
t.Fatalf("resumed registrations = %#v, want one retained registration", handshake["registrations"])
}
unregister := jsonRPCMessage{
JSONRPC: "2.0", ID: json.RawMessage(`18`), Method: "client/unregisterCapability",
Params: json.RawMessage(`{"unregisterations":[{"id":"hover-1","method":"textDocument/hover"}]}`),
}
unregisterRaw := []byte(`{"jsonrpc":"2.0","id":18,"method":"client/unregisterCapability","params":{"unregisterations":[{"id":"hover-1","method":"textDocument/hover"}]}}`)
if err := lease.handleServerRequest(unregister, unregisterRaw); err != nil {
t.Fatal(err)
}
forwarded = readLSPJSONRPCMessage(t, secondPeer)
if err := json.Unmarshal(forwarded["id"], &forwardedID); err != nil {
t.Fatal(err)
}
responseRaw = []byte(`{"jsonrpc":"2.0","id":` + string(forwardedID) + `,"result":null}`)
if err := lease.handleBrowserResponse(secondGeneration, jsonRPCMessage{ID: forwardedID}, responseRaw); err != nil {
t.Fatal(err)
}
if response := readJSONRPCResponse(t, server); string(response.ID) != `18` {
t.Fatalf("unregistration response id = %s, want 18", response.ID)
}
lease.mu.Lock()
defer lease.mu.Unlock()
if len(lease.registrations) != 0 {
t.Fatalf("unregistered capability remains in the broker snapshot: %#v", lease.registrations)
}
}
func TestLSPInitializeHandshakeSurvivesDetachBeforeResponse(t *testing.T) {
upstream, server := newLSPTestWebSocketPair(t)
upstreamFrames := observeLSPFrames(t, server)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
first, firstPeer := newLSPTestWebSocketPair(t)
firstGeneration, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, firstPeer)
if err := lease.handleBrowserMessage(firstGeneration, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
initialize := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
if string(initialize["method"]) != `"initialize"` {
t.Fatalf("first upstream request = %s, want initialize", initialize["method"])
}
serverID := append([]byte(nil), initialize["id"]...)
lease.detach(firstGeneration)
second, secondPeer := newLSPTestWebSocketPair(t)
secondGeneration, resumed, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
if resumed {
t.Fatal("pending initialize was reported as resumed")
}
_ = readLSPLeaseStatus(t, secondPeer)
if err := lease.handleBrowserMessage(secondGeneration, []byte(`{"jsonrpc":"2.0","id":2,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
select {
case <-upstreamFrames:
t.Fatal("detach or reconnect sent an extra upstream frame before initialize completed")
case <-time.After(50 * time.Millisecond):
}
result := json.RawMessage(`{"capabilities":{"hoverProvider":true}}`)
responseRaw, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": json.RawMessage(serverID), "result": result,
})
if err != nil {
t.Fatal(err)
}
if err := lease.handleServerResponse(jsonRPCMessage{ID: serverID, Result: result}, responseRaw); err != nil {
t.Fatal(err)
}
response := readJSONRPCResponse(t, secondPeer)
if string(response.ID) != `2` {
t.Fatalf("initialize response id = %s, want 2", response.ID)
}
if err := lease.handleBrowserMessage(secondGeneration, []byte(`{"jsonrpc":"2.0","method":"initialized","params":{}}`)); err != nil {
t.Fatal(err)
}
initialized := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
if string(initialized["method"]) != `"initialized"` {
t.Fatalf("upstream notification = %s, want initialized", initialized["method"])
}
}
func TestLSPInitializeHandshakeResumesBeforeInitializedNotification(t *testing.T) {
upstream, server := newLSPTestWebSocketPair(t)
upstreamFrames := observeLSPFrames(t, server)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.ready = true
lease.readyStatus = map[string]any{"status": "ready"}
first, firstPeer := newLSPTestWebSocketPair(t)
firstGeneration, _, err := lease.attach(first)
if err != nil {
t.Fatal(err)
}
_ = readLSPLeaseStatus(t, firstPeer)
if err := lease.handleBrowserMessage(firstGeneration, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
initialize := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
result := json.RawMessage(`{"capabilities":{"hoverProvider":true}}`)
responseRaw, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": initialize["id"], "result": result,
})
if err != nil {
t.Fatal(err)
}
if err := lease.handleServerResponse(jsonRPCMessage{ID: initialize["id"], Result: result}, responseRaw); err != nil {
t.Fatal(err)
}
_ = readJSONRPCResponse(t, firstPeer)
lease.detach(firstGeneration)
second, secondPeer := newLSPTestWebSocketPair(t)
secondGeneration, resumed, err := lease.attach(second)
if err != nil {
t.Fatal(err)
}
if !resumed {
t.Fatal("completed initialize response was not reported as resumed")
}
handshake := readLSPLeaseStatus(t, secondPeer)
if handshake["initialized"] != false {
t.Fatalf("resume handshake initialized = %v, want false", handshake["initialized"])
}
if err := lease.handleBrowserMessage(secondGeneration, []byte(`{"jsonrpc":"2.0","method":"initialized","params":{}}`)); err != nil {
t.Fatal(err)
}
initialized := decodeLSPJSONRPCMessage(t, nextObservedLSPFrame(t, upstreamFrames))
if string(initialized["method"]) != `"initialized"` {
t.Fatalf("upstream notification = %s, want initialized", initialized["method"])
}
lease.mu.Lock()
gotInitialized := lease.initializedReceived
lease.mu.Unlock()
if !gotInitialized {
t.Fatal("broker did not record the initialized notification")
}
}
func TestLSPLeaseRewritesMonotonicVersionsAndRejectsStaleDiagnostics(t *testing.T) {
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.resumedGeneration = true
first, err := lease.rewriteDocumentVersion("textDocument/didOpen", []byte(`{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///main.go","version":80}}}`))
if err != nil {
t.Fatal(err)
}
if got := documentVersionFromMessage(t, first); got != 1 {
t.Fatalf("first server-facing version = %d, want 1", got)
}
change, err := lease.rewriteDocumentVersion("textDocument/didChange", []byte(`{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///main.go","version":1}}}`))
if err != nil {
t.Fatal(err)
}
if got := documentVersionFromMessage(t, change); got != 2 {
t.Fatalf("second server-facing version = %d, want 2", got)
}
stale, err := lease.acceptDiagnostics(json.RawMessage(`{"uri":"file:///main.go","version":1}`))
if err != nil || stale {
t.Fatalf("stale diagnostics accepted = %v, error = %v", stale, err)
}
current, err := lease.acceptDiagnostics(json.RawMessage(`{"uri":"file:///main.go","version":2}`))
if err != nil || !current {
t.Fatalf("current diagnostics accepted = %v, error = %v", current, err)
}
unversioned, err := lease.acceptDiagnostics(json.RawMessage(`{"uri":"file:///main.go"}`))
if err != nil || unversioned {
t.Fatalf("unversioned resumed diagnostics accepted = %v, error = %v", unversioned, err)
}
_, err = lease.rewriteDocumentVersion("textDocument/didClose", []byte(`{"jsonrpc":"2.0","method":"textDocument/didClose","params":{"textDocument":{"uri":"file:///main.go"}}}`))
if err != nil {
t.Fatal(err)
}
if got := lease.documentVersions["file:///main.go"]; got != 2 {
t.Fatalf("didClose changed version counter to %d", got)
}
}
func TestLSPLeaseDetachedBrokerRepliesOnlyToSupportedRequests(t *testing.T) {
upstream, peer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
register := jsonRPCMessage{
JSONRPC: "2.0", ID: json.RawMessage(`17`), Method: "client/registerCapability",
Params: json.RawMessage(`{"registrations":[{"id":"hover-1","method":"textDocument/hover","registerOptions":{}}]}`),
}
if err := lease.handleServerRequest(register, nil); err != nil {
t.Fatal(err)
}
if got := readJSONRPCResponse(t, peer); string(got.Result) != "null" || len(got.Error) != 0 {
t.Fatalf("detached registration response = %+v, want null result", got)
}
if _, ok := lease.registrations["hover-1"]; !ok {
t.Fatal("detached dynamic registration was not retained")
}
unsupported := jsonRPCMessage{
JSONRPC: "2.0", ID: json.RawMessage(`18`), Method: "workspace/executeCommand",
}
if err := lease.handleServerRequest(unsupported, nil); err != nil {
t.Fatal(err)
}
response := readJSONRPCResponse(t, peer)
var responseError struct {
Code int `json:"code"`
}
if err := json.Unmarshal(response.Error, &responseError); err != nil {
t.Fatal(err)
}
if responseError.Code != -32601 {
t.Fatalf("unsupported detached response code = %d, want -32601", responseError.Code)
}
}
func TestLSPLeaseCancellationUsesMappedServerRequestID(t *testing.T) {
upstream, peer := newLSPTestWebSocketPair(t)
lease := newTestLSPLease(newLSPLeaseManager(2, testLogger()))
lease.upstream = upstream
lease.generation = 4
lease.browser = &gorillaws.Conn{}
lease.clientRequests["42"] = lspLeaseClientRequest{
serverID: json.RawMessage(`"kandev:client:7"`), generation: 4,
}
if err := lease.handleBrowserMessage(4, []byte(`{"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":42}}`)); err != nil {
t.Fatal(err)
}
_, message, err := peer.ReadMessage()
if err != nil {
t.Fatal(err)
}
var cancellation struct {
Params struct {
ID json.RawMessage `json:"id"`
} `json:"params"`
}
if err := json.Unmarshal(message, &cancellation); err != nil {
t.Fatal(err)
}
if string(cancellation.Params.ID) != `"kandev:client:7"` {
t.Fatalf("mapped cancellation id = %s, want %s", cancellation.Params.ID, `"kandev:client:7"`)
}
}
func TestLSPContinuityReconnectsToSameTaskHostStream(t *testing.T) {
var streamStarts atomic.Int32
var initializeRequests atomic.Int32
didOpen := make(chan struct{}, 1)
host := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := lspUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
streamStarts.Add(1)
defer func() { _ = conn.Close() }()
if err := conn.WriteJSON(map[string]any{
"status": "ready", "workspacePath": "/workspace", "workspaceUri": "file:///workspace",
"repoSubpaths": []string{},
}); err != nil {
return
}
for {
_, message, readErr := conn.ReadMessage()
if readErr != nil {
return
}
var rpc jsonRPCMessage
if json.Unmarshal(message, &rpc) != nil {
continue
}
switch rpc.Method {
case "initialize":
initializeRequests.Add(1)
_ = conn.WriteJSON(map[string]any{
"jsonrpc": "2.0", "id": rpc.ID,
"result": map[string]any{"capabilities": map[string]any{"hoverProvider": true}},
})
case "textDocument/didOpen":
select {
case didOpen <- struct{}{}:
default:
}
case "shutdown":
_ = conn.WriteJSON(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": nil})
case "exit":
return
}
}
}))
t.Cleanup(host.Close)
parsed, err := url.Parse(host.URL)
if err != nil {
t.Fatal(err)
}
hostname, portText, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
execution := &lifecycle.AgentExecution{
ID: "execution-continuity", TaskID: "task-continuity", SessionID: "session-continuity",
RuntimeName: agentruntime.RuntimeStandalone,
}
execution.SetAgentCtlClientForTesting(agentctlclient.NewClient(hostname, port, testLogger()))
manager := &recordingLSPLifecycleManager{
runtimeName: agentruntime.RuntimeStandalone,
execution: execution,
}
handler := &LSPHandler{
lifecycleMgr: manager,
capacity: newLSPCapacityLimiter(2),
logger: testLogger(),
}
var fenceHeld atomic.Bool
handler.EnableContinuity(func(string) func() {
fenceHeld.Store(true)
return func() { fenceHeld.Store(false) }
}, nil)
t.Cleanup(func() { _ = handler.Close() })
first, firstServed := dialLSPHandler(t, handler, "/lsp/session-continuity?language=go")
ready := readLSPLeaseStatus(t, first)
leaseID, _ := ready["leaseId"].(string)
if leaseID == "" || ready["resumed"] != false {
t.Fatalf("first ready status = %v, want an initial lease ID and resumed=false", ready)
}
if fenceHeld.Load() {
t.Fatal("session lifecycle fence remained held after lease admission")
}
if err := first.WriteMessage(gorillaws.TextMessage, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)); err != nil {
t.Fatal(err)
}
initialize := readLSPJSONRPCMessage(t, first)
if string(initialize["id"]) != "1" {
t.Fatalf("initialize response id = %s, want 1", initialize["id"])
}
_ = first.Close()
joinWithin(t, firstServed, "first continuity browser attachment")
waitForLSPLease(t, handler.leases, leaseID, true)
second, secondServed := dialLSPHandler(t, handler, "/lsp/session-continuity?language=go&leaseId="+leaseID)
resumed := readLSPLeaseStatus(t, second)
if resumed["leaseId"] != leaseID || resumed["resumed"] != true {
t.Fatalf("reattached status = %v, want the same lease ID and resumed=true", resumed)
}
if err := second.WriteMessage(gorillaws.TextMessage, []byte(`{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///workspace/main.go","version":1,"text":"package main"}}}`)); err != nil {
t.Fatal(err)
}
select {
case <-didOpen:
case <-time.After(wsTestTimeout):
t.Fatal("reattached document did not reach the retained server")
}
if err := second.WriteMessage(gorillaws.TextMessage, []byte(`{"kandev":"lsp","action":"attachmentReady","requestId":"sync-2"}`)); err != nil {
t.Fatal(err)
}
if ack := readLSPLeaseStatus(t, second); ack["action"] != "attachmentReady" || ack["requestId"] != "sync-2" {
t.Fatalf("attachment synchronization acknowledgement = %v", ack)
}
if got := streamStarts.Load(); got != 1 {
t.Fatalf("task-host LSP stream starts = %d, want 1", got)
}
if got := initializeRequests.Load(); got != 1 {
t.Fatalf("initialize requests = %d, want 1", got)
}
third, thirdServed := dialLSPHandler(t, handler, "/lsp/session-continuity?language=go")
independent := readLSPLeaseStatus(t, third)
independentLeaseID, _ := independent["leaseId"].(string)
if independentLeaseID == "" || independentLeaseID == leaseID || independent["resumed"] != false {
t.Fatalf("independent window status = %v, want a new lease", independent)
}
if got := streamStarts.Load(); got != 2 {
t.Fatalf("task-host LSP stream starts with two windows = %d, want 2", got)
}
if err := third.WriteMessage(gorillaws.TextMessage, []byte(`{"kandev":"lsp","action":"release","reason":"stop","requestId":"stop-independent"}`)); err != nil {
t.Fatal(err)
}
if ack := readLSPLeaseStatus(t, third); ack["action"] != "released" {
t.Fatalf("independent window Stop acknowledgement = %v", ack)
}
joinWithin(t, thirdServed, "independent continuity browser attachment")
handler.leases.mu.Lock()
originalLease := handler.leases.leases[leaseID]
remainingLeases := len(handler.leases.leases)
handler.leases.mu.Unlock()
if originalLease == nil || originalLease.isClosed() || remainingLeases != 1 {
t.Fatalf("stopping independent window affected retained lease: original=%v count=%d", originalLease, remainingLeases)
}
if err := second.WriteMessage(gorillaws.TextMessage, []byte(`{"kandev":"lsp","action":"release","reason":"stop","requestId":"stop-1"}`)); err != nil {
t.Fatal(err)
}
ack := readLSPLeaseStatus(t, second)
if ack["action"] != "released" || ack["requestId"] != "stop-1" {
t.Fatalf("explicit Stop acknowledgement = %v", ack)
}
joinWithin(t, secondServed, "second continuity browser attachment")
}
@@ -0,0 +1,169 @@
package websocket
import (
"encoding/json"
"net"
"sync"
"sync/atomic"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
)
func readLSPLeaseStatus(t *testing.T, conn *gorillaws.Conn) map[string]any {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(wsTestTimeout)); err != nil {
t.Fatal(err)
}
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatal(err)
}
var status map[string]any
if err := json.Unmarshal(message, &status); err != nil {
t.Fatal(err)
}
return status
}
func readLSPJSONRPCMessage(t *testing.T, conn *gorillaws.Conn) map[string]json.RawMessage {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(wsTestTimeout)); err != nil {
t.Fatal(err)
}
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatal(err)
}
var rpc map[string]json.RawMessage
if err := json.Unmarshal(message, &rpc); err != nil {
t.Fatal(err)
}
return rpc
}
func observeLSPFrames(t *testing.T, conn *gorillaws.Conn) <-chan []byte {
t.Helper()
frames := make(chan []byte, 8)
go func() {
defer close(frames)
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
frames <- message
}
}()
return frames
}
type lspBlockingCloseListener struct {
net.Listener
entered chan struct{}
release <-chan struct{}
accepted atomic.Int32
}
func (listener *lspBlockingCloseListener) Accept() (net.Conn, error) {
conn, err := listener.Listener.Accept()
if err != nil {
return nil, err
}
if listener.accepted.Add(1) == 1 {
return &lspBlockingCloseConn{Conn: conn, entered: listener.entered, release: listener.release}, nil
}
return conn, nil
}
type lspBlockingCloseConn struct {
net.Conn
entered chan struct{}
release <-chan struct{}
once sync.Once
}
func (conn *lspBlockingCloseConn) Close() error {
conn.once.Do(func() {
close(conn.entered)
<-conn.release
})
return conn.Conn.Close()
}
func nextObservedLSPFrame(t *testing.T, frames <-chan []byte) []byte {
t.Helper()
select {
case frame, ok := <-frames:
if !ok {
t.Fatal("upstream websocket closed before the expected frame")
}
return frame
case <-time.After(wsTestTimeout):
t.Fatal("timed out waiting for upstream LSP frame")
return nil
}
}
func decodeLSPJSONRPCMessage(t *testing.T, message []byte) map[string]json.RawMessage {
t.Helper()
var rpc map[string]json.RawMessage
if err := json.Unmarshal(message, &rpc); err != nil {
t.Fatal(err)
}
return rpc
}
func waitForLSPLease(t *testing.T, manager *lspLeaseManager, leaseID string, detached bool) {
t.Helper()
deadline := time.NewTimer(wsTestTimeout)
defer deadline.Stop()
ticker := time.NewTicker(time.Millisecond * 10)
defer ticker.Stop()
for {
manager.mu.Lock()
lease := manager.leases[leaseID]
isDetached := lease == nil || lease.isDetached()
manager.mu.Unlock()
if lease != nil && isDetached == detached {
return
}
select {
case <-ticker.C:
case <-deadline.C:
t.Fatalf("lease %q did not become detached=%t", leaseID, detached)
}
}
}
func documentVersionFromMessage(t *testing.T, message []byte) int64 {
t.Helper()
var payload struct {
Params struct {
TextDocument struct {
Version int64 `json:"version"`
} `json:"textDocument"`
} `json:"params"`
}
if err := json.Unmarshal(message, &payload); err != nil {
t.Fatal(err)
}
return payload.Params.TextDocument.Version
}
func readJSONRPCResponse(t *testing.T, conn *gorillaws.Conn) jsonRPCResponse {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(wsTestTimeout)); err != nil {
t.Fatal(err)
}
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatal(err)
}
var response jsonRPCResponse
if err := json.Unmarshal(message, &response); err != nil {
t.Fatal(err)
}
return response
}
@@ -1227,6 +1227,9 @@ func (s *Service) prepareDynamicRelaunchAfterFailure(
}
func (s *Service) stopDynamicRelaunchPredecessor(ctx context.Context, agentExecutionID string) bool {
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(agentExecutionID)
}
err := s.executor.StopExecution(ctx, agentExecutionID, "dynamic route fallback", true)
if err == nil {
return true
@@ -3865,9 +3865,9 @@ func (s *Service) handleAgentStoppedLocked(ctx context.Context, data watcher.Age
// not the event handler. This handler only manages session-level cleanup.
}
// cleanupAgentExecution stops the agentctl instance and releases its port after
// the agent reaches a terminal state (completed/failed). This runs in a goroutine
// so it doesn't block the event handler.
// cleanupAgentExecution tears down the task host after the agent reaches a
// terminal state unless an active LSP lease still owns that execution. This
// runs in a goroutine so it doesn't block the event handler.
func (s *Service) cleanupAgentExecution(executionID, taskID, sessionID string) {
s.cleanupAgentExecutionWithReason(context.Background(), executionID, taskID, sessionID, "agent completed")
}
@@ -149,6 +149,87 @@ func TestSubtaskTerminalSettle_ReclaimsProviderRuntime(t *testing.T) {
}
}
func TestAgentCompletedAndIdleReaperKeepExecutionWithActiveLSPLease(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
now := time.Now().UTC()
seedSession(t, repo, "task-lsp-completed", "session-lsp-completed", "")
if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "session-lsp-completed", SessionID: "session-lsp-completed", TaskID: "task-lsp-completed",
AgentExecutionID: "exec-lsp-completed", Runtime: agentruntime.RuntimeStandalone,
Status: models.ExecutorRunningStatusRunning, LocalPID: 8181,
CreatedAt: now.Add(-5 * time.Minute), UpdatedAt: now.Add(-5 * time.Minute),
}); err != nil {
t.Fatalf("upsert execution row: %v", err)
}
inner := &mockAgentManager{
repoForExecutionLookup: repo,
isAgentRunning: false,
rowLivenessFn: func(*models.ExecutorRunning) models.ProcessLiveness {
return models.ProcessLivenessDead
},
}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), inner)
svc.turnService = &inactiveTurnService{}
svc.idleReaper = newIdleSessionReaper()
lease := &activeLSPLeaseForTest{
sessionID: "session-lsp-completed",
executionID: "exec-lsp-completed",
executionCheck: make(chan struct{}, 2),
}
svc.SetLSPLeaseLifecycle(lease)
svc.handleAgentCompleted(ctx, watcherAgentCompletedData("task-lsp-completed", "session-lsp-completed", "exec-lsp-completed"))
select {
case <-lease.executionCheck:
case <-time.After(3 * time.Second):
t.Fatal("agent.completed cleanup did not check the exact execution's LSP lease")
}
// Exercise a stale-row reaper tick after normal turn completion. The lease
// remains authoritative even when the agent process probe reports dead.
svc.reclaimIdleSessionsOnce(ctx)
inner.mu.Lock()
stopCalls := append([]stopAgentCall(nil), inner.stopAgentWithReasonArgs...)
inner.mu.Unlock()
if len(stopCalls) != 0 {
t.Fatalf("agent.completed stopped the lease-owned execution: %+v", stopCalls)
}
row, err := repo.GetExecutorRunningBySessionID(ctx, "session-lsp-completed")
if err != nil {
t.Fatalf("load execution after reaper tick: %v", err)
}
if row.Status != models.ExecutorRunningStatusRunning || row.LocalPID != 8181 {
t.Fatalf("active lease execution changed after completion/reaper: status=%q pid=%d", row.Status, row.LocalPID)
}
}
func TestFailureCleanupStopsExecutionDespiteActiveLSPLease(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "task-lsp-failure", "session-lsp-failure", "")
inner := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), inner)
lease := &activeLSPLeaseForTest{sessionID: "session-lsp-failure", executionID: "exec-lsp-failure"}
svc.SetLSPLeaseLifecycle(lease)
if !svc.cleanupAgentExecutionWithReason(ctx, "exec-lsp-failure", "task-lsp-failure", "session-lsp-failure", "recoverable agent failure") {
t.Fatal("failed execution cleanup was deferred by an active LSP lease")
}
inner.mu.Lock()
stopCalls := append([]stopAgentCall(nil), inner.stopAgentWithReasonArgs...)
inner.mu.Unlock()
if len(stopCalls) != 1 || stopCalls[0].ExecutionID != "exec-lsp-failure" {
t.Fatalf("failure cleanup stop calls = %+v, want exact execution stop", stopCalls)
}
lease.mu.Lock()
stoppedLeases := append([]string(nil), lease.stoppedExecution...)
lease.mu.Unlock()
if len(stoppedLeases) != 1 || stoppedLeases[0] != "exec-lsp-failure" {
t.Fatalf("failure cleanup lease stops = %v, want exact execution lease stop", stoppedLeases)
}
}
// TestSubtaskTerminalCollapse_LiveAgentBlocksReclaim pins the fail-closed
// guard at the production call site: a subtask whose agent process is
// still alive must NOT be reclaimed even when the session collapses to
@@ -583,6 +583,9 @@ func (s *Service) retryTransientPrompt(ctx context.Context, taskID, sessionID, e
func (s *Service) stopTransientRetryExecution(ctx context.Context, executionID string) error {
stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), transientRetryStopTimeout)
defer cancel()
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
return s.executor.StopExecution(stopCtx, executionID, "transient retry: relaunching agent", true)
}
@@ -17,6 +17,13 @@ func (s *Service) cleanupAgentExecutionWithReason(ctx context.Context, execution
if executionID == "" {
return true
}
if reason == "agent completed" && s.lspLeases != nil && s.lspLeases.HasActiveLSPLeaseForExecution(executionID) {
s.logger.Debug("deferring execution teardown while its language server lease is active",
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return false
}
if !s.claimForcedExecutionCleanup(sessionID, executionID) {
s.logger.Debug("skipping duplicate execution teardown",
zap.String("execution_id", executionID),
@@ -36,6 +43,9 @@ func (s *Service) cleanupAgentExecutionWithReason(ctx context.Context, execution
// Defensive terminal-boundary retirement. Normal lifecycle events run this
// first; the repeated forced-cleanup call is idempotent.
s.retireExecutionActivityAndPublish(ctx, taskID, sessionID, executionID)
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
if err := s.executor.StopExecution(ctx, executionID, reason, true); err != nil && !agentruntime.IsNotFound(err) {
s.logger.Debug("agent execution cleanup after terminal state",
zap.String("execution_id", executionID),
@@ -0,0 +1,23 @@
package orchestrator
// LSPLeaseLifecycle is the narrow runtime bridge needed to keep retained
// language servers alive through idle reclaim and stop them with their task.
type LSPLeaseLifecycle interface {
HasActiveLSPLease(sessionID string) bool
HasActiveLSPLeaseForExecution(executionID string) bool
StopLSPLeasesForSession(sessionID string)
StopLSPLeasesForTask(taskID string)
StopLSPLeasesForExecution(executionID string)
}
// SetLSPLeaseLifecycle wires the optional browser-independent language-server
// runtime. Nil preserves legacy idle-reclaim behavior.
func (s *Service) SetLSPLeaseLifecycle(lifecycle LSPLeaseLifecycle) {
s.lspLeases = lifecycle
}
// AcquireSessionLifecycleFence exposes the existing per-session lifecycle
// lock to LSP admission so a retained lease cannot race idle cleanup.
func (s *Service) AcquireSessionLifecycleFence(sessionID string) func() {
return s.acquireSessionLifecycleLock(sessionID)
}
@@ -3,6 +3,7 @@ package orchestrator
import (
"context"
"errors"
"sync"
"testing"
"time"
@@ -53,6 +54,42 @@ func (*inactiveTurnService) AbandonOpenTurns(context.Context, string) error {
return nil
}
type activeLSPLeaseForTest struct {
mu sync.Mutex
sessionID string
executionID string
checks int
executionChecks int
executionCheck chan struct{}
stoppedExecution []string
}
func (lease *activeLSPLeaseForTest) HasActiveLSPLease(sessionID string) bool {
lease.mu.Lock()
lease.checks++
lease.mu.Unlock()
return sessionID == lease.sessionID
}
func (lease *activeLSPLeaseForTest) HasActiveLSPLeaseForExecution(executionID string) bool {
lease.mu.Lock()
lease.executionChecks++
lease.mu.Unlock()
if lease.executionCheck != nil {
select {
case lease.executionCheck <- struct{}{}:
default:
}
}
return executionID == lease.executionID
}
func (*activeLSPLeaseForTest) StopLSPLeasesForSession(string) {}
func (*activeLSPLeaseForTest) StopLSPLeasesForTask(string) {}
func (lease *activeLSPLeaseForTest) StopLSPLeasesForExecution(executionID string) {
lease.mu.Lock()
lease.stoppedExecution = append(lease.stoppedExecution, executionID)
lease.mu.Unlock()
}
// TestClassifyIdleReclaimDisposition is the single decision-matrix test for
// the idle-reclaim predicate. Each case names the (state, runtime-live,
// active-turn) tuple the primitive sees and the disposition it must return;
@@ -190,6 +227,48 @@ func TestReclaimIdleSessionReleasesRuntimeAndPreservesRow(t *testing.T) {
}
}
func TestReclaimIdleSessionKeepsExecutionWithActiveLSPLease(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "taskLSP", "sessionLSP", models.TaskSessionStateWaitingForInput)
if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "sessionLSP", SessionID: "sessionLSP", TaskID: "taskLSP",
AgentExecutionID: "exec-lsp", ResumeToken: "resume-keep",
Runtime: agentruntime.RuntimeStandalone, Status: models.ExecutorRunningStatusRunning,
LocalPID: 4242, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert LSP-pinned row: %v", err)
}
manager := newReclaimTrackingAgentManager(&mockAgentManager{
isAgentRunning: false,
rowLivenessFn: func(*models.ExecutorRunning) models.ProcessLiveness {
return models.ProcessLivenessDead
},
})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), manager)
svc.turnService = &inactiveTurnService{}
pin := &activeLSPLeaseForTest{sessionID: "sessionLSP"}
svc.SetLSPLeaseLifecycle(pin)
if err := svc.reclaimIdleSession(ctx, "sessionLSP"); err != nil {
t.Fatalf("reclaimIdleSession: %v", err)
}
if pin.checks != 1 {
t.Fatalf("lease liveness checks = %d, want early skip after one check", pin.checks)
}
if manager.callCount() != 0 {
t.Fatalf("stale execution cleanup calls = %v, want none while lease is active", manager.callsSnapshot())
}
row, err := repo.GetExecutorRunningBySessionID(ctx, "sessionLSP")
if err != nil {
t.Fatalf("load execution row: %v", err)
}
if row.Status != models.ExecutorRunningStatusRunning || row.LocalPID != 4242 {
t.Fatalf("active LSP lease changed execution row: status=%q pid=%d", row.Status, row.LocalPID)
}
}
// TestReclaimIdleSessionRefusesLiveRuntime proves the fail-closed guard:
// a session whose IsAgentRunningForSession returns true is never touched
// even when the session is in an idle state. The row keeps its running
@@ -328,6 +328,10 @@ func (s *Service) reclaimIdleSession(ctx context.Context, sessionID string) erro
}
releaseLifecycleLock := s.acquireSessionLifecycleLock(sessionID)
defer releaseLifecycleLock()
if s.lspLeases != nil && s.lspLeases.HasActiveLSPLease(sessionID) {
s.logger.Debug("idle reclaim skipped; language-server lease is active", zap.String("session_id", sessionID))
return nil
}
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
if isTaskSessionNotFound(err) {
@@ -778,6 +778,9 @@ func (s *Service) cleanupCancelledResumeAttempt(attempt *resumeAttempt) {
}
cleanupCtx, cancel := context.WithTimeout(context.Background(), cancellationOperationTTL)
defer cancel()
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
if err := s.executor.StopExecution(cleanupCtx, executionID, "cancelled resume startup", true); err != nil && s.logger != nil {
s.logger.Debug("failed to clean up cancelled resume execution",
zap.String("task_id", attempt.taskID),
@@ -1142,6 +1142,11 @@ type Service struct {
// / stopIdleSessionReaper no-op. See idle_session_reaper.go.
idleReaper *idleSessionReaper
// lspLeases pins an execution while a browser-independent language-server
// lease owns its task-host stream. The gateway is wired through this narrow
// interface to avoid importing its WebSocket package here.
lspLeases LSPLeaseLifecycle
// sessionCeiling is the instance-wide admission controller for agent
// session launches. Its initial effective capacity is resolved by the
// composition root and can be changed by the install Settings service.
@@ -3941,6 +3941,9 @@ func (s *Service) reapPromptUnreadyExecution(ctx context.Context, sessionID stri
zap.Error(cause))
cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), cancellationOperationTTL)
defer cancelCleanup()
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
if err := s.executor.StopExecution(cleanupCtx, executionID, promptReadinessRecoveryStopReason, true); err != nil {
return err
}
@@ -4617,6 +4620,9 @@ func (s *Service) StopTask(ctx context.Context, taskID string, reason string, fo
zap.Bool("force", force))
// Stop all agents for this task
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForTask(taskID)
}
if err := s.executor.StopByTaskID(ctx, taskID, reason, force); err != nil {
if !errors.Is(err, executor.ErrOrphanRecoveryIncomplete) {
return err
@@ -4628,7 +4634,6 @@ func (s *Service) StopTask(ctx context.Context, taskID string, reason string, fo
zap.String("task_id", taskID),
zap.Error(err))
}
// Move task to REVIEW state for user review
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW after stop",
@@ -4720,6 +4725,9 @@ func (s *Service) stopTaskSessionForCoordinator(ctx context.Context, taskID, ses
if err != nil {
return false, err
}
if result.Changed && s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForSession(sessionID)
}
// Detached teardown must not observe this operation as an in-flight
// cancellation. ScheduleTeardown starts a goroutine, so relying on the
// deferred release above makes the ordering scheduler-dependent.
@@ -4799,6 +4807,9 @@ func (s *Service) CancelTaskExecution(ctx context.Context, taskID string, reason
zap.String("task_id", taskID),
zap.String("reason", reason),
zap.Bool("force", force))
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForTask(taskID)
}
err := s.executor.StopByTaskID(ctx, taskID, reason, force)
if err != nil && errors.Is(err, executor.ErrOrphanRecoveryIncomplete) {
// Every session that could be reached did stop; only a registry-only
@@ -4846,6 +4857,9 @@ func (s *Service) StopSession(ctx context.Context, sessionID string, reason stri
zap.String("session_id", sessionID),
zap.String("reason", reason),
zap.Bool("force", force))
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForSession(sessionID)
}
return s.executor.Stop(ctx, sessionID, reason, force)
}
@@ -4854,6 +4868,9 @@ func (s *Service) StopSession(ctx context.Context, sessionID string, reason stri
// rows have been removed, and it waits for the executor process to exit before
// returning to the destructive worktree cleanup path.
func (s *Service) StopSessionSynchronously(ctx context.Context, sessionID string, reason string, force bool) error {
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForSession(sessionID)
}
return s.executor.StopSessionSynchronously(ctx, sessionID, reason, force)
}
@@ -5179,6 +5196,9 @@ func (s *Service) quiesceSessionExecutionBeforeDeletion(
ctx context.Context,
taskID, sessionID string,
) error {
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForSession(sessionID)
}
if s.agentManager == nil {
return nil
}
@@ -5310,6 +5330,9 @@ func (s *Service) StopExecution(ctx context.Context, executionID string, reason
zap.String("execution_id", executionID),
zap.String("reason", reason),
zap.Bool("force", force))
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
return s.executor.StopExecution(ctx, executionID, reason, force)
}
@@ -9945,6 +9968,9 @@ func (s *Service) CompleteTask(ctx context.Context, taskID string) error {
zap.String("task_id", taskID))
// Stop all agents for this task (which will trigger AgentCompleted events and update session states)
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForTask(taskID)
}
if err := s.executor.StopByTaskID(ctx, taskID, "task completed by user", false); err != nil {
// If agents are already stopped, just update the task state directly
s.logger.Warn("failed to stop agents, updating task state directly",
@@ -37,6 +37,13 @@
# go here as `KANDEV_FEATURES_<NAME>` — the backend's config package
# maps them to `features.<name>` Viper keys automatically.
features:
# Retains supported language-server leases across browser disconnects. The
# task-host and bounded lease lifecycle are opt-in while the feature settles.
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY:
prod: "false"
dev: "false"
e2e: "false"
# Office (autonomous agent surface). Off in prod until the feature
# is user-ready; on in dev/e2e so contributors and tests exercise it.
KANDEV_FEATURES_OFFICE:
@@ -200,6 +200,32 @@ func TestApplyStatesToConfigSetsClaudeMidTurnSteering(t *testing.T) {
}
}
func TestLSPBrowserContinuityFlagContract(t *testing.T) {
const key = "features.lspBrowserContinuity"
const envVar = "KANDEV_FEATURES_LSP_BROWSER_CONTINUITY"
definition, ok := DefinitionByKey(key)
if !ok {
t.Fatalf("runtime flag definition %q is missing", key)
}
if definition.EnvVar != envVar {
t.Fatalf("EnvVar = %q, want %q", definition.EnvVar, envVar)
}
if !definition.RestartRequired {
t.Fatal("RestartRequired = false, want true")
}
if ValuesFromConfig(&config.Config{})[key] {
t.Fatal("LSP browser continuity must default off")
}
defaults, err := profiles.FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
if got := defaults["lsp_browser_continuity"]; got != "false" {
t.Fatalf("profile default = %q, want false", got)
}
}
// TestClaudeMidTurnSteeringIsIndependentOfBackgroundHandoff pins that the two
// experiments are separately killable: enabling one must not enable the other.
func TestClaudeMidTurnSteeringIsIndependentOfBackgroundHandoff(t *testing.T) {
@@ -51,6 +51,22 @@ var retiredRuntimeFlagIdentities = []runtimeFlagIdentity{
}
var registrations = []runtimeFlagRegistration{
{
definition: RuntimeFlagDefinition{
Key: "features.lspBrowserContinuity",
EnvVar: "KANDEV_FEATURES_LSP_BROWSER_CONTINUITY",
Kind: KindFeature,
Label: "LSP browser continuity",
Description: "Keeps supported task-host language-server processes available across browser disconnects.",
Stability: StabilityExperimental,
RiskLevel: RiskMedium,
RiskDescription: "Retains the task host and language-server process while an editor is detached; enable only with the bounded lease and idle-release behavior in place.",
RestartRequired: true,
Mutable: true,
},
read: func(cfg *config.Config) bool { return cfg.Features.LSPBrowserContinuity },
apply: func(cfg *config.Config, value bool) { cfg.Features.LSPBrowserContinuity = value },
},
{
definition: RuntimeFlagDefinition{
Key: "features.office",
@@ -51,6 +51,7 @@ export function LspStatusIcon({
}
switch (status.state) {
case "connecting":
case "reconnecting":
case "starting":
case "stopping":
return (
@@ -5,6 +5,9 @@ import { modelUriForDocument } from "@/lib/lsp/file-uri";
const mocks = vi.hoisted(() => ({
getWorkspaceUriForSession: vi.fn(() => "file:///workspace"),
promoteDocumentModel: vi.fn(),
openDocument: vi.fn(),
closeDocument: vi.fn(),
lspStatus: { state: "disabled" as "disabled" | "ready" | "reconnecting" },
}));
vi.mock("@/components/state-provider", () => ({
@@ -13,13 +16,15 @@ vi.mock("@/components/state-provider", () => ({
}));
vi.mock("@/hooks/use-lsp", () => ({
useLsp: () => ({ status: { state: "disabled" }, lspLanguage: null, toggle: vi.fn() }),
useLsp: () => ({ status: mocks.lspStatus, lspLanguage: "typescript", toggle: vi.fn() }),
}));
vi.mock("@/lib/lsp/lsp-client-manager", () => ({
lspClientManager: {
getWorkspaceUriForSession: mocks.getWorkspaceUriForSession,
promoteDocumentModel: mocks.promoteDocumentModel,
openDocument: mocks.openDocument,
closeDocument: mocks.closeDocument,
},
}));
@@ -53,4 +58,29 @@ describe("useMonacoEditorLsp model identity", () => {
contentRef.current,
);
});
it("keeps its document registration through a transient reconnect", () => {
mocks.lspStatus.state = "ready";
const contentRef = { current: "class Target {}" };
const editorRef = { current: null };
const { rerender, unmount } = renderHook(() =>
useMonacoEditorLsp({
sessionId: "session",
worktreePath: "/host/worktree",
language: "typescript",
path: "src/Target.ts",
contentRef,
editorRef,
editorReady: true,
}),
);
expect(mocks.openDocument).toHaveBeenCalledTimes(1);
mocks.lspStatus.state = "reconnecting";
rerender();
expect(mocks.closeDocument).not.toHaveBeenCalled();
unmount();
expect(mocks.closeDocument).toHaveBeenCalledTimes(1);
});
});
@@ -195,7 +195,7 @@ export function useMonacoEditorLsp(opts: UseMonacoLspOpts) {
lspLanguage,
toggle: toggleLsp,
} = useLsp(lspSessionId, language);
const hasLspActive = lspStatus.state === "ready";
const hasLspActive = lspStatus.state === "ready" || lspStatus.state === "reconnecting";
const lspWorkspaceUri = lspSessionId
? lspClientManager.getWorkspaceUriForSession(lspSessionId)
: null;
+9 -3
View File
@@ -26,11 +26,12 @@ function send(message) {
process.stdout.write(payload);
}
function diagnostic(uri, message) {
function diagnostic(uri, message, version) {
send({
method: "textDocument/publishDiagnostics",
params: {
uri,
...(Number.isSafeInteger(version) ? { version } : {}),
diagnostics: [
{
range: {
@@ -135,6 +136,11 @@ function handleInitialize(message) {
const mode = JSON.parse(fs.readFileSync(initializeModePath, "utf8"));
const token = sendInitializeProgress(message, mode.progress);
if (mode.keepProgress) {
send(initializeResult(message.id));
log("initialize completed with active progress", { id: message.id, token });
return;
}
log("initialize held", { id: message.id, token });
const finish = () => {
if (!fs.existsSync(initializeReleasePath)) return false;
@@ -228,7 +234,7 @@ function handleDidOpen(message) {
const uri = message.params?.textDocument?.uri;
if (uri) {
openDocumentUris.add(uri);
diagnostic(uri, "Fake Kotlin diagnostic");
diagnostic(uri, "Fake Kotlin diagnostic", message.params?.textDocument?.version);
}
if (fs.existsSync(crashOnOpenPath)) {
log("crashing", { reason: "didOpen" });
@@ -239,7 +245,7 @@ function handleDidOpen(message) {
function handleDidChange(message) {
const uri = message.params?.textDocument?.uri;
if (uri && openDocumentUris.has(uri)) {
diagnostic(uri, "Fake Kotlin diagnostic after edit");
diagnostic(uri, "Fake Kotlin diagnostic after edit", message.params?.textDocument?.version);
}
}
+7 -2
View File
@@ -385,6 +385,7 @@ export function installFakeKotlinLsp(
options: {
crashOnOpen?: boolean;
holdInitialize?: boolean;
keepProgress?: boolean;
progress?: {
title: string;
beginPercentage?: number;
@@ -402,10 +403,14 @@ export function installFakeKotlinLsp(
fs.rmSync(initializeModePath(backend), { force: true });
fs.rmSync(initializeReleasePath(backend), { force: true });
if (options.crashOnOpen) fs.writeFileSync(crashModePath(backend), "1\n");
if (options.holdInitialize || options.progress) {
if (
options.holdInitialize ||
(options.progress && !options.keepProgress) ||
options.keepProgress
) {
fs.writeFileSync(
initializeModePath(backend),
JSON.stringify({ progress: options.progress ?? null }),
JSON.stringify({ progress: options.progress ?? null, keepProgress: options.keepProgress }),
);
}
}
@@ -4,6 +4,7 @@ import path from "node:path";
import { execSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { makeGitEnv } from "../../helpers/git-helper";
import { dwell } from "../../helpers/causal-waits";
import {
assertLocatorWithinViewportX,
assertNoElementHorizontalOverflow,
@@ -25,6 +26,7 @@ import {
releaseFakeLspInitialization,
removeFakeKotlinLsp,
} from "./lsp-e2e-helpers";
import { SessionPage } from "../../pages/session-page";
const EDITORS_SETTINGS_PATH = "/settings/preferences/terminal-editors";
const RESERVED_SOURCE_PATH = "Main # query? 100%.kt";
@@ -951,12 +953,15 @@ test.describe("LSP file intelligence", () => {
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
});
test("restores a manual connection after reload and forgets it after stop", async ({
test("disconnects the browser-owned server on reload when continuity is disabled", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const featureResponse = await apiClient.rawRequest("GET", "/api/v1/features");
expect(featureResponse.ok).toBeTruthy();
expect(await featureResponse.json()).toMatchObject({ lspBrowserContinuity: false });
installFakeKotlinLsp(backend);
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Kotlin LSP Manual Persistence",
@@ -1124,6 +1129,379 @@ test.describe("LSP file intelligence", () => {
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
});
test("retains the language server across browser close and reattaches after the reaper interval", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(210_000);
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const context = testPage.context();
try {
installFakeKotlinLsp(backend, {
keepProgress: true,
progress: {
title: "Importing Kotlin project",
message: "Project model is still warming up",
percentage: 42,
},
});
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Kotlin LSP Browser Continuity",
});
await openDesktopFile(testPage, task.session, task.filePaths[0]);
const statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const started = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"continuity task-host process",
);
const documentUri = pathToFileURL(path.join(started.cwd!, task.filePaths[0])).href;
const modelUri = expectedMonacoModelUri(documentUri, task.sessionId);
await expectFakeLspMarkerMessages(testPage, modelUri, ["Fake Kotlin diagnostic"]);
const initialInitializeCount = readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
).length;
expect(initialInitializeCount).toBe(1);
const turnsBeforeFollowUp = (await apiClient.listSessionTurns(task.sessionId)).turns.length;
await apiClient.addUserMessage(task.taskId, task.sessionId, "/e2e:simple-message");
await expect
.poll(
async () => {
const { turns } = await apiClient.listSessionTurns(task.sessionId);
return (
turns.length > turnsBeforeFollowUp &&
turns.every((turn) => Boolean(turn.completed_at))
);
},
{ timeout: 60_000 },
)
.toBe(true);
await testPage.close();
await dwell(
95_000,
"poll-interval",
"the detached language-server lease must survive a completed turn and the task-runtime idle-reaper threshold plus scan",
);
expect(isProcessAlive(started.pid)).toBe(true);
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
1,
);
const reopenedPage = await context.newPage();
await reopenedPage.goto(`/t/${task.taskId}`);
const reopenedSession = new SessionPage(reopenedPage);
await reopenedSession.waitForLoad(45_000);
await openDesktopFile(reopenedPage, reopenedSession, task.filePaths[0]);
const reopenedStatus = reopenedPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(reopenedStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
await expectFakeLspMarkerMessages(reopenedPage, modelUri, ["Fake Kotlin diagnostic"]);
const progress = (await openLspStatus(reopenedPage)).getByTestId("lsp-project-progress");
await expect(progress).toHaveAttribute("data-lsp-progress-kind", "active");
await expect(progress).toContainText("Importing Kotlin project");
await expect(progress).toContainText("Project model is still warming up");
const events = readFakeLspEvents(backend);
expect(events.filter((event) => event.event === "started")).toHaveLength(1);
expect(
events.filter((event) => event.event === "message" && event.method === "initialize"),
).toHaveLength(initialInitializeCount);
expect(
events.filter(
(event) => event.event === "message" && event.method === "textDocument/didOpen",
),
).toHaveLength(2);
await performLspAction(reopenedPage, "stop");
await expect(reopenedStatus).toHaveAttribute("data-lsp-state", "disabled");
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
});
await releaseFeature();
}
});
test("reattaches windows and duplicated tabs to independent language-server leases", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(150_000);
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const context = testPage.context();
const secondPage = await context.newPage();
const duplicatePage = await context.newPage();
try {
installFakeKotlinLsp(backend);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Independent Kotlin LSP Windows",
});
await openDesktopFile(testPage, task.session, task.filePaths[0]);
const firstStatus = testPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(firstStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const storageKey = `kandev-lsp-lease:${task.sessionId}:kotlin`;
const firstLeaseId = await testPage.evaluate(
(key) => sessionStorage.getItem(key),
storageKey,
);
expect(firstLeaseId).toBeTruthy();
const secondSession = new SessionPage(secondPage);
await secondPage.goto(`/t/${task.taskId}`);
await secondSession.waitForLoad(45_000);
await openDesktopFile(secondPage, secondSession, task.filePaths[0]);
const secondStatus = secondPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(secondStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
await duplicatePage.addInitScript(
({ key, leaseId }) => window.sessionStorage.setItem(key, leaseId),
{ key: storageKey, leaseId: firstLeaseId! },
);
const duplicateSession = new SessionPage(duplicatePage);
await duplicatePage.goto(`/t/${task.taskId}`);
await duplicateSession.waitForLoad(45_000);
await openDesktopFile(duplicatePage, duplicateSession, task.filePaths[0]);
const duplicateStatus = duplicatePage.locator('[data-testid="lsp-status-button"]:visible');
await expect(duplicateStatus).toHaveAttribute("data-lsp-state", "ready", {
timeout: 15_000,
});
await expect
.poll(() => readFakeLspEvents(backend).filter((event) => event.event === "started"))
.toHaveLength(3);
const processes = readFakeLspEvents(backend).filter((event) => event.event === "started");
expect(new Set(processes.map((event) => event.pid)).size).toBe(3);
expect(processes).toHaveLength(3);
await performLspAction(testPage, "stop");
await expect(firstStatus).toHaveAttribute("data-lsp-state", "disabled");
await expect.poll(() => isProcessAlive(processes[0].pid)).toBe(false);
await expect(secondStatus).toHaveAttribute("data-lsp-state", "ready");
await expect(duplicateStatus).toHaveAttribute("data-lsp-state", "ready");
expect(isProcessAlive(processes[1].pid)).toBe(true);
expect(isProcessAlive(processes[2].pid)).toBe(true);
await performLspAction(secondPage, "stop");
await expect.poll(() => isProcessAlive(processes[1].pid)).toBe(false);
await expect(duplicateStatus).toHaveAttribute("data-lsp-state", "ready");
await performLspAction(duplicatePage, "stop");
await expect.poll(() => isProcessAlive(processes[2].pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
});
await releaseFeature();
}
});
test("evicts a detached lease while keeping all-attached capacity exclusive", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
KANDEV_LSP_MAX_CONNECTIONS: "1",
});
const context = testPage.context();
const secondPage = await context.newPage();
try {
installFakeKotlinLsp(backend);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
});
const first = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "First Attached Kotlin LSP",
});
await openDesktopFile(testPage, first.session, first.filePaths[0]);
const firstStatus = testPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(firstStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const firstProcess = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"first attached lease process",
);
const second = await createKotlinTask(secondPage, apiClient, seedData, backend, {
title: "Second Attached Kotlin LSP",
});
await openDesktopFile(secondPage, second.session, second.filePaths[0]);
const secondStatus = secondPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(secondStatus).toHaveAttribute("data-lsp-state", "unavailable", {
timeout: 15_000,
});
await expect(secondPage.getByText(/Too many language servers are active/)).toBeVisible();
expect(isProcessAlive(firstProcess.pid)).toBe(true);
await testPage.close();
await performLspAction(secondPage, "retry");
await expect(secondStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 20_000 });
const secondProcess = await expectFakeLspEvent(
backend,
(event) => event.event === "started" && event.pid !== firstProcess.pid,
"replacement process after detached lease eviction",
);
await expect.poll(() => isProcessAlive(firstProcess.pid)).toBe(false);
expect(isProcessAlive(secondProcess.pid)).toBe(true);
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
2,
);
await performLspAction(secondPage, "stop");
await expect.poll(() => isProcessAlive(secondProcess.pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
});
await releaseFeature();
}
});
test("releases the retained language server after the last editor stays idle", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(210_000);
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
try {
installFakeKotlinLsp(backend);
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Idle Kotlin LSP Release",
});
await openDesktopFile(testPage, task.session, task.filePaths[0]);
const status = testPage.locator('[data-testid="lsp-status-button"]:visible');
await performLspAction(testPage, "start");
await expect(status).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const started = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"idle-release task-host process",
);
const editorTab = testPage.getByTestId("preview-tab-file-editor");
await editorTab.click({ button: "right" });
await testPage.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect(editorTab).toHaveCount(0);
await dwell(
125_000,
"product-timer",
"the LSP client intentionally releases its retained lease after two minutes with no open editor",
);
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
await expectFakeLspEvent(
backend,
(event) =>
event.pid === started.pid &&
(event.event === "exit" || event.event === "signal" || event.event === "stdin ended"),
"idle-release process shutdown",
);
} finally {
await releaseFeature();
}
});
test("disconnects the old lease and starts fresh analysis after backend restart", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
try {
installFakeKotlinLsp(backend);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Backend Restart LSP Recovery",
});
await openDesktopFile(testPage, task.session, task.filePaths[0]);
const status = testPage.locator('[data-testid="lsp-status-button"]:visible');
await expect(status).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const firstProcess = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"pre-restart language-server process",
);
await backend.restart();
await expect.poll(() => isProcessAlive(firstProcess.pid)).toBe(false);
const nextTask = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Kotlin LSP After Backend Restart",
});
await openDesktopFile(testPage, nextTask.session, nextTask.filePaths[0]);
await expect(status).toHaveAttribute("data-lsp-state", "ready", { timeout: 20_000 });
const secondProcess = await expectFakeLspEvent(
backend,
(event) => event.event === "started" && event.pid !== firstProcess.pid,
"fresh language-server process after backend restart",
);
expect(isProcessAlive(secondProcess.pid)).toBe(true);
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
2,
);
expect(
readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
),
).toHaveLength(2);
await performLspAction(testPage, "stop");
await expect.poll(() => isProcessAlive(secondProcess.pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
});
await releaseFeature();
}
});
test("rejects excess connections and succeeds after capacity is released", async ({
testPage,
apiClient,
@@ -5,6 +5,7 @@ import {
} from "../../helpers/layout-assertions";
import {
createKotlinTask,
expectFakeLspEvent,
installFakeKotlinLsp,
LONG_LSP_PROGRESS_MESSAGE,
openLspStatus,
@@ -13,6 +14,17 @@ import {
releaseFakeLspInitialization,
} from "./lsp-e2e-helpers";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
}
test.describe("Mobile LSP boundaries", () => {
test.describe.configure({ timeout: 90_000 });
@@ -23,6 +35,9 @@ test.describe("Mobile LSP boundaries", () => {
seedData,
backend,
}) => {
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
@@ -70,6 +85,7 @@ test.describe("Mobile LSP boundaries", () => {
lsp_auto_start_languages: initialAutoStart,
lsp_status_location: initialLocation,
});
await releaseFeature();
}
});
@@ -158,6 +174,98 @@ test.describe("Mobile LSP boundaries", () => {
}
});
test("reattaches the retained language server in the tablet drawer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const initialLocation =
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
try {
await testPage.setViewportSize({ width: 820, height: 900 });
installFakeKotlinLsp(backend, {
keepProgress: true,
progress: {
title: "Importing Kotlin project",
message: "Tablet project model is warming up",
percentage: 42,
},
});
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
lsp_status_location: "toolbar",
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Tablet Kotlin LSP Continuity",
});
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
const fileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
await expect(testPage.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
const initialStatus = testPage.getByTestId("lsp-status-button");
await expect(initialStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const started = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"tablet task-host process",
);
const initializeCount = readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
).length;
expect(initializeCount).toBe(1);
await testPage.reload();
await testPage.setViewportSize({ width: 820, height: 900 });
const reopenedSession = new SessionPage(testPage);
await reopenedSession.waitForLoad(45_000);
testPage.setDefaultTimeout(15_000);
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
const reopenedFileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(reopenedFileNode).toBeVisible({ timeout: 15_000 });
await reopenedFileNode.tap();
await expect(testPage.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
const reopenedStatus = testPage.getByTestId("lsp-status-button");
await expect(reopenedStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
await expect(testPage.getByTestId("app-status-lsp")).toHaveCount(0);
const drawer = await openLspStatus(testPage);
await expect(drawer).toHaveAttribute("data-testid", "lsp-status-drawer");
await expect(drawer.getByTestId("lsp-project-progress")).toContainText(
"Tablet project model is warming up",
);
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
1,
);
expect(
readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
),
).toHaveLength(initializeCount);
expect(isProcessAlive(started.pid)).toBe(true);
await performLspAction(testPage, "stop");
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
lsp_status_location: initialLocation,
});
await releaseFeature();
}
});
test("persists Kotlin settings with usable mobile guidance", async ({ testPage, apiClient }) => {
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
+41
View File
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => {
hasReportedProgress: false,
};
const enabledKeys = new Set<string>();
const leaseHints = new Set<string>();
const changeListeners = new Set<(key: string) => void>();
const connect = vi.fn(() => vi.fn());
const state = { status: disabledStatus, progress: emptyProgress };
@@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => {
lspAutoStartLanguages: [],
lspServerConfigs: {},
};
let continuityEnabled = false;
const stop = vi.fn((sessionId: string, language: string) => {
state.status = disabledStatus;
for (const listener of changeListeners) listener(`${sessionId}:${language}`);
@@ -29,6 +31,7 @@ const mocks = vi.hoisted(() => {
clearEnabledState: vi.fn((sessionId: string, language: string) => {
const key = `kandev-lsp:${sessionId}:${language}`;
enabledKeys.delete(key);
leaseHints.delete(`kandev-lsp-lease:${sessionId}:${language}`);
for (const listener of changeListeners) listener(`${sessionId}:${language}`);
}),
connect,
@@ -40,6 +43,9 @@ const mocks = vi.hoisted(() => {
isEnabledInStorage: vi.fn((sessionId: string, language: string) =>
enabledKeys.has(`kandev-lsp:${sessionId}:${language}`),
),
hasLeaseHint: vi.fn((sessionId: string, language: string) =>
leaseHints.has(`kandev-lsp-lease:${sessionId}:${language}`),
),
onChange: vi.fn((listener: (key: string) => void) => {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
@@ -50,9 +56,16 @@ const mocks = vi.hoisted(() => {
for (const listener of changeListeners) listener(`${sessionId}:${language}`);
}),
state,
leaseHints,
changeListeners,
stop,
userSettings,
get continuityEnabled() {
return continuityEnabled;
},
set continuityEnabled(value: boolean) {
continuityEnabled = value;
},
};
});
@@ -67,6 +80,7 @@ vi.mock("@/lib/lsp/lsp-client-manager", () => ({
connect: mocks.connect,
getProgress: mocks.getProgress,
getStatus: mocks.getStatus,
hasLeaseHint: mocks.hasLeaseHint,
isEnabledInStorage: mocks.isEnabledInStorage,
onChange: mocks.onChange,
saveEnabledState: mocks.saveEnabledState,
@@ -75,6 +89,10 @@ vi.mock("@/lib/lsp/lsp-client-manager", () => ({
toLspLanguage: (language: string) => (language === "typescript" ? language : null),
}));
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => mocks.continuityEnabled,
}));
import { useLsp, useLspStatus } from "./use-lsp";
const SESSION_ID = "session";
@@ -90,6 +108,8 @@ beforeEach(() => {
mocks.saveEnabledState.mockClear();
mocks.stop.mockClear();
mocks.enabledKeys.clear();
mocks.leaseHints.clear();
mocks.continuityEnabled = false;
mocks.state.status = mocks.disabledStatus;
mocks.state.progress = mocks.emptyProgress;
mocks.userSettings.lspAutoStartLanguages = [];
@@ -211,6 +231,27 @@ describe("useLsp auto-start policy", () => {
});
});
describe("useLsp browser continuity policy", () => {
it("reattaches a hinted lease when auto-start is disabled", async () => {
mocks.continuityEnabled = true;
mocks.leaseHints.add(`kandev-lsp-lease:${SESSION_ID}:${LANGUAGE}`);
const hook = renderHook(() => useLsp(SESSION_ID, LANGUAGE));
await waitFor(() => expect(mocks.connect).toHaveBeenCalledOnce());
expect(mocks.connect).toHaveBeenCalledWith(SESSION_ID, LANGUAGE, {}, true);
hook.unmount();
});
it("does not use a stale lease hint when continuity is disabled", async () => {
mocks.leaseHints.add(`kandev-lsp-lease:${SESSION_ID}:${LANGUAGE}`);
const hook = renderHook(() => useLsp(SESSION_ID, LANGUAGE));
expect(mocks.connect).not.toHaveBeenCalled();
hook.unmount();
});
});
describe("useLsp progress subscription", () => {
it("subscribes to the current connection progress snapshot", () => {
const hook = renderHook(() => useLsp(SESSION_ID, LANGUAGE));
+22 -3
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { lspClientManager, toLspLanguage, type LspStatus } from "@/lib/lsp/lsp-client-manager";
import { EMPTY_LSP_PROGRESS, type LspProgressSnapshot } from "@/lib/lsp/lsp-progress";
@@ -45,7 +46,8 @@ function toggleLsp(sessionId: string, lspLanguage: string): void {
current.state === "ready" ||
current.state === "connecting" ||
current.state === "installing" ||
current.state === "starting"
current.state === "starting" ||
current.state === "reconnecting"
) {
requestLspStop(sessionId, lspLanguage);
}
@@ -82,6 +84,7 @@ export function useLsp(
} {
const lspAutoStartLanguages = useAppStore((s) => s.userSettings.lspAutoStartLanguages);
const lspServerConfigs = useAppStore((s) => s.userSettings.lspServerConfigs);
const continuityEnabled = useFeature("lspBrowserContinuity");
const lspLanguage = toLspLanguage(monacoLanguage);
const shouldAutoStart = lspLanguage ? lspAutoStartLanguages.includes(lspLanguage) : false;
const key = lspKey(sessionId, lspLanguage);
@@ -93,6 +96,13 @@ export function useLsp(
? lspClientManager.isEnabledInStorage(sessionId, lspLanguage)
: false,
);
const hasLeaseHint = useSyncExternalStore(
(callback) => subscribeToLspKey(key, callback),
() =>
continuityEnabled && sessionId && lspLanguage
? lspClientManager.hasLeaseHint(sessionId, lspLanguage)
: false,
);
const startRequestGeneration = useSyncExternalStore(
(callback) => subscribeToLspKey(key, callback),
() => (key ? (startRequestGenerations.get(key) ?? 0) : 0),
@@ -104,12 +114,21 @@ export function useLsp(
// the override; later settings/configuration renders must not reacquire it.
useEffect(() => {
const autoStartEnabled = shouldAutoStart && !hasManualStopOverride;
if ((!autoStartEnabled && !isManuallyEnabled) || !sessionId || !lspLanguage) return;
const disconnect = lspClientManager.connect(sessionId, lspLanguage, lspServerConfigs);
if ((!autoStartEnabled && !isManuallyEnabled && !hasLeaseHint) || !sessionId || !lspLanguage) {
return;
}
const disconnect = lspClientManager.connect(
sessionId,
lspLanguage,
lspServerConfigs,
continuityEnabled,
);
return disconnect;
}, [
hasManualStopOverride,
isManuallyEnabled,
hasLeaseHint,
continuityEnabled,
shouldAutoStart,
sessionId,
lspLanguage,
+2
View File
@@ -4,3 +4,5 @@ export const LSP_DEFAULT_CONFIGS: Record<string, Record<string, unknown>> = {
export const DISABLED_LSP_STATUS = { state: "disabled" } as const;
export const LSP_IDLE_TIMEOUT = 2 * 60 * 1_000;
export const LSP_RECONNECT_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000] as const;
export const LSP_RELEASE_ACK_TIMEOUT_MS = 3_500;
+71
View File
@@ -0,0 +1,71 @@
export type LspBrokerControl = {
action: string;
requestId?: string;
reason?: string;
documentVersions?: Record<string, number>;
};
export type LspControlWaitOptions = {
timeoutMs: number;
send: () => void;
cancelOnClose?: boolean;
};
let requestCounter = 0;
export function nextLspControlRequestId(): string {
requestCounter += 1;
return `lsp-control-${Date.now()}-${requestCounter}`;
}
export function waitForLspControlAck(
ws: WebSocket,
requestId: string,
action: string,
options: LspControlWaitOptions,
): Promise<LspBrokerControl | null> {
const { timeoutMs, send, cancelOnClose = false } = options;
return new Promise((resolve) => {
let settled = false;
let onClose: (() => void) | null = null;
const finish = (control: LspBrokerControl | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
ws.removeEventListener("message", onMessage);
if (onClose) ws.removeEventListener("close", onClose);
resolve(control);
};
const onMessage = (event: MessageEvent) => {
let message: unknown;
try {
message = JSON.parse(event.data as string);
} catch {
return;
}
if (!isLspBrokerControl(message)) return;
if (message.action !== action || message.requestId !== requestId) return;
finish(message);
};
onClose = () => finish(null);
const timer = window.setTimeout(() => finish(null), timeoutMs);
ws.addEventListener("message", onMessage);
if (cancelOnClose) ws.addEventListener("close", onClose, { once: true });
try {
send();
} catch {
finish(null);
}
});
}
function isLspBrokerControl(value: unknown): value is LspBrokerControl & { kandev: "lsp" } {
return (
typeof value === "object" &&
value !== null &&
"kandev" in value &&
value.kandev === "lsp" &&
"action" in value &&
typeof value.action === "string"
);
}
@@ -137,6 +137,16 @@ export class LspClientEditorState {
if (targetModel) this.applyDiagnostics(connection.ownerId, targetModel, canonicalParams);
}
clearConnectionDiagnostics(connection: ManagedLspConnection): void {
connection.diagnosticsByUri.clear();
const monaco = getMonacoInstance();
if (!monaco) return;
const markerOwner = this.markerOwner(connection.ownerId);
for (const model of monaco.editor.getModels()) {
monaco.editor.setModelMarkers(model, markerOwner, []);
}
}
applyCachedDiagnostics(connection: ManagedLspConnection, model: monacoEditor.ITextModel): void {
for (const params of connection.diagnosticsByUri.values()) {
if (connectionModelMatchesUri(model, params.uri, connection)) {
@@ -0,0 +1,444 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createLspManagerHarness,
FakeWebSocket,
markerMessages,
publishDiagnostic,
} from "./lsp-client-manager.test-harness";
import {
LSP_IDLE_TIMEOUT,
LSP_RECONNECT_DELAYS_MS,
LSP_RELEASE_ACK_TIMEOUT_MS,
} from "./lsp-client-config";
import { modelUriForDocument } from "./file-uri";
const mocks = vi.hoisted(() => ({
getMonacoInstance: vi.fn(),
waitForMonacoInstance: vi.fn(),
getWebSocketClient: vi.fn(),
registerLspProviders: vi.fn(),
requestFileContent: vi.fn(),
registerBuiltinTsSuppression: vi.fn(),
}));
vi.mock("@/components/editors/monaco/monaco-init", () => ({
getMonacoInstance: mocks.getMonacoInstance,
waitForMonacoInstance: mocks.waitForMonacoInstance,
}));
vi.mock("@/components/editors/monaco/builtin-providers", () => ({
registerBuiltinTsSuppression: mocks.registerBuiltinTsSuppression,
withLspProviderRegistration: <T>(register: () => T) => register(),
}));
vi.mock("./lsp-providers", () => ({ registerLspProviders: mocks.registerLspProviders }));
vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: mocks.getWebSocketClient }));
vi.mock("@/lib/ws/workspace-files", () => ({ requestFileContent: mocks.requestFileContent }));
import { lspClientManager } from "./lsp-client-manager";
const { createMonacoHarness } = createLspManagerHarness(lspClientManager, mocks);
const SESSION_ID = "continuity-session";
const WORKSPACE_URI = "file:///workspace";
const WORKSPACE_PATH = "/workspace";
const CURRENT_DOCUMENT_TEXT = "export const current = true;";
const DOCUMENT_URI = `${WORKSPACE_URI}/Main.ts`;
const DOCUMENT_MODEL_URI = modelUriForDocument(DOCUMENT_URI, SESSION_ID);
const DID_OPEN_METHOD = "textDocument/didOpen";
const DID_CHANGE_METHOD = "textDocument/didChange";
function initializeRequest(socket: FakeWebSocket): { id: number; method: string } {
const request = socket.sent
.map((frame) => JSON.parse(frame) as { id?: number; method?: string })
.find((message) => message.method === "initialize");
if (request?.id === undefined || request.method === undefined) {
throw new Error("expected the LSP initialize request");
}
return { id: request.id, method: request.method };
}
function latestControl(
socket: FakeWebSocket,
action: string,
): { action: string; requestId: string } {
const control = socket.sent
.map((frame) => JSON.parse(frame) as { action?: string; requestId?: string })
.find((message) => message.action === action);
if (!control?.requestId) throw new Error(`expected LSP ${action} control`);
return { action, requestId: control.requestId };
}
function sendResumedHandshake(socket: FakeWebSocket): void {
socket.open();
socket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: true,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
capabilities: {
definitionProvider: true,
textDocumentSync: { openClose: true, change: 1 },
},
registrations: [],
progressTokens: [],
progress: [],
}),
);
}
async function waitForDocumentSync(socket: FakeWebSocket): Promise<void> {
await vi.waitFor(() => {
expect(socket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD)).toBe(true);
});
}
function acknowledgeAttachment(socket: FakeWebSocket): void {
const acknowledgement = latestControl(socket, "attachmentReady");
socket.emitMessage(
JSON.stringify({
kandev: "lsp",
action: "attachmentReady",
requestId: acknowledgement.requestId,
}),
);
}
async function connectContinuityReady() {
const disconnect = lspClientManager.connect(SESSION_ID, "typescript", undefined, true);
const socket = FakeWebSocket.instances.at(-1);
if (!socket) throw new Error("expected an LSP WebSocket");
socket.open();
socket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
}),
);
const initialize = initializeRequest(socket);
socket.emitMessage(
JSON.stringify({
jsonrpc: "2.0",
id: initialize.id,
result: { capabilities: { definitionProvider: true } },
}),
);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
return { disconnect, socket };
}
beforeEach(() => {
lspClientManager.disconnectAll();
FakeWebSocket.instances = [];
vi.resetAllMocks();
vi.stubGlobal("WebSocket", FakeWebSocket);
mocks.waitForMonacoInstance.mockImplementation(async () => mocks.getMonacoInstance());
mocks.registerBuiltinTsSuppression.mockReturnValue({ dispose: vi.fn() });
sessionStorage.clear();
});
afterEach(() => {
lspClientManager.disconnectAll();
sessionStorage.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("LSP browser continuity", () => {
it("reattaches without initializing again and waits for current document synchronization", async () => {
const { markersByUri } = createMonacoHarness([DOCUMENT_MODEL_URI]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(4009, "browser transport lost");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toEqual([]);
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
sendResumedHandshake(resumedSocket);
await waitForDocumentSync(resumedSocket);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialize")).toBe(
false,
);
expect(mocks.registerLspProviders).toHaveBeenCalledTimes(2);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
lspClientManager.changeDocument(
SESSION_ID,
"typescript",
DOCUMENT_URI,
"export const current = false;",
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === DID_CHANGE_METHOD)).toBe(
true,
);
publishDiagnostic(resumedSocket, DOCUMENT_URI, "too early");
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toEqual([]);
acknowledgeAttachment(resumedSocket);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
publishDiagnostic(resumedSocket, DOCUMENT_URI, "fresh diagnostic");
expect(markerMessages(markersByUri, DOCUMENT_MODEL_URI)).toContain("fresh diagnostic");
});
});
describe("LSP document reconnect synchronization", () => {
it("reopens a document when the editor effect cleans up during a reconnect", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(4009, "browser transport lost");
lspClientManager.closeDocument(SESSION_ID, "typescript", DOCUMENT_URI);
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
sendResumedHandshake(resumedSocket);
await vi.waitFor(() =>
expect(
resumedSocket.sent.some((frame) => JSON.parse(frame).action === "attachmentReady"),
).toBe(true),
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD)).toBe(
false,
);
acknowledgeAttachment(resumedSocket);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" }),
);
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
expect(
resumedSocket.sent.filter((frame) => JSON.parse(frame).method === DID_OPEN_METHOD),
).toHaveLength(1);
});
it("sends retained documents after a fresh initialize on a replacement backend", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
lspClientManager.openDocument(SESSION_ID, "typescript", {
uri: DOCUMENT_URI,
languageId: "typescript",
text: CURRENT_DOCUMENT_TEXT,
});
firstSocket.failClosed(1001, "backend restarting");
await new Promise((resolve) => setTimeout(resolve, 275));
const replacementSocket = FakeWebSocket.instances.at(-1);
if (!replacementSocket || replacementSocket === firstSocket)
throw new Error("expected a replacement backend socket");
replacementSocket.open();
replacementSocket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-2",
resumed: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
}),
);
const initialize = initializeRequest(replacementSocket);
replacementSocket.emitMessage(
JSON.stringify({
jsonrpc: "2.0",
id: initialize.id,
result: { capabilities: { definitionProvider: true } },
}),
);
await vi.waitFor(() =>
expect(
replacementSocket.sent.some((frame) => JSON.parse(frame).method === DID_OPEN_METHOD),
).toBe(true),
);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" });
});
});
describe("LSP initialization continuation", () => {
it("finishes the one-time initialized notification when resuming mid-handshake", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket: firstSocket } = await connectContinuityReady();
firstSocket.failClosed(4009, "browser transport lost");
await new Promise((resolve) => setTimeout(resolve, 275));
const resumedSocket = FakeWebSocket.instances.at(-1);
if (!resumedSocket || resumedSocket === firstSocket)
throw new Error("expected a resumed socket");
resumedSocket.open();
resumedSocket.emitMessage(
JSON.stringify({
status: "ready",
leaseId: "lease-1",
resumed: true,
initialized: false,
workspacePath: WORKSPACE_PATH,
workspaceUri: WORKSPACE_URI,
repoSubpaths: [],
capabilities: { definitionProvider: true },
registrations: [],
progressTokens: [],
progress: [],
}),
);
await vi.waitFor(() =>
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialized")).toBe(
true,
),
);
expect(
resumedSocket.sent.findIndex((frame) => JSON.parse(frame).method === "initialized"),
).toBeLessThan(
resumedSocket.sent.findIndex((frame) => JSON.parse(frame).action === "attachmentReady"),
);
expect(resumedSocket.sent.some((frame) => JSON.parse(frame).method === "initialize")).toBe(
false,
);
});
});
describe("LSP browser continuity controls", () => {
it("retries a graceful backend shutdown after the browser reconnects", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
socket.failClosed(1001, "backend restarting");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
await new Promise((resolve) => setTimeout(resolve, LSP_RECONNECT_DELAYS_MS[0] + 25));
expect(FakeWebSocket.instances).toHaveLength(2);
});
it("does not let an old Stop acknowledgement timeout clean a replacement socket", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
vi.useFakeTimers();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4009, "browser transport lost during release");
await vi.advanceTimersByTimeAsync(LSP_RECONNECT_DELAYS_MS[0]);
const replacementSocket = FakeWebSocket.instances.at(-1);
if (!replacementSocket || replacementSocket === socket)
throw new Error("expected a replacement socket after the release transport closed");
replacementSocket.open();
await vi.advanceTimersByTimeAsync(LSP_RELEASE_ACK_TIMEOUT_MS + 10);
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "reconnecting" });
expect(FakeWebSocket.instances).toContain(replacementSocket);
});
it("cleans up a Stop when the backend returns a terminal close code", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4002, "session not found");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({
state: "unavailable",
reason: expect.any(String),
cause: "workspace_unavailable",
});
});
it("does not reconnect after the backend stops the task runtime", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
socket.failClosed(4010, "task runtime stopped");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "disabled" });
expect(FakeWebSocket.instances).toHaveLength(1);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("treats confirmed process exit as actionable and does not retry", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
socket.failClosed(4006, "language server exited");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({
state: "error",
reason: "language server exited",
});
expect(FakeWebSocket.instances).toHaveLength(1);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("releases its window lease only after explicit Stop is acknowledged", async () => {
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket } = await connectContinuityReady();
lspClientManager.stop(SESSION_ID, "typescript");
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "stopping" });
const release = latestControl(socket, "release");
socket.emitMessage(
JSON.stringify({ kandev: "lsp", action: "released", requestId: release.requestId }),
);
await vi.waitFor(() =>
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "disabled" }),
);
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
it("releases after the last editor idle timeout", async () => {
vi.useFakeTimers();
createMonacoHarness([]);
mocks.registerLspProviders.mockReturnValue([]);
const { socket, disconnect } = await connectContinuityReady();
disconnect();
await vi.advanceTimersByTimeAsync(LSP_IDLE_TIMEOUT);
const release = latestControl(socket, "release");
expect(
JSON.parse(socket.sent.find((frame) => JSON.parse(frame).action === "release") ?? "null"),
).toMatchObject({
reason: "editor_idle",
});
socket.emitMessage(
JSON.stringify({ kandev: "lsp", action: "released", requestId: release.requestId }),
);
await Promise.resolve();
expect(sessionStorage.getItem(`kandev-lsp-lease:${SESSION_ID}:typescript`)).toBeNull();
});
});
@@ -43,7 +43,9 @@ export class FakeWebSocket extends EventTarget {
failClosed(code: number, reason: string): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.(new CloseEvent("close", { code, reason }));
const event = new CloseEvent("close", { code, reason });
this.onclose?.(event);
this.dispatchEvent(event);
}
}
+136 -278
View File
@@ -1,50 +1,28 @@
import type { editor as monacoEditor, IDisposable } from "monaco-editor";
import { getMonacoInstance, waitForMonacoInstance } from "@/components/editors/monaco/monaco-init";
import {
registerBuiltinTsSuppression,
withLspProviderRegistration,
} from "@/components/editors/monaco/builtin-providers";
import { t } from "@/lib/i18n";
import { registerLspProviders } from "./lsp-providers";
import { canonicalFileUri, joinFileUri } from "./file-uri";
import {
JsonRpcConnection,
getWsBaseUrl,
CLOSE_CODE_STATUS,
LSP_CLIENT_CAPABILITIES,
} from "./lsp-json-rpc";
import type { LspStatus } from "./lsp-json-rpc";
import {
createManagedLspConnection,
type LspReadyWorkspace,
type ManagedLspConnection,
type OpenDocumentParams,
type PublishDiagnosticsParams,
} from "./lsp-client-types";
import { connectionDocumentUri, connectionModelUri } from "./lsp-editor-models";
import { LspClientEditorState } from "./lsp-client-editor-state";
import {
configureLspWorkspace,
lspWorkspaceFolders,
repositorySubpathsForSession,
workspaceUriForSession,
type WorkspaceMetadata,
} from "./lsp-workspace";
import { EMPTY_LSP_PROGRESS, type LspProgressSnapshot } from "./lsp-progress";
import {
EMPTY_LSP_PROGRESS,
finishLspInitialization,
type LspProgressSnapshot,
} from "./lsp-progress";
import { beginLspProgressTracking } from "./lsp-client-progress";
import {
clearLspLeaseHint,
clearLspEnabledState,
getLspLeaseHint,
isLspEnabledInStorage,
saveLspEnabledState,
} from "./lsp-client-storage";
import { DISABLED_LSP_STATUS, LSP_IDLE_TIMEOUT } from "./lsp-client-config";
import { LSP_DEFAULT_CONFIGS } from "./lsp-client-config";
import { DISABLED_LSP_STATUS, LSP_DEFAULT_CONFIGS, LSP_IDLE_TIMEOUT } from "./lsp-client-config";
import { buildDocumentContentChanges, buildDocumentSaveParams } from "./lsp-document-sync";
import { getLspMonacoProviderMethods } from "./lsp-provider-capabilities";
import { LspClientProtocol } from "./lsp-client-protocol";
import { LspClientTransport } from "./lsp-client-transport";
export type { LspStatus } from "./lsp-json-rpc";
export { toLspLanguage } from "./lsp-json-rpc";
@@ -56,15 +34,6 @@ export { toLspLanguage } from "./lsp-json-rpc";
type ChangeListener = (key: string) => void;
type FileOpener = (uri: string, line?: number, column?: number) => boolean | Promise<boolean>;
function lspErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message || String(error);
if (typeof error === "object" && error !== null) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return String(error);
}
function hasActiveLspWork(progress: LspProgressSnapshot): boolean {
return progress.initializingSince !== null || progress.active.length > 0;
}
@@ -86,21 +55,6 @@ function configurationsMatch(
return JSON.stringify(current) === JSON.stringify(next);
}
function registerTypeScriptModelSuppression(
connection: ManagedLspConnection,
lspLanguage: string,
serverCapabilities: Record<string, unknown> | null,
): void {
if (lspLanguage !== "typescript") return;
connection.providerDisposables.push(
registerBuiltinTsSuppression(
connection.ownerId,
(model) => connectionDocumentUri(model as monacoEditor.ITextModel, connection) !== null,
getLspMonacoProviderMethods(serverCapabilities),
),
);
}
class LSPClientManager {
private connections = new Map<string, ManagedLspConnection>();
private connectionGeneration = 0;
@@ -112,6 +66,26 @@ class LSPClientManager {
private editorState = new LspClientEditorState((connection) =>
this.isCurrentConnection(connection),
);
private protocol = new LspClientProtocol({
editorState: this.editorState,
workspaceMetadata: this.workspaceMetadata,
isCurrentConnection: (conn) => this.isCurrentConnection(conn),
isCurrentTransportConnection: (conn, generation) =>
this.isCurrentTransportConnection(conn, generation),
setStatus: (key, status) => this.setStatus(key, status),
handleProgressChange: (conn) => this.handleProgressChange(conn),
cleanupConnection: (conn) => this.cleanupConnection(conn),
});
private transport = new LspClientTransport({
protocol: this.protocol,
editorState: this.editorState,
isCurrentConnection: (conn) => this.isCurrentConnection(conn),
getStatus: (key) => this.statuses.get(key) ?? DISABLED_LSP_STATUS,
setStatus: (key, status) => this.setStatus(key, status),
removeStatus: (key) => this.statuses.delete(key),
notifyChange: (key) => this.notifyChange(key),
cleanupConnection: (conn) => this.cleanupConnection(conn),
});
setFileOpener(opener: FileOpener | null): void {
this.fileOpener = opener;
}
@@ -139,6 +113,10 @@ class LSPClientManager {
return isLspEnabledInStorage(sessionId, language);
}
hasLeaseHint(sessionId: string, language: string): boolean {
return getLspLeaseHint(sessionId, language) !== null;
}
getStatus(sessionId: string, lspLanguage: string): LspStatus {
const key = `${sessionId}:${lspLanguage}`;
return this.statuses.get(key) ?? DISABLED_LSP_STATUS;
@@ -181,108 +159,38 @@ class LSPClientManager {
sessionId: string,
lspLanguage: string,
userConfigs?: Record<string, Record<string, unknown>>,
continuityEnabled = false,
): () => void {
const key = `${sessionId}:${lspLanguage}`;
const configuration = configurationForLanguage(lspLanguage, userConfigs);
const existing = this.connections.get(key);
if (existing && existing.ws.readyState <= WebSocket.OPEN) {
if (
existing &&
existing.continuityEnabled === continuityEnabled &&
(existing.ws.readyState <= WebSocket.OPEN || existing.reconnecting)
) {
return this.acquireConnection(existing, configuration);
}
if (existing) this.cleanupConnection(existing);
const wsUrl = `${getWsBaseUrl()}/lsp/${sessionId}?language=${lspLanguage}`;
const ws = new WebSocket(wsUrl);
const conn = createManagedLspConnection(
if (!continuityEnabled) clearLspLeaseHint(sessionId, lspLanguage);
const leaseId = continuityEnabled ? getLspLeaseHint(sessionId, lspLanguage) : null;
const ws = this.transport.createSocket(sessionId, lspLanguage, continuityEnabled, leaseId);
const conn = createManagedLspConnection({
key,
sessionId,
++this.connectionGeneration,
generation: ++this.connectionGeneration,
ws,
configuration,
);
continuityEnabled,
leaseId,
lspLanguage,
});
this.connections.set(key, conn);
this.setStatus(key, { state: "connecting" });
let bridgeStarted = false;
let terminalStatusReceived = false;
ws.onopen = () => {
if (!this.isCurrentConnection(conn)) return;
this.setStatus(key, { state: "starting" });
};
// Listen for backend status messages before the LSP bridge starts.
const statusHandler = (event: MessageEvent) => {
if (bridgeStarted || !this.isCurrentConnection(conn)) return;
let data: {
status?: string;
error?: string;
workspacePath?: string;
workspaceUri?: string;
repoSubpaths?: string[];
};
try {
data = JSON.parse(event.data as string);
} catch {
return;
}
if (data.status === "installing") {
this.setStatus(key, { state: "installing" });
} else if (data.status === "installed") {
this.setStatus(key, { state: "starting" });
} else if (data.status === "ready") {
// Language server is running — start the LSP JSON-RPC bridge
ws.removeEventListener("message", statusHandler);
bridgeStarted = true;
this.initializeLsp(conn, lspLanguage, {
path: data.workspacePath ?? null,
uri: data.workspaceUri ?? null,
repositorySubpaths: data.repoSubpaths ?? [],
});
} else if (data.status === "install_failed") {
ws.removeEventListener("message", statusHandler);
terminalStatusReceived = true;
this.setStatus(key, { state: "error", reason: data.error || t("lsp:installFailed") });
}
};
ws.addEventListener("message", statusHandler);
ws.onclose = (event) => {
ws.removeEventListener("message", statusHandler);
const wasCurrent = this.isCurrentConnection(conn);
this.cleanupConnection(conn);
if (!wasCurrent) return;
const current = this.statuses.get(key);
if (current?.state === "stopping") {
this.setStatus(key, { state: "disabled" });
this.statuses.delete(key);
return;
}
if (terminalStatusReceived) return;
const statusFactory = CLOSE_CODE_STATUS[event.code];
if (statusFactory) {
this.setStatus(key, statusFactory(event.reason));
} else {
const fallbackReason = bridgeStarted
? t("lsp:languageServerExited")
: t("lsp:connectionClosed");
this.setStatus(key, { state: "error", reason: event.reason || fallbackReason });
}
};
ws.onerror = () => {
if (!this.isCurrentConnection(conn)) return;
const current = this.statuses.get(key);
if (current?.state !== "error" && current?.state !== "unavailable") {
this.setStatus(key, { state: "error", reason: t("lsp:webSocketError") });
}
};
this.transport.bind(conn, ws);
return () => this.decrementRef(conn);
}
@@ -308,141 +216,6 @@ class LSPClientManager {
});
}
private async initializeLsp(
conn: ManagedLspConnection,
lspLanguage: string,
workspace: LspReadyWorkspace,
) {
if (!this.isCurrentConnection(conn)) return;
const { key, ws } = conn;
const workspaceMetadata = configureLspWorkspace(conn, workspace);
if (workspaceMetadata) this.workspaceMetadata.set(conn.key, workspaceMetadata);
try {
const rpc = new JsonRpcConnection(ws);
rpc.listen();
conn.rpc = rpc;
beginLspProgressTracking(
conn,
rpc,
() => this.isCurrentConnection(conn),
() => this.handleProgressChange(conn),
);
// Handle server requests
rpc.onRequest("workspace/configuration", (params: unknown) => {
const items = (params as { items?: { section?: string }[] })?.items;
if (!Array.isArray(items)) return [conn.configuration];
return items.map(() => conn.configuration);
});
rpc.onRequest("client/registerCapability", () => null);
const initResult = (await rpc.sendRequest("initialize", {
processId: null,
capabilities: LSP_CLIENT_CAPABILITIES,
workDoneToken: conn.ownerId,
rootUri: conn.workspaceUri,
workspaceFolders: lspWorkspaceFolders(conn.workspaceUri, workspace.path),
initializationOptions: {},
})) as { capabilities?: Record<string, unknown> } | null;
if (!this.isCurrentConnection(conn)) {
this.cleanupConnection(conn);
return;
}
const progress = finishLspInitialization(conn.progress);
if (progress !== conn.progress) {
conn.progress = progress;
this.handleProgressChange(conn);
}
conn.serverCapabilities = initResult?.capabilities ?? null;
rpc.sendNotification("initialized", {});
conn.protocolInitialized = true;
rpc.sendNotification("workspace/didChangeConfiguration", {
settings: conn.configuration,
});
// Register diagnostics handler
rpc.onNotification("textDocument/publishDiagnostics", (params) => {
if (!this.isCurrentConnection(conn)) return;
this.editorState.handleDiagnostics(conn, params as PublishDiagnosticsParams);
});
// Collect callbacks for semantic token refresh
const semanticRefreshCallbacks: (() => void)[] = [];
rpc.onRequest("workspace/semanticTokens/refresh", () => {
for (const cb of semanticRefreshCallbacks) cb();
return null;
});
// Monaco loads asynchronously. Do not expose a ready connection until its
// providers can be registered; otherwise early diagnostics are dropped.
const monaco = await waitForMonacoInstance();
if (!this.isCurrentConnection(conn)) {
this.cleanupConnection(conn);
return;
}
conn.providerDisposables.push(
monaco.editor.onDidCreateModel((model: monacoEditor.ITextModel) => {
if (this.isCurrentConnection(conn)) {
this.editorState.applyCachedDiagnostics(conn, model);
}
}),
);
for (const model of monaco.editor.getModels()) {
this.editorState.applyCachedDiagnostics(conn, model);
}
registerTypeScriptModelSuppression(conn, lspLanguage, conn.serverCapabilities);
// Register Monaco providers for this language.
conn.providerDisposables.push(
...withLspProviderRegistration(() =>
this.registerProviders(
rpc,
lspLanguage,
conn,
conn.serverCapabilities,
semanticRefreshCallbacks,
),
),
);
conn.initialized = true;
this.setStatus(key, { state: "ready" });
} catch (err) {
const wasCurrent = this.isCurrentConnection(conn);
this.cleanupConnection(conn);
if (!wasCurrent) return;
console.error(`[LSP] initializeLsp error:`, err);
this.setStatus(key, { state: "error", reason: lspErrorMessage(err) });
}
}
// ------- Monaco provider registration (delegated to lsp-providers.ts) -------
private registerProviders(
rpc: JsonRpcConnection,
lspLanguage: string,
conn: ManagedLspConnection,
serverCapabilities: Record<string, unknown> | null,
semanticRefreshCallbacks: (() => void)[],
): IDisposable[] {
return registerLspProviders({
rpc,
lspLanguage,
serverCapabilities,
semanticRefreshCallbacks,
getDocumentUri: (model) => connectionDocumentUri(model, conn),
getModelUri: (uri) =>
connectionModelUri(uri, conn, getMonacoInstance()?.editor.getModels() ?? []),
ensureModelsExist: (uris) => this.editorState.ensureModelsExist(uris, conn),
});
}
/** Dispose a placeholder model (e.g. when the file is opened in a real tab). */
disposePlaceholderModel(modelUri: string): void {
this.editorState.disposePlaceholderModel(modelUri);
@@ -453,12 +226,32 @@ class LSPClientManager {
openDocument(sessionId: string, lspLanguage: string, document: OpenDocumentParams): void {
const key = `${sessionId}:${lspLanguage}`;
const conn = this.connections.get(key);
if (!conn?.initialized || !conn.rpc) return;
if (!conn || (!conn.initialized && !conn.reconnecting)) return;
const documentUri = canonicalFileUri(document.uri);
if (!documentUri) return;
this.promoteDocumentModel(sessionId, documentUri, document.text);
const existing = conn.openDocuments.get(documentUri);
if (existing) {
if (existing.pendingClose) {
existing.pendingClose = false;
existing.refCount = 1;
if (existing.text !== document.text) {
existing.text = document.text;
existing.version++;
}
if (conn.rpc && conn.documentsSynced) {
conn.rpc.sendNotification("textDocument/didOpen", {
textDocument: {
uri: documentUri,
languageId: existing.languageId,
version: existing.version,
text: existing.text,
},
});
}
if (document.repo) conn.repositorySubpaths.add(document.repo);
return;
}
existing.refCount++;
if (document.repo) conn.repositorySubpaths.add(document.repo);
return;
@@ -470,7 +263,9 @@ class LSPClientManager {
languageId: document.languageId,
refCount: 1,
text: document.text,
pendingClose: false,
});
if (!conn.documentsSynced || !conn.rpc) return;
conn.rpc.sendNotification("textDocument/didOpen", {
textDocument: {
uri: documentUri,
@@ -489,9 +284,17 @@ class LSPClientManager {
changeDocument(sessionId: string, lspLanguage: string, documentUri: string, text: string): void {
const key = `${sessionId}:${lspLanguage}`;
const conn = this.connections.get(key);
if (!conn?.initialized || !conn.rpc) return;
if (!conn || (!conn.initialized && !conn.reconnecting)) return;
const canonicalUri = canonicalFileUri(documentUri);
if (!canonicalUri) return;
if (!conn.documentsSynced || !conn.rpc) {
const document = conn.openDocuments.get(canonicalUri);
if (document && document.text !== text) {
document.text = text;
document.version++;
}
return;
}
this.synchronizeOpenDocument(conn, canonicalUri, text);
}
@@ -503,7 +306,13 @@ class LSPClientManager {
liveText = persistedText,
): void {
for (const conn of this.connections.values()) {
if (conn.sessionId !== sessionId || !conn.initialized || !conn.rpc || !conn.workspaceUri) {
if (
conn.sessionId !== sessionId ||
!conn.initialized ||
!conn.documentsSynced ||
!conn.rpc ||
!conn.workspaceUri
) {
continue;
}
@@ -552,15 +361,21 @@ class LSPClientManager {
closeDocument(sessionId: string, lspLanguage: string, documentUri: string): void {
const key = `${sessionId}:${lspLanguage}`;
const conn = this.connections.get(key);
if (!conn?.initialized || !conn.rpc) return;
if (!conn || (!conn.initialized && !conn.reconnecting)) return;
const canonicalUri = canonicalFileUri(documentUri);
if (!canonicalUri) return;
const document = conn.openDocuments.get(canonicalUri);
if (!document) return;
document.refCount--;
document.refCount = Math.max(0, document.refCount - 1);
if (document.refCount > 0) return;
if (conn.continuityEnabled && conn.reconnecting && !conn.documentsSynced) {
document.pendingClose = true;
return;
}
conn.openDocuments.delete(canonicalUri);
if (!conn.documentsSynced || !conn.rpc) return;
conn.rpc.sendNotification("textDocument/didClose", {
textDocument: { uri: canonicalUri },
});
@@ -580,6 +395,23 @@ class LSPClientManager {
this.setStatus(key, { state: "stopping" });
if (conn.idleTimer) clearTimeout(conn.idleTimer);
if (conn.continuityEnabled) {
conn.explicitlyStopped = true;
conn.releaseAfterConnect = "stop";
if (conn.reconnectTimer) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = null;
}
clearLspLeaseHint(sessionId, lspLanguage);
if (conn.ws.readyState === WebSocket.OPEN) {
void this.transport.release(conn, "stop");
} else if (conn.ws.readyState === WebSocket.CLOSED) {
conn.reconnecting = true;
this.transport.open(conn);
}
return;
}
// Send shutdown/exit before closing
if (conn.rpc && conn.initialized) {
try {
@@ -639,6 +471,20 @@ class LSPClientManager {
if (!this.isCurrentConnection(conn) || conn.refCount > 0 || hasActiveLspWork(conn.progress)) {
return;
}
if (conn.continuityEnabled) {
conn.releaseAfterConnect = "editor_idle";
if (conn.ws.readyState === WebSocket.OPEN) {
void this.transport.release(conn, "editor_idle");
} else if (conn.ws.readyState === WebSocket.CLOSED) {
if (conn.reconnectTimer) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = null;
}
conn.reconnecting = true;
this.transport.open(conn);
}
return;
}
this.cleanupConnection(conn);
this.statuses.delete(conn.key);
this.notifyChange(conn.key);
@@ -655,16 +501,28 @@ class LSPClientManager {
return this.connections.get(conn.key) === conn;
}
private isCurrentTransportConnection(conn: ManagedLspConnection, generation: number): boolean {
return this.isCurrentConnection(conn) && conn.transportGeneration === generation;
}
private cleanupConnection(conn: ManagedLspConnection) {
this.clearIdleTimer(conn);
if (conn.reconnectTimer) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = null;
}
for (const d of conn.providerDisposables) d.dispose();
conn.providerDisposables = [];
conn.rpc?.dispose();
conn.rpc = null;
conn.initialized = false;
conn.protocolInitialized = false;
conn.documentsSynced = false;
conn.openDocuments.clear();
conn.diagnosticsByUri.clear();
conn.providersReady = false;
conn.diagnosticsReady = false;
conn.reconnecting = false;
conn.progress = EMPTY_LSP_PROGRESS;
conn.registeredProgressTokens.clear();
try {
+14 -1
View File
@@ -7,9 +7,22 @@ export function beginLspProgressTracking(
rpc: JsonRpcConnection,
isCurrent: () => boolean,
notifyChange: () => void,
resumeState?: { progressTokens?: unknown[]; progress?: unknown[] },
): void {
connection.progress = createLspProgressSnapshot(Date.now());
connection.progress = createLspProgressSnapshot(resumeState ? null : Date.now());
connection.registeredProgressTokens.clear();
connection.registeredProgressTokens.add(connection.ownerId);
for (const token of resumeState?.progressTokens ?? []) {
if (isLspProgressToken(token)) connection.registeredProgressTokens.add(token);
}
for (const progress of resumeState?.progress ?? []) {
connection.progress = applyLspProgress(
connection.progress,
connection.registeredProgressTokens,
progress,
Date.now(),
);
}
notifyChange();
rpc.onRequest("window/workDoneProgress/create", (params: unknown) => {
+338
View File
@@ -0,0 +1,338 @@
import type { editor as monacoEditor } from "monaco-editor";
import { getMonacoInstance, waitForMonacoInstance } from "@/components/editors/monaco/monaco-init";
import {
registerBuiltinTsSuppression,
withLspProviderRegistration,
} from "@/components/editors/monaco/builtin-providers";
import { t } from "@/lib/i18n";
import { registerLspProviders } from "./lsp-providers";
import { JsonRpcConnection, LSP_CLIENT_CAPABILITIES } from "./lsp-json-rpc";
import type { LspStatus } from "./lsp-json-rpc";
import type {
LspReadyWorkspace,
ManagedLspConnection,
PublishDiagnosticsParams,
} from "./lsp-client-types";
import { connectionDocumentUri, connectionModelUri } from "./lsp-editor-models";
import type { LspClientEditorState } from "./lsp-client-editor-state";
import {
configureLspWorkspace,
lspWorkspaceFolders,
type WorkspaceMetadata,
} from "./lsp-workspace";
import { finishLspInitialization } from "./lsp-progress";
import { beginLspProgressTracking } from "./lsp-client-progress";
import { LSP_RELEASE_ACK_TIMEOUT_MS } from "./lsp-client-config";
import {
applyLspRegistrations,
effectiveLspCapabilities,
normalizeLspRegistrations,
} from "./lsp-dynamic-capabilities";
import { nextLspControlRequestId, waitForLspControlAck } from "./lsp-client-control";
import { getLspMonacoProviderMethods } from "./lsp-provider-capabilities";
export type LspClientProtocolHost = {
editorState: LspClientEditorState;
workspaceMetadata: Map<string, WorkspaceMetadata>;
isCurrentConnection: (conn: ManagedLspConnection) => boolean;
isCurrentTransportConnection: (conn: ManagedLspConnection, generation: number) => boolean;
setStatus: (key: string, status: LspStatus) => void;
handleProgressChange: (conn: ManagedLspConnection) => void;
cleanupConnection: (conn: ManagedLspConnection) => void;
};
export class LspClientProtocol {
constructor(private readonly host: LspClientProtocolHost) {}
async initialize(
conn: ManagedLspConnection,
lspLanguage: string,
workspace: LspReadyWorkspace,
): Promise<void> {
if (!this.host.isCurrentConnection(conn)) return;
const { key, ws } = conn;
const transportGeneration = conn.transportGeneration;
conn.documentsSynced = false;
this.host.setStatus(key, { state: "starting" });
const workspaceMetadata = configureLspWorkspace(conn, workspace);
if (workspaceMetadata) this.host.workspaceMetadata.set(conn.key, workspaceMetadata);
const rpc = new JsonRpcConnection(ws);
rpc.listen();
conn.rpc = rpc;
conn.diagnosticsReady = true;
beginLspProgressTracking(
conn,
rpc,
() => this.host.isCurrentTransportConnection(conn, transportGeneration),
() => this.host.handleProgressChange(conn),
);
this.installRpcHandlers(conn, rpc, lspLanguage);
try {
const initResult = (await rpc.sendRequest("initialize", {
processId: null,
capabilities: LSP_CLIENT_CAPABILITIES,
workDoneToken: conn.ownerId,
rootUri: conn.workspaceUri,
workspaceFolders: lspWorkspaceFolders(conn.workspaceUri, workspace.path),
initializationOptions: {},
})) as { capabilities?: Record<string, unknown> } | null;
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
const progress = finishLspInitialization(conn.progress);
if (progress !== conn.progress) {
conn.progress = progress;
this.host.handleProgressChange(conn);
}
conn.serverCapabilities = initResult?.capabilities ?? null;
rpc.sendNotification("initialized", {});
conn.protocolInitialized = true;
rpc.sendNotification("workspace/didChangeConfiguration", {
settings: conn.configuration,
});
await waitForMonacoInstance();
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
this.rebuildProviders(conn);
this.synchronizeOpenDocuments(conn, rpc);
conn.initialized = true;
this.host.setStatus(key, { state: "ready" });
} catch (error) {
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
this.host.cleanupConnection(conn);
console.error("[LSP] initializeLsp error:", error);
this.host.setStatus(key, { state: "error", reason: lspErrorMessage(error) });
}
}
async resume(
conn: ManagedLspConnection,
handshake: {
capabilities?: Record<string, unknown>;
registrations?: unknown[];
initialized?: boolean;
progressTokens?: unknown[];
progress?: unknown[];
},
workspace: LspReadyWorkspace,
): Promise<void> {
if (!this.host.isCurrentConnection(conn)) return;
const transportGeneration = conn.transportGeneration;
const { key, ws } = conn;
conn.documentsSynced = false;
const workspaceMetadata = configureLspWorkspace(conn, workspace);
if (workspaceMetadata) this.host.workspaceMetadata.set(key, workspaceMetadata);
this.host.setStatus(key, { state: "reconnecting" });
conn.serverCapabilities = handshake.capabilities ?? null;
conn.dynamicRegistrations.clear();
for (const registration of normalizeLspRegistrations(handshake.registrations)) {
conn.dynamicRegistrations.set(registration.id, registration);
}
const rpc = new JsonRpcConnection(ws);
rpc.listen();
conn.rpc = rpc;
conn.diagnosticsReady = false;
beginLspProgressTracking(
conn,
rpc,
() => this.host.isCurrentTransportConnection(conn, transportGeneration),
() => this.host.handleProgressChange(conn),
{ progressTokens: handshake.progressTokens, progress: handshake.progress },
);
this.installRpcHandlers(conn, rpc, conn.lspLanguage);
try {
await waitForMonacoInstance();
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
this.rebuildProviders(conn);
if (handshake.initialized === false) rpc.sendNotification("initialized", {});
this.synchronizeOpenDocuments(conn, rpc);
const requestId = nextLspControlRequestId();
const acknowledgement = await waitForLspControlAck(ws, requestId, "attachmentReady", {
timeoutMs: LSP_RELEASE_ACK_TIMEOUT_MS,
send: () =>
ws.send(
JSON.stringify({
kandev: "lsp",
action: "attachmentReady",
requestId,
}),
),
});
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
if (!acknowledgement) throw new Error(t("lsp:syncFailed"));
conn.diagnosticsReady = true;
conn.protocolInitialized = true;
conn.initialized = true;
conn.reconnecting = false;
conn.reconnectAttempts = 0;
this.host.setStatus(key, { state: "ready" });
} catch (error) {
if (!this.host.isCurrentTransportConnection(conn, transportGeneration) || conn.rpc !== rpc)
return;
this.host.cleanupConnection(conn);
console.error("[LSP] resumeLsp error:", error);
this.host.setStatus(key, { state: "error", reason: lspErrorMessage(error) });
}
}
private installRpcHandlers(
conn: ManagedLspConnection,
rpc: JsonRpcConnection,
lspLanguage: string,
): void {
const transportGeneration = conn.transportGeneration;
const isCurrent = () => this.host.isCurrentTransportConnection(conn, transportGeneration);
rpc.onRequest("workspace/configuration", (params: unknown) => {
const items = (params as { items?: { section?: string }[] } | null)?.items;
if (!Array.isArray(items)) return [conn.configuration];
return items.map((item) =>
configurationSection(conn.configuration, item.section, lspLanguage),
);
});
rpc.onRequest("client/registerCapability", (params: unknown) => {
if (isCurrent()) {
conn.dynamicRegistrations = applyLspRegistrations(
conn.dynamicRegistrations,
"client/registerCapability",
params,
);
if (conn.providersReady) this.rebuildProviders(conn);
}
return null;
});
rpc.onRequest("client/unregisterCapability", (params: unknown) => {
if (isCurrent()) {
conn.dynamicRegistrations = applyLspRegistrations(
conn.dynamicRegistrations,
"client/unregisterCapability",
params,
);
if (conn.providersReady) this.rebuildProviders(conn);
}
return null;
});
rpc.onRequest("workspace/semanticTokens/refresh", () => {
if (isCurrent()) {
for (const callback of conn.semanticRefreshCallbacks) callback();
}
return null;
});
rpc.onNotification("textDocument/publishDiagnostics", (params) => {
if (!isCurrent() || !conn.diagnosticsReady) return;
this.host.editorState.handleDiagnostics(conn, params as PublishDiagnosticsParams);
});
conn.lspLanguage = lspLanguage;
}
private synchronizeOpenDocuments(conn: ManagedLspConnection, rpc: JsonRpcConnection): void {
for (const [uri, document] of conn.openDocuments) {
if (document.pendingClose) continue;
rpc.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId: document.languageId,
version: document.version,
text: document.text,
},
});
}
conn.documentsSynced = true;
for (const [uri, document] of conn.openDocuments) {
if (document.pendingClose) conn.openDocuments.delete(uri);
}
}
private rebuildProviders(conn: ManagedLspConnection): void {
if (!conn.rpc || !this.host.isCurrentConnection(conn)) return;
const monaco = getMonacoInstance();
if (!monaco) return;
for (const disposable of conn.providerDisposables) disposable.dispose();
conn.providerDisposables = [];
conn.semanticRefreshCallbacks = [];
conn.providerDisposables.push(
monaco.editor.onDidCreateModel((model: monacoEditor.ITextModel) => {
if (this.host.isCurrentConnection(conn))
this.host.editorState.applyCachedDiagnostics(conn, model);
}),
);
for (const model of monaco.editor.getModels())
this.host.editorState.applyCachedDiagnostics(conn, model);
const capabilities = effectiveLspCapabilities(
conn.serverCapabilities,
conn.dynamicRegistrations,
);
this.registerTypeScriptSuppression(conn, capabilities);
conn.providerDisposables.push(
...withLspProviderRegistration(() =>
registerLspProviders({
rpc: conn.rpc as JsonRpcConnection,
lspLanguage: conn.lspLanguage,
serverCapabilities: capabilities,
semanticRefreshCallbacks: conn.semanticRefreshCallbacks,
getDocumentUri: (model) => connectionDocumentUri(model, conn),
getModelUri: (uri) =>
connectionModelUri(uri, conn, getMonacoInstance()?.editor.getModels() ?? []),
ensureModelsExist: (uris) => this.host.editorState.ensureModelsExist(uris, conn),
}),
),
);
conn.providersReady = true;
}
private registerTypeScriptSuppression(
conn: ManagedLspConnection,
capabilities: Record<string, unknown> | null,
): void {
if (conn.lspLanguage !== "typescript") return;
conn.providerDisposables.push(
registerBuiltinTsSuppression(
conn.ownerId,
(model) => connectionDocumentUri(model as monacoEditor.ITextModel, conn) !== null,
getLspMonacoProviderMethods(capabilities),
),
);
}
}
function configurationSection(
configuration: Record<string, unknown>,
section: string | undefined,
lspLanguage: string,
): unknown {
if (!section) return configuration;
if (section === lspLanguage) return configuration;
const languagePrefix = `${lspLanguage}.`;
const normalizedSection = section.startsWith(languagePrefix)
? section.slice(languagePrefix.length)
: section;
let value: unknown = configuration;
for (const part of normalizedSection.split(".")) {
if (typeof value !== "object" || value === null || !(part in value)) return null;
value = (value as Record<string, unknown>)[part];
}
return value;
}
function lspErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message || String(error);
if (typeof error === "object" && error !== null) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message) return message;
}
return String(error);
}
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from "vitest";
import { clearLspLeaseHint, getLspLeaseHint, saveLspLeaseHint } from "./lsp-client-storage";
describe("LSP lease hints", () => {
afterEach(() => {
sessionStorage.clear();
});
it("stores hints per tab, task, and language", () => {
saveLspLeaseHint("task-a", "go", "lease-a");
saveLspLeaseHint("task-a", "typescript", "lease-b");
saveLspLeaseHint("task-b", "go", "lease-c");
expect(getLspLeaseHint("task-a", "go")).toBe("lease-a");
expect(getLspLeaseHint("task-a", "typescript")).toBe("lease-b");
expect(getLspLeaseHint("task-b", "go")).toBe("lease-c");
});
it("clears only the selected lease hint", () => {
saveLspLeaseHint("task-a", "go", "lease-a");
saveLspLeaseHint("task-a", "typescript", "lease-b");
clearLspLeaseHint("task-a", "go");
expect(getLspLeaseHint("task-a", "go")).toBeNull();
expect(getLspLeaseHint("task-a", "typescript")).toBe("lease-b");
});
});
+24
View File
@@ -2,6 +2,10 @@ function lspStorageKey(sessionId: string, language: string): string {
return `kandev-lsp:${sessionId}:${language}`;
}
function lspLeaseStorageKey(sessionId: string, language: string): string {
return `kandev-lsp-lease:${sessionId}:${language}`;
}
export function saveLspEnabledState(sessionId: string, language: string): void {
try {
localStorage.setItem(lspStorageKey(sessionId, language), "1");
@@ -21,3 +25,23 @@ export function isLspEnabledInStorage(sessionId: string, language: string): bool
return false;
}
}
export function saveLspLeaseHint(sessionId: string, language: string, leaseId: string): void {
try {
sessionStorage.setItem(lspLeaseStorageKey(sessionId, language), leaseId);
} catch {}
}
export function clearLspLeaseHint(sessionId: string, language: string): void {
try {
sessionStorage.removeItem(lspLeaseStorageKey(sessionId, language));
} catch {}
}
export function getLspLeaseHint(sessionId: string, language: string): string | null {
try {
return sessionStorage.getItem(lspLeaseStorageKey(sessionId, language));
} catch {
return null;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { t } from "@/lib/i18n";
import { getWsBaseUrl, CLOSE_CODE_STATUS } from "./lsp-json-rpc";
import type { LspStatus } from "./lsp-json-rpc";
import type { LspReadyWorkspace, ManagedLspConnection } from "./lsp-client-types";
import type { LspClientEditorState } from "./lsp-client-editor-state";
import { LSP_RECONNECT_DELAYS_MS, LSP_RELEASE_ACK_TIMEOUT_MS } from "./lsp-client-config";
import { EMPTY_LSP_PROGRESS } from "./lsp-progress";
import { clearLspLeaseHint, saveLspLeaseHint } from "./lsp-client-storage";
import { nextLspControlRequestId, waitForLspControlAck } from "./lsp-client-control";
import type { LspClientProtocol } from "./lsp-client-protocol";
export type LspReadyMessage = {
status?: string;
error?: string;
leaseId?: string;
resumed?: boolean;
initialized?: boolean;
workspacePath?: string | null;
workspaceUri?: string | null;
repoSubpaths?: string[];
capabilities?: Record<string, unknown>;
registrations?: unknown[];
progressTokens?: unknown[];
progress?: unknown[];
};
type LspTransportState = {
transportGeneration: number;
bridgeStarted: boolean;
terminalStatusReceived: boolean;
statusHandler?: (event: MessageEvent) => void;
};
type LspTransportHost = {
protocol: LspClientProtocol;
editorState: LspClientEditorState;
isCurrentConnection: (conn: ManagedLspConnection) => boolean;
getStatus: (key: string) => LspStatus;
setStatus: (key: string, status: LspStatus) => void;
removeStatus: (key: string) => void;
notifyChange: (key: string) => void;
cleanupConnection: (conn: ManagedLspConnection) => void;
};
export class LspClientTransport {
constructor(private readonly host: LspTransportHost) {}
createSocket(
sessionId: string,
lspLanguage: string,
continuityEnabled: boolean,
leaseId: string | null,
): WebSocket {
const url = `${getWsBaseUrl()}/lsp/${encodeURIComponent(sessionId)}`;
const query = new URLSearchParams({ language: lspLanguage });
if (continuityEnabled && leaseId) query.set("leaseId", leaseId);
return new WebSocket(`${url}?${query.toString()}`);
}
open(conn: ManagedLspConnection): void {
if (
!this.host.isCurrentConnection(conn) ||
(conn.explicitlyStopped && conn.releaseAfterConnect !== "stop")
) {
return;
}
const ws = this.createSocket(
conn.sessionId,
conn.lspLanguage,
conn.continuityEnabled,
conn.leaseId,
);
conn.ws = ws;
this.bind(conn, ws);
}
bind(conn: ManagedLspConnection, ws: WebSocket): void {
const state: LspTransportState = {
transportGeneration: ++conn.transportGeneration,
bridgeStarted: false,
terminalStatusReceived: false,
};
const statusHandler = (event: MessageEvent) => this.handleStatus(conn, ws, event, state);
state.statusHandler = statusHandler;
ws.onopen = () => {
if (!this.isCurrentTransport(conn, ws, state) || conn.reconnecting) return;
this.host.setStatus(conn.key, { state: "starting" });
};
ws.addEventListener("message", statusHandler);
ws.onclose = (event) => this.handleClose(conn, ws, event, statusHandler, state);
ws.onerror = () => this.handleError(conn, ws, state);
}
private handleStatus(
conn: ManagedLspConnection,
ws: WebSocket,
event: MessageEvent,
state: LspTransportState,
): void {
if (state.bridgeStarted || !this.isCurrentTransport(conn, ws, state)) return;
const data = this.parseReadyMessage(event.data);
if (!data) return;
if (data.status === "installing") {
this.host.setStatus(conn.key, { state: "installing" });
return;
}
if (data.status === "installed") {
this.host.setStatus(conn.key, { state: "starting" });
return;
}
if (data.status === "install_failed") {
if (state.statusHandler) ws.removeEventListener("message", state.statusHandler);
state.bridgeStarted = true;
state.terminalStatusReceived = true;
this.host.setStatus(conn.key, {
state: "error",
reason: data.error || t("lsp:installFailed"),
});
return;
}
if (data.status !== "ready") return;
if (state.statusHandler) ws.removeEventListener("message", state.statusHandler);
state.bridgeStarted = true;
if (conn.continuityEnabled && data.leaseId) {
conn.leaseId = data.leaseId;
saveLspLeaseHint(conn.sessionId, conn.lspLanguage, data.leaseId);
this.host.notifyChange(conn.key);
}
if (conn.releaseAfterConnect) {
void this.release(conn, conn.releaseAfterConnect);
return;
}
this.initializeOrResume(conn, data);
}
private parseReadyMessage(data: unknown): LspReadyMessage | null {
try {
return JSON.parse(data as string) as LspReadyMessage;
} catch {
return null;
}
}
private initializeOrResume(conn: ManagedLspConnection, data: LspReadyMessage): void {
const workspace: LspReadyWorkspace = {
path: data.workspacePath ?? null,
uri: data.workspaceUri ?? null,
repositorySubpaths: data.repoSubpaths ?? [],
};
if (conn.continuityEnabled && data.resumed === true) {
conn.reconnecting = true;
void this.host.protocol.resume(conn, data, workspace);
return;
}
conn.reconnecting = false;
conn.reconnectAttempts = 0;
conn.dynamicRegistrations.clear();
void this.host.protocol.initialize(conn, conn.lspLanguage, workspace);
}
private isCurrentTransport(
conn: ManagedLspConnection,
ws: WebSocket,
state: LspTransportState,
): boolean {
return (
this.host.isCurrentConnection(conn) &&
conn.transportGeneration === state.transportGeneration &&
conn.ws === ws
);
}
private handleClose(
conn: ManagedLspConnection,
ws: WebSocket,
event: CloseEvent,
statusHandler: (event: MessageEvent) => void,
state: LspTransportState,
): void {
ws.removeEventListener("message", statusHandler);
if (!this.isCurrentTransport(conn, ws, state)) return;
if (this.handleStoppingClose(conn, event)) return;
if (state.terminalStatusReceived) {
this.host.cleanupConnection(conn);
return;
}
if (this.shouldReconnect(conn, event)) {
this.prepareForReconnect(conn);
this.scheduleReconnect(conn);
return;
}
this.handleTerminalClose(conn, event, state.bridgeStarted);
}
private handleStoppingClose(conn: ManagedLspConnection, event: CloseEvent): boolean {
if (this.host.getStatus(conn.key).state !== "stopping") return false;
if (conn.continuityEnabled && conn.releaseAfterConnect === "stop") {
if (isAbnormalClose(event.code)) {
conn.reconnecting = true;
this.host.setStatus(conn.key, { state: "reconnecting" });
this.scheduleReconnect(conn);
return true;
}
conn.releaseAfterConnect = null;
this.handleTerminalClose(conn, event, true);
return true;
}
this.host.cleanupConnection(conn);
this.host.setStatus(conn.key, { state: "disabled" });
this.host.removeStatus(conn.key);
return true;
}
private shouldReconnect(conn: ManagedLspConnection, event: CloseEvent): boolean {
return (
conn.continuityEnabled &&
(!conn.explicitlyStopped || conn.releaseAfterConnect === "stop") &&
isAbnormalClose(event.code)
);
}
private handleTerminalClose(
conn: ManagedLspConnection,
event: CloseEvent,
bridgeStarted: boolean,
): void {
const statusFactory = CLOSE_CODE_STATUS[event.code];
if (conn.continuityEnabled && (event.code === 4006 || event.code === 4010)) {
clearLspLeaseHint(conn.sessionId, conn.lspLanguage);
conn.leaseId = null;
}
this.host.cleanupConnection(conn);
if (statusFactory) {
this.host.setStatus(conn.key, statusFactory(event.reason));
return;
}
const fallbackReason =
bridgeStarted && !conn.continuityEnabled
? t("lsp:languageServerExited")
: t("lsp:connectionClosed");
this.host.setStatus(conn.key, { state: "error", reason: event.reason || fallbackReason });
}
private handleError(conn: ManagedLspConnection, ws: WebSocket, state: LspTransportState): void {
if (!this.isCurrentTransport(conn, ws, state) || conn.continuityEnabled) return;
const current = this.host.getStatus(conn.key);
if (current.state !== "error" && current.state !== "unavailable") {
this.host.setStatus(conn.key, { state: "error", reason: t("lsp:webSocketError") });
}
}
prepareForReconnect(conn: ManagedLspConnection): void {
if (!this.host.isCurrentConnection(conn)) return;
for (const disposable of conn.providerDisposables) disposable.dispose();
conn.providerDisposables = [];
conn.providersReady = false;
conn.semanticRefreshCallbacks = [];
conn.rpc?.dispose();
conn.rpc = null;
conn.initialized = false;
conn.protocolInitialized = false;
conn.documentsSynced = false;
conn.diagnosticsReady = false;
this.host.editorState.clearConnectionDiagnostics(conn);
conn.progress = EMPTY_LSP_PROGRESS;
conn.registeredProgressTokens.clear();
conn.reconnecting = true;
this.host.setStatus(conn.key, { state: "reconnecting" });
}
scheduleReconnect(conn: ManagedLspConnection): void {
if (!this.host.isCurrentConnection(conn) || conn.reconnectTimer) return;
if (conn.reconnectAttempts >= LSP_RECONNECT_DELAYS_MS.length) {
conn.reconnecting = false;
if (conn.releaseAfterConnect) {
conn.releaseAfterConnect = null;
this.host.cleanupConnection(conn);
this.host.setStatus(conn.key, { state: "error", reason: t("lsp:releaseFailed") });
} else {
this.host.setStatus(conn.key, { state: "error", reason: t("lsp:connectionClosed") });
}
return;
}
const delay = LSP_RECONNECT_DELAYS_MS[conn.reconnectAttempts];
conn.reconnectAttempts++;
conn.reconnectTimer = setTimeout(() => {
conn.reconnectTimer = null;
if (this.host.isCurrentConnection(conn)) this.open(conn);
}, delay);
}
async release(conn: ManagedLspConnection, reason: "stop" | "editor_idle"): Promise<void> {
if (!this.host.isCurrentConnection(conn) || conn.ws.readyState !== WebSocket.OPEN) return;
const ws = conn.ws;
const transportGeneration = conn.transportGeneration;
const requestId = nextLspControlRequestId();
const acknowledgement = await waitForLspControlAck(ws, requestId, "released", {
timeoutMs: LSP_RELEASE_ACK_TIMEOUT_MS,
send: () => ws.send(JSON.stringify({ kandev: "lsp", action: "release", reason, requestId })),
cancelOnClose: true,
});
if (
!this.host.isCurrentConnection(conn) ||
conn.ws !== ws ||
conn.transportGeneration !== transportGeneration
) {
return;
}
if (!acknowledgement) {
if (ws.readyState !== WebSocket.OPEN) return;
conn.releaseAfterConnect = null;
conn.reconnecting = false;
this.host.cleanupConnection(conn);
this.host.setStatus(conn.key, { state: "error", reason: t("lsp:releaseFailed") });
return;
}
clearLspLeaseHint(conn.sessionId, conn.lspLanguage);
conn.releaseAfterConnect = null;
conn.reconnecting = false;
this.host.cleanupConnection(conn);
this.host.removeStatus(conn.key);
this.host.notifyChange(conn.key);
}
}
function isAbnormalClose(code: number): boolean {
return code === 4009 || code === 1001 || code === 1005 || code === 1006;
}
+51 -5
View File
@@ -1,4 +1,5 @@
import type { LSPConnection, LspRange } from "./lsp-json-rpc";
import type { LspDynamicRegistration } from "./lsp-dynamic-capabilities";
import {
EMPTY_LSP_PROGRESS,
type LspProgressSnapshot,
@@ -25,6 +26,20 @@ export type ManagedLspConnection = LSPConnection & {
diagnosticsByUri: Map<string, PublishDiagnosticsParams>;
progress: LspProgressSnapshot;
registeredProgressTokens: Set<LspProgressToken>;
continuityEnabled: boolean;
leaseId: string | null;
transportGeneration: number;
reconnectAttempts: number;
reconnectTimer: ReturnType<typeof setTimeout> | null;
reconnecting: boolean;
explicitlyStopped: boolean;
releaseAfterConnect: "stop" | "editor_idle" | null;
diagnosticsReady: boolean;
documentsSynced: boolean;
providersReady: boolean;
dynamicRegistrations: Map<string, LspDynamicRegistration>;
semanticRefreshCallbacks: (() => void)[];
lspLanguage: string;
};
export type OpenDocumentParams = {
@@ -40,13 +55,30 @@ export type LspReadyWorkspace = {
repositorySubpaths: string[];
};
export type ManagedLspConnectionOptions = {
key: string;
sessionId: string;
generation: number;
ws: WebSocket;
configuration: Record<string, unknown>;
continuityEnabled?: boolean;
leaseId?: string | null;
lspLanguage?: string;
};
export function createManagedLspConnection(
key: string,
sessionId: string,
generation: number,
ws: WebSocket,
configuration: Record<string, unknown>,
options: ManagedLspConnectionOptions,
): ManagedLspConnection {
const {
key,
sessionId,
generation,
ws,
configuration,
continuityEnabled = false,
leaseId = null,
lspLanguage = "",
} = options;
return {
key,
sessionId,
@@ -62,6 +94,20 @@ export function createManagedLspConnection(
diagnosticsByUri: new Map(),
progress: EMPTY_LSP_PROGRESS,
registeredProgressTokens: new Set(),
continuityEnabled,
leaseId,
transportGeneration: 0,
reconnectAttempts: 0,
reconnectTimer: null,
reconnecting: false,
explicitlyStopped: false,
releaseAfterConnect: null,
diagnosticsReady: false,
documentsSynced: false,
providersReady: false,
dynamicRegistrations: new Map(),
semanticRefreshCallbacks: [],
lspLanguage,
providerDisposables: [],
serverCapabilities: null,
workspaceUri: null,
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
applyLspRegistrations,
effectiveLspCapabilities,
normalizeLspRegistrations,
} from "./lsp-dynamic-capabilities";
import { LSP_CLIENT_CAPABILITIES } from "./lsp-json-rpc";
describe("LSP dynamic capabilities", () => {
it("rebuilds effective providers from retained registration options", () => {
const registrations = normalizeLspRegistrations([
{
id: "definition",
method: "textDocument/definition",
registerOptions: { documentSelector: [{ language: "go" }] },
},
{ id: "invalid", method: 3 },
]);
const effective = effectiveLspCapabilities(
{ hoverProvider: true },
new Map(registrations.map((r) => [r.id, r])),
);
expect(effective).toEqual({
hoverProvider: true,
definitionProvider: { documentSelector: [{ language: "go" }] },
});
});
it("applies register and unregister requests without mutating prior state", () => {
const original = new Map();
const registered = applyLspRegistrations(original, "client/registerCapability", {
registrations: [{ id: "hover", method: "textDocument/hover" }],
});
const unregistered = applyLspRegistrations(registered, "client/unregisterCapability", {
unregisterations: [{ id: "hover", method: "textDocument/hover" }],
});
expect(original.size).toBe(0);
expect(registered.size).toBe(1);
expect(unregistered.size).toBe(0);
expect(effectiveLspCapabilities(null, registered)).toEqual({ hoverProvider: true });
expect(effectiveLspCapabilities(null, unregistered)).toBeNull();
});
it("advertises supported dynamic providers and maps semantic tokens", () => {
const textDocument = LSP_CLIENT_CAPABILITIES.textDocument;
expect(textDocument.completion.dynamicRegistration).toBe(true);
expect(textDocument.hover.dynamicRegistration).toBe(true);
expect(textDocument.definition.dynamicRegistration).toBe(true);
expect(textDocument.references.dynamicRegistration).toBe(true);
expect(textDocument.signatureHelp.dynamicRegistration).toBe(true);
expect(textDocument.semanticTokens.dynamicRegistration).toBe(true);
expect(textDocument.synchronization.dynamicRegistration).toBe(false);
const dynamicMethods = Object.entries(textDocument)
.filter(([, capability]) => {
if (typeof capability !== "object" || capability === null) return false;
return (
"dynamicRegistration" in capability &&
(capability as { dynamicRegistration?: unknown }).dynamicRegistration === true
);
})
.map(([method]) => method)
.sort();
expect(dynamicMethods).toEqual(
["completion", "definition", "hover", "references", "semanticTokens", "signatureHelp"].sort(),
);
const registered = applyLspRegistrations(new Map(), "client/registerCapability", {
registrations: [
{
id: "semantic",
method: "textDocument/semanticTokens",
registerOptions: { legend: { tokenTypes: ["class"], tokenModifiers: [] }, full: true },
},
],
});
expect(effectiveLspCapabilities(null, registered)).toEqual({
semanticTokensProvider: {
legend: { tokenTypes: ["class"], tokenModifiers: [] },
full: true,
},
});
const resumed = new Map(
normalizeLspRegistrations([...registered.values()]).map((r) => [r.id, r]),
);
const unregistered = applyLspRegistrations(resumed, "client/unregisterCapability", {
unregisterations: [{ id: "semantic", method: "textDocument/semanticTokens" }],
});
expect(effectiveLspCapabilities(null, unregistered)).toBeNull();
});
});
@@ -0,0 +1,67 @@
export type LspDynamicRegistration = {
id: string;
method: string;
registerOptions?: unknown;
};
const providerCapabilities: Record<string, string> = {
"textDocument/completion": "completionProvider",
"textDocument/hover": "hoverProvider",
"textDocument/definition": "definitionProvider",
"textDocument/references": "referencesProvider",
"textDocument/signatureHelp": "signatureHelpProvider",
"textDocument/semanticTokens": "semanticTokensProvider",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function normalizeLspRegistrations(value: unknown): LspDynamicRegistration[] {
if (!Array.isArray(value)) return [];
return value.filter(
(registration): registration is LspDynamicRegistration =>
isRecord(registration) &&
typeof registration.id === "string" &&
typeof registration.method === "string",
);
}
export function applyLspRegistrations(
current: ReadonlyMap<string, LspDynamicRegistration>,
method: "client/registerCapability" | "client/unregisterCapability",
params: unknown,
): Map<string, LspDynamicRegistration> {
const next = new Map(current);
if (!isRecord(params)) return next;
if (method === "client/registerCapability") {
for (const registration of normalizeLspRegistrations(params.registrations)) {
next.set(registration.id, registration);
}
return next;
}
let unregistrations: unknown[] = [];
if (Array.isArray(params.unregistrations)) unregistrations = params.unregistrations;
else if (Array.isArray(params.unregisterations)) unregistrations = params.unregisterations;
for (const registration of unregistrations) {
if (isRecord(registration) && typeof registration.id === "string") {
next.delete(registration.id);
}
}
return next;
}
export function effectiveLspCapabilities(
staticCapabilities: Record<string, unknown> | null,
registrations: ReadonlyMap<string, LspDynamicRegistration>,
): Record<string, unknown> | null {
if (!staticCapabilities && registrations.size === 0) return null;
const effective = { ...(staticCapabilities ?? {}) };
for (const registration of registrations.values()) {
const capability = providerCapabilities[registration.method];
if (!capability) continue;
effective[capability] = registration.registerOptions ?? true;
}
return effective;
}
+9 -6
View File
@@ -17,6 +17,7 @@ export type LspUnavailableCause =
export type LspStatus =
| { state: "disabled" }
| { state: "connecting" }
| { state: "reconnecting" }
| { state: "installing" }
| { state: "starting" }
| { state: "ready" }
@@ -29,6 +30,7 @@ export type OpenDocument = {
languageId: string;
refCount: number;
text: string;
pendingClose?: boolean;
};
export type LSPConnection = {
@@ -229,6 +231,7 @@ export const CLOSE_CODE_STATUS: Record<number, (reason: string) => LspStatus> =
cause: "auto_install_unsupported",
}),
4008: () => ({ state: "error", reason: t("lsp:languageServerFailedToStart") }),
4010: () => ({ state: "disabled" }),
};
export function getLspUnavailableSetupHint(
@@ -260,7 +263,7 @@ export const LSP_CLIENT_CAPABILITIES = {
willSaveWaitUntil: false,
},
completion: {
dynamicRegistration: false,
dynamicRegistration: true,
completionItem: {
snippetSupport: true,
commitCharactersSupport: true,
@@ -270,11 +273,11 @@ export const LSP_CLIENT_CAPABILITIES = {
},
contextSupport: true,
},
hover: { dynamicRegistration: false, contentFormat: ["markdown", "plaintext"] },
definition: { dynamicRegistration: false },
references: { dynamicRegistration: false },
hover: { dynamicRegistration: true, contentFormat: ["markdown", "plaintext"] },
definition: { dynamicRegistration: true },
references: { dynamicRegistration: true },
signatureHelp: {
dynamicRegistration: false,
dynamicRegistration: true,
signatureInformation: {
documentationFormat: ["markdown", "plaintext"],
parameterInformation: { labelOffsetSupport: true },
@@ -282,7 +285,7 @@ export const LSP_CLIENT_CAPABILITIES = {
},
publishDiagnostics: { relatedInformation: true },
semanticTokens: {
dynamicRegistration: false,
dynamicRegistration: true,
requests: { full: true },
tokenTypes: [
"namespace",
+2
View File
@@ -78,6 +78,8 @@ export function getLspConnectionLabel(status: LspStatus, progress?: LspProgressS
return t("lsp:off");
case "connecting":
return t("lsp:connecting");
case "reconnecting":
return t("lsp:reconnecting");
case "installing":
return t("lsp:installingLanguageServer");
case "starting":
@@ -16,6 +16,10 @@ function backendFeatureKeys(source: string): string[] {
}
describe("feature flag repository contract", () => {
it("keeps browser-owned LSP continuity disabled by default", () => {
expect(defaultFeatureFlags.lspBrowserContinuity).toBe(false);
});
it("keeps dynamic agent routing disabled by default", () => {
expect(defaultFeatureFlags.dynamicAgentRouting).toBe(false);
});
@@ -7,6 +7,7 @@
export const defaultFeatureFlags = {
// New release toggles start disabled in every frontend state. The SSR layer
// overwrites this with the backend's effective values after startup.
lspBrowserContinuity: false,
office: false,
auth: false,
canvases: false,
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "Retry the connection to receive project progress.",
"progressWillClear": "Project progress will clear when the language server stops.",
"projectAnalysis": "Project analysis",
"reconnecting": "Reconnecting to language server",
"releaseFailed": "Could not confirm that the language server stopped",
"reportedWorkDisclaimer": "This confirms only the reported work item, not full project indexing.",
"retryLanguageServer": "Retry language server",
"serverProcessStarted": "Server process started",
@@ -63,6 +65,7 @@
"startLanguageServer": "Start language server",
"startServerForProgress": "Start the language server to receive project progress.",
"startingLanguageServer": "Starting language server",
"syncFailed": "The language server did not confirm document synchronization.",
"statusLocation": "Status location",
"statusLocationAria": "LSP status location",
"stopLanguageServer": "Stop language server",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "接続を再試行して、プロジェクト進捗を受け取る。",
"progressWillClear": "言語サーバー停止で、プロジェクト進捗は消える。",
"projectAnalysis": "プロジェクト解析",
"reconnecting": "言語サーバーに再接続しています",
"releaseFailed": "言語サーバーの停止を確認できませんでした",
"reportedWorkDisclaimer": "これは報告された作業項目だけを示す。完全なプロジェクト索引ではない。",
"retryLanguageServer": "言語サーバーを再試行",
"serverProcessStarted": "サーバープロセス開始",
@@ -63,6 +65,7 @@
"startLanguageServer": "言語サーバーを開始",
"startServerForProgress": "プロジェクト進捗を受け取るため、言語サーバーを開始する。",
"startingLanguageServer": "言語サーバー開始中",
"syncFailed": "言語サーバーはドキュメントの同期を確認しませんでした。",
"statusLocation": "ステータス位置",
"statusLocationAria": "LSP ステータス位置",
"stopLanguageServer": "言語サーバーを停止",
+3 -3
View File
@@ -169,10 +169,10 @@
"examplePreviewUrl": "ĥţţƥś://ćďń.ēxàḿƥĺē.ćōḿ/ḿŷ-ćàńvàś-ćōvēŕ.ŵēƀƥ",
"examplePreviewAlt": "Ćàńvàś ōvēŕvĩēŵ",
"yes": "Ŷēś",
"renameCanvas": "Ŕēńàḿē ćàńvāś",
"canvasName": "Ćàńvāś ńàḿē",
"renameCanvas": "Ŕēńàḿē ćàńvàś",
"canvasName": "Ćàńvàś ńàḿē",
"renameCanvasInvalid": "Ēńţēŕ à ńàḿē ōƒ 1 ţō 200 ćĥàŕàćţēŕś.",
"renameCanvasFailed": "Ćōũĺď ńōţ ŕēńàḿē ţĥē ćàńvāś. Ţŕŷ àĝàĩń.",
"renameCanvasFailed": "Ćōũĺď ńōţ ŕēńàḿē ţĥē ćàńvàś. Ţŕŷ àĝàĩń.",
"renamingCanvas": "Śàvĩńĝ ńàḿē…",
"loadingShareDefaults": "Ĺōàďĩńĝ śĥàŕē ďēţàĩĺś…",
"shareDefaultsFailed": "Ćōũĺď ńōţ ĺōàď śĥàŕē ďēţàĩĺś. Ţŕŷ àĝàĩń.",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "Ŕēţŕŷ ţĥē ćōńńēćţĩōń ţō ŕēćēĩvē ƥŕōĵēćţ ƥŕōĝŕēśś.",
"progressWillClear": "Ƥŕōĵēćţ ƥŕōĝŕēśś ŵĩĺĺ ćĺēàŕ ŵĥēń ţĥē ĺàńĝũàĝē śēŕvēŕ śţōƥś.",
"projectAnalysis": "Ƥŕōĵēćţ àńàĺŷśĩś",
"reconnecting": "Ŕēćōńńēćţĩńĝ ţō ĺàńĝũàĝē śēŕvēŕ",
"releaseFailed": "Ćōũĺď ńōţ ćōńƒĩŕḿ ţĥàţ ţĥē ĺàńĝũàĝē śēŕvēŕ śţōƥƥēď",
"reportedWorkDisclaimer": "Ţĥĩś ćōńƒĩŕḿś ōńĺŷ ţĥē ŕēƥōŕţēď ŵōŕķ ĩţēḿ, ńōţ ƒũĺĺ ƥŕōĵēćţ ĩńďēxĩńĝ.",
"retryLanguageServer": "Ŕēţŕŷ ĺàńĝũàĝē śēŕvēŕ",
"serverProcessStarted": "Śēŕvēŕ ƥŕōćēśś śţàŕţēď",
@@ -63,6 +65,7 @@
"startLanguageServer": "Śţàŕţ ĺàńĝũàĝē śēŕvēŕ",
"startServerForProgress": "Śţàŕţ ţĥē ĺàńĝũàĝē śēŕvēŕ ţō ŕēćēĩvē ƥŕōĵēćţ ƥŕōĝŕēśś.",
"startingLanguageServer": "Śţàŕţĩńĝ ĺàńĝũàĝē śēŕvēŕ",
"syncFailed": "Ţĥē ĺàńĝũàĝē śēŕvēŕ ďĩď ńōţ ćōńƒĩŕḿ ďōćũḿēńţ śŷńćĥŕōńĩźàţĩōń.",
"statusLocation": "Śţàţũś ĺōćàţĩōń",
"statusLocationAria": "ĹŚƤ śţàţũś ĺōćàţĩōń",
"stopLanguageServer": "Śţōƥ ĺàńĝũàĝē śēŕvēŕ",
+5 -5
View File
@@ -1213,15 +1213,15 @@
"previewCancel": "Ćàńćēĺ",
"previewCancelSelection": "Ćàńćēĺ śēĺēćţĩōń",
"previewCandidate": "Śēĺēćţĩōń ćàńďĩďàţē: {{candidate}}",
"previewCaptureElement": "Ēĺēmēńţ",
"previewCaptureElement": "Ēĺēḿēńţ",
"previewCaptureScreenshot": "Śćŕēēńśĥōţ",
"previewCaptureText": "Ţēxţ",
"previewClearAll": "Ćĺēàŕ àĺĺ",
"previewCommentPlaceholder": "Đēśćŕĩƀē ŵĥàţ śĥōũĺď ćĥàńĝē",
"previewCommentPlaceholder": "Ďēśćŕĩƀē ŵĥàţ śĥōũĺď ćĥàńĝē",
"previewCommentSelection": "Ćōḿḿēńţ ōń śēĺēćţĩōń",
"previewConfirmClearDescription": "Ŕēḿōvē àĺĺ ƥēńďĩńĝ ƥŕēvĩēŵ ƒēēďƀàćķ ƒŕōḿ ţĥĩś ţàśķ?",
"previewDeleteFeedback": "Đēĺēţē ƒēēďƀàćķ",
"previewDiscardSelection": "Đĩśćàŕď",
"previewDeleteFeedback": "Ďēĺēţē ƒēēďƀàćķ",
"previewDiscardSelection": "Ďĩśćàŕď",
"previewEditComment": "Ēďĩţ ćōḿḿēńţ",
"previewEditFeedback": "Ēďĩţ ƒēēďƀàćķ",
"previewFeedbackTitle": "Ƥŕēvĩēŵ ƒēēďƀàćķ",
@@ -1229,7 +1229,7 @@
"previewMutationFailed": "Ţĥē ƒēēďƀàćķ ćōũĺď ńōţ ƀē śàvēď. Ţŕŷ àĝàĩń.",
"previewPendingEmpty": "Ńō ƥēńďĩńĝ ƥŕēvĩēŵ ƒēēďƀàćķ.",
"previewPendingFeedback": "Ƥēńďĩńĝ ƒēēďƀàćķ",
"previewLoadingFeedback": "Ŀōàďĩńĝ ƥŕēvĩēŵ ƒēēďƀàćķ...",
"previewLoadingFeedback": "Ĺōàďĩńĝ ƥŕēvĩēŵ ƒēēďƀàćķ...",
"previewSaveChanges": "Śàvē ćĥàńĝēś",
"previewSaveFeedback": "Śàvē ƒēēďƀàćķ",
"previewSelectElement": "Śēĺēćţ ēĺēḿēńţ",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "Tente novamente a ligação para receber o progresso do projeto.",
"progressWillClear": "O progresso do projeto será limpo quando o servidor de linguagem parar.",
"projectAnalysis": "Análise do projeto",
"reconnecting": "A restabelecer ligação ao servidor de linguagem",
"releaseFailed": "Não foi possível confirmar que o servidor de linguagem parou",
"reportedWorkDisclaimer": "Isto confirma apenas o item de trabalho reportado, não a indexação completa do projeto.",
"retryLanguageServer": "Repetir o servidor de linguagem",
"serverProcessStarted": "Processo do servidor iniciado",
@@ -63,6 +65,7 @@
"startLanguageServer": "Iniciar o servidor de linguagem",
"startServerForProgress": "Inicie o servidor de linguagem para receber o progresso do projeto.",
"startingLanguageServer": "A iniciar o servidor de linguagem",
"syncFailed": "O servidor de linguagem não confirmou a sincronização dos documentos.",
"statusLocation": "Localização do estado",
"statusLocationAria": "Localização do estado do LSP",
"stopLanguageServer": "Parar o servidor de linguagem",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "请重试连接以接收项目进度。",
"progressWillClear": "语言服务器停止后,项目进度将被清除。",
"projectAnalysis": "项目分析",
"reconnecting": "正在重新连接语言服务器",
"releaseFailed": "无法确认语言服务器已停止",
"reportedWorkDisclaimer": "这仅确认已报告的工作项完成,并不表示整个项目已完成索引。",
"retryLanguageServer": "重试语言服务器",
"serverProcessStarted": "服务器进程已启动",
@@ -63,6 +65,7 @@
"startLanguageServer": "启动语言服务器",
"startServerForProgress": "启动语言服务器以接收项目进度。",
"startingLanguageServer": "正在启动语言服务器",
"syncFailed": "语言服务器未确认文档同步。",
"statusLocation": "状态位置",
"statusLocationAria": "LSP 状态位置",
"stopLanguageServer": "停止语言服务器",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "請重試連接以接收項目進度。",
"progressWillClear": "語言伺服器停止後,項目進度將被清除。",
"projectAnalysis": "項目分析",
"reconnecting": "正在重新連接語言伺服器",
"releaseFailed": "無法確認語言伺服器已停止",
"reportedWorkDisclaimer": "這僅確認已報告的工作項完成,並不表示整個項目已完成索引。",
"retryLanguageServer": "重試語言伺服器",
"serverProcessStarted": "伺服器程序已啓動",
@@ -63,6 +65,7 @@
"startLanguageServer": "啓動語言伺服器",
"startServerForProgress": "啓動語言伺服器以接收項目進度。",
"startingLanguageServer": "正在啓動語言伺服器",
"syncFailed": "語言伺服器未確認文件同步。",
"statusLocation": "狀態位置",
"statusLocationAria": "LSP 狀態位置",
"stopLanguageServer": "停止語言伺服器",
+3
View File
@@ -55,6 +55,8 @@
"progressUnavailableDescription": "請重試連線以接收專案進度。",
"progressWillClear": "語言伺服器停止後,專案進度將被清除。",
"projectAnalysis": "專案分析",
"reconnecting": "正在重新連線至語言伺服器",
"releaseFailed": "無法確認語言伺服器已停止",
"reportedWorkDisclaimer": "這僅確認已報告的工作項完成,並不表示整個專案已完成索引。",
"retryLanguageServer": "重試語言伺服器",
"serverProcessStarted": "伺服器處理程序已啟動",
@@ -63,6 +65,7 @@
"startLanguageServer": "啟動語言伺服器",
"startServerForProgress": "啟動語言伺服器以接收專案進度。",
"startingLanguageServer": "正在啟動語言伺服器",
"syncFailed": "語言伺服器未確認文件同步。",
"statusLocation": "狀態位置",
"statusLocationAria": "LSP 狀態位置",
"stopLanguageServer": "停止語言伺服器",
@@ -0,0 +1,26 @@
# ADR-2026-09-23-task-owned-lsp-leases: Retain LSP processes across browser disconnects
**Status:** accepted
**Date:** 2026-09-23
**Area:** protocol
## Context
The browser WebSocket currently owns a task-host language-server process. Closing the tab or losing the network closes the stream and interrupts the process, even when the task workspace remains active. A later editor must start a new server and repeat project analysis. Concurrent browser windows currently have independent protocol sessions and may hold different unsaved document buffers.
## Decision
The task runtime will own bounded LSP leases. One lease owns one language-server process and one logical LSP protocol session. A browser connection attaches to a lease; closing that connection detaches it while Kandev continues to drain and service the language server. A later editor for the same task and language may reclaim a detached lease. Concurrently attached browser windows keep independent leases. An active lease prevents idle-session reclaim of its execution. An intentional two-minute browser editor-idle timeout releases its lease, while tab close or network loss retains it for up to one hour. Each detachment starts a new one-hour deadline; reattachment cancels that deadline, and an attached lease never expires. At the LSP limit, admission of a new server evicts the least recently detached lease; attached leases are never evicted. Explicit Stop, server exit, task-host shutdown, or backend shutdown also ends a lease. The existing LSP limit counts retained leases as well as attached ones.
The runtime broker retains only bounded protocol state needed to reattach: server capabilities and dynamic registrations, workspace identity, current configuration, active progress, and open-document revision counters. It discards diagnostics on detach and accepts fresh publications only after the new document is synchronized. It must not treat a transport disconnect as a server crash. A release toggle merges disabled in all shipped profiles; the disabled path preserves the current browser-owned lifecycle.
## Consequences
Project analysis can continue while no browser is open, and a returning editor can regain its state. Keeping the lease active also retains the whole Local PC or Docker task host, including agentctl, container and worktree runtime resources, after an agent turn completes. Intentional browser editor-idle release, one-hour detached expiry, and capacity eviction bound this cost. A user returning after expiry or eviction gets a fresh server and visible initialization. Servers that publish diagnostics without a document version cannot safely restore those markers on a resumed lease, so they stay hidden until a fresh server generation. The broker must handle server requests and drain output without a browser, distinguish explicit Stop from transport loss, and reject stale responses from previous browser attachments. Backend restart loses runtime-only leases and requires a fresh server start.
## Alternatives Considered
- Keep browser-owned processes and restart automatically on reopen: easy to implement, but discards in-flight analysis and does not satisfy continuous work while the browser is closed.
- Retain a process for a short grace period: bounds resource use, but a user returning after the grace period still loses project analysis.
- Retain detached leases until capacity pressure: preserves analysis indefinitely when capacity is available, but can also keep an abandoned task host running indefinitely.
- Share one protocol session across all browser windows: reduces process count, but conflicting unsaved buffers and request IDs would change the current independent-window behavior.
+132
View File
@@ -0,0 +1,132 @@
---
created: 2026-09-23
status: implemented
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
system_design:
- ../../specs/platform/system-design/lsp-file-intelligence-01.md
- ../../specs/platform/system-design/lsp-file-intelligence-02.md
legacy_specs: []
---
# Implementation Plan: LSP browser continuity
## Overview
Keep a task-host language server working after its browser tab closes, then reconnect a later editor to the retained process and restore valid LSP information. Replace the browser-to-agentctl pass-through with a bounded runtime lease that remains the protocol peer while no browser is attached. Add browser reattachment and status recovery, then prove the user flow with desktop and tablet E2E tests. The design follows [ADR-2026-09-23](../../decisions/2026-09-23-task-owned-lsp-leases.md).
## Scope
### In scope
- Retain one task-host process per LSP lease until intentional editor-idle release, one-hour detached expiry, explicit Stop, capacity eviction, actual process exit, task-runtime shutdown, or backend shutdown.
- Reattach to a detached lease for the same execution and language; preserve independent simultaneously open windows.
- Resume providers and progress, then accept fresh current-document diagnostics without treating transport loss as a process crash.
- Count detached leases against the existing LSP limit and preserve supported-executor and access checks.
- Update user-facing status, six locale catalogs, and public LSP/configuration documentation.
### Out of scope
- Persisting a language-server process through task-host or backend restart.
- Sharing one live protocol session between concurrently open browser windows.
- Phone LSP, unsupported executors, new language features, or a global LSP dashboard.
## Technical approach
### Runtime lease and protocol continuity
Refactor `apps/backend/internal/gateway/websocket/lsp_handler.go` around a lease manager keyed by execution identity, language, and opaque lease ID. Keep the agentctl upstream WebSocket open after browser detachment, continuously drain it, and track initialized and dynamic capabilities, workspace metadata, document version counters, latest configuration, and active work progress. Clear diagnostics on detach and accept only fresh, synchronized publications. A returning authorized browser claims its detached lease or an eligible detached lease for the same execution/language. Each concurrently attached browser window keeps its own lease. `lsp_capacity.go` charges one slot per lease and evicts the oldest detached lease before admitting a new process at capacity; if all are attached it returns `4005`. Reattachment does not charge twice. Intentional two-minute browser editor-idle release frees a lease; tab close and network loss retain it for at most one hour. The existing `limits.lspMaxConnections` / `KANDEV_LSP_MAX_CONNECTIONS` identity remains compatible but counts server leases. Update its config-catalog description without changing the key or default; coordinate with the startup-configuration-parity work order that also mentions the key.
The broker maps JSON-RPC request IDs and `$/cancelRequest` to the active attachment generation, cancels in-flight requests on detach, answers dynamic capability and configuration requests while detached, and drops replies to a prior generation. A settings-save event updates detached leases. It handles acknowledged explicit Stop and editor-idle release separately from socket close; Stop rejects pending browser requests, sends LSP `shutdown` and `exit`, then closes upstream after bounded cleanup. Agentctl sends explicit `4006` only for confirmed process exit. Upstream read failure without that signal maps to `4009` transport/broker failure, never to server-exited; reserved close codes `1005` and `1006` are normalized. Agentctl remains the process owner and reaps descendants. A lost execution or backend shutdown closes the upstream and releases the lease exactly once.
The orchestrator's idle-session reclaim path (`apps/backend/internal/orchestrator/reconcile_liveness.go`) must treat an active lease as live work at all three callers: the periodic idle reaper, the agent event handler, and the streaming event handler. Wire a narrow lease-liveness interface through backend initialization, without importing the WebSocket gateway into the orchestrator. The lease admission path and reclaim cleanup must share a session lifecycle fence or use equivalent generation checks, so a new lease cannot race with cleanup of the execution it needs. Lease release/eviction removes the pin so normal reclaim can resume.
Add restart-required `features.lspBrowserContinuity` / `KANDEV_FEATURES_LSP_BROWSER_CONTINUITY` as a runtime release toggle, off in prod, dev, and e2e profiles. Gate backend lease construction and WebSocket admission as well as frontend resume behavior. The disabled path keeps the current browser-owned process and reclaim behavior. Explicitly enable the flag in focused tests and E2E fixtures; exercise both enabled and disabled paths.
### Editor reconnection
Update `apps/web/lib/lsp/lsp-client-manager.ts` and its protocol/state helpers to remember an opaque lease ID for tab restoration, reattach automatically after transient transport loss, and accept a resumed ready handshake with retained and dynamic capabilities. A resumed browser does not send a second LSP `initialize`; it rebuilds Monaco providers, sends current document text, waits for synchronization, and accepts only new qualifying diagnostics. It restores current reported work progress from the lease snapshot. Explicit Stop and the existing two-minute last-editor idle timer send acknowledged release controls. A hinted tab may resume its existing lease after auto-start is disabled, unless the user explicitly stopped it; an unhinted new tab follows current auto-start/manual-enable policy. Backend or task-host restart clears stale lease state and follows the same policy. Duplicated-tab hints never steal an attached lease.
The toolbar and fine-pointer status bar show a localized reconnecting state; the coarse-pointer tablet uses the existing drawer. A true server exit still shows Retry. Phone viewing neither attaches nor starts LSP.
### Public documentation
Update `docs/public/developer-tools.md` for continuity, Stop, and restart behavior; `docs/public/configuration.md` for the retained-lease meaning of the existing limit; and `docs/public/websocket-api.md` for the close-code distinction. Check `docs/public/feature-status.md` and update its summary if the new behavior needs mention. These pages change with implementation, after the behavior is verified.
## ASCII UI preview
`UI-01: Desktop active Monaco file after transient LSP disconnection` (AC .2, .3). The status control stays in its selected toolbar or status-bar location. The reconnecting state replaces the misleading server-exited message.
```text
Before: Go [!] Error: language server exited [Retry]
After: Go [~] Reconnecting to language server... [Stop]
Go [o] Ready | Project work: <server report> [Stop]
```
`UI-02: Coarse-pointer tablet LSP drawer` (AC .2, .7). The existing toolbar trigger opens the same drawer; no new navigation surface is introduced.
```text
File editor [Go LSP: Reconnecting]
+-------------------------------------------+
| Language server |
| Reconnecting to language server... |
| Project progress resumes when connected. |
| [Stop] |
+-------------------------------------------+
```
`UI-03: Phone file viewer` (AC .7). The phone viewer retains its existing file header and content; it has no LSP trigger or background attachment.
```text
< Back main.go
--------------------------------
File content (one scroll region)
```
The structure and lifecycle states are required; the shown text and spacing are illustrative and must use localized copy. Desktop and tablet reuse their existing status placement and surfaces. Tablet keeps the current inset drawer, safe-area handling, touch-sized action, and internal scroll owner. The phone boundary is a no-attachment assertion, not a new screen design.
## Tests
- AC .1, .4, .5, .8, .10: Go lease-manager tests prove detach survives a completed turn and reaper interval, Stop/idle release/task stop release capacity, independent attached windows, one-hour detached expiry and reattachment cancellation, LRU detached eviction, exact accounting, and cancellation of a detached install on teardown.
- AC .2, .3, .6, .8: Go protocol tests prove capability registration replay, detached configuration updates, monotonic document versions, diagnostic invalidation, version matching and resumed unversioned suppression, request cancellation and generation isolation, process-exit versus transport codes, and reserved-code normalization.
- AC .2, .3, .6, .7, .8, .9: web manager tests prove resumed provider setup without second initialize, document synchronization, reconnect/backoff, idle release, close-code translation, policy after auto-start changes, duplicate-hint isolation, localized states, stale-marker suppression, and phone boundary. Flag contract tests cover both modes.
## E2E tests
- AC .1, .2, .3: With the flag enabled, extend `apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts` and the fake LSP server. Close the desktop page, complete an agent turn, pass a reaper interval, reopen the task, confirm the same fake server process/initialize count, and verify fresh current-file diagnostics and status return.
- AC .4, .5, .6, .8, .9: Cover explicit Stop, browser editor-idle release, a second live window and duplicated tab, detached-lease eviction at capacity, all-attached `4005`, and task-host restart followed by fresh initialization.
- AC .7: Extend `apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts` for tablet reattachment through its drawer; preserve its existing phone no-socket assertion.
## Work orders
- [x] [Task 01: Runtime LSP leases](task-01-runtime-lsp-leases.md) — done.
- [x] [Task 02: Editor reconnection and status](task-02-editor-reconnection.md) — done.
- [x] [Task 03: Browser proof and public docs](task-03-browser-proof-and-docs.md) — done.
Execution is sequential because the broker handshake, browser state, and E2E fixture share one protocol contract.
## Verification results
Implemented all three work orders. Focused backend tests and `go vet` passed, including the LSP gateway, agentctl API, orchestrator, backend wiring, runtime flags, profiles, and configuration catalog. The focused web LSP/component/feature suite passed (58 tests), web typecheck and changed-file ESLint passed, and `pnpm run i18n:pseudo && pnpm run i18n:check` passed. Managed desktop LSP E2E scenarios passed for close/reopen retention, independent windows and duplicate tabs, detached capacity eviction, all-attached capacity, intentional idle release, disabled-flag behavior, and fresh initialization after task-host restart. Managed mobile E2E passed for tablet drawer reattachment and the phone no-socket boundary. Public-doc validators and spec catalog/lint checks passed. `git diff --check` passed. The repository-wide E2E sleep lint reports unrelated existing violations outside the changed LSP files; targeted lint for the changed E2E files passed.
### Code-review remediation results (2026-09-23)
- Normal `agent.completed` cleanup now defers teardown while the exact execution owns a live LSP lease. Regression coverage drives the actual completion event and a stale-row idle-reaper scan; the runtime row remains running.
- Detach keeps the upstream write fence through old-generation cancellation and `didClose` writes. Controlled concurrency coverage proves cleanup precedes successor `didOpen`, and a stale handler generation cannot detach its successor.
- The broker owns the single `initialize` request and response across browser detaches. Tests cover reconnect before the response and reconnect after the response but before `initialized`; the resumed client completes the missing notification without a second initialize.
- Detached reuse now requires the same user ID. Broker configuration lookup restores full-language and language-prefixed section behavior. Tests cover both-user lease selection and nested/missing configuration sections.
- The browser advertises only supported dynamic provider methods, maps semantic tokens, and rebuilds registrations across resume. Focused backend/frontend registration tests pass.
- The full non-race orchestrator suite passed (265.6s), the complete gateway race suite passed, and focused race tests for the LSP gateway and orchestrator reaper regressions passed. The combined full race run ended with a nil-executor panic in an asynchronous queued-message test goroutine (`Executor.GetExecutionBySession` from `executeQueuedMessageWithReservation`); no LSP regression test failed. All 134 LSP Vitest tests passed; web typecheck, changed-file ESLint, `git diff --check`, and the managed desktop E2E that completes a real follow-up turn and waits 95 seconds through the reaper age threshold and scan passed.
- The one-hour detached expiry follow-up adds timer and admission cleanup, protects reattached leases from stale deadlines, and documents fresh analysis after expiry. The gateway race suite, `go vet`, spec catalog/lint, and public-doc validators pass.
## Risks
- A retained server keeps the entire Local PC or Docker task host alive, including agentctl, container, and worktree runtime resources, after a turn completes. Intentional editor-idle release, one-hour detached expiry, and oldest-detached eviction bound the cost; users returning after expiry or eviction see fresh initialization.
- All three idle-reclaim call sites can end an execution after a turn completes. None may reclaim one with a live LSP lease, and lease admission must not race with those decisions.
- Some servers issue client requests or progress while detached. The broker must continue draining and responding without a browser.
- Multiple browser windows may have divergent unsaved text. They must remain on independent leases.
- Reattached diagnostics need a matching server-facing document version. Unversioned diagnostics stay hidden on resumed leases until a fresh server generation, which reduces diagnostic coverage for servers that omit versions.
## Open questions
None blocking the design. Retention ends with the active task runtime; backend or task-host restart uses a fresh server and visible initialization.
@@ -0,0 +1,100 @@
---
id: "01-runtime-lsp-leases"
title: "Runtime LSP leases"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
acceptance_criteria:
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.1
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.2
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.3
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.4
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.5
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.6
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.8
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.9
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.10
system_design:
- ../../specs/platform/system-design/lsp-file-intelligence-01.md
- ../../specs/platform/system-design/lsp-file-intelligence-02.md
---
# Task 01: Runtime LSP leases
## Summary
Make the backend own a bounded LSP lease and keep its agentctl stream and language-server process alive when a browser detaches. Supply a safe reattachment handshake, explicit Stop, and separate process-exit and transport-failure signals.
## In scope
- Add a backend lease manager around the current LSP handler. It continuously drains the task-host stream, owns request-generation mapping and bounded resume snapshots, and reattaches only authorized browsers for the same execution and language.
- Keep concurrently attached browser windows on independent leases, including duplicated-tab hints. Count detached leases against the existing limit; release capacity once on editor-idle release, Stop, one-hour detached expiry, oldest-detached capacity eviction, process exit, task-runtime stop, or backend shutdown.
- Integrate lease liveness with all `reclaimIdleSession` callers: `idle_session_reaper.go`, `event_handlers_agent.go`, and `event_handlers_streaming.go`. Fence admission against concurrent reclaim and preserve explicit task-stop behavior.
- Handle dynamic registrations and `workspace/configuration` while detached, subscribe to saved per-language settings changes, map `$/cancelRequest`, cancel pending requests on detach/Stop, reconcile reopened document versions, invalidate old diagnostics, and normalize reserved WebSocket close codes. Stop sends `shutdown`/`exit`; agentctl marks confirmed process exit distinctly from upstream read failure and owns process-tree teardown.
- Add the all-off, restart-required `features.lspBrowserContinuity` release toggle through profiles, typed backend config, runtime flag registry/binding, and backend construction/WebSocket gates. Its disabled path preserves the browser-owned proxy and idle-reclaim behavior. Update the `limits.lspMaxConnections` catalog description without changing its identity or default; coordinate with startup-configuration-parity Task 04.
## Out of scope
- Browser status rendering, locale copy, and Playwright flows.
- Durable process persistence across backend or task-host restart.
## Acceptance
1. Closing a browser socket leaves the same upstream/process alive and draining; an authorized later attachment resumes its capabilities and progress without another task-host process start.
2. Explicit Stop, editor-idle release, one-hour detached expiry, capacity eviction, and every runtime terminal path stop the intended lease and release capacity once; a separate live window is unchanged, a detached lease remains admissible at capacity, and all idle-reclaim callers skip an execution with an active lease even after the agent turn completes. Reattachment cancels expiry, and expiry allows subsequent idle reclaim.
3. The broker isolates old request generations, translates cancellations, retains effective dynamic registrations and latest settings, uses increasing server-facing document versions, clears diagnostics on detach, accepts only qualifying fresh publications, and distinguishes confirmed server exit from transport failure.
4. The release toggle is off in all shipped profiles; direct backend access uses the legacy behavior when disabled, and enabled-only work is unreachable without the flag.
## Verification
```bash
(cd apps/backend && go test ./internal/gateway/websocket ./internal/agentctl/server/api ./internal/orchestrator ./internal/backendapp ./internal/runtimeflags ./internal/common/config ./internal/profiles)
(cd apps/backend && go vet ./internal/gateway/websocket ./internal/agentctl/server/api ./internal/orchestrator ./internal/backendapp)
```
## Files likely touched
- `apps/backend/internal/gateway/websocket/lsp_handler.go`
- `apps/backend/internal/gateway/websocket/lsp_capacity.go`
- `apps/backend/internal/gateway/websocket/lsp_lease.go` and focused tests
- `apps/backend/internal/agentctl/server/api/lsp.go` and focused tests for the upstream process-exit signal
- `apps/backend/internal/backendapp/gateway.go` for lifecycle shutdown wiring
- `apps/backend/internal/backendapp/services.go` or the nearest initialization seam for the lease-liveness callback
- `apps/backend/internal/orchestrator/reconcile_liveness.go`, `idle_session_reaper.go`, `event_handlers_agent.go`, `event_handlers_streaming.go`, and focused reclaim tests
- `apps/backend/internal/user/service/service.go` or the existing settings-save event seam for detached configuration updates
- `profiles.yaml`, `apps/backend/internal/common/config/config.go`, `apps/backend/internal/runtimeflags/{registry,config}.go` and their contract tests
- `apps/backend/internal/common/config/catalog.go` and its catalog tests
- `AGENTS.md` Observability section and `apps/backend/AGENTS.md` LSP ownership notes
## Dependencies
None. Task 02 consumes the lease handshake and close-code contract.
## Risks
- A detached server can emit output indefinitely; reading, caching, and client request handling must stay bounded.
- Capacity or process teardown races can leak a lease or interrupt the wrong execution after replacement.
- Idle reclaim and lease admission can race unless both use the same session lifecycle fence or an equivalent generation check.
- A new HTTP or control surface must enforce session authorization before lease lookup.
- Holding the lease pins the whole task host; idle release, one-hour detached expiry, and capacity eviction must allow normal reclaim again.
## Parallelism
`sequential`
## Inputs
- [Plan and preview](plan.md)
- `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002` and both linked system designs.
- [ADR-2026-09-23](../../decisions/2026-09-23-task-owned-lsp-leases.md)
## Results
Implemented runtime-owned bounded leases, detach/reattach protocol brokering, capacity/idle release, task-runtime liveness fencing, process-exit signaling, and the all-off release toggle. Focused Go tests passed for the gateway, agentctl API, orchestrator, backend wiring, runtime flags, profiles, and the audited configuration catalog. Focused `go vet` passed for the changed runtime packages.
Code-review remediation preserves the task-host execution after normal `agent.completed` while that exact execution has an active lease. Detach cleanup remains ordered before successor document writes; the attach call returns its own generation; initialize/initialized handshake state survives either detach boundary; reuse is scoped to the settings owner; dynamic registrations are brokered while attached and replayed after resume; language-prefixed `workspace/configuration` sections retain their prior behavior; and pending browser requests have count, ID-size, aggregate-state, and timeout bounds. Focused race tests passed for both changed packages, the complete `websocket` package race suite passed, full non-race and race-enabled orchestrator suites passed, and backend lint and build passed. An earlier full race run hit a nil-executor panic in an asynchronous queued-message test goroutine (`Executor.GetExecutionBySession` from `executeQueuedMessageWithReservation`); the final full race rerun passed without reproducing it.
The follow-up detached deadline releases an idle lease one hour after browser detachment, cancels on reattachment, and permits normal task-host reclaim. Expired leases are also removed before admission if a timer callback was delayed. Focused deadline tests and the full gateway race suite pass.
@@ -0,0 +1,107 @@
---
id: "02-editor-reconnection"
title: "Editor reconnection and status"
status: done
wave: 2
depends_on:
- "01-runtime-lsp-leases"
plan: "plan.md"
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
acceptance_criteria:
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.2
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.3
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.4
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.6
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.7
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.8
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.9
system_design:
- ../../specs/platform/system-design/lsp-file-intelligence-01.md
- ../../specs/platform/system-design/lsp-file-intelligence-02.md
---
# Task 02: Editor reconnection and status
## Summary
Let a returning Monaco editor reattach to a retained LSP lease, rebuild its providers, and show current status and analysis. Recover brief transport loss automatically while keeping actual server exits actionable.
## In scope
- Store an opaque lease hint for tab restoration, consume the resumed handshake with effective dynamic registrations and without a second `initialize`, register providers, and synchronize current documents before accepting fresh diagnostics.
- Add bounded reconnect attempts and a localized reconnecting status. Keep Stop as an acknowledged explicit action, send acknowledged release at the existing two-minute last-editor idle timeout, and preserve browser-local manual enablement. A hinted existing lease may resume after auto-start is disabled, unless explicitly stopped; an unhinted new tab follows current policy. Duplicated tabs never steal an attached lease.
- Add the frontend feature key and enabled/disabled client behavior. When the flag is off, preserve the existing browser-owned connection and close-code handling. Translate `4006` only as confirmed process exit and `4009` or abnormal close as transport failure when on.
- Reuse the existing desktop toolbar/status-bar and coarse-pointer tablet drawer; keep phone viewing LSP-free. Add focused unit/component coverage and all required locales.
## Out of scope
- A new LSP dashboard, phone controls, or layout redesign.
- Browser E2E and public documentation, covered by Task 03.
## Acceptance
1. A resumed connection rebuilds Monaco providers from retained and dynamic capabilities, synchronizes open documents, shows current project progress without a second LSP initialize request, and accepts only fresh qualifying diagnostics.
2. Unexpected transport loss (`4009` or abnormal close) shows reconnecting and retries the retained lease; confirmed process exit (`4006`) shows server-exited Retry; stale responses from a replaced browser attachment cannot change the new status.
3. Explicit Stop ends only this window's lease, and tablet shows the same state/action in its existing drawer while phone mounts no LSP connection.
4. The last-editor idle timeout releases its lease, a duplicated tab cannot steal an attached lease, and a hinted tab resumes its lease after an auto-start preference change unless explicitly stopped.
## ASCII UI preview
`UI-01: Desktop active Monaco file after transient disconnection` and `UI-02: Coarse-pointer tablet LSP drawer` follow the [combined preview](plan.md#ascii-ui-preview) (AC .2, .3, .7).
```text
Desktop: Go [~] Reconnecting to language server... [Stop]
Go [o] Ready | Project work: <server report> [Stop]
Tablet: [Go LSP: Reconnecting] -> drawer -> status + [Stop]
Phone: file header + content; no LSP control or attachment
```
The reconnecting state and action are required. Copy and spacing are illustrative and localized. Existing desktop placement, tablet drawer geometry, and phone viewer composition remain the structural baseline.
## Verification
```bash
(cd apps/web && pnpm exec vitest run lib/lsp/lsp-client-manager.test.ts lib/lsp/lsp-client-manager.progress.test.ts lib/lsp/lsp-client-manager.document-sync.test.ts hooks/use-lsp.test.tsx)
(cd apps && pnpm --filter @kandev/web test -- lib/state/slices/features/features-contract.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm exec eslint lib/lsp hooks/use-lsp.ts components/editors/lsp-status-button.tsx)
```
## Files likely touched
- `apps/web/lib/lsp/lsp-client-manager.ts` and its focused tests
- `apps/web/lib/lsp/lsp-json-rpc.ts`, progress and document-sync helpers
- `apps/web/hooks/use-lsp.ts` and tests
- `apps/web/lib/state/slices/features/types.ts` and feature contract tests
- `apps/web/components/editors/lsp-status-button.tsx`
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,ja}/lsp.json` and verbatim records only if needed
## Dependencies
Task 01 supplies the lease/resume protocol and close-code distinction.
## Risks
- Monaco provider disposal during reconnect can erase another session's markers or leave stale built-in suppression.
- A tab with unsaved edits must not show diagnostics produced for the previous attachment's buffer.
- A server that omits diagnostic versions cannot restore markers safely on a resumed lease; the editor keeps markers clear until a fresh process starts.
- Automatic retries must be bounded and cancel when the user explicitly stops or leaves the editor.
- Disabled rollout must preserve the old `4006` proxy-stream handling; `4009` must never inherit the true process-exit label.
## Parallelism
`sequential`
## Inputs
- [Plan and preview](plan.md)
- `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002` and both linked system designs.
- Task 01's tested handshake and close-code contract.
## Results
Implemented lease-hint restoration, bounded reconnect, resumed capability/progress state, document synchronization before diagnostics, acknowledged Stop/idle release, localized reconnect status, feature gating, and the phone LSP boundary. The focused web LSP, hook, storage, dynamic-capability, and feature-contract suite passed (58 tests); web typecheck, changed-file ESLint, and the pseudo-locale/i18n checks passed.
Code-review remediation advertises dynamic registration only for the supported completion, hover, definition, references, signature-help, and semantic-token providers. The semantic-token registration maps to Monaco's supported capability shape. A resumed handshake that reports `initialized: false` sends the one-time `initialized` notification before document synchronization and attachment readiness. The final focused LSP suite passed (150 tests across 15 files); web typecheck and changed-file ESLint passed.
@@ -0,0 +1,109 @@
---
id: "03-browser-proof-and-docs"
title: "Browser proof and public docs"
status: done
wave: 3
depends_on:
- "01-runtime-lsp-leases"
- "02-editor-reconnection"
plan: "plan.md"
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
acceptance_criteria:
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.1
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.2
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.3
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.4
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.5
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.6
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.7
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.8
- AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.9
system_design:
- ../../specs/platform/system-design/lsp-file-intelligence-01.md
- ../../specs/platform/system-design/lsp-file-intelligence-02.md
---
# Task 03: Browser proof and public docs
## Summary
Prove browser close and return against a real task runtime and a deterministic fake language server. Update public guidance to describe retained processes, capacity, Stop, and restart recovery.
## In scope
- Extend the existing fake LSP fixture with a count or process marker that distinguishes reattachment from a fresh process and initialize.
- Enable the release flag explicitly in the E2E fixture. Add desktop E2E for page close/reopen after a completed agent turn and reaper interval, fresh diagnostics and progress, duplicated-tab isolation, explicit Stop, editor-idle release, detached-lease eviction, all-attached capacity, and task-host restart. Add tablet reattachment coverage and retain the phone no-socket assertion. Include a disabled-flag regression of the existing browser-owned lifecycle.
- Update the existing public developer-tools, configuration, WebSocket API, and feature-status text where the previous browser-owned lifecycle is documented.
## Out of scope
- New LSP capabilities, executor support, mobile phone LSP, or a new status surface.
## Acceptance
1. A desktop browser closes and reopens on the task; the same fake server process remains, the editor reaches ready with current-file diagnostics and project status, and no second initialize occurs.
2. A second active window and duplicated tab remain independent, idle release and capacity eviction free task-host resources, explicit Stop and host restart have the specified effects, and the tablet drawer regains status while phone opens no LSP socket.
3. Public docs accurately describe retained-lease capacity, Stop, restart recovery, and transport versus process-exit statuses; focused docs validators pass.
## ASCII UI preview
`UI-01: Desktop active Monaco file`, `UI-02: Coarse-pointer tablet drawer`, and `UI-03: Phone file viewer` use the [combined preview](plan.md#ascii-ui-preview) (AC .2, .3, .7).
```text
Desktop: [Go LSP: Reconnecting] -> [Go LSP: Ready + project report]
Tablet: [Go LSP] -> bottom drawer -> Ready + project report
Phone: main.go -> file content; no LSP control
```
No new navigation is required. Test the existing desktop and tablet surfaces and the phone absence contract.
## Verification
```bash
(cd apps/web && pnpm e2e:run tests/lsp/lsp-file-intelligence.spec.ts -- --grep "retains|reattaches|disconnects|evicts|releases|capacity")
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/lsp/mobile-lsp-file-intelligence.spec.ts -- --grep "reattaches|without starting")
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
The implementation must use test names matching the focused grep expressions and confirm Playwright discovers the intended scenarios. The managed runner builds production frontend and backend assets for each command.
## Files likely touched
- `apps/web/e2e/fixtures/fake-lsp-server.mjs`
- `apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts`
- `apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts`
- `apps/web/e2e/tests/lsp/lsp-e2e-helpers.ts`
- `apps/web/e2e/fixtures/backend.ts` or the nearest per-spec release-flag override
- `docs/public/developer-tools.md`
- `docs/public/configuration.md`
- `docs/public/websocket-api.md`
- `docs/public/feature-status.md` if its current lifecycle summary changes
## Dependencies
Tasks 01 and 02 must complete so the E2E scenarios exercise the final protocol and UI.
## Risks
- Browser page close may race process teardown; the fake server marker must prove the same process survived rather than merely a fast restart.
- The tablet test must exercise its actual drawer and touch action; the phone test must confirm no socket is opened.
- The public config key name remains unchanged even though its resource count becomes retained leases.
- A retained lease pins the whole Local PC or Docker task host until release, eviction, or task stop; public guidance should make this cost and fresh-start-after-eviction behavior clear.
## Parallelism
`sequential`
## Inputs
- [Plan and preview](plan.md)
- `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002` and both linked system designs.
- Completed Task 01 and Task 02 results.
## Results
Added managed desktop E2E coverage for retained process identity and fresh diagnostics/progress after reattachment, independent windows and duplicate-tab isolation, Stop and idle release, detached eviction and all-attached capacity, disabled-flag compatibility, and fresh startup after task-host restart. Tablet drawer reattachment and the phone no-socket boundary passed on the managed mobile project. Updated public developer, configuration, feature-status, and WebSocket guidance; both public-doc validators passed. Spec catalog validation, spec lint, and `git diff --check` passed.
Code-review remediation extends the close/reopen scenario to send and observe a second completed agent turn after LSP admission. It then waits 95 seconds to pass the 60-second idle age threshold and the 30-second reaper tick, and verifies the same fake LSP process and initialize count after reattachment. The focused managed desktop E2E passed (1 test, 1.7 minutes); the frontend production build and backend build both completed as part of the managed runner.
+65 -65
View File
@@ -147,19 +147,19 @@ The Docker socket is effectively root-equivalent on many hosts. Do not publish i
### Core agent service
| YAML key | Environment variable | Default | Current behavior |
| ---------------------- | ------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
| YAML key | Environment variable | Default | Current behavior |
| ---------------------- | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------- |
| `agent.standaloneHost` | `KANDEV_AGENT_STANDALONE_HOST` | `127.0.0.1` | Host of the core `agentctl` control server. The literal avoids IPv6 loopback resolution variance. |
| `agent.standalonePort` | `AGENTCTL_PORT` or `KANDEV_AGENT_STANDALONE_PORT` | `39429` | Preferred control port. The launcher may supply a free fallback. |
| `agent.standalonePort` | `AGENTCTL_PORT` or `KANDEV_AGENT_STANDALONE_PORT` | `39429` | Preferred control port. The launcher may supply a free fallback. |
The launcher starts `agentctl`, performs a one-time nonce handshake, and supplies the resulting per-launch token internally. Do not persist or proxy its bootstrap/auth state. Agent command, model, environment, permission, and MCP configuration belongs in agent profiles rather than this section.
### Setup and launch timing
| YAML key | Environment variable | Default | Current behavior |
| -------------------------- | --------------------------------- | ------- | ------------------------------------------------------------------------------- |
| `tasks.preparationTimeout` | `KANDEV_TASK_PREPARATION_TIMEOUT` | `10m` | Positive Go duration for repository setup and executor-profile prepare scripts. |
| `tasks.stallDetectionThreshold` | `KANDEV_TASK_STALL_DETECTION_THRESHOLD` | `2h` | Positive Go duration for the session reconciliation sweep's stall threshold. |
| YAML key | Environment variable | Default | Current behavior |
| ------------------------------- | --------------------------------------- | ------- | ------------------------------------------------------------------------------- |
| `tasks.preparationTimeout` | `KANDEV_TASK_PREPARATION_TIMEOUT` | `10m` | Positive Go duration for repository setup and executor-profile prepare scripts. |
| `tasks.stallDetectionThreshold` | `KANDEV_TASK_STALL_DETECTION_THRESHOLD` | `2h` | Positive Go duration for the session reconciliation sweep's stall threshold. |
`tasks.preparationTimeout` controls how long Kandev allows repository setup and
executor-profile prepare scripts to run. The value uses Go duration syntax,
@@ -197,28 +197,28 @@ variable overrides YAML, and changes require a backend restart.
### Capacity and managed-process startup settings
| YAML key | Environment variable | Type and default | Current behavior |
| ------------------------------------ | --------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `credentials.file` | `KANDEV_CREDENTIALS_FILE` | path, empty | Optional fallback JSON credential file. The file is read lazily and must be restricted to the Kandev service account. |
| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | positive integer, `8` | Process-wide `gh` subprocess admission cap. |
| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | positive integer, `12` | Process-wide `git` subprocess admission cap. |
| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | positive integer, `8` | Active browser-to-task-host language-server connection cap. |
| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | integer `>= 0`, `10` | Per-session pending-message cap. Zero means unlimited. A non-negative YAML or environment value locks capacity in Settings; a negative environment value means unlimited, and invalid environment input falls through to the lower-precedence source. |
| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | Go duration, `1h` | Idle managed-agent reaping timeout. Zero disables reaping. |
| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | Go duration, `1m` | Interval between idle-agent scans. |
| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | integer `1024`-`131072`, `131072` | ACP inbound notification queue capacity. An out-of-range YAML value fails startup; an invalid or out-of-range environment value uses the built-in default. |
| `agentctl.recoveryDeadline` | `KANDEV_ACP_RECOVERY_DEADLINE` | Go duration `1s`-`5m`, `30s` | Single bound covering one startup's control-server adoption, instance enumeration, and session reconstruction. Unlike the settings above, an invalid or out-of-range environment value **fails startup** rather than falling back to the default. |
| `agentctl.recoveryReadTimeout` | `KANDEV_ACP_RECOVERY_READ_TIMEOUT` | Go duration `100ms`-`1h`, `2s` | Per-attempt timeout for each read of a surviving control server during recovery. An invalid or out-of-range environment value fails startup. The combined budget with `agentctl.recoveryReadRetries` must also fit the lesser of 6s and one fifth of `agentctl.recoveryDeadline`, so values above a few seconds fail startup regardless of the range. |
| `agentctl.recoveryReadRetries` | `KANDEV_ACP_RECOVERY_READ_RETRIES` | integer `0`-`10`, `2` | Retries per recovery read, on top of the first attempt. The resulting budget must not exceed the lesser of 6s and one fifth of `agentctl.recoveryDeadline`, or startup fails. An invalid or out-of-range environment value fails startup. |
| `agentctl.unownedPeriod` | `KANDEV_ACP_UNOWNED_PERIOD` | Go duration, `10m` | How long a standalone control server keeps running with no backend renewing ownership before it shuts itself down. Raised to a one-minute floor, and clamped to half `agentctl.idleTimeout` when that is enabled and shorter; an invalid or negative environment value uses the default. |
| `agentctl.detachedEventLimit` | `KANDEV_ACP_DETACHED_EVENT_LIMIT` | integer `1`-`10000`, `100` | Per-instance agent events retained while no backend is attached. An invalid or out-of-range environment value fails startup. |
| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | integer `>= 0`, `300000` | Same-author plan revision coalescing window in milliseconds. |
| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | URL, empty | OTLP tracing endpoint. Treat the value and emitted spans as sensitive. |
| `executors.sshReachabilityIntervalSeconds` | `KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS` | integer `>= 0`, `60` | Interval between background SSH-executor reachability probes, in seconds. `0` disables the poller entirely; a probed executor then keeps its last recorded state rather than going stale. The effective cadence is otherwise clamped to `15`-`3600`: a value from `1` to `14` becomes `15`, and a value above `3600` becomes `3600`, each logged once at startup. Valid environment and YAML values use this clamp. An invalid environment value falls back to `60`; an invalid YAML value fails startup like any other typed catalog key. |
| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | positive integer, `5000` | Office queued/retry run safety-net interval in milliseconds. |
| `launcher.webPort` | `KANDEV_WEB_PORT` | automatic, `0` | Development web-server port. It is used with `dev` and ignored by embedded-asset launches. |
| `launcher.healthTimeoutMs` | `KANDEV_HEALTH_TIMEOUT_MS` | positive integer, `45000` | Launcher startup-health timeout in milliseconds. Development and E2E profiles use a longer default. |
| `launcher.noBrowser` | `KANDEV_NO_BROWSER` | `false` | Suppresses browser opening when true or `1`, depending on launch mode. |
| YAML key | Environment variable | Type and default | Current behavior |
| ------------------------------------------ | ------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `credentials.file` | `KANDEV_CREDENTIALS_FILE` | path, empty | Optional fallback JSON credential file. The file is read lazily and must be restricted to the Kandev service account. |
| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | positive integer, `8` | Process-wide `gh` subprocess admission cap. |
| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | positive integer, `12` | Process-wide `git` subprocess admission cap. |
| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | positive integer, `8` | Maximum active and detached task-host language-server leases when browser continuity is enabled. At capacity, Kandev evicts the least-recently detached lease; if all leases are attached, it rejects the new request. |
| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | integer `>= 0`, `10` | Per-session pending-message cap. Zero means unlimited. A non-negative YAML or environment value locks capacity in Settings; a negative environment value means unlimited, and invalid environment input falls through to the lower-precedence source. |
| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | Go duration, `1h` | Idle managed-agent reaping timeout. Zero disables reaping. |
| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | Go duration, `1m` | Interval between idle-agent scans. |
| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | integer `1024`-`131072`, `131072` | ACP inbound notification queue capacity. An out-of-range YAML value fails startup; an invalid or out-of-range environment value uses the built-in default. |
| `agentctl.recoveryDeadline` | `KANDEV_ACP_RECOVERY_DEADLINE` | Go duration `1s`-`5m`, `30s` | Single bound covering one startup's control-server adoption, instance enumeration, and session reconstruction. Unlike the settings above, an invalid or out-of-range environment value **fails startup** rather than falling back to the default. |
| `agentctl.recoveryReadTimeout` | `KANDEV_ACP_RECOVERY_READ_TIMEOUT` | Go duration `100ms`-`1h`, `2s` | Per-attempt timeout for each read of a surviving control server during recovery. An invalid or out-of-range environment value fails startup. The combined budget with `agentctl.recoveryReadRetries` must also fit the lesser of 6s and one fifth of `agentctl.recoveryDeadline`, so values above a few seconds fail startup regardless of the range. |
| `agentctl.recoveryReadRetries` | `KANDEV_ACP_RECOVERY_READ_RETRIES` | integer `0`-`10`, `2` | Retries per recovery read, on top of the first attempt. The resulting budget must not exceed the lesser of 6s and one fifth of `agentctl.recoveryDeadline`, or startup fails. An invalid or out-of-range environment value fails startup. |
| `agentctl.unownedPeriod` | `KANDEV_ACP_UNOWNED_PERIOD` | Go duration, `10m` | How long a standalone control server keeps running with no backend renewing ownership before it shuts itself down. Raised to a one-minute floor, and clamped to half `agentctl.idleTimeout` when that is enabled and shorter; an invalid or negative environment value uses the default. |
| `agentctl.detachedEventLimit` | `KANDEV_ACP_DETACHED_EVENT_LIMIT` | integer `1`-`10000`, `100` | Per-instance agent events retained while no backend is attached. An invalid or out-of-range environment value fails startup. |
| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | integer `>= 0`, `300000` | Same-author plan revision coalescing window in milliseconds. |
| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | URL, empty | OTLP tracing endpoint. Treat the value and emitted spans as sensitive. |
| `executors.sshReachabilityIntervalSeconds` | `KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS` | integer `>= 0`, `60` | Interval between background SSH-executor reachability probes, in seconds. `0` disables the poller entirely; a probed executor then keeps its last recorded state rather than going stale. The effective cadence is otherwise clamped to `15`-`3600`: a value from `1` to `14` becomes `15`, and a value above `3600` becomes `3600`, each logged once at startup. Valid environment and YAML values use this clamp. An invalid environment value falls back to `60`; an invalid YAML value fails startup like any other typed catalog key. |
| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | positive integer, `5000` | Office queued/retry run safety-net interval in milliseconds. |
| `launcher.webPort` | `KANDEV_WEB_PORT` | automatic, `0` | Development web-server port. It is used with `dev` and ignored by embedded-asset launches. |
| `launcher.healthTimeoutMs` | `KANDEV_HEALTH_TIMEOUT_MS` | positive integer, `45000` | Launcher startup-health timeout in milliseconds. Development and E2E profiles use a longer default. |
| `launcher.noBrowser` | `KANDEV_NO_BROWSER` | `false` | Suppresses browser opening when true or `1`, depending on launch mode. |
These settings are read at startup. Their environment aliases remain
compatible overrides, and a YAML value is not copied into a public process
@@ -532,16 +532,16 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
**Settings → System → Feature Toggles** manages startup-time flags:
| Key | Environment lock | Production default | Effect |
|---|---|---|---|
| `features.auth` | `KANDEV_FEATURES_AUTH` | off | Experimental authentication, users, per-user workspaces, and team access. |
| `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. |
| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. |
| `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. |
| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Inbox for clarification questions and failed tasks in the active workspace. The Failed tab shows a bounded list with its own count. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `features.agentSurvival` | `KANDEV_FEATURES_AGENT_SURVIVAL` | off | Experimental. Lets the `agentctl` control server and its running agents outlive a backend restart or upgrade, for the `local_pc` and worktree executors only. The restarted backend takes the surviving server over and reconnects to the sessions still running on it. **Unavailable on Windows**, where surviving the backend means giving up the Job Object that guarantees agent processes are cleaned up. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. |
| Key | Environment lock | Production default | Effect |
| -------------------------------- | ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `features.auth` | `KANDEV_FEATURES_AUTH` | off | Experimental authentication, users, per-user workspaces, and team access. |
| `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. |
| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. |
| `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. |
| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Inbox for clarification questions and failed tasks in the active workspace. The Failed tab shows a bounded list with its own count. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `features.agentSurvival` | `KANDEV_FEATURES_AGENT_SURVIVAL` | off | Experimental. Lets the `agentctl` control server and its running agents outlive a backend restart or upgrade, for the `local_pc` and worktree executors only. The restarted backend takes the surviving server over and reconnects to the sessions still running on it. **Unavailable on Windows**, where surviving the backend means giving up the Job Object that guarantees agent processes are cleaned up. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. |
The `KANDEV_FEATURES_*` values have no canonical YAML keys. They are selected
from the embedded profile and can be overridden by the environment or the
@@ -615,32 +615,32 @@ These settings are startup-only unless stated otherwise. Their YAML keys are
the canonical file form; the environment aliases remain compatibility
overrides.
| YAML key | Environment variable | Default | Parsing and effect |
| ------------------------------------ | --------------------------------- | -------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | `8` | Positive integer process-wide cap for `gh` subprocesses; invalid/non-positive uses default. |
| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | `12` | Positive integer process-wide cap for `git` subprocesses; invalid/non-positive uses default. |
| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | `8` | Positive integer cap for active browser-to-task-host language-server streams; invalid/non-positive uses default. |
| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | `10` | Pending messages per session. A non-negative YAML value locks the saved UI capacity; a valid environment value overrides YAML and locks it; a negative environment value means unlimited; malformed environment input falls through to YAML, the saved setting, or default. |
| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | `1h` | Go duration after which idle managed agentctl instances are reaped; `0` disables. Invalid uses default. |
| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | `1m` | Go duration between idle scans. |
| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | `131072` | Per-connection ACP inbound notification capacity. YAML values outside `1024`-`131072` fail startup; invalid or out-of-range environment values use `131072`. |
| `agentctl.recoveryDeadline` | `KANDEV_ACP_RECOVERY_DEADLINE` | `30s` | Bound on one startup's adoption, enumeration, and session reconstruction. Accepts `1s`-`5m`. Out-of-range or unparseable values fail startup from either source; they are never replaced by the default. |
| `agentctl.recoveryReadTimeout` | `KANDEV_ACP_RECOVERY_READ_TIMEOUT` | `2s` | Per-attempt read timeout during recovery. Accepts `100ms`-`1h`, but the combined budget below caps the usable value at the lesser of 6s and one fifth of `agentctl.recoveryDeadline`. Out-of-range or unparseable values fail startup. |
| `agentctl.recoveryReadRetries` | `KANDEV_ACP_RECOVERY_READ_RETRIES` | `2` | Retries after the first recovery read attempt. Accepts `0`-`10`. Startup also fails when `recoveryReadTimeout * (retries + 1)` exceeds the lesser of 6s and one fifth of `recoveryDeadline`. |
| `agentctl.unownedPeriod` | `KANDEV_ACP_UNOWNED_PERIOD` | `10m` | Time a standalone control server tolerates having no backend renew ownership before shutting itself down. Effective value is raised to a one-minute floor and, when `agentctl.idleTimeout` is enabled and not longer, clamped to half of it. Invalid or negative environment values use the default. |
| `agentctl.detachedEventLimit` | `KANDEV_ACP_DETACHED_EVENT_LIMIT` | `100` | Agent events retained per instance while no backend is attached. Accepts `1`-`10000`. Out-of-range or unparseable values fail startup. |
| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | `300000` | Non-negative milliseconds for same-author plan revision coalescing; invalid/negative uses five minutes. |
| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | `5000` | Positive integer safety-net interval for queued/retry run claiming. New-run signals are event-driven. |
| `office.maxConcurrentInstance` | `KANDEV_OFFICE_MAX_CONCURRENT_INSTANCE` | `8` | Maximum concurrent runs for one Office agent instance. Invalid or non-positive values use the default. |
| `office.maxConcurrentWorkspace` | `KANDEV_OFFICE_MAX_CONCURRENT_WORKSPACE` | `4` | Maximum concurrent runs for one workspace. Invalid or non-positive values use the default. |
| `office.workspaceBudgetPerHour` | `KANDEV_OFFICE_WORKSPACE_BUDGET_PER_HOUR` | `120` | Maximum runs charged to one workspace in the rolling hourly budget. Invalid or non-positive values use the default. |
| `office.routineBudgetPerHour` | `KANDEV_OFFICE_ROUTINE_BUDGET_PER_HOUR` | `20` | Maximum runs charged to one routine in the rolling hourly budget. Invalid or non-positive values use the default. |
| `office.promotionAgeMinutes` | `KANDEV_OFFICE_PROMOTION_AGE_MINUTES` | `15` | Age in minutes after which a queued run receives priority promotion. Invalid or non-positive values use the default. |
| `office.maxCausationDepth` | `KANDEV_OFFICE_MAX_CAUSATION_DEPTH` | `8` | Maximum causation depth for a run. An enqueue beyond this depth is refused. Invalid or non-positive values use the default. |
| `office.selfTriggerAllowance` | `KANDEV_OFFICE_SELF_TRIGGER_ALLOWANCE` | `3` | Maximum self-triggered runs for one reason in the rolling hourly window. Invalid or non-positive values use the default. |
| `office.selfTriggerTotalAllowance` | `KANDEV_OFFICE_SELF_TRIGGER_TOTAL_ALLOWANCE` | `8` | Maximum self-triggered runs across all reasons in the rolling hourly window. Invalid or non-positive values use the default. |
| `office.gateFailureThreshold` | `KANDEV_OFFICE_GATE_FAILURE_THRESHOLD` | `3` | Consecutive launch-safety gate failures before the durable alert state is raised. Invalid or non-positive values use the default. |
| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. |
| YAML key | Environment variable | Default | Parsing and effect |
| ------------------------------------ | -------------------------------------------- | -------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | `8` | Positive integer process-wide cap for `gh` subprocesses; invalid/non-positive uses default. |
| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | `12` | Positive integer process-wide cap for `git` subprocesses; invalid/non-positive uses default. |
| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | `8` | Positive integer cap for active and detached task-host language-server leases when continuity is enabled; Kandev evicts the least-recently detached lease or rejects the request if all leases are attached. Invalid/non-positive uses default. |
| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | `10` | Pending messages per session. A non-negative YAML value locks the saved UI capacity; a valid environment value overrides YAML and locks it; a negative environment value means unlimited; malformed environment input falls through to YAML, the saved setting, or default. |
| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | `1h` | Go duration after which idle managed agentctl instances are reaped; `0` disables. Invalid uses default. |
| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | `1m` | Go duration between idle scans. |
| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | `131072` | Per-connection ACP inbound notification capacity. YAML values outside `1024`-`131072` fail startup; invalid or out-of-range environment values use `131072`. |
| `agentctl.recoveryDeadline` | `KANDEV_ACP_RECOVERY_DEADLINE` | `30s` | Bound on one startup's adoption, enumeration, and session reconstruction. Accepts `1s`-`5m`. Out-of-range or unparseable values fail startup from either source; they are never replaced by the default. |
| `agentctl.recoveryReadTimeout` | `KANDEV_ACP_RECOVERY_READ_TIMEOUT` | `2s` | Per-attempt read timeout during recovery. Accepts `100ms`-`1h`, but the combined budget below caps the usable value at the lesser of 6s and one fifth of `agentctl.recoveryDeadline`. Out-of-range or unparseable values fail startup. |
| `agentctl.recoveryReadRetries` | `KANDEV_ACP_RECOVERY_READ_RETRIES` | `2` | Retries after the first recovery read attempt. Accepts `0`-`10`. Startup also fails when `recoveryReadTimeout * (retries + 1)` exceeds the lesser of 6s and one fifth of `recoveryDeadline`. |
| `agentctl.unownedPeriod` | `KANDEV_ACP_UNOWNED_PERIOD` | `10m` | Time a standalone control server tolerates having no backend renew ownership before shutting itself down. Effective value is raised to a one-minute floor and, when `agentctl.idleTimeout` is enabled and not longer, clamped to half of it. Invalid or negative environment values use the default. |
| `agentctl.detachedEventLimit` | `KANDEV_ACP_DETACHED_EVENT_LIMIT` | `100` | Agent events retained per instance while no backend is attached. Accepts `1`-`10000`. Out-of-range or unparseable values fail startup. |
| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | `300000` | Non-negative milliseconds for same-author plan revision coalescing; invalid/negative uses five minutes. |
| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | `5000` | Positive integer safety-net interval for queued/retry run claiming. New-run signals are event-driven. |
| `office.maxConcurrentInstance` | `KANDEV_OFFICE_MAX_CONCURRENT_INSTANCE` | `8` | Maximum concurrent runs for one Office agent instance. Invalid or non-positive values use the default. |
| `office.maxConcurrentWorkspace` | `KANDEV_OFFICE_MAX_CONCURRENT_WORKSPACE` | `4` | Maximum concurrent runs for one workspace. Invalid or non-positive values use the default. |
| `office.workspaceBudgetPerHour` | `KANDEV_OFFICE_WORKSPACE_BUDGET_PER_HOUR` | `120` | Maximum runs charged to one workspace in the rolling hourly budget. Invalid or non-positive values use the default. |
| `office.routineBudgetPerHour` | `KANDEV_OFFICE_ROUTINE_BUDGET_PER_HOUR` | `20` | Maximum runs charged to one routine in the rolling hourly budget. Invalid or non-positive values use the default. |
| `office.promotionAgeMinutes` | `KANDEV_OFFICE_PROMOTION_AGE_MINUTES` | `15` | Age in minutes after which a queued run receives priority promotion. Invalid or non-positive values use the default. |
| `office.maxCausationDepth` | `KANDEV_OFFICE_MAX_CAUSATION_DEPTH` | `8` | Maximum causation depth for a run. An enqueue beyond this depth is refused. Invalid or non-positive values use the default. |
| `office.selfTriggerAllowance` | `KANDEV_OFFICE_SELF_TRIGGER_ALLOWANCE` | `3` | Maximum self-triggered runs for one reason in the rolling hourly window. Invalid or non-positive values use the default. |
| `office.selfTriggerTotalAllowance` | `KANDEV_OFFICE_SELF_TRIGGER_TOTAL_ALLOWANCE` | `8` | Maximum self-triggered runs across all reasons in the rolling hourly window. Invalid or non-positive values use the default. |
| `office.gateFailureThreshold` | `KANDEV_OFFICE_GATE_FAILURE_THRESHOLD` | `3` | Consecutive launch-safety gate failures before the durable alert state is raised. Invalid or non-positive values use the default. |
| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. |
Changing concurrency values trades process pressure against throughput and requires a restart. Under **Settings > Preferences > Task Behavior > Runtime**, an admin can save an install-wide capacity and independently control manual and automatic merging. The merge switches apply live and persist across restarts. `messageQueue.maxPerSession` resolves as environment, YAML, saved setting, then default; a non-negative YAML value or any valid environment value locks only capacity. A negative environment value means unlimited. See [Operations](operations.md#message-queue-settings) for capacity and automatic-fold behavior.
+5 -1
View File
@@ -301,7 +301,11 @@ The status surface separates process startup, the LSP `initialize` request, and
Kandev does not impose an automatic initialization timeout or invent a percentage or ETA. Some valid project imports take several minutes, and LSP has no universal indexing-progress contract. When a server reports standard work-done progress, Kandev shows its title, message, percentage, and concurrent work-item count when available. Those values describe only the work item the server reported; its completion does not guarantee that every cross-file definition or reference is ready. If cross-file navigation is still incomplete, leave the server running while its project model warms up, or stop it explicitly if the wait is unexpected.
Each browser connection owns a language-server process; editors in one browser window share the connection for the same session and language. Kandev allows eight active connections by default; operators can change that startup limit with `KANDEV_LSP_MAX_CONNECTIONS`. A request above that limit is rejected before it can start or resume the task host. Stopping a server, closing its connection, or stopping the task reaps the task-host process tree. If the toolbar says the server is unavailable, distinguish a missing task-host binary from an unsupported executor or the active-connection limit before retrying.
Task-owned browser continuity is controlled by the restart-required `features.lspBrowserContinuity` runtime flag. It is off in shipped profiles. When enabled, each browser window owns an independent lease for the task-host language-server process. Closing a browser window or losing its network connection detaches the window while the lease continues to drain server messages. Reopening the task can reattach to that lease without a second `initialize`; duplicated tabs and other windows receive independent leases. Current diagnostics are cleared on detach and restored only after the editor resends its open files.
With continuity enabled, **Stop** releases that window's lease, and closing the last editor releases it after two minutes. Closing the browser retains a detached lease for up to one hour; reopening it resets that deadline, and an attached editor has no expiry deadline. Expiry releases the language server and lets normal task-host cleanup resume. Archiving or deleting the task starts background runtime cleanup that releases its leases. Stopping the task runtime or Kandev also releases its leases. Kandev allows eight active or detached leases by default; operators can change that startup limit with `KANDEV_LSP_MAX_CONNECTIONS`. If the limit is reached, a new request is rejected while every lease is attached. When a detached lease is available, Kandev evicts the one detached longest. After expiry, eviction, or a backend restart, the next eligible connection starts a fresh server and project analysis must run again. With continuity disabled, closing the browser connection reaps its process as before. If the toolbar says the server is unavailable, distinguish a missing task-host binary from an unsupported executor or the active-lease limit before retrying.
During a temporary browser-to-backend transport failure, the status changes to **Reconnecting** while Kandev attempts to reattach; browser closes `1005` and `1006`, backend restart close `1001`, and backend transport close `4009` do not prove that the language server exited. A confirmed server process exit uses `4006` and shows the server-exited state with **Retry**. Close `4010` means the task runtime stopped, so Kandev ends that lease without reconnecting it.
## Integrated terminal
+1 -1
View File
@@ -85,7 +85,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
| Voice input | Plugin | Not built in. Provided by the [Voice Mode plugin](https://github.com/kdlbs/kandev-plugin-voice): browser speech, an in-browser Whisper model, and an operator-keyed server relay. Selection does not retry another engine after a recognition error. |
| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:<random-port>`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Kubernetes, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. |
| Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. |
| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or the opt-in status surface controlled by **Show status bar**; when that surface is off it falls back to the toolbar. Touch tablet uses the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while Kubernetes, SSH, Sprites, remote Docker, and mobile LSP are not. |
| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or the opt-in status surface controlled by **Show status bar**; when that surface is off it falls back to the toolbar. Touch tablet uses the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while Kubernetes, SSH, Sprites, remote Docker, and mobile LSP are not. Task-owned browser continuity is controlled by the restart-required `features.lspBrowserContinuity` toggle and is off in shipped profiles. When enabled, browser close/reopen can retain and reattach a task-host language-server lease for up to one hour while detached; explicit Stop, two minutes without an open editor, detached expiry, capacity eviction, task-runtime stop, or backend shutdown releases it.|
| Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. |
| Prompts, keyboard shortcuts, notifications, appearance, and task actions | Supported | These are normal settings surfaces. Browser notification permission, custom command validity, and local shell/editor availability still apply to their effects. |
| Web UI localization | Limited | **Settings > General > Appearance** offers English, Português (Portugal), 简体中文, 繁體中文(台灣), 繁體中文(香港), and 日本語. The choice applies immediately, persists per browser through a `kandev_locale` cookie, and is negotiated from `Accept-Language` before defaulting to English when no cookie is set. Externalization is complete: every reachable web UI surface renders through the translation layer, and dates, numbers, and relative timestamps follow the selected locale. Per-language completeness still differs. Portuguese tracks English closely; Simplified Chinese is maintained out of band and lags, most visibly in the task workbench. An untranslated key falls back to English rather than failing. About fifteen shared `@kandev/ui` accessibility labels (mostly screen-reader-only text such as a dialog's **Close**) stay English even where the visible UI is translated. Desktop-shell and CLI surfaces, API/log diagnostics, and agent output were never in scope; user content such as task titles, chat, and diffs is never translated. Development and E2E builds list an extra **Pseudo (QA)** locale that production builds do not ship. |
+4
View File
@@ -891,4 +891,8 @@ Routing is an efficiency mechanism, not the access-control boundary. With authen
Dedicated `/terminal/*target` and `/lsp/:sessionId` WebSockets, plus `/vscode/:sessionId/*path` and `/port-proxy/:sessionId/:port/*path` proxies, are separate protocols. They do not use this JSON envelope and should not be sent `/ws` actions.
The browser language-server socket at `/lsp/:sessionId?language=...` carries raw LSP JSON-RPC plus private Kandev control frames. With the restart-required `features.lspBrowserContinuity` flag enabled, a browser close or temporary network loss detaches the window while its task-host lease keeps running for up to one hour. Reopening the task within that hour can reattach to that lease without another `initialize`; each window and duplicated tab has its own lease. **Stop**, two minutes with no open editor, one hour detached, task-runtime shutdown, backend shutdown, or capacity eviction releases a lease. Reattachment cancels the detached deadline; an attached lease does not expire. `KANDEV_LSP_MAX_CONNECTIONS` counts attached and detached leases; at capacity, Kandev evicts the least-recently detached lease, or rejects a new request when all leases are attached. When continuity is disabled, closing the browser socket stops its process as before.
The browser shows **Reconnecting** for an uncertain transport loss and retries attachment. A browser-reported `1005` or `1006`, graceful backend restart close `1001`, or backend transport close `4009` does not confirm that the language-server process exited. Close `4006` means the task-host process exited; the editor clears that generation's providers and diagnostics and offers **Retry**. Close `4010` means the task runtime stopped, so the editor ends the lease without reconnecting it. A backend restart releases leases, so the next connection starts a fresh process and repeats project analysis.
Related guides: [Configuration](configuration.md), [Executors](executors.md), [Git Operations](git-operations.md), [Operations](operations.md), [Workflow Import / Export](workflow-import-export.md), and [Workflow Sync](workflow-sync.md).
@@ -2,7 +2,7 @@
status: active
system: platform
created: 2026-07-09
updated: 2026-08-11
updated: 2026-09-24
owners:
- tbd
---
@@ -29,6 +29,29 @@ Users inspect and edit code inside Kandev task file tabs, but code navigation an
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-001.7:** Wired editor capabilities are diagnostics and the server-advertised completion, hover, go-to-definition, references, signature-help, and semantic-token providers.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-001.8:** Global editor settings select languages that auto-start, languages Kandev may auto-install, and per-language configuration returned through `workspace/configuration`. Saving changed configuration updates the existing server through `workspace/didChangeConfiguration` without waiting for an idle disconnect or process restart.
### REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002: Browser-independent language-server continuity
**Intent:** Closing a browser must not discard a task's live language analysis. A returning editor shall regain useful LSP information without the user restarting the server.
#### Acceptance criteria
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.1:** When a desktop browser tab closes or loses its connection, an enabled language server shall continue running and analyzing that task workspace while its lease is retained. The lease shall keep the task host active across a completed agent turn and idle-reclaim interval, unless the user stops the task.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.2:** When a desktop editor for the same task and language opens again while its lease is retained, it shall attach to the same server, restore providers and reported project progress without a manual Retry or second server start, synchronize its current file contents, and show diagnostics only after the server publishes them for the reopened document.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.3:** When a connected browser briefly loses the LSP transport, the editor shall show a connection state, attempt to reattach, and reserve the server-exited error for an actual server exit. Diagnostics from a previous attachment shall remain hidden until the server analyzes the reopened document.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.4:** An explicit Stop shall release that editor's server. Other concurrently open browser windows for the same task and language shall keep their independent server and editor state. Stopping the task host shall stop all of its retained servers and descendants.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.5:** Retained servers shall count against the configured LSP resource limit. Reattaching to a matching retained lease shall remain possible at the limit. Admission of a new server shall release the least recently detached lease when necessary; if every lease is attached, it shall show the existing capacity state. An evicted lease shall start fresh on a later eligible editor open.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.6:** If the task host or Kandev restarts, the editor shall start a new server when its existing auto-start or manual-enable policy requests one and shall show fresh initialization until that server is ready. It shall not present a lost server's progress or diagnostics as live.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.7:** On phone, opening the file viewer shall not start or attach to a language server in the background. A coarse-pointer tablet editor shall regain the same status and lifecycle action through its existing toolbar drawer.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.8:** When the last editor in a connected browser remains closed for the existing two-minute idle timeout, that browser shall release its lease and free its capacity. Closing the browser or losing the network before the timeout shall retain the lease until its detached deadline. A saved per-language configuration change shall reach a detached server without requiring a restart.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.9:** A returning tab shall attach to its retained lease when the user has not explicitly stopped it, even if auto-start was subsequently disabled. A new tab without a lease hint shall follow current auto-start or browser-local manual-enable policy. A duplicated tab shall not take over the original tab's attached lease.
- **AC-PLATFORM-LSP-FILE-INTELLIGENCE-002.10:** A lease shall expire one hour after its most recent browser detachment if no browser reattaches. Expiry shall stop its language server, release its capacity, and allow normal task-host idle reclaim. An attached lease shall not expire. A later eligible editor open shall start a fresh server and project analysis.
## Out of scope
- Keeping a server running after its task host stops.
- Sharing one live protocol session between concurrently open browser windows.
- Adding LSP controls to the phone file viewer or extending LSP to unsupported executors.
## System design
The migrated technical source is split into [part 1](../system-design/lsp-file-intelligence-01.md), [part 2](../system-design/lsp-file-intelligence-02.md).
@@ -3,8 +3,9 @@ status: draft
system: platform
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-001
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
created: 2026-07-09
updated: 2026-08-11
updated: 2026-09-24
owners:
- tbd
---
@@ -19,6 +20,7 @@ This design preserves the technical source detail for `REQ-PLATFORM-LSP-FILE-INT
| Requirement | Design section |
| --- | --- |
| `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002` | [Browser-independent leases](#browser-independent-leases) |
## Migrated source detail
@@ -68,14 +70,14 @@ Users inspect and edit code inside Kandev task file tabs, but code navigation an
- Completion requests translate Monaco invocation, trigger-character, and incomplete-result context into the corresponding LSP enum values and forward the trigger character when present. A server item without `textEdit` receives Monaco's current-word insertion range; explicit LSP `TextEdit` and `InsertReplaceEdit` ranges remain authoritative.
- Successful Monaco file saves synchronize every matching open language-server document to the newest editor snapshot, then notify servers that requested `textDocument/didSave`. When that live snapshot still matches the persisted snapshot, the persisted text is included only for servers that advertise `includeText`. If editing advanced while persistence was in flight, the newer buffer stays dirty and synchronized and the optional stale save text is omitted so the language server cannot be rewound. Failed saves emit no save notification.
- V1 task-host support is limited to Local PC and local Docker executors. Remote Docker, SSH, and Sprites report an unsupported-executor state.
- Each active browser WebSocket owns one language-server process. The browser shares a connection for the same session and language inside one window and closes it after its idle timeout; separate browser windows may own separate processes.
- The backend caps active LSP WebSocket connections at 8 by default. `KANDEV_LSP_MAX_CONNECTIONS` overrides the cap.
- Each runtime LSP lease owns one language-server process. Editors in one browser window share an attachment for the same session and language; separate concurrently attached browser windows keep separate leases. An intentional editor-idle timeout releases the lease; tab closure and transport loss detach it for at most one hour.
- The backend caps active LSP leases, whether attached or detached, at 8 by default. The existing `KANDEV_LSP_MAX_CONNECTIONS` setting remains the compatible operator override for this resource cap. At capacity, admission of a new process releases the least recently detached lease; attached leases cannot be evicted.
- Language-server processes and npm/Go auto-install commands are owned by the existing agentctl process manager. Instance teardown cancels and drains install work, then reaps full process trees on Unix and Windows before releasing resources.
- During auto-install, one pending task-host WebSocket read cancels the connection-owned installer context if the browser stops or disconnects. After a successful install, that same read becomes the bridge's first inbound frame so readiness handoff does not race or lose an initialize request.
- Agentctl must deliver the bounded `installing` status before acquiring auto-install work. If that write fails or times out, it closes the stream without starting the installer because no live consumer can observe or control the operation.
- During auto-install, the runtime lease retains the task-host WebSocket when the browser detaches. Explicit Stop or task-host teardown cancels installation; the lease consumes the first inbound frame after installation without racing the ready handshake.
- Agentctl must deliver the bounded `installing` status to the backend lease before acquiring auto-install work. If that write fails or times out, it closes the stream without starting the installer.
- Kandev-managed npm and release binaries live under the task host's `~/.kandev/lsp-servers`; `gopls` is installed through the task host's Go toolchain. No managed server cache lives inside a checked-out project.
- LSP JSON-RPC bodies are limited to 16 MiB across stdio and WebSocket transport; stdio headers are bounded separately. Oversized frames close the affected connection instead of allocating unbounded memory.
- Every task-host LSP WebSocket write has a five-second deadline, including installing, installed, failure, ready, close, and bridged JSON-RPC frames. A stalled browser peer cannot retain the stream handler or its owned language-server process indefinitely.
- Every task-host LSP WebSocket write has a five-second deadline, including installing, installed, failure, ready, close, and bridged JSON-RPC frames. The backend lease continuously drains that stream, including while no browser is attached; a stalled browser attachment is detached without blocking the server reader.
- Each browser-to-server stdio frame has a 30-second write cutoff, and stdout-forwarder termination closes stdin immediately. A language server that stops reading cannot leave the bridge handler or its owned process pinned indefinitely.
- Mobile file viewing does not start language servers in the background.
@@ -98,7 +100,7 @@ There is no durable per-task or per-session LSP policy in V1. Manual toolbar sta
`GET /lsp/:sessionId?language=<language>`
The main backend resolves or restores the task execution, checks executor support and global capacity, authenticates to that execution's agentctl instance, and proxies WebSocket frames.
The main backend resolves or restores the task execution, checks executor support and global capacity, and authenticates to that execution's agentctl instance. With browser continuity enabled, the broker owns the upstream and attaches the browser to a lease; with it disabled, the route retains its current WebSocket proxy behavior.
### Task-host stream
@@ -123,10 +125,11 @@ Application close codes are:
| `4002` | Session, execution, or agentctl stream unavailable. |
| `4003` | Auto-install failed. |
| `4004` | Executor unsupported in V1. |
| `4005` | Active LSP connection cap reached. |
| `4006` | Language server exit or unexpected LSP proxy stream failure. |
| `4005` | Active LSP lease cap reached. |
| `4006` | Language server exited unexpectedly. |
| `4007` | Server binary missing and the task host has no supported installer. |
| `4008` | Language-server process failed to start. |
| `4009` | LSP transport or broker connection failed while the server may still run. |
The browser translates categorical close statuses from the close code instead of rendering transport prose. A preceding `install_failed` status payload may retain its actionable task-host diagnostic when the stream then closes with `4003`; without that payload, `4003` uses the localized installation-failure fallback. A JSON-RPC initialization rejection preserves the server-provided `error.message`.
@@ -134,7 +137,7 @@ The browser translates categorical close statuses from the close code instead of
The browser advertises standard LSP `window.workDoneProgress` support and includes a client-generated `workDoneToken` in `initialize`. This lets servers such as JetBrains Kotlin LSP report project-import phases before the initialize response.
The browser accepts both server-created progress tokens through `window/workDoneProgress/create` and `$/progress` notifications for the initialize token. Tokens can be strings or numbers and are scoped to one browser-owned session, language, and connection generation.
The broker accepts both server-created progress tokens through `window/workDoneProgress/create` and `$/progress` notifications for the initialize token. Tokens can be strings or numbers and are scoped to one LSP lease and server generation; the attached browser receives the current snapshot.
Supported work-done payloads are:
@@ -146,7 +149,7 @@ Supported work-done payloads are:
Percentages are clamped to 0–100 for presentation. Unknown tokens, malformed payloads, and late notifications from a replaced connection are ignored.
No backend or task-host payload transforms are required: both WebSocket proxy hops transport the JSON-RPC body unchanged.
The broker preserves LSP method semantics while mapping request IDs, tracking progress and diagnostics, and separating browser attachments from the task-host process lifetime.
## Readiness and progress state
@@ -157,17 +160,41 @@ No backend or task-host payload transforms are required: both WebSocket proxy ho
- Work-done progress is runtime-only activity attached to a live connection. Multiple active tokens are a flat list because LSP defines no parent/child relationship.
- The oldest active work item is the primary summary; additional active items are shown as a count. Percentages from unrelated work items are never averaged.
- The most recently ended item can remain visible as “server-reported work finished” for the lifetime of that connection. It is not described as project-wide success.
- Stop, idle disconnect, crash, socket close, connection replacement, and retry clear all active and completed progress. A replacement generation starts with no inherited work state.
- Explicit Stop, server crash, or task-host teardown clears active and completed progress. Browser detach leaves progress in the lease; reattachment receives its current snapshot. A new server generation starts without stale progress.
- Progress activity is scoped to the current editor's session and language. It is not a global task-wide language-server dashboard.
## Browser-independent leases
The backend lease manager owns the browser-to-agentctl upstream stream. It keys a lease by the task execution, language, and an opaque lease ID; it keeps the upstream open and reads it even when no browser is attached. Agentctl still owns and reaps the actual process. The lease is runtime-only and ends on editor-idle release, one-hour detached expiry, explicit Stop, capacity eviction, server exit, execution replacement or stop, or backend shutdown. Backend restart drops runtime-only leases. This boundary follows [ADR-2026-09-23](../../../decisions/2026-09-23-task-owned-lsp-leases.md).
All three orchestrator paths that call `reclaimIdleSession` (the periodic idle reaper and the agent and streaming event handlers) treat an active lease as live task-runtime work, including when no browser is attached and no agent turn is running. The lease thereby keeps the whole Local PC or Docker task host, including agentctl, container, and worktree runtime resources, active. Lease admission and reclaim use the same session lifecycle fence or a generation-checked equivalent, and reclaim rechecks the lease immediately before cleanup. A new lease cannot be admitted onto an execution already being reclaimed. Explicit task stop still wins and closes all leases for that execution. Editor-idle release, detached expiry, and capacity eviction remove the pin when the lease ends.
The existing browser-facing `/lsp/:sessionId` route authorizes the caller before attachment or lease lookup. The browser includes its lease ID when it has one. The backend reattaches to that detached lease if its execution and language still match; otherwise it may claim the newest detached lease for the same execution and language, or create a new one when admission permits. An attached lease is never stolen from another live browser window, including when a duplicated tab copies its `sessionStorage` hint. In that case the new tab receives a separate lease if admitted. The opaque ID is a routing hint, not an access credential, and is never logged. Browser `sessionStorage` can retain it across a tab reload; a new tab can still claim a detached lease without it when its current auto-start or local manual-enable policy requests LSP. A previously attached tab may resume its hinted lease after auto-start is disabled, unless it explicitly stopped LSP. A lease is counted against `limits.lspMaxConnections` from process admission until teardown, including periods without a browser. Reattachment needs no new capacity slot. Admission under a full cap first releases the oldest detached lease, waits for its upstream/process cleanup, and then admits the replacement; if all leases are attached it returns `4005` without starting a process. An evicted hint becomes a fresh start under the current browser policy.
The backend broker is the stable LSP protocol peer. It retains the first `initialize` result, workspace identity, effective per-language configuration, active work progress, dynamic `client/registerCapability` registrations, and per-URI server-facing document-version counters within a 4 MiB state budget. It applies `client/unregisterCapability` and answers both methods while detached; while attached it also forwards effective registration changes so Monaco providers update immediately. The resumed handshake includes the effective registrations so providers match the server's current capabilities. It answers `workspace/configuration` from the latest persisted user settings, scoped to the lease owner, rather than from a detached browser snapshot. A settings-save event updates affected leases and sends `workspace/didChangeConfiguration` even while detached; reattachment also reconciles the latest settings revision before reporting ready. This preserves AC .8 without keeping a browser connection open.
Pending browser-to-server requests are independently bounded to 256 requests, 256 bytes per JSON-RPC ID, and 64 KiB of tracked request state per lease. The broker cancels and returns a timeout error after 30 seconds; detach cancels requests from that browser generation. Request admission stops before forwarding when any bound is exceeded.
The broker continues reading server output while detached, answers supported server-to-client requests, and gives unsupported requests a JSON-RPC method error so they cannot hang indefinitely. It assigns server-facing request IDs and maps them to the current browser attachment generation. Browser `$/cancelRequest` maps to the corresponding server-facing ID; on detach it cancels all that generation's pending browser requests toward the server and rejects late responses. Replies to a detached generation are discarded. A browser close or write timeout detaches that generation without closing the upstream. Explicit Stop or idle release uses an acknowledged broker control frame: the broker rejects outstanding browser requests, sends LSP `shutdown`, waits within a bounded deadline, sends `exit`, then closes the upstream and waits for agentctl process cleanup. The UI cannot report Stop complete before acknowledgment. An unexpected agentctl process exit sends an explicit upstream `4006`; an upstream read failure without confirmed process exit maps to transport `4009`. Invalid WebSocket reserved close codes such as `1005` and `1006` are normalized before forwarding.
On detach, the broker sends `textDocument/didClose` for each open document, clears all diagnostic state, and drops later diagnostic publications for closed documents. It retains no document text. On reattachment, the ready handshake identifies the retained lease and supplies server capabilities, effective dynamic registrations, workspace metadata, and current progress. The browser rebuilds Monaco providers without sending a second LSP `initialize`. It reopens current documents with their current text and sends attachment-ready after handlers are registered. The broker assigns each `didOpen` and `didChange` a strictly increasing server-facing version for that URI over the lease lifetime, independent of browser-local versions, and acknowledges synchronization before the UI reports `ready`. On a resumed lease it forwards `publishDiagnostics` only for a currently open URI after synchronization and only when the publication's version equals the current server-facing version. Unversioned publications cannot be bound to the reopened text and remain hidden for that URI until a fresh server generation; initial connections may still display unversioned diagnostics. Old markers remain clear until a qualifying new publication. Two simultaneously attached windows retain separate leases, request maps, documents, and progress, preserving their current independent unsaved-buffer behavior.
`features.lspBrowserContinuity` (`KANDEV_FEATURES_LSP_BROWSER_CONTINUITY`) is a restart-required runtime release toggle, off in prod, dev, and e2e shipped profiles. The backend gates lease manager construction and the browser-facing WebSocket path; when disabled, the existing browser-owned proxy and idle-reclaim behavior remain unchanged. The frontend uses the matching boot feature value to keep its existing LSP client behavior when disabled. An enabled test installation or E2E fixture turns the flag on explicitly.
The browser manager uses bounded reconnect attempts after unexpected transport loss. During those attempts it shows a localized reconnecting state with no stale markers. A lost or expired lease starts a fresh server under the existing auto-start or manual-enable preference and clears the previous generation's progress. A confirmed server process exit uses `4006` and the server-exited state; a broker or proxy transport failure uses `4009` or the browser's abnormal-close signal and offers Retry after automatic attempts fail. The toolbar, fine-pointer status bar, and coarse-pointer tablet drawer share this state. The phone viewer neither creates nor attaches to a lease.
Each detachment arms a one-hour lease deadline independent of LSP progress. Reattachment cancels the deadline before the new attachment becomes visible; a later detachment starts a full new hour. The deadline callback checks the original detachment time and detached state under the lease lock used by attach and termination, so a stale callback cannot close a reattached lease. Expiry closes the upstream and releases its capacity with reason `detached_timeout`; idle reclaim can then release the task host. Task archive and delete continue to stop the runtime through their background cleanup, without waiting for this deadline. Backend shutdown cancels pending deadlines.
The broker logs lease creation, attachment, detachment, reattachment, Stop, idle release, detached expiry, capacity eviction, process exit, and teardown reason with bounded metadata. Dev expvar exposes `lsp_lease_active` and `lsp_lease_detached` gauges, `lsp_lease_evicted_total`, and `lsp_lease_released_total` with a closed `reason` set (`stop`, `editor_idle`, `detached_timeout`, `capacity`, `server_exit`, `runtime_stop`, `backend_stop`). No metric uses task, session, browser, lease, or language IDs as labels. Capacity and active/detached counts are observable without IDs. It releases capacity exactly once on every terminal path and never lets a slow browser block task-host stdout draining.
## State and persistence
- User settings persist in the existing user-settings store.
- Manual enablement persists only in browser local storage under the session and language.
- Processes, open documents, diagnostics, and semantic-token caches are runtime-only.
- Processes, open documents, capabilities, and progress are runtime-only. The backend lease retains bounded metadata but discards diagnostics on detach; no new durable task or user record is created.
- Save notifications are runtime-only and follow confirmed workspace persistence; they are not emitted for closed documents, failed writes, or servers that did not request them.
- A missing server starts only when a supported file is opened and auto-start or a toolbar action requests it.
- Closing the browser connection stops its process; stopping the task reaps every owned language-server process even if a browser connection remains open.
- Closing a browser attachment leaves its server lease running for up to one hour. Explicit Stop, intentional editor-idle timeout, and detached expiry release it; capacity admission may evict a detached lease sooner. Stopping the task or its runtime reaps every owned language-server process even if a browser connection remains open.
## Failure modes
@@ -183,7 +210,7 @@ No backend or task-host payload transforms are required: both WebSocket proxy ho
- **Cold Monaco initialization:** TypeScript built-ins are wrapped before model-scoped LSP suppression is registered; the LSP provider registration guard does not depend on suppression state.
- **Capacity exceeded:** the UI reports that too many language servers are active; the backend rejects the request before starting or resuming its supported task host.
- **Process start failure:** agentctl logs the task-host execution error, closes with categorical `4008` and no transport prose, and the UI shows a localized start-failure status with Retry.
- **Server crash:** the connection closes with categorical `4006`, Monaco providers and markers are cleaned up, and the status shows the localized server-exited message with a Retry action. Only intentional stop or idle teardown returns to Off.
- **Server crash:** the lease ends with categorical `4006`, Monaco providers and markers are cleaned up, and the status shows the localized server-exited message with a Retry action. Only intentional Stop returns to Off.
- **Initialize rejection:** when the server rejects the JSON-RPC `initialize` request, the UI preserves its `error.message` instead of stringifying the error object as `[object Object]`.
- **No progress support or reports:** initialization still shows an indeterminate state and elapsed time; after initialize succeeds, the status surface says the server has not reported background analysis progress.
- **Initialize response is slow or never arrives:** the UI confirms that the process launched, changes to a long-running initialization warning after 60 seconds, and keeps Stop available. Kandev does not automatically kill a cold project import or claim that the server is indexing.
@@ -192,8 +219,8 @@ No backend or task-host payload transforms are required: both WebSocket proxy ho
- **Cross-file intelligence remains incomplete after ready:** the UI does not claim that the server is still indexing unless a work item is active; the status surface explains that project import, dependencies, or module resolution may require investigation.
- **Task stop:** agentctl closes process admission and reaps the language-server process tree before releasing task resources.
- **Instance teardown during auto-install:** agentctl cancels the install, removes an unpublished partial release download, drains the shared cache mutation, and reaps npm/Go descendants before releasing task resources.
- **Browser disconnect during auto-install:** the task-host read watcher cancels and drains that connection's owned install instead of allowing npm, Go, or release downloads to continue without a consumer.
- **Unread install status:** if the browser cannot receive the initial `installing` frame, agentctl closes the connection and returns before starting npm, Go, or release install work.
- **Stalled browser peer:** bounded task-host WebSocket writes fail and enter the existing connection cleanup path, including stopping a language-server process that was started before its ready frame could be delivered.
- **Browser disconnect during auto-install:** the lease continues a requested installation after browser detach. Explicit Stop or runtime teardown cancels and drains it.
- **Unread install status:** if the backend lease cannot receive the initial `installing` frame, agentctl closes the stream before starting npm, Go, or release install work.
- **Stalled browser peer:** bounded writes detach that browser without blocking task-host output or terminating the language-server process.
- **Stalled language-server stdin:** stdout-forwarder termination releases an active stdin write immediately; otherwise the write cutoff closes stdin and enters owned-process cleanup after 30 seconds.
- **Unknown language:** no LSP control is shown.
@@ -3,8 +3,9 @@ status: draft
system: platform
requirements:
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-001
- REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002
created: 2026-07-09
updated: 2026-08-11
updated: 2026-09-24
owners:
- tbd
---
@@ -19,6 +20,7 @@ This design preserves the technical source detail for `REQ-PLATFORM-LSP-FILE-INT
| Requirement | Design section |
| --- | --- |
| `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-PLATFORM-LSP-FILE-INTELLIGENCE-002` | [Continuity scenarios](#continuity-scenarios) |
## Migrated source detail
@@ -50,7 +52,7 @@ This design preserves the technical source detail for `REQ-PLATFORM-LSP-FILE-INT
- **GIVEN** an open Monaco document has a debounced content change and its server requests save synchronization, **WHEN** Kandev successfully persists the current editor snapshot, **THEN** the server receives the final `textDocument/didChange` before `textDocument/didSave` for its canonical task-host URI and receives that persisted snapshot on the save notification only when it requests `includeText`; a rejected write sends no save notification.
- **GIVEN** the user types again while a file save is in flight, **WHEN** the older snapshot finishes persisting, **THEN** the newer editor snapshot remains dirty and is the language server's current document, while `textDocument/didSave` omits the stale optional text instead of rewinding the document.
- **GIVEN** an SSH, Sprites, or remote-Docker task, **WHEN** a user starts LSP, **THEN** the UI reports an unsupported executor and no language-server or task execution is started or resumed for that request.
- **GIVEN** the configured connection cap is reached, **WHEN** another editor starts LSP for a stopped supported task, **THEN** the new connection closes with `4005` before Kandev starts or resumes that task host.
- **GIVEN** the configured lease cap is reached and every lease is attached, **WHEN** another editor needs a new server, **THEN** the request closes with `4005` before Kandev starts or resumes that task host; an editor reattaching to its retained lease remains admissible.
- **GIVEN** a discovered language-server executable cannot be launched, **WHEN** agentctl starts it, **THEN** the task-host error stays in logs while the browser receives `4008` with no reason and shows the localized start-failure status.
- **GIVEN** a language server rejects `initialize` with a JSON-RPC error object, **WHEN** the browser handles that response, **THEN** the error state shows the server's `error.message` rather than `[object Object]`.
- **GIVEN** two task/session connections have active providers, placeholder models, or diagnostics, **WHEN** one connection stops or crashes, **THEN** cleanup removes only that connection's state and leaves the other connection fully functional.
@@ -83,17 +85,35 @@ This design preserves the technical source detail for `REQ-PLATFORM-LSP-FILE-INT
- **GIVEN** a repository contains `.kandev/lsp-servers/kotlin-lsp`, **WHEN** Kotlin LSP starts, **THEN** Kandev ignores that project-controlled executable.
- **GIVEN** a mobile viewport, **WHEN** a supported file opens, **THEN** the mobile viewer does not start an LSP process invisibly.
## Continuity scenarios
- **GIVEN** a ready Go LSP lease, **WHEN** its browser tab closes, **THEN** the backend detaches the browser, continues draining the task-host stream, and does not interrupt `gopls`.
- **GIVEN** that detached lease, **WHEN** an eligible desktop editor for the same task and language opens, **THEN** it reuses the same process, registers providers from retained and dynamic capabilities, synchronizes its current document, reaches ready with current progress, and receives diagnostics only from a qualifying new publication for that document.
- **GIVEN** an editor has an unsaved buffer when its tab closes, **WHEN** it reattaches with the persisted file contents, **THEN** prior diagnostics for the unsaved buffer stay hidden until the server analyzes the reopened contents.
- **GIVEN** two browser windows already have independent LSP leases, **WHEN** one closes or explicitly stops, **THEN** the other retains its process, requests, providers, progress, and diagnostics.
- **GIVEN** a temporary WebSocket failure, **WHEN** the browser remains on the editor, **THEN** it shows reconnecting and reattaches without representing the failure as a language-server exit; a true process exit uses `4006` and Retry.
- **GIVEN** the last editor in a connected tab remains closed for two minutes, **WHEN** its idle timer fires, **THEN** it explicitly releases the lease; tab close or network loss before that timer detaches without release.
- **GIVEN** a browser has remained detached for one hour, **WHEN** its lease deadline passes, **THEN** the backend releases the lease and permits task-host idle reclaim; reattachment before the deadline cancels it, and a later eligible editor open starts fresh analysis.
- **GIVEN** all LSP lease slots are occupied and at least one lease is detached, **WHEN** a new editor starts another language server, **THEN** the oldest detached lease is released and the new server is admitted; if every lease is attached, the editor gets `4005`.
- **GIVEN** a returning tab has a valid lease hint, **WHEN** auto-start is later disabled for that language, **THEN** the tab still resumes its existing lease unless it explicitly stopped LSP; a new tab without a hint follows current auto-start or local manual-enable policy.
- **GIVEN** a duplicated tab copied `sessionStorage`, **WHEN** it opens the same file while the original tab's lease is attached, **THEN** the original connection remains attached and the duplicate gets a separate lease or a capacity state.
- **GIVEN** a server dynamically registers or unregisters a capability while its browser is detached, **WHEN** the editor reattaches, **THEN** the effective registration set is restored without a second `initialize`.
- **GIVEN** per-language settings change while a browser is detached, **WHEN** the save completes, **THEN** the retained server receives `workspace/didChangeConfiguration` and later `workspace/configuration` responses use the new value.
- **GIVEN** a request is pending at detach or explicit Stop, **WHEN** the browser generation ends, **THEN** the broker cancels its server-facing request and ignores its late response; a browser `$/cancelRequest` is translated to the mapped server ID.
- **GIVEN** the task host or backend has stopped, **WHEN** the editor opens again, **THEN** no stale lease snapshot is shown as live and existing auto-start or manual enablement starts a new process when allowed.
- **GIVEN** a phone file viewer, **WHEN** it opens a task with a detached LSP lease, **THEN** it does not attach or start an LSP process. The coarse-pointer tablet editor uses its existing toolbar drawer after reattachment.
## Out of scope
- Remote Docker, SSH, and Sprites executor support.
- Durable per-task/session enablement and deny lists.
- Sharing one server process across browser windows.
- Sharing one server process between concurrently attached browser windows.
- Rename, code actions, document symbols, formatting, and workspace-edit application.
- CodeMirror/mobile LSP parity.
- A global dashboard across every session/language connection; the application status-bar item represents only the active Monaco file.
- Estimated time remaining, predicted completion, or any guarantee that a percentage maps linearly to project readiness.
- Inferring actual indexing state, percentage, or completion from `window/logMessage`, `window/showMessage`, process output, elapsed time, or language-specific text heuristics. Elapsed time is used only to disclose that initialization is long-running.
- Request-scoped partial-result streaming, `partialResultToken`, `$/cancelRequest`, and progress cancellation.
- Request-scoped partial-result streaming, `partialResultToken`, and progress cancellation. Browser `$/cancelRequest` forwarding for mapped requests is in scope.
- Bootstrapping project dependencies such as Gradle import, `npm install`, `go mod download`, or Python virtual environments.
- Replacing external editors or embedded VS Code.