Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/aether-lspd/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
}
Expand All @@ -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?;

Expand Down
2 changes: 1 addition & 1 deletion crates/aether-lspd/src/language_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions crates/aether-lspd/src/testing.rs
Original file line number Diff line number Diff line change
@@ -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<oneshot::Sender<()>>,
task: Option<JoinHandle<Result<(), TestDaemonError>>>,
}

impl TestDaemon {
pub async fn spawn(
workspace_root: &Path,
language: LanguageId,
request_timeout: Duration,
) -> Result<Self, TestDaemonError> {
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;
Expand Down
13 changes: 4 additions & 9 deletions crates/aether-lspd/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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);
});
}

Expand Down
48 changes: 48 additions & 0 deletions crates/mcp-servers/tests/lsp_workspace_search_recovery_e2e.rs
Original file line number Diff line number Diff line change
@@ -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());
}