1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
//! `LspManager` — the sync-side handle to the async LSP runtime thread.
use std::path::Path;
use std::sync::Arc;
use crossbeam_channel::Receiver;
use tokio::sync::mpsc::UnboundedSender;
use crate::BufferId;
use crate::config::LspConfig;
use crate::event::{LspCommand, LspEvent, TextChange};
use crate::runtime;
/// Owned handle to the background LSP thread and its channels.
///
/// Drop calls `ShutdownAll` but does **not** join the thread. Call
/// [`LspManager::shutdown`] for a clean, blocking teardown.
pub struct LspManager {
cmd_tx: UnboundedSender<LspCommand>,
evt_rx: Receiver<LspEvent>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl LspManager {
/// Spawn the background "hjkl-lsp" thread with its own `current_thread`
/// tokio runtime. Returns immediately.
pub fn spawn(config: LspConfig) -> Self {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<LspCommand>();
let (evt_tx, evt_rx) = crossbeam_channel::unbounded::<LspEvent>();
let thread = std::thread::Builder::new()
.name("hjkl-lsp".to_string())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build LSP tokio runtime");
rt.block_on(runtime::dispatch(cmd_rx, evt_tx, config));
})
.expect("failed to spawn hjkl-lsp thread");
Self {
cmd_tx,
evt_rx,
thread: Some(thread),
}
}
/// Gracefully shut down: sends `ShutdownAll`, joins the thread (≤ 2 s).
///
/// If the LSP background thread hangs past the 2 s deadline, the helper
/// thread becomes an intentionally detached OS thread until process exit
/// (audit M8). This is bounded: one thread per shutdown call, typically
/// once per process lifetime. A proper timed join on `JoinHandle` would
/// require nightly `JoinHandleExt::join_timeout` or a full `pthread_timedjoin`.
pub fn shutdown(mut self) {
let _ = self.cmd_tx.send(LspCommand::ShutdownAll);
if let Some(handle) = self.thread.take() {
// Park the current thread with a 2-second timeout.
// `std::thread::JoinHandle` doesn't have a timed join in stable Rust,
// so we use a helper thread to enforce the deadline.
// If `handle.join()` takes > 2 s the helper is intentionally
// detached — the OS reclaims it at process exit. (M8)
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let _ = handle.join();
let _ = done_tx.send(());
});
let _ = done_rx.recv_timeout(std::time::Duration::from_secs(2));
}
}
/// Send a command to the background LSP thread, warning (once per
/// call) instead of silently swallowing the failure when the channel
/// is closed — i.e. the background thread has died. Without this, a
/// dead `hjkl-lsp` thread makes every command a permanent, invisible
/// no-op (audit D7). `label` is the command's variant name, logged
/// instead of the command itself since some variants (`NotifyChange`,
/// `AttachBuffer`) carry a whole buffer's text.
fn send_cmd(&self, label: &'static str, cmd: LspCommand) {
if self.cmd_tx.send(cmd).is_err() {
tracing::warn!(
command = label,
"hjkl-lsp: command dropped — background thread is not running"
);
}
}
/// Tell the runtime an LSP process exited so dead server state is removed.
pub fn server_exited(&self, key: crate::event::ServerKey) {
self.send_cmd("ServerExited", LspCommand::ServerExited { key });
}
/// Attach a buffer to the appropriate language server.
pub fn attach_buffer(&self, id: BufferId, path: &Path, language_id: &str, text: &str) {
self.send_cmd(
"AttachBuffer",
LspCommand::AttachBuffer {
id,
path: path.to_path_buf(),
language_id: language_id.to_string(),
text: text.to_string(),
},
);
}
/// Detach a buffer (closes the document on the server side).
pub fn detach_buffer(&self, id: BufferId) {
self.send_cmd("DetachBuffer", LspCommand::DetachBuffer { id });
}
/// Notify the server that a buffer's full text changed.
///
/// Takes an `Arc<String>` so the caller can hand off the shared
/// `View::content_joined()` cache without a `to_string()` clone of the
/// entire buffer per keystroke (on a 1.86 M-line file the clone was
/// ~22 % of per-keystroke CPU in profiling).
pub fn notify_change(&self, id: BufferId, full_text: Arc<String>) {
self.send_cmd("NotifyChange", LspCommand::NotifyChange { id, full_text });
}
/// Notify the server of an incremental edit batch. Each `TextChange` is
/// a `{range, text}` LSP `TextDocumentContentChangeEvent`. Positions
/// must already match the server's negotiated `positionEncoding` —
/// the caller is responsible for converting from UTF-8 byte columns to
/// UTF-16 code units when the server didn't negotiate UTF-8.
pub fn notify_change_incremental(&self, id: BufferId, changes: Vec<TextChange>) {
self.send_cmd(
"NotifyChangeIncremental",
LspCommand::NotifyChangeIncremental { id, changes },
);
}
/// Notify the server that a buffer was saved (`textDocument/didSave`).
/// rust-analyzer (and most servers) run their flycheck — `cargo check` /
/// `cargo clippy` — on save, so this is what surfaces clippy diagnostics.
pub fn notify_save(&self, id: BufferId) {
self.send_cmd("NotifySave", LspCommand::NotifySave { id });
}
/// Send a JSON-RPC request to the server attached to `buffer_id`.
///
/// `request_id` is app-allocated — it will appear in
/// `LspEvent::Response { request_id, .. }` when the response arrives.
pub fn send_request(
&self,
request_id: i64,
buffer_id: BufferId,
method: &str,
params: serde_json::Value,
) {
self.send_cmd(
"Request",
LspCommand::Request {
request_id,
buffer_id,
method: method.to_string(),
params,
},
);
}
/// Non-blocking poll: returns the next pending event, or `None` if empty.
pub fn try_recv_event(&self) -> Option<LspEvent> {
self.evt_rx.try_recv().ok()
}
}
impl Drop for LspManager {
fn drop(&mut self) {
let _ = self.cmd_tx.send(LspCommand::ShutdownAll);
// Don't join in Drop — caller should call shutdown() explicitly.
// If the thread is still around, just detach (JoinHandle drop detaches).
}
}