Skip to repository content1525 lines · 63.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:07:21.798Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
lsp_button.rs
1use std::{
2 cell::RefCell,
3 collections::{BTreeMap, HashMap},
4 path::{Path, PathBuf},
5 rc::Rc,
6 time::{Duration, Instant},
7};
8
9use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
10
11use language::language_settings::{EditPredictionProvider, all_language_settings};
12
13use client::proto;
14use collections::HashSet;
15use editor::{Editor, EditorEvent};
16use gpui::{Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions};
17use language::{BinaryStatus, BufferId, ServerHealth};
18use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector};
19use project::{
20 LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore,
21 project_settings::ProjectSettings,
22};
23use settings::{Settings as _, SettingsStore};
24use ui::{
25 ContextMenu, ContextMenuEntry, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
26};
27
28use util::{ResultExt, paths::PathExt, rel_path::RelPath};
29use workspace::{StatusItemView, Workspace};
30
31use crate::lsp_log_view;
32
33actions!(
34 lsp_tool,
35 [
36 /// Toggles the language server tool menu.
37 ToggleMenu
38 ]
39);
40
41pub struct LspButton {
42 server_state: Entity<LanguageServerState>,
43 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
44 lsp_menu: Option<Entity<ContextMenu>>,
45 lsp_menu_refresh: Task<()>,
46 _subscriptions: Vec<Subscription>,
47}
48
49struct LanguageServerState {
50 items: Vec<LspMenuItem>,
51 workspace: WeakEntity<Workspace>,
52 lsp_store: WeakEntity<LspStore>,
53 active_editor: Option<ActiveEditor>,
54 language_servers: LanguageServers,
55 process_memory_cache: Rc<RefCell<ProcessMemoryCache>>,
56}
57
58impl std::fmt::Debug for LanguageServerState {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("LanguageServerState")
61 .field("items", &self.items)
62 .field("workspace", &self.workspace)
63 .field("lsp_store", &self.lsp_store)
64 .field("active_editor", &self.active_editor)
65 .field("language_servers", &self.language_servers)
66 .finish_non_exhaustive()
67 }
68}
69
70const PROCESS_MEMORY_CACHE_DURATION: Duration = Duration::from_secs(5);
71
72struct ProcessMemoryCache {
73 system: System,
74 memory_usage: HashMap<u32, u64>,
75 last_refresh: Option<Instant>,
76}
77
78impl ProcessMemoryCache {
79 fn new() -> Self {
80 Self {
81 system: System::new(),
82 memory_usage: HashMap::new(),
83 last_refresh: None,
84 }
85 }
86
87 fn get_memory_usage(&mut self, process_id: u32) -> u64 {
88 let cache_expired = self
89 .last_refresh
90 .map(|last| last.elapsed() >= PROCESS_MEMORY_CACHE_DURATION)
91 .unwrap_or(true);
92
93 if cache_expired {
94 let refresh_kind = RefreshKind::nothing()
95 .with_processes(ProcessRefreshKind::nothing().without_tasks().with_memory());
96 self.system.refresh_specifics(refresh_kind);
97 self.memory_usage.clear();
98 self.last_refresh = Some(Instant::now());
99 }
100
101 if let Some(&memory) = self.memory_usage.get(&process_id) {
102 return memory;
103 }
104
105 let root_pid = Pid::from_u32(process_id);
106
107 let parent_map: HashMap<Pid, Pid> = self
108 .system
109 .processes()
110 .iter()
111 .filter_map(|(&pid, process)| Some((pid, process.parent()?)))
112 .collect();
113
114 let total_memory = self
115 .system
116 .processes()
117 .iter()
118 .filter(|(pid, _)| self.is_descendant_of(**pid, root_pid, &parent_map))
119 .map(|(_, process)| process.memory())
120 .sum();
121
122 self.memory_usage.insert(process_id, total_memory);
123 total_memory
124 }
125
126 fn is_descendant_of(&self, pid: Pid, root_pid: Pid, parent_map: &HashMap<Pid, Pid>) -> bool {
127 let mut current = pid;
128 let mut visited = HashSet::default();
129 while current != root_pid {
130 if !visited.insert(current) {
131 return false;
132 }
133 match parent_map.get(¤t) {
134 Some(&parent) => current = parent,
135 None => return false,
136 }
137 }
138 true
139 }
140}
141
142struct ActiveEditor {
143 editor: WeakEntity<Editor>,
144 _editor_subscription: Subscription,
145 editor_buffers: HashSet<BufferId>,
146}
147
148impl std::fmt::Debug for ActiveEditor {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("ActiveEditor")
151 .field("editor", &self.editor)
152 .field("editor_buffers", &self.editor_buffers)
153 .finish_non_exhaustive()
154 }
155}
156
157#[derive(Debug, Default, Clone)]
158struct LanguageServers {
159 health_statuses: HashMap<LanguageServerId, LanguageServerHealthStatus>,
160 binary_statuses: HashMap<LanguageServerName, LanguageServerBinaryStatus>,
161 servers_per_buffer_abs_path: HashMap<PathBuf, ServersForPath>,
162}
163
164#[derive(Debug, Clone)]
165struct ServersForPath {
166 servers: HashMap<LanguageServerId, Option<LanguageServerName>>,
167 worktree: Option<WeakEntity<Worktree>>,
168}
169
170#[derive(Debug, Clone)]
171struct LanguageServerHealthStatus {
172 name: LanguageServerName,
173 health: Option<(Option<SharedString>, ServerHealth)>,
174}
175
176#[derive(Debug, Clone)]
177struct LanguageServerBinaryStatus {
178 status: BinaryStatus,
179 message: Option<SharedString>,
180}
181
182#[derive(Debug, Clone)]
183struct ServerInfo {
184 name: LanguageServerName,
185 id: LanguageServerId,
186 health: Option<ServerHealth>,
187 binary_status: Option<LanguageServerBinaryStatus>,
188 message: Option<SharedString>,
189}
190
191impl ServerInfo {
192 fn server_selector(&self) -> LanguageServerSelector {
193 LanguageServerSelector::Id(self.id)
194 }
195
196 fn can_stop(&self) -> bool {
197 self.binary_status.as_ref().is_none_or(|status| {
198 matches!(status.status, BinaryStatus::None | BinaryStatus::Starting)
199 })
200 }
201}
202
203impl LanguageServerHealthStatus {
204 fn health(&self) -> Option<ServerHealth> {
205 self.health.as_ref().map(|(_, health)| *health)
206 }
207
208 fn message(&self) -> Option<SharedString> {
209 self.health
210 .as_ref()
211 .and_then(|(message, _)| message.clone())
212 }
213}
214
215impl LanguageServerState {
216 fn fill_menu(&self, mut menu: ContextMenu, cx: &mut Context<Self>) -> ContextMenu {
217 let lsp_logs = cx
218 .try_global::<GlobalLogStore>()
219 .map(|lsp_logs| lsp_logs.0.clone());
220 let Some(lsp_logs) = lsp_logs else {
221 return menu;
222 };
223
224 let server_metadata = self
225 .lsp_store
226 .update(cx, |lsp_store, _| {
227 lsp_store
228 .language_server_statuses()
229 .map(|(server_id, status)| {
230 (
231 server_id,
232 (
233 status.server_readable_version.clone(),
234 status.binary.as_ref().map(|b| b.path.clone()),
235 status.process_id,
236 ),
237 )
238 })
239 .collect::<HashMap<_, _>>()
240 })
241 .unwrap_or_default();
242
243 let process_memory_cache = self.process_memory_cache.clone();
244
245 let mut first_button_encountered = false;
246 for item in &self.items {
247 if let LspMenuItem::ToggleServersButton { restart } = item {
248 let label = if *restart {
249 "Restart All Servers"
250 } else {
251 "Stop All Servers"
252 };
253
254 let restart = *restart;
255
256 let button = ContextMenuEntry::new(label).handler({
257 let state = cx.entity();
258 move |_, cx| {
259 let lsp_store = state.read(cx).lsp_store.clone();
260 lsp_store
261 .update(cx, |lsp_store, cx| {
262 if restart {
263 lsp_store.restart_all_language_servers(cx);
264 } else {
265 lsp_store.stop_all_language_servers(cx);
266 }
267 })
268 .ok();
269 }
270 });
271
272 if !first_button_encountered {
273 menu = menu.separator();
274 first_button_encountered = true;
275 }
276
277 menu = menu.item(button);
278 continue;
279 } else if let LspMenuItem::Header { header, separator } = item {
280 menu = menu
281 .when(*separator, |menu| menu.separator())
282 .when_some(header.as_ref(), |menu, header| menu.header(header));
283 continue;
284 }
285
286 let Some(server_info) = item.server_info() else {
287 continue;
288 };
289 let server_selector = server_info.server_selector();
290 let is_remote = self
291 .lsp_store
292 .update(cx, |lsp_store, _| lsp_store.as_remote().is_some())
293 .unwrap_or(false);
294 let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector);
295
296 let (status_color, status_label) = server_info
297 .binary_status
298 .as_ref()
299 .and_then(|binary_status| match binary_status.status {
300 BinaryStatus::None => None,
301 BinaryStatus::CheckingForUpdate
302 | BinaryStatus::Downloading
303 | BinaryStatus::Starting => Some((Color::Modified, "Starting…")),
304 BinaryStatus::Stopping | BinaryStatus::Stopped => {
305 Some((Color::Disabled, "Stopped"))
306 }
307 BinaryStatus::Failed { .. } => Some((Color::Error, "Error")),
308 })
309 .or_else(|| {
310 Some(match server_info.health? {
311 ServerHealth::Ok => (Color::Success, "Running"),
312 ServerHealth::Warning => (Color::Warning, "Warning"),
313 ServerHealth::Error => (Color::Error, "Error"),
314 })
315 })
316 .unwrap_or((Color::Success, "Running"));
317
318 let message = server_info
319 .message
320 .as_ref()
321 .or_else(|| server_info.binary_status.as_ref()?.message.as_ref())
322 .cloned();
323
324 let (server_version, binary_path, process_id) = server_metadata
325 .get(&server_info.id)
326 .map(|(version, path, process_id)| {
327 (
328 version.clone(),
329 path.as_ref()
330 .map(|p| SharedString::from(p.compact().to_string_lossy().to_string())),
331 *process_id,
332 )
333 })
334 .unwrap_or((None, None, None));
335
336 let server_message = message.clone();
337
338 let submenu_server_name = server_info.name.clone();
339 let submenu_server_info = server_info.clone();
340
341 menu = menu.submenu_with_colored_icon(
342 server_info.name.0.clone(),
343 IconName::Circle,
344 status_color,
345 {
346 let lsp_logs = lsp_logs.clone();
347 let message = message.clone();
348 let server_selector = server_selector.clone();
349 let workspace = self.workspace.clone();
350 let lsp_store = self.lsp_store.clone();
351 let state = cx.entity().downgrade();
352 let can_stop = submenu_server_info.can_stop();
353 let process_memory_cache = process_memory_cache.clone();
354
355 move |menu, _window, _cx| {
356 let mut submenu = menu;
357
358 if let Some(ref message) = message {
359 let workspace_for_message = workspace.clone();
360 let message_for_handler = message.clone();
361 let server_name_for_message = submenu_server_name.clone();
362 submenu = submenu.entry("View Message", None, move |window, cx| {
363 let Some(create_buffer) = workspace_for_message
364 .update(cx, |workspace, cx| {
365 workspace.project().update(cx, |project, cx| {
366 project.create_buffer(None, false, cx)
367 })
368 })
369 .ok()
370 else {
371 return;
372 };
373
374 let window_handle = window.window_handle();
375 let workspace = workspace_for_message.clone();
376 let message = message_for_handler.clone();
377 let server_name = server_name_for_message.clone();
378 cx.spawn(async move |cx| {
379 let buffer = create_buffer.await?;
380 buffer.update(cx, |buffer, cx| {
381 buffer.edit(
382 [(
383 0..0,
384 format!(
385 "Language server {server_name}:\n\n{message}"
386 ),
387 )],
388 None,
389 cx,
390 );
391 buffer.set_capability(language::Capability::ReadOnly, cx);
392 });
393
394 workspace.update(cx, |workspace, cx| {
395 window_handle.update(cx, |_, window, cx| {
396 workspace.add_item_to_active_pane(
397 Box::new(cx.new(|cx| {
398 let mut editor = Editor::for_buffer(
399 buffer, None, window, cx,
400 );
401 editor.set_read_only(true);
402 editor
403 })),
404 None,
405 true,
406 window,
407 cx,
408 );
409 })
410 })??;
411
412 anyhow::Ok(())
413 })
414 .detach();
415 });
416 }
417
418 if has_logs {
419 let lsp_logs_for_debug = lsp_logs.clone();
420 let workspace_for_debug = workspace.clone();
421 let server_selector_for_debug = server_selector.clone();
422 submenu = submenu.entry("View Logs", None, move |window, cx| {
423 lsp_log_view::open(
424 &lsp_logs_for_debug,
425 workspace_for_debug.clone(),
426 server_selector_for_debug.clone(),
427 window,
428 cx,
429 );
430 });
431 }
432
433 let state_for_restart = state.clone();
434 let workspace_for_restart = workspace.clone();
435 let lsp_store_for_restart = lsp_store.clone();
436 let server_name_for_restart = submenu_server_name.clone();
437 submenu = submenu.entry("Restart Server", None, move |_window, cx| {
438 let Some(workspace) = workspace_for_restart.upgrade() else {
439 return;
440 };
441
442 let project = workspace.read(cx).project().clone();
443 let path_style = project.read(cx).path_style(cx);
444 let buffer_store = project.read(cx).buffer_store().clone();
445
446 let buffers = state_for_restart
447 .update(cx, |state, cx| {
448 let server_buffers = state
449 .language_servers
450 .servers_per_buffer_abs_path
451 .iter()
452 .filter_map(|(abs_path, servers)| {
453 // Check if this server is associated with this path
454 let has_server = servers.servers.values().any(|name| {
455 name.as_ref() == Some(&server_name_for_restart)
456 });
457
458 if !has_server {
459 return None;
460 }
461
462 let worktree = servers.worktree.as_ref()?.upgrade()?;
463 let worktree_ref = worktree.read(cx);
464 let relative_path = abs_path
465 .strip_prefix(&worktree_ref.abs_path())
466 .ok()?;
467 let relative_path =
468 RelPath::new(relative_path, path_style)
469 .log_err()?;
470 let entry =
471 worktree_ref.entry_for_path(&relative_path)?;
472 let project_path =
473 project.read(cx).path_for_entry(entry.id, cx)?;
474
475 buffer_store.read(cx).get_by_path(&project_path)
476 })
477 .collect::<Vec<_>>();
478
479 if server_buffers.is_empty() {
480 state
481 .language_servers
482 .servers_per_buffer_abs_path
483 .iter()
484 .filter_map(|(abs_path, servers)| {
485 let worktree =
486 servers.worktree.as_ref()?.upgrade()?.read(cx);
487 let relative_path = abs_path
488 .strip_prefix(&worktree.abs_path())
489 .ok()?;
490 let relative_path =
491 RelPath::new(relative_path, path_style)
492 .log_err()?;
493 let entry =
494 worktree.entry_for_path(&relative_path)?;
495 let project_path = project
496 .read(cx)
497 .path_for_entry(entry.id, cx)?;
498 buffer_store.read(cx).get_by_path(&project_path)
499 })
500 .collect()
501 } else {
502 server_buffers
503 }
504 })
505 .unwrap_or_default();
506
507 if !buffers.is_empty() {
508 lsp_store_for_restart
509 .update(cx, |lsp_store, cx| {
510 lsp_store.restart_language_servers_for_buffers(
511 buffers,
512 HashSet::from_iter([LanguageServerSelector::Name(
513 server_name_for_restart.clone(),
514 )]),
515 true,
516 cx,
517 );
518 })
519 .ok();
520 }
521 });
522
523 if can_stop {
524 let lsp_store_for_stop = lsp_store.clone();
525 let server_selector_for_stop = server_selector.clone();
526
527 submenu = submenu.entry("Stop Server", None, move |_window, cx| {
528 lsp_store_for_stop
529 .update(cx, |lsp_store, cx| {
530 lsp_store
531 .stop_language_servers_for_buffers(
532 Vec::new(),
533 HashSet::from_iter([
534 server_selector_for_stop.clone()
535 ]),
536 cx,
537 )
538 .detach_and_log_err(cx);
539 })
540 .ok();
541 });
542 }
543
544 submenu = submenu.separator().custom_row({
545 let binary_path = binary_path.clone();
546 let server_version = server_version.clone();
547 let server_message = server_message.clone();
548 let process_memory_cache = process_memory_cache.clone();
549 move |_, cx| {
550 let memory_usage = process_id.map(|pid| {
551 process_memory_cache.borrow_mut().get_memory_usage(pid)
552 });
553
554 let memory_label = memory_usage.map(|bytes| {
555 if bytes >= 1024 * 1024 * 1024 {
556 format!(
557 "{:.1} GB",
558 bytes as f64 / (1024.0 * 1024.0 * 1024.0)
559 )
560 } else {
561 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
562 }
563 });
564
565 let version_label =
566 server_version.as_ref().map(|v| format!("v{}", v.as_ref()));
567
568 let separator_color =
569 cx.theme().colors().icon_disabled.opacity(0.8);
570
571 v_flex()
572 .id("metadata-container")
573 .gap_1()
574 .when_some(server_message.as_ref(), |this, _| {
575 this.w(rems_from_px(240.))
576 })
577 .child(
578 h_flex()
579 .ml_neg_1()
580 .gap_1()
581 .child(
582 Icon::new(IconName::Circle)
583 .color(status_color)
584 .size(IconSize::Small),
585 )
586 .child(
587 Label::new(status_label)
588 .size(LabelSize::Small)
589 .color(Color::Muted),
590 )
591 .when_some(version_label.as_ref(), |row, version| {
592 row.child(
593 Icon::new(IconName::Dash)
594 .color(Color::Custom(separator_color))
595 .size(IconSize::XSmall),
596 )
597 .child(
598 Label::new(version)
599 .size(LabelSize::Small)
600 .color(Color::Muted),
601 )
602 })
603 .when_some(memory_label.as_ref(), |row, memory| {
604 row.child(
605 Icon::new(IconName::Dash)
606 .color(Color::Custom(separator_color))
607 .size(IconSize::XSmall),
608 )
609 .child(
610 Label::new(memory)
611 .size(LabelSize::Small)
612 .color(Color::Muted),
613 )
614 }),
615 )
616 .when_some(server_message.clone(), |container, message| {
617 container.child(
618 Label::new(message)
619 .color(Color::Muted)
620 .size(LabelSize::Small),
621 )
622 })
623 .when_some(binary_path.clone(), |el, path| {
624 el.tooltip(Tooltip::text(path))
625 })
626 .into_any_element()
627 }
628 });
629
630 submenu
631 }
632 },
633 );
634 }
635 menu
636 }
637}
638
639impl LanguageServers {
640 fn update_binary_status(
641 &mut self,
642 binary_status: BinaryStatus,
643 message: Option<&str>,
644 name: LanguageServerName,
645 ) {
646 let binary_status_message = message.map(SharedString::new);
647 if matches!(
648 binary_status,
649 BinaryStatus::Stopped | BinaryStatus::Failed { .. }
650 ) {
651 self.health_statuses.retain(|_, server| server.name != name);
652 }
653 self.binary_statuses.insert(
654 name,
655 LanguageServerBinaryStatus {
656 status: binary_status,
657 message: binary_status_message,
658 },
659 );
660 }
661
662 fn update_server_health(
663 &mut self,
664 id: LanguageServerId,
665 health: ServerHealth,
666 message: Option<&str>,
667 name: Option<LanguageServerName>,
668 ) {
669 if let Some(state) = self.health_statuses.get_mut(&id) {
670 state.health = Some((message.map(SharedString::new), health));
671 if let Some(name) = name {
672 state.name = name;
673 }
674 } else if let Some(name) = name {
675 self.health_statuses.insert(
676 id,
677 LanguageServerHealthStatus {
678 health: Some((message.map(SharedString::new), health)),
679 name,
680 },
681 );
682 }
683 }
684
685 fn is_empty(&self) -> bool {
686 self.binary_statuses.is_empty() && self.health_statuses.is_empty()
687 }
688
689 /// Drop all id-keyed state for a server that has been removed (stopped or
690 /// reaching end-of-life via restart). `binary_statuses` is intentionally
691 /// preserved — it is keyed by name and shared across restart cycles to
692 /// drive the "Downloading… → Starting…" status UX.
693 fn remove_server(&mut self, server_id: LanguageServerId) {
694 self.health_statuses.remove(&server_id);
695 self.servers_per_buffer_abs_path
696 .retain(|_, servers_for_path| {
697 servers_for_path.servers.remove(&server_id);
698 !servers_for_path.servers.is_empty()
699 });
700 }
701}
702
703#[derive(Debug)]
704enum ServerData<'a> {
705 WithHealthCheck {
706 server_id: LanguageServerId,
707 health: &'a LanguageServerHealthStatus,
708 binary_status: Option<&'a LanguageServerBinaryStatus>,
709 },
710 WithBinaryStatus {
711 server_id: LanguageServerId,
712 server_name: &'a LanguageServerName,
713 binary_status: &'a LanguageServerBinaryStatus,
714 },
715}
716
717#[derive(Debug)]
718enum LspMenuItem {
719 WithHealthCheck {
720 server_id: LanguageServerId,
721 health: LanguageServerHealthStatus,
722 binary_status: Option<LanguageServerBinaryStatus>,
723 },
724 WithBinaryStatus {
725 server_id: LanguageServerId,
726 server_name: LanguageServerName,
727 binary_status: LanguageServerBinaryStatus,
728 },
729 ToggleServersButton {
730 restart: bool,
731 },
732 Header {
733 header: Option<SharedString>,
734 separator: bool,
735 },
736}
737
738impl LspMenuItem {
739 fn server_info(&self) -> Option<ServerInfo> {
740 match self {
741 Self::Header { .. } => None,
742 Self::ToggleServersButton { .. } => None,
743 Self::WithHealthCheck {
744 server_id,
745 health,
746 binary_status,
747 ..
748 } => Some(ServerInfo {
749 name: health.name.clone(),
750 id: *server_id,
751 health: health.health(),
752 binary_status: binary_status.clone(),
753 message: health.message(),
754 }),
755 Self::WithBinaryStatus {
756 server_id,
757 server_name,
758 binary_status,
759 ..
760 } => Some(ServerInfo {
761 name: server_name.clone(),
762 id: *server_id,
763 health: None,
764 binary_status: Some(binary_status.clone()),
765 message: binary_status.message.clone(),
766 }),
767 }
768 }
769}
770
771impl ServerData<'_> {
772 fn into_lsp_item(self) -> LspMenuItem {
773 match self {
774 Self::WithHealthCheck {
775 server_id,
776 health,
777 binary_status,
778 ..
779 } => LspMenuItem::WithHealthCheck {
780 server_id,
781 health: health.clone(),
782 binary_status: binary_status.cloned(),
783 },
784 Self::WithBinaryStatus {
785 server_id,
786 server_name,
787 binary_status,
788 ..
789 } => LspMenuItem::WithBinaryStatus {
790 server_id,
791 server_name: server_name.clone(),
792 binary_status: binary_status.clone(),
793 },
794 }
795 }
796}
797
798impl LspButton {
799 pub fn new(
800 workspace: &Workspace,
801 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
802 window: &mut Window,
803 cx: &mut Context<Self>,
804 ) -> Self {
805 let settings_subscription =
806 cx.observe_global_in::<SettingsStore>(window, move |lsp_button, window, cx| {
807 if ProjectSettings::get_global(cx).global_lsp_settings.button {
808 if lsp_button.lsp_menu.is_none() {
809 lsp_button.refresh_lsp_menu(true, window, cx);
810 }
811 } else if lsp_button.lsp_menu.take().is_some() {
812 cx.notify();
813 }
814 });
815
816 let lsp_store = workspace.project().read(cx).lsp_store();
817 let mut language_servers = LanguageServers::default();
818 for (_, status) in lsp_store.read(cx).language_server_statuses() {
819 language_servers.binary_statuses.insert(
820 status.name.clone(),
821 LanguageServerBinaryStatus {
822 status: BinaryStatus::None,
823 message: None,
824 },
825 );
826 }
827
828 let lsp_store_subscription =
829 cx.subscribe_in(&lsp_store, window, |lsp_button, _, e, window, cx| {
830 lsp_button.on_lsp_store_event(e, window, cx)
831 });
832
833 let server_state = cx.new(|_| LanguageServerState {
834 workspace: workspace.weak_handle(),
835 items: Vec::new(),
836 lsp_store: lsp_store.downgrade(),
837 active_editor: None,
838 language_servers,
839 process_memory_cache: Rc::new(RefCell::new(ProcessMemoryCache::new())),
840 });
841
842 let mut lsp_button = Self {
843 server_state,
844 popover_menu_handle,
845 lsp_menu: None,
846 lsp_menu_refresh: Task::ready(()),
847 _subscriptions: vec![settings_subscription, lsp_store_subscription],
848 };
849 if !lsp_button
850 .server_state
851 .read(cx)
852 .language_servers
853 .binary_statuses
854 .is_empty()
855 {
856 lsp_button.refresh_lsp_menu(true, window, cx);
857 }
858
859 lsp_button
860 }
861
862 fn on_lsp_store_event(
863 &mut self,
864 e: &LspStoreEvent,
865 window: &mut Window,
866 cx: &mut Context<Self>,
867 ) {
868 if self.lsp_menu.is_none() {
869 return;
870 };
871 let mut updated = false;
872
873 // TODO `LspStore` is global and reports status from all language servers, even from the other windows.
874 match e {
875 LspStoreEvent::LanguageServerUpdate {
876 language_server_id,
877 name,
878 message: proto::update_language_server::Variant::StatusUpdate(status_update),
879 } => match &status_update.status {
880 Some(proto::status_update::Status::Binary(binary_status)) => {
881 let Some(name) = name.as_ref() else {
882 return;
883 };
884 if let Some(binary_status) = proto::ServerBinaryStatus::from_i32(*binary_status)
885 {
886 let binary_status = match binary_status {
887 proto::ServerBinaryStatus::None => BinaryStatus::None,
888 proto::ServerBinaryStatus::CheckingForUpdate => {
889 BinaryStatus::CheckingForUpdate
890 }
891 proto::ServerBinaryStatus::Downloading => BinaryStatus::Downloading,
892 proto::ServerBinaryStatus::Starting => BinaryStatus::Starting,
893 proto::ServerBinaryStatus::Stopping => BinaryStatus::Stopping,
894 proto::ServerBinaryStatus::Stopped => BinaryStatus::Stopped,
895 proto::ServerBinaryStatus::Failed => {
896 let Some(error) = status_update.message.clone() else {
897 return;
898 };
899 BinaryStatus::Failed { error }
900 }
901 };
902 self.server_state.update(cx, |state, _| {
903 state.language_servers.update_binary_status(
904 binary_status,
905 status_update.message.as_deref(),
906 name.clone(),
907 );
908 });
909 updated = true;
910 };
911 }
912 Some(proto::status_update::Status::Health(health_status)) => {
913 if let Some(health) = proto::ServerHealth::from_i32(*health_status) {
914 let health = match health {
915 proto::ServerHealth::Ok => ServerHealth::Ok,
916 proto::ServerHealth::Warning => ServerHealth::Warning,
917 proto::ServerHealth::Error => ServerHealth::Error,
918 };
919 self.server_state.update(cx, |state, _| {
920 state.language_servers.update_server_health(
921 *language_server_id,
922 health,
923 status_update.message.as_deref(),
924 name.clone(),
925 );
926 });
927 updated = true;
928 }
929 }
930 None => {}
931 },
932 LspStoreEvent::LanguageServerUpdate {
933 language_server_id,
934 name,
935 message: proto::update_language_server::Variant::RegisteredForBuffer(update),
936 ..
937 } => {
938 self.server_state.update(cx, |state, cx| {
939 let Ok(worktree) = state.workspace.update(cx, |workspace, cx| {
940 workspace
941 .project()
942 .read(cx)
943 .find_worktree(Path::new(&update.buffer_abs_path), cx)
944 .map(|(worktree, _)| worktree.downgrade())
945 }) else {
946 return;
947 };
948 let entry = state
949 .language_servers
950 .servers_per_buffer_abs_path
951 .entry(PathBuf::from(&update.buffer_abs_path))
952 .or_insert_with(|| ServersForPath {
953 servers: HashMap::default(),
954 worktree: worktree.clone(),
955 });
956 entry.servers.insert(*language_server_id, name.clone());
957 if worktree.is_some() {
958 entry.worktree = worktree;
959 }
960 });
961 updated = true;
962 }
963 LspStoreEvent::LanguageServerRemoved(server_id) => {
964 self.server_state.update(cx, |state, _| {
965 state.language_servers.remove_server(*server_id);
966 });
967 updated = true;
968 }
969 _ => {}
970 };
971
972 if updated {
973 self.refresh_lsp_menu(false, window, cx);
974 }
975 }
976
977 fn regenerate_items(&mut self, cx: &mut App) {
978 self.server_state.update(cx, |state, cx| {
979 let active_worktrees = state
980 .active_editor
981 .as_ref()
982 .into_iter()
983 .flat_map(|active_editor| {
984 active_editor
985 .editor
986 .upgrade()
987 .into_iter()
988 .flat_map(|active_editor| {
989 active_editor
990 .read(cx)
991 .buffer()
992 .read(cx)
993 .all_buffers()
994 .into_iter()
995 .filter_map(|buffer| {
996 project::File::from_dyn(buffer.read(cx).file())
997 })
998 .map(|buffer_file| buffer_file.worktree.clone())
999 })
1000 })
1001 .collect::<HashSet<_>>();
1002
1003 let mut server_ids_to_worktrees =
1004 HashMap::<LanguageServerId, Entity<Worktree>>::default();
1005 let mut server_names_to_worktrees = HashMap::<
1006 LanguageServerName,
1007 HashSet<(Entity<Worktree>, LanguageServerId)>,
1008 >::default();
1009 for servers_for_path in state.language_servers.servers_per_buffer_abs_path.values() {
1010 if let Some(worktree) = servers_for_path
1011 .worktree
1012 .as_ref()
1013 .and_then(|worktree| worktree.upgrade())
1014 {
1015 for (server_id, server_name) in &servers_for_path.servers {
1016 server_ids_to_worktrees.insert(*server_id, worktree.clone());
1017 if let Some(server_name) = server_name {
1018 server_names_to_worktrees
1019 .entry(server_name.clone())
1020 .or_default()
1021 .insert((worktree.clone(), *server_id));
1022 }
1023 }
1024 }
1025 }
1026 state
1027 .lsp_store
1028 .update(cx, |lsp_store, cx| {
1029 for (server_id, status) in lsp_store.language_server_statuses() {
1030 if let Some(worktree) = status.worktree.and_then(|worktree_id| {
1031 lsp_store
1032 .worktree_store()
1033 .read(cx)
1034 .worktree_for_id(worktree_id, cx)
1035 }) {
1036 server_ids_to_worktrees.insert(server_id, worktree.clone());
1037 server_names_to_worktrees
1038 .entry(status.name.clone())
1039 .or_default()
1040 .insert((worktree, server_id));
1041 }
1042 }
1043 })
1044 .ok();
1045
1046 let mut servers_per_worktree = BTreeMap::<SharedString, Vec<ServerData>>::new();
1047 let mut servers_with_health_checks = HashSet::default();
1048
1049 for (server_id, health) in &state.language_servers.health_statuses {
1050 let worktree = server_ids_to_worktrees.get(server_id).or_else(|| {
1051 let worktrees = server_names_to_worktrees.get(&health.name)?;
1052 worktrees
1053 .iter()
1054 .find(|(worktree, _)| active_worktrees.contains(worktree))
1055 .or_else(|| worktrees.iter().next())
1056 .map(|(worktree, _)| worktree)
1057 });
1058 servers_with_health_checks.insert(&health.name);
1059 let worktree_name =
1060 worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name_str()));
1061
1062 let binary_status = state.language_servers.binary_statuses.get(&health.name);
1063 let server_data = ServerData::WithHealthCheck {
1064 server_id: *server_id,
1065 health,
1066 binary_status,
1067 };
1068 if let Some(worktree_name) = worktree_name {
1069 servers_per_worktree
1070 .entry(worktree_name.clone())
1071 .or_default()
1072 .push(server_data);
1073 }
1074 }
1075
1076 let mut can_stop_all = !state.language_servers.health_statuses.is_empty();
1077 let mut can_restart_all = state.language_servers.health_statuses.is_empty();
1078 for (server_name, binary_status) in state
1079 .language_servers
1080 .binary_statuses
1081 .iter()
1082 .filter(|(name, _)| !servers_with_health_checks.contains(name))
1083 {
1084 match binary_status.status {
1085 BinaryStatus::None => {
1086 can_restart_all = false;
1087 can_stop_all |= true;
1088 }
1089 BinaryStatus::CheckingForUpdate => {
1090 can_restart_all = false;
1091 can_stop_all = false;
1092 }
1093 BinaryStatus::Downloading => {
1094 can_restart_all = false;
1095 can_stop_all = false;
1096 }
1097 BinaryStatus::Starting => {
1098 can_restart_all = false;
1099 can_stop_all = false;
1100 }
1101 BinaryStatus::Stopping => {
1102 can_restart_all = false;
1103 can_stop_all = false;
1104 }
1105 BinaryStatus::Stopped => {}
1106 BinaryStatus::Failed { .. } => {}
1107 }
1108
1109 if let Some(worktrees_for_name) = server_names_to_worktrees.get(server_name)
1110 && let Some((worktree, server_id)) = worktrees_for_name
1111 .iter()
1112 .find(|(worktree, _)| active_worktrees.contains(worktree))
1113 .or_else(|| worktrees_for_name.iter().next())
1114 {
1115 let worktree_name = SharedString::new(worktree.read(cx).root_name_str());
1116 servers_per_worktree
1117 .entry(worktree_name.clone())
1118 .or_default()
1119 .push(ServerData::WithBinaryStatus {
1120 server_name,
1121 binary_status,
1122 server_id: *server_id,
1123 });
1124 }
1125 }
1126
1127 let mut new_lsp_items = Vec::with_capacity(servers_per_worktree.len() + 1);
1128 for (worktree_name, worktree_servers) in servers_per_worktree {
1129 if worktree_servers.is_empty() {
1130 continue;
1131 }
1132 new_lsp_items.push(LspMenuItem::Header {
1133 header: Some(worktree_name),
1134 separator: false,
1135 });
1136 new_lsp_items.extend(worktree_servers.into_iter().map(ServerData::into_lsp_item));
1137 }
1138 if !new_lsp_items.is_empty() {
1139 if can_stop_all {
1140 new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true });
1141 new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: false });
1142 } else if can_restart_all {
1143 new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true });
1144 }
1145 }
1146
1147 state.items = new_lsp_items;
1148 });
1149 }
1150
1151 fn refresh_lsp_menu(
1152 &mut self,
1153 create_if_empty: bool,
1154 window: &mut Window,
1155 cx: &mut Context<Self>,
1156 ) {
1157 if create_if_empty || self.lsp_menu.is_some() {
1158 let state = self.server_state.clone();
1159 self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_button, cx| {
1160 cx.background_executor()
1161 .timer(Duration::from_millis(30))
1162 .await;
1163 lsp_button
1164 .update_in(cx, |lsp_button, window, cx| {
1165 lsp_button.regenerate_items(cx);
1166 let menu = ContextMenu::build(window, cx, |menu, _, cx| {
1167 state.update(cx, |state, cx| state.fill_menu(menu, cx))
1168 });
1169 lsp_button.lsp_menu = Some(menu.clone());
1170 lsp_button.popover_menu_handle.refresh_menu(
1171 window,
1172 cx,
1173 Rc::new(move |_, _| Some(menu.clone())),
1174 );
1175 cx.notify();
1176 })
1177 .ok();
1178 });
1179 }
1180 }
1181}
1182
1183impl StatusItemView for LspButton {
1184 fn set_active_pane_item(
1185 &mut self,
1186 active_pane_item: Option<&dyn workspace::ItemHandle>,
1187 window: &mut Window,
1188 cx: &mut Context<Self>,
1189 ) {
1190 if ProjectSettings::get_global(cx).global_lsp_settings.button {
1191 if let Some(editor) = active_pane_item.and_then(|item| item.downcast::<Editor>()) {
1192 if Some(&editor)
1193 != self
1194 .server_state
1195 .read(cx)
1196 .active_editor
1197 .as_ref()
1198 .and_then(|active_editor| active_editor.editor.upgrade())
1199 .as_ref()
1200 {
1201 let editor_buffers = HashSet::from_iter(
1202 editor
1203 .read(cx)
1204 .buffer()
1205 .read(cx)
1206 .snapshot(cx)
1207 .excerpts()
1208 .map(|excerpt| excerpt.context.start.buffer_id),
1209 );
1210 let _editor_subscription = cx.subscribe_in(
1211 &editor,
1212 window,
1213 |lsp_button, _, e: &EditorEvent, window, cx| match e {
1214 EditorEvent::BufferRangesUpdated { buffer, .. } => {
1215 let updated = lsp_button.server_state.update(cx, |state, cx| {
1216 if let Some(active_editor) = state.active_editor.as_mut() {
1217 let buffer_id = buffer.read(cx).remote_id();
1218 active_editor.editor_buffers.insert(buffer_id)
1219 } else {
1220 false
1221 }
1222 });
1223 if updated {
1224 lsp_button.refresh_lsp_menu(false, window, cx);
1225 }
1226 }
1227 EditorEvent::BuffersRemoved { removed_buffer_ids } => {
1228 let removed = lsp_button.server_state.update(cx, |state, _| {
1229 let mut removed = false;
1230 if let Some(active_editor) = state.active_editor.as_mut() {
1231 for id in removed_buffer_ids {
1232 active_editor.editor_buffers.retain(|buffer_id| {
1233 let retain = buffer_id != id;
1234 removed |= !retain;
1235 retain
1236 });
1237 }
1238 }
1239 removed
1240 });
1241 if removed {
1242 lsp_button.refresh_lsp_menu(false, window, cx);
1243 }
1244 }
1245 _ => {}
1246 },
1247 );
1248 self.server_state.update(cx, |state, _| {
1249 state.active_editor = Some(ActiveEditor {
1250 editor: editor.downgrade(),
1251 _editor_subscription,
1252 editor_buffers,
1253 });
1254 });
1255 self.refresh_lsp_menu(true, window, cx);
1256 }
1257 } else if self.server_state.read(cx).active_editor.is_some() {
1258 self.server_state.update(cx, |state, _| {
1259 state.active_editor = None;
1260 });
1261 self.refresh_lsp_menu(false, window, cx);
1262 }
1263 } else if self.server_state.read(cx).active_editor.is_some() {
1264 self.server_state.update(cx, |state, _| {
1265 state.active_editor = None;
1266 });
1267 self.refresh_lsp_menu(false, window, cx);
1268 }
1269 }
1270
1271 fn hide_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
1272 Some(workspace::HideStatusItem::new(|settings| {
1273 settings.global_lsp_settings.get_or_insert_default().button = Some(false);
1274 }))
1275 }
1276}
1277
1278impl Render for LspButton {
1279 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
1280 if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() {
1281 return div().hidden();
1282 }
1283
1284 let state = self.server_state.read(cx);
1285 let is_via_ssh = state
1286 .workspace
1287 .upgrade()
1288 .map(|workspace| workspace.read(cx).project().read(cx).is_via_remote_server())
1289 .unwrap_or(false);
1290
1291 let mut has_errors = false;
1292 let mut has_warnings = false;
1293 let mut has_other_notifications = false;
1294 for binary_status in state.language_servers.binary_statuses.values() {
1295 has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. });
1296 has_other_notifications |= binary_status.message.is_some();
1297 }
1298
1299 for server in state.language_servers.health_statuses.values() {
1300 if let Some((message, health)) = &server.health {
1301 has_other_notifications |= message.is_some();
1302 match health {
1303 ServerHealth::Ok => {}
1304 ServerHealth::Warning => has_warnings = true,
1305 ServerHealth::Error => has_errors = true,
1306 }
1307 }
1308 }
1309
1310 let (indicator, description) = if has_errors {
1311 (
1312 Some(Indicator::dot().color(Color::Error)),
1313 "Server with errors",
1314 )
1315 } else if has_warnings {
1316 (
1317 Some(Indicator::dot().color(Color::Warning)),
1318 "Server with warnings",
1319 )
1320 } else if has_other_notifications {
1321 (
1322 Some(Indicator::dot().color(Color::Modified)),
1323 "Server with notifications",
1324 )
1325 } else {
1326 (None, "All Servers Operational")
1327 };
1328
1329 let lsp_button = cx.weak_entity();
1330
1331 div().child(
1332 PopoverMenu::new("lsp-tool")
1333 .on_open(Rc::new(move |_window, cx| {
1334 let copilot_enabled = all_language_settings(None, cx).edit_predictions.provider
1335 == EditPredictionProvider::Copilot;
1336 telemetry::event!(
1337 "Toolbar Menu Opened",
1338 name = "Language Servers",
1339 copilot_enabled,
1340 is_via_ssh,
1341 );
1342 }))
1343 .menu(move |_, cx| {
1344 lsp_button
1345 .read_with(cx, |lsp_button, _| lsp_button.lsp_menu.clone())
1346 .ok()
1347 .flatten()
1348 })
1349 .anchor(Anchor::BottomLeft)
1350 .with_handle(self.popover_menu_handle.clone())
1351 .trigger_with_tooltip(
1352 IconButton::new("zed-lsp-tool-button", IconName::BoltOutlined)
1353 .when_some(indicator, IconButton::indicator)
1354 .icon_size(IconSize::Small)
1355 .tab_index(0isize)
1356 .aria_label("Language Servers")
1357 .indicator_border_color(Some(cx.theme().colors().status_bar_background)),
1358 move |_window, cx| {
1359 Tooltip::with_meta("Language Servers", Some(&ToggleMenu), description, cx)
1360 },
1361 ),
1362 )
1363 }
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 fn server_id(n: usize) -> LanguageServerId {
1371 LanguageServerId(n)
1372 }
1373
1374 fn server_name(s: &str) -> LanguageServerName {
1375 LanguageServerName(s.into())
1376 }
1377
1378 fn health_status(name: &str) -> LanguageServerHealthStatus {
1379 LanguageServerHealthStatus {
1380 name: server_name(name),
1381 health: Some((None, ServerHealth::Ok)),
1382 }
1383 }
1384
1385 fn servers_for_path(servers: &[(LanguageServerId, &str)]) -> ServersForPath {
1386 ServersForPath {
1387 servers: servers
1388 .iter()
1389 .map(|(id, name)| (*id, Some(server_name(name))))
1390 .collect(),
1391 worktree: None,
1392 }
1393 }
1394
1395 /// `remove_server` evicts the id from `health_statuses` so a restarted
1396 /// server's new id renders without inheriting the old one's stale entry.
1397 /// This is the regression test for #53627.
1398 #[test]
1399 fn remove_server_drops_health_entry_for_id() {
1400 let mut state = LanguageServers::default();
1401 state
1402 .health_statuses
1403 .insert(server_id(1), health_status("rust-analyzer"));
1404 state
1405 .health_statuses
1406 .insert(server_id(2), health_status("typescript-language-server"));
1407
1408 state.remove_server(server_id(1));
1409
1410 assert!(!state.health_statuses.contains_key(&server_id(1)));
1411 assert!(state.health_statuses.contains_key(&server_id(2)));
1412 }
1413
1414 /// `remove_server` evicts the id from each per-buffer entry; entries that
1415 /// become empty are dropped so the map does not grow unbounded across
1416 /// many buffer opens/closes.
1417 #[test]
1418 fn remove_server_evicts_id_from_per_buffer_entries_and_drops_empty_entries() {
1419 let mut state = LanguageServers::default();
1420 let buffer_a = PathBuf::from("/project/a.rs");
1421 let buffer_b = PathBuf::from("/project/b.rs");
1422
1423 state.servers_per_buffer_abs_path.insert(
1424 buffer_a.clone(),
1425 servers_for_path(&[(server_id(1), "rust-analyzer")]),
1426 );
1427 state.servers_per_buffer_abs_path.insert(
1428 buffer_b.clone(),
1429 servers_for_path(&[(server_id(1), "rust-analyzer"), (server_id(2), "typos-lsp")]),
1430 );
1431
1432 state.remove_server(server_id(1));
1433
1434 assert!(
1435 !state.servers_per_buffer_abs_path.contains_key(&buffer_a),
1436 "buffer_a's entry held only the removed server, so the entry itself should be dropped",
1437 );
1438 let buffer_b_entry = state
1439 .servers_per_buffer_abs_path
1440 .get(&buffer_b)
1441 .expect("buffer_b's entry has another server, so it must be retained");
1442 assert!(!buffer_b_entry.servers.contains_key(&server_id(1)));
1443 assert!(buffer_b_entry.servers.contains_key(&server_id(2)));
1444 }
1445
1446 /// `binary_statuses` is keyed by name and intentionally shared across
1447 /// restart cycles to drive the "Downloading… → Starting…" UX. Removing a
1448 /// single server's id must not touch it.
1449 #[test]
1450 fn remove_server_does_not_touch_binary_statuses() {
1451 let mut state = LanguageServers::default();
1452 state.binary_statuses.insert(
1453 server_name("rust-analyzer"),
1454 LanguageServerBinaryStatus {
1455 status: BinaryStatus::Starting,
1456 message: None,
1457 },
1458 );
1459
1460 state.remove_server(server_id(1));
1461
1462 assert!(
1463 state
1464 .binary_statuses
1465 .contains_key(&server_name("rust-analyzer")),
1466 "binary_statuses is name-keyed and shared across restart cycles",
1467 );
1468 }
1469
1470 /// Simulates the full restart event sequence: remove old id, register
1471 /// new id with same name, write health for the new id. After restart
1472 /// only the new id should be visible — no leftover entry from the old
1473 /// incarnation.
1474 #[test]
1475 fn restart_sequence_leaves_only_new_server_id() {
1476 let mut state = LanguageServers::default();
1477 let buffer = PathBuf::from("/project/main.rs");
1478 let name = "rust-analyzer";
1479
1480 // Pre-restart: server v1 is registered for the buffer with health.
1481 state
1482 .servers_per_buffer_abs_path
1483 .insert(buffer.clone(), servers_for_path(&[(server_id(1), name)]));
1484 state
1485 .health_statuses
1486 .insert(server_id(1), health_status(name));
1487
1488 // Restart: old id is removed.
1489 state.remove_server(server_id(1));
1490
1491 // New id registers for the same buffer.
1492 let entry = state
1493 .servers_per_buffer_abs_path
1494 .entry(buffer.clone())
1495 .or_insert_with(|| ServersForPath {
1496 servers: HashMap::default(),
1497 worktree: None,
1498 });
1499 entry.servers.insert(server_id(2), Some(server_name(name)));
1500
1501 // Health update for the new id arrives.
1502 state
1503 .health_statuses
1504 .insert(server_id(2), health_status(name));
1505
1506 let entry = state
1507 .servers_per_buffer_abs_path
1508 .get(&buffer)
1509 .expect("buffer must still be tracked");
1510 assert_eq!(
1511 entry.servers.keys().copied().collect::<Vec<_>>(),
1512 vec![server_id(2)],
1513 "exactly one server for this buffer — the new incarnation",
1514 );
1515 assert!(
1516 !state.health_statuses.contains_key(&server_id(1)),
1517 "the dead server's health entry must not linger",
1518 );
1519 assert!(
1520 state.health_statuses.contains_key(&server_id(2)),
1521 "the new server's health entry is present",
1522 );
1523 }
1524}
1525