Synced from monorepo

Synced from monorepo

Changes:
- Temporarily disable session share link creation in the TUI
- Do not approve plan on empty Enter from the revise prompt
- Expose chat product Skills via ACP available_commands_update
- Return immediately from a blocking wait on an already-completed ACP task
- Split headless pager module for clearer structure
- Stop git worktree prune from removing user registrations on resume
- Use compaction sampler tokenizer for item token counts
- Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE
- Cancel all session subagents when the user stops
- Let the session persistence actor exit when its session ends
- Make fullscreen terminal resize much cheaper on long sessions
- Report honestly from kill_task when an ACP task does not exist
- Hide /usage for external-auth deployments
- Forward the history-load trailer’s computer_reason to the client
- Remove ineffective no-op tool reminder
- Declare slash-command screen-mode support in one place
- Keep settings enum picker on the committed value until Enter
- Reap a PTY’s full process tree
- Stream tool calls from headless mode over ACP
- Bridge gateway task lifecycle to ACP for chat session background tasks
- Don’t warn about truncated history on a suppressed replay
- Fit full-replace summarizer input and recover on context-length errors
- Stop dropping agents over an unrecognized frontmatter color
- Add /undo as a slash alias for /rewind
- Harden sleep/wake token-refresh paths against forced re-login
- Add session/list ACP method
- Give each sampling backend its own conversion module
- Treat an unenrolled child process as a lint error
- Suppress the cancelled marker on send-now wake turns
- Stop tearing down Roslyn on every edit, and read C# diagnostics

Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
grokkybara[bot] 2026-07-30 19:07:40 +00:00
commit dd04f397b1
367 changed files with 29489 additions and 10051 deletions

View file

@ -16,6 +16,7 @@ educe = { workspace = true, features = ["Debug"] }
async-openai = { workspace = true }
xai-tty-utils = { workspace = true }
xai-grok-config = { path = "../xai-grok-config" }
xai-grok-extra-ca = { workspace = true }
xai-grok-tools-api = { path = "../xai-grok-tools-api" }
xai-grok-workspace-types = { path = "../xai-grok-workspace-types" }
xai-grok-version = { workspace = true }

View file

@ -2,7 +2,11 @@
use std::sync::Arc;
/// Bearer prefix length shared across crate boundaries.
/// Bearer-fragment length shared across crate boundaries. The fragment
/// is the **last** N characters (the tail): JWT session bearers all share
/// the same base64 header, so only the tail distinguishes tokens. Mirrors
/// `token_suffix` in xai-grok-shell, which the shell's 401-attribution
/// event compares this fragment against.
pub const SENT_BEARER_PREFIX_LEN: usize = 12;
/// Which tool endpoint produced the 401.
@ -36,30 +40,30 @@ pub trait Auth401AttributionCallback: Send + Sync + std::fmt::Debug {
pub type SharedAttributionCallback = Arc<dyn Auth401AttributionCallback>;
/// Record a 401 attribution event if a callback is wired. Truncates
/// the bearer to [`SENT_BEARER_PREFIX_LEN`] before crossing the
/// trait boundary.
/// the bearer to its [`SENT_BEARER_PREFIX_LEN`]-char tail before
/// crossing the trait boundary, so only the fragment is materialized
/// as an owned copy.
pub(crate) fn emit_401(
callback: Option<&SharedAttributionCallback>,
consumer: ToolConsumer,
sent_bearer: Option<&str>,
) {
if let Some(cb) = callback {
let prefix = sent_bearer.map(|s| truncate_to_prefix(s.to_string()));
let prefix = sent_bearer.map(|s| tail_fragment(s).to_string());
cb.record_401(consumer, prefix.as_deref());
}
}
/// Truncate a bearer string to the first [`SENT_BEARER_PREFIX_LEN`]
/// characters. Used by tool clients before passing the bearer across
/// the [`Auth401AttributionCallback`] boundary.
///
/// Bearer tokens are ASCII (per the `Authorization` header grammar)
/// so the byte index is always a char boundary; this function uses
/// `String::truncate` which would otherwise panic on a non-boundary
/// cut.
pub(crate) fn truncate_to_prefix(mut bearer: String) -> String {
bearer.truncate(SENT_BEARER_PREFIX_LEN.min(bearer.len()));
bearer
/// Last [`SENT_BEARER_PREFIX_LEN`] characters of a bearer (see the
/// constant's doc for why the tail, not the head). Used by tool clients
/// before passing the bearer across the [`Auth401AttributionCallback`]
/// boundary. Counts chars from the end, so it cannot panic on a
/// non-char-boundary cut for non-ASCII input.
pub(crate) fn tail_fragment(s: &str) -> &str {
match s.char_indices().rev().nth(SENT_BEARER_PREFIX_LEN - 1) {
Some((i, _)) => &s[i..],
None => s,
}
}
#[cfg(test)]
@ -67,30 +71,17 @@ mod tests {
use super::*;
#[test]
fn truncate_to_prefix_long_string_cuts_at_12() {
fn tail_fragment_semantics() {
// The tail, not the head: heads are shared across xai keys/JWTs.
assert_eq!(
truncate_to_prefix("xai-key-aaaaaaaaaaaaaaaaaaa".to_string()),
"xai-key-aaaa"
tail_fragment("xai-key-aaaaaaaaaaadistinct1"),
"aaadistinct1"
);
}
#[test]
fn truncate_to_prefix_short_string_unchanged() {
assert_eq!(truncate_to_prefix("abc".to_string()), "abc");
}
#[test]
fn truncate_to_prefix_exact_12_unchanged() {
assert_eq!(
truncate_to_prefix("123456789012".to_string()),
"123456789012"
);
assert_eq!(truncate_to_prefix("123456789012".to_string()).len(), 12);
}
#[test]
fn truncate_to_prefix_empty_unchanged() {
assert_eq!(truncate_to_prefix(String::new()), "");
assert_eq!(tail_fragment("abc"), "abc");
assert_eq!(tail_fragment(""), "");
assert_eq!(tail_fragment("123456789012"), "123456789012");
// 13 multi-byte chars: a byte-index cut would land mid-char.
assert_eq!(tail_fragment("ééééééééééééé"), "éééééééééééé");
}
#[test]

View file

@ -314,6 +314,7 @@ impl ShellState {
}
crate::util::apply_shell_environment_policy(&mut cmd, shell_env_policy);
cmd.envs(crate::util::pager_env());
#[allow(clippy::disallowed_methods)] // one-shot init run, waited on here
let mut child = cmd.spawn().map_err(|e| {
crate::computer::types::ComputerError::io(format!(
"failed to spawn {shell:?} for shell state init: {e}"
@ -917,6 +918,7 @@ mod tests {
cmd.fd_mappings(prep.fd_mappings).unwrap();
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
let child = cmd.spawn().unwrap();
// Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure.
// Without this, the parent keeps the write-end of the state-out pipe open,
@ -974,6 +976,7 @@ mod tests {
.stderr(Stdio::piped())
.kill_on_drop(true);
cmd.fd_mappings(prep.fd_mappings).unwrap();
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
let child = cmd.spawn().unwrap();
drop(cmd);

View file

@ -78,6 +78,7 @@ impl StaticShellSnapshot {
.kill_on_drop(true);
crate::util::detach_command(&mut cmd);
cmd.envs(crate::util::pager_env());
#[allow(clippy::disallowed_methods)] // probe killed on drop
let mut child = cmd.spawn().ok()?;
let mut stdout_buf = Vec::new();
@ -268,6 +269,7 @@ mod tests {
.stderr(Stdio::piped())
.kill_on_drop(true);
cmd.fd_mappings(prep.fd_mappings).unwrap();
#[allow(clippy::disallowed_methods)] // test fixture; the test reaps it
let child = cmd.spawn().unwrap();
drop(cmd);

View file

@ -702,6 +702,7 @@ impl LocalTerminalActor {
}
}
#[allow(clippy::disallowed_methods)] // attached to a process group below
let child = cmd.spawn().map_err(|e| {
ComputerError::io_with_kind(format!("spawn shell in {}: {e}", cwd.display()), e.kind())
})?;
@ -822,6 +823,7 @@ impl LocalTerminalActor {
}
}
#[allow(clippy::disallowed_methods)] // attached to a process group below
let child = cmd.spawn().map_err(|e| {
ComputerError::io_with_kind(
format!("spawn shell in {}: {e}", prep.cwd.display()),
@ -2943,6 +2945,7 @@ async fn capture_login_env() -> HashMap<String, String> {
.kill_on_drop(true);
crate::util::detach_command(&mut cmd);
cmd.envs(crate::util::pager_env());
#[allow(clippy::disallowed_methods)] // probe killed on drop
let mut child = cmd.spawn().ok()?;
let mut stdout_buf = Vec::new();
@ -3191,11 +3194,13 @@ fn spawn_shell_command(
#[cfg(unix)]
let mut group = crate::util::ProcessGroup::new()?;
#[cfg(unix)]
#[allow(clippy::disallowed_methods)] // attached to the process group built above
let child = cmd.spawn().map_err(|e| {
std::io::Error::new(e.kind(), format!("spawn shell in {}: {e}", cwd.display()))
})?;
#[cfg(not(unix))]
#[allow(clippy::disallowed_methods)] // attached to the process group built in this block
let (child, mut group) = {
let group = crate::util::ProcessGroup::new()?;
let mut cmd = build_cmd(true);

View file

@ -421,53 +421,12 @@ fn annotations(bash: &BashOutput) -> String {
s
}
const NOOP_END_TURN_REMINDER: &str = "<system-reminder>\n\
You appear to be running empty commands to stay active while waiting for background work. \
End your turn you will be woken automatically when there is something to do.\n\
</system-reminder>";
fn is_noop_command(command: &str) -> bool {
let trimmed = command.trim();
trimmed.is_empty() || trimmed == "true" || trimmed == ":" || is_pure_status_print(trimmed)
}
fn is_pure_status_print(trimmed: &str) -> bool {
if !(matches!(trimmed, "echo" | "printf")
|| trimmed.starts_with("echo ")
|| trimmed.starts_with("printf "))
{
return false;
}
let mut in_single = false;
let mut in_double = false;
let mut chars = trimmed.chars();
while let Some(c) = chars.next() {
match c {
'\\' if !in_single => {
chars.next();
}
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'$' | '`' if !in_single => return false,
';' | '&' | '|' | '<' | '>' | '(' | ')' | '\n' if !in_single && !in_double => {
return false;
}
_ => {}
}
}
true
}
/// Build the full DEFAULT prompt text from a `BashOutput`.
///
/// - Normal: `exit: N [annotations]\n<stripped_output>`
/// - Killed by harness/signal: `exit: killed (reason) [annotations]\n<stripped_output>`
/// - Backgrounded: verbose `[Command moved to background]...` format.
///
/// `append_noop_reminder` gates the no-op-command end-turn `<system-reminder>`.
/// Callers pass the session's `SystemRemindersEnabled` value so the nudge
/// follows the same switch as every other system reminder.
pub(crate) fn format_default_prompt(bash: &BashOutput, append_noop_reminder: bool) -> String {
pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
let output_str = if bash.output_for_prompt.is_empty() {
let raw = String::from_utf8_lossy(&bash.output);
strip_ansi_escapes::strip_str(&raw).to_string()
@ -497,12 +456,7 @@ pub(crate) fn format_default_prompt(bash: &BashOutput, append_noop_reminder: boo
Some(reason) => format!("exit: killed ({}){}", reason, annotations(bash)),
None => format!("exit: {}{}", bash.exit_code, annotations(bash)),
};
let prompt = format!("{}\n{}", header, output_str);
if append_noop_reminder && bash.signal.is_none() && is_noop_command(&bash.command) {
format!("{}\n\n{}", prompt.trim_end(), NOOP_END_TURN_REMINDER)
} else {
prompt
}
format!("{}\n{}", header, output_str)
}
}
@ -2264,16 +2218,7 @@ impl xai_tool_runtime::Tool for BashTool {
output_delta: None,
was_bare_echo: false,
};
// Gate the no-op end-turn reminder on the same switch as every other
// system reminder (absent resource => enabled, mirroring
// `finalize_output`), so toolsets with `system_reminders_enabled=false`
// don't receive it.
let append_noop_reminder = resources
.lock()
.await
.get::<crate::types::resources::SystemRemindersEnabled>()
.is_none_or(|e| e.0);
bash.output_for_prompt = format_default_prompt(&bash, append_noop_reminder);
bash.output_for_prompt = format_default_prompt(&bash);
// Bare `echo "<msg>"` usage (common model anti-pattern for "just output something").
// We tag it for statistics (grok_build backend) and can surface an educational
@ -3479,7 +3424,7 @@ mod tests {
output_delta: None,
was_bare_echo: false,
};
bash.output_for_prompt = format_default_prompt(&bash, /* append_noop_reminder */ true);
bash.output_for_prompt = format_default_prompt(&bash);
bash
}
@ -3532,7 +3477,7 @@ mod tests {
let mut bash = make_bash_output(-1, "partial\n");
bash.signal = Some("timeout".to_string());
bash.timed_out = true;
bash.output_for_prompt = format_default_prompt(&bash, /* append_noop_reminder */ true);
bash.output_for_prompt = format_default_prompt(&bash);
// Synthetic kill reasons render as `exit: killed (reason)` — no
// redundant `[signal=…]` / `[timeout]` annotation.
assert!(
@ -3577,8 +3522,7 @@ mod tests {
for reason in ["timeout", "max_runtime", "cancelled", "killed", "signal 15"] {
let mut bash = make_bash_output(-1, "partial\n");
bash.signal = Some(reason.to_string());
bash.output_for_prompt =
format_default_prompt(&bash, /* append_noop_reminder */ true);
bash.output_for_prompt = format_default_prompt(&bash);
let expected = format!("exit: killed ({})", reason);
assert!(
bash.output_for_prompt.starts_with(&expected),
@ -3598,7 +3542,7 @@ mod tests {
let mut oom = make_bash_output(137, "killed\n");
oom.signal = Some("oom".to_string());
oom.output_for_prompt = format_default_prompt(&oom, /* append_noop_reminder */ true);
oom.output_for_prompt = format_default_prompt(&oom);
assert!(oom.output_for_prompt.starts_with("exit: 137 [signal=oom]"));
}
@ -3608,7 +3552,7 @@ mod tests {
bash.signal = Some("backgrounded".to_string());
bash.output_file = "/tmp/bg.log".to_string();
bash.total_bytes = 10000;
bash.output_for_prompt = format_default_prompt(&bash, /* append_noop_reminder */ true);
bash.output_for_prompt = format_default_prompt(&bash);
assert!(
bash.output_for_prompt
.starts_with("[Command moved to background]")
@ -3619,91 +3563,6 @@ mod tests {
);
}
fn bash_output_with_command(command: &str, output: &str) -> BashOutput {
BashOutput {
output: output.as_bytes().to_vec(),
output_for_prompt: BashOutput::make_output_for_prompt(output),
exit_code: 0,
command: command.to_string(),
truncated: false,
signal: None,
timed_out: false,
description: None,
current_dir: "/tmp".to_string(),
output_file: String::new(),
total_bytes: output.len(),
output_delta: None,
was_bare_echo: false,
}
}
#[test]
fn default_prompt_noop_command_appends_end_turn_reminder() {
for cmd in [
"true",
":",
"",
" ",
"\t\n",
"echo ok",
"echo \"Healthy.\"",
"echo \"s14=198; s11 full. Healthy.\"",
"printf hi",
"printf 'done\\n'",
] {
let prompt = format_default_prompt(
&bash_output_with_command(cmd, ""),
/* append_noop_reminder */ true,
);
assert!(
prompt.contains(NOOP_END_TURN_REMINDER),
"no-op command {cmd:?} should append the end-turn reminder, got: {prompt:?}"
);
}
}
/// With `append_noop_reminder = false` (session `system_reminders_enabled=false`),
/// the no-op end-turn reminder is suppressed even for no-op commands. Mirrors
/// gating the reminder on the shared `SystemRemindersEnabled` switch.
#[test]
fn default_prompt_noop_reminder_suppressed_when_disabled() {
for cmd in ["true", ":", "", "echo ok", "printf hi"] {
let prompt = format_default_prompt(
&bash_output_with_command(cmd, ""),
/* append_noop_reminder */ false,
);
assert!(
!prompt.contains("<system-reminder>"),
"no-op command {cmd:?} must not append the reminder when disabled, got: {prompt:?}"
);
}
}
#[test]
fn default_prompt_normal_command_has_no_end_turn_reminder() {
for cmd in [
"true && echo hi",
"run-true",
"grep : file",
"cat file",
"echo $VAR",
"echo x > f",
"echo a | cat",
"echo $(date)",
"echo hi; ls",
"printf '%s' \"$x\"",
] {
let prompt = format_default_prompt(
&bash_output_with_command(cmd, "hi\n"),
/* append_noop_reminder */ true,
);
assert!(
!prompt.contains("<system-reminder>"),
"normal command {cmd:?} must not append the end-turn reminder, got: {prompt:?}"
);
}
}
// ─── contains_background_operator unit tests ───
mod background_operator_tests {

View file

@ -826,6 +826,7 @@ async fn prepare_grep(
crate::util::detach_command(&mut cmd);
cmd.stdin(Stdio::null());
#[allow(clippy::disallowed_methods)] // search helper, waited on below
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {

View file

@ -129,16 +129,18 @@ impl ImageGenClient {
Ok::<(), xai_tool_runtime::ToolError>(())
})?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(IMAGE_GEN_TIMEOUT_SECS))
.read_timeout(std::time::Duration::from_secs(IMAGE_GEN_READ_TIMEOUT_SECS))
.default_headers(headers)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build HTTP client: {e}"
))
})?;
let http = xai_grok_extra_ca::with_extra_root_certificates(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(IMAGE_GEN_TIMEOUT_SECS))
.read_timeout(std::time::Duration::from_secs(IMAGE_GEN_READ_TIMEOUT_SECS))
.default_headers(headers),
)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build HTTP client: {e}"
))
})?;
Ok(Self {
http,

View file

@ -163,6 +163,46 @@ impl ChannelBackend {
response_rx.await.unwrap_or(SubagentCancelOutcome::NotFound)
}
/// User Stop: cancel all non-workflow children for this parent session.
///
/// Requires [`Self::for_session`]; unbound backends return `NotFound` and
/// do not broadcast a wildcard cancel.
pub async fn cancel_parent_session(&self) -> SubagentCancelOutcome {
let (respond_to, response_rx) = oneshot::channel();
if !self.request_cancel_parent_session(respond_to) {
return SubagentCancelOutcome::NotFound;
}
response_rx.await.unwrap_or(SubagentCancelOutcome::NotFound)
}
/// Fire-and-forget ParentSession cancel used by the shell Stop path.
/// Returns false when the backend is unbound or the channel is closed.
pub fn request_cancel_parent_session(
&self,
respond_to: oneshot::Sender<SubagentCancelOutcome>,
) -> bool {
let Some(parent_session_id) = self.parent_session_id() else {
return false;
};
self.tx
.send(SubagentEvent::Cancel(SubagentCancelRequest {
parent_session_id: Some(parent_session_id),
target: SubagentCancelTarget::ParentSession,
respond_to,
}))
.is_ok()
}
/// Re-open Task spawns after a prior ParentSession stop (start of next turn).
pub fn open_spawn_admission(&self) -> bool {
let Some(parent_session_id) = self.parent_session_id() else {
return false;
};
self.tx
.send(SubagentEvent::OpenSpawnAdmission { parent_session_id })
.is_ok()
}
pub async fn inspect(&self, id: &str) -> Option<SubagentInspection> {
let (respond_to, response_rx) = oneshot::channel();
self.tx

View file

@ -26,8 +26,9 @@ use super::coordinator_state::{
};
use super::types::{
SpawnedSubagentRef, SubagentCancelOutcome, SubagentCancelTarget, SubagentDescribeOutcome,
SubagentEvent, SubagentOutstandingReply, SubagentRegistryCounts, SubagentRequest,
SubagentResult, SubagentResumeLookup, SubagentResumeSource, SubagentValidateTypeOutcome,
SubagentEvent, SubagentOutstandingReply, SubagentOwner, SubagentRegistryCounts,
SubagentRequest, SubagentResult, SubagentResumeLookup, SubagentResumeSource,
SubagentValidateTypeOutcome,
};
pub use super::coordinator_state::{
@ -49,6 +50,10 @@ pub struct SubagentCoordinator<R: ChildRunner> {
completed_order: VecDeque<String>,
waiters: HashMap<String, Vec<BlockingWaiter>>,
workflow_cancel_waiters: HashMap<String, Vec<oneshot::Sender<SubagentCancelOutcome>>>,
/// Parent sessions that received `ParentSession` cancel. Non-workflow spawns
/// are rejected until [`SubagentEvent::OpenSpawnAdmission`] (next turn) or
/// teardown, so a detached late `TaskTool` spawn cannot outrun Stop.
spawn_blocked_sessions: HashSet<String>,
usage_not_applied_prompts: HashSet<PromptScope>,
pending_completions: Vec<BufferedCompletion>,
runs: FuturesUnordered<
@ -95,6 +100,7 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
completed_order: VecDeque::new(),
waiters: HashMap::new(),
workflow_cancel_waiters: HashMap::new(),
spawn_blocked_sessions: HashSet::new(),
usage_not_applied_prompts: HashSet::new(),
pending_completions: Vec::new(),
runs: FuturesUnordered::new(),
@ -163,7 +169,7 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
match command {
SubagentEvent::Spawn(command) => {
let mut request = *command.request;
if let Some((root_parent, loop_task_id, spawner_cancelled)) = self
if let Some((root_parent, loop_task_id, spawner_cancelled, spawner_owner)) = self
.active
.values()
.find(|child| child.child_session_id == request.parent_session_id)
@ -172,6 +178,7 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
child.request.parent_session_id.clone(),
child.request.runtime_overrides.loop_task_id.clone(),
child.cancellation.is_cancelled(),
child.request.owner.clone(),
)
})
{
@ -191,10 +198,34 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
}
request.parent_session_id = root_parent;
request.surface_completion = false;
// Nested children keep workflow lineage after reparent so
// ParentSession Stop does not kill in-flight workflow work.
if !request.owner.is_workflow()
&& let Some(run_id) = spawner_owner.workflow_run_id()
{
request.owner = SubagentOwner::workflow(run_id);
}
if request.runtime_overrides.loop_task_id.is_none() {
request.runtime_overrides.loop_task_id = loop_task_id;
}
}
// Late Task spawn after user Stop (detached TaskTool background).
if !request.owner.is_workflow()
&& self
.spawn_blocked_sessions
.contains(&request.parent_session_id)
{
let id = request.id.clone();
let _ = command.result_tx.send(SubagentResult {
success: false,
cancelled: true,
error: Some("parent session is stopped".to_owned()),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
});
return;
}
let id = request.id.clone();
if self.pending.contains_key(&id)
|| self.active.contains_key(&id)
@ -261,6 +292,10 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
self.cancel_parent_prompt(&prompt_id, request.parent_session_id.as_deref());
let _ = request.respond_to.send(SubagentCancelOutcome::Cancelled);
}
SubagentCancelTarget::ParentSession => {
let outcome = self.cancel_parent_session(request.parent_session_id.as_deref());
let _ = request.respond_to.send(outcome);
}
SubagentCancelTarget::WorkflowRunId(run_id) => {
self.cancel_workflow_children(&run_id, request.parent_session_id.as_deref());
if workflow_outstanding(&self.pending, &self.active, &run_id) == 0 {
@ -309,8 +344,12 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
SubagentEvent::TeardownSession { parent_session_id } => {
self.pending_completions
.retain(|completion| completion.parent_session_id != parent_session_id);
self.spawn_blocked_sessions.remove(&parent_session_id);
self.teardown_session_children(&parent_session_id);
}
SubagentEvent::OpenSpawnAdmission { parent_session_id } => {
self.spawn_blocked_sessions.remove(&parent_session_id);
}
SubagentEvent::Outstanding(request) => {
// Reap again here so turn-freeze / Outstanding polls see
// ParentGone even if no other command woke the actor first.
@ -763,6 +802,34 @@ impl<R: ChildRunner> SubagentCoordinator<R> {
}
}
/// All non-workflow children for the parent session (user Stop / Esc).
///
/// Requires a concrete session id — unbound (`None`) is rejected so a
/// wildcard cannot cancel every session on a shared coordinator.
fn cancel_parent_session(&mut self, parent_session_id: Option<&str>) -> SubagentCancelOutcome {
let Some(parent_session_id) = parent_session_id else {
return SubagentCancelOutcome::NotFound;
};
self.spawn_blocked_sessions
.insert(parent_session_id.to_owned());
for child in self.active.values() {
if child.request.parent_session_id == parent_session_id
&& !child.request.owner.is_workflow()
{
child.cancellation.cancel();
child.control.cancel();
}
}
for child in self.pending.values() {
if child.request.parent_session_id == parent_session_id
&& !child.request.owner.is_workflow()
{
child.cancellation.cancel();
}
}
SubagentCancelOutcome::Cancelled
}
fn cancel_workflow_children(&mut self, run_id: &str, parent_session_id: Option<&str>) {
for child in self.active.values() {
if child.request.owner.workflow_run_id() == Some(run_id)

View file

@ -232,6 +232,9 @@ fn harness_with_options(
.run(),
);
Harness {
// Unbound by default so tests can set request.parent_session_id
// freely (e.g. nested reparent). ParentSession APIs must use
// `parent_backend` so they stay session-scoped.
backend: ChannelBackend::new(command_tx),
start,
finish,
@ -242,6 +245,12 @@ fn harness_with_options(
}
}
/// Session-bound backend for ParentSession cancel / admission on the default
/// test parent (`"parent"`). Required because unbound cancel is rejected.
fn parent_backend(harness: &Harness) -> ChannelBackend {
ChannelBackend::for_session(harness.backend.sender(), "parent")
}
async fn loop_unit_active(backend: &ChannelBackend, task_id: &str) -> bool {
let (respond_to, response_rx) = oneshot::channel();
backend
@ -1008,6 +1017,251 @@ async fn usage_events_feed_sorted_outstanding_reply() {
harness.actor.abort();
}
/// Prior-turn background + current-turn children all die on ParentSession cancel (GBT-4942).
#[tokio::test]
async fn cancel_parent_session_kills_prior_turn_background() {
let mut harness = harness(true, std::time::Duration::from_secs(60));
let mut prior = request("prior-bg", true);
prior.parent_prompt_id = Some("turn-1".into());
let mut current = request("current", false);
current.parent_prompt_id = Some("turn-2".into());
let mut spawns = Vec::new();
for req in [prior, current] {
let id = req.id.clone();
spawns.push(tokio::spawn({
let backend = harness.backend.clone();
async move { backend.spawn(req).await }
}));
assert_eq!(
harness
.requests
.recv()
.await
.as_ref()
.map(|r| r.id.as_str()),
Some(id.as_str())
);
let _ = harness.start.send(());
assert_eq!(harness.started.recv().await.as_deref(), Some(id.as_str()));
}
assert!(matches!(
parent_backend(&harness).cancel_parent_session().await,
SubagentCancelOutcome::Cancelled
));
for spawn in spawns {
assert!(
spawn.await.unwrap().unwrap().cancelled,
"ParentSession cancel must kill prior-turn and current-turn children"
);
}
harness.actor.abort();
}
/// A foreign session's children must not die when this session Stop fires.
#[tokio::test]
async fn cancel_parent_session_does_not_touch_foreign_session() {
let mut harness = harness(true, std::time::Duration::from_secs(60));
let mut foreign = request("foreign-child", true);
foreign.parent_session_id = "other-session".into();
let foreign_spawn = tokio::spawn({
let backend = ChannelBackend::for_session(harness.backend.sender(), "other-session");
async move { backend.spawn(foreign).await }
});
assert_eq!(
harness
.requests
.recv()
.await
.as_ref()
.map(|r| r.id.as_str()),
Some("foreign-child")
);
let _ = harness.start.send(());
assert_eq!(
harness.started.recv().await.as_deref(),
Some("foreign-child")
);
assert!(matches!(
parent_backend(&harness).cancel_parent_session().await,
SubagentCancelOutcome::Cancelled
));
// Foreign child still running — finish it successfully.
let _ = harness.finish.send(());
let result = foreign_spawn.await.unwrap().unwrap();
assert!(result.success && !result.cancelled);
harness.actor.abort();
}
/// Unbound backend must not wildcard-cancel (rejects before send).
#[tokio::test]
async fn cancel_parent_session_unbound_backend_is_not_found() {
let (tx, _rx) = mpsc::unbounded_channel();
let unbound = ChannelBackend::new(tx);
assert!(matches!(
unbound.cancel_parent_session().await,
SubagentCancelOutcome::NotFound
));
}
/// Late Task spawn after ParentSession cancel is rejected until admission reopens.
#[tokio::test]
async fn cancel_parent_session_rejects_late_spawn_until_admission_reopens() {
let mut harness = harness(true, std::time::Duration::from_secs(60));
let bound = parent_backend(&harness);
let prior = tokio::spawn({
let backend = harness.backend.clone();
async move { backend.spawn(request("prior", true)).await }
});
assert_eq!(
harness
.requests
.recv()
.await
.as_ref()
.map(|r| r.id.as_str()),
Some("prior")
);
let _ = harness.start.send(());
assert_eq!(harness.started.recv().await.as_deref(), Some("prior"));
assert!(matches!(
bound.cancel_parent_session().await,
SubagentCancelOutcome::Cancelled
));
assert!(prior.await.unwrap().unwrap().cancelled);
// Late Task spawn is rejected by the coordinator gate (request still carries
// parent="parent" via unbound backend + request default).
let late = harness
.backend
.spawn(request("late-after-stop", true))
.await
.unwrap();
assert!(
late.cancelled && !late.success,
"late Task spawn after ParentSession must be rejected"
);
assert!(bound.open_spawn_admission());
let allowed = tokio::spawn({
let backend = harness.backend.clone();
async move { backend.spawn(request("after-reopen", true)).await }
});
assert_eq!(
harness
.requests
.recv()
.await
.as_ref()
.map(|r| r.id.as_str()),
Some("after-reopen")
);
let _ = harness.start.send(());
assert_eq!(
harness.started.recv().await.as_deref(),
Some("after-reopen")
);
let _ = harness.finish.send(());
assert!(allowed.await.unwrap().unwrap().success);
harness.actor.abort();
}
/// Nested children of a workflow subagent keep workflow ownership after reparent
/// and survive ParentSession cancel (active + pending).
#[tokio::test]
async fn cancel_parent_session_spares_nested_workflow_children() {
// wait_before_start only: keep one child in pending through ParentSession.
// (wait_after_cancel not needed — workflow lineage is not cancelled.)
let mut harness = harness(true, std::time::Duration::from_secs(60));
// Workflow-owned parent child (child_session_id = "wf-child").
let mut wf_parent = request("wf-child", true);
wf_parent.owner = SubagentOwner::workflow("run-1");
let wf_spawn = tokio::spawn({
let backend = harness.backend.clone();
async move { backend.spawn(wf_parent).await }
});
assert_eq!(
harness
.requests
.recv()
.await
.as_ref()
.map(|r| r.id.as_str()),
Some("wf-child")
);
let _ = harness.start.send(());
assert_eq!(harness.started.recv().await.as_deref(), Some("wf-child"));
// Nested spawns use a backend bound to the workflow child's session id
// (production binds ChannelBackend::for_session to the child session).
let child_backend = ChannelBackend::for_session(harness.backend.sender(), "wf-child");
// Nested Task-owned spawn from the workflow child (reparented to root parent).
let nested_active = request("nested-active", true);
let nested_active_spawn = tokio::spawn({
let backend = child_backend.clone();
async move { backend.spawn(nested_active).await }
});
let observed = harness
.requests
.recv()
.await
.expect("nested active observed");
assert_eq!(observed.parent_session_id, "parent");
assert_eq!(
observed.owner.workflow_run_id(),
Some("run-1"),
"reparent must copy workflow lineage"
);
let _ = harness.start.send(());
assert_eq!(
harness.started.recv().await.as_deref(),
Some("nested-active")
);
// Nested pending (not started yet) under the same workflow child.
let nested_pending = request("nested-pending", true);
let nested_pending_spawn = tokio::spawn({
let backend = child_backend.clone();
async move { backend.spawn(nested_pending).await }
});
let observed_pending = harness
.requests
.recv()
.await
.expect("nested pending observed");
assert_eq!(observed_pending.owner.workflow_run_id(), Some("run-1"));
assert!(matches!(
parent_backend(&harness).cancel_parent_session().await,
SubagentCancelOutcome::Cancelled
));
// Promote pending → active, then finish all three (must wait until each is
// subscribed on finish; broadcast does not buffer for late receivers).
let _ = harness.start.send(());
assert_eq!(
harness.started.recv().await.as_deref(),
Some("nested-pending")
);
let _ = harness.finish.send(());
assert!(
nested_active_spawn.await.unwrap().unwrap().success,
"active nested workflow child must survive ParentSession"
);
assert!(
nested_pending_spawn.await.unwrap().unwrap().success,
"pending nested workflow child must survive ParentSession"
);
assert!(
wf_spawn.await.unwrap().unwrap().success,
"workflow parent must survive ParentSession"
);
harness.actor.abort();
}
#[tokio::test]
async fn loop_tracking_covers_pending_active_and_nested_reparenting() {
let mut harness = harness(true, std::time::Duration::from_secs(60));

View file

@ -556,7 +556,10 @@ impl SubagentSnapshotStatus {
#[derive(Debug, Clone)]
pub enum SubagentCancelTarget {
SubagentId(String),
/// Turn-scoped cancel (soft cancel / max-turns).
ParentPromptId(String),
/// User Stop / Esc with cancel_subagents — prior-turn background too.
ParentSession,
WorkflowRunId(String),
}
@ -852,6 +855,12 @@ pub enum SubagentEvent {
TeardownSession {
parent_session_id: String,
},
/// Re-open Task spawns for a parent session after a prior ParentSession stop.
/// Emitted at the start of each user turn so Stop's late-spawn gate does not
/// permanently block the next prompt.
OpenSpawnAdmission {
parent_session_id: String,
},
Outstanding(SubagentOutstandingRequest),
ClearUsageNotApplied(SubagentClearUsageNotAppliedRequest),
MarkUsageNotApplied(SubagentMarkUsageNotAppliedRequest),

View file

@ -201,23 +201,26 @@ impl VideoGenClient {
Ok::<(), xai_tool_runtime::ToolError>(())
})?;
let http = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build HTTP client: {e}"
))
})?;
let http = xai_grok_extra_ca::with_extra_root_certificates(
reqwest::Client::builder().default_headers(headers),
)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build HTTP client: {e}"
))
})?;
let download_http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(VIDEO_DOWNLOAD_TIMEOUT_SECS))
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build download client: {e}"
))
})?;
let download_http = xai_grok_extra_ca::with_extra_root_certificates(
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(VIDEO_DOWNLOAD_TIMEOUT_SECS)),
)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::invalid_arguments(format!(
"Failed to build download client: {e}"
))
})?;
Ok(Self {
http,

View file

@ -54,18 +54,20 @@ impl HttpClient {
}
fn build(params: &WebFetchParams) -> Result<reqwest::Client, WebFetchError> {
let mut builder = reqwest::Client::builder()
.timeout(params.timeout_secs())
.connect_timeout(std::time::Duration::from_secs(10))
// We manage redirects for SSRF.
.redirect(reqwest::redirect::Policy::none())
.pool_max_idle_per_host(2)
.pool_idle_timeout(std::time::Duration::from_secs(30))
.tcp_nodelay(true)
// Reduce size of incoming payloads.
.gzip(true)
.brotli(true)
.deflate(true);
let mut builder = xai_grok_extra_ca::with_extra_root_certificates(
reqwest::Client::builder()
.timeout(params.timeout_secs())
.connect_timeout(std::time::Duration::from_secs(10))
// We manage redirects for SSRF.
.redirect(reqwest::redirect::Policy::none())
.pool_max_idle_per_host(2)
.pool_idle_timeout(std::time::Duration::from_secs(30))
.tcp_nodelay(true)
// Reduce size of incoming payloads.
.gzip(true)
.brotli(true)
.deflate(true),
);
// Route all traffic through the egress proxy when configured.
if let Some(ref endpoint) = params.proxy_endpoint {

View file

@ -265,9 +265,8 @@ mod tests {
// to_prompt_format() is a passthrough — it must NOT add another header.
let mut bash = make_bash(0, "hello world\n");
// Pre-bake DEFAULT (what BashTool::run() does)
bash.output_for_prompt = crate::implementations::grok_build::bash::format_default_prompt(
&bash, /* append_noop_reminder */ true,
);
bash.output_for_prompt =
crate::implementations::grok_build::bash::format_default_prompt(&bash);
assert!(bash.output_for_prompt.starts_with("exit: 0"));
// Concise post-processing (what BashConciseTool::run() does)

View file

@ -0,0 +1,239 @@
//! What a server told us it wants during `initialize`, and the document
//! bookkeeping that follows from it.
//!
//! Grok used to log the initialize result and throw it away, which is how it
//! ended up sending Roslyn a change event the protocol says must carry a range.
//! Everything the handshake tells us that changes what we send lives here.
use async_lsp::lsp_types::{
DidSaveTextDocumentParams, Position, Range, ServerCapabilities, TextDocumentIdentifier,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncSaveOptions, Url,
};
/// What the server asked us to do on save.
///
/// A server may want no `didSave` at all, or one without the document text.
/// Sending the full text unconditionally violates the protocol and ships a copy
/// of the file on every edit to a server that will discard it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SavePolicy {
/// The server did not ask to be told about saves.
Skip,
/// Notify, but without the document text.
WithoutText,
/// Notify and include the full document text.
WithText,
}
/// The parts of a server's advertised capabilities that change what we send it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerPolicy {
/// The server declared incremental text sync. Such servers reject a change
/// event without a range — Roslyn dereferences it and tears its request
/// queue down — so a whole-document range has to be supplied instead.
pub sync_incremental: bool,
pub save: SavePolicy,
/// The server advertised a `textDocument/diagnostic` provider. Absence is
/// not proof of absence; see `pull::PullSupport`.
pub advertises_pull: bool,
}
impl ServerPolicy {
pub fn from_capabilities(capabilities: &ServerCapabilities) -> Self {
let sync = capabilities.text_document_sync.as_ref();
Self {
sync_incremental: wants_incremental_sync(sync),
save: save_policy(sync),
advertises_pull: capabilities.diagnostic_provider.is_some(),
}
}
/// The range to attach to a change event that replaces the whole document,
/// given where the previous revision ended.
///
/// We always resend the whole file. A server that asked for incremental
/// sync still requires a range on every change event, so the full
/// replacement is expressed as a range covering the previous revision.
/// Full-sync servers get the rangeless form they expect.
pub fn full_replacement_range(&self, previous_end: Position) -> Option<Range> {
self.sync_incremental.then_some(Range {
start: Position {
line: 0,
character: 0,
},
end: previous_end,
})
}
/// The `didSave` to send for this document, or `None` if the server did not
/// ask to hear about saves at all.
pub fn did_save(&self, uri: Url, content: &str) -> Option<DidSaveTextDocumentParams> {
let text = match self.save {
SavePolicy::Skip => return None,
SavePolicy::WithoutText => None,
SavePolicy::WithText => Some(content.to_string()),
};
Some(DidSaveTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
text,
})
}
}
/// Whether the server asked for incremental text synchronization.
///
/// Servers advertise this either as a bare kind or inside sync options; both
/// spellings mean the same thing for our purposes. Anything else (including an
/// absent capability) is treated as full-document sync, which is what we send.
fn wants_incremental_sync(cap: Option<&TextDocumentSyncCapability>) -> bool {
let kind = match cap {
Some(TextDocumentSyncCapability::Kind(kind)) => Some(*kind),
Some(TextDocumentSyncCapability::Options(opts)) => opts.change,
None => None,
};
kind == Some(TextDocumentSyncKind::INCREMENTAL)
}
/// What the server asked for on save, from its sync capability.
fn save_policy(cap: Option<&TextDocumentSyncCapability>) -> SavePolicy {
match cap {
// A bare sync kind says nothing about save. Notify without the text,
// which is what other clients do and is enough for servers that only
// recompute on save.
Some(TextDocumentSyncCapability::Kind(_)) => SavePolicy::WithoutText,
Some(TextDocumentSyncCapability::Options(opts)) => match opts.save.as_ref() {
// Save omitted entirely (Roslyn) or explicitly declined.
None | Some(TextDocumentSyncSaveOptions::Supported(false)) => SavePolicy::Skip,
Some(TextDocumentSyncSaveOptions::Supported(true)) => SavePolicy::WithoutText,
Some(TextDocumentSyncSaveOptions::SaveOptions(options)) => {
if options.include_text.unwrap_or(false) {
SavePolicy::WithText
} else {
SavePolicy::WithoutText
}
}
},
// The server declared no sync capability at all, so we cannot tell.
// Keep the historical behaviour rather than risk silencing a server
// that only reports diagnostics after a save.
None => SavePolicy::WithText,
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_lsp::lsp_types::{DiagnosticOptions, DiagnosticServerCapabilities, SaveOptions};
fn uri() -> Url {
Url::parse("file:///a.cs").unwrap()
}
fn position(line: u32, character: u32) -> Position {
Position { line, character }
}
fn policy(capabilities: ServerCapabilities) -> ServerPolicy {
ServerPolicy::from_capabilities(&capabilities)
}
#[test]
fn a_bare_incremental_kind_means_incremental() {
let p = policy(ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
..Default::default()
});
assert!(p.sync_incremental);
assert_eq!(p.save, SavePolicy::WithoutText);
}
#[test]
fn roslyns_shape_is_incremental_with_no_save() {
// {"openClose": true, "change": 2} — no `save` key at all.
let p = policy(ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(
async_lsp::lsp_types::TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::INCREMENTAL),
..Default::default()
},
)),
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
DiagnosticOptions::default(),
)),
..Default::default()
});
assert!(p.sync_incremental);
assert_eq!(p.save, SavePolicy::Skip);
assert!(p.advertises_pull);
assert!(p.did_save(uri(), "body").is_none());
}
#[test]
fn include_text_is_the_only_way_to_get_the_text() {
let with_text = policy(ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(
async_lsp::lsp_types::TextDocumentSyncOptions {
change: Some(TextDocumentSyncKind::FULL),
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
include_text: Some(true),
})),
..Default::default()
},
)),
..Default::default()
});
assert_eq!(with_text.save, SavePolicy::WithText);
assert_eq!(
with_text.did_save(uri(), "body").and_then(|p| p.text),
Some("body".to_string())
);
let without_text = policy(ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(
async_lsp::lsp_types::TextDocumentSyncOptions {
save: Some(TextDocumentSyncSaveOptions::Supported(true)),
..Default::default()
},
)),
..Default::default()
});
assert_eq!(without_text.save, SavePolicy::WithoutText);
let sent = without_text
.did_save(uri(), "body")
.expect("the server asked to hear about saves");
assert_eq!(sent.text, None, "but not to be sent the file with them");
}
#[test]
fn a_server_with_no_sync_capability_keeps_the_historical_behaviour() {
let p = policy(ServerCapabilities::default());
assert!(!p.sync_incremental);
assert_eq!(p.save, SavePolicy::WithText);
assert!(!p.advertises_pull);
}
#[test]
fn only_incremental_servers_get_a_range() {
let incremental = ServerPolicy {
sync_incremental: true,
save: SavePolicy::Skip,
advertises_pull: false,
};
assert_eq!(
incremental.full_replacement_range(position(1, 5)),
Some(Range {
start: position(0, 0),
end: position(1, 5),
})
);
let full = ServerPolicy {
sync_incremental: false,
..incremental
};
assert_eq!(full.full_replacement_range(position(1, 5)), None);
}
}

View file

@ -1,22 +1,27 @@
//! Single LSP server connection — spawn, handshake, protocol methods.
use std::collections::HashMap;
use std::ops::ControlFlow;
use std::path::Path;
use std::sync::Arc;
use async_lsp::LanguageServer;
use async_lsp::lsp_types::{
self, ClientCapabilities, DidChangeTextDocumentParams, DidOpenTextDocumentParams,
DidSaveTextDocumentParams, GotoCapability, HoverClientCapabilities, InitializeParams,
InitializedParams, MarkupKind, PublishDiagnosticsClientCapabilities,
ReferenceClientCapabilities, TextDocumentClientCapabilities, TextDocumentContentChangeEvent,
TextDocumentIdentifier, TextDocumentItem, TextDocumentSyncClientCapabilities, Url,
VersionedTextDocumentIdentifier,
self, ClientCapabilities, DiagnosticClientCapabilities, DiagnosticWorkspaceClientCapabilities,
DidChangeTextDocumentParams, DidOpenTextDocumentParams, GotoCapability,
HoverClientCapabilities, InitializeParams, InitializedParams, MarkupKind,
PublishDiagnosticsClientCapabilities, ReferenceClientCapabilities,
TextDocumentClientCapabilities, TextDocumentContentChangeEvent, TextDocumentIdentifier,
TextDocumentItem, TextDocumentSyncClientCapabilities, Url, VersionedTextDocumentIdentifier,
WorkspaceClientCapabilities,
};
use super::capabilities::ServerPolicy;
use super::config::{LspServerConfig, LspTransport};
use super::{DiagnosticsMap, DiagnosticsNotify, LspError, LspMainLoop, file_uri};
use super::diagnostics::DiagnosticsStore;
use super::documents::{Documents, Update, end_position};
use super::pull::PullDiagnostics;
use super::refresh::{ProjectInitializationComplete, RefreshTarget};
use super::{DiagnosticsNotify, LspError, LspMainLoop, file_uri, workspace_open};
use crate::util::{ProcessGroup, ProcessScope};
#[cfg(test)]
@ -34,8 +39,19 @@ pub struct LspClient {
pub server_name: String,
pub lifecycle_id: u64,
pub socket: async_lsp::ServerSocket,
pub diagnostics: DiagnosticsMap,
pub open_documents: HashMap<String, (i32, String)>,
pub diagnostics: DiagnosticsStore,
/// What we have told this server about each open document. Shared, because
/// the pull tasks and the `publishDiagnostics` handler both need to know
/// which version an answer is about.
pub documents: Documents,
/// What the server asked for during the handshake: how to sync text, and
/// whether it wants to hear about saves.
pub policy: ServerPolicy,
/// Pull-model diagnostics. Roslyn is pull-only and never publishes, so
/// without asking we would never see a single C# diagnostic.
pub pull: PullDiagnostics,
/// The server's own signal that its answers are out of date.
pub refresh: RefreshTarget,
pub main_loop: tokio::task::JoinHandle<()>,
pub stderr_task: Option<tokio::task::JoinHandle<()>>,
pub child_process: Option<std::process::Child>,
@ -70,29 +86,60 @@ impl Drop for LspClient {
type LspMainLoopAndServer = (LspMainLoop, async_lsp::ServerSocket);
fn create_client_main_loop(
diagnostics: DiagnosticsMap,
server_name: &str,
diagnostics: DiagnosticsStore,
documents: Documents,
diagnostics_notify: DiagnosticsNotify,
refresh: RefreshTarget,
) -> LspMainLoopAndServer {
async_lsp::MainLoop::new_client(|_server_socket| {
let diag = diagnostics;
let notify = diagnostics_notify;
let name = Arc::<str>::from(server_name);
async_lsp::MainLoop::new_client(move |_server_socket| {
let mut router = async_lsp::router::Router::new(());
router.notification::<lsp_types::notification::PublishDiagnostics>(
move |_state, params| {
let uri_str = params.uri.to_string();
match diag.write() {
Ok(mut map) => {
map.insert(uri_str, params.diagnostics);
}
Err(e) => {
tracing::warn!(error = %e, "diagnostics lock poisoned, dropping update")
}
}
notify.notify_one();
{
let diagnostics = diagnostics.clone();
let documents = documents.clone();
let notify = diagnostics_notify.clone();
router.notification::<lsp_types::notification::PublishDiagnostics>(
move |_state, params| {
let uri = params.uri.as_str();
// `version` is the revision the server analyzed. Servers
// that name it are taken at their word; the rest are
// credited with the text we had most recently sent, which
// is all arrival order can tell us.
diagnostics.record_push(
uri,
params.diagnostics,
params.version,
documents.version(uri),
);
notify.notify_one();
ControlFlow::Continue(())
},
);
}
// A pull-model server answers with whatever it knows when asked, which
// right after an edit may be nothing yet. Rather than guess how long
// its analysis takes, we let it say: both of these mean "ask me again".
{
let refresh = refresh.clone();
let name = name.clone();
router.request::<lsp_types::request::WorkspaceDiagnosticRefresh, _>(
move |_state, ()| {
refresh.refresh_all(&name, "server requested a diagnostics refresh");
std::future::ready(Ok(()))
},
);
}
{
let refresh = refresh.clone();
let name = name.clone();
router.notification::<ProjectInitializationComplete>(move |_state, _params| {
refresh.refresh_all(&name, "server finished loading the workspace");
ControlFlow::Continue(())
},
);
});
}
router.unhandled_notification(|_, _| ControlFlow::Continue(()));
router
@ -124,13 +171,7 @@ async fn spawn_transport(
}
fn build_initialize_params(config: &LspServerConfig, workspace_root: &Path) -> InitializeParams {
// Per-server override > session cwd.
let effective_root = config
.workspace_folder
.as_deref()
.map(Path::new)
.unwrap_or(workspace_root);
let effective_root = config.effective_root(workspace_root);
let workspace_uri = Url::from_file_path(effective_root).ok();
let workspace_folders = workspace_uri.map(|uri| {
vec![lsp_types::WorkspaceFolder {
@ -198,9 +239,16 @@ impl LspClient {
workspace_root: &Path,
diagnostics_notify: DiagnosticsNotify,
) -> Result<Self, LspError> {
let diagnostics: DiagnosticsMap = Arc::new(std::sync::RwLock::new(HashMap::new()));
let (main_loop, mut server) =
create_client_main_loop(diagnostics.clone(), diagnostics_notify);
let diagnostics = DiagnosticsStore::new();
let documents = Documents::new();
let refresh = RefreshTarget::new();
let (main_loop, mut server) = create_client_main_loop(
&server_name,
diagnostics.clone(),
documents.clone(),
diagnostics_notify.clone(),
refresh.clone(),
);
let (main_loop_handle, stderr_task, mut child_process) =
spawn_transport(&server_name, &config, main_loop).await?;
@ -216,10 +264,15 @@ impl LspClient {
}
};
let policy = ServerPolicy::from_capabilities(&init_result.capabilities);
tracing::info!(
server = %server_name,
transport = ?config.transport,
has_text_sync = init_result.capabilities.text_document_sync.is_some(),
sync_incremental = policy.sync_incremental,
save = ?policy.save,
advertises_pull = policy.advertises_pull,
has_definition = init_result.capabilities.definition_provider.is_some(),
has_references = init_result.capabilities.references_provider.is_some(),
"LSP server initialized"
@ -230,15 +283,40 @@ impl LspClient {
.map_err(|e| LspError::InitFailed(format!("initialized notification failed: {e}")))?;
send_initial_configuration(&server_name, &config, &mut server);
workspace_open::send(
&server_name,
&config,
config.effective_root(workspace_root),
&mut server,
);
tokio::task::yield_now().await;
if !policy.advertises_pull {
// Not proof of absence: Roslyn implements the handler without
// always advertising it, so it gets asked anyway.
tracing::debug!(server = %server_name, "server advertises no diagnostic provider; asking anyway");
}
let pull = PullDiagnostics::new(
&server_name,
server.clone(),
diagnostics.clone(),
documents.clone(),
diagnostics_notify,
);
// From here a refresh request has somewhere to go. Before it, there is
// nothing open to re-pull.
refresh.publish(pull.clone());
Ok(Self {
server_name,
lifecycle_id,
socket: server,
diagnostics,
open_documents: HashMap::new(),
documents,
policy,
pull,
refresh,
main_loop: main_loop_handle,
stderr_task,
child_process,
@ -307,6 +385,7 @@ impl LspClient {
}
xai_tty_utils::detach_std_command(&mut cmd);
cmd.envs(xai_tty_utils::pager_env());
#[allow(clippy::disallowed_methods)] // enrolled by LspClient::enroll once started
let mut child = cmd
.spawn()
.map_err(|e| LspError::SpawnFailed(format!("'{}': {e}", config.command)))?;
@ -384,7 +463,10 @@ impl LspClient {
}
pub fn close_all_documents(&mut self) {
for (uri_str, _version) in std::mem::take(&mut self.open_documents) {
for uri_str in self.documents.take_all() {
// What the server said about a document it no longer has open, and
// the result id naming it, go together.
self.diagnostics.forget(&uri_str);
let Ok(uri) = Url::parse(&uri_str) else {
continue;
};
@ -479,22 +561,41 @@ impl LspClient {
related_information: Some(true),
..Default::default()
}),
// Pull diagnostics. Some servers — Roslyn among them — only
// answer `textDocument/diagnostic` and never publish, so
// without this we would see no diagnostics from them at all.
//
// `dynamic_registration: false` is deliberate: it makes Roslyn
// advertise one static provider instead of registering a
// separate provider per diagnostic source, which would turn
// every document into six pulls and six cache entries.
diagnostic: Some(DiagnosticClientCapabilities {
dynamic_registration: Some(false),
related_document_support: Some(false),
}),
hover: Some(HoverClientCapabilities {
dynamic_registration: Some(false),
content_format: Some(vec![MarkupKind::PlainText]),
}),
..Default::default()
}),
workspace: Some(WorkspaceClientCapabilities {
// A pull-model server cannot volunteer that its answers have
// changed unless we say we can hear it. Without this, a Roslyn
// that finishes analyzing a solution after we asked has no way
// to tell us, and we are left guessing how long to wait.
diagnostic: Some(DiagnosticWorkspaceClientCapabilities {
refresh_support: Some(true),
}),
..Default::default()
}),
..Default::default()
}
}
/// Returns (uri_string, language_id) for all documents this client has opened.
pub fn tracked_documents(&self) -> Vec<(String, String)> {
self.open_documents
.iter()
.map(|(uri, (_, lang_id))| (uri.clone(), lang_id.clone()))
.collect()
self.documents.tracked()
}
#[cfg(test)]
@ -502,78 +603,104 @@ impl LspClient {
&self.server_name
}
pub fn notify_file_change(&mut self, path: &Path, content: &str, language_id: &str) {
/// Tell the server about the current contents of `path`.
///
/// Returns the document version the change was sent as, which is what a
/// caller waiting for the server's verdict compares later answers against.
/// `None` means the server was never told, so there is nothing to wait for.
pub fn notify_file_change(
&mut self,
path: &Path,
content: &str,
language_id: &str,
) -> Option<i32> {
let uri = match file_uri(path) {
Ok(u) => u,
Err(_) => {
tracing::warn!(server = %self.server_name,"skipping didOpen/didChange: invalid path");
return;
return None;
}
};
let uri_str = uri.to_string();
let new_end = end_position(content);
let update = self.documents.plan(&uri_str);
let version = update.version();
let (is_new, version) = match self.open_documents.get_mut(&uri_str) {
Some((v, _)) => {
*v += 1;
(false, *v)
let sent = match update {
Update::Open { version } => {
tracing::debug!(server = %self.server_name, uri = %uri, language_id, "didOpen");
self.socket.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: language_id.to_string(),
version,
text: content.to_string(),
},
})
}
None => {
self.open_documents
.insert(uri_str, (0, language_id.to_string()));
(true, 0)
Update::Change {
version,
previous_end,
} => {
// We always resend the whole file. A server that asked for
// incremental sync still requires a range on every change
// event — Roslyn dereferences it unconditionally and tears its
// request queue down without one — so the full replacement is
// expressed as a range covering the previous revision.
let range = self.policy.full_replacement_range(previous_end);
tracing::debug!(
server = %self.server_name, uri = %uri, version, ranged = range.is_some(),
"didChange"
);
self.socket.did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range,
range_length: None,
text: content.to_string(),
}],
})
}
};
if is_new {
tracing::debug!(server = %self.server_name, uri = %uri, language_id, "didOpen");
if let Err(e) = self.socket.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: language_id.to_string(),
version,
text: content.to_string(),
},
}) {
tracing::debug!(server = %self.server_name, error = %e, "failed to send didOpen");
}
} else {
tracing::debug!(server = %self.server_name, uri = %uri, version, "didChange");
if let Err(e) = self.socket.did_change(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: content.to_string(),
}],
}) {
tracing::debug!(server = %self.server_name, error = %e, "failed to send didChange");
}
if let Err(e) = sent {
tracing::debug!(server = %self.server_name, error = %e, "failed to send document update");
return None;
}
// Some servers only emit diagnostics on save, not change.
if let Err(e) = self.socket.did_save(DidSaveTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
text: Some(content.to_string()),
}) {
// Only now, with the notification actually on the wire, does our record
// of the server's copy advance. It describes the text the *server* has;
// advancing it after a send that failed would compute every later
// incremental range against a revision the server never received — the
// same protocol violation the range exists to avoid. It is also what
// the pull about to be spawned reads to know which revision it is
// asking about, so it has to be committed first.
self.documents
.commit(&uri_str, version, language_id, new_end);
// Some servers only emit diagnostics on save, not change — but only
// notify the ones that asked, and only include the text when they said
// they want it.
if let Some(saved) = self.policy.did_save(uri.clone(), content)
&& let Err(e) = self.socket.did_save(saved)
{
tracing::debug!(server = %self.server_name, error = %e, "failed to send didSave");
}
// Pull-model servers publish nothing; ask them instead.
self.pull.will_answer(uri);
Some(version)
}
#[cfg(test)]
pub fn get_diagnostics(&self, path: &Path) -> Vec<Diagnostic> {
let uri = match file_uri(path) {
Ok(u) => u,
Err(_) => return vec![],
};
self.diagnostics
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&uri.to_string())
.cloned()
.unwrap_or_default()
match file_uri(path) {
Ok(uri) => self.diagnostics.items(uri.as_str()),
Err(_) => vec![],
}
}
#[cfg(test)]

View file

@ -226,6 +226,27 @@ pub enum LspTransport {
Socket,
}
/// Which solution or projects the server should load once it is running.
///
/// Some servers do not derive their workspace from `rootUri`/`workspaceFolders`
/// and instead load it through a protocol extension. Roslyn is the notable one:
/// left alone it treats every file as a loose "miscellaneous file" and reports
/// no project-level diagnostics at all, until it is sent `solution/open` or
/// `project/open`. Wrappers such as `roslyn-language-server` do this for you; a
/// bare `Microsoft.CodeAnalysis.LanguageServer` does not.
///
/// Paths may be absolute or relative to the workspace root.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceOpen {
/// A single solution file, sent as `solution/open`.
#[serde(default)]
pub solution: Option<String>,
/// Project files, sent as `project/open`.
#[serde(default)]
pub projects: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LspServerConfig {
pub command: String,
@ -247,6 +268,8 @@ pub struct LspServerConfig {
pub settings: Option<serde_json::Value>,
#[serde(default, alias = "workspaceFolder")]
pub workspace_folder: Option<String>,
#[serde(default, alias = "workspaceOpen")]
pub workspace_open: Option<WorkspaceOpen>,
#[serde(default, alias = "startupTimeout")]
pub startup_timeout: Option<u64>,
#[serde(default, alias = "shutdownTimeout")]
@ -275,6 +298,20 @@ impl LspServerConfig {
pub fn max_restarts(&self) -> u32 {
self.max_restarts.unwrap_or(3)
}
/// The directory this server should treat as its workspace: the per-server
/// override if there is one, otherwise the session cwd. Everything that
/// needs to name the server's root — `rootUri`, `workspaceFolders`,
/// `workspaceOpen` — resolves it here so they cannot drift apart.
pub fn effective_root<'a>(
&'a self,
workspace_root: &'a std::path::Path,
) -> &'a std::path::Path {
self.workspace_folder
.as_deref()
.map(std::path::Path::new)
.unwrap_or(workspace_root)
}
}
#[cfg(test)]

View file

@ -0,0 +1,422 @@
//! The per-server diagnostics store.
//!
//! Diagnostics reach us two ways — pushed by the server via
//! `textDocument/publishDiagnostics`, or pulled by us via
//! `textDocument/diagnostic` — and both land here, so the rest of the code has
//! one place to read from.
//!
//! Every answer says which document version it describes. That is what makes
//! "has the server given a verdict on the edit I just sent?" a comparison
//! rather than a guess. Without it, the presence of an entry means only "the
//! server said something about this file at some point", which cannot tell a
//! file that is genuinely clean now from one that was clean before the edit
//! that broke it.
//!
//! The version comes from the protocol wherever the protocol provides one: a
//! pull knows which revision it asked about, and a pushed report may carry the
//! version it was computed for. Only a push that omits it falls back to
//! arrival order — it is credited with the newest version we had sent when it
//! arrived.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use async_lsp::lsp_types::Diagnostic;
/// The server's latest word on one document.
///
/// The items and the `result_id` that names them are one value on purpose. An
/// id is a promise that what it names is what a reader would find, and a
/// promise kept by remembering to update two containers together is a promise
/// that eventually gets broken. Here there is nothing to keep in step: an
/// answer the store turns away takes its id with it.
#[derive(Debug, Clone, PartialEq)]
pub struct Answer {
pub items: Vec<Diagnostic>,
/// The document version this verdict describes.
pub covers: i32,
/// Result id from a pull, valid only for the `items` beside it. Sending it
/// back lets the server reply "unchanged" instead of recomputing.
pub result_id: Option<String>,
}
impl Answer {
pub fn new(items: Vec<Diagnostic>, covers: i32, result_id: Option<String>) -> Self {
Self {
items,
covers,
result_id,
}
}
}
/// The version of a document the server cannot have a verdict on, because we
/// have never told it about one. Real documents start at
/// [`super::documents::FIRST_VERSION`].
pub const NO_VERSION: i32 = 0;
/// Diagnostics for every document one server has reported on.
///
/// Cheap to clone (shared handle) so the pull tasks, the router and the manager
/// can each hold one. Lock poisoning is recovered from in one place rather than
/// being spelled differently at each call site: a panicking writer leaves the
/// map structurally intact, and stale diagnostics beat no diagnostics.
#[derive(Debug, Clone, Default)]
pub struct DiagnosticsStore {
inner: Arc<Inner>,
}
#[derive(Debug, Default)]
struct Inner {
documents: RwLock<HashMap<String, Answer>>,
publishes: AtomicBool,
}
impl DiagnosticsStore {
pub fn new() -> Self {
Self::default()
}
/// Whether this server publishes diagnostics of its own accord.
///
/// Set by the first `publishDiagnostics` to arrive and never cleared. It
/// decides whether to ask the server for diagnostics as well, and the
/// answer is no: a server with a push channel is telling us how it reports,
/// and its answer to a pull may be only part of what it knows.
/// rust-analyzer is the case in point — it answers
/// `textDocument/diagnostic` with its own analysis and *deliberately* does
/// not include `cargo check` results there, publishing those instead. Take
/// the pull answer as the whole picture and every clippy and type error in
/// the crate disappears.
pub fn server_publishes(&self) -> bool {
self.inner.publishes.load(Ordering::Acquire)
}
/// Write `answer` down unless what we hold is newer. Returns whether it
/// landed.
///
/// This is the only rule in the store, and every write goes through it.
/// Two things fall out of it that used to be maintained by hand:
///
/// - an answer about superseded text cannot erase a newer one, so a
/// mid-analysis blank that arrives late is harmless;
/// - an answer that did not land leaves no `result_id` behind, because the
/// id is part of the value that did not land.
pub fn install(&self, uri: &str, answer: Answer) -> bool {
self.install_if(uri, answer, || true)
}
/// The same, for a writer whose answer may have been overtaken by
/// something the store cannot see — a refresh, or the server revealing
/// that it publishes.
///
/// `still_wanted` is evaluated under the same lock that installs, so
/// nothing can slip between deciding to write and writing. Checking first
/// and writing second leaves a gap in which a `forget` is undone or a
/// fuller report is replaced by a thinner one.
///
/// It must not touch the store, or it will deadlock; the flags it reads
/// are atomics for that reason.
pub fn install_if(
&self,
uri: &str,
answer: Answer,
still_wanted: impl FnOnce() -> bool,
) -> bool {
let mut documents = self.write();
if let Some(held) = documents.get(uri)
&& held.covers > answer.covers
{
return false;
}
if !still_wanted() {
return false;
}
documents.insert(uri.to_string(), answer);
true
}
/// Record a pushed report.
///
/// `reported` is the version the server said it analyzed, and
/// `latest_sent` the newest version we have sent it. A server that names a
/// version is taken at its word; one that does not is credited with the
/// text it had most recently been given, which is all arrival order can
/// tell us.
pub fn record_push(
&self,
uri: &str,
items: Vec<Diagnostic>,
reported: Option<i32>,
latest_sent: Option<i32>,
) -> bool {
self.inner.publishes.store(true, Ordering::Release);
let covers = match (reported, latest_sent) {
// Never above what we sent. A server naming a version we never gave
// it — its own numbering, or a counter left over from a previous
// connection — would otherwise set a bar no later answer could
// clear, freezing that file's diagnostics for the session.
(Some(reported), Some(sent)) => reported.min(sent),
(None, Some(sent)) => sent,
// We have told this server nothing about the document, so nothing
// it says can be a verdict on text of ours.
(_, None) => NO_VERSION,
};
// A push replaces the whole set for the document, and carries no id.
self.install(uri, Answer::new(items, covers, None))
}
/// Record that the server stands by its previous answer for `uri`, as an
/// `unchanged` pull report does: same items, but a verdict on newer text.
///
/// Nothing to stand by means nothing to record — the id we sent named an
/// answer that has since been forgotten.
pub fn confirm_unchanged(
&self,
uri: &str,
covers: i32,
result_id: String,
still_wanted: impl FnOnce() -> bool,
) -> bool {
let Some(held) = self.answer(uri) else {
return false;
};
self.install_if(
uri,
Answer::new(held.items, covers, Some(result_id)),
still_wanted,
)
}
/// The version of the latest answer for `uri`, or `None` if the server has
/// never answered for it.
pub fn covers(&self, uri: &str) -> Option<i32> {
self.read().get(uri).map(|answer| answer.covers)
}
/// Whether the server has given a verdict on `uri` at `version` or later.
///
/// "No problems" is a verdict like any other. Conflating it with silence is
/// what makes a clean file wait forever for an answer it has already had.
pub fn answered_for(&self, uri: &str, version: i32) -> bool {
self.covers(uri).is_some_and(|covers| covers >= version)
}
/// The whole of the latest answer for `uri`, items and id together.
pub fn answer(&self, uri: &str) -> Option<Answer> {
self.read().get(uri).cloned()
}
/// The latest diagnostics for `uri`; empty both when the server has
/// answered "clean" and when it has not answered at all. Callers that need
/// to tell those apart use [`Self::answered_for`].
pub fn items(&self, uri: &str) -> Vec<Diagnostic> {
self.read()
.get(uri)
.map(|answer| answer.items.clone())
.unwrap_or_default()
}
/// Forget a document, for when it is closed.
pub fn forget(&self, uri: &str) {
self.write().remove(uri);
}
fn read(&self) -> RwLockReadGuard<'_, HashMap<String, Answer>> {
self.inner
.documents
.read()
.unwrap_or_else(|e| e.into_inner())
}
fn write(&self) -> RwLockWriteGuard<'_, HashMap<String, Answer>> {
self.inner
.documents
.write()
.unwrap_or_else(|e| e.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "file:///a.cs";
fn diagnostic(message: &str) -> Diagnostic {
Diagnostic {
message: message.to_string(),
..Default::default()
}
}
fn reported(message: &str, covers: i32) -> Answer {
Answer::new(vec![diagnostic(message)], covers, None)
}
#[test]
fn an_unanswered_document_has_no_verdict() {
let store = DiagnosticsStore::new();
assert_eq!(store.covers(A), None);
assert!(!store.answered_for(A, 0));
assert!(store.items(A).is_empty());
}
#[test]
fn a_clean_answer_is_still_an_answer() {
let store = DiagnosticsStore::new();
assert!(store.install(A, Answer::new(vec![], 0, None)));
assert!(
store.answered_for(A, 0),
"an empty report is the server saying the file is clean"
);
assert!(store.items(A).is_empty());
}
#[test]
fn an_answer_from_before_the_edit_does_not_count_as_a_reply_to_it() {
let store = DiagnosticsStore::new();
store.install(A, Answer::new(vec![], 0, None));
assert!(
!store.answered_for(A, 1),
"the pre-edit clean report must not be mistaken for a post-edit one"
);
store.install(A, reported("boom", 1));
assert!(store.answered_for(A, 1));
assert_eq!(store.items(A).len(), 1);
}
#[test]
fn a_late_answer_does_not_overwrite_a_newer_one() {
let store = DiagnosticsStore::new();
assert!(store.install(A, reported("the current truth", 2)));
assert!(
!store.install(A, reported("the late pull", 1)),
"an answer about text the server has since been sent a replacement for"
);
assert_eq!(store.items(A)[0].message, "the current truth");
assert_eq!(store.covers(A), Some(2));
}
/// A stale answer written down anyway would erase the errors it should
/// have deferred to, and the next pull would then have nothing to lose and
/// believe the first mid-analysis blank it was given.
#[test]
fn an_overtaken_empty_answer_does_not_erase_what_we_know() {
let store = DiagnosticsStore::new();
store.install(A, reported("boom", 3));
assert!(!store.install(A, Answer::new(vec![], 2, None)));
assert_eq!(store.items(A).len(), 1, "the real error stands");
assert_eq!(store.covers(A), Some(3));
}
/// The id and the items are one value, so an answer the store turns away
/// cannot leave its id behind to be sent back later — which is how a file
/// that had been fixed went on reporting its old errors.
#[test]
fn an_answer_the_store_refused_leaves_no_result_id_behind() {
let store = DiagnosticsStore::new();
store.install(A, Answer::new(vec![diagnostic("boom")], 3, None));
assert!(!store.install(A, Answer::new(vec![], 2, Some("stale-id".into()))));
assert_eq!(
store.answer(A).and_then(|answer| answer.result_id),
None,
"the id belonged to the answer that did not land"
);
}
#[test]
fn unchanged_refreshes_the_verdict_without_losing_the_diagnostics() {
let store = DiagnosticsStore::new();
store.install(
A,
Answer::new(vec![diagnostic("boom")], 1, Some("id-1".into())),
);
assert!(store.confirm_unchanged(A, 2, "id-2".into(), || true));
assert!(
store.answered_for(A, 2),
"standing by an answer is a verdict on the newer text"
);
assert_eq!(store.items(A).len(), 1, "and it keeps what it stands by");
assert_eq!(
store.answer(A).and_then(|answer| answer.result_id),
Some("id-2".into())
);
}
#[test]
fn an_overtaken_unchanged_reply_does_not_reply_either() {
let store = DiagnosticsStore::new();
store.install(A, Answer::new(vec![diagnostic("boom")], 3, None));
assert!(!store.confirm_unchanged(A, 2, "stale-id".into(), || true));
assert!(!store.answered_for(A, 4));
assert_eq!(store.covers(A), Some(3));
}
#[test]
fn standing_by_an_answer_we_no_longer_hold_records_nothing() {
let store = DiagnosticsStore::new();
assert!(!store.confirm_unchanged(A, 1, "id".into(), || true));
assert_eq!(store.covers(A), None);
}
#[test]
fn a_push_naming_its_version_is_taken_at_its_word() {
let store = DiagnosticsStore::new();
// The server is one revision behind: we are at 4, it analyzed 3.
store.record_push(A, vec![diagnostic("boom")], Some(3), Some(4));
assert!(store.answered_for(A, 3));
assert!(
!store.answered_for(A, 4),
"a verdict on the previous revision does not settle the newest edit"
);
}
#[test]
fn a_push_without_a_version_is_credited_with_the_text_the_server_has() {
let store = DiagnosticsStore::new();
store.record_push(A, vec![diagnostic("boom")], None, Some(4));
assert!(
store.answered_for(A, 4),
"arrival order is all a versionless report gives us"
);
}
#[test]
fn a_push_for_a_document_we_never_opened_is_still_kept() {
let store = DiagnosticsStore::new();
store.record_push(A, vec![diagnostic("boom")], None, None);
assert_eq!(store.items(A).len(), 1);
}
/// The writer's own reason to change its mind is checked under the lock
/// that installs, so there is no gap for a refresh or a push to fall into.
#[test]
fn an_answer_its_writer_no_longer_wants_does_not_land() {
let store = DiagnosticsStore::new();
store.install(A, reported("boom", 1));
assert!(!store.install_if(A, Answer::new(vec![], 2, None), || false));
assert_eq!(store.items(A).len(), 1);
assert_eq!(store.covers(A), Some(1));
assert!(store.install_if(A, Answer::new(vec![], 2, None), || true));
assert!(store.items(A).is_empty());
}
#[test]
fn forgetting_a_document_forgets_its_verdict() {
let store = DiagnosticsStore::new();
store.install(A, reported("boom", 1));
store.forget(A);
assert_eq!(store.covers(A), None);
}
}

View file

@ -267,30 +267,27 @@ impl super::LspBackend for LspBackendAdapter {
let mut file_diagnostics = Vec::new();
for client in mgr.clients.values() {
let map = client.diagnostics.read().unwrap_or_else(|e| e.into_inner());
if let Some(diags) = map.get(&uri_str) {
for d in diags {
let severity = match d.severity {
Some(async_lsp::lsp_types::DiagnosticSeverity::ERROR) => {
super::DiagnosticSeverityLevel::Error
}
Some(async_lsp::lsp_types::DiagnosticSeverity::WARNING) => {
super::DiagnosticSeverityLevel::Warning
}
_ => continue,
};
file_diagnostics.push(super::DiagnosticEntry {
severity,
// LSP uses 0-based positions; convert to 1-based
// for display (L{line}:{column}).
line: d.range.start.line + 1,
column: d.range.start.character + 1,
message: d.message.clone(),
source: d.source.clone(),
code: None,
is_stale: false,
});
}
for d in client.diagnostics.items(&uri_str) {
let severity = match d.severity {
Some(async_lsp::lsp_types::DiagnosticSeverity::ERROR) => {
super::DiagnosticSeverityLevel::Error
}
Some(async_lsp::lsp_types::DiagnosticSeverity::WARNING) => {
super::DiagnosticSeverityLevel::Warning
}
_ => continue,
};
file_diagnostics.push(super::DiagnosticEntry {
severity,
// LSP uses 0-based positions; convert to 1-based
// for display (L{line}:{column}).
line: d.range.start.line + 1,
column: d.range.start.character + 1,
message: d.message.clone(),
source: d.source.clone(),
code: None,
is_stale: false,
});
}
}

View file

@ -0,0 +1,248 @@
//! What we have told one server about each open document.
//!
//! Two readers besides the client need this. Incremental servers require a
//! range on every change event, which is computed from where the previous
//! revision ended. And every diagnostic answer has to be attributed to a
//! document version — pull knows the version it asked about, and a pushed
//! report that omits `version` is credited with the newest version we had sent
//! when it arrived. Both of those happen off the client's thread, so the
//! versions live behind a shared handle rather than inside `LspClient`.
use std::collections::HashMap;
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use async_lsp::lsp_types::Position;
/// The version a document is opened at.
///
/// Deliberately above [`super::diagnostics::NO_VERSION`], which is what a
/// report about a document we have never opened is credited: were they equal,
/// such a report would count as a verdict on our first edit to that file.
pub const FIRST_VERSION: i32 = 1;
const _: () = assert!(FIRST_VERSION > super::diagnostics::NO_VERSION);
/// The revision of one document that the server has.
#[derive(Debug, Clone)]
pub struct Tracked {
/// Version of the last notification we successfully sent for it.
pub version: i32,
pub language_id: String,
/// Where that revision ends. Two integers, not a copy of the text.
pub end: Position,
}
/// What the next notification for a document should be.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Update {
/// The server has never seen this document.
Open { version: i32 },
/// The server has it at `previous_end`; send `version` next.
Change {
version: i32,
previous_end: Position,
},
}
impl Update {
pub fn version(self) -> i32 {
match self {
Update::Open { version } | Update::Change { version, .. } => version,
}
}
}
/// Open documents for one server connection.
///
/// Cheap to clone (shared handle). Lock poisoning is recovered from in one
/// place: a panicking writer leaves the map structurally intact, and a stale
/// version beats no version at all.
#[derive(Debug, Clone, Default)]
pub struct Documents {
inner: Arc<RwLock<HashMap<String, Tracked>>>,
}
impl Documents {
pub fn new() -> Self {
Self::default()
}
/// What to send for `uri`, without recording it as sent.
///
/// Deliberately separate from [`Self::commit`]: what is recorded here
/// describes the text the *server* has, so a notification that failed to go
/// out must not advance it. Advancing it anyway would aim every later
/// incremental range at a revision the server never received — the same
/// protocol violation the range exists to avoid.
pub fn plan(&self, uri: &str) -> Update {
match self.read().get(uri) {
Some(tracked) => Update::Change {
version: tracked.version.saturating_add(1),
previous_end: tracked.end,
},
None => Update::Open {
version: FIRST_VERSION,
},
}
}
/// Record a notification that is on the wire.
pub fn commit(&self, uri: &str, version: i32, language_id: &str, end: Position) {
self.write()
.entry(uri.to_string())
.and_modify(|tracked| {
tracked.version = version;
tracked.end = end;
})
.or_insert_with(|| Tracked {
version,
language_id: language_id.to_string(),
end,
});
}
/// The version the server has, or `None` if it has never been told about
/// this document.
pub fn version(&self, uri: &str) -> Option<i32> {
self.read().get(uri).map(|tracked| tracked.version)
}
pub fn contains(&self, uri: &str) -> bool {
self.read().contains_key(uri)
}
/// Every open document, as `(uri, language_id)` — what a restart replays.
pub fn tracked(&self) -> Vec<(String, String)> {
self.read()
.iter()
.map(|(uri, tracked)| (uri.clone(), tracked.language_id.clone()))
.collect()
}
/// Every open document's URI. A refresh re-pulls all of them.
pub fn uris(&self) -> Vec<String> {
self.read().keys().cloned().collect()
}
/// Every open document with the version the server has, for re-asking
/// questions a refresh has made open again.
pub fn versions(&self) -> Vec<(String, i32)> {
self.read()
.iter()
.map(|(uri, tracked)| (uri.clone(), tracked.version))
.collect()
}
/// Forget everything, returning what was open so it can be closed.
pub fn take_all(&self) -> Vec<String> {
std::mem::take(&mut *self.write()).into_keys().collect()
}
fn read(&self) -> RwLockReadGuard<'_, HashMap<String, Tracked>> {
self.inner.read().unwrap_or_else(|e| e.into_inner())
}
fn write(&self) -> RwLockWriteGuard<'_, HashMap<String, Tracked>> {
self.inner.write().unwrap_or_else(|e| e.into_inner())
}
}
/// End position of `text`, i.e. the position just past its final character.
pub fn end_position(text: &str) -> Position {
// `lines()` drops a trailing newline, which would give a position that is
// short of the real end of the document, so count explicitly.
let mut line = 0u32;
let mut last_line_start = 0usize;
for (idx, ch) in text.char_indices() {
if ch == '\n' {
line += 1;
last_line_start = idx + 1;
}
}
// LSP character offsets are UTF-16 code units.
let character = text[last_line_start..].encode_utf16().count() as u32;
Position { line, character }
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "file:///a.cs";
#[test]
fn an_unknown_document_is_opened_above_the_no_version_marker() {
let documents = Documents::new();
assert_eq!(
documents.plan(A),
Update::Open {
version: FIRST_VERSION
}
);
assert_eq!(documents.version(A), None);
}
#[test]
fn a_committed_document_is_changed_from_where_it_ended() {
let documents = Documents::new();
documents.commit(A, 1, "csharp", end_position("one\ntwo"));
assert_eq!(
documents.plan(A),
Update::Change {
version: 2,
previous_end: Position {
line: 1,
character: 3
},
}
);
assert_eq!(documents.version(A), Some(1));
}
/// The plan is what to send; only a send that went out is committed. A
/// failed one must leave the server's revision where it was, or the next
/// incremental range will describe text the server never received.
#[test]
fn planning_alone_does_not_move_the_document() {
let documents = Documents::new();
documents.commit(A, 0, "csharp", end_position("one"));
let planned = documents.plan(A);
assert_eq!(
documents.plan(A),
planned,
"planning twice is the same plan"
);
assert_eq!(documents.version(A), Some(0));
}
fn position(line: u32, character: u32) -> Position {
Position { line, character }
}
#[test]
fn end_position_counts_the_trailing_newline_as_a_new_line() {
assert_eq!(end_position(""), position(0, 0));
assert_eq!(end_position("abc"), position(0, 3));
assert_eq!(end_position("abc\n"), position(1, 0));
assert_eq!(end_position("const x = 1;\nabcde"), position(1, 5));
assert_eq!(end_position("a\nb\nc\n"), position(3, 0));
}
#[test]
fn end_position_measures_in_utf16_code_units() {
// Astral-plane characters are two UTF-16 units; 'é' is one.
assert_eq!(end_position("é"), position(0, 1));
assert_eq!(end_position("🚀"), position(0, 2));
assert_eq!(end_position("a\n🚀b"), position(1, 3));
}
#[test]
fn take_all_empties_the_map() {
let documents = Documents::new();
documents.commit(A, FIRST_VERSION, "csharp", Position::default());
assert_eq!(documents.take_all(), vec![A.to_string()]);
assert!(documents.uris().is_empty());
assert!(!documents.contains(A));
}
}

View file

@ -1,13 +1,15 @@
//! Manages multiple LSP servers, routes by file extension, collects diagnostics.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use async_lsp::lsp_types::{DiagnosticSeverity, Url};
use async_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url};
use super::client::LspClient;
use super::config::LspServerConfig;
use super::pending::{PendingEdits, PendingPolicy};
use super::{DiagnosticsNotify, file_uri};
use crate::util::ProcessScope;
@ -20,19 +22,91 @@ pub struct DiagnosticsSummary {
pub diagnostic_count: usize,
}
#[derive(Default)]
pub struct PendingDiagnosticsState {
lifecycle_id: u64,
uris: BTreeSet<String>,
}
/// How many diagnostics one file may contribute to a summary.
///
/// A file with forty errors is usually one mistake seen forty times, and the
/// fortieth line teaches the reader nothing the first ten did not.
const MAX_PER_FILE: usize = 10;
/// Result of `collect_pending_diagnostics` — pure data, no state mutation.
/// How many a whole summary may carry.
///
/// A refresh re-opens every document at once, so without a ceiling the first
/// one on a large solution could put every problem in the workspace into a
/// single tool result.
const MAX_PER_SUMMARY: usize = 30;
/// What a drain found: the lines to show, and the counts that go with them.
#[derive(Default)]
struct CollectedDiagnostics {
lines: Vec<String>,
file_count: usize,
/// Reportable diagnostics found, including any the caps left out — the
/// counts describe what the servers said, not what survived the trim.
diagnostic_count: usize,
servers_without_diagnostics: Vec<String>,
shown: usize,
}
/// One server's open documents, to be asked about again after it said its
/// answers were out of date.
struct Reopened {
server_name: String,
lifecycle_id: u64,
documents: Vec<(String, i32)>,
}
impl CollectedDiagnostics {
/// Add the reportable diagnostics for one file. A file with nothing worth
/// showing — clean, or only hints and information — adds no header.
///
/// Errors come before warnings, and both are capped, so what survives a
/// trim is the part worth reading. The line each was reported on breaks
/// ties, so the order does not depend on how the server happened to sort
/// them.
fn append_file(&mut self, uri: &str, items: Vec<Diagnostic>) {
let mut reportable: Vec<(&str, &Diagnostic)> = items
.iter()
.filter_map(|d| match d.severity {
Some(DiagnosticSeverity::ERROR) => Some(("error", d)),
Some(DiagnosticSeverity::WARNING) => Some(("warn", d)),
_ => None,
})
.collect();
reportable.sort_by_key(|(label, d)| (*label != "error", d.range.start.line));
self.diagnostic_count += reportable.len();
// How many this file may contribute, given what the summary has room
// for. Applying it here rather than counting inside the loop means the
// header is only written when something follows it.
let room = MAX_PER_FILE.min(MAX_PER_SUMMARY.saturating_sub(self.shown));
let showing: Vec<_> = reportable.into_iter().take(room).collect();
if showing.is_empty() {
return;
}
let display_path = uri.strip_prefix("file://").unwrap_or(uri); // Unix-only
self.lines.push(format!("{display_path}:"));
self.file_count += 1;
self.shown += showing.len();
for (label, d) in showing {
let msg = d
.message
.replace("</lsp-diagnostics>", "&lt;/lsp-diagnostics&gt;")
.replace("</system-reminder>", "&lt;/system-reminder&gt;");
self.lines
.push(format!(" {label}[L{}]: {msg}", d.range.start.line + 1));
}
}
/// The line that tells the reader something was left out, if anything was.
///
/// Silently truncating would be worse than not reporting at all: the reader
/// would take a partial list for the whole truth and conclude the rest of
/// the file was fine.
fn trimmed_note(&self) -> Option<String> {
let hidden = self.diagnostic_count.saturating_sub(self.shown);
(hidden > 0).then(|| format!("… and {hidden} more not shown"))
}
}
pub struct LspManager {
@ -41,7 +115,11 @@ pub struct LspManager {
pub workspace_root: PathBuf,
pub initialized: bool,
pub tools_enabled: bool,
pub pending_diagnostics_by_server: HashMap<String, PendingDiagnosticsState>,
pub pending_diagnostics_by_server: HashMap<String, PendingEdits>,
/// How long a file is held waiting for a verdict, and how long a silent
/// server is blocked on. Configurable so a test can exercise the real drain
/// without spending the production durations waiting.
pub pending_policy: PendingPolicy,
pub diagnostics_ready: DiagnosticsNotify,
pub shutting_down: bool,
pub next_lifecycle_id: u64,
@ -61,6 +139,7 @@ impl Default for LspManager {
initialized: false,
tools_enabled: false,
pending_diagnostics_by_server: HashMap::new(),
pending_policy: PendingPolicy::default(),
diagnostics_ready: Arc::new(tokio::sync::Notify::new()),
shutting_down: false,
next_lifecycle_id: 1,
@ -103,27 +182,23 @@ impl LspManager {
lifecycle_id
}
pub fn mark_uri_pending_diagnostics(&mut self, server_name: &str, lifecycle_id: u64, uri: Url) {
let pending = self
.pending_diagnostics_by_server
.entry(server_name.to_string())
.or_default();
if pending.lifecycle_id != lifecycle_id {
pending.lifecycle_id = lifecycle_id;
pending.uris.clear();
}
pending.uris.insert(uri.to_string());
}
pub fn mark_path_pending_diagnostics(
/// Start waiting for the server's verdict on `uri`.
///
/// `version` is the document version the change was sent as — a verdict on
/// that version or a later one settles it, and one on an earlier version
/// does not. See [`PendingEdits`].
pub fn mark_uri_pending_diagnostics(
&mut self,
server_name: &str,
lifecycle_id: u64,
path: &Path,
uri: Url,
version: i32,
) {
if let Ok(uri) = file_uri(path) {
self.mark_uri_pending_diagnostics(server_name, lifecycle_id, uri);
}
let policy = self.pending_policy;
self.pending_diagnostics_by_server
.entry(server_name.to_string())
.or_insert_with(|| PendingEdits::new(policy))
.mark(lifecycle_id, uri.as_str(), version, Instant::now());
}
pub async fn ensure_initialized(&mut self) {
@ -218,25 +293,92 @@ impl LspManager {
Some(pair) => pair,
None => return,
};
let Ok(uri) = file_uri(path) else {
return;
};
let client = match self.clients.get_mut(&server_name) {
Some(c) => c,
None => return,
};
let lifecycle_id = client.lifecycle_id;
client.notify_file_change(path, content, &lang_id);
self.mark_path_pending_diagnostics(&server_name, lifecycle_id, path);
// No version means the server was never told about the edit, so there
// is no verdict on it to wait for.
let Some(version) = client.notify_file_change(path, content, &lang_id) else {
return;
};
self.mark_uri_pending_diagnostics(&server_name, lifecycle_id, uri, version);
}
pub fn has_pending_diagnostics(&self) -> bool {
self.pending_diagnostics_by_server
.values()
.any(|pending| !pending.uris.is_empty())
.any(|pending| !pending.is_empty())
}
/// Whether any pending server is still expected to answer. False once every
/// server owing us one has been silent for longer than
/// [`super::pending::SERVER_PATIENCE`], which lets the drain return immediately
/// instead of blocking for its whole timeout.
fn worth_blocking_for_diagnostics(&self) -> bool {
let now = Instant::now();
self.pending_diagnostics_by_server
.values()
.any(|pending| pending.worth_blocking(now))
}
/// Ask again about every open document of any server that has told us its
/// answers are out of date.
///
/// The re-pull is already under way — [`super::refresh`] starts it — but a
/// document nobody is waiting on has nowhere to report to, so the questions
/// have to be re-opened as well. Without this, the truth a server arrives
/// at *after* answering too early would sit in the store until the next
/// time that file happened to be edited.
fn reopen_refreshed_questions(&mut self) {
let mut reopened = Vec::new();
for (name, client) in &self.clients {
if client.refresh.take_invalidated() {
reopened.push(Reopened {
server_name: name.clone(),
lifecycle_id: client.lifecycle_id,
documents: client.documents.versions(),
});
}
}
let now = Instant::now();
let policy = self.pending_policy;
for Reopened {
server_name,
lifecycle_id,
documents,
} in reopened
{
if documents.is_empty() {
continue;
}
tracing::debug!(
server = %server_name, documents = documents.len(),
"server's answers were invalidated; waiting on all of its open documents again"
);
let pending = self
.pending_diagnostics_by_server
.entry(server_name)
.or_insert_with(|| PendingEdits::new(policy));
// Asking for a refresh is the server speaking. A server that spent
// a long time loading has been written off as silent by now, and
// this is the moment it is least true.
pending.note_server_spoke();
for (uri, version) in documents {
pending.mark(lifecycle_id, &uri, version, now);
}
}
}
fn pending_file_count(&self) -> usize {
self.pending_diagnostics_by_server
.values()
.map(|pending| pending.uris.len())
.map(PendingEdits::len)
.sum()
}
@ -251,90 +393,63 @@ impl LspManager {
.map(|uri| {
self.pending_diagnostics_by_server
.values()
.any(|pending| pending.uris.contains(uri.as_str()))
.any(|pending| pending.contains(uri.as_str()))
})
.unwrap_or(false)
}
fn build_pending_diagnostics_summary(&mut self) -> Option<DiagnosticsSummary> {
if !self.has_pending_diagnostics() {
return None;
}
/// Take the verdicts the servers have given on the files we are waiting on,
/// and report the problems among them.
///
/// Every file this settles leaves the pending set, whether or not it
/// produced a line to show: "no problems" is a verdict, and a file that
/// keeps waiting for one it has already had is what makes the set grow
/// without bound.
fn take_answered_diagnostics(&mut self) -> Option<DiagnosticsSummary> {
let now = Instant::now();
let mut collected = CollectedDiagnostics::default();
let collected = self.collect_pending_diagnostics();
// In server-name order, so what the reader sees does not depend on how
// a hash map happened to lay itself out.
let mut servers: Vec<&String> = self.pending_diagnostics_by_server.keys().collect();
servers.sort_unstable();
let servers: Vec<String> = servers.into_iter().cloned().collect();
if collected.lines.is_empty() {
tracing::debug!(
pending_servers = collected.servers_without_diagnostics.len(),
pending_file_count = self.pending_file_count(),
servers = ?collected.servers_without_diagnostics,
"no LSP diagnostics available for pending files"
);
None
} else {
self.pending_diagnostics_by_server.clear();
Some(DiagnosticsSummary {
text: format!(
"<lsp-diagnostics>\n{}\n</lsp-diagnostics>",
collected.lines.join("\n")
),
file_count: collected.file_count,
diagnostic_count: collected.diagnostic_count,
})
}
}
/// Pure data collection — reads from clients and pending state without mutation.
fn collect_pending_diagnostics(&self) -> CollectedDiagnostics {
let mut result = CollectedDiagnostics::default();
for (server_name, pending) in &self.pending_diagnostics_by_server {
let Some(client) = self.clients.get(server_name) else {
for server_name in servers {
let Some(pending) = self.pending_diagnostics_by_server.get_mut(&server_name) else {
continue;
};
if client.lifecycle_id != pending.lifecycle_id {
// The questions were put to a server that is gone, or that has been
// replaced. Either way nobody is going to answer them.
let Some(client) = self.clients.get(&server_name) else {
pending.clear();
continue;
};
if client.lifecycle_id != pending.lifecycle_id() {
pending.clear();
continue;
}
let map = client.diagnostics.read().unwrap_or_else(|e| e.into_inner());
let mut server_had_diagnostics = false;
for uri in &pending.uris {
let Some(diags) = map.get(uri.as_str()) else {
continue;
};
server_had_diagnostics = true;
let display_path = uri.strip_prefix("file://").unwrap_or(uri); // Unix-only
let mut has_header = false;
for d in diags {
let label = match d.severity {
Some(DiagnosticSeverity::ERROR) => "error",
Some(DiagnosticSeverity::WARNING) => "warn",
_ => continue,
};
if !has_header {
result.lines.push(format!("{display_path}:"));
has_header = true;
result.file_count += 1;
}
result.diagnostic_count += 1;
let msg = d
.message
.replace("</lsp-diagnostics>", "&lt;/lsp-diagnostics&gt;")
.replace("</system-reminder>", "&lt;/system-reminder&gt;");
result
.lines
.push(format!(" {label}[L{}]: {msg}", d.range.start.line + 1));
}
}
if !server_had_diagnostics {
result.servers_without_diagnostics.push(server_name.clone());
for uri in pending.take_answered(&server_name, &client.diagnostics, now) {
collected.append_file(&uri, client.diagnostics.items(&uri));
}
}
result
if collected.lines.is_empty() {
return None;
}
if let Some(note) = collected.trimmed_note() {
collected.lines.push(note);
}
Some(DiagnosticsSummary {
text: format!(
"<lsp-diagnostics>\n{}\n</lsp-diagnostics>",
collected.lines.join("\n")
),
file_count: collected.file_count,
diagnostic_count: collected.diagnostic_count,
})
}
/// Auto-open file if needed, return cloned socket for lock-free dispatch.
@ -348,7 +463,7 @@ impl LspManager {
.and_then(|c| {
file_uri(path)
.ok()
.map(|uri| !c.open_documents.contains_key(&uri.to_string()))
.map(|uri| !c.documents.contains(uri.as_str()))
})
.unwrap_or(false);
if needs_open
@ -473,37 +588,172 @@ impl LspManager {
}
}
/// Drops the lock during the Notify wait so `notify_file_changed` isn't blocked.
/// Wait, up to `timeout`, for the servers to say something about the files we
/// have told them about, and report whatever they said.
///
/// Drops the lock across the wait so `notify_file_changed` isn't blocked.
pub async fn drain_lsp_diagnostics(
lsp_manager: &tokio::sync::Mutex<LspManager>,
timeout: std::time::Duration,
) -> Option<DiagnosticsSummary> {
let deadline = tokio::time::Instant::now() + timeout;
let mut lsp = lsp_manager.lock().await;
if !lsp.has_pending_diagnostics() {
return None;
// Set once the budget is spent. Checked only *after* collecting, so the
// store is always read one final time before we conclude there was nothing:
// a report can land between the wait's last poll and our re-taking the
// lock, and it would otherwise sit unread.
let mut out_of_time = false;
loop {
// A refresh can land at any point, including during the wait below.
lsp.reopen_refreshed_questions();
if !lsp.has_pending_diagnostics() {
return None;
}
// The answer may already be in; on the first pass that saves the wait
// entirely, and on later passes it is what the wait was for.
if let Some(summary) = lsp.take_answered_diagnostics() {
return Some(summary);
}
// Nothing to report, and either the budget is gone or every server
// still owing us a verdict has stopped talking. Both mean stop.
if out_of_time || !lsp.worth_blocking_for_diagnostics() {
tracing::debug!(
pending_file_count = lsp.pending_file_count(),
timeout_ms = timeout.as_millis() as u64,
timed_out = out_of_time,
"no LSP diagnostics for pending files"
);
return None;
}
// Register the waiter before dropping the lock so a notify_one() that
// lands in between is not lost.
let notify = lsp.diagnostics_ready.clone();
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
drop(lsp);
// Every document shares this notification, so being woken is not proof
// that *our* files were answered — a publish for some other file wakes
// us just the same. Go back and look, and if it was not for us, keep
// waiting until it is or the budget runs out.
out_of_time = tokio::time::timeout_at(deadline, notified).await.is_err();
lsp = lsp_manager.lock().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_lsp::lsp_types::{Position, Range};
fn diagnostic(line: u32, severity: DiagnosticSeverity, message: &str) -> Diagnostic {
Diagnostic {
range: Range {
start: Position { line, character: 0 },
end: Position { line, character: 1 },
},
severity: Some(severity),
message: message.to_string(),
..Default::default()
}
}
if let Some(summary) = lsp.build_pending_diagnostics_summary() {
return Some(summary);
fn errors(n: u32) -> Vec<Diagnostic> {
(0..n)
.map(|i| diagnostic(i, DiagnosticSeverity::ERROR, &format!("error {i}")))
.collect()
}
// Register waiter before dropping lock so notify_one() isn't lost.
let notify = lsp.diagnostics_ready.clone();
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
drop(lsp);
/// A file with forty problems is usually one mistake seen forty times, and
/// the reader is worse off for having all of them.
#[test]
fn one_file_cannot_fill_the_whole_summary() {
let mut collected = CollectedDiagnostics::default();
collected.append_file("file:///a.cs", errors(40));
let _ = tokio::time::timeout(timeout, &mut notified).await;
let mut lsp = lsp_manager.lock().await;
let result = lsp.build_pending_diagnostics_summary();
if result.is_none() {
tracing::debug!(
pending_file_count = lsp.pending_file_count(),
timeout_ms = timeout.as_millis() as u64,
"LSP diagnostics not available after timeout, preserving pending state"
assert_eq!(collected.lines.len(), MAX_PER_FILE + 1, "plus the header");
assert_eq!(
collected.diagnostic_count, 40,
"the count is what the server said, not what survived the trim"
);
assert_eq!(
collected.trimmed_note().as_deref(),
Some("… and 30 more not shown"),
"silently truncating would let the reader take a partial list for the whole truth"
);
}
result
/// A refresh re-opens every open document at once, so the ceiling has to
/// hold across files and not just within one.
#[test]
fn a_refresh_over_many_files_cannot_flood_one_turn() {
let mut collected = CollectedDiagnostics::default();
for i in 0..20 {
collected.append_file(&format!("file:///f{i}.cs"), errors(5));
}
let shown = collected
.lines
.iter()
.filter(|l| l.starts_with(" "))
.count();
assert_eq!(shown, MAX_PER_SUMMARY);
assert_eq!(collected.diagnostic_count, 100);
assert_eq!(
collected.trimmed_note().as_deref(),
Some("… and 70 more not shown")
);
}
/// When something has to go, warnings go first.
#[test]
fn errors_come_before_warnings() {
let mut collected = CollectedDiagnostics::default();
let mut items = vec![
diagnostic(9, DiagnosticSeverity::WARNING, "a warning"),
diagnostic(1, DiagnosticSeverity::ERROR, "an error"),
];
items.extend(
(0..MAX_PER_FILE as u32)
.map(|i| diagnostic(20 + i, DiagnosticSeverity::WARNING, "filler")),
);
collected.append_file("file:///a.cs", items);
assert!(
collected.lines[1].contains("an error"),
"{:?}",
collected.lines
);
assert!(
collected.lines[2].contains("a warning"),
"{:?}",
collected.lines
);
}
#[test]
fn nothing_worth_showing_adds_no_header() {
let mut collected = CollectedDiagnostics::default();
collected.append_file(
"file:///a.cs",
vec![diagnostic(0, DiagnosticSeverity::HINT, "just a hint")],
);
assert!(collected.lines.is_empty());
assert_eq!(collected.file_count, 0);
assert_eq!(collected.trimmed_note(), None);
}
#[test]
fn a_summary_that_fits_says_nothing_about_trimming() {
let mut collected = CollectedDiagnostics::default();
collected.append_file("file:///a.cs", errors(3));
assert_eq!(collected.trimmed_note(), None);
assert_eq!(collected.file_count, 1);
}
}

View file

@ -1,10 +1,17 @@
pub mod capabilities;
pub mod client;
pub mod config;
pub mod diagnostics;
pub mod dispatch;
pub mod documents;
pub mod format;
pub mod manager;
pub mod pending;
pub mod pull;
pub mod refresh;
pub mod restart;
mod types;
pub mod workspace_open;
#[cfg(test)]
mod tests;
@ -19,13 +26,19 @@ pub use types::{
// ── Shared types used across submodules ─────────────────────────────────
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use async_lsp::lsp_types::{
Diagnostic, Position, TextDocumentIdentifier, TextDocumentPositionParams, Url,
};
use async_lsp::lsp_types::{Position, TextDocumentIdentifier, TextDocumentPositionParams, Url};
/// How long a reader will wait for diagnostics to arrive after an edit before
/// reporting what it has.
///
/// This is the budget the whole after-edit diagnostics path is sized against:
/// anything scheduled to happen later than this — a pull retry, say — answers
/// after the reader has already given up. Kept here, next to the pieces that
/// have to agree on it, rather than as a number at the call site.
pub const DIAGNOSTICS_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
#[derive(Debug, thiserror::Error)]
pub enum LspError {
@ -41,7 +54,6 @@ pub enum LspError {
InvalidPath,
}
pub type DiagnosticsMap = Arc<std::sync::RwLock<HashMap<String, Vec<Diagnostic>>>>;
pub type DiagnosticsNotify = Arc<tokio::sync::Notify>;
pub type LspMainLoop = async_lsp::MainLoop<async_lsp::router::Router<()>>;

View file

@ -0,0 +1,484 @@
//! The edits one server still owes us a verdict on.
//!
//! A URI is pending from the moment we tell the server about an edit until the
//! server gives a verdict on that edit — diagnostics, or the news that there
//! are none — or until it has waited long enough that we stop expecting one.
//!
//! Both of those are questions about *data*, not about bookkeeping: whether the
//! store holds a verdict for the version we sent, and whether the wall clock
//! has passed a deadline. Nothing here has to be told how a drain ended, so
//! there is no path on which a drain can forget to tell it, and no counter that
//! can drift.
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use super::diagnostics::DiagnosticsStore;
/// How long we hold on to a file waiting for a verdict.
///
/// Bounds the pending set: a file nobody ever answers for is let go rather than
/// carried for the rest of the session. Generous, because the cost of holding
/// one is two integers and the cost of dropping one too early is a missed
/// diagnostic.
pub const VERDICT_TTL: Duration = Duration::from_secs(30);
/// How long a server gets to say *something* before we stop blocking on it.
///
/// Measured only while we are actually waiting on it — see
/// [`PendingEdits::asking_since`] — so an idle stretch with nothing outstanding
/// does not count against a server, and any answer starts it over. A server
/// that has been asked for this long without a word is not about to answer
/// within the drain's budget, and waiting out that budget on every later turn
/// just makes every turn slower.
///
/// It measures *silence*, not "no problems found": a server reporting that a
/// file is clean has answered. Conflating the two writes a healthy server off
/// after a few clean edits, which is the common case.
pub const SERVER_PATIENCE: Duration = Duration::from_secs(10);
/// The two durations, together, so a caller that wants to change one is
/// choosing between two named things rather than editing a constant that also
/// means something else.
///
/// They answer different questions — how long we hold on to a file, and
/// whether the server is worth blocking on — which is why they are separate.
/// One number doing both jobs is how a server that answered three clean edits
/// in a row came to be written off.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PendingPolicy {
pub verdict_ttl: Duration,
pub server_patience: Duration,
}
impl Default for PendingPolicy {
fn default() -> Self {
Self {
verdict_ttl: VERDICT_TTL,
server_patience: SERVER_PATIENCE,
}
}
}
/// One edit we are waiting on.
#[derive(Debug, Clone, Copy)]
struct PendingEdit {
/// The document version we want a verdict on. A verdict on this version or
/// a later one settles it; one on an earlier version — an answer that was
/// already being computed when this edit arrived — does not.
version: i32,
expires_at: Instant,
}
/// Files edited since the server last gave a verdict on them.
#[derive(Debug, Default)]
pub struct PendingEdits {
policy: PendingPolicy,
lifecycle_id: u64,
by_uri: BTreeMap<String, PendingEdit>,
/// When the current stretch of asking-without-an-answer began.
///
/// Set while something is outstanding, cleared by any answer. That is what
/// makes it a measure of the server rather than of the clock: a session
/// spent reading code does not make a healthy server look dead.
asking_since: Option<Instant>,
}
impl PendingEdits {
pub fn new(policy: PendingPolicy) -> Self {
Self {
policy,
..Self::default()
}
}
/// Start waiting for a verdict on `version` of `uri`.
///
/// A restart makes every outstanding question meaningless — the fresh
/// server was never asked them — so a new lifecycle starts from nothing.
pub fn mark(&mut self, lifecycle_id: u64, uri: &str, version: i32, now: Instant) {
if self.lifecycle_id != lifecycle_id {
*self = Self {
policy: self.policy,
lifecycle_id,
..Self::default()
};
}
self.asking_since.get_or_insert(now);
// Re-editing a file we are already waiting on moves the goalposts: only
// a verdict on this version or later will do, and the clock restarts.
self.by_uri.insert(
uri.to_string(),
PendingEdit {
version,
expires_at: now + self.policy.verdict_ttl,
},
);
}
/// The server has spoken of its own accord — it has asked us to read its
/// answers again — which is proof of life whatever it was about, so any
/// stretch of silence it was in is over.
///
/// Without this the case the refresh exists for is the one it fails on: a
/// server that spends a long time loading has already been written off as
/// silent by the time it announces it is ready, so the very drain that
/// should wait for the re-pull it just asked for would not wait at all.
pub fn note_server_spoke(&mut self) {
self.asking_since = None;
}
/// Forget everything, for a server whose client has been replaced.
pub fn clear(&mut self) {
self.by_uri.clear();
self.asking_since = None;
}
pub fn lifecycle_id(&self) -> u64 {
self.lifecycle_id
}
pub fn is_empty(&self) -> bool {
self.by_uri.is_empty()
}
pub fn len(&self) -> usize {
self.by_uri.len()
}
#[cfg(test)]
pub fn contains(&self, uri: &str) -> bool {
self.by_uri.contains_key(uri)
}
/// Take the files the server has now given a verdict on, and let go of the
/// ones that have waited long enough.
///
/// Returned in URI order, so what a reader sees does not depend on the
/// order edits happened to arrive in.
pub fn take_answered(
&mut self,
server_name: &str,
store: &DiagnosticsStore,
now: Instant,
) -> Vec<String> {
let mut answered = Vec::new();
let mut expired = 0usize;
self.by_uri.retain(|uri, pending| {
if store.answered_for(uri, pending.version) {
answered.push(uri.clone());
return false;
}
if now >= pending.expires_at {
expired += 1;
return false;
}
true
});
if expired > 0 {
tracing::debug!(
server = %server_name, expired,
"no verdict on these files in time; no longer waiting"
);
}
// An answer about any file is proof the server is working, whatever it
// was about, so the stretch of silence is over. It does not restart for
// whatever is still outstanding: a server that answers for most files
// and never for one would then be judged silent on the strength of the
// one, and stop being waited on while it was plainly working. The cost
// of erring this way is bounded — the stuck file leaves on its own
// deadline — and it errs towards waiting for a server we have evidence
// is alive, which is the right direction.
//
// Only an answer does this. Letting go of a file because it ran out of
// time is the opposite of evidence, and treating an empty set as a
// fresh start would hand a server that has never said a word a clean
// slate every time its files expired.
if !answered.is_empty() {
self.asking_since = None;
}
answered
}
/// Whether it is still worth blocking a turn on this server.
pub fn worth_blocking(&self, now: Instant) -> bool {
!self.by_uri.is_empty()
&& self.asking_since.is_none_or(|since| {
now.saturating_duration_since(since) < self.policy.server_patience
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::implementations::lsp::diagnostics::Answer;
use async_lsp::lsp_types::Diagnostic;
const SERVER: &str = "test-server";
const A: &str = "file:///a.cs";
const B: &str = "file:///b.cs";
fn diagnostic() -> Diagnostic {
Diagnostic {
message: "boom".into(),
..Default::default()
}
}
#[test]
fn a_verdict_on_the_edit_settles_it() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let now = Instant::now();
pending.mark(1, A, 3, now);
assert!(pending.take_answered(SERVER, &store, now).is_empty());
assert!(pending.contains(A));
store.install(A, Answer::new(vec![diagnostic()], 3, None));
assert_eq!(pending.take_answered(SERVER, &store, now), vec![A]);
assert!(pending.is_empty());
}
#[test]
fn a_clean_verdict_settles_it_too() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let now = Instant::now();
pending.mark(1, A, 0, now);
store.install(A, Answer::new(vec![], 0, None));
assert_eq!(
pending.take_answered(SERVER, &store, now),
vec![A],
"'no problems' is a verdict; a file that keeps waiting for one it \
has already had is what makes the set grow without bound"
);
}
#[test]
fn a_verdict_on_the_previous_revision_does_not_settle_the_new_one() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let now = Instant::now();
store.install(A, Answer::new(vec![diagnostic()], 2, None));
pending.mark(1, A, 3, now);
assert!(
pending.take_answered(SERVER, &store, now).is_empty(),
"the answer already in the store is about text we have since replaced"
);
}
#[test]
fn a_file_nobody_answers_for_is_eventually_let_go() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 0, start);
assert!(
pending
.take_answered(
SERVER,
&store,
start + VERDICT_TTL - Duration::from_millis(1)
)
.is_empty()
);
assert!(pending.contains(A), "still within its time");
assert!(
pending
.take_answered(SERVER, &store, start + VERDICT_TTL)
.is_empty(),
"letting go is not the same as reporting"
);
assert!(pending.is_empty());
}
/// The set is bounded by what the server owes us, not by how many files
/// have been touched: a server answering for one file while ignoring
/// another must still let go of the one it ignores.
#[test]
fn a_productive_server_still_lets_go_of_a_file_it_never_answers_for() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 0, start);
pending.mark(1, B, 0, start);
store.install(A, Answer::new(vec![diagnostic()], 0, None));
assert_eq!(pending.take_answered(SERVER, &store, start), vec![A]);
assert_eq!(pending.len(), 1, "b is still owed");
assert!(
pending
.take_answered(SERVER, &store, start + VERDICT_TTL)
.is_empty()
);
assert!(pending.is_empty(), "and eventually let go of");
}
#[test]
fn a_silent_server_stops_costing_every_turn_its_budget() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 0, start);
assert!(pending.worth_blocking(start));
// Turn after turn of edits, and never a word back.
let later = start + SERVER_PATIENCE;
pending.mark(1, B, 0, later);
pending.take_answered(SERVER, &store, later);
assert!(
!pending.worth_blocking(later),
"a fresh edit does not restart the clock on a server that has said nothing"
);
}
#[test]
fn a_server_that_starts_answering_is_waited_on_again() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 0, start);
let quiet = start + SERVER_PATIENCE;
assert!(!pending.worth_blocking(quiet));
// Roslyn finishes loading its solution and finally says something.
store.install(A, Answer::new(vec![diagnostic()], 0, None));
assert_eq!(pending.take_answered(SERVER, &store, quiet), vec![A]);
pending.mark(1, B, 0, quiet);
assert!(
pending.worth_blocking(quiet),
"an answer is proof of life, whatever it was about"
);
}
/// A server that answers for most files and never for one must not be
/// judged silent on the strength of the one. The stuck file leaves on its
/// own deadline; until then the server keeps being waited on, because it is
/// plainly working.
#[test]
fn one_file_nobody_answers_for_does_not_make_a_busy_server_look_dead() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
let mut now = start;
pending.mark(1, A, 1, now); // never answered
// Several stretches longer than the server's patience, all well within
// the stuck file's own deadline.
for round in 1..3 {
pending.mark(1, B, round, now);
store.install(B, Answer::new(vec![diagnostic()], round, None));
assert_eq!(pending.take_answered(SERVER, &store, now), vec![B]);
now += SERVER_PATIENCE;
assert!(
pending.contains(A),
"round {round}: the stuck file is still within its deadline"
);
assert!(
pending.worth_blocking(now),
"round {round}: the server answered, so it is worth waiting for"
);
}
assert!(
now.duration_since(start) < VERDICT_TTL,
"the test must stay inside the stuck file's deadline to be about patience"
);
}
/// Roslyn goes quiet for as long as it takes to load a solution, and then
/// says it is ready. Asking for a refresh is the server speaking, so it
/// starts the clock over — otherwise the drain that should wait for the
/// re-pull the server just asked for would return without waiting.
#[test]
fn a_server_that_asks_for_a_refresh_is_worth_waiting_for_again() {
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 1, start);
let quiet = start + SERVER_PATIENCE;
assert!(!pending.worth_blocking(quiet), "written off as silent");
pending.note_server_spoke();
pending.mark(1, A, 1, quiet);
assert!(
pending.worth_blocking(quiet),
"the refresh was the server proving it is alive and about to answer"
);
}
#[test]
fn nothing_outstanding_is_nothing_to_wait_for() {
let pending = PendingEdits::default();
assert!(!pending.worth_blocking(Instant::now()));
}
/// Letting go of a file is not the server saying something. If running out
/// of time counted as the end of a stretch of silence, a server that never
/// speaks would get a clean slate every time its files expired, and every
/// later turn would block on it for the full drain budget again.
#[test]
fn running_out_of_time_is_not_evidence_of_life() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
pending.mark(1, A, 0, start);
let expired = start + VERDICT_TTL;
assert!(pending.take_answered(SERVER, &store, expired).is_empty());
assert!(pending.is_empty(), "the file was let go");
pending.mark(1, B, 0, expired);
assert!(
!pending.worth_blocking(expired),
"the server has still never said a word"
);
}
#[test]
fn a_clean_stretch_does_not_count_against_the_server() {
let store = DiagnosticsStore::new();
let mut pending = PendingEdits::default();
let start = Instant::now();
// Several turns, every one of them answered "no problems".
let mut now = start;
for round in 0..5 {
pending.mark(1, A, round, now);
store.install(A, Answer::new(vec![], round, None));
assert_eq!(pending.take_answered(SERVER, &store, now), vec![A]);
now += Duration::from_secs(5);
}
pending.mark(1, A, 5, now);
assert!(
pending.worth_blocking(now),
"five clean edits are five answers, not five silences"
);
}
#[test]
fn a_restart_forgets_what_the_old_server_was_asked() {
let mut pending = PendingEdits::default();
let now = Instant::now();
pending.mark(1, A, 7, now);
pending.mark(2, B, 0, now);
assert!(!pending.contains(A), "the fresh server was never asked");
assert!(pending.contains(B));
assert_eq!(pending.lifecycle_id(), 2);
}
}

View file

@ -0,0 +1,591 @@
//! Pull-model diagnostics (`textDocument/diagnostic`).
//!
//! Most servers push diagnostics at us. Some — Roslyn among them — never
//! publish anything and only answer when asked, so without pulling we would see
//! no C# diagnostics at all.
//!
//! The split here is deliberate: [`PullDiagnostics::request`] performs exactly
//! one round trip and reports what came back as a [`PullOutcome`], while
//! [`PullDiagnostics::resolve`] decides what to do about it. Keeping the two
//! apart is what lets the transport be tested without a server, and what keeps
//! the one judgement call, [`CONFIRM_DELAY`], in one readable place.
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use async_lsp::LanguageServer;
use async_lsp::lsp_types::{
Diagnostic, DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult,
TextDocumentIdentifier, Url,
};
use super::DiagnosticsNotify;
use super::diagnostics::{Answer, DiagnosticsStore};
use super::documents::Documents;
/// How long to wait for a pull-diagnostics response before giving up on it.
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Delay before asking a second time, when an empty answer is not yet worth
/// believing.
///
/// This deliberately runs past [`super::DIAGNOSTICS_DRAIN_TIMEOUT`], and only
/// ever does so on the path where the answer would erase. Being a turn late to
/// say "it's fixed now" costs the reader nothing — the previous, still-accurate
/// errors stay on screen in the meantime. The path that has nothing to lose
/// does not wait at all, so a first answer, and any answer that reports
/// problems, still lands inside the drain's budget.
const CONFIRM_DELAY: std::time::Duration = std::time::Duration::from_millis(600);
// Not an accident, and not a number to "fix" by shrinking: a server needs
// longer than the drain budget to re-analyze, so confirming that a broken file
// is now clean cannot happen within it. Stated here so that shrinking it below
// the budget — which would look like an optimisation — fails the build instead.
const _: () = assert!(CONFIRM_DELAY.as_millis() > super::DIAGNOSTICS_DRAIN_TIMEOUT.as_millis());
/// Whether a server answers `textDocument/diagnostic`.
///
/// The advertised capability is not enough on its own: Roslyn implements the
/// handler but, depending on the build, advertises no diagnostic provider at
/// all. So a server that did not advertise one is still asked, and only its own
/// rejection stops us asking again.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PullSupport {
/// Worth asking — either the server advertised a provider, or it has not
/// told us otherwise.
Asking,
/// The server answered `MethodNotFound`. Conclusive, and the only thing
/// that is.
Rejected,
}
/// [`PullSupport`] shared between the client and its detached pull tasks.
///
/// Only `MethodNotFound` writes a server off; timeouts do not, since concurrent
/// pulls would spend any such budget at once on a single slow episode.
#[derive(Debug)]
struct SupportFlag {
rejected: AtomicBool,
}
impl SupportFlag {
fn new() -> Self {
Self {
rejected: AtomicBool::new(false),
}
}
fn get(&self) -> PullSupport {
if self.rejected.load(Ordering::Relaxed) {
PullSupport::Rejected
} else {
PullSupport::Asking
}
}
/// The server said it does not implement the request.
fn write_off(&self) {
self.rejected.store(true, Ordering::Relaxed);
}
}
/// What one `textDocument/diagnostic` round trip came back with.
#[derive(Debug, PartialEq)]
enum PullOutcome {
/// The server reported problems.
Reported {
items: Vec<Diagnostic>,
result_id: Option<String>,
},
/// The server answered, and had nothing to report.
Clean { result_id: Option<String> },
/// The server stands by the answer we told it we already have.
Unchanged { result_id: String },
/// The server does not implement pull diagnostics.
Unsupported,
/// The request failed, timed out, or returned a partial result we did not
/// ask for. Nothing to write down.
Failed,
}
/// Pull-diagnostics state for one server connection.
///
/// Cheap to clone: every field is a shared handle, so a detached task takes a
/// whole clone rather than five separate ones.
#[derive(Clone)]
pub struct PullDiagnostics {
server_name: Arc<str>,
socket: async_lsp::ServerSocket,
store: DiagnosticsStore,
documents: Documents,
notify: DiagnosticsNotify,
support: Arc<SupportFlag>,
in_flight: Arc<std::sync::Mutex<InFlight>>,
/// Whether this server has ever answered a pull.
///
/// Until it has, we do not know what kind of server it is. It may be
/// pull-only, like Roslyn, or it may be one that publishes and has simply
/// not had anything to publish yet — and those want opposite treatment.
/// One way, and never cleared.
answered_a_pull: Arc<AtomicBool>,
/// Bumped when the server says its answers no longer describe the code.
///
/// A document version cannot express this: the text did not change, the
/// server's knowledge of it did. Without it, a pull already in flight when
/// the refresh arrives comes back with the very answer the server has just
/// disowned, and — being about the current version — passes for a reply to
/// the question the refresh re-opened.
generation: Arc<AtomicU64>,
}
/// Which documents have a pull running, and which were edited again while it
/// ran. Keeps one task per document instead of one per edit.
#[derive(Debug, Default)]
struct InFlight {
running: HashSet<String>,
superseded: HashSet<String>,
}
impl PullDiagnostics {
pub fn new(
server_name: &str,
socket: async_lsp::ServerSocket,
store: DiagnosticsStore,
documents: Documents,
notify: DiagnosticsNotify,
) -> Self {
Self {
server_name: Arc::from(server_name),
socket,
store,
documents,
notify,
support: Arc::new(SupportFlag::new()),
in_flight: Arc::new(std::sync::Mutex::new(InFlight::default())),
answered_a_pull: Arc::new(AtomicBool::new(false)),
generation: Arc::new(AtomicU64::new(0)),
}
}
pub fn support(&self) -> PullSupport {
self.support.get()
}
/// Ask the server for `uri`'s diagnostics and fold the answer into the
/// store, so pulled and pushed diagnostics reach readers by the same path.
///
/// Detached, because the callers are synchronous notification paths that
/// must not block on a server round trip. At most one task per document:
/// an edit arriving while a pull is running re-runs it once at the end
/// rather than racing a second task against the first.
///
/// Returns whether the document will get an answer — `false` only when
/// this server is not one we ask. A pull already running counts as `true`:
/// it has been told to run once more, so the question does get put.
pub fn will_answer(&self, uri: Url) -> bool {
if !self.worth_asking() {
return false;
}
let key = uri.to_string();
if !self.begin(&key) {
// Already running, and now queued to run again — the caller's
// question will be answered, just not by a task of its own.
return true;
}
let pull = self.clone();
tokio::spawn(async move {
loop {
pull.resolve(&uri, &key).await;
if !pull.finish(&key) {
break;
}
}
});
true
}
/// Whether asking this server is the right way to learn what it thinks.
///
/// No, if it has told us it does not implement the request, and no if it
/// publishes: a server with a push channel has said how it reports, and
/// what it returns from a pull may be only part of it. Both are one-way —
/// once either is true it stays true — so this cannot oscillate.
fn worth_asking(&self) -> bool {
self.support.get() == PullSupport::Asking && !self.store.server_publishes()
}
/// Re-ask about every open document, for when the server says its answers
/// have changed. Coalescing means a burst of these costs one pull per
/// document, however many arrive.
///
/// What the server said before is forgotten first. It has just told us
/// those answers are out of date, and keeping one would both misreport it
/// as current and make the re-pull look like it had already been answered.
/// Returns whether anything was asked.
pub fn refresh_all(&self) -> bool {
if !self.worth_asking() {
// Nothing we can do about it, so nothing is thrown away either. A
// server we do not ask keeps whatever it has already told us —
// discarding that would leave the reader with nothing at all.
tracing::debug!(server = %self.server_name, "server asked for a diagnostics refresh, but it is not one we pull from");
return false;
}
// Everything computed before this point is now the server's own old
// news. Bumped before anything is forgotten or asked, so no answer can
// slip between the two and be kept.
self.generation.fetch_add(1, Ordering::AcqRel);
let mut asked = false;
for uri in self.documents.uris() {
let Ok(parsed) = Url::parse(&uri) else {
tracing::debug!(server = %self.server_name, %uri, "cannot re-pull an unparseable uri");
continue;
};
if !self.will_answer(parsed) {
continue;
}
// Forgotten only once the replacement is on its way. What the
// server has disowned is worse than nothing — reported as current
// it is wrong, and left in place it makes the re-pull look like it
// has already been answered — but throwing it away with no
// replacement coming would just blind the reader.
self.store.forget(&uri);
asked = true;
}
asked
}
/// Ask, and write down what comes back.
///
/// The one judgement call in this module lives here. A server answers
/// before it has finished re-analyzing the change we just sent: Roslyn will
/// return an empty report a few hundred milliseconds after an edit to a
/// file it is still working on. Believing that erases real errors and tells
/// the reader its problem is fixed, so when there is something to lose the
/// answer has to be given twice. When there is nothing to lose — no
/// diagnostics held for this document — the first answer is taken at once,
/// so "nothing wrong here" still lands inside the drain's budget.
///
/// This is the only guess in the diagnostics path. The mechanism that makes
/// it merely a matter of latency rather than of correctness is
/// [`super::refresh`]: when the server finishes analyzing, it says so, and
/// everything is asked again.
async fn resolve(&self, uri: &Url, key: &str) {
// The revision we are asking about, read just before the request goes
// out and used to describe the answer. `textDocument/diagnostic`
// carries no version, so an answer to a request that predates an edit
// may or may not have taken that edit into account — and one that
// cannot be shown to postdate it must not settle it. Being wrong that
// way costs one more round trip; being wrong the other way reports
// diagnostics for text that no longer exists.
let Some(asked) = self.documents.version(key) else {
return;
};
// What the server knew when we asked. An answer from before a refresh
// describes code the server has since disowned; the re-ask that the
// refresh queued is the one worth listening to.
let generation = self.generation.load(Ordering::Acquire);
// Two reasons not to believe the first "nothing to report" we are
// given, both answered by asking again a moment later.
//
// The first is that the server may not have finished re-analyzing the
// change we just sent, and erasing errors it is about to re-report
// tells the reader its problem is fixed when it is not.
//
// The second is that we may not yet know what kind of server this is.
// A server that publishes keeps some of what it knows on that channel —
// rust-analyzer leaves `cargo check` there — so its pull answer is a
// part rather than the whole, and its first publish is what tells us
// so. The wait gives that publish a chance to arrive; if it does, this
// answer is dropped as the partial view it was.
let mut confirming = self
.store
.answer(key)
.is_some_and(|answer| !answer.items.is_empty())
|| (!self.answered_a_pull.load(Ordering::Acquire) && !self.store.server_publishes());
loop {
// Read afresh each time round: the id is only worth sending while
// it names what the store holds, and the wait below is long enough
// for that to have changed.
let previous_result_id = self.store.answer(key).and_then(|held| held.result_id);
match self.request(uri, previous_result_id).await {
PullOutcome::Reported { items, result_id } => {
self.note_answered();
tracing::debug!(
server = %self.server_name, uri = %key, count = items.len(),
"diagnostic pull returned"
);
self.commit(key, Answer::new(items, asked, result_id), generation);
return;
}
PullOutcome::Unchanged { result_id } => {
self.note_answered();
// Safe to take at face value: the id we sent came out of
// the store, beside the answer it names.
if self.store.confirm_unchanged(key, asked, result_id, || {
!self.disowned(key, generation)
}) {
self.notify.notify_one();
}
return;
}
PullOutcome::Clean { result_id } => {
self.note_answered();
if confirming {
confirming = false;
tokio::time::sleep(CONFIRM_DELAY).await;
continue;
}
// An empty answer about text that has since been replaced
// is the weakest evidence there is: old text, and nothing
// to report about it. Writing it down would erase errors
// that may well still be there — and worse, leave the
// re-pull with nothing to protect, so it would believe the
// first premature blank it was given. The re-ask is
// already queued; this answer has nothing to add to it.
if self.documents.version(key) != Some(asked) {
tracing::debug!(
server = %self.server_name, uri = %key, asked,
"clean answer is about text that has since been replaced; leaving what we hold"
);
return;
}
tracing::debug!(
server = %self.server_name, uri = %key,
"diagnostic pull returned nothing"
);
self.commit(key, Answer::new(Vec::new(), asked, result_id), generation);
return;
}
PullOutcome::Unsupported | PullOutcome::Failed => return,
}
}
}
/// One round trip. Updates the support flag from what the server does, but
/// writes no diagnostics — that is [`Self::resolve`]'s job.
async fn request(&self, uri: &Url, previous_result_id: Option<String>) -> PullOutcome {
let params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
// No identifier: servers that split diagnostics across several
// sources merge them into one report for us.
identifier: None,
previous_result_id,
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let mut socket = self.socket.clone();
let response = match tokio::time::timeout(
REQUEST_TIMEOUT,
socket.document_diagnostic(params),
)
.await
{
Ok(Ok(response)) => response,
Ok(Err(e)) => {
// A server that does not implement pull diagnostics should
// only ever be asked once.
if matches!(
&e,
async_lsp::Error::Response(r)
if r.code == async_lsp::ErrorCode::METHOD_NOT_FOUND
) {
self.support.write_off();
tracing::debug!(server = %self.server_name, "server has no pull diagnostics; not asking again");
return PullOutcome::Unsupported;
}
tracing::debug!(server = %self.server_name, error = %e, "diagnostic pull failed");
return PullOutcome::Failed;
}
Err(_) => {
// Slow is not the same as absent: a server still loading a
// solution is asked again on the next edit, and told us so
// itself when it finishes.
tracing::debug!(server = %self.server_name, uri = %uri, "diagnostic pull timed out");
return PullOutcome::Failed;
}
};
let DocumentDiagnosticReportResult::Report(report) = response else {
// Partial results only arrive via `$/progress`, which we do not
// request, so there is nothing to fold in.
return PullOutcome::Failed;
};
match report {
DocumentDiagnosticReport::Full(full) => {
let report = full.full_document_diagnostic_report;
if report.items.is_empty() {
PullOutcome::Clean {
result_id: report.result_id,
}
} else {
PullOutcome::Reported {
items: report.items,
result_id: report.result_id,
}
}
}
DocumentDiagnosticReport::Unchanged(unchanged) => PullOutcome::Unchanged {
result_id: unchanged.unchanged_document_diagnostic_report.result_id,
},
}
}
/// Record that the server has answered a pull, and report whether that was
/// the first time. Answering is what tells us it is a server we can ask.
fn note_answered(&self) -> bool {
!self.answered_a_pull.swap(true, Ordering::AcqRel)
}
/// Whether the server has disowned everything it knew when this pull was
/// sent. The re-ask is already queued — `refresh_all` goes through
/// `will_answer`, which marks a running document for one more round.
fn disowned(&self, key: &str, generation: u64) -> bool {
if self.store.server_publishes() {
// It revealed itself while we were waiting. Its own reports are the
// whole picture; ours may be a slice of it, and writing that down
// would replace the picture with the slice.
tracing::debug!(
server = %self.server_name, uri = %key,
"server publishes; discarding the answer to a pull we should not have sent"
);
return true;
}
if self.generation.load(Ordering::Acquire) == generation {
return false;
}
tracing::debug!(
server = %self.server_name, uri = %key,
"pull answered from before the server disowned its answers; asking again"
);
true
}
/// Write an answer down and wake the readers if it landed.
fn commit(&self, key: &str, answer: Answer, generation: u64) {
if self
.store
.install_if(key, answer, || !self.disowned(key, generation))
{
self.notify.notify_one();
} else {
// The re-pull for the newer text is already queued: the edit that
// overtook us went through `spawn`, which either marked this
// document superseded or started a fresh pull.
tracing::debug!(
server = %self.server_name, uri = %key,
"pull answered about text that has since been replaced; a newer answer stands"
);
}
}
/// Claim the pull slot for `key`. `false` means one is already running, and
/// has been told to run again when it finishes.
fn begin(&self, key: &str) -> bool {
let mut in_flight = self.in_flight.lock().unwrap_or_else(|e| e.into_inner());
if in_flight.running.contains(key) {
in_flight.superseded.insert(key.to_string());
return false;
}
in_flight.running.insert(key.to_string());
true
}
/// Release the pull slot. `true` means the document was edited again while
/// the pull ran, so it is worth one more round.
fn finish(&self, key: &str) -> bool {
let mut in_flight = self.in_flight.lock().unwrap_or_else(|e| e.into_inner());
if in_flight.superseded.remove(key) {
return true;
}
in_flight.running.remove(key);
false
}
}
impl std::fmt::Debug for PullDiagnostics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PullDiagnostics")
.field("server_name", &self.server_name)
.field("support", &self.support.get())
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_server_is_asked_until_it_says_no() {
let flag = SupportFlag::new();
assert_eq!(flag.get(), PullSupport::Asking);
flag.write_off();
assert_eq!(flag.get(), PullSupport::Rejected);
}
fn detached_pull() -> PullDiagnostics {
PullDiagnostics::new(
"test",
async_lsp::MainLoop::new_client(|_| async_lsp::router::Router::new(())).1,
DiagnosticsStore::new(),
Documents::new(),
Arc::new(tokio::sync::Notify::new()),
)
}
/// The case that made this counter necessary: a pull already in flight when
/// the refresh lands comes back with exactly the answer the server has just
/// disowned. It is about the current document version, so nothing about the
/// text can tell it apart — only the fact that the server changed its mind
/// while we were waiting.
#[tokio::test]
async fn an_answer_from_before_a_refresh_is_not_written_down() {
let pull = detached_pull();
pull.documents
.commit("file:///a.cs", 0, "csharp", Default::default());
let sent_before = pull.generation.load(Ordering::Acquire);
pull.refresh_all();
pull.commit(
"file:///a.cs",
Answer::new(vec![Diagnostic::default()], 0, None),
sent_before,
);
assert_eq!(
pull.store.covers("file:///a.cs"),
None,
"the answer the server disowned must not stand as its verdict"
);
// The re-ask that the refresh queued is heard.
let now = pull.generation.load(Ordering::Acquire);
pull.commit(
"file:///a.cs",
Answer::new(vec![Diagnostic::default()], 0, None),
now,
);
assert_eq!(pull.store.covers("file:///a.cs"), Some(0));
}
#[test]
fn a_document_with_a_pull_running_is_queued_rather_than_raced() {
let pull = detached_pull();
assert!(pull.begin("file:///a.cs"), "the first caller runs");
assert!(
!pull.begin("file:///a.cs"),
"the second is queued behind it"
);
assert!(pull.finish("file:///a.cs"), "and asked for one more round");
assert!(!pull.finish("file:///a.cs"), "which is the last");
assert!(pull.begin("file:///a.cs"), "the slot is free again");
}
}

View file

@ -0,0 +1,115 @@
//! When the server tells us its answers have changed.
//!
//! A pull-model server answers whatever it knows at the moment it is asked,
//! which after an edit — or during the first load of a large solution — may be
//! nothing yet. The protocol's answer to that is not for the client to guess
//! how long analysis takes, but for the server to say when it has finished:
//! `workspace/diagnostic/refresh` asks the client to re-pull everything, and
//! Roslyn sends it on re-analysis, project load and configuration changes.
//! Roslyn also has its own `workspace/projectInitializationComplete`, which is
//! what other clients use as the "the solution is really open now" signal.
//!
//! Both mean the same thing to us: ask again about everything we have open.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use async_lsp::lsp_types::notification::Notification;
use super::pull::PullDiagnostics;
/// Roslyn's notification that the solution has finished loading.
///
/// Not part of the specification, so it is spelled out here. Roslyn analyzes
/// nothing meaningful until this point, and its answers before it are worth
/// re-asking.
pub enum ProjectInitializationComplete {}
impl Notification for ProjectInitializationComplete {
/// Roslyn sends `null`, older builds send an empty array; neither carries
/// anything we need.
type Params = serde_json::Value;
const METHOD: &'static str = "workspace/projectInitializationComplete";
}
/// The pull handle, as seen by the router.
///
/// The router is built before the handshake, and the pull handle is only
/// complete after it, so the two meet here. A refresh that arrives before the
/// handshake finishes is dropped, which is right: there is nothing open to
/// re-pull yet, and the first pull of each document is about to happen anyway.
#[derive(Debug, Clone, Default)]
pub struct RefreshTarget {
pull: Arc<OnceLock<PullDiagnostics>>,
/// Set when the server has told us its answers are out of date, cleared by
/// the manager once it has re-opened the questions they answered.
invalidated: Arc<AtomicBool>,
}
impl RefreshTarget {
pub fn new() -> Self {
Self::default()
}
/// Hand the router the pull handle, once there is one.
pub fn publish(&self, pull: PullDiagnostics) {
if self.pull.set(pull).is_err() {
tracing::debug!("refresh target already published");
}
}
/// Throw away what the server has told us so far and ask again.
///
/// Forgetting is the point. The server has said its previous answers no
/// longer describe the code, and an answer that is known to be out of date
/// is worse than none: presented as current it is a lie, and left in place
/// it makes the re-pull look like it has already been answered.
pub fn refresh_all(&self, server_name: &str, reason: &str) {
let Some(pull) = self.pull.get() else {
tracing::debug!(server = %server_name, reason, "diagnostics refresh before the handshake finished; nothing open to re-pull");
return;
};
tracing::debug!(server = %server_name, reason, "re-pulling diagnostics for every open document");
if pull.refresh_all() {
self.invalidated.store(true, Ordering::Release);
}
}
/// Whether a refresh has happened since this was last asked.
///
/// Consuming: the caller owns re-opening the questions the refresh
/// invalidated, and dropping the answer drops the refresh with it.
///
/// The re-pull puts the answers back, but only someone waiting on a
/// document ever gets told about them — so the questions have to be asked
/// again too, and only the manager keeps those.
#[must_use]
pub fn take_invalidated(&self) -> bool {
self.invalidated.swap(false, Ordering::Acquire)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refresh_before_the_handshake_is_a_no_op() {
let target = RefreshTarget::new();
// No panic, no work: there is nothing open to re-pull yet, so there is
// nothing to re-ask about either.
target.refresh_all("test", "unit test");
assert!(!target.take_invalidated());
}
#[test]
fn the_invalidation_is_reported_once() {
let target = RefreshTarget::new();
target.invalidated.store(true, Ordering::Release);
assert!(target.take_invalidated());
assert!(
!target.take_invalidated(),
"a second reader must not re-open questions already re-opened"
);
}
}

View file

@ -32,11 +32,14 @@ async fn wait_for_crashed_lifecycle(
}
}
/// Replays tracked documents and returns their URIs.
fn replay_tracked_documents(
/// Replays tracked documents, returning each URI with the document version its
/// replay was sent as — what the manager needs to tell a verdict on the replay
/// from a leftover one. Documents the fresh server was never told about are
/// left out, so nothing waits on a verdict that was never asked for.
pub(super) fn replay_tracked_documents(
restarted_client: &mut LspClient,
tracked_docs: &[(String, String)],
) -> Vec<Url> {
) -> Vec<(Url, i32)> {
tracked_docs
.iter()
.filter_map(|(uri_str, lang_id)| {
@ -45,8 +48,9 @@ fn replay_tracked_documents(
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(uri_str));
let content = std::fs::read_to_string(&path).ok()?;
restarted_client.notify_file_change(&path, &content, lang_id);
file_uri(&path).ok()
let uri = file_uri(&path).ok()?;
let version = restarted_client.notify_file_change(&path, &content, lang_id)?;
Some((uri, version))
})
.collect()
}
@ -102,15 +106,15 @@ async fn install_restarted_client(
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
server_name: &str,
restarted_client: LspClient,
replayed_uris: Vec<Url>,
replayed_uris: Vec<(Url, i32)>,
) -> Result<(), LspClient> {
let mut mgr = lsp_manager.lock().await;
if mgr.shutting_down {
return Err(restarted_client);
}
let lifecycle_id = restarted_client.lifecycle_id;
for uri in replayed_uris {
mgr.mark_uri_pending_diagnostics(server_name, lifecycle_id, uri);
for (uri, version) in replayed_uris {
mgr.mark_uri_pending_diagnostics(server_name, lifecycle_id, uri, version);
}
mgr.clients
.insert(server_name.to_string(), restarted_client);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,989 @@
//! Mock language servers used by the LSP tests.
//!
//! Each is a small Python script speaking LSP over stdio, written to a temp dir
//! and spawned like a real server. They exist so the client can be tested
//! against the *shapes* real servers come in — full versus incremental sync,
//! push versus pull diagnostics, save with or without text — without needing
//! any of those servers installed.
use std::path::PathBuf;
const MOCK_LSP_SERVER: &str = r#"
import json, sys
def read_message():
headers = {}
while True:
line = sys.stdin.readline()
if not line:
return None
if line.strip() == '':
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
length = int(headers.get('Content-Length', 0))
if length == 0:
return None
body = sys.stdin.read(length)
return json.loads(body)
def send_message(msg):
body = json.dumps(msg)
header = f"Content-Length: {len(body)}\r\n\r\n"
sys.stdout.write(header)
sys.stdout.write(body)
sys.stdout.flush()
def send_diagnostics(uri):
send_message({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": uri,
"diagnostics": [
{
"range": {
"start": {"line": 0, "character": 5},
"end": {"line": 0, "character": 10}
},
"severity": 1,
"source": "mock",
"message": "mock error: undeclared variable"
},
{
"range": {
"start": {"line": 2, "character": 0},
"end": {"line": 2, "character": 15}
},
"severity": 2,
"source": "mock",
"message": "mock warning: unused import"
}
]
}
})
while True:
msg = read_message()
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
send_message({
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"capabilities": {
"textDocumentSync": 1,
"definitionProvider": True,
"referencesProvider": True
}
}
})
elif method == "initialized":
pass
elif method == "textDocument/didOpen":
uri = msg["params"]["textDocument"]["uri"]
send_diagnostics(uri)
elif method == "textDocument/didChange":
uri = msg["params"]["textDocument"]["uri"]
send_diagnostics(uri)
elif method == "textDocument/didSave":
pass
elif method == "textDocument/definition":
uri = msg["params"]["textDocument"]["uri"]
send_message({
"jsonrpc": "2.0",
"id": msg_id,
"result": [{
"uri": uri,
"range": {
"start": {"line": 10, "character": 0},
"end": {"line": 10, "character": 20}
}
}]
})
elif method == "textDocument/references":
uri = msg["params"]["textDocument"]["uri"]
send_message({
"jsonrpc": "2.0",
"id": msg_id,
"result": [
{
"uri": uri,
"range": {
"start": {"line": 5, "character": 0},
"end": {"line": 5, "character": 10}
}
},
{
"uri": uri,
"range": {
"start": {"line": 15, "character": 3},
"end": {"line": 15, "character": 13}
}
}
]
})
elif method == "shutdown":
send_message({"jsonrpc": "2.0", "id": msg_id, "result": None})
elif method == "exit":
break
elif msg_id is not None:
# Real servers answer requests they do not implement rather than
# leaving the client hanging.
send_message({"jsonrpc": "2.0", "id": msg_id,
"error": {"code": -32601, "message": "Method not found"}})
"#;
pub(super) fn write_mock_server() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("mock_lsp.py");
std::fs::write(&script_path, MOCK_LSP_SERVER).unwrap();
(dir, script_path)
}
pub(super) fn write_delayed_diagnostics_server() -> (tempfile::TempDir, PathBuf) {
const DELAYED_SERVER: &str = r#"
import json, sys, time
def read_message():
headers = {}
while True:
line = sys.stdin.readline()
if not line:
return None
if line.strip() == '':
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
length = int(headers.get('Content-Length', 0))
if length == 0:
return None
return json.loads(sys.stdin.read(length))
def send_message(msg):
body = json.dumps(msg)
sys.stdout.write(f"Content-Length: {len(body)}\r\n\r\n{body}")
sys.stdout.flush()
while True:
msg = read_message()
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
send_message({
"jsonrpc": "2.0",
"id": msg_id,
"result": {"capabilities": {"textDocumentSync": 1}}
})
elif method == "initialized":
pass
elif method == "textDocument/didOpen":
time.sleep(1.0)
send_message({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": msg["params"]["textDocument"]["uri"],
"diagnostics": [{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 5}
},
"severity": 1,
"source": "delayed",
"message": "delayed diagnostic after restart"
}]
}
})
elif method == "shutdown":
send_message({"jsonrpc": "2.0", "id": msg_id, "result": None})
elif method == "exit":
break
"#;
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("delayed_lsp.py");
std::fs::write(&script_path, DELAYED_SERVER).unwrap();
(dir, script_path)
}
pub(super) fn write_init_failure_server() -> (tempfile::TempDir, PathBuf) {
write_init_failure_server_n_times(3)
}
pub(super) fn write_slow_init_server(delay_ms: u64) -> (tempfile::TempDir, PathBuf) {
let script = format!(
r#"import json, sys, time
def read_message():
headers = {{}}
while True:
line = sys.stdin.readline()
if not line:
return None
if line.strip() == '':
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
length = int(headers.get('Content-Length', 0))
if length == 0:
return None
return json.loads(sys.stdin.read(length))
def send_message(msg):
body = json.dumps(msg)
sys.stdout.write(f"Content-Length: {{len(body)}}\r\n\r\n{{body}}")
sys.stdout.flush()
while True:
msg = read_message()
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
time.sleep({delay_ms} / 1000.0)
send_message({{
"jsonrpc": "2.0",
"id": msg_id,
"result": {{"capabilities": {{"textDocumentSync": 1, "definitionProvider": True}}}}
}})
elif method == "initialized":
pass
elif method == "textDocument/definition":
uri = msg["params"]["textDocument"]["uri"]
send_message({{
"jsonrpc": "2.0",
"id": msg_id,
"result": [{{
"uri": uri,
"range": {{
"start": {{"line": 1, "character": 0}},
"end": {{"line": 1, "character": 5}}
}}
}}]
}})
elif method == "shutdown":
send_message({{"jsonrpc": "2.0", "id": msg_id, "result": None}})
elif method == "exit":
break
"#
);
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("slow_init_lsp.py");
std::fs::write(&script_path, script).unwrap();
(dir, script_path)
}
pub(super) fn write_init_failure_server_n_times(
failures_before_success: usize,
) -> (tempfile::TempDir, PathBuf) {
let init_error_payload = format!(
"{{\"code\": -32603, \"message\": \"init failed on purpose after {} failures\"}}",
failures_before_success
);
let init_error_payload = init_error_payload.replace('"', r#"\""#);
let script = format!(
r#"import json, os, sys
FAILURES_BEFORE_SUCCESS = {failures_before_success}
COUNTER_FILE = os.environ["INIT_FAILURE_COUNTER_FILE"]
INIT_ERROR = json.loads("{init_error_payload}")
def read_message():
headers = {{}}
while True:
line = sys.stdin.readline()
if not line:
return None
if line.strip() == '':
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
length = int(headers.get('Content-Length', 0))
if length == 0:
return None
return json.loads(sys.stdin.read(length))
def send_message(msg):
body = json.dumps(msg)
sys.stdout.write(f"Content-Length: {{len(body)}}\r\n\r\n{{body}}")
sys.stdout.flush()
def increment_attempts():
attempts = 0
if os.path.exists(COUNTER_FILE):
with open(COUNTER_FILE, "r", encoding="utf-8") as f:
content = f.read().strip()
if content:
attempts = int(content)
attempts += 1
with open(COUNTER_FILE, "w", encoding="utf-8") as f:
f.write(str(attempts))
return attempts
while True:
msg = read_message()
if msg is None:
break
method = msg.get("method")
msg_id = msg.get("id")
if method == "initialize":
attempts = increment_attempts()
if attempts <= FAILURES_BEFORE_SUCCESS:
send_message({{"jsonrpc": "2.0", "id": msg_id, "error": INIT_ERROR}})
break
send_message({{
"jsonrpc": "2.0",
"id": msg_id,
"result": {{"capabilities": {{"textDocumentSync": 1}}}}
}})
elif method == "initialized":
pass
elif method == "shutdown":
send_message({{"jsonrpc": "2.0", "id": msg_id, "result": None}})
elif method == "exit":
break
"#
);
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("init_fail_lsp.py");
std::fs::write(&script_path, script).unwrap();
(dir, script_path)
}
// ── Roslyn-shaped mock servers ──────────────────────────────────────────
//
// These differ only in how they answer `initialize` and what they do with the
// notifications that follow, so they share one framing preamble rather than
// each carrying its own copy of the JSON-RPC plumbing.
/// `read_message` / `send_message` / `publish` — the same for every mock.
const MOCK_PREAMBLE: &str = r#"
import json, sys
state = {"saves": 0, "pulls": 0}
def read_message():
headers = {}
while True:
line = sys.stdin.readline()
if not line:
return None
if line.strip() == '':
break
if ':' in line:
key, value = line.split(':', 1)
headers[key.strip()] = value.strip()
length = int(headers.get('Content-Length', 0))
if length == 0:
return None
return json.loads(sys.stdin.read(length))
def send_message(msg):
body = json.dumps(msg)
sys.stdout.write(f"Content-Length: {len(body)}\r\n\r\n")
sys.stdout.write(body)
sys.stdout.flush()
def one_diagnostic(message):
return [{
"range": {"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 1}},
"severity": 1,
"source": "mock",
"message": message
}]
def publish(uri, message):
send_message({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": one_diagnostic(message)}
})
def reply(msg, result):
send_message({"jsonrpc": "2.0", "id": msg.get("id"), "result": result})
def publish_at(uri, message, version):
send_message({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": one_diagnostic(message), "version": version}
})
def notify(method, params=None):
send_message({"jsonrpc": "2.0", "method": method, "params": params})
def ask(method, params, request_id):
send_message({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
def serve(capabilities, handle):
while True:
msg = read_message()
if msg is None:
return
method = msg.get("method")
if method == "initialize":
reply(msg, {"capabilities": capabilities})
elif method == "shutdown":
reply(msg, None)
elif method == "exit":
return
else:
handle(msg, method)
"#;
/// Write a mock server whose behaviour is `body`, on top of [`MOCK_PREAMBLE`].
pub(super) fn write_python_server(file_name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join(file_name);
std::fs::write(&script_path, format!("{MOCK_PREAMBLE}\n{body}")).unwrap();
(dir, script_path)
}
/// A server that declares **incremental** sync (`textDocumentSync: 2`), like
/// Roslyn does. It reports back, as the diagnostic message, whether the
/// `didChange` it received carried a `range`. Roslyn dereferences that range
/// unconditionally and tears its request queue down when it is missing, so a
/// rangeless change against such a server is a client bug.
pub(super) fn write_incremental_sync_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"incremental_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/didOpen":
publish(msg["params"]["textDocument"]["uri"], "opened")
elif method == "textDocument/didChange":
change = msg["params"]["contentChanges"][0]
uri = msg["params"]["textDocument"]["uri"]
if change.get("range") is None:
publish(uri, "changed without range")
else:
r = change["range"]
publish(uri, "changed with range %d:%d-%d:%d" % (
r["start"]["line"], r["start"]["character"],
r["end"]["line"], r["end"]["character"]))
serve({"textDocumentSync": 2}, handle)
"#,
)
}
/// A Roslyn-shaped server: incremental sync, **no** save support, and
/// diagnostics served by pull only — it never publishes. Its diagnostic message
/// reports what the client actually did, so tests can assert on client
/// behaviour rather than on internal state.
pub(super) fn write_pull_diagnostics_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"pull_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/didSave":
state["saves"] += 1
elif method == "textDocument/diagnostic":
state["pulls"] += 1
previous = msg["params"].get("previousResultId")
reply(msg, {
"kind": "full",
"resultId": "result-%d" % state["pulls"],
"items": one_diagnostic("pull #%d saves=%d prev=%s" % (
state["pulls"], state["saves"], previous))
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A pull server that answers honestly: a document is clean unless its name
/// says "broken". Used to check that "no problems" counts as an answer rather
/// than as silence, and that a real problem after a run of clean files is still
/// reported promptly.
pub(super) fn write_selective_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"selective_pull_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
uri = msg["params"]["textDocument"]["uri"]
items = one_diagnostic("pulled problem") if "broken" in uri else []
reply(msg, {"kind": "full", "resultId": "r-%d" % state["pulls"], "items": items})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A pull server that answers the second pull with an empty report before
/// going back to reporting the problem — the shape Roslyn has when it is asked
/// again before it has finished re-analyzing an edit.
pub(super) fn write_mid_analysis_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"mid_analysis_pull_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
items = [] if state["pulls"] == 2 else one_diagnostic("real problem %d" % state["pulls"])
reply(msg, {"kind": "full", "resultId": "r-%d" % state["pulls"], "items": items})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A pull server that takes its time, and every answer names the revision it
/// was asked about — so an answer to superseded text is recognisable on sight.
///
/// When the first pull arrives it touches [`FIRST_PULL_MARKER`] beside the
/// document, which is the moment a test has to edit the file again if it wants
/// an answer to land for a revision the server has since been sent a
/// replacement for. The signal deliberately goes through the filesystem rather
/// than a `publishDiagnostics`: a push is itself an answer, and would be the
/// newest one, which is exactly the thing under test.
pub(super) fn write_slow_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"slow_pull_lsp.py",
r#"
import os, time
state["revisions"] = 0
def handle(msg, method):
if method in ("textDocument/didOpen", "textDocument/didChange"):
state["revisions"] += 1
elif method == "textDocument/diagnostic":
state["pulls"] += 1
asked_about = state["revisions"]
if state["pulls"] == 1:
path = msg["params"]["textDocument"]["uri"][len("file://"):]
open(os.path.join(os.path.dirname(path), "first-pull-started"), "w").close()
time.sleep(0.3)
reply(msg, {
"kind": "full",
"resultId": "r-%d" % state["pulls"],
"items": one_diagnostic("pull %d answers revision %d" % (
state["pulls"], asked_about))
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// The file [`write_slow_pull_server`] touches once its first pull is in flight.
pub(super) const FIRST_PULL_MARKER: &str = "first-pull-started";
/// The file [`write_stale_clean_pull_server`] touches once its second pull is
/// in flight.
pub(super) const SECOND_PULL_MARKER: &str = "second-pull-started";
/// A pull server whose "the file is clean now" answer arrives late, and which
/// then stands by it when asked again with its own result id.
///
/// The first pull reports a problem. The second answers clean, slowly enough
/// that a test can edit the file again first. From the third on, a client that
/// sends back the clean report's id is told "unchanged" — so a client that
/// remembers an id for an answer it never stored will have the server confirm
/// errors the server does not have.
pub(super) fn write_stale_clean_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"stale_clean_pull_lsp.py",
r#"
import os, time
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
previous = msg["params"].get("previousResultId")
if state["pulls"] == 1:
reply(msg, {"kind": "full", "resultId": "r1",
"items": one_diagnostic("the problem")})
elif state["pulls"] == 2:
path = msg["params"]["textDocument"]["uri"][len("file://"):]
open(os.path.join(os.path.dirname(path), "second-pull-started"), "w").close()
time.sleep(0.3)
reply(msg, {"kind": "full", "resultId": "clean", "items": []})
elif previous == "clean":
reply(msg, {"kind": "unchanged", "resultId": "clean"})
else:
reply(msg, {"kind": "full", "resultId": "clean", "items": []})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A pull server that answers for some documents and simply never replies for
/// others — the shape of a server that is working, and productive, but has
/// nothing to say about one particular file, ever. Documents whose name
/// contains "loud" get an error; the rest get silence.
pub(super) fn write_partially_answering_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"partial_pull_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/diagnostic":
uri = msg["params"]["textDocument"]["uri"]
if "loud" not in uri:
return
state["pulls"] += 1
reply(msg, {
"kind": "full",
"resultId": "r-%d" % state["pulls"],
"items": one_diagnostic("loud problem")
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A server that accepts everything and never reports a diagnostic.
pub(super) fn write_silent_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"silent_lsp.py",
r#"
def handle(msg, method):
pass
serve({"textDocumentSync": 1}, handle)
"#,
)
}
/// A server that asks for `didSave` **with** the document text, and reports
/// back whether it actually got it.
pub(super) fn write_save_with_text_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"save_text_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/didSave":
has_text = msg["params"].get("text") is not None
publish(msg["params"]["textDocument"]["uri"], "saved with text=%s" % has_text)
serve({
"textDocumentSync": {"openClose": True, "change": 1, "save": {"includeText": True}}
}, handle)
"#,
)
}
/// A pull server in the shape Roslyn has at session start: it answers before it
/// has loaded the solution, so its first answer is empty, and it says so
/// afterwards with `workspace/projectInitializationComplete`.
pub(super) fn write_loads_late_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"loads_late_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
if state["pulls"] == 1:
# Still loading. Nothing to report yet.
reply(msg, {"kind": "full", "resultId": "r-1", "items": []})
notify("workspace/projectInitializationComplete", None)
else:
reply(msg, {
"kind": "full",
"resultId": "r-%d" % state["pulls"],
"items": one_diagnostic("found once the solution was loaded")
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// The same, but announced the way the specification provides for: a
/// `workspace/diagnostic/refresh` request, which the client has to answer.
/// Whether the client answered is reported as the diagnostic message, so a
/// client that advertises `refreshSupport` and then ignores the request fails
/// the test rather than merely logging.
pub(super) fn write_diagnostic_refresh_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"diagnostic_refresh_lsp.py",
r#"
state["answered_refresh"] = False
def handle(msg, method):
if method is None and msg.get("id") == 9001:
state["answered_refresh"] = True
elif method == "textDocument/diagnostic":
state["pulls"] += 1
if state["pulls"] == 1:
reply(msg, {"kind": "full", "resultId": "r-1", "items": []})
ask("workspace/diagnostic/refresh", None, 9001)
else:
reply(msg, {
"kind": "full",
"resultId": "r-%d" % state["pulls"],
"items": one_diagnostic(
"refresh answered=%s" % state["answered_refresh"])
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A push server that names the revision it analyzed, and runs one behind: the
/// report for an edit describes the text before it, and the real verdict
/// follows. Servers that fill in `version` let us tell those apart exactly
/// instead of crediting whatever arrives.
pub(super) fn write_versioned_push_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"versioned_push_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/didOpen":
uri = msg["params"]["textDocument"]["uri"]
version = msg["params"]["textDocument"]["version"]
publish_at(uri, "verdict on version %d" % version, version)
elif method == "textDocument/didChange":
uri = msg["params"]["textDocument"]["uri"]
version = msg["params"]["textDocument"]["version"]
# One revision behind: this describes the text before the edit.
publish_at(uri, "stale verdict on version %d" % (version - 1), version - 1)
serve({"textDocumentSync": {"openClose": True, "change": 1}}, handle)
"#,
)
}
/// rust-analyzer's shape: it publishes, *and* it answers
/// `textDocument/diagnostic` — but deliberately with a different, smaller set.
/// Its `cargo check` results only ever arrive by push, so a client that takes
/// the pull answer as the whole picture loses every one of them.
pub(super) fn write_push_and_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"push_and_pull_lsp.py",
r#"
def handle(msg, method):
if method in ("textDocument/didOpen", "textDocument/didChange"):
uri = msg["params"]["textDocument"]["uri"]
version = msg["params"]["textDocument"]["version"]
publish_at(uri, "the check that only the push channel runs, pulls=%d" % state["pulls"], version)
elif method == "textDocument/diagnostic":
state["pulls"] += 1
# Answers, and has nothing of its own to say about this file.
reply(msg, {"kind": "full", "resultId": "r-%d" % state["pulls"], "items": []})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// A server that publishes for a file before it has ever been told about it —
/// the shape of a workspace-wide or `cargo check` report arriving for a file
/// the client has not opened.
pub(super) fn write_publishes_before_open_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"publishes_before_open_lsp.py",
r#"
import os
def handle(msg, method):
if method == "initialized":
# Report on a file the client has not opened, the way a workspace-wide
# or check-on-save pass does.
publish(os.environ["PREOPENED_URI"], "reported before the file was opened")
serve({"textDocumentSync": {"openClose": True, "change": 1}}, handle)
"#,
)
}
/// A push-only server that asks for a diagnostics refresh anyway. There is
/// nothing to re-pull from it, so the right response is to leave what it has
/// already told us alone rather than throw it away.
pub(super) fn write_refresh_without_pull_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"refresh_without_pull_lsp.py",
r#"
def handle(msg, method):
if method in ("textDocument/didOpen", "textDocument/didChange"):
uri = msg["params"]["textDocument"]["uri"]
publish(uri, "a real problem")
ask("workspace/diagnostic/refresh", None, 9002)
serve({"textDocumentSync": {"openClose": True, "change": 1}}, handle)
"#,
)
}
/// A server that says nothing of its own accord and does not implement pull
/// diagnostics either. It is asked once, says so, and must not be asked again.
pub(super) fn write_pull_rejecting_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"pull_rejecting_lsp.py",
r#"
import os
# The count goes to a file, not a diagnostic: a server that publishes is not
# one we pull from, so publishing here would remove the thing being counted.
counted = os.path.join(os.path.dirname(sys.argv[0]), "pulls.txt")
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
with open(counted, "w") as f:
f.write(str(state["pulls"]))
send_message({
"jsonrpc": "2.0",
"id": msg.get("id"),
"error": {"code": -32601, "message": "method not found"}
})
serve({"textDocumentSync": {"openClose": True, "change": 1}}, handle)
"#,
)
}
/// Reports a real problem once, then answers "clean" twice, then stops
/// answering at all. Enough rope to hang a client that lets a clean answer
/// about replaced text erase what it holds: the two clean answers belong to a
/// revision that has been superseded by the time the second arrives, and the
/// silence afterwards means nothing can quietly put the error back.
pub(super) fn write_clean_then_silent_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"clean_then_silent_lsp.py",
r#"
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
if state["pulls"] == 1:
items = one_diagnostic("the real problem")
elif state["pulls"] <= 3:
items = []
else:
return # no reply at all
reply(msg, {"kind": "full", "resultId": "r-%d" % state["pulls"], "items": items})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// Roslyn's worst-case shape: asked for diagnostics before it has loaded the
/// solution, it does not answer at all. Some time later it announces it is
/// ready, and only then does it start answering — and even then not instantly.
///
/// By the time it speaks, a client that judges silence by the clock has already
/// stopped waiting for it, which is exactly when it must start again.
pub(super) fn write_loads_after_going_quiet_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"loads_after_quiet_lsp.py",
r#"
import time
def handle(msg, method):
if method == "textDocument/diagnostic":
state["pulls"] += 1
if state["pulls"] == 1:
# Still loading, and it will not answer questions about code it has
# not read. Not MethodNotFound it implements this, it just cannot
# answer yet.
send_message({
"jsonrpc": "2.0",
"id": msg.get("id"),
"error": {"code": -32603, "message": "still loading"}
})
# Some time later long enough that a client watching the clock
# has given up on it the solution is open.
time.sleep(0.1)
notify("workspace/projectInitializationComplete", None)
return
time.sleep(0.25)
reply(msg, {
"kind": "full",
"resultId": "r-%d" % state["pulls"],
"items": one_diagnostic("found once the solution was loaded")
})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}
/// rust-analyzer at its most dangerous: it answers a pull promptly and has
/// nothing of its own to say, while the errors that matter — the ones only
/// `cargo check` finds — arrive on the push channel a moment later.
///
/// A client that takes the pull answer as the verdict settles the file as
/// clean, and by the time the real errors land nobody is waiting for them.
pub(super) fn write_slow_check_server() -> (tempfile::TempDir, PathBuf) {
write_python_server(
"slow_check_lsp.py",
r#"
import threading
def publish_later(uri, version):
def run():
import time
time.sleep(0.3)
publish_at(uri, "an error only the check finds", version)
threading.Thread(target=run, daemon=True).start()
def handle(msg, method):
if method in ("textDocument/didOpen", "textDocument/didChange"):
publish_later(msg["params"]["textDocument"]["uri"],
msg["params"]["textDocument"]["version"])
elif method == "textDocument/diagnostic":
state["pulls"] += 1
# Answers at once, with only what its own analysis knows: nothing.
reply(msg, {"kind": "full", "resultId": "r-%d" % state["pulls"], "items": []})
serve({
"textDocumentSync": {"openClose": True, "change": 2},
"diagnosticProvider": {"interFileDependencies": True, "workspaceDiagnostics": False}
}, handle)
"#,
)
}

View file

@ -0,0 +1,137 @@
//! Telling a server which solution or projects to load.
//!
//! Most servers work out what to analyze from `rootUri`. A few load their
//! workspace through a protocol extension instead — Roslyn is the notable one:
//! left alone it treats every file as a loose "miscellaneous file" and reports
//! no project-level diagnostics at all. These notifications are vendor
//! extensions, not LSP, which is why they are modelled here rather than coming
//! from `lsp_types`.
use std::path::Path;
use async_lsp::lsp_types;
use super::config::LspServerConfig;
use super::file_uri;
/// `solution/open`, a Roslyn protocol extension.
enum SolutionOpen {}
impl lsp_types::notification::Notification for SolutionOpen {
type Params = serde_json::Value;
const METHOD: &'static str = "solution/open";
}
/// `project/open`, the multi-project counterpart to [`SolutionOpen`].
enum ProjectOpen {}
impl lsp_types::notification::Notification for ProjectOpen {
type Params = serde_json::Value;
const METHOD: &'static str = "project/open";
}
/// Tell the server which solution or projects to load. No-op unless configured.
pub fn send(
server_name: &str,
config: &LspServerConfig,
workspace_root: &Path,
server: &mut async_lsp::ServerSocket,
) {
let Some(open) = config.workspace_open.as_ref() else {
return;
};
if let Some(solution) = open.solution.as_deref()
&& let Some(uri) = resolve(server_name, workspace_root, solution)
{
tracing::info!(server = %server_name, %uri, "solution/open");
if let Err(e) = server.notify::<SolutionOpen>(serde_json::json!({ "solution": uri })) {
tracing::warn!(server = %server_name, error = %e, "failed to send solution/open");
}
}
let projects: Vec<String> = open
.projects
.iter()
.filter_map(|project| resolve(server_name, workspace_root, project))
.collect();
if !projects.is_empty() {
tracing::info!(server = %server_name, count = projects.len(), "project/open");
if let Err(e) = server.notify::<ProjectOpen>(serde_json::json!({ "projects": projects })) {
tracing::warn!(server = %server_name, error = %e, "failed to send project/open");
}
}
}
/// Resolve a configured path against the workspace root and turn it into a URI.
///
/// A path that does not exist is still sent — the server may create or find it
/// — but it is by far the most likely reason for "I configured this and got no
/// diagnostics", so it is worth saying out loud.
fn resolve(server_name: &str, workspace_root: &Path, raw: &str) -> Option<String> {
let path = Path::new(raw);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
};
match file_uri(&absolute) {
Ok(uri) => {
if !absolute.exists() {
tracing::warn!(
server = %server_name,
path = %absolute.display(),
"workspaceOpen path does not exist; the server will have nothing to load"
);
}
Some(uri.to_string())
}
Err(_) => {
tracing::warn!(
server = %server_name,
path = %absolute.display(),
"workspaceOpen path is not a valid file URI"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_paths_resolve_against_the_workspace_root() {
let root = tempfile::tempdir().unwrap();
let solution = root.path().join("MyApp.sln");
std::fs::write(&solution, "").unwrap();
let uri = resolve("test", root.path(), "MyApp.sln").expect("should resolve");
assert!(uri.starts_with("file://"), "{uri}");
assert!(uri.ends_with("MyApp.sln"), "{uri}");
}
#[test]
fn absolute_paths_are_left_alone() {
let root = tempfile::tempdir().unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let solution = elsewhere.path().join("Other.sln");
std::fs::write(&solution, "").unwrap();
let uri =
resolve("test", root.path(), &solution.to_string_lossy()).expect("should resolve");
assert!(uri.ends_with("Other.sln"), "{uri}");
assert!(
!uri.contains(root.path().to_string_lossy().as_ref()),
"an absolute path must not be joined to the root: {uri}"
);
}
#[test]
fn a_missing_path_still_resolves() {
let root = tempfile::tempdir().unwrap();
// Warns, but the server is still told — we are not the authority on
// what it can load.
assert!(resolve("test", root.path(), "Nope.sln").is_some());
}
}

View file

@ -184,6 +184,7 @@ impl xai_tool_runtime::Tool for GlobTool {
crate::util::detach_command(&mut cmd);
cmd.stdin(Stdio::null());
#[allow(clippy::disallowed_methods)] // search helper, waited on below
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {

View file

@ -185,6 +185,7 @@ impl xai_tool_runtime::Tool for GrepTool {
cmd.stdin(Stdio::null());
// Spawn.
#[allow(clippy::disallowed_methods)] // search helper, waited on below
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {

View file

@ -481,6 +481,18 @@ pub fn extract_skill_body(content: &str) -> String {
/// `load_skill_content` in `grok_build/skill/mod.rs` is a duplicate
/// of this.
pub async fn load_skill_content(skill: &SkillInfo) -> Result<String, String> {
// Producers strip frontmatter before setting `body`. Re-strip would drop a
// leading Markdown HR (`---`) and skip link resolution for disk skills.
if let Some(body) = skill.body.as_ref().filter(|b| !b.is_empty()) {
return Ok(body.clone());
}
// Synthetic product paths are never on disk; empty body is authoritative.
if skill.path.contains("://") {
return Err(format!(
"Skill '{}' has no preloaded body (path '{}')",
skill.name, skill.path
));
}
let path = std::path::Path::new(&skill.path);
match tokio::fs::read_to_string(path).await {
Ok(content) => {
@ -544,6 +556,75 @@ It has multiple lines."#;
assert_eq!(body, content);
}
#[tokio::test]
async fn load_skill_content_trusts_preloaded_body_with_leading_hr() {
let skill = SkillInfo {
name: "hr-body".to_string(),
display_name: None,
description: "test".to_string(),
short_description: None,
author: None,
argument_hint: None,
path: "chat-product://hr-body".to_string(),
scope: SkillScope::User,
config_source: None,
plugin_name: None,
plugin_version: None,
plugin_root: None,
plugin_data: None,
allowed_tools: None,
license: None,
compatibility: None,
metadata: None,
model: None,
effort: None,
user_invocable: true,
disable_model_invocation: false,
when_to_use: None,
has_user_specified_description: true,
paths: None,
enabled: true,
body: Some("---\n\nParagraph after a markdown HR.".to_string()),
};
let loaded = load_skill_content(&skill).await.unwrap();
assert_eq!(loaded, "---\n\nParagraph after a markdown HR.");
}
#[tokio::test]
async fn load_skill_content_rejects_synthetic_path_without_body() {
let skill = SkillInfo {
name: "pdf".to_string(),
display_name: None,
description: "test".to_string(),
short_description: None,
author: None,
argument_hint: None,
path: "chat-product://pdf".to_string(),
scope: SkillScope::Server,
config_source: None,
plugin_name: None,
plugin_version: None,
plugin_root: None,
plugin_data: None,
allowed_tools: None,
license: None,
compatibility: None,
metadata: None,
model: None,
effort: None,
user_invocable: true,
disable_model_invocation: false,
when_to_use: None,
has_user_specified_description: true,
paths: None,
enabled: true,
body: None,
};
let err = load_skill_content(&skill).await.unwrap_err();
assert!(err.contains("no preloaded body"), "{err}");
assert!(err.contains("chat-product://pdf"), "{err}");
}
#[test]
fn test_format_skill_name() {
let skill = SkillInfo {

View file

@ -64,15 +64,16 @@ impl WebSearchClient {
headers.insert(header_name, header_value);
}
let _ = alpha_test_key;
let http = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::execution(
xai_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build HTTP client: {e}"),
)
})?;
let http = xai_grok_extra_ca::with_extra_root_certificates(
reqwest::Client::builder().default_headers(headers),
)
.build()
.map_err(|e| {
xai_tool_runtime::ToolError::execution(
xai_tool_protocol::ToolId::new("web_search").expect("valid"),
format!("Failed to build HTTP client: {e}"),
)
})?;
Ok(Self {
http,
base_url: base_url.clone(),
@ -390,11 +391,11 @@ mod tests {
let client = WebSearchClient::new(&config, None)
.expect("client should build")
.with_attribution_callback(Some(cb_dyn));
client.record_401_attribution(Some("bearer-with-long-tail-aaaaaaaaaa"));
client.record_401_attribution(Some("bearer-with-long-tail-aaaadistinct"));
let calls = cb.invocations.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, ToolConsumer::WebSearch);
assert_eq!(calls[0].1.as_deref(), Some("bearer-with-"));
assert_eq!(calls[0].1.as_deref(), Some("aaaadistinct"));
assert_eq!(
calls[0].1.as_deref().map(str::len),
Some(crate::attribution::SENT_BEARER_PREFIX_LEN),

View file

@ -37,7 +37,7 @@ impl Reminder for LspDiagnosticsReminder {
// Drain any pending diagnostics (from this or previous edits).
if let Some(summary) = lsp
.drain_diagnostics(std::time::Duration::from_millis(500))
.drain_diagnostics(crate::implementations::lsp::DIAGNOSTICS_DRAIN_TIMEOUT)
.await
{
return vec![summary.text];