diff --git a/crates/aft/src/context.rs b/crates/aft/src/context.rs index ccd74d7f..9bdb0231 100644 --- a/crates/aft/src/context.rs +++ b/crates/aft/src/context.rs @@ -5442,7 +5442,7 @@ impl AppContext { pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) { let config = self.config(); if let Some(mut lsp) = self.lsp_manager.try_lock() { - if let Err(e) = lsp.notify_file_changed(file_path, content, &config) { + if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) { crate::slog_warn!("sync error for {}: {}", file_path.display(), e); } } diff --git a/crates/aft/src/lsp/manager.rs b/crates/aft/src/lsp/manager.rs index 839e5d40..95ad3cdb 100644 --- a/crates/aft/src/lsp/manager.rs +++ b/crates/aft/src/lsp/manager.rs @@ -370,6 +370,23 @@ impl LspManager { .successful } + fn running_server_keys_for_file(&self, file_path: &Path, config: &Config) -> Vec { + let mut keys = Vec::new(); + for def in servers_for_file(file_path, config) { + let Some(root) = def.workspace_root_for_file(file_path) else { + continue; + }; + let key = ServerKey { + kind: def.kind.clone(), + root, + }; + if self.clients.contains_key(&key) { + keys.push(key); + } + } + keys + } + /// Detailed version of [`ensure_server_for_file`] that records every /// matching server's outcome (`Ok` / `NoRootMarker` / `BinaryNotInstalled` /// / `SpawnFailed`). @@ -628,6 +645,32 @@ impl LspManager { ) -> Result, LspError> { let canonical_path = canonicalize_for_lsp(file_path)?; let server_keys = self.ensure_server_for_file(&canonical_path, config); + self.notify_file_changed_for_server_keys(canonical_path, content, server_keys) + } + + /// Notify only LSP servers that are already running for this file. + /// + /// Post-write notifications are best-effort and must not make a mutation + /// wait for cold server startup. Explicit LSP requests use + /// [`Self::notify_file_changed_versioned`] and retain lazy startup. + pub fn notify_file_changed_if_running( + &mut self, + file_path: &Path, + content: &str, + config: &Config, + ) -> Result<(), LspError> { + let canonical_path = canonicalize_for_lsp(file_path)?; + let server_keys = self.running_server_keys_for_file(&canonical_path, config); + self.notify_file_changed_for_server_keys(canonical_path, content, server_keys) + .map(|_| ()) + } + + fn notify_file_changed_for_server_keys( + &mut self, + canonical_path: PathBuf, + content: &str, + server_keys: Vec, + ) -> Result, LspError> { if server_keys.is_empty() { return Ok(Vec::new()); } @@ -2153,7 +2196,10 @@ mod failure_hint_tests { #[cfg(test)] mod diagnostic_capacity_tests { + use std::fs; + use super::LspManager; + use crate::config::Config; // The lsp.diagnostic_cache_size config knob must actually take effect: // set_diagnostic_capacity (called at AppContext construction with the config @@ -2178,6 +2224,20 @@ mod diagnostic_capacity_tests { assert_eq!(manager.clear_failed_spawns(), 1); assert_eq!(manager.clear_failed_spawns(), 0); } + + #[test] + fn post_write_notification_does_not_start_a_cold_server() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("main.ts"); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(&file, "export const value = 1;\n").unwrap(); + + let mut manager = LspManager::new(); + manager + .notify_file_changed_if_running(&file, "export const value = 1;\n", &Config::default()) + .unwrap(); + assert!(manager.clients.is_empty()); + } } #[cfg(test)] diff --git a/crates/aft/src/main.rs b/crates/aft/src/main.rs index 2131c617..59a9b4ff 100644 --- a/crates/aft/src/main.rs +++ b/crates/aft/src/main.rs @@ -367,8 +367,11 @@ fn drain_runtime_events_and_write_pending( registry: &RuntimeRegistry, pending: &mut PendingResponses, ) -> io::Result { + let ctx = registry.current(); + let mut written = write_ready_pending(ctx, pending)?; drain_runtime_events(registry); - write_ready_pending(registry.current(), pending) + written += write_ready_pending(ctx, pending)?; + Ok(written) } fn write_ready_pending(ctx: &AppContext, pending: &mut PendingResponses) -> io::Result { @@ -381,8 +384,11 @@ fn drain_runtime_events_and_write_pending_to_writer( pending: &mut PendingResponses, writer: &mut impl Write, ) -> io::Result { + let ctx = registry.current(); + let mut written = write_ready_pending_to_writer(ctx, pending, writer)?; drain_runtime_events(registry); - write_ready_pending_to_writer(registry.current(), pending, writer) + written += write_ready_pending_to_writer(ctx, pending, writer)?; + Ok(written) } #[cfg(test)] @@ -1278,11 +1284,60 @@ mod pending_response_tests { 0 ); assert_eq!(drain_frames.load(Ordering::SeqCst), 1); - assert_eq!(poll_calls.get(), 1); + assert_eq!(poll_calls.get(), 2); assert!(writer.is_empty()); assert!(!pending.is_empty()); } + #[test] + fn tick_flushes_ready_pending_before_runtime_maintenance() { + let root = TempDir::new().unwrap(); + let registry = make_runtime_registry(root.path()); + let events = Arc::new(Mutex::new(Vec::new())); + let events_for_drain = Arc::clone(&events); + registry + .current() + .set_progress_sender(Some(Arc::new(Box::new(move |_| { + events_for_drain.lock().unwrap().push("drain"); + })))); + let generation = registry.current().advance_configure_generation(); + registry + .current() + .configure_warnings_sender() + .send(( + generation, + ConfigureWarningsFrame { + frame_type: "configure_warnings", + session_id: Some("session-order".to_string()), + project_root: root.path().display().to_string(), + warnings: Vec::new(), + }, + )) + .unwrap(); + + let events_for_pending = Arc::clone(&events); + let mut pending = PendingResponses::default(); + pending.register(PendingResponse { + request_id: "pending-order".to_string(), + session_id: "session-order".to_string(), + attach_command: "bash".to_string(), + poll: Box::new(move |_| { + events_for_pending.lock().unwrap().push("pending"); + Some(Response::success("pending-order", serde_json::json!({}))) + }), + }); + let mut writer = Vec::new(); + + assert_eq!( + drain_runtime_events_and_write_pending_to_writer(®istry, &mut pending, &mut writer) + .unwrap(), + 1 + ); + assert_eq!(*events.lock().unwrap(), ["pending", "drain"]); + assert_eq!(line_values(&writer).len(), 1); + assert!(pending.is_empty()); + } + #[test] fn long_pending_response_is_polled_each_tick_without_writing_until_ready() { let root = TempDir::new().unwrap();