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:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>", "</lsp-diagnostics>")
|
||||
.replace("</system-reminder>", "</system-reminder>");
|
||||
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>", "</lsp-diagnostics>")
|
||||
.replace("</system-reminder>", "</system-reminder>");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<()>>;
|
||||
|
||||
|
|
|
|||
484
crates/codegen/xai-grok-tools/src/implementations/lsp/pending.rs
Normal file
484
crates/codegen/xai-grok-tools/src/implementations/lsp/pending.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
591
crates/codegen/xai-grok-tools/src/implementations/lsp/pull.rs
Normal file
591
crates/codegen/xai-grok-tools/src/implementations/lsp/pull.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
115
crates/codegen/xai-grok-tools/src/implementations/lsp/refresh.rs
Normal file
115
crates/codegen/xai-grok-tools/src/implementations/lsp/refresh.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue