Skip to content

Commit a077703

Browse files
refactor: serve read requests from session snapshots (#673)
1 parent 90e165d commit a077703

3 files changed

Lines changed: 129 additions & 59 deletions

File tree

crates/djls-server/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ percent-encoding = { workspace = true }
1919
rustc-hash = { workspace = true }
2020
serde = { workspace = true }
2121
serde_json = { workspace = true }
22+
salsa = { workspace = true }
2223
tokio = { workspace = true }
2324
tower-lsp-server = { workspace = true }
2425
tracing = { workspace = true }
2526
tracing-appender = { workspace = true }
2627
tracing-subscriber = { workspace = true }
2728

2829
[dev-dependencies]
29-
salsa = { workspace = true }
3030
tempfile = { workspace = true }
3131

3232
[build-dependencies]

crates/djls-server/src/server.rs

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::future::Future;
2+
use std::panic::AssertUnwindSafe;
23
use std::sync::Arc;
34

45
use djls_project::Db as ProjectDb;
@@ -17,6 +18,9 @@ use crate::ext::UriExt;
1718
use crate::logging::LoggingGuard;
1819
use crate::queue::Queue;
1920
use crate::session::Session;
21+
use crate::session::SessionSnapshot;
22+
23+
const SNAPSHOT_CANCEL_RETRIES: usize = 2;
2024

2125
pub(crate) struct DjangoLanguageServer {
2226
client: Client,
@@ -52,6 +56,47 @@ impl DjangoLanguageServer {
5256
f(&mut session)
5357
}
5458

59+
/// Capture a snapshot under a brief lock, then compute on the blocking
60+
/// pool so the single-threaded event loop stays responsive.
61+
pub(crate) async fn with_snapshot<F, R>(&self, f: F) -> R
62+
where
63+
F: Fn(&SessionSnapshot) -> R + Send + Sync + 'static,
64+
R: Default + Send + 'static,
65+
{
66+
let f = Arc::new(f);
67+
68+
for attempt in 0..=SNAPSHOT_CANCEL_RETRIES {
69+
let snapshot = { self.session.lock().await.snapshot() };
70+
let f = Arc::clone(&f);
71+
let result = tokio::task::spawn_blocking(move || {
72+
salsa::Cancelled::catch(AssertUnwindSafe(|| f(&snapshot)))
73+
})
74+
.await
75+
.expect("snapshot task must not panic");
76+
77+
match result {
78+
Ok(result) => return result,
79+
Err(cancelled) if attempt < SNAPSHOT_CANCEL_RETRIES => {
80+
tracing::debug!(
81+
?cancelled,
82+
attempt = attempt + 1,
83+
"Snapshot request cancelled; retrying with fresh snapshot"
84+
);
85+
}
86+
Err(cancelled) => {
87+
tracing::debug!(
88+
?cancelled,
89+
retries = SNAPSHOT_CANCEL_RETRIES,
90+
"Snapshot request cancelled; returning fallback"
91+
);
92+
return R::default();
93+
}
94+
}
95+
}
96+
97+
unreachable!("snapshot retry loop must return")
98+
}
99+
55100
pub(crate) async fn with_session_mut_task<F, Fut>(
56101
&self,
57102
f: F,
@@ -85,14 +130,15 @@ impl DjangoLanguageServer {
85130
}
86131

87132
async fn maybe_push_diagnostics(&self, document: &TextDocument) {
133+
let file = document.file();
88134
let Some(diagnostics) = self
89-
.with_session(|session| {
90-
if session.client_info().supports_pull_diagnostics() {
135+
.with_snapshot(move |snapshot| {
136+
if snapshot.client_info().supports_pull_diagnostics() {
91137
tracing::debug!("Client supports pull diagnostics, skipping push");
92138
return None;
93139
}
94140

95-
djls_ide::collect_diagnostics(session.db(), document.file())
141+
djls_ide::collect_diagnostics(snapshot.db(), file)
96142
})
97143
.await
98144
else {
@@ -260,13 +306,13 @@ impl LanguageServer for DjangoLanguageServer {
260306
params: ls_types::CompletionParams,
261307
) -> LspResult<Option<ls_types::CompletionResponse>> {
262308
let response = self
263-
.with_session(|session| {
264-
let (file, offset) = session.position_for_document_request(
309+
.with_snapshot(move |snapshot| {
310+
let (file, offset) = snapshot.position_for_document_request(
265311
&params.text_document_position.text_document,
266312
params.text_document_position.position,
267313
"completion",
268314
)?;
269-
let db = session.db();
315+
let db = snapshot.db();
270316

271317
if *file.source(db).kind() != FileKind::Template {
272318
return None;
@@ -276,8 +322,8 @@ impl LanguageServer for DjangoLanguageServer {
276322
db,
277323
file,
278324
offset,
279-
session.client_info().position_encoding(),
280-
session.client_info().supports_snippets(),
325+
snapshot.client_info().position_encoding(),
326+
snapshot.client_info().supports_snippets(),
281327
)
282328
})
283329
.await;
@@ -287,13 +333,13 @@ impl LanguageServer for DjangoLanguageServer {
287333

288334
async fn hover(&self, params: ls_types::HoverParams) -> LspResult<Option<ls_types::Hover>> {
289335
let response = self
290-
.with_session(|session| {
291-
let (file, offset) = session.position_for_document_request(
336+
.with_snapshot(move |snapshot| {
337+
let (file, offset) = snapshot.position_for_document_request(
292338
&params.text_document_position_params.text_document,
293339
params.text_document_position_params.position,
294340
"hover",
295341
)?;
296-
let db = session.db();
342+
let db = snapshot.db();
297343

298344
if *file.source(db).kind() != FileKind::Template {
299345
return None;
@@ -316,14 +362,14 @@ impl LanguageServer for DjangoLanguageServer {
316362
);
317363

318364
let diagnostics = self
319-
.with_session(|session| {
365+
.with_snapshot(move |snapshot| {
320366
let Some(file) =
321-
session.file_for_document_request(&params.text_document, "diagnostic")
367+
snapshot.file_for_document_request(&params.text_document, "diagnostic")
322368
else {
323369
return Vec::new();
324370
};
325371

326-
djls_ide::collect_diagnostics(session.db(), file).unwrap_or_default()
372+
djls_ide::collect_diagnostics(snapshot.db(), file).unwrap_or_default()
327373
})
328374
.await;
329375

@@ -345,13 +391,13 @@ impl LanguageServer for DjangoLanguageServer {
345391
params: ls_types::FoldingRangeParams,
346392
) -> LspResult<Option<Vec<ls_types::FoldingRange>>> {
347393
let ranges = self
348-
.with_session(|session| {
394+
.with_snapshot(move |snapshot| {
349395
let Some(file) =
350-
session.file_for_document_request(&params.text_document, "folding")
396+
snapshot.file_for_document_request(&params.text_document, "folding")
351397
else {
352398
return Vec::new();
353399
};
354-
let db = session.db();
400+
let db = snapshot.db();
355401

356402
if *file.source(db).kind() != FileKind::Template {
357403
return Vec::new();
@@ -369,13 +415,13 @@ impl LanguageServer for DjangoLanguageServer {
369415
params: ls_types::DocumentSymbolParams,
370416
) -> LspResult<Option<ls_types::DocumentSymbolResponse>> {
371417
let symbols = self
372-
.with_session(|session| {
418+
.with_snapshot(move |snapshot| {
373419
let Some(file) =
374-
session.file_for_document_request(&params.text_document, "document symbol")
420+
snapshot.file_for_document_request(&params.text_document, "document symbol")
375421
else {
376422
return Vec::new();
377423
};
378-
let db = session.db();
424+
let db = snapshot.db();
379425

380426
if *file.source(db).kind() != FileKind::Template {
381427
return Vec::new();
@@ -393,13 +439,13 @@ impl LanguageServer for DjangoLanguageServer {
393439
params: ls_types::GotoDefinitionParams,
394440
) -> LspResult<Option<ls_types::GotoDefinitionResponse>> {
395441
let response = self
396-
.with_session(|session| {
397-
let (file, offset) = session.position_for_document_request(
442+
.with_snapshot(move |snapshot| {
443+
let (file, offset) = snapshot.position_for_document_request(
398444
&params.text_document_position_params.text_document,
399445
params.text_document_position_params.position,
400446
"goto definition",
401447
)?;
402-
let db = session.db();
448+
let db = snapshot.db();
403449

404450
if *file.source(db).kind() != FileKind::Template {
405451
return None;
@@ -417,13 +463,13 @@ impl LanguageServer for DjangoLanguageServer {
417463
params: ls_types::ReferenceParams,
418464
) -> LspResult<Option<Vec<ls_types::Location>>> {
419465
let response = self
420-
.with_session(|session| {
421-
let (file, offset) = session.position_for_document_request(
466+
.with_snapshot(move |snapshot| {
467+
let (file, offset) = snapshot.position_for_document_request(
422468
&params.text_document_position.text_document,
423469
params.text_document_position.position,
424470
"references",
425471
)?;
426-
let db = session.db();
472+
let db = snapshot.db();
427473

428474
if *file.source(db).kind() != FileKind::Template {
429475
return None;
@@ -441,13 +487,13 @@ impl LanguageServer for DjangoLanguageServer {
441487
params: ls_types::DocumentFormattingParams,
442488
) -> LspResult<Option<Vec<ls_types::TextEdit>>> {
443489
let edits = self
444-
.with_session(|session| {
490+
.with_snapshot(move |snapshot| {
445491
let Some(file) =
446-
session.file_for_document_request(&params.text_document, "formatting")
492+
snapshot.file_for_document_request(&params.text_document, "formatting")
447493
else {
448494
return Vec::new();
449495
};
450-
let db = session.db();
496+
let db = snapshot.db();
451497
let format_config = db.settings().format().clone();
452498

453499
if !format_config.enabled() {
@@ -462,7 +508,7 @@ impl LanguageServer for DjangoLanguageServer {
462508
djls_ide::format_document(
463509
db,
464510
file,
465-
session.client_info().position_encoding(),
511+
snapshot.client_info().position_encoding(),
466512
format_config.backend(),
467513
&params.options,
468514
)

crates/djls-server/src/session.rs

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ impl Session {
8686
}
8787
}
8888

89-
#[cfg(test)]
90-
fn snapshot(&self) -> SessionSnapshot {
89+
pub(crate) fn snapshot(&self) -> SessionSnapshot {
9190
SessionSnapshot::new(self.db.clone(), self.client_info.clone())
9291
}
9392

@@ -196,40 +195,26 @@ impl Session {
196195
/// Open editor buffers are exposed to Salsa through the workspace overlay,
197196
/// so feature code should read current text through [`File::source`]
198197
/// instead of reaching back into [`TextDocument`] state.
198+
#[allow(dead_code)]
199199
pub(crate) fn file_for_document_request(
200200
&self,
201201
text_document: &ls_types::TextDocumentIdentifier,
202202
request: &str,
203203
) -> Option<File> {
204-
let Some(path) = text_document.uri.to_utf8_path_buf() else {
205-
tracing::debug!(
206-
"Skipping non-file URI in {} request: {}",
207-
request,
208-
text_document.uri.as_str()
209-
);
210-
return None;
211-
};
212-
213-
Some(self.db.get_or_create_file(&path))
204+
self.snapshot()
205+
.file_for_document_request(text_document, request)
214206
}
215207

216208
/// Resolve an LSP positioned document request to a tracked file and byte offset.
209+
#[allow(dead_code)]
217210
pub(crate) fn position_for_document_request(
218211
&self,
219212
text_document: &ls_types::TextDocumentIdentifier,
220213
position: ls_types::Position,
221214
request: &str,
222215
) -> Option<(File, Offset)> {
223-
let file = self.file_for_document_request(text_document, request)?;
224-
let source = file.source(&self.db);
225-
let line_index = file.line_index(&self.db);
226-
let offset = position.to_offset(
227-
source.as_str(),
228-
line_index,
229-
self.client_info.position_encoding(),
230-
);
231-
232-
Some((file, offset))
216+
self.snapshot()
217+
.position_for_document_request(text_document, position, request)
233218
}
234219

235220
/// Get all currently open documents.
@@ -248,27 +233,66 @@ impl Default for Session {
248233
}
249234
}
250235

251-
/// Immutable snapshot of session state for tests.
252-
#[cfg(test)]
236+
/// Immutable snapshot of session state.
253237
#[derive(Clone)]
254-
struct SessionSnapshot {
238+
pub(crate) struct SessionSnapshot {
255239
db: DjangoDatabase,
256240
client_info: ClientInfo,
257241
}
258242

259-
#[cfg(test)]
260243
impl SessionSnapshot {
261244
fn new(db: DjangoDatabase, client_info: ClientInfo) -> Self {
262245
Self { db, client_info }
263246
}
264247

265-
fn db(&self) -> &DjangoDatabase {
248+
pub(crate) fn db(&self) -> &DjangoDatabase {
266249
&self.db
267250
}
268251

269-
fn client_info(&self) -> &ClientInfo {
252+
pub(crate) fn client_info(&self) -> &ClientInfo {
270253
&self.client_info
271254
}
255+
256+
/// Resolve an LSP document request to the tracked file for that URI.
257+
///
258+
/// Open editor buffers are exposed to Salsa through the workspace overlay,
259+
/// so feature code should read current text through [`File::source`]
260+
/// instead of reaching back into [`TextDocument`] state.
261+
pub(crate) fn file_for_document_request(
262+
&self,
263+
text_document: &ls_types::TextDocumentIdentifier,
264+
request: &str,
265+
) -> Option<File> {
266+
let Some(path) = text_document.uri.to_utf8_path_buf() else {
267+
tracing::debug!(
268+
"Skipping non-file URI in {} request: {}",
269+
request,
270+
text_document.uri.as_str()
271+
);
272+
return None;
273+
};
274+
275+
Some(self.db.get_or_create_file(&path))
276+
}
277+
278+
/// Resolve an LSP positioned document request to a tracked file and byte offset.
279+
pub(crate) fn position_for_document_request(
280+
&self,
281+
text_document: &ls_types::TextDocumentIdentifier,
282+
position: ls_types::Position,
283+
request: &str,
284+
) -> Option<(File, Offset)> {
285+
let file = self.file_for_document_request(text_document, request)?;
286+
let source = file.source(&self.db);
287+
let line_index = file.line_index(&self.db);
288+
let offset = position.to_offset(
289+
source.as_str(),
290+
line_index,
291+
self.client_info.position_encoding(),
292+
);
293+
294+
Some((file, offset))
295+
}
272296
}
273297

274298
#[cfg(test)]

0 commit comments

Comments
 (0)