From 5b731b4eb4dd16835eee4e2260aae037ed6a7f8f Mon Sep 17 00:00:00 2001 From: Josh Carver Date: Tue, 28 Jul 2026 11:54:14 -0700 Subject: [PATCH] test: isolate LSP timeout recovery coverage Run the recovery daemon with explicit test-owned configuration and deterministic shutdown so timeout tests do not leak processes or expand production APIs. --- crates/aether-lspd/src/daemon.rs | 6 +- crates/aether-lspd/src/language_catalog.rs | 2 +- crates/aether-lspd/src/testing.rs | 104 ++++++++++++++++++ crates/aether-lspd/tests/common/mod.rs | 13 +-- .../lsp_workspace_search_recovery_e2e.rs | 48 ++++++++ 5 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 crates/mcp-servers/tests/lsp_workspace_search_recovery_e2e.rs diff --git a/crates/aether-lspd/src/daemon.rs b/crates/aether-lspd/src/daemon.rs index 8af0650e9..330cb6956 100644 --- a/crates/aether-lspd/src/daemon.rs +++ b/crates/aether-lspd/src/daemon.rs @@ -30,6 +30,10 @@ impl LspDaemon { /// Run the daemon until shutdown. pub async fn run(self) -> DaemonResult<()> { + self.run_until_shutdown(spawn_shutdown_signal_handler()).await + } + + pub(crate) async fn run_until_shutdown(self, shutdown_rx: oneshot::Receiver<()>) -> DaemonResult<()> { if let Some(parent) = self.socket_path.parent() { create_dir_all(parent).map_err(DaemonError::Io)?; } @@ -39,8 +43,6 @@ impl LspDaemon { let _ = remove_file(&self.socket_path); - let shutdown_rx = spawn_shutdown_signal_handler(); - tracing::info!("Daemon listening on {:?}", self.socket_path); self.run_listener_loop(shutdown_rx).await?; diff --git a/crates/aether-lspd/src/language_catalog.rs b/crates/aether-lspd/src/language_catalog.rs index 57d194781..2fa1d10e1 100644 --- a/crates/aether-lspd/src/language_catalog.rs +++ b/crates/aether-lspd/src/language_catalog.rs @@ -104,7 +104,7 @@ impl ServerKind { } } - fn env_key(self) -> &'static str { + pub(crate) fn env_key(self) -> &'static str { match self { Self::RustAnalyzer => "RUST_ANALYZER", Self::TypeScriptLanguageServer => "TYPESCRIPT_LANGUAGE_SERVER", diff --git a/crates/aether-lspd/src/testing.rs b/crates/aether-lspd/src/testing.rs index 8f435d866..a6547cdfd 100644 --- a/crates/aether-lspd/src/testing.rs +++ b/crates/aether-lspd/src/testing.rs @@ -1,10 +1,114 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use std::thread::JoinHandle; +use std::time::Duration; use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::sync::oneshot; +use crate::LanguageId; +use crate::daemon::LspDaemon; +use crate::error::DaemonError; +use crate::language_catalog::server_kind_for_language; +use crate::socket_path::socket_path; use crate::uri::path_to_uri; +/// An in-process daemon with explicit configuration and deterministic shutdown. +pub struct TestDaemon { + shutdown_tx: Option>, + task: Option>>, +} + +impl TestDaemon { + pub async fn spawn( + workspace_root: &Path, + language: LanguageId, + request_timeout: Duration, + ) -> Result { + let socket_path = socket_path(workspace_root, language); + let _ = fs::remove_file(&socket_path); + let _ = fs::remove_file(socket_path.with_extension("lock")); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let daemon_socket_path = socket_path.clone(); + let task = std::thread::spawn(move || { + tokio::runtime::Runtime::new() + .map_err(TestDaemonError::Runtime)? + .block_on(LspDaemon::new(daemon_socket_path, None, request_timeout).run_until_shutdown(shutdown_rx)) + .map_err(TestDaemonError::Daemon) + }); + let mut daemon = Self { shutdown_tx: Some(shutdown_tx), task: Some(task) }; + + while UnixStream::connect(&socket_path).await.is_err() { + if daemon.task.as_ref().is_some_and(JoinHandle::is_finished) { + daemon.finish()?; + return Err(TestDaemonError::ExitedBeforeReady); + } + tokio::task::yield_now().await; + } + + Ok(daemon) + } + + pub fn shutdown(mut self) -> Result<(), TestDaemonError> { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + self.finish() + } + + fn finish(&mut self) -> Result<(), TestDaemonError> { + let task = self.task.take().expect("test daemon task should be present"); + task.join().map_err(|_| TestDaemonError::ThreadPanicked)??; + Ok(()) + } +} + +impl Drop for TestDaemon { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.join(); + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TestDaemonError { + #[error("Daemon failed: {0}")] + Daemon(#[from] DaemonError), + #[error("Daemon exited before its socket was ready")] + ExitedBeforeReady, + #[error("Failed to create daemon runtime: {0}")] + Runtime(std::io::Error), + #[error("Daemon thread panicked")] + ThreadPanicked, +} + +/// Configure one language to use the shared fake Python server in the current test process. +/// +/// # Safety +/// +/// This mutates process-global environment variables. Call it only from an isolated test binary +/// before starting any daemon or other thread that can read language-server configuration. +pub unsafe fn configure_fake_server(language: LanguageId, extra_args: &[&str]) { + let server_kind = server_kind_for_language(language).expect("language should have a configured server"); + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/common/fake_lsp_server.py"); + let mut args = vec![script.to_string_lossy().into_owned()]; + args.extend(extra_args.iter().map(ToString::to_string)); + let env_key = server_kind.env_key(); + + unsafe { + std::env::set_var(format!("AETHER_LSPD_SERVER_COMMAND_{env_key}"), "python3"); + std::env::set_var( + format!("AETHER_LSPD_SERVER_ARGS_{env_key}"), + serde_json::to_string(&args).expect("fake server arguments should serialize"), + ); + } +} + #[doc = include_str!("docs/testing.md")] pub trait TestProject { fn root(&self) -> &Path; diff --git a/crates/aether-lspd/tests/common/mod.rs b/crates/aether-lspd/tests/common/mod.rs index 4fdbdf1de..75f6a4162 100644 --- a/crates/aether-lspd/tests/common/mod.rs +++ b/crates/aether-lspd/tests/common/mod.rs @@ -4,8 +4,9 @@ pub mod daemon_harness; pub use cargo_project::{CargoProject, TestProject}; pub use daemon_harness::DaemonHarness; +use aether_lspd::LanguageId; +use aether_lspd::testing::configure_fake_server; use lsp_types::Hover; -use std::path::PathBuf; use std::sync::Once; #[allow(dead_code)] @@ -23,14 +24,8 @@ pub fn use_fake_rust_server() { /// ignored. Don't mix differently-configured fake servers in one test binary. #[allow(dead_code)] pub fn use_fake_rust_server_with_args(extra_args: &[&str]) { - FAKE_SERVER_ENV.call_once(|| { - let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("common").join("fake_lsp_server.py"); - let mut args = vec![script.to_string_lossy().to_string()]; - args.extend(extra_args.iter().map(ToString::to_string)); - unsafe { - std::env::set_var("AETHER_LSPD_SERVER_COMMAND_RUST_ANALYZER", "python3"); - std::env::set_var("AETHER_LSPD_SERVER_ARGS_RUST_ANALYZER", serde_json::to_string(&args).unwrap()); - } + FAKE_SERVER_ENV.call_once(|| unsafe { + configure_fake_server(LanguageId::Rust, extra_args); }); } diff --git a/crates/mcp-servers/tests/lsp_workspace_search_recovery_e2e.rs b/crates/mcp-servers/tests/lsp_workspace_search_recovery_e2e.rs new file mode 100644 index 000000000..2808169e3 --- /dev/null +++ b/crates/mcp-servers/tests/lsp_workspace_search_recovery_e2e.rs @@ -0,0 +1,48 @@ +#[path = "integration/common/mod.rs"] +mod common; + +use aether_lspd::testing::{TestDaemon, configure_fake_server}; +use aether_lspd::{LanguageId, socket_path}; +use common::{call_tool, call_tool_error, test_client_info}; +use mcp_servers::coding::CodingMcp; +use mcp_utils::testing::connect; +use std::time::Duration; +use tempfile::tempdir; + +#[tokio::test] +async fn workspace_search_recovers_after_language_server_timeout() { + unsafe { configure_fake_server(LanguageId::TypeScript, &["--wedge-on", "workspace/symbol"]) }; + + let root = tempdir().expect("Failed to create project"); + std::fs::write(root.path().join("package.json"), r#"{"name":"workspace-search-recovery"}"#) + .expect("Failed to write package.json"); + let source_path = root.path().join("example.ts"); + std::fs::write(&source_path, "export function example_fn(): void {}\n").expect("Failed to write example.ts"); + + let daemon = TestDaemon::spawn(root.path(), LanguageId::TypeScript, Duration::from_secs(1)) + .await + .expect("Failed to spawn test daemon"); + let server = CodingMcp::new().with_lsp(root.path().to_path_buf()); + let (server_handle, client) = connect(server, test_client_info()).await.expect("Failed to connect"); + + let error = call_tool_error( + &client, + "lsp_workspace_search", + serde_json::json!({ "query": "example_fn", "language": "typescript" }), + ) + .await; + assert!(error.contains("timed out after 1s"), "unexpected workspace search error: {error}"); + + let result = + call_tool(&client, "lsp_document", serde_json::json!({ "file_path": source_path.to_string_lossy() })).await; + assert!( + result["symbols"] + .as_array() + .is_some_and(|symbols| { symbols.iter().any(|symbol| symbol["name"] == "example_fn") }) + ); + + drop(client); + drop(server_handle); + daemon.shutdown().expect("Failed to shut down test daemon"); + assert!(!socket_path(root.path(), LanguageId::TypeScript).exists()); +}