-
Notifications
You must be signed in to change notification settings - Fork 70
feat: Recipe Editor Phase 3 - LSP WebSocket Bridge #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Bridges browser WebSocket connections to cooklang-language-server: - Spawns 'cook lsp' subprocess per connection - Forwards JSON-RPC messages bidirectionally - Handles LSP Content-Length framing - Cleans up subprocess on disconnect
Shows connection status in editor status bar: - Green: Connected to LSP - Gray: Disconnected - Red: Connection error Includes auto-reconnect on disconnect.
Add translation keys for LSP connection status indicator to all locale files (en-US, de-DE, es-ES, fr-FR, nl-NL). Also adds complete Ukrainian (uk-UA) locale with all translation files.
Pull Request Review: Recipe Editor Phase 3 - LSP WebSocket BridgeSummaryThis PR implements a WebSocket bridge that connects the browser-based recipe editor to the cooklang-language-server subprocess, enabling LSP features like diagnostics and completions. The implementation follows a clean architecture with proper message framing and bidirectional communication. ✅ StrengthsArchitecture & Design
Code Quality
🔍 Issues & Concerns1. CRITICAL - Resource Leak & DoS Risk (src/server/lsp_bridge.rs:1022-1034)Severity: High Each WebSocket connection spawns a new LSP subprocess without any limiting mechanism. This could lead to:
Recommendation: // Add connection pooling or limit concurrent LSP processes
use std::sync::atomic::{AtomicUsize, Ordering};
static ACTIVE_LSP_CONNECTIONS: AtomicUsize = AtomicUsize::new(0);
const MAX_LSP_CONNECTIONS: usize = 10; // Adjust based on server capacity
async fn handle_lsp_connection(socket: WebSocket, state: Arc<AppState>) {
let current = ACTIVE_LSP_CONNECTIONS.fetch_add(1, Ordering::SeqCst);
if current >= MAX_LSP_CONNECTIONS {
ACTIVE_LSP_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
error\!("Max LSP connections ({}) reached, rejecting connection", MAX_LSP_CONNECTIONS);
let _ = socket.close().await;
return;
}
// ... existing code ...
// Ensure we decrement on exit
let _guard = scopeguard::guard((), |_| {
ACTIVE_LSP_CONNECTIONS.fetch_sub(1, Ordering::SeqCst);
});
}2. Security - Process Execution Path (src/server/lsp_bridge.rs:1024)Severity: Medium Using
Recommendation: async fn spawn_lsp_process(base_path: &camino::Utf8Path) -> Result<Child, std::io::Error> {
let exe_path = std::env::current_exe()?;
// Verify the executable supports LSP
if \!tokio::fs::metadata(&exe_path).await.is_ok() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"LSP executable not found"
));
}
debug\!("Spawning LSP process: {} lsp", exe_path.display());
// ... rest of implementation
}3. Bug - Incorrect Response Handling (templates/edit.html:1307-1315)Severity: Medium The LSP message handler checks for Current code: if (message.id \!== undefined && message.result \!== undefined) {
// Response to a request
console.log('LSP response:', message);
if (message.result && message.result.capabilities) {
// Initialize response received, send initialized notification
sendLspNotification('initialized', {});
}
}Recommendation: const pendingRequests = new Map();
function sendLspRequest(method, params) {
if (\!lspSocket || lspSocket.readyState \!== WebSocket.OPEN) {
return null;
}
const id = lspMessageId++;
const message = {
jsonrpc: '2.0',
id: id,
method: method,
params: params
};
pendingRequests.set(id, { method, timestamp: Date.now() });
lspSocket.send(JSON.stringify(message));
return id;
}
function handleLspMessage(message) {
if (message.id \!== undefined) {
const request = pendingRequests.get(message.id);
if (request) {
pendingRequests.delete(message.id);
if (request.method === 'initialize' && message.result?.capabilities) {
sendLspNotification('initialized', {});
console.log('LSP initialized successfully');
}
}
} else if (message.method) {
// Server notification
console.log('LSP notification:', message.method);
}
}4. Potential Race Condition (src/server/lsp_bridge.rs:1054-1108)Severity: Low The Recommendation: // Use a shutdown token to coordinate cleanup
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
let stdout_task = tokio::spawn(async move {
loop {
tokio::select\! {
_ = shutdown_rx.changed() => {
debug\!("Shutdown signal received, closing stdout reader");
return;
}
result = read_lsp_message(&mut stdout_reader) => {
// Handle message...
}
}
}
});5. Missing Error Context (src/server/lsp_bridge.rs:1089)Severity: Low Using Recommendation: if let Err(e) = tokio::io::AsyncReadExt::read_exact(&mut stdout_reader, &mut content).await {
error\!("Error reading LSP content ({} bytes expected): {}", content_length, e);
return;
}6. Hardcoded Values (templates/edit.html:1260)Severity: Low The 3-second reconnect delay is hardcoded. Consider:
Recommendation: let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let reconnectAttempts = 0;
lspSocket.onclose = function() {
console.log('LSP WebSocket closed');
updateLspStatus('disconnected');
// Exponential backoff with jitter
const delay = Math.min(
reconnectDelay * Math.pow(2, reconnectAttempts) + Math.random() * 1000,
MAX_RECONNECT_DELAY
);
reconnectAttempts++;
setTimeout(() => {
connectLsp();
}, delay);
};
lspSocket.onopen = function() {
reconnectAttempts = 0; // Reset on successful connection
reconnectDelay = 1000;
// ... rest of onopen
};7. Internationalization Not Applied (templates/edit.html:1334, 1340, 1345)Severity: Low The PR adds i18n keys ( Current: text.textContent = 'LSP Connected';Should be: text.textContent = '{{ "lsp-connected" | fluent }}';Or use a data attribute approach if dynamic updates are needed. 📋 Minor Issues8. Missing WebSocket Close HandlingThe server doesn't send a proper close frame before killing the subprocess. Add: // Before killing the process, try to close WebSocket gracefully
let _ = ws_sender.send(Message::Close(None)).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = lsp_process.kill().await;9. Cursor Position Not ImplementedThe status bar shows 10. Missing Content-Type ValidationThe WebSocket handler doesn't validate that text messages are valid JSON before attempting to forward them to the LSP. 🧪 Test CoverageMissing Tests:
Recommendation: #[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_lsp_websocket_connection() {
// Test WebSocket upgrade and LSP subprocess spawn
}
#[tokio::test]
async fn test_lsp_message_framing() {
// Test Content-Length header parsing
}
#[tokio::test]
async fn test_concurrent_connection_limit() {
// Test connection limiting
}
}🎯 Performance Considerations
🔒 Security Recommendations
✨ Suggestions
📝 DocumentationThe plan document (
Overall AssessmentCode Quality: ✅ Good RecommendationAPPROVE with requested changes The implementation is solid and well-structured, but should address the critical resource leak issue (#1) and the security concerns before merging. The other issues are less urgent but should be tracked for follow-up. Priority fixes:
Great work on Phase 3! The LSP bridge architecture is clean and extensible for Phase 4's completion and diagnostic features. Review generated by Claude Code |
PR Review: Recipe Editor Phase 3 - LSP WebSocket BridgeSummaryThis PR implements a WebSocket bridge connecting the browser editor to the cooklang-language-server subprocess. The architecture is sound and follows best practices for async Rust with Axum and Tokio. ✅ StrengthsArchitecture & Design
Code Quality
🔍 Issues & Concerns1. Security: Potential Resource Exhaustion
|
Summary
Phase 3 of the Recipe Editor feature - WebSocket bridge connecting the browser editor to cooklang-language-server.
Depends on: #244 (Phase 2)
Changes
src/server/lsp_bridge.rs)/api/ws/lspArchitecture
Editor Status Bar
Shows LSP connection status:
Auto-reconnects after 3 seconds on disconnect.
Test Plan