//! Integration tests for engine restart and transport recovery. use lsp_types as _; use ra_mcp_domain::{ lifecycle::LifecycleSnapshot, types::{ OneIndexedColumn, OneIndexedLine, SourceFilePath, SourcePoint, SourcePosition, WorkspaceRoot, }, }; use ra_mcp_engine::{BackoffPolicy, Engine, EngineConfig, EngineError}; use serde as _; use serde_json::{self, json}; use serial_test::serial; use std::{error::Error, fs, path::PathBuf, time::Duration}; use tempfile::TempDir; use thiserror as _; use tracing as _; use url as _; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn stable_fake_server_handles_core_requests() -> Result<(), Box> { let fixture = make_fixture()?; let config = make_engine_config(&fixture, vec!["--mode".into(), "stable".into()])?; let engine = Engine::new(config); let position = fixture.position()?; let hover = engine.hover(position.clone()).await?; assert_eq!(hover.rendered.as_deref(), Some("hover::ok")); let definitions = engine.definition(position.clone()).await?; assert_eq!(definitions.len(), 1); assert_eq!(definitions[0].line().get(), 3); assert_eq!(definitions[0].column().get(), 4); let references = engine.references(position.clone()).await?; assert_eq!(references.len(), 1); let rename = engine .rename_symbol(position.clone(), "renamed".to_owned()) .await?; assert!(rename.files_touched >= 1); assert!(rename.edits_applied >= 1); let diagnostics = engine.diagnostics(fixture.source_file_path()?).await?; assert_eq!(diagnostics.diagnostics.len(), 1); let snapshot = engine.lifecycle_snapshot().await; assert!(matches!(snapshot, LifecycleSnapshot::Ready { .. })); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn stable_fake_server_reports_success_telemetry() -> Result<(), Box> { let fixture = make_fixture()?; let config = make_engine_config(&fixture, vec!["--mode".into(), "stable".into()])?; let engine = Engine::new(config); let position = fixture.position()?; let _hover = engine.hover(position.clone()).await?; let _definition = engine.definition(position.clone()).await?; let _references = engine.references(position.clone()).await?; let _diagnostics = engine.diagnostics(fixture.source_file_path()?).await?; let telemetry = engine.telemetry_snapshot().await; assert_eq!(telemetry.totals.request_count, 4); assert_eq!(telemetry.totals.success_count, 4); assert_eq!(telemetry.totals.response_error_count, 0); assert_eq!(telemetry.totals.transport_fault_count, 0); assert_eq!(telemetry.totals.retry_count, 0); assert_eq!(telemetry.restart_count, 0); assert!(telemetry.last_fault.is_none()); assert_eq!(telemetry.consecutive_failures, 0); assert_method_counts( telemetry.methods.as_slice(), "textDocument/hover", MethodExpectation::new(1, 1, 0, 0, 0), ); assert_method_counts( telemetry.methods.as_slice(), "textDocument/definition", MethodExpectation::new(1, 1, 0, 0, 0), ); assert_method_counts( telemetry.methods.as_slice(), "textDocument/references", MethodExpectation::new(1, 1, 0, 0, 0), ); assert_method_counts( telemetry.methods.as_slice(), "textDocument/diagnostic", MethodExpectation::new(1, 1, 0, 0, 0), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn diagnostics_retry_server_cancelled_response() -> Result<(), Box> { let fixture = make_fixture()?; let config = make_engine_config( &fixture, vec![ "--mode".into(), "stable".into(), "--diagnostic-cancel-count".into(), "1".into(), ], )?; let engine = Engine::new(config); let diagnostics = engine.diagnostics(fixture.source_file_path()?).await?; assert_eq!(diagnostics.diagnostics.len(), 1); let telemetry = engine.telemetry_snapshot().await; assert_eq!(telemetry.totals.request_count, 2); assert_eq!(telemetry.totals.success_count, 1); assert_eq!(telemetry.totals.response_error_count, 1); assert_eq!(telemetry.totals.transport_fault_count, 0); assert_eq!(telemetry.totals.retry_count, 1); assert_eq!(telemetry.restart_count, 0); assert_eq!(telemetry.consecutive_failures, 0); assert_method_counts( telemetry.methods.as_slice(), "textDocument/diagnostic", MethodExpectation::new(2, 1, 1, 0, 1), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn engine_recovers_after_first_hover_crash() -> Result<(), Box> { let fixture = make_fixture()?; let marker = fixture.path().join("crash-marker"); let args = vec![ "--mode".into(), "crash_on_first_hover".into(), "--crash-marker".into(), marker.display().to_string(), ]; let config = make_engine_config(&fixture, args)?; let engine = Engine::new(config); let hover = engine.hover(fixture.position()?).await?; assert_eq!(hover.rendered.as_deref(), Some("hover::ok")); assert!(marker.exists()); let snapshot = engine.lifecycle_snapshot().await; let generation = if let LifecycleSnapshot::Ready { generation } = snapshot { generation.get() } else { return Err("expected ready lifecycle snapshot after successful recovery".into()); }; assert!(generation >= 2); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn crash_recovery_records_transport_fault_retry_and_restart() -> Result<(), Box> { let fixture = make_fixture()?; let marker = fixture.path().join("crash-marker"); let args = vec![ "--mode".into(), "crash_on_first_hover".into(), "--crash-marker".into(), marker.display().to_string(), ]; let config = make_engine_config(&fixture, args)?; let engine = Engine::new(config); let hover = engine.hover(fixture.position()?).await?; assert_eq!(hover.rendered.as_deref(), Some("hover::ok")); let telemetry = engine.telemetry_snapshot().await; assert_eq!(telemetry.totals.request_count, 2); assert_eq!(telemetry.totals.success_count, 1); assert_eq!(telemetry.totals.response_error_count, 0); assert_eq!(telemetry.totals.transport_fault_count, 1); assert_eq!(telemetry.totals.retry_count, 1); assert_eq!(telemetry.restart_count, 1); assert_eq!(telemetry.consecutive_failures, 0); assert!(telemetry.last_fault.is_some()); assert_method_counts( telemetry.methods.as_slice(), "textDocument/hover", MethodExpectation::new(2, 1, 0, 1, 1), ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn response_error_requests_are_telemetered() -> Result<(), Box> { let fixture = make_fixture()?; let config = make_engine_config(&fixture, vec!["--mode".into(), "stable".into()])?; let engine = Engine::new(config); let invalid = engine .raw_lsp_request("textDocument/notReal", json!({})) .await; match invalid { Err(EngineError::LspResponse { .. }) => {} other => return Err(format!("expected LSP response error, got {other:?}").into()), } let telemetry = engine.telemetry_snapshot().await; assert_eq!(telemetry.totals.request_count, 1); assert_eq!(telemetry.totals.success_count, 0); assert_eq!(telemetry.totals.response_error_count, 1); assert_eq!(telemetry.totals.transport_fault_count, 0); assert_eq!(telemetry.totals.retry_count, 0); assert_eq!(telemetry.restart_count, 0); assert_method_counts( telemetry.methods.as_slice(), "textDocument/notReal", MethodExpectation::new(1, 0, 1, 0, 0), ); Ok(()) } #[derive(Debug, Clone, Copy)] struct MethodExpectation { request_count: u64, success_count: u64, response_error_count: u64, transport_fault_count: u64, retry_count: u64, } impl MethodExpectation { const fn new( request_count: u64, success_count: u64, response_error_count: u64, transport_fault_count: u64, retry_count: u64, ) -> Self { Self { request_count, success_count, response_error_count, transport_fault_count, retry_count, } } } fn assert_method_counts( methods: &[ra_mcp_engine::MethodTelemetrySnapshot], method: &str, expected: MethodExpectation, ) { let maybe_entry = methods.iter().find(|entry| entry.method == method); assert!( maybe_entry.is_some(), "expected telemetry entry for method `{method}`", ); let entry = if let Some(value) = maybe_entry { value } else { return; }; assert_eq!(entry.request_count, expected.request_count); assert_eq!(entry.success_count, expected.success_count); assert_eq!(entry.response_error_count, expected.response_error_count); assert_eq!(entry.transport_fault_count, expected.transport_fault_count); assert_eq!(entry.retry_count, expected.retry_count); } struct Fixture { temp_dir: TempDir, } impl Fixture { fn path(&self) -> &std::path::Path { self.temp_dir.path() } fn source_file_path(&self) -> Result> { let path = self.path().join("src").join("lib.rs"); SourceFilePath::try_new(path).map_err(|error| error.to_string().into()) } fn position(&self) -> Result> { let line = OneIndexedLine::try_new(1).map_err(|error| error.to_string())?; let column = OneIndexedColumn::try_new(1).map_err(|error| error.to_string())?; Ok(SourcePosition::new( self.source_file_path()?, SourcePoint::new(line, column), )) } } fn make_fixture() -> Result> { let temp_dir = tempfile::tempdir()?; let src_dir = temp_dir.path().join("src"); fs::create_dir_all(&src_dir)?; fs::write( temp_dir.path().join("Cargo.toml"), "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", )?; fs::write(src_dir.join("lib.rs"), "pub fn touch() -> i32 { 1 }\n")?; Ok(Fixture { temp_dir }) } fn make_engine_config( fixture: &Fixture, args: Vec, ) -> Result> { let workspace_root = WorkspaceRoot::try_new(fixture.path().to_path_buf()).map_err(|error| error.to_string())?; let binary = fake_rust_analyzer_binary()?; let backoff = BackoffPolicy::try_new(Duration::from_millis(5), Duration::from_millis(20)) .map_err(|error| error.to_string())?; EngineConfig::try_new( workspace_root, binary, args, Vec::new(), Duration::from_secs(2), Duration::from_secs(2), backoff, ) .map_err(|error| error.to_string().into()) } fn fake_rust_analyzer_binary() -> Result> { if let Ok(path) = std::env::var("CARGO_BIN_EXE_fake-rust-analyzer") { return Ok(PathBuf::from(path)); } if let Ok(path) = std::env::var("CARGO_BIN_EXE_fake_rust_analyzer") { return Ok(PathBuf::from(path)); } let current = std::env::current_exe()?; let deps_dir = current .parent() .ok_or_else(|| "failed to resolve test binary parent".to_owned())?; let debug_dir = deps_dir .parent() .ok_or_else(|| "failed to resolve target debug directory".to_owned())?; Ok(debug_dir.join("fake-rust-analyzer")) }