diff --git a/.claude/TYPESCRIPT_LSP_SETUP.md b/.claude/TYPESCRIPT_LSP_SETUP.md new file mode 100644 index 00000000..866d4fd7 --- /dev/null +++ b/.claude/TYPESCRIPT_LSP_SETUP.md @@ -0,0 +1,366 @@ +# Claude Code TypeScript LSP 配置指南 + +> 更新日期: 2026-01-20 +> 适用版本: Claude Code v2.0.74+ + +--- + +## 目录 + +1. [方式一:插件市场(推荐)](#方式一插件市场推荐) +2. [方式二:MCP Server (cclsp)](#方式二mcp-server-cclsp) +3. [方式三:内置LSP工具](#方式三内置lsp工具) +4. [配置验证](#配置验证) +5. [故障排查](#故障排查) + +--- + +## 方式一:插件市场(推荐) + +### 步骤 1: 添加插件市场 + +在Claude Code中执行: + +```bash +/plugin marketplace add boostvolt/claude-code-lsps +``` + +### 步骤 2: 安装TypeScript LSP插件 + +```bash +# TypeScript/JavaScript支持(推荐vtsls) +/plugin install vtsls@claude-code-lsps +``` + +### 步骤 3: 验证安装 + +```bash +/plugin list +``` + +应该看到: +``` +✓ vtsls@claude-code-lsps (enabled) +✓ pyright-lsp@claude-plugins-official (enabled) +``` + +### 配置文件自动更新 + +安装后,`~/.claude/settings.json` 会自动添加: + +```json +{ + "enabledPlugins": { + "pyright-lsp@claude-plugins-official": true, + "vtsls@claude-code-lsps": true + } +} +``` + +### 支持的操作 + +- `goToDefinition` - 跳转到定义 +- `findReferences` - 查找引用 +- `hover` - 显示类型信息 +- `documentSymbol` - 文档符号 +- `getDiagnostics` - 诊断信息 + +--- + +## 方式二:MCP Server (cclsp) + +### 优势 + +- **位置容错**:自动修正AI生成的不精确行号 +- **更多功能**:支持重命名、完整诊断 +- **灵活配置**:完全自定义LSP服务器 + +### 安装步骤 + +#### 1. 安装TypeScript Language Server + +```bash +npm install -g typescript-language-server typescript +``` + +验证安装: +```bash +typescript-language-server --version +``` + +#### 2. 配置cclsp + +运行自动配置: +```bash +npx cclsp@latest setup --user +``` + +或手动创建配置文件: + +**文件位置**: `~/.claude/cclsp.json` 或 `~/.config/claude/cclsp.json` + +```json +{ + "servers": [ + { + "extensions": ["ts", "tsx", "js", "jsx"], + "command": ["typescript-language-server", "--stdio"], + "rootDir": ".", + "restartInterval": 5, + "initializationOptions": { + "preferences": { + "includeInlayParameterNameHints": "all", + "includeInlayPropertyDeclarationTypeHints": true, + "includeInlayFunctionParameterTypeHints": true, + "includeInlayVariableTypeHints": true + } + } + }, + { + "extensions": ["py", "pyi"], + "command": ["pylsp"], + "rootDir": ".", + "restartInterval": 5 + } + ] +} +``` + +#### 3. 在Claude Code中启用MCP Server + +添加到Claude Code配置: + +```bash +# 查看当前MCP配置 +cat ~/.claude/.mcp.json + +# 如果没有,创建新的 +``` + +**文件**: `~/.claude/.mcp.json` + +```json +{ + "mcpServers": { + "cclsp": { + "command": "npx", + "args": ["cclsp@latest"] + } + } +} +``` + +### cclsp可用的MCP工具 + +使用时,Claude Code会自动调用这些工具: + +- `find_definition` - 按名称查找定义(支持模糊匹配) +- `find_references` - 查找所有引用 +- `rename_symbol` - 重命名符号(带备份) +- `get_diagnostics` - 获取诊断信息 +- `restart_server` - 重启LSP服务器 + +--- + +## 方式三:内置LSP工具 + +### 启用方式 + +设置环境变量: + +**Linux/Mac**: +```bash +export ENABLE_LSP_TOOL=1 +claude +``` + +**Windows (PowerShell)**: +```powershell +$env:ENABLE_LSP_TOOL=1 +claude +``` + +**永久启用** (添加到shell配置): +```bash +# Linux/Mac +echo 'export ENABLE_LSP_TOOL=1' >> ~/.bashrc +source ~/.bashrc + +# Windows (PowerShell Profile) +Add-Content $PROFILE '$env:ENABLE_LSP_TOOL=1' +``` + +### 限制 + +- 需要先安装语言服务器插件(见方式一) +- 不支持重命名等高级操作 +- 无位置容错功能 + +--- + +## 配置验证 + +### 1. 检查LSP服务器是否可用 + +```bash +# 检查TypeScript Language Server +which typescript-language-server # Linux/Mac +where typescript-language-server # Windows + +# 测试运行 +typescript-language-server --stdio +``` + +### 2. 在Claude Code中测试 + +打开任意TypeScript文件,让Claude执行: + +```typescript +// 测试LSP功能 +LSP({ + operation: "hover", + filePath: "path/to/your/file.ts", + line: 10, + character: 5 +}) +``` + +### 3. 检查插件状态 + +```bash +/plugin list +``` + +查看启用的插件: +```bash +cat ~/.claude/settings.json | grep enabledPlugins +``` + +--- + +## 故障排查 + +### 问题 1: "No LSP server available" + +**原因**:TypeScript LSP插件未安装或未启用 + +**解决**: +```bash +# 重新安装插件 +/plugin install vtsls@claude-code-lsps + +# 检查settings.json +cat ~/.claude/settings.json +``` + +### 问题 2: "typescript-language-server: command not found" + +**原因**:未安装TypeScript Language Server + +**解决**: +```bash +npm install -g typescript-language-server typescript + +# 验证 +typescript-language-server --version +``` + +### 问题 3: LSP响应慢或超时 + +**原因**:项目太大或配置不当 + +**解决**: +```json +// 在tsconfig.json中优化 +{ + "compilerOptions": { + "incremental": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "dist"] +} +``` + +### 问题 4: 插件安装失败 + +**原因**:网络问题或插件市场未添加 + +**解决**: +```bash +# 确认插件市场已添加 +/plugin marketplace list + +# 如果没有,重新添加 +/plugin marketplace add boostvolt/claude-code-lsps + +# 重试安装 +/plugin install vtsls@claude-code-lsps +``` + +--- + +## 三种方式对比 + +| 特性 | 插件市场 | cclsp (MCP) | 内置LSP | +|------|----------|-------------|---------| +| 安装复杂度 | ⭐ 低 | ⭐⭐ 中 | ⭐ 低 | +| 功能完整性 | ⭐⭐⭐ 完整 | ⭐⭐⭐ 完整+ | ⭐⭐ 基础 | +| 位置容错 | ❌ 无 | ✅ 有 | ❌ 无 | +| 重命名支持 | ✅ 有 | ✅ 有 | ❌ 无 | +| 自定义配置 | ⚙️ 有限 | ⚙️ 完整 | ❌ 无 | +| 生产稳定性 | ⭐⭐⭐ 高 | ⭐⭐ 中 | ⭐⭐⭐ 高 | + +--- + +## 推荐配置 + +### 新手用户 +**推荐**: 方式一(插件市场) +- 一条命令安装 +- 官方维护,稳定可靠 +- 满足日常使用需求 + +### 高级用户 +**推荐**: 方式二(cclsp) +- 完整功能支持 +- 位置容错(AI友好) +- 灵活配置 +- 支持重命名等高级操作 + +### 快速测试 +**推荐**: 方式三(内置LSP)+ 方式一(插件) +- 设置环境变量 +- 安装插件 +- 立即可用 + +--- + +## 附录:支持的语言 + +通过插件市场可用的LSP: + +| 语言 | 插件名 | 安装命令 | +|------|--------|----------| +| TypeScript/JavaScript | vtsls | `/plugin install vtsls@claude-code-lsps` | +| Python | pyright | `/plugin install pyright@claude-code-lsps` | +| Go | gopls | `/plugin install gopls@claude-code-lsps` | +| Rust | rust-analyzer | `/plugin install rust-analyzer@claude-code-lsps` | +| Java | jdtls | `/plugin install jdtls@claude-code-lsps` | +| C/C++ | clangd | `/plugin install clangd@claude-code-lsps` | +| C# | omnisharp | `/plugin install omnisharp@claude-code-lsps` | +| PHP | intelephense | `/plugin install intelephense@claude-code-lsps` | +| Kotlin | kotlin-ls | `/plugin install kotlin-language-server@claude-code-lsps` | +| Ruby | solargraph | `/plugin install solargraph@claude-code-lsps` | + +--- + +## 相关文档 + +- [Claude Code LSP 文档](https://docs.anthropic.com/claude-code/lsp) +- [cclsp GitHub](https://github.com/ktnyt/cclsp) +- [TypeScript Language Server](https://github.com/typescript-language-server/typescript-language-server) +- [Plugin Marketplace](https://github.com/boostvolt/claude-code-lsps) + +--- + +**配置完成后,重启Claude Code以应用更改** diff --git a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js index 4ffbf29e..2dd715b8 100644 --- a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js +++ b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js @@ -34,11 +34,56 @@ async function syncActiveExecutions() { const { executions } = await response.json(); if (!executions || executions.length === 0) return; - executions.forEach(exec => { - // Skip if already tracked (avoid overwriting live data) - if (cliStreamExecutions[exec.id]) return; + let needsUiUpdate = false; - // Rebuild execution state + executions.forEach(exec => { + const existing = cliStreamExecutions[exec.id]; + + // Parse historical output from server + const historicalLines = []; + if (exec.output) { + const lines = exec.output.split('\n'); + const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1); + lines.slice(startIndex).forEach(line => { + if (line.trim()) { + historicalLines.push({ + type: 'stdout', + content: line, + timestamp: exec.startTime || Date.now() + }); + } + }); + } + + if (existing) { + // Already tracked by WebSocket events - merge historical output + // Only prepend historical lines that are not already in the output + // (WebSocket events only add NEW output, so historical output should come before) + const existingContentSet = new Set(existing.output.map(o => o.content)); + const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content)); + + if (missingLines.length > 0) { + // Find the system start message index (skip it when prepending) + const systemMsgIndex = existing.output.findIndex(o => o.type === 'system'); + const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0; + + // Prepend missing historical lines after system message + existing.output.splice(insertIndex, 0, ...missingLines); + + // Trim if too long + if (existing.output.length > MAX_OUTPUT_LINES) { + existing.output = existing.output.slice(-MAX_OUTPUT_LINES); + } + + needsUiUpdate = true; + console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`); + } + return; + } + + needsUiUpdate = true; + + // New execution - rebuild full state cliStreamExecutions[exec.id] = { tool: exec.tool || 'cli', mode: exec.mode || 'analysis', @@ -55,24 +100,12 @@ async function syncActiveExecutions() { timestamp: exec.startTime }); - // Fill historical output (limit to last MAX_OUTPUT_LINES) - if (exec.output) { - const lines = exec.output.split('\n'); - const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1); - lines.slice(startIndex).forEach(line => { - if (line.trim()) { - cliStreamExecutions[exec.id].output.push({ - type: 'stdout', - content: line, - timestamp: Date.now() - }); - } - }); - } + // Add historical output + cliStreamExecutions[exec.id].output.push(...historicalLines); }); - // Update UI if we recovered any executions - if (executions.length > 0) { + // Update UI if we recovered or merged any executions + if (needsUiUpdate) { // Set active tab to first running execution const runningExec = executions.find(e => e.status === 'running'); if (runningExec && !activeStreamTab) { diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 0bbbd3b7..7a5d560a 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -119,6 +119,7 @@ export class CliHistoryStore { this.db = new Database(this.dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); + this.db.pragma('busy_timeout = 5000'); // Wait up to 5 seconds for locks this.initSchema(); this.migrateFromJson(historyDir); @@ -365,6 +366,41 @@ export class CliHistoryStore { } } + /** + * Execute a database operation with retry logic for SQLITE_BUSY errors + * @param operation - Function to execute + * @param maxRetries - Maximum retry attempts (default: 3) + * @param baseDelay - Base delay in ms for exponential backoff (default: 100) + */ + private withRetry(operation: () => T, maxRetries = 3, baseDelay = 100): T { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return operation(); + } catch (err) { + const error = err as Error; + // Check if it's a SQLITE_BUSY error + if (error.message?.includes('SQLITE_BUSY') || error.message?.includes('database is locked')) { + lastError = error; + if (attempt < maxRetries) { + // Exponential backoff: 100ms, 200ms, 400ms + const delay = baseDelay * Math.pow(2, attempt); + // Sync sleep using Atomics (works in Node.js) + const sharedBuffer = new SharedArrayBuffer(4); + const sharedArray = new Int32Array(sharedBuffer); + Atomics.wait(sharedArray, 0, 0, delay); + } + } else { + // Non-BUSY error, throw immediately + throw error; + } + } + } + + throw lastError || new Error('Operation failed after retries'); + } + /** * Migrate existing JSON files to SQLite */ @@ -522,7 +558,7 @@ export class CliHistoryStore { } }); - transaction(); + this.withRetry(() => transaction()); } /** @@ -795,7 +831,9 @@ export class CliHistoryStore { */ deleteConversation(id: string): { success: boolean; error?: string } { try { - const result = this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id); + const result = this.withRetry(() => + this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id) + ); return { success: result.changes > 0 }; } catch (err) { return { success: false, error: (err as Error).message }; @@ -821,7 +859,7 @@ export class CliHistoryStore { } }); - transaction(); + this.withRetry(() => transaction()); return { success: true, @@ -896,14 +934,14 @@ export class CliHistoryStore { project_hash = @project_hash `); - stmt.run({ + this.withRetry(() => stmt.run({ ccw_id: mapping.ccw_id, tool: mapping.tool, native_session_id: mapping.native_session_id, native_session_path: mapping.native_session_path || null, project_hash: mapping.project_hash || null, created_at: mapping.created_at || new Date().toISOString() - }); + })); } /** @@ -1147,7 +1185,7 @@ export class CliHistoryStore { VALUES (@id, @created_at, @tool, @prompt_count, @patterns, @suggestions, @raw_output, @execution_id, @lang) `); - stmt.run({ + this.withRetry(() => stmt.run({ id: insight.id, created_at: new Date().toISOString(), tool: insight.tool, @@ -1157,7 +1195,7 @@ export class CliHistoryStore { raw_output: insight.rawOutput || null, execution_id: insight.executionId || null, lang: insight.lang || 'en' - }); + })); } /** @@ -1249,7 +1287,7 @@ export class CliHistoryStore { updated_at = @updated_at `); - const result = stmt.run({ + const result = this.withRetry(() => stmt.run({ execution_id: review.execution_id, status: review.status, rating: review.rating ?? null, @@ -1257,7 +1295,7 @@ export class CliHistoryStore { reviewer: review.reviewer ?? null, created_at, updated_at - }); + })); return { id: result.lastInsertRowid as number, diff --git a/codex-lens/src/codexlens/lsp/standalone_manager.py b/codex-lens/src/codexlens/lsp/standalone_manager.py index c1042119..ad27ac87 100644 --- a/codex-lens/src/codexlens/lsp/standalone_manager.py +++ b/codex-lens/src/codexlens/lsp/standalone_manager.py @@ -42,10 +42,10 @@ class ServerConfig: max_restarts: int = 3 -@dataclass +@dataclass class ServerState: """State of a running language server.""" - + config: ServerConfig process: asyncio.subprocess.Process reader: asyncio.StreamReader @@ -55,6 +55,8 @@ class ServerState: capabilities: Dict[str, Any] = field(default_factory=dict) pending_requests: Dict[int, asyncio.Future] = field(default_factory=dict) restart_count: int = 0 + # Queue for producer-consumer pattern - continuous reading puts messages here + message_queue: asyncio.Queue = field(default_factory=asyncio.Queue) class StandaloneLspManager: @@ -253,20 +255,24 @@ class StandaloneLspManager: self._servers[language_id] = state - # Start reading responses in background - self._read_tasks[language_id] = asyncio.create_task( - self._read_responses(language_id) - ) - # Start reading stderr in background (prevents pipe buffer from filling up) if process.stderr: self._stderr_tasks[language_id] = asyncio.create_task( self._read_stderr(language_id, process.stderr) ) - # Initialize the server + # CRITICAL: Start the continuous reader task IMMEDIATELY before any communication + # This ensures no messages are lost during initialization handshake + self._read_tasks[language_id] = asyncio.create_task( + self._continuous_reader(language_id) + ) + + # Start the message processor task to handle queued messages + asyncio.create_task(self._process_messages(language_id)) + + # Initialize the server - now uses queue for reading responses await self._initialize_server(state) - + logger.info(f"{config.display_name} started and initialized") return state @@ -353,49 +359,25 @@ class StandaloneLspManager: return await self._start_server(language_id) async def _initialize_server(self, state: ServerState) -> None: - """Send initialize request to language server.""" + """Send initialize request and wait for response via the message queue. + + The continuous reader and message processor are already running, so we just + send the request and wait for the response via pending_requests. + """ root_uri = self.workspace_root.as_uri() - + + # Simplified params matching direct test that works params = { - "processId": os.getpid(), + "processId": None, # Use None like direct test "rootUri": root_uri, "rootPath": str(self.workspace_root), "capabilities": { "textDocument": { - "synchronization": { - "dynamicRegistration": False, - "willSave": False, - "willSaveWaitUntil": False, - "didSave": True, - }, - "completion": { - "dynamicRegistration": False, - "completionItem": { - "snippetSupport": False, - "documentationFormat": ["plaintext", "markdown"], - }, - }, - "hover": { - "dynamicRegistration": False, - "contentFormat": ["plaintext", "markdown"], - }, - "definition": { - "dynamicRegistration": False, - "linkSupport": False, - }, - "references": { - "dynamicRegistration": False, - }, "documentSymbol": { - "dynamicRegistration": False, "hierarchicalDocumentSymbolSupport": True, }, - "callHierarchy": { - "dynamicRegistration": False, - }, }, "workspace": { - "workspaceFolders": True, "configuration": True, }, }, @@ -405,17 +387,49 @@ class StandaloneLspManager: "name": self.workspace_root.name, } ], - "initializationOptions": state.config.initialization_options, } - - result = await self._send_request(state, "initialize", params) - if result: - state.capabilities = result.get("capabilities", {}) - state.initialized = True + # Send initialize request and wait for response via queue + state.request_id += 1 + init_request_id = state.request_id - # Send initialized notification - await self._send_notification(state, "initialized", {}) + # Create future for the response + future: asyncio.Future = asyncio.get_event_loop().create_future() + state.pending_requests[init_request_id] = future + + # Send the request + init_message = { + "jsonrpc": "2.0", + "id": init_request_id, + "method": "initialize", + "params": params, + } + encoded = self._encode_message(init_message) + logger.debug(f"Sending initialize request id={init_request_id}") + state.writer.write(encoded) + await state.writer.drain() + + # Wait for response (will be routed by _process_messages) + try: + init_result = await asyncio.wait_for(future, timeout=30.0) + except asyncio.TimeoutError: + state.pending_requests.pop(init_request_id, None) + raise RuntimeError("Initialize request timed out") + + if init_result is None: + init_result = {} + + # Store capabilities + state.capabilities = init_result.get("capabilities", {}) + state.initialized = True + logger.debug(f"Initialize response received, capabilities: {len(state.capabilities)} keys") + + # Send initialized notification + await self._send_notification(state, "initialized", {}) + + # Give time for server to process initialized and send any requests + # The message processor will handle workspace/configuration automatically + await asyncio.sleep(0.5) def _encode_message(self, content: Dict[str, Any]) -> bytes: """Encode a JSON-RPC message with LSP headers.""" @@ -465,63 +479,121 @@ class StandaloneLspManager: except Exception as e: logger.error(f"Error reading message: {e}") return None, True - - async def _read_responses(self, language_id: str) -> None: - """Background task to read responses from a language server.""" + + async def _continuous_reader(self, language_id: str) -> None: + """Continuously read messages from language server and put them in the queue. + + This is the PRODUCER in the producer-consumer pattern. It starts IMMEDIATELY + after subprocess creation and runs continuously until shutdown. This ensures + no messages are ever lost, even during initialization handshake. + """ state = self._servers.get(language_id) if not state: return + logger.debug(f"Continuous reader started for {language_id}") + try: while True: - # Yield to allow other tasks to run - await asyncio.sleep(0) + try: + # Read headers with timeout + content_length = 0 + while True: + try: + line = await asyncio.wait_for(state.reader.readline(), timeout=5.0) + except asyncio.TimeoutError: + continue # Keep waiting for data - message, stream_closed = await self._read_message(state.reader) + if not line: + logger.debug(f"Continuous reader for {language_id}: EOF") + return - if stream_closed: - logger.debug(f"Read loop for {language_id}: stream closed") - break + line_str = line.decode("ascii").strip() + if not line_str: + break # End of headers - if message is None: - # Just a timeout, continue waiting - logger.debug(f"Read loop for {language_id}: timeout, continuing...") - continue + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":")[1].strip()) - # Log all incoming messages for debugging - msg_id = message.get("id", "none") - msg_method = message.get("method", "none") - logger.debug(f"Received message: id={msg_id}, method={msg_method}") + if content_length == 0: + continue - # Handle response (has id but no method) - if "id" in message and "method" not in message: - request_id = message["id"] - logger.debug(f"Received response id={request_id}, pending={list(state.pending_requests.keys())}") - if request_id in state.pending_requests: - future = state.pending_requests.pop(request_id) + # Read body + body = await state.reader.readexactly(content_length) + message = json.loads(body.decode("utf-8")) + + # Put message in queue for processing + await state.message_queue.put(message) + + msg_id = message.get("id", "none") + msg_method = message.get("method", "none") + logger.debug(f"Queued message: id={msg_id}, method={msg_method}") + + except asyncio.IncompleteReadError: + logger.debug(f"Continuous reader for {language_id}: IncompleteReadError") + return + except Exception as e: + logger.error(f"Error in continuous reader for {language_id}: {e}") + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + logger.debug(f"Continuous reader cancelled for {language_id}") + except Exception as e: + logger.error(f"Fatal error in continuous reader for {language_id}: {e}") + + async def _process_messages(self, language_id: str) -> None: + """Process messages from the queue and route them appropriately. + + This is the CONSUMER in the producer-consumer pattern. It handles: + - Server requests (workspace/configuration, etc.) - responds immediately + - Notifications (window/logMessage, etc.) - logs them + - Responses to our requests are NOT handled here - they're consumed by _wait_for_response + """ + state = self._servers.get(language_id) + if not state: + return + + logger.debug(f"Message processor started for {language_id}") + + try: + while True: + # Get message from queue (blocks until available) + message = await state.message_queue.get() + + msg_id = message.get("id") + method = message.get("method", "") + + # Response (has id but no method) - put back for _wait_for_response to consume + if msg_id is not None and not method: + # This is a response to one of our requests + if msg_id in state.pending_requests: + future = state.pending_requests.pop(msg_id) if "error" in message: future.set_exception( Exception(message["error"].get("message", "Unknown error")) ) else: future.set_result(message.get("result")) + logger.debug(f"Response routed to pending request id={msg_id}") else: - logger.debug(f"No pending request for id={request_id}") + logger.debug(f"No pending request for response id={msg_id}") - # Handle server request (has both id and method) - needs response - elif "id" in message and "method" in message: - logger.info(f"Server request received: {message.get('method')} with id={message.get('id')}") + # Server request (has both id and method) - needs response + elif msg_id is not None and method: + logger.info(f"Server request: {method} (id={msg_id})") await self._handle_server_request(state, message) - # Handle notification from server (has method but no id) - elif "method" in message: + # Notification (has method but no id) + elif method: self._handle_server_message(language_id, message) - - except asyncio.CancelledError: - pass - except Exception as e: - logger.error(f"Error in read loop for {language_id}: {e}") + state.message_queue.task_done() + + except asyncio.CancelledError: + logger.debug(f"Message processor cancelled for {language_id}") + except Exception as e: + logger.error(f"Error in message processor for {language_id}: {e}") + async def _read_stderr(self, language_id: str, stderr: asyncio.StreamReader) -> None: """Background task to read stderr from a language server. @@ -732,9 +804,9 @@ class StandaloneLspManager: } }) - # Give the language server time to process the file and send any requests - # The read loop running in background will handle workspace/configuration requests - await asyncio.sleep(2.0) + # Give the language server a brief moment to process the file + # The message queue handles any server requests automatically + await asyncio.sleep(0.5) # ========== Public LSP Methods ========== diff --git a/codex-lens/tests/real/debug_compare.py b/codex-lens/tests/real/debug_compare.py new file mode 100644 index 00000000..77f3b022 --- /dev/null +++ b/codex-lens/tests/real/debug_compare.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +"""Compare manager read behavior vs direct read.""" + +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from codexlens.lsp.standalone_manager import StandaloneLspManager + + +async def direct_test(): + """Direct communication - this works.""" + workspace = Path(__file__).parent.parent.parent + print("\n=== DIRECT TEST ===") + + process = await asyncio.create_subprocess_exec( + "pyright-langserver", "--stdio", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + ) + + def encode_message(content): + body = json.dumps(content).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n" + return header.encode("ascii") + body + + async def send(message): + encoded = encode_message(message) + process.stdin.write(encoded) + await process.stdin.drain() + msg_desc = message.get('method') or f"response id={message.get('id')}" + print(f" SENT: {msg_desc}") + + async def read_one(): + content_length = 0 + while True: + line = await asyncio.wait_for(process.stdout.readline(), timeout=3.0) + if not line: + return None + line_str = line.decode("ascii").strip() + if not line_str: + break + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":")[1].strip()) + if content_length == 0: + return None + body = await process.stdout.readexactly(content_length) + return json.loads(body.decode("utf-8")) + + # Initialize + print(" Sending initialize...") + await send({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "processId": None, + "rootUri": workspace.as_uri(), + "capabilities": {"workspace": {"configuration": True}}, + "workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}], + }, + }) + + # Read until response + while True: + msg = await read_one() + if msg and msg.get("id") == 1: + print(f" Initialize response OK") + break + elif msg: + print(f" Notification: {msg.get('method')}") + + # Send initialized + print(" Sending initialized...") + await send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + + # Check for workspace/configuration + print(" Checking for workspace/configuration (3s timeout)...") + try: + for i in range(10): + msg = await read_one() + if msg: + method = msg.get("method") + msg_id = msg.get("id") + print(f" RECV: {method or 'response'} (id={msg_id})") + if method == "workspace/configuration": + print(" SUCCESS: workspace/configuration received!") + break + except asyncio.TimeoutError: + print(" TIMEOUT: No more messages") + + process.terminate() + await process.wait() + + +async def manager_test(): + """Manager communication - investigating why this doesn't work.""" + workspace = Path(__file__).parent.parent.parent + print("\n=== MANAGER TEST ===") + + manager = StandaloneLspManager( + workspace_root=str(workspace), + timeout=60.0 + ) + await manager.start() + + # Just check if server initialized + state = manager._servers.get("python") + if state: + print(f" Server initialized: {state.initialized}") + print(f" Capabilities: {len(state.capabilities)} keys") + else: + # Force initialization by getting server for a Python file + print(" Getting server for Python file...") + test_file = workspace / "tests" / "real" / "debug_compare.py" + state = await manager._get_server(str(test_file)) + if state: + print(f" Server initialized: {state.initialized}") + + # Try to read directly from state.reader + if state: + print("\n Direct read test from state.reader:") + print(f" state.reader is: {type(state.reader)}") + print(f" state.reader at_eof: {state.reader.at_eof()}") + + # Check if there's data available + try: + line = await asyncio.wait_for(state.reader.readline(), timeout=1.0) + if line: + print(f" Got line: {line[:50]}...") + else: + print(f" readline returned empty (EOF)") + except asyncio.TimeoutError: + print(f" readline timed out (no data)") + + await manager.stop() + + +async def main(): + await direct_test() + await manager_test() + print("\n=== DONE ===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/codex-lens/tests/real/debug_config.py b/codex-lens/tests/real/debug_config.py new file mode 100644 index 00000000..90fae268 --- /dev/null +++ b/codex-lens/tests/real/debug_config.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +"""Test if pyright sends workspace/configuration after initialized.""" + +import asyncio +import json +import sys +from pathlib import Path + +# Add source to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + + +async def read_message_direct(reader): + """Read a JSON-RPC message - direct blocking read, no timeout.""" + content_length = 0 + while True: + line = await reader.readline() + if not line: + return None + line_str = line.decode("ascii").strip() + if not line_str: + break + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":")[1].strip()) + + if content_length == 0: + return None + + body = await reader.readexactly(content_length) + return json.loads(body.decode("utf-8")) + + +async def main(): + workspace = Path(__file__).parent.parent.parent + print(f"Workspace: {workspace}") + + # Start pyright - exactly like in direct test + process = await asyncio.create_subprocess_exec( + "pyright-langserver", "--stdio", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + ) + + def encode_message(content): + body = json.dumps(content).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n" + return header.encode("ascii") + body + + async def send(message): + encoded = encode_message(message) + process.stdin.write(encoded) + await process.stdin.drain() + method_or_resp = message.get('method') or f"response id={message.get('id')}" + print(f"SENT: {method_or_resp} ({len(encoded)} bytes)") + + # Start stderr reader + async def read_stderr(): + while True: + line = await process.stderr.readline() + if not line: + break + print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}") + asyncio.create_task(read_stderr()) + + print("\n=== INITIALIZE ===") + await send({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": None, + "rootUri": workspace.as_uri(), + "rootPath": str(workspace), + "capabilities": { + "workspace": {"configuration": True}, + }, + "workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}], + }, + }) + + # Read until we get initialize response + print("Reading initialize response...") + while True: + msg = await asyncio.wait_for(read_message_direct(process.stdout), timeout=10) + if msg is None: + break + method = msg.get("method") + msg_id = msg.get("id") + if method: + print(f"RECV: {method} (notification)") + else: + print(f"RECV: response id={msg_id}") + if msg_id == 1: + print("Initialize OK!") + break + + print("\n=== SEND INITIALIZED ===") + await send({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {}, + }) + + # Now, here's the key test - will we receive workspace/configuration? + print("\n=== WAIT FOR workspace/configuration ===") + print("Reading with 5 second timeout...") + + try: + for i in range(10): + msg = await asyncio.wait_for(read_message_direct(process.stdout), timeout=2) + if msg is None: + print("EOF") + break + method = msg.get("method") + msg_id = msg.get("id") + print(f"RECV: method={method}, id={msg_id}") + + # Respond to server requests + if msg_id is not None and method: + if method == "workspace/configuration": + print(" -> Got workspace/configuration! Responding...") + await send({ + "jsonrpc": "2.0", + "id": msg_id, + "result": [{} for _ in msg.get("params", {}).get("items", [])], + }) + else: + print(f" -> Responding to {method}") + await send({"jsonrpc": "2.0", "id": msg_id, "result": None}) + except asyncio.TimeoutError: + print("No more messages (timeout)") + + print("\n=== Now start background read task like manager does ===") + + # Store references like manager does + reader = process.stdout # This is how manager does it + writer = process.stdin + + # Start background read task + async def bg_read_loop(): + print("[BG] Read loop started") + try: + while True: + await asyncio.sleep(0) + try: + msg = await asyncio.wait_for(read_message_direct(reader), timeout=1.0) + if msg is None: + print("[BG] Stream closed") + break + bg_method = msg.get('method') or f"response id={msg.get('id')}" + print(f"[BG] RECV: {bg_method}") + + # Handle server requests + method = msg.get("method") + msg_id = msg.get("id") + if msg_id is not None and method: + print(f"[BG] Responding to {method}") + await send({"jsonrpc": "2.0", "id": msg_id, "result": None}) + except asyncio.TimeoutError: + print("[BG] timeout") + except asyncio.CancelledError: + print("[BG] Cancelled") + + read_task = asyncio.create_task(bg_read_loop()) + + # Wait a moment + await asyncio.sleep(0.5) + + # Now send didOpen and documentSymbol like manager does + print("\n=== SEND didOpen ===") + test_file = workspace / "tests" / "real" / "debug_config.py" + await send({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": test_file.as_uri(), + "languageId": "python", + "version": 1, + "text": test_file.read_text(), + }, + }, + }) + + # Wait for processing + await asyncio.sleep(2) + + print("\n=== SEND documentSymbol ===") + await send({ + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/documentSymbol", + "params": {"textDocument": {"uri": test_file.as_uri()}}, + }) + + # Wait for response + print("Waiting for documentSymbol response (max 30s)...") + deadline = asyncio.get_event_loop().time() + 30 + while asyncio.get_event_loop().time() < deadline: + await asyncio.sleep(0.5) + # The background task will print when it receives the response + + read_task.cancel() + try: + await read_task + except asyncio.CancelledError: + pass + + process.terminate() + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/codex-lens/tests/real/debug_direct.py b/codex-lens/tests/real/debug_direct.py new file mode 100644 index 00000000..99e4b992 --- /dev/null +++ b/codex-lens/tests/real/debug_direct.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python +"""Minimal direct test of pyright LSP communication.""" + +import asyncio +import json +import sys +from pathlib import Path + + +async def send_message(writer, message): + """Send a JSON-RPC message.""" + body = json.dumps(message).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + writer.write(header + body) + await writer.drain() + print(f"SENT: {message.get('method', 'response')} (id={message.get('id', 'N/A')})") + + +async def read_message(reader): + """Read a JSON-RPC message.""" + # Read headers + content_length = 0 + while True: + line = await reader.readline() + if not line: + return None + line_str = line.decode("ascii").strip() + if not line_str: + break + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":")[1].strip()) + + if content_length == 0: + return None + + # Read body + body = await reader.readexactly(content_length) + return json.loads(body.decode("utf-8")) + + +async def main(): + workspace = Path(__file__).parent.parent.parent + test_file = workspace / "tests" / "real" / "debug_direct.py" + + print(f"Workspace: {workspace}") + print(f"Test file: {test_file}") + print() + + # Start pyright + print("Starting pyright-langserver...") + process = await asyncio.create_subprocess_exec( + "pyright-langserver", "--stdio", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + ) + + # Start stderr reader + async def read_stderr(): + while True: + line = await process.stderr.readline() + if not line: + break + print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}") + + stderr_task = asyncio.create_task(read_stderr()) + + try: + # 1. Send initialize + print("\n=== INITIALIZE ===") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": None, + "rootUri": workspace.as_uri(), + "rootPath": str(workspace), + "capabilities": { + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + }, + }, + "workspace": { + "configuration": True, + }, + }, + "workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}], + }, + }) + + # Read all messages until we get initialize response + print("\n=== READING RESPONSES ===") + init_done = False + for i in range(20): + try: + msg = await asyncio.wait_for(read_message(process.stdout), timeout=5.0) + if msg is None: + print("EOF") + break + + method = msg.get("method", "") + msg_id = msg.get("id", "N/A") + + if method: + print(f"RECV: {method} (id={msg_id})") + + # Handle server requests + if msg_id != "N/A": + if method == "workspace/configuration": + print(" -> Responding to workspace/configuration") + items = msg.get("params", {}).get("items", []) + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": [{"pythonPath": "python"} for _ in items], + }) + elif method == "client/registerCapability": + print(" -> Responding to client/registerCapability") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + elif method == "window/workDoneProgress/create": + print(" -> Responding to window/workDoneProgress/create") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + else: + print(f"RECV: response (id={msg_id})") + if msg_id == 1: + print(" -> Initialize response received!") + caps = list(msg.get("result", {}).get("capabilities", {}).keys()) + print(f" -> Capabilities: {caps[:5]}...") + init_done = True + break + + except asyncio.TimeoutError: + print(f" Timeout waiting for message {i+1}") + break + + if not init_done: + print("ERROR: Initialize failed") + return + + # 2. Send initialized notification + print("\n=== INITIALIZED ===") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "method": "initialized", + "params": {}, + }) + + # Read any messages pyright sends after initialized + print("\n=== READING POST-INITIALIZED MESSAGES ===") + for i in range(10): + try: + msg = await asyncio.wait_for(read_message(process.stdout), timeout=2.0) + if msg is None: + break + + method = msg.get("method", "") + msg_id = msg.get("id", "N/A") + + print(f"RECV: {method or 'response'} (id={msg_id})") + + # Handle server requests + if msg_id != "N/A" and method: + if method == "workspace/configuration": + print(" -> Responding to workspace/configuration") + items = msg.get("params", {}).get("items", []) + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": [{"pythonPath": "python"} for _ in items], + }) + elif method == "client/registerCapability": + print(" -> Responding to client/registerCapability") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + elif method == "window/workDoneProgress/create": + print(" -> Responding to window/workDoneProgress/create") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + + except asyncio.TimeoutError: + print(f" No more messages (timeout)") + break + + # 3. Send didOpen + print("\n=== DIDOPEN ===") + content = test_file.read_text(encoding="utf-8") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": test_file.as_uri(), + "languageId": "python", + "version": 1, + "text": content, + }, + }, + }) + + # Read any messages + print("\n=== READING POST-DIDOPEN MESSAGES ===") + for i in range(10): + try: + msg = await asyncio.wait_for(read_message(process.stdout), timeout=2.0) + if msg is None: + break + + method = msg.get("method", "") + msg_id = msg.get("id", "N/A") + + print(f"RECV: {method or 'response'} (id={msg_id})") + + # Handle server requests + if msg_id != "N/A" and method: + if method == "workspace/configuration": + print(" -> Responding to workspace/configuration") + items = msg.get("params", {}).get("items", []) + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": [{"pythonPath": "python"} for _ in items], + }) + else: + print(f" -> Responding with null to {method}") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + + except asyncio.TimeoutError: + print(f" No more messages (timeout)") + break + + # 4. Send documentSymbol request + print("\n=== DOCUMENTSYMBOL ===") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": {"uri": test_file.as_uri()}, + }, + }) + + # Wait for response + print("\n=== READING DOCUMENTSYMBOL RESPONSE ===") + for i in range(20): + try: + msg = await asyncio.wait_for(read_message(process.stdout), timeout=5.0) + if msg is None: + break + + method = msg.get("method", "") + msg_id = msg.get("id", "N/A") + + if method: + print(f"RECV: {method} (id={msg_id})") + + # Handle server requests + if msg_id != "N/A": + if method == "workspace/configuration": + print(" -> Responding to workspace/configuration") + items = msg.get("params", {}).get("items", []) + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": [{"pythonPath": "python"} for _ in items], + }) + else: + print(f" -> Responding with null to {method}") + await send_message(process.stdin, { + "jsonrpc": "2.0", + "id": msg_id, + "result": None, + }) + else: + print(f"RECV: response (id={msg_id})") + if msg_id == 2: + result = msg.get("result", []) + print(f" -> DocumentSymbol response: {len(result)} symbols") + for sym in result[:5]: + print(f" - {sym.get('name')} ({sym.get('kind')})") + break + + except asyncio.TimeoutError: + print(f" Timeout {i+1}") + if i >= 5: + break + + print("\n=== DONE ===") + + finally: + stderr_task.cancel() + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + process.kill() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/codex-lens/tests/real/debug_reads.py b/codex-lens/tests/real/debug_reads.py new file mode 100644 index 00000000..56048c73 --- /dev/null +++ b/codex-lens/tests/real/debug_reads.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +"""Debug exactly what's happening with reads after initialized.""" + +import asyncio +import json +from pathlib import Path + + +async def main(): + workspace = Path(__file__).parent.parent.parent + print(f"Workspace: {workspace}") + + # Start pyright + process = await asyncio.create_subprocess_exec( + "pyright-langserver", "--stdio", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + ) + + # Helper to encode message + def encode(content): + body = json.dumps(content).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n" + return header.encode("ascii") + body + + # Helper to send + async def send(msg): + encoded = encode(msg) + process.stdin.write(encoded) + await process.stdin.drain() + method = msg.get("method") or f"response-{msg.get('id')}" + print(f"SENT: {method}") + + # Helper to read one message + async def read_one(timeout=3.0): + content_length = 0 + while True: + try: + print(f" readline(timeout={timeout})...") + line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout) + print(f" got line: {repr(line[:50] if len(line) > 50 else line)}") + except asyncio.TimeoutError: + print(f" TIMEOUT on readline") + return None + + if not line: + print(f" EOF") + return None + + line_str = line.decode("ascii").strip() + if not line_str: + break # End of headers + + if line_str.lower().startswith("content-length:"): + content_length = int(line_str.split(":")[1].strip()) + + if content_length == 0: + return None + + body = await process.stdout.readexactly(content_length) + return json.loads(body.decode("utf-8")) + + # Start stderr reader + async def read_stderr(): + while True: + line = await process.stderr.readline() + if not line: + break + print(f"[stderr] {line.decode('utf-8', errors='replace').rstrip()}") + asyncio.create_task(read_stderr()) + + print("\n=== INITIALIZE ===") + await send({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "processId": None, + "rootUri": workspace.as_uri(), + "capabilities": {"workspace": {"configuration": True}}, + "workspaceFolders": [{"uri": workspace.as_uri(), "name": workspace.name}], + }, + }) + + # Read until initialize response + print("\n=== READING UNTIL INITIALIZE RESPONSE ===") + while True: + msg = await read_one() + if msg and msg.get("id") == 1 and "method" not in msg: + print(f"Got initialize response") + break + elif msg: + print(f"Got notification: {msg.get('method')}") + + print("\n=== SEND INITIALIZED ===") + await send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + + print("\n=== NOW TRY TO READ WORKSPACE/CONFIGURATION ===") + print("Attempting reads with 2s timeout each...") + + for i in range(3): + print(f"\n--- Read attempt {i+1} ---") + msg = await read_one(timeout=2.0) + if msg: + method = msg.get("method", "") + msg_id = msg.get("id") + print(f"SUCCESS: method={method}, id={msg_id}") + if method and msg_id is not None: + # Respond to server request + print(f"Responding to {method}") + await send({"jsonrpc": "2.0", "id": msg_id, "result": [{}]}) + else: + print(f"No message (timeout or EOF)") + break + + print("\n=== CLEANUP ===") + process.terminate() + await process.wait() + print("Done") + + +if __name__ == "__main__": + asyncio.run(main())