swarm repositories / source
summaryrefslogtreecommitdiff
path: root/crates/ra-mcp-engine/tests/engine_recovery.rs
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-19 15:49:41 -0400
committermain <main@swarm.moe>2026-03-19 15:49:41 -0400
commitfa1bd32800b65aab31ea732dd240261b4047522c (patch)
tree2fd08af6f36b8beb3c7c941990becc1a0a091d62 /crates/ra-mcp-engine/tests/engine_recovery.rs
downloadadequate-rust-mcp-fa1bd32800b65aab31ea732dd240261b4047522c.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.rs353
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"))
+}