diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14f63573..964d2309 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,10 +55,10 @@ jobs: test -n "$VERSION" bash check-version.sh "$VERSION" - - name: Check public API compatibility with v5.2.8 + - name: Check public API compatibility with v5.3.2 run: | cargo install cargo-semver-checks --version 0.48.0 --locked - bash scripts/check_semver.sh 5.2.8 + bash scripts/check_semver.sh 5.3.2 - name: Check SDK protocol and API alignment run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c488734..9a5fefb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.3.3] - 2026-07-16 + +### Fixed + +- Kept a shared language-runtime startup alive when the query that initiated it + disconnects or is cancelled, so concurrent and subsequent semantic queries + reuse one process generation instead of restarting it. +- Made language-runtime startup, source removal, and workspace shutdown use + bounded generation-aware cleanup, preventing late readiness updates, + overlapping replacement processes, and incomplete multi-language status. + ## [5.3.2] - 2026-07-16 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 2e204515..823fcb3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.2" +version = "5.3.3" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", diff --git a/core/Cargo.toml b/core/Cargo.toml index 8a64dd41..d8ef2efa 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-core" -version = "5.3.2" +version = "5.3.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" diff --git a/core/src/code_intelligence/language_runtime.rs b/core/src/code_intelligence/language_runtime.rs index 10ce8cc8..44bc02fe 100644 --- a/core/src/code_intelligence/language_runtime.rs +++ b/core/src/code_intelligence/language_runtime.rs @@ -547,6 +547,15 @@ impl LanguageRuntime { }) } + pub(crate) fn force_kill(&self) { + self.process.force_kill(); + if let Ok(mut task) = self.notification_task.try_lock() { + if let Some(task) = task.take() { + task.abort(); + } + } + } + pub(crate) fn unavailable_message(&self) -> Option { use super::lsp::process::LspProcessState; diff --git a/core/src/code_intelligence/workspace_runtime.rs b/core/src/code_intelligence/workspace_runtime.rs index 43fbd31e..455c98ff 100644 --- a/core/src/code_intelligence/workspace_runtime.rs +++ b/core/src/code_intelligence/workspace_runtime.rs @@ -1,7 +1,10 @@ //! One lazily-started semantic runtime generation for a workspace layout. +#[cfg(test)] +mod integration_test_support; #[cfg(test)] mod integration_tests; +mod lifecycle; mod support; #[cfg(test)] mod tests; @@ -9,6 +12,8 @@ mod tests; use support::*; use std::{ + any::Any, + panic::AssertUnwindSafe, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -17,8 +22,11 @@ use std::{ time::{Duration, Instant}, }; -use futures::{stream::FuturesUnordered, StreamExt}; -use tokio::sync::{watch, Mutex, RwLock}; +use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; +use tokio::{ + sync::{oneshot, watch, Mutex, RwLock}, + task::AbortHandle, +}; use tokio_util::sync::CancellationToken; use super::{ @@ -43,14 +51,34 @@ const WORKSPACE_DIAGNOSTIC_DOCUMENT_LIMIT: usize = 128; const WORKSPACE_DIAGNOSTIC_CONCURRENCY: usize = 8; const MAX_SYMBOL_LIMIT: usize = 1_000; const START_RETRY_DELAY: Duration = Duration::from_secs(2); +const SHUTDOWN_GRACE: Duration = Duration::from_secs(4); +const SHUTDOWN_ABORT_SETTLE: Duration = Duration::from_millis(500); + +type RuntimeStartOutcome = CodeIntelligenceResult>; + +#[derive(Clone)] +struct RuntimeStart { + generation: u64, + cancellation: CancellationToken, + outcome: watch::Receiver>, + abort: AbortHandle, +} struct StartFailure { at: Instant, message: String, + retained_runtime: Option>, +} + +struct StartAttemptFailure { + public: CodeIntelligenceError, + message: String, + retained_runtime: Option>, } enum SlotState { Dormant, + Starting(RuntimeStart), Ready(Arc), Failed(StartFailure), } @@ -58,6 +86,7 @@ enum SlotState { struct LanguageSlot { profile: LanguageServerProfile, relevant: AtomicBool, + generation: AtomicU64, documents: Arc, state: Arc>, } @@ -67,6 +96,7 @@ impl LanguageSlot { Self { profile, relevant: AtomicBool::new(relevant), + generation: AtomicU64::new(0), documents: Arc::new(DocumentStore::new(document_capacity)), state: Arc::new(Mutex::new(SlotState::Dormant)), } @@ -84,6 +114,7 @@ pub(crate) struct WorkspaceRuntime { workspace_revision: AtomicU64, timeout: Duration, status: watch::Sender, + status_updates: Arc>, shutting_down: AtomicBool, lifetime: CancellationToken, } @@ -183,7 +214,7 @@ impl WorkspaceRuntime { let source_paths = supported_source_paths(snapshot, |path| { profiles.iter().any(|profile| profile.supports_path(path)) }); - let slots = profiles + let slots: Vec<_> = profiles .into_iter() .map(|profile| { let relevant = source_paths @@ -192,8 +223,19 @@ impl WorkspaceRuntime { LanguageSlot::new(profile, relevant, document_capacity) }) .collect(); + let languages = slots + .iter() + .filter(|slot| slot.relevant.load(Ordering::Acquire)) + .map(|slot| CodeIntelligenceLanguageStatus { + language: profile_language(slot.profile.id()), + state: CodeIntelligenceState::Starting, + capabilities: CodeIntelligenceCapabilities::default(), + message: Some("starts on first semantic query".to_owned()), + }) + .collect(); let (status, _) = watch::channel(CodeIntelligenceStatus { state: CodeIntelligenceState::Starting, + languages, message: Some("Code Intelligence starts language runtimes on demand".to_owned()), ..CodeIntelligenceStatus::default() }); @@ -207,6 +249,7 @@ impl WorkspaceRuntime { source_paths: RwLock::new(source_paths), timeout, status, + status_updates: Arc::new(Mutex::new(())), shutting_down: AtomicBool::new(false), lifetime: CancellationToken::new(), } @@ -235,22 +278,38 @@ impl WorkspaceRuntime { .any(|path| slot.profile.supports_path(Path::new(path.as_str()))); let was_relevant = slot.relevant.swap(relevant, Ordering::AcqRel); if was_relevant && !relevant { - let runtime = { - let mut state = slot.state.lock().await; - match std::mem::replace(&mut *state, SlotState::Dormant) { - SlotState::Ready(runtime) => Some(runtime), - SlotState::Dormant | SlotState::Failed(_) => None, + let mut state = slot.state.lock().await; + if let SlotState::Starting(start) = &*state { + // Cancel while the slot is still locked. Otherwise the + // start task can publish Ready after this lock is dropped + // but before bounded cleanup observes the generation. + start.cancellation.cancel(); + let start = start.clone(); + let generation = start.generation; + drop(state); + self.stop_generations(&[], std::slice::from_ref(&start), "source removal") + .await; + state = slot.state.lock().await; + if matches!( + &*state, + SlotState::Starting(current) if current.generation == generation + ) { + *state = SlotState::Dormant; } + continue; + } + + let runtime = match &*state { + SlotState::Ready(runtime) => Some(Arc::clone(runtime)), + SlotState::Failed(failure) => failure.retained_runtime.clone(), + SlotState::Dormant => None, + SlotState::Starting(_) => None, }; if let Some(runtime) = runtime { - if let Err(error) = runtime.shutdown().await { - tracing::warn!( - language = %profile_language(slot.profile.id()), - error = %error, - "Code Intelligence could not stop an irrelevant language runtime" - ); - } + self.stop_generations(std::slice::from_ref(&runtime), &[], "source removal") + .await; } + *state = SlotState::Dormant; } } self.refresh_status().await; @@ -552,24 +611,6 @@ impl WorkspaceRuntime { } } - pub(crate) async fn shutdown(&self) { - if self.shutting_down.swap(true, Ordering::AcqRel) { - return; - } - self.lifetime.cancel(); - let runtimes = self.ready_runtimes().await; - for runtime in runtimes { - if let Err(error) = runtime.shutdown().await { - tracing::warn!(error = %error, "Code Intelligence runtime shutdown failed"); - } - } - self.status.send_replace(CodeIntelligenceStatus { - state: CodeIntelligenceState::Unavailable, - message: Some("Code Intelligence runtime is shut down".to_owned()), - ..CodeIntelligenceStatus::default() - }); - } - async fn runtime_for_path( &self, path: &WorkspacePath, @@ -591,178 +632,6 @@ impl WorkspaceRuntime { Ok((slot.profile.id(), runtime)) } - async fn ensure_runtime( - &self, - slot: &LanguageSlot, - cancellation: &CancellationToken, - ) -> CodeIntelligenceResult> { - if self.shutting_down.load(Ordering::Acquire) { - return Err(CodeIntelligenceError::Unavailable { - message: "the workspace runtime is shutting down".to_owned(), - }); - } - let mut state = tokio::select! { - _ = cancellation.cancelled() => return Err(CodeIntelligenceError::Cancelled), - state = slot.state.lock() => state, - }; - let retiring = match &*state { - SlotState::Ready(runtime) => { - if let Some(message) = runtime.unavailable_message() { - Some((Arc::clone(runtime), message)) - } else { - return Ok(Arc::clone(runtime)); - } - } - SlotState::Failed(failure) if failure.at.elapsed() < START_RETRY_DELAY => { - return Err(CodeIntelligenceError::Unavailable { - message: failure.message.clone(), - }); - } - SlotState::Dormant | SlotState::Failed(_) => None, - }; - if let Some((runtime, message)) = retiring { - tracing::warn!( - language = %profile_language(slot.profile.id()), - message, - "Code Intelligence will restart an exited language runtime" - ); - // The client can observe protocol EOF before the process monitor - // has reaped a server that kept running. Keep the slot locked and - // finish the old generation before making it startable again. - if let Err(error) = runtime.shutdown().await { - tracing::warn!( - language = %profile_language(slot.profile.id()), - error = %error, - "Code Intelligence could not fully retire an exited language runtime" - ); - // Keep the failed generation in Ready. A later query may retry - // cleanup, but no replacement may start while the old process - // has not been confirmed reaped. - return Err(map_language_error(slot.profile.id(), error)); - } - *state = SlotState::Dormant; - } - if cancellation.is_cancelled() { - return Err(CodeIntelligenceError::Cancelled); - } - if self.shutting_down.load(Ordering::Acquire) { - return Err(CodeIntelligenceError::Unavailable { - message: "the workspace runtime is shutting down".to_owned(), - }); - } - - let result = LanguageRuntime::start( - slot.profile.clone(), - self.canonical_root.clone(), - self.layout.clone(), - Arc::clone(&slot.documents), - Arc::clone(&self.diagnostics), - cancellation.clone(), - self.timeout, - ) - .await; - if cancellation.is_cancelled() || self.shutting_down.load(Ordering::Acquire) { - if let Ok(runtime) = result { - if let Err(error) = runtime.shutdown().await { - tracing::warn!( - language = %profile_language(slot.profile.id()), - error = %error, - "Code Intelligence could not retire a cancelled language runtime start" - ); - } - } - *state = SlotState::Dormant; - return if cancellation.is_cancelled() { - Err(CodeIntelligenceError::Cancelled) - } else { - Err(CodeIntelligenceError::Unavailable { - message: "the workspace runtime is shutting down".to_owned(), - }) - }; - } - match result { - Ok(runtime) => { - let runtime = Arc::new(runtime); - *state = SlotState::Ready(Arc::clone(&runtime)); - drop(state); - self.spawn_runtime_monitor(slot, Arc::clone(&runtime)); - self.refresh_status().await; - Ok(runtime) - } - Err(error) => { - let message = error.to_string(); - let public = map_language_error(slot.profile.id(), error); - *state = SlotState::Failed(StartFailure { - at: Instant::now(), - message, - }); - drop(state); - self.refresh_status().await; - Err(public) - } - } - } - - fn spawn_runtime_monitor(&self, slot: &LanguageSlot, runtime: Arc) { - let state = Arc::clone(&slot.state); - let status = self.status.clone(); - let lifetime = self.lifetime.clone(); - let language = profile_language(slot.profile.id()); - let mut process_state = runtime.subscribe_process_state(); - tokio::spawn(async move { - loop { - if !matches!( - *process_state.borrow(), - super::lsp::process::LspProcessState::Running - ) { - break; - } - tokio::select! { - _ = lifetime.cancelled() => return, - changed = process_state.changed() => { - if changed.is_err() { - break; - } - } - } - } - if lifetime.is_cancelled() { - return; - } - let message = runtime - .unavailable_message() - .unwrap_or_else(|| "the language runtime stopped unexpectedly".to_owned()); - let mut slot_state = state.lock().await; - let is_current = matches!( - &*slot_state, - SlotState::Ready(current) if Arc::ptr_eq(current, &runtime) - ); - if !is_current { - return; - } - if let Err(error) = runtime.shutdown().await { - tracing::warn!( - language = %language, - error = %error, - "Code Intelligence could not clean up a stopped language runtime" - ); - // Leave the current generation installed. `ensure_runtime` - // will retry its cleanup and must not start a replacement - // until shutdown confirms the process has been reaped. - drop(slot_state); - publish_stopped_language_status( - &status, - language, - format!("{message}; cleanup failed: {error}"), - ); - return; - } - *slot_state = SlotState::Dormant; - drop(slot_state); - publish_stopped_language_status(&status, language, message); - }); - } - async fn read_saved( &self, path: &WorkspacePath, @@ -821,6 +690,10 @@ impl WorkspaceRuntime { } async fn refresh_status(&self) { + let _status_update = self.status_updates.lock().await; + if self.shutting_down.load(Ordering::Acquire) { + return; + } let mut languages = Vec::new(); let mut capabilities = CodeIntelligenceCapabilities::default(); let mut ready = 0_usize; @@ -832,7 +705,7 @@ impl WorkspaceRuntime { } let state = slot.state.lock().await; let (runtime_state, runtime_capabilities, message) = match &*state { - SlotState::Dormant => { + SlotState::Dormant | SlotState::Starting(_) => { dormant += 1; ( CodeIntelligenceState::Starting, @@ -841,10 +714,19 @@ impl WorkspaceRuntime { ) } SlotState::Ready(runtime) => { - ready += 1; - let current = runtime.capabilities(); - union_capabilities(&mut capabilities, current); - (CodeIntelligenceState::Ready, current, None) + if let Some(message) = runtime.unavailable_message() { + failed += 1; + ( + CodeIntelligenceState::Unavailable, + CodeIntelligenceCapabilities::default(), + Some(message), + ) + } else { + ready += 1; + let current = runtime.capabilities(); + union_capabilities(&mut capabilities, current); + (CodeIntelligenceState::Ready, current, None) + } } SlotState::Failed(failure) => { failed += 1; diff --git a/core/src/code_intelligence/workspace_runtime/integration_test_support.rs b/core/src/code_intelligence/workspace_runtime/integration_test_support.rs new file mode 100644 index 00000000..a14e2264 --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime/integration_test_support.rs @@ -0,0 +1,57 @@ +use std::{path::Path, process::Command, sync::OnceLock}; + +pub(super) fn compile_fake_server(output: &Path) { + static BINARY: OnceLock> = OnceLock::new(); + + let binary = BINARY.get_or_init(|| { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/code_intelligence_fake_lsp.rs"); + let build_dir = tempfile::tempdir().expect("fake language server build directory"); + let binary = build_dir.path().join(if cfg!(windows) { + "code-intelligence-fake-lsp.exe" + } else { + "code-intelligence-fake-lsp" + }); + let result = Command::new("rustc") + .arg("--edition=2021") + .arg(source) + .arg("-o") + .arg(&binary) + .output() + .expect("rustc must be available while Cargo tests are running"); + assert!( + result.status.success(), + "failed to compile fake language server: {}", + String::from_utf8_lossy(&result.stderr) + ); + std::fs::read(binary).expect("read compiled fake language server") + }); + + std::fs::write(output, binary).expect("write fake language server fixture"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(output, std::fs::Permissions::from_mode(0o755)) + .expect("make fake language server executable"); + } +} + +pub(super) fn fixture_started_pids(log: &str) -> Vec { + log.lines() + .filter(|line| line.contains("\"event\":\"process_started\"")) + .filter_map(|line| { + line.split_once("\"pid\":")? + .1 + .trim_end_matches('}') + .parse() + .ok() + }) + .collect() +} + +#[cfg(unix)] +pub(super) fn process_exists(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} diff --git a/core/src/code_intelligence/workspace_runtime/integration_tests.rs b/core/src/code_intelligence/workspace_runtime/integration_tests.rs index 6f5a3963..bc441854 100644 --- a/core/src/code_intelligence/workspace_runtime/integration_tests.rs +++ b/core/src/code_intelligence/workspace_runtime/integration_tests.rs @@ -1,6 +1,5 @@ use std::{ path::{Path, PathBuf}, - process::Command, sync::{ atomic::{AtomicUsize, Ordering}, Arc, @@ -11,7 +10,12 @@ use std::{ use async_trait::async_trait; use tokio_util::sync::CancellationToken; -use super::WorkspaceRuntime; +#[cfg(unix)] +use super::integration_test_support::process_exists; +use super::{ + integration_test_support::{compile_fake_server, fixture_started_pids}, + WorkspaceRuntime, +}; use crate::{ code_intelligence::{ language_profile::LanguageServerProfile, @@ -102,27 +106,19 @@ fn write_workspace_files(root: &Path, files: &[(&str, &str)]) { } } -fn compile_fake_server(output: &Path) { - let source = - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/code_intelligence_fake_lsp.rs"); - let result = Command::new("rustc") - .arg("--edition=2021") - .arg(source) - .arg("-o") - .arg(output) - .output() - .expect("rustc must be available while Cargo tests are running"); - assert!( - result.status.success(), - "failed to compile fake language server: {}", - String::from_utf8_lossy(&result.stderr) - ); +fn test_runtime( + root: &Path, + snapshot: &LocalWorkspaceManifestSnapshot, + profiles: Vec, +) -> WorkspaceRuntime { + test_runtime_with_timeout(root, snapshot, profiles, TEST_QUERY_TIMEOUT) } -fn test_runtime( +fn test_runtime_with_timeout( root: &Path, snapshot: &LocalWorkspaceManifestSnapshot, profiles: Vec, + timeout: Duration, ) -> WorkspaceRuntime { let file_system: Arc = Arc::new(LocalWorkspaceBackend::new(root.to_path_buf())); @@ -131,7 +127,7 @@ fn test_runtime( ProjectLayoutResolver::resolve(snapshot), snapshot, file_system, - TEST_QUERY_TIMEOUT, + timeout, profiles, ) } @@ -154,6 +150,492 @@ fn test_runtime_with_file_system( ) } +#[tokio::test] +async fn abandoned_caller_does_not_restart_an_in_flight_language_runtime() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("package.json", "{}\n"), + ( + "src/main.ts", + "export function answer(): number { return 42; }\n", + ), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["package.json", "src/main.ts"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "slow-initialize-fake-lsp.exe" + } else { + "slow-initialize-fake-lsp" + }); + compile_fake_server(&server); + let runtime = Arc::new(test_runtime( + &root, + &snapshot, + vec![LanguageServerProfile::typescript_javascript(&server)], + )); + let path = WorkspacePath::from_normalized("src/main.ts"); + + let abandoned = { + let runtime = Arc::clone(&runtime); + let path = path.clone(); + tokio::spawn(async move { + runtime + .document_symbols(&path, CancellationToken::new()) + .await + }) + }; + let log_path = server.with_extension("log"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if std::fs::read_to_string(&log_path) + .is_ok_and(|log| log.contains("\"method\":\"initialize\"")) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the first runtime must begin initialization"); + + let surviving = { + let runtime = Arc::clone(&runtime); + let path = path.clone(); + tokio::spawn(async move { + runtime + .document_symbols(&path, CancellationToken::new()) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(25)).await; + abandoned.abort(); + assert!(abandoned.await.unwrap_err().is_cancelled()); + + let result = tokio::time::timeout(Duration::from_secs(3), surviving) + .await + .expect("the concurrent waiter must remain bounded") + .expect("the concurrent waiter task must not fail") + .expect("the concurrent waiter must reuse the in-flight runtime"); + assert_eq!(result.items.len(), 1); + + let mut status = runtime.subscribe_status(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if status.borrow().state == CodeIntelligenceState::Ready { + break; + } + status.changed().await.expect("runtime status channel"); + } + }) + .await + .expect("detached initialization must publish ready status"); + let current_status = status.borrow().clone(); + assert_eq!(current_status.languages.len(), 1); + assert_eq!( + current_status.languages[0].state, + CodeIntelligenceState::Ready + ); + assert!(current_status.languages[0].capabilities.document_symbols); + assert!(current_status.languages[0].message.is_none()); + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("runtime shutdown must remain bounded"); + + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!( + log.matches("\"method\":\"initialize\"").count(), + 1, + "abandoning one caller must not kill and restart shared initialization: {log}" + ); +} + +#[tokio::test] +async fn abandoned_caller_before_start_task_runs_does_not_strand_the_slot() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("package.json", "{}\n"), + ("src/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["package.json", "src/main.ts"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "preflight-slow-initialize-fake-lsp.exe" + } else { + "preflight-slow-initialize-fake-lsp" + }); + compile_fake_server(&server); + let runtime = Arc::new(test_runtime( + &root, + &snapshot, + vec![LanguageServerProfile::typescript_javascript(&server)], + )); + let path = WorkspacePath::from_normalized("src/main.ts"); + + // Hold status publication so the detached task cannot begin its attempt + // until after the initiating query has been force-abandoned. + let status_update = runtime.status_updates.lock().await; + let abandoned = { + let runtime = Arc::clone(&runtime); + let path = path.clone(); + tokio::spawn(async move { + runtime + .document_symbols(&path, CancellationToken::new()) + .await + }) + }; + let slot = runtime + .slots + .iter() + .find(|slot| slot.profile.id() == ProjectLanguageProfile::TypeScriptJavaScript) + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if matches!(&*slot.state.lock().await, super::SlotState::Starting(_)) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the slot must publish its shared starting generation"); + abandoned.abort(); + assert!(abandoned.await.unwrap_err().is_cancelled()); + drop(status_update); + + let result = tokio::time::timeout( + Duration::from_secs(10), + runtime.document_symbols(&path, CancellationToken::new()), + ) + .await + .expect("a later query must not wait on a stranded starting state") + .expect("a later query must share the detached start"); + assert_eq!(result.items.len(), 1); + let status = runtime.subscribe_status().borrow().clone(); + assert_eq!(status.state, CodeIntelligenceState::Ready); + assert_eq!(status.languages.len(), 1); + assert_eq!(status.languages[0].state, CodeIntelligenceState::Ready); + + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("runtime shutdown must remain bounded"); + let log = std::fs::read_to_string(server.with_extension("log")).unwrap(); + assert_eq!(log.matches("\"method\":\"initialize\"").count(), 1); +} + +#[tokio::test] +async fn shutdown_cancels_and_reaps_an_in_flight_language_runtime_start() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("package.json", "{}\n"), + ("src/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["package.json", "src/main.ts"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "shutdown-slow-initialize-fake-lsp.exe" + } else { + "shutdown-slow-initialize-fake-lsp" + }); + compile_fake_server(&server); + let runtime = Arc::new(test_runtime( + &root, + &snapshot, + vec![LanguageServerProfile::typescript_javascript(&server)], + )); + let query = { + let runtime = Arc::clone(&runtime); + tokio::spawn(async move { + runtime + .document_symbols( + &WorkspacePath::from_normalized("src/main.ts"), + CancellationToken::new(), + ) + .await + }) + }; + let log_path = server.with_extension("log"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if std::fs::read_to_string(&log_path) + .is_ok_and(|log| log.contains("\"method\":\"initialize\"")) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the runtime must begin initialization"); + + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("shutdown must cancel an in-flight startup within its host bound"); + let result = tokio::time::timeout(Duration::from_secs(1), query) + .await + .expect("the initiating query must settle after shutdown") + .expect("the query task must not panic"); + assert!(result.is_err()); + + let status = runtime.subscribe_status().borrow().clone(); + assert_eq!(status.state, CodeIntelligenceState::Unavailable); + assert!(status.languages.is_empty()); + assert_eq!( + status.message.as_deref(), + Some("Code Intelligence runtime is shut down") + ); + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!(log.matches("\"method\":\"initialize\"").count(), 1); +} + +#[tokio::test] +async fn forced_shutdown_reaps_an_unresponsive_language_runtime() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("package.json", "{}\n"), + ("src/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot(&root, &["package.json", "src/main.ts"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "ignore-shutdown-fake-lsp.exe" + } else { + "ignore-shutdown-fake-lsp" + }); + compile_fake_server(&server); + let runtime = Arc::new(test_runtime_with_timeout( + &root, + &snapshot, + vec![LanguageServerProfile::typescript_javascript(&server)], + Duration::from_secs(10), + )); + runtime + .document_symbols( + &WorkspacePath::from_normalized("src/main.ts"), + CancellationToken::new(), + ) + .await + .unwrap(); + let language_runtime = { + let state = runtime.slots[0].state.lock().await; + match &*state { + super::SlotState::Ready(runtime) => Arc::clone(runtime), + _ => panic!("language runtime must be ready before forced shutdown"), + } + }; + let process_state = language_runtime.subscribe_process_state(); + let log_path = server.with_extension("log"); + let log = std::fs::read_to_string(&log_path).unwrap(); + let pid = *fixture_started_pids(&log).last().unwrap(); + + tokio::time::timeout(Duration::from_secs(6), runtime.shutdown()) + .await + .expect("forced shutdown must remain within the workspace bound"); + assert!(!matches!( + *process_state.borrow(), + crate::code_intelligence::lsp::process::LspProcessState::Running + )); + #[cfg(unix)] + assert!( + !process_exists(pid), + "forced language process {pid} survived" + ); + let log = std::fs::read_to_string(log_path).unwrap(); + assert!(log.contains("\"method\":\"shutdown\"")); +} + +#[tokio::test] +async fn removing_a_source_during_start_reaps_it_before_the_slot_reopens() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("package.json", "{}\n"), + ("src/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let initial = snapshot(&root, &["package.json", "src/main.ts"]); + let server_dir = tempfile::tempdir().unwrap(); + let server = server_dir.path().join(if cfg!(windows) { + "source-removal-slow-initialize-fake-lsp.exe" + } else { + "source-removal-slow-initialize-fake-lsp" + }); + compile_fake_server(&server); + let runtime = Arc::new(test_runtime( + &root, + &initial, + vec![LanguageServerProfile::typescript_javascript(&server)], + )); + let path = WorkspacePath::from_normalized("src/main.ts"); + let query = { + let runtime = Arc::clone(&runtime); + let path = path.clone(); + tokio::spawn(async move { + runtime + .document_symbols(&path, CancellationToken::new()) + .await + }) + }; + let log_path = server.with_extension("log"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if std::fs::read_to_string(&log_path) + .is_ok_and(|log| log.contains("\"method\":\"initialize\"")) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the removed language runtime must begin initialization"); + + tokio::time::timeout( + Duration::from_secs(5), + runtime.update_snapshot(&snapshot(&root, &[])), + ) + .await + .expect("source removal cleanup must remain bounded"); + assert!(tokio::time::timeout(Duration::from_secs(1), query) + .await + .expect("the cancelled query must settle") + .expect("the cancelled query task must not panic") + .is_err()); + assert!(matches!( + *runtime.slots[0].state.lock().await, + super::SlotState::Dormant + )); + let first_log = std::fs::read_to_string(&log_path).unwrap(); + let first_pid = fixture_started_pids(&first_log)[0]; + assert!(first_log.contains(&format!( + "\"event\":\"process_exiting\",\"pid\":{first_pid}" + ))); + #[cfg(unix)] + assert!( + !process_exists(first_pid), + "removed language process {first_pid} survived cleanup" + ); + + runtime.update_snapshot(&initial).await; + runtime + .document_symbols(&path, CancellationToken::new()) + .await + .expect("the reopened slot must start a fresh generation"); + let final_log = std::fs::read_to_string(&log_path).unwrap(); + assert_eq!(fixture_started_pids(&final_log).len(), 2); + assert_eq!(final_log.matches("\"method\":\"initialize\"").count(), 2); + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("restored runtime shutdown must remain bounded"); +} + +#[tokio::test] +async fn abandoned_multi_language_start_publishes_a_complete_ready_snapshot() { + let workspace = tempfile::tempdir().unwrap(); + write_workspace_files( + workspace.path(), + &[ + ("Cargo.toml", "[package]\nname='fixture'\n"), + ("package.json", "{}\n"), + ("src/lib.rs", "pub fn answer() -> u32 { 42 }\n"), + ("web/main.ts", "export function answer() { return 42; }\n"), + ], + ); + let root = std::fs::canonicalize(workspace.path()).unwrap(); + let snapshot = snapshot( + &root, + &["Cargo.toml", "package.json", "src/lib.rs", "web/main.ts"], + ); + let server_dir = tempfile::tempdir().unwrap(); + let rust_server = server_dir.path().join(if cfg!(windows) { + "rust-slow-initialize-fake-lsp.exe" + } else { + "rust-slow-initialize-fake-lsp" + }); + let typescript_server = server_dir.path().join(if cfg!(windows) { + "typescript-slow-initialize-fake-lsp.exe" + } else { + "typescript-slow-initialize-fake-lsp" + }); + compile_fake_server(&rust_server); + compile_fake_server(&typescript_server); + let runtime = Arc::new(test_runtime( + &root, + &snapshot, + vec![ + LanguageServerProfile::rust(&rust_server), + LanguageServerProfile::typescript_javascript(&typescript_server), + ], + )); + let abandoned = { + let runtime = Arc::clone(&runtime); + tokio::spawn(async move { runtime.diagnostics(None, CancellationToken::new()).await }) + }; + let logs = [ + rust_server.with_extension("log"), + typescript_server.with_extension("log"), + ]; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if logs.iter().all(|log| { + std::fs::read_to_string(log) + .is_ok_and(|content| content.contains("\"method\":\"initialize\"")) + }) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("both language runtimes must begin initialization"); + abandoned.abort(); + assert!(abandoned.await.unwrap_err().is_cancelled()); + + let mut status = runtime.subscribe_status(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let current = status.borrow().clone(); + if current.state == CodeIntelligenceState::Ready + && current.languages.len() == 2 + && current + .languages + .iter() + .all(|language| language.state == CodeIntelligenceState::Ready) + { + break; + } + status.changed().await.expect("runtime status channel"); + } + }) + .await + .expect("both detached starts must publish a complete ready snapshot"); + + tokio::time::timeout(Duration::from_secs(2), runtime.shutdown()) + .await + .expect("multi-language shutdown must remain bounded"); + for log_path in logs { + let log = std::fs::read_to_string(log_path).unwrap(); + assert_eq!(log.matches("\"method\":\"initialize\"").count(), 1); + } +} + #[tokio::test] async fn saved_file_change_during_query_marks_the_document_result_stale() { let workspace = tempfile::tempdir().unwrap(); diff --git a/core/src/code_intelligence/workspace_runtime/lifecycle.rs b/core/src/code_intelligence/workspace_runtime/lifecycle.rs new file mode 100644 index 00000000..e31a7075 --- /dev/null +++ b/core/src/code_intelligence/workspace_runtime/lifecycle.rs @@ -0,0 +1,518 @@ +use super::*; + +impl WorkspaceRuntime { + pub(crate) async fn shutdown(&self) { + if self.shutting_down.swap(true, Ordering::AcqRel) { + return; + } + self.lifetime.cancel(); + let (runtimes, starts) = { + let _status_update = self.status_updates.lock().await; + let mut runtimes = Vec::new(); + let mut starts = Vec::new(); + for slot in &self.slots { + let mut state = slot.state.lock().await; + match std::mem::replace(&mut *state, SlotState::Dormant) { + SlotState::Ready(runtime) => runtimes.push(runtime), + SlotState::Starting(start) => starts.push(start), + SlotState::Failed(failure) => { + if let Some(runtime) = failure.retained_runtime { + runtimes.push(runtime); + } + } + SlotState::Dormant => {} + } + } + self.status.send_replace(CodeIntelligenceStatus { + state: CodeIntelligenceState::Unavailable, + message: Some("Code Intelligence runtime is shut down".to_owned()), + ..CodeIntelligenceStatus::default() + }); + (runtimes, starts) + }; + + self.stop_generations(&runtimes, &starts, "workspace shutdown") + .await; + } + + pub(super) async fn stop_generations( + &self, + runtimes: &[Arc], + starts: &[RuntimeStart], + operation: &'static str, + ) { + for start in starts { + start.cancellation.cancel(); + } + let cleanup = async { + let runtime_cleanup = async { + let mut shutdowns = FuturesUnordered::new(); + for runtime in runtimes { + shutdowns.push(runtime.shutdown()); + } + let mut complete = true; + while let Some(result) = shutdowns.next().await { + if let Err(error) = result { + complete = false; + tracing::warn!( + error = %error, + operation, + "Code Intelligence runtime cleanup failed" + ); + } + } + complete + }; + let start_cleanup = async { + let mut completions = FuturesUnordered::new(); + for start in starts { + completions.push(Self::wait_for_start_completion(start.outcome.clone())); + } + while completions.next().await.is_some() {} + }; + let (runtime_complete, ()) = tokio::join!(runtime_cleanup, start_cleanup); + runtime_complete + }; + let graceful = tokio::time::timeout(self.timeout.min(SHUTDOWN_GRACE), cleanup).await; + if !matches!(graceful, Ok(true)) { + tracing::warn!( + timeout = ?self.timeout.min(SHUTDOWN_GRACE), + operation, + "Code Intelligence cleanup exceeded its shutdown bound; forcing remaining processes" + ); + for start in starts { + start.abort.abort(); + } + for runtime in runtimes { + runtime.force_kill(); + } + let settle = async { + let runtime_settle = async { + let mut shutdowns = FuturesUnordered::new(); + for runtime in runtimes { + shutdowns.push(runtime.shutdown()); + } + while shutdowns.next().await.is_some() {} + }; + let start_settle = async { + let mut completions = FuturesUnordered::new(); + for start in starts { + completions.push(Self::wait_for_start_completion(start.outcome.clone())); + } + while completions.next().await.is_some() {} + }; + tokio::join!(runtime_settle, start_settle); + }; + let _ = tokio::time::timeout(SHUTDOWN_ABORT_SETTLE, settle).await; + } + } + + pub(super) async fn ensure_runtime( + &self, + slot: &LanguageSlot, + cancellation: &CancellationToken, + ) -> CodeIntelligenceResult> { + if self.shutting_down.load(Ordering::Acquire) { + return Err(CodeIntelligenceError::Unavailable { + message: "the workspace runtime is shutting down".to_owned(), + }); + } + let state = Arc::clone(&slot.state); + let mut state = tokio::select! { + _ = cancellation.cancelled() => return Err(CodeIntelligenceError::Cancelled), + state = state.lock_owned() => state, + }; + match &*state { + SlotState::Starting(start) => { + let outcome = start.outcome.clone(); + drop(state); + return Self::wait_for_runtime_start(outcome, cancellation).await; + } + SlotState::Ready(runtime) => { + if runtime.unavailable_message().is_none() { + return Ok(Arc::clone(runtime)); + } + } + SlotState::Failed(failure) if failure.at.elapsed() < START_RETRY_DELAY => { + return Err(CodeIntelligenceError::Unavailable { + message: failure.message.clone(), + }); + } + SlotState::Dormant | SlotState::Failed(_) => {} + } + if cancellation.is_cancelled() { + return Err(CodeIntelligenceError::Cancelled); + } + if self.shutting_down.load(Ordering::Acquire) { + return Err(CodeIntelligenceError::Unavailable { + message: "the workspace runtime is shutting down".to_owned(), + }); + } + + let retiring = match std::mem::replace(&mut *state, SlotState::Dormant) { + SlotState::Ready(runtime) => { + let message = runtime.unavailable_message().unwrap_or_else(|| { + "the previous language runtime is no longer usable".to_owned() + }); + Some((runtime, message)) + } + SlotState::Failed(failure) => failure + .retained_runtime + .map(|runtime| (runtime, failure.message)), + SlotState::Dormant => None, + SlotState::Starting(start) => { + let outcome = start.outcome.clone(); + *state = SlotState::Starting(start); + drop(state); + return Self::wait_for_runtime_start(outcome, cancellation).await; + } + }; + let generation = slot.generation.fetch_add(1, Ordering::AcqRel) + 1; + let start_cancellation = self.lifetime.child_token(); + let (outcome_tx, outcome_rx) = watch::channel(None); + let (begin_tx, begin_rx) = oneshot::channel(); + let task = self.spawn_runtime_start( + slot, + generation, + start_cancellation.clone(), + retiring, + begin_rx, + outcome_tx, + ); + *state = SlotState::Starting(RuntimeStart { + generation, + cancellation: start_cancellation, + outcome: outcome_rx.clone(), + abort: task.abort_handle(), + }); + drop(state); + let _ = begin_tx.send(()); + Self::wait_for_runtime_start(outcome_rx, cancellation).await + } + + fn spawn_runtime_start( + &self, + slot: &LanguageSlot, + generation: u64, + cancellation: CancellationToken, + retiring: Option<(Arc, String)>, + begin: oneshot::Receiver<()>, + outcome: watch::Sender>, + ) -> tokio::task::JoinHandle<()> { + let profile = slot.profile.clone(); + let profile_id = profile.id(); + let canonical_root = self.canonical_root.clone(); + let layout = self.layout.clone(); + let documents = Arc::clone(&slot.documents); + let diagnostics = Arc::clone(&self.diagnostics); + let timeout = self.timeout; + let slot_state = Arc::clone(&slot.state); + let status = self.status.clone(); + let status_updates = Arc::clone(&self.status_updates); + let lifetime = self.lifetime.clone(); + + tokio::spawn(async move { + if begin.await.is_err() { + return; + } + { + let _status_update = status_updates.lock().await; + let state = slot_state.lock().await; + let is_current = matches!( + &*state, + SlotState::Starting(start) if start.generation == generation + ); + if is_current && !lifetime.is_cancelled() && !cancellation.is_cancelled() { + publish_language_status( + &status, + profile_language(profile_id), + CodeIntelligenceState::Starting, + CodeIntelligenceCapabilities::default(), + Some("language runtime is starting".to_owned()), + "one or more language runtimes are unavailable", + ); + } + } + let attempt = AssertUnwindSafe(async { + if let Some((runtime, message)) = retiring { + tracing::warn!( + language = %profile_language(profile_id), + message, + "Code Intelligence will restart an exited language runtime" + ); + let retired = tokio::select! { + _ = cancellation.cancelled() => { + return Err(StartAttemptFailure { + public: CodeIntelligenceError::Unavailable { + message: "language runtime start was cancelled".to_owned(), + }, + message: "language runtime start was cancelled".to_owned(), + retained_runtime: Some(runtime), + }); + } + result = runtime.shutdown() => result, + }; + if let Err(error) = retired { + tracing::warn!( + language = %profile_language(profile_id), + error = %error, + "Code Intelligence could not fully retire an exited language runtime" + ); + let message = error.to_string(); + return Err(StartAttemptFailure { + public: map_language_error(profile_id, error), + message, + retained_runtime: Some(runtime), + }); + } + } + if cancellation.is_cancelled() { + return Err(StartAttemptFailure { + public: CodeIntelligenceError::Unavailable { + message: "language runtime start was cancelled".to_owned(), + }, + message: "language runtime start was cancelled".to_owned(), + retained_runtime: None, + }); + } + LanguageRuntime::start( + profile, + canonical_root, + layout, + documents, + diagnostics, + cancellation.clone(), + timeout, + ) + .await + .map(Arc::new) + .map_err(|error| { + let message = error.to_string(); + StartAttemptFailure { + public: map_language_error(profile_id, error), + message, + retained_runtime: None, + } + }) + }) + .catch_unwind() + .await + .unwrap_or_else(|payload| { + let message = panic_payload_to_string(payload); + Err(StartAttemptFailure { + public: CodeIntelligenceError::Unavailable { + message: format!("language runtime start task panicked: {message}"), + }, + message: format!("language runtime start task panicked: {message}"), + retained_runtime: None, + }) + }); + + let mut cleanup_runtime = None; + let mut monitor_runtime = None; + let public_outcome; + { + let _status_update = status_updates.lock().await; + let mut state = slot_state.lock().await; + let is_current = matches!( + &*state, + SlotState::Starting(start) if start.generation == generation + ); + if is_current && !lifetime.is_cancelled() && !cancellation.is_cancelled() { + match attempt { + Ok(runtime) => { + let capabilities = runtime.capabilities(); + *state = SlotState::Ready(Arc::clone(&runtime)); + publish_language_status( + &status, + profile_language(profile_id), + CodeIntelligenceState::Ready, + capabilities, + None, + "one or more language runtimes are unavailable", + ); + public_outcome = Ok(Arc::clone(&runtime)); + monitor_runtime = Some(runtime); + } + Err(failure) => { + public_outcome = Err(failure.public.clone()); + publish_language_status( + &status, + profile_language(profile_id), + CodeIntelligenceState::Unavailable, + CodeIntelligenceCapabilities::default(), + Some(failure.message.clone()), + "one or more language runtimes are unavailable", + ); + *state = SlotState::Failed(StartFailure { + at: Instant::now(), + message: failure.message, + retained_runtime: failure.retained_runtime, + }); + } + } + } else { + cleanup_runtime = match attempt { + Ok(runtime) => Some(runtime), + Err(failure) => failure.retained_runtime, + }; + public_outcome = Err(CodeIntelligenceError::Unavailable { + message: "language runtime start is no longer current".to_owned(), + }); + } + } + + if let Some(runtime) = cleanup_runtime { + if let Err(error) = runtime.shutdown().await { + tracing::warn!( + language = %profile_language(profile_id), + error = %error, + "Code Intelligence could not retire a cancelled language runtime start" + ); + } + } + if let Some(runtime) = monitor_runtime { + Self::spawn_runtime_monitor( + slot_state, + status, + status_updates, + lifetime, + profile_id, + runtime, + ); + } + outcome.send_replace(Some(public_outcome)); + }) + } + + async fn wait_for_runtime_start( + mut outcome: watch::Receiver>, + cancellation: &CancellationToken, + ) -> RuntimeStartOutcome { + loop { + if cancellation.is_cancelled() { + return Err(CodeIntelligenceError::Cancelled); + } + if let Some(result) = outcome.borrow().clone() { + return result; + } + tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(CodeIntelligenceError::Cancelled), + changed = outcome.changed() => { + if changed.is_err() { + return Err(CodeIntelligenceError::Unavailable { + message: "language runtime start task ended before publishing an outcome".to_owned(), + }); + } + } + } + } + } + + async fn wait_for_start_completion(mut outcome: watch::Receiver>) { + loop { + if outcome.borrow().is_some() { + return; + } + if outcome.changed().await.is_err() { + return; + } + } + } + + fn spawn_runtime_monitor( + state: Arc>, + status: watch::Sender, + status_updates: Arc>, + lifetime: CancellationToken, + profile: ProjectLanguageProfile, + runtime: Arc, + ) { + let language = profile_language(profile); + let mut process_state = runtime.subscribe_process_state(); + tokio::spawn(async move { + loop { + if !matches!( + *process_state.borrow(), + super::super::lsp::process::LspProcessState::Running + ) { + break; + } + tokio::select! { + _ = lifetime.cancelled() => return, + changed = process_state.changed() => { + if changed.is_err() { + break; + } + } + } + } + if lifetime.is_cancelled() { + return; + } + let message = runtime + .unavailable_message() + .unwrap_or_else(|| "the language runtime stopped unexpectedly".to_owned()); + { + let _status_update = status_updates.lock().await; + let mut slot_state = state.lock().await; + let is_current = matches!( + &*slot_state, + SlotState::Ready(current) if Arc::ptr_eq(current, &runtime) + ); + if !is_current || lifetime.is_cancelled() { + return; + } + *slot_state = SlotState::Failed(StartFailure { + at: Instant::now(), + message: message.clone(), + retained_runtime: Some(Arc::clone(&runtime)), + }); + publish_stopped_language_status(&status, language.clone(), message.clone()); + } + + let cleanup = runtime.shutdown().await; + let _status_update = status_updates.lock().await; + let mut slot_state = state.lock().await; + let is_current = matches!( + &*slot_state, + SlotState::Failed(failure) + if failure.retained_runtime.as_ref().is_some_and(|current| Arc::ptr_eq(current, &runtime)) + ); + if !is_current { + return; + } + match cleanup { + Ok(()) => { + *slot_state = SlotState::Dormant; + publish_stopped_language_status(&status, language, message); + } + Err(error) => { + let message = format!("{message}; cleanup failed: {error}"); + tracing::warn!( + language = %language, + error = %error, + "Code Intelligence could not clean up a stopped language runtime" + ); + *slot_state = SlotState::Failed(StartFailure { + at: Instant::now(), + message: message.clone(), + retained_runtime: Some(runtime), + }); + publish_stopped_language_status(&status, language, message); + } + } + }); + } +} + +fn panic_payload_to_string(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_owned(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "unknown panic payload".to_owned() +} diff --git a/core/src/code_intelligence/workspace_runtime/support.rs b/core/src/code_intelligence/workspace_runtime/support.rs index cda4271b..93d0f279 100644 --- a/core/src/code_intelligence/workspace_runtime/support.rs +++ b/core/src/code_intelligence/workspace_runtime/support.rs @@ -233,10 +233,13 @@ pub(super) fn union_capabilities( target.diagnostics |= source.diagnostics; } -pub(super) fn publish_stopped_language_status( +pub(super) fn publish_language_status( sender: &watch::Sender, language: LanguageId, - message: String, + state: CodeIntelligenceState, + runtime_capabilities: CodeIntelligenceCapabilities, + runtime_message: Option, + unavailable_summary: &'static str, ) { let mut status = sender.borrow().clone(); if let Some(current) = status @@ -244,15 +247,15 @@ pub(super) fn publish_stopped_language_status( .iter_mut() .find(|current| current.language == language) { - current.state = CodeIntelligenceState::Unavailable; - current.capabilities = CodeIntelligenceCapabilities::default(); - current.message = Some(message); + current.state = state; + current.capabilities = runtime_capabilities; + current.message = runtime_message; } else { status.languages.push(CodeIntelligenceLanguageStatus { language, - state: CodeIntelligenceState::Unavailable, - capabilities: CodeIntelligenceCapabilities::default(), - message: Some(message), + state, + capabilities: runtime_capabilities, + message: runtime_message, }); } @@ -287,10 +290,25 @@ pub(super) fn publish_stopped_language_status( CodeIntelligenceState::Unavailable }; status.capabilities = capabilities; - status.message = Some("one or more language runtimes stopped unexpectedly".to_owned()); + status.message = (unavailable > 0).then(|| unavailable_summary.to_owned()); sender.send_replace(status); } +pub(super) fn publish_stopped_language_status( + sender: &watch::Sender, + language: LanguageId, + message: String, +) { + publish_language_status( + sender, + language, + CodeIntelligenceState::Unavailable, + CodeIntelligenceCapabilities::default(), + Some(message), + "one or more language runtimes stopped unexpectedly", + ); +} + pub(super) fn symbol_key(symbol: &SymbolInformation) -> (String, u32, u32, String) { ( symbol.location.path.as_str().to_owned(), diff --git a/core/src/code_intelligence/workspace_runtime/tests.rs b/core/src/code_intelligence/workspace_runtime/tests.rs index 0488b0b7..24e64a7c 100644 --- a/core/src/code_intelligence/workspace_runtime/tests.rs +++ b/core/src/code_intelligence/workspace_runtime/tests.rs @@ -158,6 +158,7 @@ async fn removing_the_last_supported_source_resets_failed_runtime_state() { *rust.state.lock().await = SlotState::Failed(StartFailure { at: Instant::now(), message: "failed".to_owned(), + retained_runtime: None, }); runtime.update_snapshot(&snapshot(&root, 2, &[])).await; diff --git a/core/tests/fixtures/code_intelligence_fake_lsp.rs b/core/tests/fixtures/code_intelligence_fake_lsp.rs index 32ee550e..eea17a59 100644 --- a/core/tests/fixtures/code_intelligence_fake_lsp.rs +++ b/core/tests/fixtures/code_intelligence_fake_lsp.rs @@ -11,7 +11,15 @@ fn main() -> io::Result<()> { .file_name() .and_then(|name| name.to_str()) .unwrap_or_default(); + append_log( + &log_path, + &format!( + "{{\"event\":\"process_started\",\"pid\":{}}}", + std::process::id() + ), + )?; let push_diagnostics = executable_name.contains("push-diagnostics"); + let ignore_shutdown = executable_name.contains("ignore-shutdown"); let cold_navigation = if executable_name.contains("cold-empty") { ColdNavigation::Empty } else if executable_name.contains("cold-partial") { @@ -29,6 +37,9 @@ fn main() -> io::Result<()> { while let Some(body) = read_message(&mut input)? { append_log(&log_path, &body)?; let method = string_field(&body, "method").unwrap_or_default(); + if executable_name.contains("slow-initialize") && method == "initialize" { + std::thread::sleep(std::time::Duration::from_millis(500)); + } if method == "workspace/symbol" && body.contains("\"query\":\"terminate-process\"") { eprintln!("fixture language server terminated unexpectedly"); std::process::exit(12); @@ -42,6 +53,9 @@ fn main() -> io::Result<()> { } } } + if ignore_shutdown && matches!(method.as_str(), "shutdown" | "exit") { + continue; + } let Some(id) = request_id(&body) else { if method == "exit" { @@ -64,6 +78,13 @@ fn main() -> io::Result<()> { &format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}"), )?; } + append_log( + &log_path, + &format!( + "{{\"event\":\"process_exiting\",\"pid\":{}}}", + std::process::id() + ), + )?; Ok(()) } diff --git a/scripts/check_release_versions.sh b/scripts/check_release_versions.sh index 60c5dfac..9992b64e 100755 --- a/scripts/check_release_versions.sh +++ b/scripts/check_release_versions.sh @@ -109,6 +109,8 @@ def check_node_lockfile(path): for name, value in optional.items(): if name.startswith("@a3s-lab/code-"): check_equal(f"{path} package {key or ''} optionalDependency {name}", value) + if key.startswith("node_modules/@a3s-lab/code-") and package.get("version") is not None: + check_equal(f"{path} platform package {key}", package.get("version")) def check_bootstrap_runtime_version(path): @@ -143,7 +145,9 @@ check_bootstrap_runtime_version("sdk/python-bootstrap/src/a3s_code/_bootstrap.py check_changelog("CHANGELOG.md") check_changelog("sdk/python/CHANGELOG.md") check_cargo_lock("Cargo.lock") +check_cargo_lock("sdk/node/Cargo.lock") check_cargo_lock_package("sdk/node/Cargo.lock", "a3s-code-node") +check_cargo_lock("sdk/python/Cargo.lock") check_cargo_lock_package("sdk/python/Cargo.lock", "a3s-code-py") check_node_lockfile("sdk/node/package-lock.json") check_node_lockfile("sdk/node/examples/package-lock.json") diff --git a/scripts/check_semver.sh b/scripts/check_semver.sh index 2511c56d..de17a17a 100644 --- a/scripts/check_semver.sh +++ b/scripts/check_semver.sh @@ -3,10 +3,16 @@ set -euo pipefail -BASELINE_VERSION="${1:-5.2.8}" +BASELINE_VERSION="${1:-5.3.2}" PACKAGE="a3s-code-core" case "$BASELINE_VERSION" in + 5.3.2) + BASELINE_SHA256="da8f43aa04ca80edbc543575a5b2bfc9e1cf48e76aecf74a61f67d536f8172c2" + ;; + 5.3.1) + BASELINE_SHA256="ffb2d920c288247b0e5733eb7f8265b1dccbb5fa0926fc2dc10431d70deb486b" + ;; 5.2.8) BASELINE_SHA256="059e9eefe6f2d0b816b9ec9f906878413a2f30fd1bc90a751c53b77972ff84a7" ;; diff --git a/sdk/node/Cargo.lock b/sdk/node/Cargo.lock index b5c017cc..458c11be 100644 --- a/sdk/node/Cargo.lock +++ b/sdk/node/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.2" +version = "5.3.3" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-node" -version = "5.3.2" +version = "5.3.3" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/node/Cargo.toml b/sdk/node/Cargo.toml index dd26812c..029e6a02 100644 --- a/sdk/node/Cargo.toml +++ b/sdk/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-node" -version = "5.3.2" +version = "5.3.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -11,7 +11,7 @@ description = "A3S Code Node.js bindings - Native addon via napi-rs" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.3.2", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.3", path = "../../core", features = ["s3", "serve"] } napi = { version = "2", features = ["async", "napi6", "serde-json"] } napi-derive = "2" tokio = { version = "1.35", features = ["full"] } diff --git a/sdk/node/examples/package-lock.json b/sdk/node/examples/package-lock.json index e51f228d..c2beba2d 100644 --- a/sdk/node/examples/package-lock.json +++ b/sdk/node/examples/package-lock.json @@ -18,7 +18,7 @@ }, "..": { "name": "@a3s-lab/code", - "version": "5.3.2", + "version": "5.3.3", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -27,12 +27,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.2", - "@a3s-lab/code-linux-arm64-gnu": "5.3.2", - "@a3s-lab/code-linux-arm64-musl": "5.3.2", - "@a3s-lab/code-linux-x64-gnu": "5.3.2", - "@a3s-lab/code-linux-x64-musl": "5.3.2", - "@a3s-lab/code-win32-x64-msvc": "5.3.2" + "@a3s-lab/code-darwin-arm64": "5.3.3", + "@a3s-lab/code-linux-arm64-gnu": "5.3.3", + "@a3s-lab/code-linux-arm64-musl": "5.3.3", + "@a3s-lab/code-linux-x64-gnu": "5.3.3", + "@a3s-lab/code-linux-x64-musl": "5.3.3", + "@a3s-lab/code-win32-x64-msvc": "5.3.3" } }, "node_modules/@a3s-lab/code": { diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index ff00ef12..57423bfa 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a3s-lab/code", - "version": "5.3.2", + "version": "5.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a3s-lab/code", - "version": "5.3.2", + "version": "5.3.3", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2", @@ -15,12 +15,12 @@ "typescript": "^5.9.3" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.2", - "@a3s-lab/code-linux-arm64-gnu": "5.3.2", - "@a3s-lab/code-linux-arm64-musl": "5.3.2", - "@a3s-lab/code-linux-x64-gnu": "5.3.2", - "@a3s-lab/code-linux-x64-musl": "5.3.2", - "@a3s-lab/code-win32-x64-msvc": "5.3.2" + "@a3s-lab/code-darwin-arm64": "5.3.3", + "@a3s-lab/code-linux-arm64-gnu": "5.3.3", + "@a3s-lab/code-linux-arm64-musl": "5.3.3", + "@a3s-lab/code-linux-x64-gnu": "5.3.3", + "@a3s-lab/code-linux-x64-musl": "5.3.3", + "@a3s-lab/code-win32-x64-msvc": "5.3.3" } }, "node_modules/@a3s-lab/code-darwin-arm64": { diff --git a/sdk/node/package.json b/sdk/node/package.json index d2cf816f..cd948dec 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@a3s-lab/code", - "version": "5.3.2", + "version": "5.3.3", "description": "A3S Code - Native Node.js bindings for the coding-agent runtime", "main": "index.js", "types": "index.d.ts", @@ -44,11 +44,11 @@ "test:helpers": "node test-helpers.mjs" }, "optionalDependencies": { - "@a3s-lab/code-darwin-arm64": "5.3.2", - "@a3s-lab/code-linux-x64-gnu": "5.3.2", - "@a3s-lab/code-linux-x64-musl": "5.3.2", - "@a3s-lab/code-linux-arm64-gnu": "5.3.2", - "@a3s-lab/code-linux-arm64-musl": "5.3.2", - "@a3s-lab/code-win32-x64-msvc": "5.3.2" + "@a3s-lab/code-darwin-arm64": "5.3.3", + "@a3s-lab/code-linux-x64-gnu": "5.3.3", + "@a3s-lab/code-linux-x64-musl": "5.3.3", + "@a3s-lab/code-linux-arm64-gnu": "5.3.3", + "@a3s-lab/code-linux-arm64-musl": "5.3.3", + "@a3s-lab/code-win32-x64-msvc": "5.3.3" } } diff --git a/sdk/python-bootstrap/pyproject.toml b/sdk/python-bootstrap/pyproject.toml index 627a2c8b..4cc0efb7 100644 --- a/sdk/python-bootstrap/pyproject.toml +++ b/sdk/python-bootstrap/pyproject.toml @@ -7,7 +7,7 @@ name = "a3s-code" # Keep in sync with crates/code core release. The bootstrap loader fetches # the matching native wheel from `https://github.com/AI45Lab/Code/releases/tag/v` # at import time. -version = "5.3.2" +version = "5.3.3" description = "A3S Code Python SDK — pure-Python bootstrap that fetches the native wheel from GitHub Releases" readme = "README.md" license = {text = "MIT"} diff --git a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py index f5c3a2a9..ae2c6352 100644 --- a/sdk/python-bootstrap/src/a3s_code/_bootstrap.py +++ b/sdk/python-bootstrap/src/a3s_code/_bootstrap.py @@ -31,7 +31,7 @@ # Version is the bootstrap's own version, which equals the matching native # wheel version on GH Releases. Bumped by the release workflow. -__version__ = "5.3.2" +__version__ = "5.3.3" _DEFAULT_BASE_URL = "https://github.com/A3S-Lab/Code/releases/download" _REQUEST_TIMEOUT_S = 120 diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 351e19dc..4eacad44 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the A3S Code Python SDK will be documented in this file. ## [Unreleased] +## [5.3.3] - 2026-07-16 + +### Fixed + +- Updated the bundled Core so abandoned semantic queries share cancellation-safe + language-runtime startup and workspace shutdown remains bounded. + ## [5.3.2] - 2026-07-16 ### Fixed diff --git a/sdk/python/Cargo.lock b/sdk/python/Cargo.lock index d70ef99f..995d105f 100644 --- a/sdk/python/Cargo.lock +++ b/sdk/python/Cargo.lock @@ -15,7 +15,7 @@ source = "git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ac [[package]] name = "a3s-code-core" -version = "5.3.2" +version = "5.3.3" dependencies = [ "a3s-acl 0.2.1 (git+https://github.com/A3S-Lab/ACL.git?rev=6e2a6469edc0f4c61b1e588d0ace873aaf15ce22)", "a3s-common", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "a3s-code-py" -version = "5.3.2" +version = "5.3.3" dependencies = [ "a3s-code-core", "anyhow", diff --git a/sdk/python/Cargo.toml b/sdk/python/Cargo.toml index e36dd13c..fd2824a1 100644 --- a/sdk/python/Cargo.toml +++ b/sdk/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s-code-py" -version = "5.3.2" +version = "5.3.3" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" @@ -12,7 +12,7 @@ name = "a3s_code" crate-type = ["cdylib"] [dependencies] -a3s-code-core = { version = "5.3.2", path = "../../core", features = ["s3", "serve"] } +a3s-code-core = { version = "5.3.3", path = "../../core", features = ["s3", "serve"] } pyo3 = { version = "0.23", features = ["multiple-pymethods"] } tokio = { version = "1.35", features = ["full"] } serde_json = "1.0" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9fb7ebb8..40f1a2b0 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "a3s-code" -version = "5.3.2" +version = "5.3.3" description = "A3S Code - Native Python bindings for the coding-agent runtime" readme = "README.md" license = {text = "MIT"}