Skip to content

Commit aa9a1ff

Browse files
authored
[ty] Avoid stale I/O diagnostics when closing deleted files (#28988)
1 parent 2d25346 commit aa9a1ff

2 files changed

Lines changed: 182 additions & 86 deletions

File tree

‎crates/ty_server/src/session.rs‎

Lines changed: 123 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ use lsp_types::{
1919
use lsp_types::{ExitNotification, Notification};
2020
use ruff_db::Db;
2121
use ruff_db::files::{File, system_path_to_file, vendored_path_to_file};
22-
use ruff_db::system::{System, SystemPath, SystemPathBuf};
22+
use ruff_db::system::{System, SystemPath, SystemPathBuf, SystemVirtualPath};
2323
use ruff_python_ast::PySourceType;
2424
use ty_combine::Combine;
2525
use ty_project::metadata::Options;
26-
use ty_project::watch::ChangeEvent;
26+
use ty_project::watch::{ChangeEvent, DeletedKind};
2727
use ty_project::{
2828
ChangeResult, Db as _, ProjectDatabase, ProjectMetadata, ProjectReloadResult,
2929
ScriptEnvironmentAvailability, UseUv, UvSyncChanges,
@@ -1958,7 +1958,6 @@ impl OpenDocumentHandle {
19581958
}
19591959
}
19601960

1961-
#[expect(unused)]
19621961
fn file_path(&self) -> Option<&AnySystemPath> {
19631962
match self {
19641963
Self::Text { path, .. } | Self::Notebook { path, .. } => Some(path),
@@ -2084,21 +2083,29 @@ impl OpenDocumentHandle {
20842083
}
20852084

20862085
fn update_in_databases(&self, session: &mut Session, client: &Client) {
2087-
let path = self.notebook_or_file_path();
2088-
let (containing_workspace, is_virtual, changes) = match path {
2089-
AnySystemPath::System(system_path) => (
2090-
session.workspaces().for_path(system_path),
2091-
false,
2092-
[ChangeEvent::file_content_changed(system_path.clone())],
2093-
),
2094-
AnySystemPath::SystemVirtual(virtual_path) => (
2095-
None,
2096-
true,
2097-
[ChangeEvent::ChangedVirtual(virtual_path.clone())],
2098-
),
2086+
let change = match self.notebook_or_file_path() {
2087+
AnySystemPath::System(system_path) => {
2088+
ChangeEvent::file_content_changed(system_path.clone())
2089+
}
2090+
AnySystemPath::SystemVirtual(virtual_path) => {
2091+
ChangeEvent::ChangedVirtual(virtual_path.clone())
2092+
}
20992093
};
2094+
self.apply_change_in_databases(session, client, change);
2095+
}
2096+
2097+
fn apply_change_in_databases(
2098+
&self,
2099+
session: &mut Session,
2100+
client: &Client,
2101+
change: ChangeEvent,
2102+
) {
2103+
let path = self.notebook_or_file_path();
2104+
let changes = [change];
2105+
let is_external = matches!(path, AnySystemPath::System(system_path)
2106+
if session.workspaces().for_path(system_path).is_none());
21002107

2101-
if containing_workspace.is_some() || is_virtual {
2108+
if !is_external {
21022109
// A containing workspace determines the project for a system file, while virtual
21032110
// documents select a single, arbitrary project. Neither selection depends on import
21042111
// search paths, so update only the selected database.
@@ -2135,80 +2142,114 @@ impl OpenDocumentHandle {
21352142
/// This can return an error when the document does not exist in the
21362143
/// session index.
21372144
pub(crate) fn close(&self, session: &mut Session, client: &Client) -> crate::Result<bool> {
2138-
let is_cell = self.is_cell();
2139-
let path = self.notebook_or_file_path();
2140-
21412145
let removed_document = session.index_mut().close_document(&self.key())?;
21422146

2143-
// Close the text or notebook file in the database but skip this
2144-
// step for cells because closing a cell doesn't close its notebook.
2145-
let requires_clear_diagnostics = if is_cell {
2146-
true
2147-
} else {
2148-
let db = session.project_db_mut(path);
2149-
2150-
match path {
2151-
AnySystemPath::System(system_path) => {
2152-
if let Some(file) = db.files().try_system(db, system_path) {
2153-
db.project().close_file(db, file);
2154-
2155-
// In case we preferred the language given by the Client
2156-
// over the one detected by the file extension, remove the file
2157-
// from the project to handle cases where a user changes the language
2158-
// of a file (which results in a didClose and didOpen for the same path but with different languages).
2159-
if removed_document.language_id().is_some()
2160-
&& system_path
2161-
.extension()
2162-
.and_then(PySourceType::try_from_extension)
2163-
.is_none()
2164-
{
2165-
db.project().remove_file(db, file);
2166-
}
2167-
2168-
// Restore file and script membership from the saved contents. Discarding
2169-
// unsaved script metadata can bring a file back into the project when
2170-
// `exclude-scripts` is enabled. Also request synchronization for saved
2171-
// metadata changes that were skipped while the editor overlay was present.
2172-
self.update_in_databases(session, client);
2173-
} else {
2174-
// This can only fail when the path is a directory or it doesn't exists but the
2175-
// file should exists for this handler in this branch. This is because every
2176-
// close call is preceded by an open call, which ensures that the file is
2177-
// interned in the lookup table (`Files`).
2178-
tracing::warn!("Salsa file does not exists for {}", system_path);
2179-
}
2180-
2181-
// For non-virtual files, we clear diagnostics if:
2182-
//
2183-
// 1. The file does not belong to any workspace e.g., opening a random file from
2184-
// outside the workspace because closing it acts like the file doesn't exists
2185-
// 2. The diagnostic mode is set to open-files only
2186-
session.workspaces().for_path(system_path).is_none()
2187-
|| session
2188-
.global_settings()
2189-
.diagnostic_mode()
2190-
.is_open_files_only()
2191-
}
2192-
AnySystemPath::SystemVirtual(virtual_path) => {
2193-
if let Some(virtual_file) = db.files().try_virtual_file(virtual_path) {
2194-
db.project().close_file(db, virtual_file.file());
2195-
virtual_file.close(db);
2196-
// Bump the file's revision back to using the file system's revision.
2197-
virtual_file.sync(db);
2198-
} else {
2199-
tracing::warn!("Salsa virtual file does not exists for {}", virtual_path);
2200-
}
2147+
let should_clear_diagnostics = match self.file_path() {
2148+
// Closing a notebook cell clears its diagnostics but leaves its notebook open.
2149+
None => true,
2150+
Some(AnySystemPath::System(path)) => {
2151+
let is_deleted = self.close_system_file(session, client, path, &removed_document);
2152+
2153+
// For non-virtual files, we clear diagnostics if:
2154+
//
2155+
// 1. The file was deleted before it was closed
2156+
// 2. The file does not belong to any workspace e.g., opening a random file from
2157+
// outside the workspace because closing it acts like the file doesn't exist
2158+
// 3. The diagnostic mode is set to open-files only
2159+
is_deleted
2160+
|| session.workspaces().for_path(path).is_none()
2161+
|| session
2162+
.global_settings()
2163+
.diagnostic_mode()
2164+
.is_open_files_only()
2165+
}
2166+
Some(AnySystemPath::SystemVirtual(path)) => {
2167+
self.close_virtual_file(session, path);
22012168

2202-
// Always clear diagnostics for virtual files, as they don't really exist on disk
2203-
// which means closing them is like deleting the file.
2204-
true
2205-
}
2169+
// Always clear diagnostics for virtual files, as they don't really exist on disk
2170+
// which means closing them is like deleting the file.
2171+
true
22062172
}
22072173
};
22082174

22092175
session.bump_revision();
22102176

2211-
Ok(requires_clear_diagnostics)
2177+
Ok(should_clear_diagnostics)
2178+
}
2179+
2180+
/// Closes a system file and returns whether it was deleted from disk.
2181+
fn close_system_file(
2182+
&self,
2183+
session: &mut Session,
2184+
client: &Client,
2185+
system_path: &SystemPath,
2186+
removed_document: &Document,
2187+
) -> bool {
2188+
let db = session.project_db_mut(self.notebook_or_file_path());
2189+
2190+
// The editor can delete the file before closing it and before its watcher
2191+
// reports the deletion. Check disk state after removing the editor overlay.
2192+
let is_deleted = db
2193+
.system()
2194+
.path_metadata(system_path)
2195+
.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound);
2196+
2197+
if let Some(file) = db.files().try_system(db, system_path) {
2198+
db.project().close_file(db, file);
2199+
2200+
// In case we preferred the language given by the Client
2201+
// over the one detected by the file extension, remove the file
2202+
// from the project to handle cases where a user changes the language
2203+
// of a file (which results in a didClose and didOpen for the same path but with different languages).
2204+
if removed_document.language_id().is_some()
2205+
&& system_path
2206+
.extension()
2207+
.and_then(PySourceType::try_from_extension)
2208+
.is_none()
2209+
{
2210+
db.project().remove_file(db, file);
2211+
}
2212+
2213+
if is_deleted {
2214+
// A content change leaves missing files in the project index, allowing
2215+
// workspace diagnostics to report an I/O error until the watcher fires.
2216+
self.apply_change_in_databases(
2217+
session,
2218+
client,
2219+
ChangeEvent::Deleted {
2220+
path: system_path.to_path_buf(),
2221+
kind: DeletedKind::File,
2222+
},
2223+
);
2224+
} else {
2225+
// Restore file and script membership from the saved contents. Discarding
2226+
// unsaved script metadata can bring a file back into the project when
2227+
// `exclude-scripts` is enabled. Also request synchronization for saved
2228+
// metadata changes that were skipped while the editor overlay was present.
2229+
self.update_in_databases(session, client);
2230+
}
2231+
} else {
2232+
// This can only fail when the path is a directory or it doesn't exists but the
2233+
// file should exists for this handler in this branch. This is because every
2234+
// close call is preceded by an open call, which ensures that the file is
2235+
// interned in the lookup table (`Files`).
2236+
tracing::warn!("Salsa file does not exists for {}", system_path);
2237+
}
2238+
2239+
is_deleted
2240+
}
2241+
2242+
fn close_virtual_file(&self, session: &mut Session, virtual_path: &SystemVirtualPath) {
2243+
let db = session.project_db_mut(self.notebook_or_file_path());
2244+
2245+
if let Some(virtual_file) = db.files().try_virtual_file(virtual_path) {
2246+
db.project().close_file(db, virtual_file.file());
2247+
virtual_file.close(db);
2248+
// Bump the file's revision back to using the file system's revision.
2249+
virtual_file.sync(db);
2250+
} else {
2251+
tracing::warn!("Salsa virtual file does not exists for {}", virtual_path);
2252+
}
22122253
}
22132254
}
22142255

‎crates/ty_server/tests/e2e/pull_diagnostics.rs‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ use anyhow::Result;
44
use insta::{assert_compact_json_snapshot, assert_debug_snapshot};
55
use lsp_server::RequestId;
66
use lsp_types::{
7-
DocumentDiagnosticReport, PartialResultParams, PreviousResultId, ProgressNotification, Uri,
8-
WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressParams, WorkspaceDiagnosticParams,
9-
WorkspaceDiagnosticReport, WorkspaceDiagnosticReportPartialResult,
7+
DocumentDiagnosticReport, FileChangeType, PartialResultParams, PreviousResultId,
8+
ProgressNotification, Uri, WorkDoneProgressBegin, WorkDoneProgressEnd, WorkDoneProgressParams,
9+
WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticReportPartialResult,
1010
WorkspaceDocumentDiagnosticReport,
1111
};
1212
use lsp_types::{TextDocumentContentChangeWholeDocument, WorkspaceDiagnosticRequest};
1313
use ruff_db::system::SystemPath;
1414
use ty_server::{ClientOptions, DiagnosticMode};
1515

16-
use crate::diagnostic_snapshots::condensed_document_diagnostic_snapshot;
16+
use crate::diagnostic_snapshots::{
17+
condensed_document_diagnostic_snapshot, condensed_workspace_diagnostic_snapshot,
18+
};
1719
use crate::{AwaitResponseError, TestServer, TestServerBuilder};
1820

1921
#[test]
@@ -1420,6 +1422,59 @@ def foo() -> str:
14201422
Ok(())
14211423
}
14221424

1425+
#[test]
1426+
fn closing_deleted_file_clears_diagnostics_before_watcher_notification() -> Result<()> {
1427+
let path = SystemPath::new("src/deleted.py");
1428+
let source = "value: int = 'wrong'\n";
1429+
let mut server = create_workspace_server_with_file(SystemPath::new("src"), path, source)?;
1430+
server.open_text_document(path, source, 1);
1431+
1432+
let initial = server.workspace_diagnostic_request(None, None);
1433+
let previous_result_ids = extract_result_ids_from_response(&initial);
1434+
insta::assert_snapshot!(condensed_workspace_diagnostic_snapshot(initial), @r#"
1435+
file://<temp_dir>/src/deleted.py
1436+
0:13..0:20[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`
1437+
"#);
1438+
1439+
let request_id = server.send_request::<WorkspaceDiagnosticRequest>(WorkspaceDiagnosticParams {
1440+
previous_result_ids: previous_result_ids.clone(),
1441+
..WorkspaceDiagnosticParams::default()
1442+
});
1443+
assert_workspace_diagnostics_suspends_for_long_polling(&mut server, &request_id);
1444+
1445+
// Editors can delete the file and close it before the filesystem watcher reports deletion.
1446+
std::fs::remove_file(server.file_path(path))?;
1447+
server.close_text_document(path);
1448+
insta::assert_snapshot!(
1449+
condensed_document_diagnostic_snapshot(server.document_diagnostic_request(path, None)),
1450+
@"",
1451+
);
1452+
1453+
let after_close = server.await_response::<WorkspaceDiagnosticRequest>(&request_id);
1454+
insta::assert_snapshot!(
1455+
condensed_workspace_diagnostic_snapshot(after_close),
1456+
@"file://<temp_dir>/src/deleted.py",
1457+
);
1458+
1459+
// The later watcher notification must preserve the empty report.
1460+
server.did_change_watched_file(path, FileChangeType::Deleted);
1461+
let after_watcher = server.workspace_diagnostic_request(None, Some(previous_result_ids));
1462+
insta::assert_snapshot!(
1463+
condensed_workspace_diagnostic_snapshot(after_watcher),
1464+
@"file://<temp_dir>/src/deleted.py",
1465+
);
1466+
1467+
server.write_file(path, source)?;
1468+
server.did_change_watched_file(path, FileChangeType::Created);
1469+
let recreated = server.workspace_diagnostic_request(None, None);
1470+
insta::assert_snapshot!(condensed_workspace_diagnostic_snapshot(recreated), @r#"
1471+
file://<temp_dir>/src/deleted.py
1472+
0:13..0:20[ERROR]: Object of type `Literal["wrong"]` is not assignable to `int`
1473+
"#);
1474+
1475+
Ok(())
1476+
}
1477+
14231478
// Helper functions for long-polling tests
14241479
fn create_workspace_server_with_file(
14251480
workspace_root: &SystemPath,

0 commit comments

Comments
 (0)