diff options
| author | main <main@swarm.moe> | 2026-03-19 15:49:41 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 15:49:41 -0400 |
| commit | fa1bd32800b65aab31ea732dd240261b4047522c (patch) | |
| tree | 2fd08af6f36b8beb3c7c941990becc1a0a091d62 /crates/ra-mcp-engine/tests/engine_recovery.rs | |
| download | adequate-rust-mcp-310b00d40db7639654bdc1d416ae222de481e8fc.zip | |
Release adequate-rust-mcp 1.0.0v1.0.0
Diffstat (limited to 'crates/ra-mcp-engine/tests/engine_recovery.rs')
| -rw-r--r-- | crates/ra-mcp-engine/tests/engine_recovery.rs | 353 |
1 files changed, 353 insertions, 0 deletions
diff --git a/crates/ra-mcp-engine/tests/engine_recovery.rs b/crates/ra-mcp-engine/tests/engine_recovery.rs new file mode 100644 index 0000000..a7f2db8 --- /dev/null +++ b/crates/ra-mcp-engine/tests/engine_recovery.rs @@ -0,0 +1,353 @@ +//! 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<dyn Error>> { + 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<dyn Error>> { + 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<dyn Error>> { + 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<dyn Error>> { + 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<dyn Error>> { + 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<dyn Error>> { + 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<SourceFilePath, Box<dyn Error>> { + let path = self.path().join("src").join("lib.rs"); + SourceFilePath::try_new(path).map_err(|error| error.to_string().into()) + } + + fn position(&self) -> Result<SourcePosition, Box<dyn Error>> { + 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<Fixture, Box<dyn Error>> { + 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<String>, +) -> Result<EngineConfig, Box<dyn Error>> { + 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<PathBuf, Box<dyn Error>> { + 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")) +} |