diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index f9fa8ca..000fe34 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -2570,7 +2570,12 @@ its optional `hostOptions` (plugin-owned sessions only; `editor:{actionIcons, actionSourceBytes, providerTypes}` declares the icons, collector source bound and provider types the host implements, reported through `lithe/host/getCapabilities` to sessions granted the matching editor methods), plus -initialize, request, and shutdown deadlines. +initialize, request, and shutdown deadlines. Omitted +`initializeTimeoutMilliseconds` allows 60000 milliseconds for one startup +handshake (previously 10000); cold runtime loading can exceed ten seconds. +An explicit deadline still takes precedence. Cancellation and timeout still +terminate and dispose of the owned process tree; this does not add retries or +change feature-request deadlines. Rust owns the returned session's child process, stdin/stdout/stderr, framing buffer, JSON-RPC request IDs, document versions, pending deadlines, capabilities, diagnostics, and diff --git a/rust/crates/core/src/lsp/interface/engine.rs b/rust/crates/core/src/lsp/interface/engine.rs index 1a0c139..12675c9 100644 --- a/rust/crates/core/src/lsp/interface/engine.rs +++ b/rust/crates/core/src/lsp/interface/engine.rs @@ -36,7 +36,10 @@ use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; -const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 10_000; +// Cold language-server runtimes can spend more than ten seconds loading their +// bundles before answering initialize. This remains one bounded handshake, +// independent of request deadlines, with normal cancellation and disposal. +const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 60_000; const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; /// Upper bound of one build-provider request. A model request can load a @@ -6233,6 +6236,28 @@ mod tests { } } + /// A real cold Windows language server can take longer than ten seconds. + /// Exercise the omitted-field default with an injected instant: it must + /// allow that handshake while still disposing of a server that never replies. + #[test] + fn default_initialize_budget_survives_cold_start_and_still_expires() { + let parsed: StartServerRequest = serde_json::from_value(json!({ + "providerId": "cold-fixture", "executablePath": "/scripted-server", + "rootUri": "file:///workspace", "workingDirectory": "/workspace" + })) + .unwrap(); + let harness = Harness::start(|request| { + request.initialize_timeout_milliseconds = parsed.initialize_timeout_milliseconds; + }); + let session = harness.session(); + let now = Instant::now(); + session.expire_deadlines_at(now + Duration::from_secs(15)); + assert_eq!(harness.snapshot().state, LspLifecycleState::Initializing); + session.expire_deadlines_at(now + Duration::from_secs(61)); + assert_eq!(harness.snapshot().state, LspLifecycleState::Failed); + assert!(session.process.exit_status().is_some()); + } + /// Criterion 1: a spawned process that never initializes cannot become ready. #[test] fn a_server_that_never_answers_initialize_fails_instead_of_becoming_ready() {