/ services / lsp / manager.ts
manager.ts
  1  import { logForDebugging } from '../../utils/debug.js'
  2  import { isBareMode } from '../../utils/envUtils.js'
  3  import { errorMessage } from '../../utils/errors.js'
  4  import { logError } from '../../utils/log.js'
  5  import {
  6    createLSPServerManager,
  7    type LSPServerManager,
  8  } from './LSPServerManager.js'
  9  import { registerLSPNotificationHandlers } from './passiveFeedback.js'
 10  
 11  /**
 12   * Initialization state of the LSP server manager
 13   */
 14  type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'
 15  
 16  /**
 17   * Global singleton instance of the LSP server manager.
 18   * Initialized during Claude Code startup.
 19   */
 20  let lspManagerInstance: LSPServerManager | undefined
 21  
 22  /**
 23   * Current initialization state
 24   */
 25  let initializationState: InitializationState = 'not-started'
 26  
 27  /**
 28   * Error from last initialization attempt, if any
 29   */
 30  let initializationError: Error | undefined
 31  
 32  /**
 33   * Generation counter to prevent stale initialization promises from updating state
 34   */
 35  let initializationGeneration = 0
 36  
 37  /**
 38   * Promise that resolves when initialization completes (success or failure)
 39   */
 40  let initializationPromise: Promise<void> | undefined
 41  
 42  /**
 43   * Test-only sync reset. shutdownLspServerManager() is async and tears down
 44   * real connections; this only clears the module-scope singleton state so
 45   * reinitializeLspServerManager() early-returns on 'not-started' in downstream
 46   * tests on the same shard.
 47   */
 48  export function _resetLspManagerForTesting(): void {
 49    initializationState = 'not-started'
 50    initializationError = undefined
 51    initializationPromise = undefined
 52    initializationGeneration++
 53  }
 54  
 55  /**
 56   * Get the singleton LSP server manager instance.
 57   * Returns undefined if not yet initialized, initialization failed, or still pending.
 58   *
 59   * Callers should check for undefined and handle gracefully, as initialization happens
 60   * asynchronously during Claude Code startup. Use getInitializationStatus() to
 61   * distinguish between pending, failed, and not-started states.
 62   */
 63  export function getLspServerManager(): LSPServerManager | undefined {
 64    // Don't return a broken instance if initialization failed
 65    if (initializationState === 'failed') {
 66      return undefined
 67    }
 68    return lspManagerInstance
 69  }
 70  
 71  /**
 72   * Get the current initialization status of the LSP server manager.
 73   *
 74   * @returns Status object with current state and error (if failed)
 75   */
 76  export function getInitializationStatus():
 77    | { status: 'not-started' }
 78    | { status: 'pending' }
 79    | { status: 'success' }
 80    | { status: 'failed'; error: Error } {
 81    if (initializationState === 'failed') {
 82      return {
 83        status: 'failed',
 84        error: initializationError || new Error('Initialization failed'),
 85      }
 86    }
 87    if (initializationState === 'not-started') {
 88      return { status: 'not-started' }
 89    }
 90    if (initializationState === 'pending') {
 91      return { status: 'pending' }
 92    }
 93    return { status: 'success' }
 94  }
 95  
 96  /**
 97   * Check whether at least one language server is connected and healthy.
 98   * Backs LSPTool.isEnabled().
 99   */
100  export function isLspConnected(): boolean {
101    if (initializationState === 'failed') return false
102    const manager = getLspServerManager()
103    if (!manager) return false
104    const servers = manager.getAllServers()
105    if (servers.size === 0) return false
106    for (const server of servers.values()) {
107      if (server.state !== 'error') return true
108    }
109    return false
110  }
111  
112  /**
113   * Wait for LSP server manager initialization to complete.
114   *
115   * Returns immediately if initialization has already completed (success or failure).
116   * If initialization is pending, waits for it to complete.
117   * If initialization hasn't started, returns immediately.
118   *
119   * @returns Promise that resolves when initialization is complete
120   */
121  export async function waitForInitialization(): Promise<void> {
122    // If already initialized or failed, return immediately
123    if (initializationState === 'success' || initializationState === 'failed') {
124      return
125    }
126  
127    // If pending and we have a promise, wait for it
128    if (initializationState === 'pending' && initializationPromise) {
129      await initializationPromise
130    }
131  
132    // If not started, return immediately (nothing to wait for)
133  }
134  
135  /**
136   * Initialize the LSP server manager singleton.
137   *
138   * This function is called during Claude Code startup. It synchronously creates
139   * the manager instance, then starts async initialization (loading LSP configs)
140   * in the background without blocking the startup process.
141   *
142   * Safe to call multiple times - will only initialize once (idempotent).
143   * However, if initialization previously failed, calling again will retry.
144   */
145  export function initializeLspServerManager(): void {
146    // --bare / SIMPLE: no LSP. LSP is for editor integration (diagnostics,
147    // hover, go-to-def in the REPL). Scripted -p calls have no use for it.
148    if (isBareMode()) {
149      return
150    }
151    logForDebugging('[LSP MANAGER] initializeLspServerManager() called')
152  
153    // Skip if already initialized or currently initializing
154    if (lspManagerInstance !== undefined && initializationState !== 'failed') {
155      logForDebugging(
156        '[LSP MANAGER] Already initialized or initializing, skipping',
157      )
158      return
159    }
160  
161    // Reset state for retry if previous initialization failed
162    if (initializationState === 'failed') {
163      lspManagerInstance = undefined
164      initializationError = undefined
165    }
166  
167    // Create the manager instance and mark as pending
168    lspManagerInstance = createLSPServerManager()
169    initializationState = 'pending'
170    logForDebugging('[LSP MANAGER] Created manager instance, state=pending')
171  
172    // Increment generation to invalidate any pending initializations
173    const currentGeneration = ++initializationGeneration
174    logForDebugging(
175      `[LSP MANAGER] Starting async initialization (generation ${currentGeneration})`,
176    )
177  
178    // Start initialization asynchronously without blocking
179    // Store the promise so callers can await it via waitForInitialization()
180    initializationPromise = lspManagerInstance
181      .initialize()
182      .then(() => {
183        // Only update state if this is still the current initialization
184        if (currentGeneration === initializationGeneration) {
185          initializationState = 'success'
186          logForDebugging('LSP server manager initialized successfully')
187  
188          // Register passive notification handlers for diagnostics
189          if (lspManagerInstance) {
190            registerLSPNotificationHandlers(lspManagerInstance)
191          }
192        }
193      })
194      .catch((error: unknown) => {
195        // Only update state if this is still the current initialization
196        if (currentGeneration === initializationGeneration) {
197          initializationState = 'failed'
198          initializationError = error as Error
199          // Clear the instance since it's not usable
200          lspManagerInstance = undefined
201  
202          logError(error as Error)
203          logForDebugging(
204            `Failed to initialize LSP server manager: ${errorMessage(error)}`,
205          )
206        }
207      })
208  }
209  
210  /**
211   * Force re-initialization of the LSP server manager, even after a prior
212   * successful init. Called from refreshActivePlugins() after plugin caches
213   * are cleared, so newly-loaded plugin LSP servers are picked up.
214   *
215   * Fixes https://github.com/anthropics/claude-code/issues/15521:
216   * loadAllPlugins() is memoized and can be called very early in startup
217   * (via getCommands prefetch in setup.ts) before marketplaces are reconciled,
218   * caching an empty plugin list. initializeLspServerManager() then reads that
219   * stale memoized result and initializes with 0 servers. Unlike commands/agents/
220   * hooks/MCP, LSP was never re-initialized on plugin refresh.
221   *
222   * Safe to call when no LSP plugins changed: initialize() is just config
223   * parsing (servers are lazy-started on first use). Also safe during pending
224   * init: the generation counter invalidates the in-flight promise.
225   */
226  export function reinitializeLspServerManager(): void {
227    if (initializationState === 'not-started') {
228      // initializeLspServerManager() was never called (e.g. headless subcommand
229      // path). Don't start it now.
230      return
231    }
232  
233    logForDebugging('[LSP MANAGER] reinitializeLspServerManager() called')
234  
235    // Best-effort shutdown of any running servers on the old instance so
236    // /reload-plugins doesn't leak child processes. Fire-and-forget: the
237    // primary use case (issue #15521) has 0 servers so this is usually a no-op.
238    if (lspManagerInstance) {
239      void lspManagerInstance.shutdown().catch(err => {
240        logForDebugging(
241          `[LSP MANAGER] old instance shutdown during reinit failed: ${errorMessage(err)}`,
242        )
243      })
244    }
245  
246    // Force the idempotence check in initializeLspServerManager() to fall
247    // through. Generation counter handles invalidating any in-flight init.
248    lspManagerInstance = undefined
249    initializationState = 'not-started'
250    initializationError = undefined
251  
252    initializeLspServerManager()
253  }
254  
255  /**
256   * Shutdown the LSP server manager and clean up resources.
257   *
258   * This should be called during Claude Code shutdown. Stops all running LSP servers
259   * and clears internal state. Safe to call when not initialized (no-op).
260   *
261   * NOTE: Errors during shutdown are logged for monitoring but NOT propagated to the caller.
262   * State is always cleared even if shutdown fails, to prevent resource accumulation.
263   * This is acceptable during application exit when recovery is not possible.
264   *
265   * @returns Promise that resolves when shutdown completes (errors are swallowed)
266   */
267  export async function shutdownLspServerManager(): Promise<void> {
268    if (lspManagerInstance === undefined) {
269      return
270    }
271  
272    try {
273      await lspManagerInstance.shutdown()
274      logForDebugging('LSP server manager shut down successfully')
275    } catch (error: unknown) {
276      logError(error as Error)
277      logForDebugging(
278        `Failed to shutdown LSP server manager: ${errorMessage(error)}`,
279      )
280    } finally {
281      // Always clear state even if shutdown failed
282      lspManagerInstance = undefined
283      initializationState = 'not-started'
284      initializationError = undefined
285      initializationPromise = undefined
286      // Increment generation to invalidate any pending initializations
287      initializationGeneration++
288    }
289  }