From 9119548ec91ba5510518fc44547121780f370fc0 Mon Sep 17 00:00:00 2001 From: defin85 <31535407+defin85@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:46:17 +0300 Subject: [PATCH 01/58] refactor(workspace): share semantic source updates across LSP and MCP --- crates/bsl-analyzer/src/bin/cli/analyze.rs | 12 +- crates/bsl-analyzer/src/global_state.rs | 113 + crates/bsl-analyzer/src/workspace.rs | 149 +- crates/bsl-analyzer/tests/common/mod.rs | 159 +- .../tests/diagnostics_baseline_lsp.rs | 63 +- ...0\276\320\264\321\203\320\273\321\214.xml" | 10 + .../Ext/Module.bsl" | 3 + ...0\270\321\202\320\265\320\273\321\214.xml" | 10 + .../Ext/Module.bsl" | 6 + .../bsl-analyzer.toml | 2 + ...0\276\320\264\321\203\320\273\321\214.xml" | 11 + .../Ext/Module.bsl" | 3 + ...0\270\321\202\320\265\320\273\321\214.xml" | 10 + .../Ext/Module.bsl" | 7 + .../perf-shadow/bsl-analyzer.toml | 6 + ...1\202\320\260\320\273\320\276\320\263.xml" | 14 + .../Ext/ObjectModule.bsl" | 7 + .../Configuration.xml | 7 + .../bsl-analyzer.toml | 2 + ...0\236\320\261\321\211\320\270\320\271.xml" | 11 + .../Ext/Module.bsl" | 4 + .../Configuration.xml | 7 + .../bsl-analyzer.toml | 8 + ...0\270\321\202\320\265\320\273\321\214.xml" | 11 + .../Ext/Module.bsl" | 16 + .../extensions/consumer/Configuration.xml | 7 + ...0\276\320\264\321\203\320\273\321\214.xml" | 11 + .../Ext/Module.bsl" | 3 + .../extensions/hidden/Configuration.xml | 7 + ...0\236\320\261\321\211\320\270\320\271.xml" | 11 + .../Ext/Module.bsl" | 9 + .../extensions/provider/Configuration.xml | 7 + .../base/Configuration.xml | 7 + .../bsl-analyzer.toml | 6 + ...0\270\321\202\320\265\320\273\321\214.xml" | 11 + .../Ext/Module.bsl" | 16 + .../extensions/consumer/Configuration.xml | 7 + ...0\236\320\261\321\211\320\270\320\271.xml" | 11 + .../Ext/Module.bsl" | 4 + .../extensions/provider-a/Configuration.xml | 7 + ...0\236\320\261\321\211\320\270\320\271.xml" | 11 + .../Ext/Module.bsl" | 4 + .../extensions/provider-b/Configuration.xml | 7 + .../tests/shared_workspace_analysis.rs | 490 ++++ .../shared_workspace_analysis/mcp_metrics.rs | 2268 +++++++++++++++++ .../tests/shared_workspace_analysis_s10.rs | 657 +++++ .../tests/shared_workspace_analysis_s4.rs | 241 ++ .../tests/shared_workspace_analysis_s5.rs | 793 ++++++ .../tests/shared_workspace_analysis_s6.rs | 427 ++++ crates/ide-host-core/src/lib.rs | 3 +- crates/ide-host-core/src/load.rs | 462 +++- .../src/diagnostics_state/lifecycle.rs | 1 + .../src/diagnostics_state/resident.rs | 491 ++-- crates/mcp-server/src/lib.rs | 2 + crates/mcp-server/src/serve_stream_tests.rs | 389 ++- crates/mcp-server/src/walk_probe.rs | 4 +- crates/test-utils/src/walk_probe.rs | 102 + docs/architecture/ARCHITECTURE.md | 33 + scripts/lsp-bench/bench_lsp.py | 596 ++++- scripts/lsp-bench/lsp_client.py | 16 +- scripts/lsp-bench/tests/test_bench_lsp.py | 127 + 61 files changed, 7548 insertions(+), 351 deletions(-) create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/bsl-analyzer.toml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/bsl-analyzer.toml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263/Ext/ObjectModule.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Configuration.xml create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/bsl-analyzer.toml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/Configuration.xml create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/bsl-analyzer.toml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/Configuration.xml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/Configuration.xml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/Configuration.xml create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/base/Configuration.xml create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/Configuration.xml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/Configuration.xml create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" create mode 100644 "crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" create mode 100644 crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/Configuration.xml create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis.rs create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis/mcp_metrics.rs create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis_s10.rs create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis_s4.rs create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis_s5.rs create mode 100644 crates/bsl-analyzer/tests/shared_workspace_analysis_s6.rs create mode 100644 scripts/lsp-bench/tests/test_bench_lsp.py diff --git a/crates/bsl-analyzer/src/bin/cli/analyze.rs b/crates/bsl-analyzer/src/bin/cli/analyze.rs index fd77533a4..5f1749fbc 100644 --- a/crates/bsl-analyzer/src/bin/cli/analyze.rs +++ b/crates/bsl-analyzer/src/bin/cli/analyze.rs @@ -489,13 +489,10 @@ fn analyze_salsa( ); tracing::info!("Loading files into database"); - let mut file_set = vfs::FileSet::new(); let mut all_file_ids: Vec<(FileId, PathBuf)> = Vec::new(); for (idx, path) in bsl_files.iter().enumerate() { let file_id = FileId(idx as u32); - let vfs_path = vfs::VfsPath::new(path.clone()); - file_set.insert(file_id, vfs_path); all_file_ids.push((file_id, path.clone())); } @@ -521,10 +518,8 @@ fn analyze_salsa( all_file_ids.clone() }; - let file_set_arc = Arc::new(file_set); let source_root_id = base_db::SourceRootId(0); - let source_root = base_db::SourceRoot::new_local((*file_set_arc).clone()); - db.set_source_root(source_root_id, source_root); + db.set_source_root(source_root_id, ide_host_core::build_source_root(&[])); // Disk-backed: record each file's content revision and drop the text (re-read on // demand by `file_text_query` under the salsa LRU, verified against the revision), @@ -535,6 +530,11 @@ fn analyze_salsa( if let Some((path, err)) = unreadable.into_iter().next() { return Err(format!("failed to read BSL file {}: {err}", path.display()).into()); } + let file_set_arc = { + use base_db::SourceDatabase; + + Arc::new(db.source_root_input(source_root_id).root(&db).file_set().clone()) + }; ide::warm_batch_config_roots(&db, &file_ids); tracing::info!( diff --git a/crates/bsl-analyzer/src/global_state.rs b/crates/bsl-analyzer/src/global_state.rs index 67f60a26a..16f70ed54 100644 --- a/crates/bsl-analyzer/src/global_state.rs +++ b/crates/bsl-analyzer/src/global_state.rs @@ -1008,6 +1008,114 @@ mod vfs_race_tests { assert!(state.call_hierarchy_index.active().is_some()); } + #[test] + fn reloading_with_base_root_keeps_project_input_in_vfs_without_stale_root_registration() { + use base_db::{SourceDatabase, SourceRootId}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug)] + struct CountingLoader(Arc); + impl loader::Handle for CountingLoader { + fn spawn(_sender: loader::Sender) -> Self + where + Self: Sized, + { + unreachable!("injected loader") + } + fn set_config(&mut self, _config: loader::Config) { + self.0.fetch_add(1, Ordering::SeqCst); + } + fn invalidate(&mut self, _path: paths::AbsPathBuf) {} + fn load_sync(&mut self, _path: &paths::AbsPath) -> Option> { + None + } + } + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let base = root.join("base"); + std::fs::create_dir_all(base.join("generated")).unwrap(); + std::fs::write(base.join("Configuration.xml"), "").unwrap(); + let initial_config = "[source]\nroot = \"base\"\n"; + let changed_config = "[source]\nroot = \"base\"\nexclude = [\"base/generated\"]\n"; + let config_path = root.join("bsl-analyzer.toml"); + std::fs::write(&config_path, initial_config).unwrap(); + let allowed_path = base.join("Allowed.bsl"); + let hidden_path = base.join("generated/Hidden.bsl"); + std::fs::write(&allowed_path, "allowed").unwrap(); + std::fs::write(&hidden_path, "hidden").unwrap(); + + let loader_config_count = Arc::new(AtomicUsize::new(0)); + let (sender, _receiver) = crossbeam_channel::unbounded(); + let (_loader_tx, loader_receiver) = crossbeam_channel::bounded(4); + let mut state = GlobalState::with_loader( + sender, + Box::new(CountingLoader(Arc::clone(&loader_config_count))), + loader_receiver, + ); + state.init_empty_source_root(); + state.set_workspace_root(root.to_path_buf()).unwrap(); + + let config_vfs_path = vfs::VfsPath::new(config_path.clone()); + let allowed_vfs_path = vfs::VfsPath::new(allowed_path.clone()); + let hidden_vfs_path = vfs::VfsPath::new(hidden_path.clone()); + let (config_id, allowed_id, hidden_id) = { + let mut vfs = state.vfs.write(); + vfs.set_file_contents(config_vfs_path.clone(), Some(Arc::from(initial_config))); + vfs.set_file_contents(allowed_vfs_path.clone(), Some(Arc::from("allowed"))); + vfs.set_file_contents(hidden_vfs_path.clone(), Some(Arc::from("hidden"))); + ( + vfs.file_id(&config_vfs_path).unwrap(), + vfs.file_id(&allowed_vfs_path).unwrap(), + vfs.file_id(&hidden_vfs_path).unwrap(), + ) + }; + state.process_changes(true); + state.init_source_root(); + state.vfs_done = true; + state.analysis_host.raw_database_mut().set_workspace_load_complete(true); + + std::fs::write(&config_path, changed_config).unwrap(); + assert!(state + .vfs + .write() + .set_file_contents(config_vfs_path.clone(), Some(Arc::from(changed_config)))); + let outcome = state.process_changes(false); + + assert!(outcome.config_file_changed, "the delivered config edit must reload the project"); + assert_eq!( + loader_config_count.load(Ordering::SeqCst), + 2, + "one initial load and one reload" + ); + { + let mut vfs = state.vfs.write(); + assert!(vfs.exists(config_id), "the watched project input was tombstoned"); + assert!( + !vfs.set_file_contents(config_vfs_path.clone(), Some(Arc::from(changed_config))), + "the project input VFS contents differ from the saved config" + ); + assert!(vfs.exists(allowed_id), "the included BSL source was evicted"); + assert!(!vfs.exists(hidden_id), "the newly excluded BSL source stayed in VFS"); + assert_eq!(vfs.pending_change_bytes(), (0, 0), "reload left a spurious VFS change"); + } + let db = state.analysis_host.raw_database(); + let root0 = db.source_root_input(SourceRootId(0)).root(db); + assert!( + root0.file_set().file_for_path(&config_vfs_path).is_none(), + "the non-source project input remained registered in SourceRoot(0)" + ); + assert!( + root0.file_set().file_for_path(&allowed_vfs_path).is_some(), + "the included BSL source was removed from SourceRoot(0)" + ); + assert!( + root0.file_set().file_for_path(&hidden_vfs_path).is_none(), + "the excluded BSL source remained in SourceRoot(0)" + ); + assert_eq!(&*db.file_text(hidden_id), "", "excluded BSL text was not tombstoned"); + } + #[test] fn set_workspace_root_closes_load_gate_only_for_initial_load() { let (sender, _receiver) = crossbeam_channel::unbounded(); @@ -1127,6 +1235,11 @@ mod vfs_race_tests { root0.file_set().file_for_path(&vfs::VfsPath::new(hidden_bsl)).is_none(), "excluded BSL remained in FileSet" ); + assert_eq!( + &*db.file_text(hidden_bsl_id), + "", + "excluded BSL was removed from FileSet without a deleted-text input" + ); assert!( root0.file_set().file_for_path(&vfs::VfsPath::new(allowed_bsl)).is_some(), "allowed BSL left FileSet" diff --git a/crates/bsl-analyzer/src/workspace.rs b/crates/bsl-analyzer/src/workspace.rs index fad6331e8..7c14db168 100644 --- a/crates/bsl-analyzer/src/workspace.rs +++ b/crates/bsl-analyzer/src/workspace.rs @@ -209,8 +209,6 @@ impl GlobalState { } pub fn process_changes(&mut self, suppress_metadata_bump: bool) -> ChangeOutcome { - use base_db::SourceDatabase; - let start = Instant::now(); let take_start = Instant::now(); let changed_files = self.vfs.write().take_changes(); @@ -230,20 +228,17 @@ impl GlobalState { let db = self.analysis_host.raw_database_mut(); let source_root_id = base_db::SourceRootId(0); - let source_root_input = db.source_root_input(source_root_id); - let source_root = source_root_input.root(db); - let mut file_set = source_root.file_set().clone(); - let mut file_set_modified = false; + let mut source_changes = Vec::with_capacity(file_count); let mut config_file_changed = false; let mut diagnostics_baseline_changed = false; - let mut bsl_source_changed = false; let mut call_hierarchy_body_edits = Vec::new(); + let mut call_hierarchy_structural_file_ids = Vec::new(); let mut call_hierarchy_structural_change = false; let mut changed_metadata_paths: Vec = Vec::new(); // Substrate-listed module bodies (common modules and HTTP/Web/Integration // services) that were added or removed (not merely edited): their per-MDO // listing `module_file` entry must be rebuilt. - let mut changed_listed_bodies: Vec = Vec::new(); + let mut changed_listed_bodies: Vec<(FileId, std::path::PathBuf)> = Vec::new(); for file in changed_files { // Capture the change kind before `file.change` is consumed below; only an @@ -267,9 +262,7 @@ impl GlobalState { continue; } - db.set_file_source_root(file.file_id, source_root_id); - - let is_bsl_path = { + let (path, is_bsl_path) = { let vfs = self.vfs.read(); let path = vfs.file_path(file.file_id); let path_path = path.as_path(); @@ -287,57 +280,30 @@ impl GlobalState { call_hierarchy_structural_change = true; } if change_is_structural && project_model::is_substrate_listed_body_path(path_path) { - changed_listed_bodies.push(path_path.to_path_buf()); + changed_listed_bodies.push((file.file_id, path_path.to_path_buf())); } - project_model::is_bsl_source_path(path_path) + (path.clone(), project_model::is_bsl_source_path(path_path)) }; if is_bsl_path { if change_is_structural { - call_hierarchy_structural_change = true; + call_hierarchy_structural_file_ids.push(file.file_id); } else if text.is_some() { call_hierarchy_body_edits.push(file.file_id); } } if is_bsl_path && text.is_none() { - if file_set.path_for_file(&file.file_id).is_some() { - file_set.remove(file.file_id); - // `Deleted` rather than `Unreadable`: the VFS reports both as - // absent text, and the eviction below puts the file out of every - // consumer's reach anyway, so marking it unread would assert a - // cause nobody established and nobody could read back. - ide_host_core::set_file_text_source( - db, - file.file_id, - ide_host_core::FileTextSource::Deleted, - ); - file_set_modified = true; - bsl_source_changed = true; - tracing::warn!( - file_id = file.file_id.0, - "BSL file evicted from FileSet (deleted or unreadable); text input emptied", - ); - } + // `Deleted` rather than `Unreadable`: the VFS reports both as + // absent text, and the batch removes the file from every consumer's + // reach before publishing the updated root. + source_changes + .push(ide_host_core::SourceBatchChange::Remove { file_id: file.file_id }); continue; } - if file_set.path_for_file(&file.file_id).is_none() { - let vfs = self.vfs.read(); - let path = vfs.file_path(file.file_id); - file_set.insert(file.file_id, path.clone()); - drop(vfs); - file_set_modified = true; - - tracing::debug!( - file_id = file.file_id.0, - "added file to FileSet during process_changes" - ); - } - if let Some(text) = text { - let store_in_salsa = ide_db::is_bsl_source(&file_set, file.file_id); - if store_in_salsa { + let source = if is_bsl_path { // Open editor buffers are the source of truth for unsaved // content, so they stay a resident overlay. Closed files are // recorded by content revision only and re-read from disk on @@ -351,21 +317,38 @@ impl GlobalState { is_open, "process_changes: file text" ); - let source = if is_open { - ide_host_core::FileTextSource::Overlay(&text) + if is_open { + Some(ide_host_core::BatchTextSource::Overlay(text)) } else { - ide_host_core::FileTextSource::Disk(&text) - }; - ide_host_core::set_file_text_source(db, file.file_id, source); - bsl_source_changed = true; - } + Some(ide_host_core::BatchTextSource::Disk(text)) + } + } else { + None + }; + source_changes.push(ide_host_core::SourceBatchChange::Upsert { + file_id: file.file_id, + path, + source, + }); } } - if file_set_modified { - let updated_source_root = base_db::SourceRoot::new_local(file_set); - db.set_source_root(source_root_id, updated_source_root); - } + let source_effects = ide_host_core::apply_source_batch(db, source_root_id, source_changes); + // LSP already captured its metadata paths; release the unused common path copies before reload. + drop(source_effects.affected_paths); + let changed_file_ids = &source_effects.changed_file_ids; + let changed_file_id_set = (changed_file_ids.len() > 1) + .then(|| changed_file_ids.iter().copied().collect::>()); + let has_source_change = |file_id: &FileId| { + changed_file_id_set + .as_ref() + .map_or_else(|| changed_file_ids.contains(file_id), |set| set.contains(file_id)) + }; + let bsl_source_changed = source_effects.bsl_source_changed; + call_hierarchy_body_edits.retain(has_source_change); + call_hierarchy_structural_change |= + call_hierarchy_structural_file_ids.iter().any(has_source_change); + changed_listed_bodies.retain(|(file_id, _)| has_source_change(file_id)); if config_file_changed { if suppress_metadata_bump { @@ -415,7 +398,9 @@ impl GlobalState { // the owning roots so the reverse index reflects the new/removed body. (The // initial sync is suppressed; the post-sync bootstrap rebuilds the listings.) if !suppress_metadata_bump && !changed_listed_bodies.is_empty() { - self.refresh_metadata_substrate(&changed_listed_bodies); + let paths: Vec<_> = + changed_listed_bodies.iter().map(|(_, path)| path.clone()).collect(); + self.refresh_metadata_substrate(&paths); } if !suppress_metadata_bump { @@ -684,8 +669,10 @@ impl GlobalState { self.show_error_message(format!("bsl-analyzer: project config reload failed: {e}")); return false; } - self.prune_stale_workspace_files(); + // Let excluded sources flow through the VFS removal batch while their + // FileIds are still in SourceRoot, so the batch can tombstone their text. self.evict_excluded_sources(); + self.prune_stale_workspace_files(); self.readmit_open_documents(); true } @@ -812,14 +799,13 @@ impl GlobalState { } fn prune_stale_workspace_files(&mut self) { - use base_db::{SourceDatabase, SourceRoot, SourceRootId}; + use base_db::{SourceDatabase, SourceRootId}; let Some(allowed_roots) = self.workspace_allowed_roots() else { return }; let open_paths = self.open_doc_paths(); let source_root_id = SourceRootId(0); - let mut new_file_set = vfs::file_set::FileSet::new(); - let mut dropped = 0usize; + let mut dropped_paths = Vec::new(); let mut resolved = stdx::path_exclusion::ResolvedDirs::default(); { let db = self.analysis_host.raw_database(); @@ -829,29 +815,52 @@ impl GlobalState { for file_id in file_set.iter() { let Some(vfs_path) = file_set.path_for_file(&file_id) else { continue }; - if path_in_workspace( + if !path_in_workspace( vfs_path.as_path(), Some(&allowed_roots), &open_paths, &self.source_exclusions, &mut resolved, ) { - new_file_set.insert(file_id, vfs_path.clone()); - } else { - dropped += 1; + dropped_paths.push((file_id, vfs_path.clone())); } } } - if dropped == 0 { + if dropped_paths.is_empty() { return; } + let dropped = dropped_paths.len(); tracing::info!(dropped, "pruning stale files from SourceRoot after workspace reconfig"); self.analysis_host.request_cancellation(); - let new_source_root = SourceRoot::new_local(new_file_set); - self.analysis_host.raw_database_mut().set_source_root(source_root_id, new_source_root); + let (stale_bsl, stale_non_bsl): (Vec<_>, Vec<_>) = dropped_paths + .into_iter() + .partition(|(_, path)| project_model::is_bsl_source_path(path.as_path())); + + // The project inputs are VFS-backed so the loader can watch and reload + // them, but they are not workspace sources. Remove only their stale + // SourceRoot registrations; deleting their VFS contents would report + // another project-input change and recursively start the same reload. + if !stale_non_bsl.is_empty() { + ide_host_core::apply_source_batch( + self.analysis_host.raw_database_mut(), + SourceRootId(0), + stale_non_bsl + .into_iter() + .map(|(file_id, _)| ide_host_core::SourceBatchChange::Remove { file_id }), + ); + } + + if !stale_bsl.is_empty() { + let mut vfs = self.vfs.write(); + for (_, path) in stale_bsl { + vfs.set_file_contents(path, None); + } + drop(vfs); + self.process_changes(!self.vfs_done); + } } /// The project's source roots, or `None` without a project. Empty when the user diff --git a/crates/bsl-analyzer/tests/common/mod.rs b/crates/bsl-analyzer/tests/common/mod.rs index 01a7066ab..a68bfd95c 100644 --- a/crates/bsl-analyzer/tests/common/mod.rs +++ b/crates/bsl-analyzer/tests/common/mod.rs @@ -19,6 +19,13 @@ pub const BROKEN: &str = "Процедура Тест(\n"; /// Silence a server at rest is allowed before a wait is judged lost. pub const SILENCE: Duration = Duration::from_secs(60); +/// Total bound for one wait, even while unrelated messages keep arriving. +pub const BUSY_CEILING: Duration = Duration::from_secs(10 * 60); + +const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(10); +const PROCESS_CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); + pub fn project() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("src")).unwrap(); @@ -138,9 +145,26 @@ impl Lsp { })); let deadline = std::time::Instant::now() + SILENCE; loop { - let remaining = deadline.checked_duration_since(std::time::Instant::now()).unwrap(); - let message = - lsp.messages.recv_timeout(remaining).expect("the LSP server answers initialize"); + let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else { + panic!( + "LSP child {} phase initialize exceeded {SILENCE:?}; recent messages: {}", + lsp.child.id(), + lsp.recent_methods() + ); + }; + let message = match lsp.messages.recv_timeout(remaining) { + Ok(message) if std::time::Instant::now() < deadline => message, + Ok(_) => panic!( + "LSP child {} phase initialize exceeded {SILENCE:?}; recent messages: {}", + lsp.child.id(), + lsp.recent_methods() + ), + Err(error) => panic!( + "LSP child {} phase initialize failed after {SILENCE:?}: {error}; recent messages: {}", + lsp.child.id(), + lsp.recent_methods() + ), + }; lsp.note_seen(&message); if message["id"] == 1 { break; @@ -170,28 +194,38 @@ impl Lsp { /// so the failure names the condition instead of "Timeout". #[track_caller] pub fn wait_for(&self, predicate: impl Fn(&Value) -> bool) -> Value { - self.wait_for_judging(SILENCE, predicate) + self.wait_for_judging(SILENCE, BUSY_CEILING, predicate) } /// [`Self::wait_for`] with the silence window spelled out — for the control that /// shows the judgement can fail, without sitting through the real window. #[track_caller] - pub fn wait_for_judging(&self, silence: Duration, predicate: impl Fn(&Value) -> bool) -> Value { - /// How long a server may stay busy and silent before the wait gives up anyway: - /// a bound on a runaway, not a measure of anything the tests expect. - const BUSY_CEILING: Duration = Duration::from_secs(10 * 60); - + pub fn wait_for_judging( + &self, + silence: Duration, + busy_ceiling: Duration, + predicate: impl Fn(&Value) -> bool, + ) -> Value { let caller = std::panic::Location::caller(); let started = std::time::Instant::now(); + let deadline = started + busy_ceiling; loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); let cpu_before = self.server_cpu_ticks(); - if let Some(message) = self.wait_for_within(silence, |_| true) { + let message = self.wait_for_within(silence.min(remaining), |_| true); + let waited = started.elapsed(); + assert!( + waited < busy_ceiling, + "waiting at {caller} for {waited:?}: total deadline elapsed for LSP child {}; recent messages: {}", + self.child.id(), + self.recent_methods(), + ); + if let Some(message) = message { if predicate(&message) { return message; } continue; } - let waited = started.elapsed(); let cpu_after = self.server_cpu_ticks(); let worked = match (cpu_before, cpu_after) { (Some(before), Some(after)) => after > before, @@ -203,14 +237,8 @@ impl Lsp { "waiting at {caller} for {waited:?}: the server answered nothing for \ {silence:?} and used no CPU in that time, so it is not working towards \ an answer — the awaited condition is one it will not meet. Last messages \ - seen: {}", - self.recent_methods(), - ); - assert!( - waited < BUSY_CEILING, - "waiting at {caller} for {waited:?}: the server stayed busy but answered \ - nothing for {silence:?} at a stretch, past the {BUSY_CEILING:?} ceiling. \ - Last messages seen: {}", + seen from LSP child {}: {}", + self.child.id(), self.recent_methods(), ); } @@ -266,17 +294,23 @@ impl Lsp { /// `None` means silence and nothing else. A server that has exited is not silence — /// it is the answer — so it fails here rather than leaving the caller to provoke a /// process that is gone, as fast as the loop can go round. + #[track_caller] pub fn wait_for_within( &self, timeout: Duration, predicate: impl Fn(&Value) -> bool, ) -> Option { + let caller = std::panic::Location::caller(); + let pid = self.child.id(); let deadline = std::time::Instant::now() + timeout; loop { let remaining = deadline.checked_duration_since(std::time::Instant::now())?; let pending = self.pending.lock().unwrap().pop_front(); match pending.map(Ok).unwrap_or_else(|| self.messages.recv_timeout(remaining)) { Ok(message) => { + if std::time::Instant::now() >= deadline { + return None; + } self.note_seen(&message); if predicate(&message) { return Some(message); @@ -284,7 +318,10 @@ impl Lsp { } Err(mpsc::RecvTimeoutError::Timeout) => return None, Err(mpsc::RecvTimeoutError::Disconnected) => { - panic!("the server exited instead of answering") + panic!( + "LSP child {pid} phase message wait at {caller}: output closed before matching a message; recent messages: {}", + self.recent_methods() + ) } } } @@ -300,6 +337,67 @@ impl Lsp { })); } + pub fn wait_for_exit_within( + &mut self, + timeout: Duration, + phase: &str, + ) -> Result, String> { + let pid = self.child.id(); + let deadline = std::time::Instant::now() + timeout; + loop { + match self.child.try_wait() { + Ok(Some(status)) => return Ok(Some(status)), + Ok(None) => {} + Err(error) => { + return Err(format!( + "LSP child {pid} phase {phase}: process poll failed: {error}; recent messages: {}", + self.recent_methods() + )); + } + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + std::thread::sleep(PROCESS_POLL_INTERVAL.min(remaining)); + } + } + + fn stop_child( + &mut self, + phase: &str, + timeout: Duration, + ) -> Result { + let pid = self.child.id(); + let poll_error = match self.wait_for_exit_within(Duration::ZERO, phase) { + Ok(Some(status)) => return Ok(status), + Ok(None) => None, + Err(error) => Some(error), + }; + if let Err(error) = self.child.kill() { + match self.wait_for_exit_within(Duration::ZERO, phase) { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(poll_error) => { + return Err(format!( + "LSP child {pid} phase {phase}: kill failed: {error}; {poll_error}" + )); + } + } + return Err(format!( + "LSP child {pid} phase {phase}: kill failed: {error}; prior process poll: {}; recent messages: {}", + poll_error.as_deref().unwrap_or("no process poll error"), + self.recent_methods() + )); + } + self.wait_for_exit_within(timeout, phase)?.ok_or_else(|| { + format!( + "LSP child {pid} phase {phase}: did not exit within {timeout:?} after kill; recent messages: {}", + self.recent_methods() + ) + }) + } + pub fn open(&mut self, path: &Path, text: &str) -> Value { let uri = lsp_types::Url::from_file_path(path).unwrap(); self.send(json!({ @@ -325,13 +423,28 @@ impl Lsp { let response = self.request(id, "shutdown", Value::Null); assert!(response["error"].is_null(), "shutdown request failed: {response}"); self.send(json!({"jsonrpc": "2.0", "method": "exit"})); - self.child.wait().expect("LSP child exits after shutdown/exit") + match self.wait_for_exit_within(PROCESS_SHUTDOWN_TIMEOUT, "shutdown/exit") { + Ok(Some(status)) => status, + Ok(None) => { + let pid = self.child.id(); + let recent = self.recent_methods(); + let cleanup = self.stop_child("shutdown timeout cleanup", PROCESS_CLEANUP_TIMEOUT); + panic!( + "LSP child {pid} phase shutdown/exit exceeded {PROCESS_SHUTDOWN_TIMEOUT:?}; recent messages: {recent}; forced cleanup: {cleanup:?}" + ); + } + Err(error) => { + let cleanup = self.stop_child("shutdown error cleanup", PROCESS_CLEANUP_TIMEOUT); + panic!("{error}; forced cleanup: {cleanup:?}"); + } + } } } impl Drop for Lsp { fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); + if let Err(error) = self.stop_child("Drop", PROCESS_CLEANUP_TIMEOUT) { + eprintln!("{error}"); + } } } diff --git a/crates/bsl-analyzer/tests/diagnostics_baseline_lsp.rs b/crates/bsl-analyzer/tests/diagnostics_baseline_lsp.rs index d7e5415ff..4e5417001 100644 --- a/crates/bsl-analyzer/tests/diagnostics_baseline_lsp.rs +++ b/crates/bsl-analyzer/tests/diagnostics_baseline_lsp.rs @@ -186,7 +186,7 @@ fn a_wait_an_idle_server_will_never_satisfy_is_judged_lost_not_timed_out() { let started = std::time::Instant::now(); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - lsp.wait_for_judging(silence, |_| false); + lsp.wait_for_judging(silence, common::BUSY_CEILING, |_| false); })); let waited = started.elapsed(); @@ -203,3 +203,64 @@ fn a_wait_an_idle_server_will_never_satisfy_is_judged_lost_not_timed_out() { "an idle server is judged on its own silence window, not the default: {waited:?}", ); } + +#[test] +fn a_continuous_unrelated_message_stream_cannot_extend_a_wait_past_its_ceiling() { + let dir = project(); + let mut lsp = Lsp::start(dir.path()); + let (sender, injected) = std::sync::mpsc::channel::(); + let original = std::mem::replace(&mut lsp.messages, injected); + let stream_for = Duration::from_millis(300); + let producer = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + stream_for; + while std::time::Instant::now() < deadline { + if sender.send(json!({"method": "$/testNoise"})).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + + let started = std::time::Instant::now(); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + lsp.wait_for_judging(Duration::from_millis(20), Duration::from_millis(80), |_| false); + })); + let waited = started.elapsed(); + let payload = outcome.expect_err("unrelated traffic cannot satisfy the requested condition"); + let message = payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_default(); + assert!( + message.contains("total deadline elapsed"), + "the verdict names the total bound: {message}" + ); + assert!(message.contains("$/testNoise"), "the verdict names the unrelated traffic: {message}"); + assert!(waited < stream_for, "the wait ended before the bounded noise producer: {waited:?}"); + producer.join().expect("the bounded noise producer exits"); + lsp.messages = original; +} + +#[test] +fn an_owned_live_lsp_child_is_killed_and_reaped_with_a_bounded_wait() { + let dir = project(); + let mut lsp = Lsp::start(dir.path()); + let pid = lsp.child.id(); + assert!(lsp.child.try_wait().unwrap().is_none(), "owned LSP child {pid} is running"); + + assert_eq!( + lsp.wait_for_exit_within(Duration::from_millis(20), "process deadline control").unwrap(), + None, + "a running child remains alive when its bounded poll expires" + ); + assert!(lsp.child.try_wait().unwrap().is_none(), "the deadline poll did not kill child {pid}"); + + lsp.child.kill().expect("the owned LSP child can be killed directly"); + let status = lsp + .wait_for_exit_within(Duration::from_secs(2), "process cleanup control") + .expect("the post-kill process poll succeeds") + .expect("the killed LSP child is reaped within the bound"); + assert!(!status.success(), "forced cleanup is not a graceful LSP shutdown"); + assert!(lsp.child.try_wait().unwrap().is_some(), "owned LSP child {pid} was reaped"); +} diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" new file mode 100644 index 000000000..616ed5b3c --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" @@ -0,0 +1,10 @@ + + + + + ОбщийМодуль + true + true + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..a8cff337b --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,3 @@ +Функция ЧисловоеЗначение() Экспорт + Возврат 42; +КонецФункции diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" new file mode 100644 index 000000000..86a2af0e8 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" @@ -0,0 +1,10 @@ + + + + + Потребитель + true + true + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..f98fe3c89 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,6 @@ +&НаСервере +Процедура Проверить() Экспорт + Значение = ОбщийМодуль.ЧисловоеЗначение(); + + Значение = "🧪"; НетТакойПроцедуры(); +КонецПроцедуры diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/bsl-analyzer.toml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/bsl-analyzer.toml new file mode 100644 index 000000000..00fd793b2 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/bsl-analyzer.toml @@ -0,0 +1,2 @@ +[source] +root = "." diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" new file mode 100644 index 000000000..32df5f7ca --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" @@ -0,0 +1,11 @@ + + + + + ОбщийМодуль + true + true + true + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..a8cff337b --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\236\320\261\321\211\320\270\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,3 @@ +Функция ЧисловоеЗначение() Экспорт + Возврат 42; +КонецФункции diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" new file mode 100644 index 000000000..86a2af0e8 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" @@ -0,0 +1,10 @@ + + + + + Потребитель + true + true + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..31578e579 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,7 @@ +&НаСервере +Процедура Проверить() Экспорт + Значение = ОбщийМодуль.ЧисловоеЗначение(); + + Значение = "🧪"; НетТакойПроцедуры(); + ЧисловоеЗначение(); +КонецПроцедуры diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/bsl-analyzer.toml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/bsl-analyzer.toml new file mode 100644 index 000000000..b2f69b724 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow/bsl-analyzer.toml @@ -0,0 +1,6 @@ +[source] +root = "." + + +[diagnostics.parameters] +UnresolvedBareCall = true diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263.xml" new file mode 100644 index 000000000..f42f7e9d2 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263.xml" @@ -0,0 +1,14 @@ + + + + КонтрольныйКаталог + + + ИзменяемыйТипxs:string + + + СтабильныйТипxs:string + + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263/Ext/ObjectModule.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263/Ext/ObjectModule.bsl" new file mode 100644 index 000000000..25fa97896 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Catalogs/\320\232\320\276\320\275\321\202\321\200\320\276\320\273\321\214\320\275\321\213\320\271\320\232\320\260\321\202\320\260\320\273\320\276\320\263/Ext/ObjectModule.bsl" @@ -0,0 +1,7 @@ +Функция ПолучитьПоле() Экспорт + Возврат ЭтотОбъект.ИзменяемыйТип; +КонецФункции + +Функция ПолучитьКонтрольноеПоле() Экспорт + Возврат ЭтотОбъект.СтабильныйТип; +КонецФункции diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Configuration.xml new file mode 100644 index 000000000..5ef94be57 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/Configuration.xml @@ -0,0 +1,7 @@ + + + + S4Fixture + КонтрольныйКаталог + + diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/bsl-analyzer.toml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/bsl-analyzer.toml new file mode 100644 index 000000000..00fd793b2 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s4/bsl-analyzer.toml @@ -0,0 +1,2 @@ +[source] +root = "." diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" new file mode 100644 index 000000000..632ac39bb --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" @@ -0,0 +1,11 @@ + + + + + Общийfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" new file mode 100644 index 000000000..004125a9d --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" @@ -0,0 +1,4 @@ +&НаСервере +Функция Спорный() Экспорт + Возврат 42; +КонецФункции diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/Configuration.xml new file mode 100644 index 000000000..01c8cdb3a --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/Configuration.xml @@ -0,0 +1,7 @@ + + + + БазоваяКонфигурация + Общий + + diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/bsl-analyzer.toml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/bsl-analyzer.toml new file mode 100644 index 000000000..df400de67 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/bsl-analyzer.toml @@ -0,0 +1,8 @@ +[source] +root = "." +exclude = ["extensions/hidden"] +extensions = [ + { name = "Поставщик", path = "extensions/provider", scope = "InfoBase" }, + { name = "Потребитель", path = "extensions/consumer", scope = "InfoBase", dependsOn = ["Поставщик"] }, + { name = "Скрытое", path = "extensions/hidden", scope = "InfoBase" }, +] diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" new file mode 100644 index 000000000..53902e004 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" @@ -0,0 +1,11 @@ + + + + + Потребительfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..400bc59e2 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,16 @@ +&НаСервере +Функция РезультатЗависимости() Экспорт + ОтветПоставщика = Общий.ДатьСтроку(); + Возврат ОтветПоставщика; +КонецФункции + +&НаСервере +Функция РезультатОдинаковогоИмени() Экспорт + ОтветКонфигурации = Общий.Спорный(); + Возврат ОтветКонфигурации; +КонецФункции + +&НаСервере +Процедура ИспользоватьИсключённыйМодуль() Экспорт + СкрытыйМодуль.СекретныйМетод(); +КонецПроцедуры diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/Configuration.xml new file mode 100644 index 000000000..dfc5ea536 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/consumer/Configuration.xml @@ -0,0 +1,7 @@ + + + + РасширениеПотребитель + Потребитель + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" new file mode 100644 index 000000000..c4be69021 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214.xml" @@ -0,0 +1,11 @@ + + + + + СкрытыйМодульfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..7c6c52083 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/CommonModules/\320\241\320\272\321\200\321\213\321\202\321\213\320\271\320\234\320\276\320\264\321\203\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,3 @@ +&НаСервере +Процедура СекретныйМетод() Экспорт +КонецПроцедуры diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/Configuration.xml new file mode 100644 index 000000000..8b29b984e --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/hidden/Configuration.xml @@ -0,0 +1,7 @@ + + + + СкрытоеРасширение + СкрытыйМодуль + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" new file mode 100644 index 000000000..6e54e69de --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" @@ -0,0 +1,11 @@ + + + + + Общийfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" new file mode 100644 index 000000000..c5b418d03 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" @@ -0,0 +1,9 @@ +&НаСервере +Функция Спорный() Экспорт + Возврат "extension"; +КонецФункции + +&НаСервере +Функция ДатьСтроку() Экспорт + Возврат "dependency"; +КонецФункции diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/Configuration.xml new file mode 100644 index 000000000..efbf3d751 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s5/extensions/provider/Configuration.xml @@ -0,0 +1,7 @@ + + + + РасширениеПоставщик + Общий + + diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/base/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/base/Configuration.xml new file mode 100644 index 000000000..ef0427b2a --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/base/Configuration.xml @@ -0,0 +1,7 @@ + + + + БазоваяКонфигурация + + + diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml new file mode 100644 index 000000000..aaaa3cf53 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml @@ -0,0 +1,6 @@ +[source] +root = "base" +extensions = [ + { name = "Поставщик", path = "extensions/provider-a", scope = "InfoBase" }, + { name = "Потребитель", path = "extensions/consumer", scope = "InfoBase", dependsOn = ["Поставщик"] }, +] diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" new file mode 100644 index 000000000..878f3b89a --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214.xml" @@ -0,0 +1,11 @@ + + + + + Потребительfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" new file mode 100644 index 000000000..2c17b7a83 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/\320\237\320\276\321\202\321\200\320\265\320\261\320\270\321\202\320\265\320\273\321\214/Ext/Module.bsl" @@ -0,0 +1,16 @@ +&НаСервере +Функция РезультатСтроки() Экспорт + ОтветСтроки = Общий.ДатьСтроку(); + Возврат ОтветСтроки; +КонецФункции + +&НаСервере +Функция РезультатЧисла() Экспорт + ОтветЧисла = Общий.ДатьЧисло(); + Возврат ОтветЧисла; +КонецФункции + +&НаСервере +Процедура ПроверитьДиагностику() Экспорт + НетТакойПроцедуры(); +КонецПроцедуры diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/Configuration.xml new file mode 100644 index 000000000..0bf67ef5e --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/consumer/Configuration.xml @@ -0,0 +1,7 @@ + + + + РасширениеПотребитель + Потребитель + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" new file mode 100644 index 000000000..b81ffe13f --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" @@ -0,0 +1,11 @@ + + + + + Общийfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" new file mode 100644 index 000000000..b800ac72a --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" @@ -0,0 +1,4 @@ +&НаСервере +Функция ДатьСтроку() Экспорт + Возврат "active provider A"; +КонецФункции diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/Configuration.xml new file mode 100644 index 000000000..1c22e6573 --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-a/Configuration.xml @@ -0,0 +1,7 @@ + + + + РасширениеПоставщик + Общий + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" new file mode 100644 index 000000000..436fe9db8 --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271.xml" @@ -0,0 +1,11 @@ + + + + + Общийfalsefalse + truefalse + falsefalse + falseDontUse + + + diff --git "a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" new file mode 100644 index 000000000..d0c0d0a5b --- /dev/null +++ "b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/CommonModules/\320\236\320\261\321\211\320\270\320\271/Ext/Module.bsl" @@ -0,0 +1,4 @@ +&НаСервере +Функция ДатьЧисло() Экспорт + Возврат 7; +КонецФункции diff --git a/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/Configuration.xml b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/Configuration.xml new file mode 100644 index 000000000..1959dbc0f --- /dev/null +++ b/crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis_s6/extensions/provider-b/Configuration.xml @@ -0,0 +1,7 @@ + + + + РасширениеПоставщик + Общий + + diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis.rs new file mode 100644 index 000000000..18004a4d8 --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis.rs @@ -0,0 +1,490 @@ +mod common; +#[path = "shared_workspace_analysis/mcp_metrics.rs"] +mod mcp_metrics; + +use std::path::Path; +use std::time::Duration; + +use common::Lsp; +use mcp_server::{serve_stream, McpProfile, McpServer, SharedState}; +use rmcp::model::CallToolRequestParams; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Map, Value}; + +type Client = RunningService; + +const CALLER: &str = + include_str!("fixtures/shared_workspace_analysis/CommonModules/Потребитель/Ext/Module.bsl"); +const STRING_TARGET: &str = + "Функция ЧисловоеЗначение() Экспорт\n Возврат \"buffer-A\";\nКонецФункции\n"; + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("fixture workspace"); + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/shared_workspace_analysis"); + for entry in walkdir::WalkDir::new(&fixture_root) + .into_iter() + .filter_entry(|entry| entry.path() != fixture_root.join("perf-shadow")) + { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(&fixture_root).expect("fixture-relative path"); + let target = root.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("fixture directory"); + } else { + std::fs::create_dir_all(target.parent().expect("fixture parent")) + .expect("fixture directory"); + std::fs::copy(entry.path(), target).expect("fixture file"); + } + } + root +} + +async fn mcp_client(root: &Path) -> Client { + let server = McpServer::new( + McpProfile::Workspace, + SharedState::workspace(root.to_path_buf()).expect("valid fixture workspace"), + ); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + tokio::spawn(serve_stream(server, server_io)); + ().serve(client_io).await.expect("real rmcp handshake") +} + +async fn poll_tool(client: &Client, tool: &'static str, args: Map) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + loop { + let result = client + .call_tool(CallToolRequestParams::new(tool).with_arguments(args.clone())) + .await + .expect("MCP transport call"); + if let Some(value) = result.structured_content { + if value["status"] != "loading" { + return value; + } + } + assert!(tokio::time::Instant::now() < deadline, "{tool} stayed loading"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_symbol_status(client: &Client, expected: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let card = poll_tool( + client, + "symbol_info", + args(&[("symbol", json!("ОбщийМодуль.ЧисловоеЗначение"))]), + ) + .await; + if card["status"] == expected { + return card; + } + assert!( + tokio::time::Instant::now() < deadline, + "expected symbol status {expected}: {card}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_symbol_return_type(client: &Client, expected: &str, stale: Option<&str>) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "symbol_info never returned type {expected}"); + let result = tokio::time::timeout( + remaining, + client.call_tool( + CallToolRequestParams::new("symbol_info") + .with_arguments(args(&[("symbol", json!("ОбщийМодуль.ЧисловоеЗначение"))])), + ), + ) + .await + .expect("symbol_info request deadline") + .expect("MCP transport call"); + let card = result.structured_content.expect("symbol_info structured result"); + match card["status"].as_str() { + Some("loading") => {} + Some("ok") => { + let actual = card["return_type"].as_str().expect("symbol_info return_type string"); + if actual == expected { + return card; + } + assert_eq!( + Some(actual), + stale, + "unexpected symbol_info type while waiting for {expected}: {card}" + ); + } + _ => panic!("symbol_info did not succeed while waiting for {expected}: {card}"), + } + assert!( + tokio::time::Instant::now() < deadline, + "symbol_info never returned type {expected}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn wait_hover_type( + lsp: &mut Lsp, + first_id: u64, + params: Value, + expected: &str, + stale: Option<&str>, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let mut id = first_id; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP hover never returned type {expected}"); + lsp.send( + json!({"jsonrpc": "2.0", "id": id, "method": "textDocument/hover", "params": params}), + ); + let response = lsp + .wait_for_within(remaining, |message| { + message["id"] == id + && message["method"].is_null() + && (message.get("result").is_some() || message.get("error").is_some()) + }) + .unwrap_or_else(|| panic!("LSP hover request {id} exceeded its deadline")); + assert!(response["error"].is_null(), "LSP hover failed: {response}"); + let result = response["result"].to_string(); + if result.contains(expected) { + return response; + } + let stale_type = stale.expect("unexpected LSP hover type"); + assert!( + result.contains(stale_type), + "unexpected LSP hover result while waiting for {expected}: {response}" + ); + assert!(std::time::Instant::now() < deadline, "LSP hover never returned type {expected}"); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn args(pairs: &[(&str, Value)]) -> Map { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn lsp_and_mcp_return_the_same_nonempty_diagnostic_and_semantic_target() { + let root = fixture(); + let caller_path = root.path().join("CommonModules/Потребитель/Ext/Module.bsl"); + let target_path = root.path().join("CommonModules/ОбщийМодуль/Ext/Module.bsl"); + + let mut lsp = Lsp::start(root.path()); + let published = lsp.open(&caller_path, CALLER); + let diagnostics = published["params"]["diagnostics"].as_array().expect("diagnostic list"); + let unresolved = diagnostics + .iter() + .find(|diagnostic| diagnostic["code"] == "UnresolvedBareCall") + .expect("fixture has a known unresolved call"); + let diagnostic_prefix = " Значение = \"🧪\"; "; + let utf16_column = diagnostic_prefix.encode_utf16().count() as u64; + let codepoint_column = diagnostic_prefix.chars().count() as u64; + assert_ne!(utf16_column, codepoint_column, "fixture distinguishes UTF-16 columns"); + assert_eq!(unresolved["severity"], 1, "known diagnostic is an LSP error: {unresolved}"); + assert_eq!(unresolved["range"]["start"]["line"], 4); + assert_eq!(unresolved["range"]["start"]["character"], utf16_column); + assert_eq!( + unresolved["message"], + "Процедура или функция с указанным именем не определена: 'НетТакойПроцедуры'" + ); + + let caller_uri = lsp_types::Url::from_file_path(&caller_path).expect("caller file URI"); + let definition = lsp.request( + 2, + "textDocument/definition", + json!({ + "textDocument": {"uri": caller_uri.clone()}, + "position": {"line": 2, "character": 27} + }), + ); + assert!(definition["error"].is_null(), "definition request: {definition}"); + let location = if definition["result"].is_array() { + &definition["result"][0] + } else { + &definition["result"] + }; + assert_eq!(location["uri"], lsp_types::Url::from_file_path(&target_path).unwrap().as_str()); + assert_eq!(location["range"]["start"]["line"], 0); + + let hover = lsp.request( + 3, + "textDocument/hover", + json!({ + "textDocument": {"uri": caller_uri.clone()}, + "position": {"line": 2, "character": 8} + }), + ); + assert!(hover["error"].is_null(), "hover request: {hover}"); + assert!(hover["result"].to_string().contains("Число"), "expected inferred Число: {hover}"); + + let client = mcp_client(root.path()).await; + let diagnostic = poll_tool( + &client, + "diagnostics", + args(&[ + ("action", json!("file")), + ("path", json!("CommonModules/Потребитель/Ext/Module.bsl")), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]), + ) + .await; + let findings = diagnostic["result"]["findings"].as_array().expect("MCP findings"); + let mcp_unresolved = findings + .iter() + .find(|finding| finding["code"] == "UnresolvedBareCall") + .expect("MCP carries the known diagnostic"); + assert_eq!(mcp_unresolved["internal_severity"], "Major"); + assert_eq!(mcp_unresolved["range"]["start_line"], 4); + assert_eq!(mcp_unresolved["range"]["start_column"], codepoint_column); + let mcp_location = &mcp_unresolved["location"]; + assert_eq!(mcp_location["position_encoding"], "utf-16"); + assert_eq!(mcp_location["range"]["start_line"], 4); + assert_eq!(mcp_location["range"]["start_character"], utf16_column); + assert_eq!( + unresolved["range"]["start"]["character"], mcp_location["range"]["start_character"], + "LSP and MCP location contract normalize to the same UTF-16 position" + ); + + let symbol = poll_tool( + &client, + "symbol_info", + args(&[("symbol", json!("ОбщийМодуль.ЧисловоеЗначение"))]), + ) + .await; + assert_eq!(symbol["status"], "ok", "symbol_info result: {symbol}"); + assert!(symbol["return_type"] == "Число", "expected exact Число return_type: {symbol}"); + let definition = symbol["definitions"].as_array().unwrap().first().unwrap(); + assert!( + definition["location"]["path"].as_str().unwrap().ends_with("ОбщийМодуль/Ext/Module.bsl"), + "MCP definition target: {symbol}" + ); + + // LSP owns the unsaved editor view; MCP keeps the workspace's saved source view. + lsp.open(&target_path, STRING_TARGET); + let buffer_hover = lsp.request( + 4, + "textDocument/hover", + json!({ + "textDocument": {"uri": caller_uri}, + "position": {"line": 2, "character": 8} + }), + ); + assert!( + buffer_hover["result"].to_string().contains("Строка"), + "LSP reads buffer A's inferred string type: {buffer_hover}" + ); + + std::fs::write( + &target_path, + "Функция ЧисловоеЗначение() Экспорт\n Возврат 84;\nКонецФункции\n", + ) + .expect("write saved disk view B"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{ + "uri": lsp_types::Url::from_file_path(&target_path).unwrap(), "type": 2 + }]} + })); + let after_disk_event = lsp.request( + 5, + "textDocument/hover", + json!({ + "textDocument": {"uri": lsp_types::Url::from_file_path(&caller_path).unwrap()}, + "position": {"line": 2, "character": 8} + }), + ); + assert!( + after_disk_event["result"].to_string().contains("Строка"), + "disk B must not replace open buffer A: {after_disk_event}" + ); + let disk_card = wait_symbol_return_type(&client, "Число", None).await; + assert!( + disk_card["return_type"] == "Число", + "MCP reads the saved numeric disk view B: {disk_card}" + ); + + client.cancel().await.ok(); + assert!(lsp.shutdown(6).success(), "LSP process exits cleanly"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn unsaved_lsp_overlay_remains_distinct_from_mcp_disk_until_save_and_close() { + let root = fixture(); + let caller_path = root.path().join("CommonModules/Потребитель/Ext/Module.bsl"); + let target_path = root.path().join("CommonModules/ОбщийМодуль/Ext/Module.bsl"); + let caller_uri = lsp_types::Url::from_file_path(&caller_path).expect("caller URI"); + let target_uri = lsp_types::Url::from_file_path(&target_path).expect("target URI"); + let original_target = + include_str!("fixtures/shared_workspace_analysis/CommonModules/ОбщийМодуль/Ext/Module.bsl"); + + let mut lsp = Lsp::start(root.path()); + let published = lsp.open(&caller_path, CALLER); + let diagnostics = published["params"]["diagnostics"].as_array().expect("diagnostics"); + let unresolved = diagnostics + .iter() + .find(|diagnostic| diagnostic["code"] == "UnresolvedBareCall") + .expect("fixture's positive diagnostic control"); + let diagnostic_prefix = " Значение = \"🧪\"; "; + let utf16_column = diagnostic_prefix.encode_utf16().count() as u64; + assert_eq!(unresolved["severity"], 1, "known diagnostic is an LSP error"); + assert_eq!(unresolved["range"]["start"]["line"], 4); + assert_eq!(unresolved["range"]["start"]["character"], utf16_column); + assert_eq!( + unresolved["message"], + "Процедура или функция с указанным именем не определена: 'НетТакойПроцедуры'" + ); + + let client = mcp_client(root.path()).await; + let disk_number = wait_symbol_return_type(&client, "Число", None).await; + assert_eq!(disk_number["status"], "ok", "MCP disk baseline: {disk_number}"); + assert!( + disk_number["return_type"] == "Число", + "saved fixture has the numeric return_type: {disk_number}" + ); + + let hover_params = json!({ + "textDocument": {"uri": caller_uri.clone()}, + "position": {"line": 2, "character": 8} + }); + wait_hover_type(&mut lsp, 2, hover_params.clone(), "Число", None); + + lsp.open(&target_path, original_target); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didChange", + "params": {"textDocument": {"uri": target_uri.clone(), "version": 2}, + "contentChanges": [{"text": STRING_TARGET}]} + })); + let disk_number_during_overlay = wait_symbol_return_type(&client, "Число", None).await; + let unsaved_string_hover = + wait_hover_type(&mut lsp, 3, hover_params.clone(), "Строка", Some("Число")); + assert!(disk_number_during_overlay["return_type"] == "Число"); + assert_ne!( + unsaved_string_hover["result"].to_string().contains("Строка"), + disk_number_during_overlay["return_type"] == "Строка", + "positive type mismatch: LSP buffer is string, MCP disk is numeric" + ); + + let disk_replacement = "Функция ЧисловоеЗначение() Экспорт\n Возврат 84;\nКонецФункции\n"; + std::fs::write(&target_path, disk_replacement).expect("external numeric disk update"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": target_uri.clone(), "type": 2}]} + })); + wait_hover_type(&mut lsp, 4, hover_params.clone(), "Строка", Some("Число")); + let disk_still_number = wait_symbol_return_type(&client, "Число", None).await; + assert!( + disk_still_number["return_type"] == "Число", + "MCP continues to read the numeric disk version: {disk_still_number}" + ); + + std::fs::write(&target_path, STRING_TARGET).expect("save string source to disk"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": target_uri.clone(), "type": 2}]} + })); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didSave", + "params": {"textDocument": {"uri": target_uri.clone()}} + })); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didClose", + "params": {"textDocument": {"uri": target_uri}} + })); + + wait_hover_type(&mut lsp, 5, hover_params, "Строка", Some("Число")); + let disk_string = wait_symbol_return_type(&client, "Строка", Some("Число")).await; + assert!( + disk_string["return_type"] == "Строка", + "after save/close MCP converges to the saved string source: {disk_string}" + ); + + std::fs::write(&target_path, original_target).expect("restore owned fixture contents"); + client.cancel().await.ok(); + assert!(lsp.shutdown(6).success(), "LSP process exits cleanly"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn watched_bsl_remove_and_recreate_updates_both_protocols() { + let root = fixture(); + let caller_path = root.path().join("CommonModules/Потребитель/Ext/Module.bsl"); + let target_path = root.path().join("CommonModules/ОбщийМодуль/Ext/Module.bsl"); + let caller_uri = lsp_types::Url::from_file_path(&caller_path).unwrap(); + let target_uri = lsp_types::Url::from_file_path(&target_path).unwrap(); + let mut lsp = Lsp::start(root.path()); + lsp.open(&caller_path, CALLER); + let client = mcp_client(root.path()).await; + let initial_card = wait_symbol_status(&client, "ok").await; + assert_eq!(initial_card["status"], "ok"); + + let before = lsp.request( + 2, + "textDocument/definition", + json!({"textDocument": {"uri": caller_uri.clone()}, "position": {"line": 2, "character": 27}}), + ); + assert!(before["error"].is_null() && !before["result"].is_null(), "initial target: {before}"); + + std::fs::remove_file(&target_path).expect("remove target module"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": target_uri.clone(), "type": 3}]} + })); + let mut removed = None; + for id in 3..=10 { + let response = lsp.request( + id, + "textDocument/definition", + json!({"textDocument": {"uri": caller_uri.clone()}, "position": {"line": 2, "character": 27}}), + ); + assert!(response["error"].is_null(), "missing target is a semantic miss: {response}"); + if response["result"].is_null() { + removed = Some(response); + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + let removed = removed.expect("the removed target disappears within the retry budget"); + assert!(removed["result"].is_null(), "removed target has no definition: {removed}"); + let missing_card = wait_symbol_status(&client, "not_found").await; + assert_eq!(missing_card["status"], "not_found"); + + std::fs::write( + &target_path, + include_str!("fixtures/shared_workspace_analysis/CommonModules/ОбщийМодуль/Ext/Module.bsl"), + ) + .expect("recreate target module"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": target_uri.clone(), "type": 1}]} + })); + let mut recreated = None; + for id in 11..=18 { + let response = lsp.request( + id, + "textDocument/definition", + json!({"textDocument": {"uri": caller_uri.clone()}, "position": {"line": 2, "character": 27}}), + ); + assert!(response["error"].is_null(), "recreated target request: {response}"); + if !response["result"].is_null() { + recreated = Some(response); + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + let recreated = recreated.expect("the recreated target returns within the retry budget"); + assert!(recreated["error"].is_null(), "recreated target request: {recreated}"); + assert!(!recreated["result"].is_null(), "recreated target is resolvable: {recreated}"); + let location = + if recreated["result"].is_array() { &recreated["result"][0] } else { &recreated["result"] }; + assert_eq!(location["uri"], target_uri.as_str()); + let recreated_card = wait_symbol_status(&client, "ok").await; + assert_eq!(recreated_card["status"], "ok"); + client.cancel().await.ok(); + assert!(lsp.shutdown(19).success(), "LSP process exits cleanly"); +} diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis/mcp_metrics.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis/mcp_metrics.rs new file mode 100644 index 000000000..2502b2dde --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis/mcp_metrics.rs @@ -0,0 +1,2268 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver}, + Arc, Mutex, OnceLock, +}; +use std::time::{Duration, Instant}; + +use rmcp::model::CallToolRequestParams; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Value}; +use tokio::process::{Child as TokioChild, Command as TokioCommand}; + +use super::args; + +type Client = RunningService; + +fn metric_binary_path() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY + .get_or_init(|| { + let path = std::env::var_os("BSL_ANALYZER_BENCH_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_bsl-analyzer-app"))); + path.canonicalize().expect("resolve shared MCP/LSP measurement binary") + }) + .as_path() +} + +const FIXTURE_CALLER: &str = "CommonModules/Потребитель/Ext/Module.bsl"; +const FIXTURE_TARGET: &str = "CommonModules/ОбщийМодуль/Ext/Module.bsl"; +const PERF_SHADOW_MANIFEST_HASH: &str = + "e3a61e08c4c48841da096794968ddf04959e096bbfcc38b516ec64c74c14f574"; +const CORPUS_CALLER: &str = "CommonModules/Module0001/Ext/Module.bsl"; +const CORPUS_TARGET: &str = "CommonModules/Module0999/Ext/Module.bsl"; +const SCOPE_CALLER: &str = "CommonModules/ScopePerfCaller/Ext/Module.bsl"; +const CORPUS_MANIFEST_HASH: &str = + "b3dbad0f1053db327ef18840db3388bb31beb50205e3b4f7d2dccc42a95df7c9"; +const METRIC_CYCLES: usize = 30; +const RUNS_PER_GROUP: usize = 5; + +fn require_release_profile(debug_assertions: bool) { + assert!(!debug_assertions, "MCP workspace metrics require a release-profile binary"); +} + +#[test] +fn measurement_profile_guard_accepts_release_and_rejects_debug() { + require_release_profile(false); + assert!(std::panic::catch_unwind(|| require_release_profile(true)).is_err()); +} + +struct GroupSelection { + keys: BTreeSet, + requested: String, + partial: bool, +} + +impl GroupSelection { + fn from_env() -> Self { + Self::parse(&std::env::var("BSL_ANALYZER_BENCH_GROUPS").unwrap_or_default()) + } + + fn parse(requested: &str) -> Self { + let requested = requested.to_owned(); + let partial = !requested.trim().is_empty(); + let mut keys = BTreeSet::new(); + if partial { + for selector in requested.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let parts: Vec<_> = selector.split('/').collect(); + assert_eq!(parts.len(), 3, "invalid benchmark group selector: {selector}"); + assert!( + matches!(parts[0], "perf-shadow" | "corpus-1000-v4"), + "unknown benchmark dataset in selector: {selector}" + ); + assert!( + matches!(parts[1], "cold" | "warm" | "scope"), + "unknown benchmark group in selector: {selector}" + ); + let (first, last) = if let Some((first, last)) = parts[2].split_once('-') { + ( + first + .parse::() + .unwrap_or_else(|_| panic!("invalid group range: {selector}")), + last.parse::() + .unwrap_or_else(|_| panic!("invalid group range: {selector}")), + ) + } else { + let index = parts[2] + .parse::() + .unwrap_or_else(|_| panic!("invalid group range: {selector}")); + (index, index) + }; + assert!( + first > 0 && first <= last && last <= RUNS_PER_GROUP, + "group range must be within 1..={RUNS_PER_GROUP}: {selector}" + ); + for index in first..=last { + keys.insert(format!("{}/{}/{index}", parts[0], parts[1])); + } + } + assert!(!keys.is_empty(), "benchmark group selection is empty"); + } else { + for dataset in ["perf-shadow", "corpus-1000-v4"] { + for group in ["cold", "warm", "scope"] { + for index in 1..=RUNS_PER_GROUP { + keys.insert(format!("{dataset}/{group}/{index}")); + } + } + } + } + Self { keys, requested, partial } + } + + fn contains(&self, dataset: &str, group: &str, index: usize) -> bool { + self.keys.contains(&format!("{dataset}/{group}/{index}")) + } + + fn count(&self, group: &str, dataset: Option<&str>) -> usize { + self.keys + .iter() + .filter(|key| { + let mut parts = key.split('/'); + let key_dataset = parts.next().unwrap_or_default(); + let key_group = parts.next().unwrap_or_default(); + key_group == group && dataset.is_none_or(|expected| key_dataset == expected) + }) + .count() + } + + fn labels(&self) -> Vec<&str> { + self.keys.iter().map(String::as_str).collect() + } +} + +#[test] +fn group_selection_covers_the_requested_partial_and_full_sets() { + let selected = GroupSelection::parse( + "perf-shadow/scope/2-5;corpus-1000-v4/cold/1-5;corpus-1000-v4/warm/1-5;corpus-1000-v4/scope/1-5", + ); + assert!(selected.partial); + assert_eq!(selected.keys.len(), 19); + assert_eq!(selected.count("scope", Some("perf-shadow")), 4); + assert_eq!(selected.count("cold", Some("corpus-1000-v4")), 5); + assert_eq!(selected.count("warm", Some("corpus-1000-v4")), 5); + assert_eq!(selected.count("scope", Some("corpus-1000-v4")), 5); + assert!(!selected.contains("perf-shadow", "cold", 1)); + assert!(!selected.contains("perf-shadow", "warm", 5)); + assert!(!selected.contains("perf-shadow", "scope", 1)); + + let full = GroupSelection::parse(""); + assert!(!full.partial); + assert_eq!(full.keys.len(), 30); + assert!(full.contains("perf-shadow", "scope", 1)); + assert!(full.contains("corpus-1000-v4", "warm", 5)); + + assert!(std::panic::catch_unwind(|| GroupSelection::parse("unknown/scope/1-5")).is_err()); + assert!(std::panic::catch_unwind(|| GroupSelection::parse("perf-shadow/scope/1-6")).is_err()); +} + +#[derive(Clone, Copy)] +struct Dataset { + name: &'static str, + caller_path: &'static str, + symbol: &'static str, + global_symbol: Option<&'static str>, + global_xml_path: &'static str, + global_bare_call: &'static str, + diagnostic_name: &'static str, + target_path: &'static str, + target_function: &'static str, +} + +const FIXTURE_DATASET: Dataset = Dataset { + name: "perf-shadow", + caller_path: FIXTURE_CALLER, + symbol: "ОбщийМодуль.ЧисловоеЗначение", + global_symbol: None, + global_xml_path: "CommonModules/ОбщийМодуль.xml", + global_bare_call: "ЧисловоеЗначение", + diagnostic_name: "НетТакойПроцедуры", + target_path: FIXTURE_TARGET, + target_function: "ЧисловоеЗначение", +}; + +const CORPUS_DATASET: Dataset = Dataset { + name: "corpus-1000-v4", + caller_path: CORPUS_CALLER, + symbol: "Module0999.Значение", + global_symbol: Some("Module0000.BenchmarkGlobalCall"), + global_xml_path: "CommonModules/Module0000.xml", + global_bare_call: "BenchmarkGlobalCall", + diagnostic_name: "НетТакойПроцедуры", + target_path: CORPUS_TARGET, + target_function: "Значение", +}; + +#[derive(serde::Serialize)] +struct ProcessRun { + dataset: &'static str, + dataset_manifest_sha256: Option<&'static str>, + cache: &'static str, + run: usize, + mcp_pid: u32, + lsp_pid: u32, + lazy_control_requests_us: u128, + process_to_handshake_us: u128, + explicit_diagnostics_status_us: u128, + explicit_graph_status_us: u128, + diagnostics_status_after_static_controls: Value, + graph_status_after_static_controls: Value, + handshake_to_resident_result_us: u128, + handshake_to_graph_ready_us: u128, + graph_overview_us: u128, + lsp_initialize_and_open_us: u128, + mcp_hwm_kib_before_cleanup: u64, + lsp_hwm_kib_before_cleanup: u64, + rss: Vec, +} + +#[derive(Clone, serde::Serialize)] +struct PairRssSample { + elapsed_us: u128, + sequential_read_skew_us: u128, + mcp_rss_kib: u64, + mcp_hwm_kib: u64, + lsp_rss_kib: u64, + lsp_hwm_kib: u64, + pair_rss_kib: u64, +} + +#[derive(serde::Serialize)] +struct LatencySample { + dataset: &'static str, + cache_group: &'static str, + run: usize, + kind: &'static str, + iteration: usize, + expected: String, + elapsed_us: u128, + #[serde(skip_serializing_if = "Option::is_none")] + lsp_client_poll_attempts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + lsp_content_modified_interruptions: Option, +} + +#[derive(serde::Serialize)] +struct ScopeRun { + dataset: &'static str, + group: usize, + cycle: usize, + excluded_module: String, + old_mcp_pid: u32, + new_mcp_pid: u32, + lsp_pid: u32, + old_scope_symbol: String, + new_scope_symbol_status: String, + retained_symbol_status: String, + global_symbol_status: Option, + lsp_old_composition_poll_attempts: usize, + lsp_old_composition_content_modified_interruptions: usize, + lsp_new_composition_poll_attempts: usize, + lsp_new_composition_content_modified_interruptions: usize, + config_to_old_mcp_retired_us: u128, + config_to_lsp_new_composition_us: u128, + config_to_mcp_reconnected_us: u128, + old_mcp_stderr: String, + reconnected_mcp_stderr: String, + old_pair_rss: Vec, + reconnected_pair_rss: Vec, +} + +struct DefinitionPoll { + elapsed_us: u128, + attempts: usize, + content_modified_interruptions: usize, +} + +#[derive(serde::Serialize)] +struct EffectiveSettingsRun { + dataset: &'static str, + dataset_manifest_sha256: &'static str, + cache_group: &'static str, + run: usize, + cycle: usize, + mcp_pid: u32, + lsp_pid: u32, + diagnostic_code: &'static str, + diagnostic_name: &'static str, + retained_symbol: &'static str, + retained_symbol_path: String, + source_identity_unchanged: bool, + config_to_disabled_absent_us: u128, + config_to_restored_present_us: u128, +} + +struct McpChild { + process: TokioChild, + client: Client, + stderr: Arc>, + stderr_thread: Option>, + pid: u32, +} + +impl McpChild { + async fn start(workspace: &Path, cache: &Path) -> Self { + std::fs::create_dir_all(cache).expect("owned MCP cache directory"); + let mut process = TokioCommand::new(metric_binary_path()) + .args(["mcp", "serve", "--profile", "workspace", "--mode", "stdio", "-s"]) + .arg(workspace) + .arg("--cache-dir") + .arg(cache) + .current_dir(workspace) + .env("XDG_CONFIG_HOME", cache.join("xdg-config")) + .env("XDG_CACHE_HOME", cache.join("xdg-cache")) + .env("XDG_STATE_HOME", cache.join("xdg-state")) + .env("XDG_DATA_HOME", cache.join("xdg-data")) + .env_remove("BSL_CACHE_DIR") + .env_remove("XDG_RUNTIME_DIR") + .env_remove("BSL_PROFILE") + .env_remove("BSL_LOG_FILE") + .env_remove("BSL_MCP_DAEMON_LOG") + .env_remove("EMBEDDING_URL") + .env_remove("EMBEDDING_MODEL") + .env_remove("EMBEDDING_DIM") + .env_remove("EMBEDDING_API_KEY") + .env_remove("EMBEDDING_PROVIDER") + .env_remove("EMBEDDING_QUERY_PREFIX") + .env_remove("EMBEDDING_DOCUMENT_PREFIX") + .kill_on_drop(true) + .env("BSL_MCP_BROKER", "0") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start direct stdio MCP backend"); + let pid = process.id().expect("owned MCP child PID"); + let stdout = process.stdout.take().expect("MCP stdout pipe"); + let stdin = process.stdin.take().expect("MCP stdin pipe"); + let stderr = Arc::new(Mutex::new(String::new())); + let stderr_thread = { + let stderr_text = stderr.clone(); + let mut reader = + tokio::io::BufReader::new(process.stderr.take().expect("MCP stderr pipe")); + tokio::spawn(async move { + let mut line = String::new(); + while tokio::io::AsyncBufReadExt::read_line(&mut reader, &mut line) + .await + .unwrap_or(0) + > 0 + { + stderr_text.lock().expect("MCP stderr lock").push_str(&line); + line.clear(); + } + }) + }; + let client = ().serve((stdout, stdin)).await.expect("MCP initialize handshake"); + Self { process, client, stderr, stderr_thread: Some(stderr_thread), pid } + } + + async fn stop(mut self) { + drop(self.client); + if self.process.try_wait().expect("check MCP child").is_none() { + let _ = self.process.kill().await; + } + let _ = self.process.wait().await; + if let Some(thread) = self.stderr_thread.take() { + let _ = thread.await; + } + } +} + +struct OwnedLsp { + child: Child, + stdin: ChildStdin, + messages: Receiver, + next_id: u64, + version: i32, +} + +impl OwnedLsp { + fn start(workspace: &Path, cache: &Path, caller: &str, caller_text: &str) -> Self { + Self::start_inner(workspace, cache, caller, caller_text, None).0 + } + + fn start_with_pair_sampler( + workspace: &Path, + cache: &Path, + caller: &str, + caller_text: &str, + mcp_pid: u32, + ) -> (Self, PairSampler) { + let (lsp, sampler) = + Self::start_inner(workspace, cache, caller, caller_text, Some(mcp_pid)); + (lsp, sampler.expect("paired sampler for launched MCP/LSP processes")) + } + + fn start_inner( + workspace: &Path, + cache: &Path, + caller: &str, + caller_text: &str, + mcp_pid: Option, + ) -> (Self, Option) { + std::fs::create_dir_all(cache).expect("owned LSP cache directory"); + let mut child = Command::new(metric_binary_path()) + .arg("lsp") + .current_dir(workspace) + .env("XDG_CONFIG_HOME", cache.join("lsp-xdg-config")) + .env("XDG_CACHE_HOME", cache.join("lsp-xdg-cache")) + .env("XDG_STATE_HOME", cache.join("lsp-xdg-state")) + .env("XDG_DATA_HOME", cache.join("lsp-xdg-data")) + .env_remove("BSL_CACHE_DIR") + .env_remove("BSL_PROFILE") + .env_remove("BSL_LOG") + .env_remove("BSL_LOG_FILE") + .env_remove("XDG_RUNTIME_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("start owned Content-Length LSP peer"); + let sampler = mcp_pid.map(|pid| PairSampler::start(pid, child.id())); + let stdin = child.stdin.take().expect("LSP stdin"); + let stdout = child.stdout.take().expect("LSP stdout"); + let (sender, messages) = mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length = None; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).ok().filter(|length| *length > 0).is_none() { + return; + } + if header == "\r\n" { + break; + } + if let Some(value) = header.strip_prefix("Content-Length:") { + content_length = value.trim().parse::().ok(); + } + } + let Some(content_length) = content_length else { return }; + let mut body = vec![0; content_length]; + if reader.read_exact(&mut body).is_err() { + return; + } + if let Ok(message) = serde_json::from_slice(&body) { + if sender.send(message).is_err() { + return; + } + } + } + }); + + let mut lsp = Self { child, stdin, messages, next_id: 2, version: 1 }; + let root_uri = lsp_types::Url::from_directory_path(workspace).expect("workspace URI"); + lsp.send(json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"rootUri": root_uri, "capabilities": {}} + })); + let initialized = lsp.wait_response(1); + assert!(initialized["error"].is_null(), "LSP initialize failed: {initialized}"); + lsp.send(json!({"jsonrpc": "2.0", "method": "initialized", "params": {}})); + let uri = lsp_types::Url::from_file_path(workspace.join(caller)).expect("caller URI"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": {"textDocument": {"uri": uri.clone(), "languageId": "bsl", "version": 1, "text": caller_text}} + })); + let published = lsp.wait_message(Duration::from_secs(180), |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == uri.as_str() + }); + assert!( + published["params"]["diagnostics"].as_array().is_some_and(|items| { + items.iter().any(|diagnostic| diagnostic["code"] == "UnresolvedBareCall") + }), + "LSP peer did not publish its known diagnostic: {published}" + ); + (lsp, sampler) + } + + fn send(&mut self, message: Value) { + let body = message.to_string(); + write!(self.stdin, "Content-Length: {}\r\n\r\n{}", body.len(), body) + .expect("write LSP Content-Length frame"); + self.stdin.flush().expect("flush LSP frame"); + } + + fn wait_message(&self, timeout: Duration, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "LSP Content-Length response timed out"); + let message = self.messages.recv_timeout(remaining).expect("LSP response channel"); + if predicate(&message) { + return message; + } + } + } + + fn wait_response(&self, id: u64) -> Value { + self.wait_message(Duration::from_secs(180), |message| { + message["id"].as_u64() == Some(id) && message["method"].is_null() + }) + } + + fn wait_response_until(&self, id: u64, deadline: Instant) -> Value { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "LSP response {id} exceeded its polling deadline"); + let message = self + .messages + .recv_timeout(remaining) + .unwrap_or_else(|error| panic!("LSP response {id} timed out: {error}")); + if message["id"].as_u64() == Some(id) && message["method"].is_null() { + return message; + } + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + self.wait_response(id) + } + + fn request_until(&mut self, method: &str, params: Value, deadline: Instant) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + self.wait_response_until(id, deadline) + } + + fn set_caller_text(&mut self, workspace: &Path, caller: &str, text: &str) { + self.version += 1; + let uri = lsp_types::Url::from_file_path(workspace.join(caller)).expect("caller URI"); + self.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didChange", + "params": {"textDocument": {"uri": uri, "version": self.version}, "contentChanges": [{"text": text}]} + })); + } + + fn watched_config(&mut self, workspace: &Path) { + let uri = lsp_types::Url::from_file_path(workspace.join("bsl-analyzer.toml")) + .expect("project config URI"); + self.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{"uri": uri, "type": 2}]} + })); + } + + fn definition_at_until( + &mut self, + caller: &str, + workspace: &Path, + line: u64, + character: u64, + deadline: Instant, + ) -> Value { + let uri = lsp_types::Url::from_file_path(workspace.join(caller)).expect("caller URI"); + self.request_until( + "textDocument/definition", + json!({"textDocument": {"uri": uri}, "position": {"line": line, "character": character}}), + deadline, + ) + } + + fn wait_definition_path_at( + &mut self, + caller: &str, + workspace: &Path, + line: u64, + module: &str, + ) -> DefinitionPoll { + let started = Instant::now(); + let deadline = Instant::now() + Duration::from_secs(90); + let mut attempts = 0; + let mut content_modified_interruptions = 0; + loop { + attempts += 1; + let result = self.definition_at_until(caller, workspace, line, 4, deadline); + if result["error"]["code"].as_i64() == Some(-32801) { + content_modified_interruptions += 1; + assert!( + Instant::now() < deadline, + "LSP definition stayed ContentModified for {attempts} attempts: {result}" + ); + std::thread::sleep(Duration::from_millis(25)); + continue; + } + assert!(result["error"].is_null(), "LSP definition request failed: {result}"); + if result["result"].to_string().contains(module) { + return DefinitionPoll { + elapsed_us: started.elapsed().as_micros(), + attempts, + content_modified_interruptions, + }; + } + assert!( + Instant::now() < deadline, + "LSP did not resolve current scope module: {result}" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn wait_definition_absent_at( + &mut self, + caller: &str, + workspace: &Path, + line: u64, + ) -> DefinitionPoll { + let started = Instant::now(); + let deadline = Instant::now() + Duration::from_secs(120); + let mut attempts = 0; + let mut content_modified_interruptions = 0; + loop { + attempts += 1; + let result = self.definition_at_until(caller, workspace, line, 4, deadline); + if result["error"]["code"].as_i64() == Some(-32801) { + content_modified_interruptions += 1; + assert!( + Instant::now() < deadline, + "LSP definition stayed ContentModified for {attempts} attempts: {result}" + ); + std::thread::sleep(Duration::from_millis(25)); + continue; + } + assert!(result["error"].is_null(), "LSP definition request failed: {result}"); + if result["result"].is_null() + || result["result"].as_array().is_some_and(|locations| locations.is_empty()) + { + return DefinitionPoll { + elapsed_us: started.elapsed().as_micros(), + attempts, + content_modified_interruptions, + }; + } + assert!( + Instant::now() < deadline, + "LSP retained a definition outside new scope: {result}" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn shutdown(&mut self) { + let response = self.request("shutdown", Value::Null); + assert!(response["error"].is_null(), "LSP shutdown failed: {response}"); + self.send(json!({"jsonrpc": "2.0", "method": "exit"})); + let _ = self.child.wait().expect("LSP exits after shutdown/exit"); + } +} + +impl Drop for OwnedLsp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +struct PairSampler { + stop: Arc, + samples: Arc>>, + started: Instant, + mcp_pid: u32, + lsp_pid: u32, + thread: Option>, +} + +impl PairSampler { + fn start(mcp_pid: u32, lsp_pid: u32) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let samples = Arc::new(Mutex::new(Vec::new())); + let worker_stop = stop.clone(); + let worker_samples = samples.clone(); + let started = Instant::now(); + let thread = std::thread::spawn(move || { + while !worker_stop.load(Ordering::Relaxed) { + let pair_started = Instant::now(); + if let Some((mcp_rss_kib, mcp_hwm_kib)) = process_memory(mcp_pid) { + let mcp_sampled_at = Instant::now(); + if let Some((lsp_rss_kib, lsp_hwm_kib)) = process_memory(lsp_pid) { + let sequential_read_skew_us = mcp_sampled_at.elapsed().as_micros(); + worker_samples.lock().expect("RSS samples lock").push(PairRssSample { + elapsed_us: pair_started.duration_since(started).as_micros(), + sequential_read_skew_us, + mcp_rss_kib, + mcp_hwm_kib, + lsp_rss_kib, + lsp_hwm_kib, + pair_rss_kib: mcp_rss_kib + lsp_rss_kib, + }); + } + } + std::thread::sleep(Duration::from_millis(25)); + } + }); + Self { stop, samples, started, mcp_pid, lsp_pid, thread: Some(thread) } + } + + fn finish(mut self) -> Vec { + self.stop.store(true, Ordering::Relaxed); + self.thread.take().expect("RSS sampler thread").join().expect("RSS sampler exits"); + let mut samples = self.samples.lock().expect("RSS samples lock").clone(); + let pair_started = Instant::now(); + if let Some((mcp_rss_kib, mcp_hwm_kib)) = process_memory(self.mcp_pid) { + let mcp_sampled_at = Instant::now(); + if let Some((lsp_rss_kib, lsp_hwm_kib)) = process_memory(self.lsp_pid) { + samples.push(PairRssSample { + elapsed_us: pair_started.duration_since(self.started).as_micros(), + sequential_read_skew_us: mcp_sampled_at.elapsed().as_micros(), + mcp_rss_kib, + mcp_hwm_kib, + lsp_rss_kib, + lsp_hwm_kib, + pair_rss_kib: mcp_rss_kib + lsp_rss_kib, + }); + } + } + assert!(!samples.is_empty(), "both owned processes must have /proc VmRSS samples"); + samples + } +} + +impl Drop for PairSampler { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn process_memory(pid: u32) -> Option<(u64, u64)> { + let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?; + let mut rss = None; + let mut hwm = None; + for line in status.lines() { + if let Some(value) = line.strip_prefix("VmRSS:") { + rss = value.split_whitespace().next()?.parse().ok(); + } else if let Some(value) = line.strip_prefix("VmHWM:") { + hwm = value.split_whitespace().next()?.parse().ok(); + } + } + Some((rss?, hwm?)) +} + +fn dataset_manifest_sha256(dataset: Dataset) -> &'static str { + if dataset.name == FIXTURE_DATASET.name { + PERF_SHADOW_MANIFEST_HASH + } else { + CORPUS_MANIFEST_HASH + } +} + +fn has_known_diagnostic(result: &Value, dataset: Dataset) -> bool { + has_unresolved_bare_call(result, dataset.diagnostic_name) +} + +fn has_unresolved_bare_call(result: &Value, name: &str) -> bool { + result["result"]["findings"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| { + finding["code"] == "UnresolvedBareCall" + && finding["message"].as_str().is_some_and(|message| message.contains(name)) + }) + }) +} + +async fn wait_diagnostic_present(client: &Client, dataset: Dataset) -> Value { + wait_diagnostic_named_present(client, dataset, dataset.diagnostic_name).await +} + +async fn wait_diagnostic_named_present(client: &Client, dataset: Dataset, name: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + loop { + let result = call( + client, + "diagnostics", + args(&[ + ("action", json!("file")), + ("path", json!(dataset.caller_path)), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]), + ) + .await; + if result["status"] != "loading" && has_unresolved_bare_call(&result, name) { + return result; + } + assert!( + tokio::time::Instant::now() < deadline, + "known {} diagnostic did not become present: {result}", + name + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +fn perf_shadow_fixture() -> tempfile::TempDir { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source = + repo.join("crates/bsl-analyzer/tests/fixtures/shared_workspace_analysis/perf-shadow"); + let manifest: Value = + serde_json::from_slice( + &std::fs::read(repo.join( + ".artifacts/issue384-implementation/baseline/shared-perf-shadow-manifest.json", + )) + .expect("read pinned performance shadow manifest"), + ) + .expect("parse performance shadow manifest"); + assert_eq!(manifest["workspace_sha256"], PERF_SHADOW_MANIFEST_HASH); + + let workspace = tempfile::tempdir().expect("performance shadow workspace"); + for entry in walkdir::WalkDir::new(&source) { + let entry = entry.expect("performance shadow input entry"); + let relative = entry.path().strip_prefix(&source).expect("shadow relative path"); + let target = workspace.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("create shadow workspace directory"); + } else { + std::fs::create_dir_all(target.parent().expect("shadow parent")) + .expect("create shadow parent"); + std::fs::copy(entry.path(), &target).expect("copy pinned performance shadow input"); + } + } + let files = manifest["files"].as_array().expect("performance shadow manifest files"); + assert_eq!(files.len(), 5, "pinned performance shadow file count"); + for file in files { + let relative = file["path"].as_str().expect("shadow manifest path"); + let bytes = std::fs::read(workspace.path().join(relative)).expect("copied shadow file"); + assert_eq!( + bytes.len(), + file["bytes"].as_u64().unwrap() as usize, + "shadow bytes: {relative}" + ); + } + workspace +} + +fn corpus_1000_v4() -> tempfile::TempDir { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".artifacts/issue384-implementation/baseline/corpus-1000-v4"); + let manifest: Value = serde_json::from_slice( + &std::fs::read(source.join("input-manifest.json")).expect("read pinned corpus manifest"), + ) + .expect("parse pinned corpus manifest"); + assert_eq!(manifest["module_count"], 1000); + assert_eq!(manifest["workspace_sha256"], CORPUS_MANIFEST_HASH); + + let workspace = tempfile::tempdir().expect("1000-module workspace"); + for entry in walkdir::WalkDir::new(&source) + .into_iter() + .filter_entry(|entry| entry.file_name() != "input-manifest.json") + { + let entry = entry.expect("corpus input entry"); + let relative = entry.path().strip_prefix(&source).expect("corpus relative path"); + let target = workspace.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("create corpus directory"); + } else { + std::fs::create_dir_all(target.parent().expect("corpus parent")) + .expect("create corpus parent"); + std::fs::copy(entry.path(), target).expect("copy pinned corpus input"); + } + } + + let listed = manifest["files"].as_array().expect("manifest file list"); + assert_eq!(listed.len(), 2002, "pinned v4 corpus manifest file count"); + for file in listed { + let relative = file["path"].as_str().expect("manifest relative path"); + let bytes = std::fs::read(workspace.path().join(relative)).expect("copied corpus file"); + assert_eq!( + bytes.len(), + file["bytes"].as_u64().unwrap() as usize, + "corpus bytes: {relative}" + ); + } + workspace +} + +fn clone_workspace(source: &Path) -> tempfile::TempDir { + let target = tempfile::tempdir().expect("cloned workspace"); + for entry in walkdir::WalkDir::new(source) { + let entry = entry.expect("workspace clone entry"); + let relative = entry.path().strip_prefix(source).expect("workspace-relative path"); + let destination = target.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&destination).expect("create cloned workspace directory"); + } else { + std::fs::create_dir_all(destination.parent().expect("clone parent")) + .expect("create cloned workspace parent"); + std::fs::copy(entry.path(), destination).expect("copy workspace input"); + } + } + target +} + +fn reset_workspace(target: &Path, source: &Path) { + for entry in std::fs::read_dir(target).expect("list measured workspace") { + let path = entry.expect("measured workspace entry").path(); + if path.is_dir() { + std::fs::remove_dir_all(path).expect("clear measured workspace directory"); + } else { + std::fs::remove_file(path).expect("clear measured workspace file"); + } + } + for entry in walkdir::WalkDir::new(source) { + let entry = entry.expect("source reset entry"); + let relative = entry.path().strip_prefix(source).expect("reset relative path"); + let destination = target.join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&destination).expect("create reset workspace directory"); + } else { + std::fs::create_dir_all(destination.parent().expect("reset parent")) + .expect("create reset parent"); + std::fs::copy(entry.path(), destination).expect("restore workspace input"); + } + } +} + +fn tree_blake3(root: &Path, excluded_file: Option<&str>) -> String { + let mut files = walkdir::WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .filter(|entry| { + excluded_file.is_none_or(|excluded| entry.file_name().to_str() != Some(excluded)) + }) + .collect::>(); + files.sort_by_key(|entry| entry.path().to_path_buf()); + let mut hasher = blake3::Hasher::new(); + for entry in files { + let relative = entry.path().strip_prefix(root).expect("tree-relative path"); + hasher.update(relative.to_string_lossy().as_bytes()); + hasher.update(&[0]); + hasher.update(&std::fs::read(entry.path()).expect("read tree hash input")); + hasher.update(&[0]); + } + hasher.finalize().to_hex().to_string() +} + +fn actual_hashes(dataset: Dataset, input: &Path) -> Value { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let binary = metric_binary_path(); + let fixture_manifest = + repo.join(".artifacts/issue384-implementation/baseline/shared-perf-shadow-manifest.json"); + let corpus_manifest = + repo.join(".artifacts/issue384-implementation/baseline/corpus-1000-v4/input-manifest.json"); + static SOURCE_HASH: OnceLock = OnceLock::new(); + let source_hash = SOURCE_HASH.get_or_init(|| tree_blake3(&repo.join("crates"), None)); + let mut build_inputs = blake3::Hasher::new(); + for path in ["Cargo.toml", "Cargo.lock"] { + build_inputs.update(path.as_bytes()); + build_inputs.update(&[0]); + build_inputs.update(&std::fs::read(repo.join(path)).expect("read build input")); + build_inputs.update(&[0]); + } + let harness_hash = [ + "crates/bsl-analyzer/tests/shared_workspace_analysis.rs", + "crates/bsl-analyzer/tests/shared_workspace_analysis/mcp_metrics.rs", + ] + .into_iter() + .map(|path| { + let bytes = std::fs::read(repo.join(path)).expect("read actual harness source"); + blake3::hash(&bytes).to_hex().to_string() + }) + .collect::>(); + json!({ + "source_tree_blake3_crates": source_hash, + "build_manifests_blake3": build_inputs.finalize().to_hex().to_string(), + "harness_file_blake3": harness_hash, + "binary_path": binary.display().to_string(), + "binary_blake3": blake3::hash(&std::fs::read(binary).expect("read actual MCP/LSP binary")).to_hex().to_string(), + "input_tree_blake3": tree_blake3(input, None), + "input_manifest_sha256_pinned_and_byte_checked": dataset_manifest_sha256(dataset), + "input_manifest_blake3_actual": blake3::hash(&std::fs::read(if dataset.name == FIXTURE_DATASET.name { fixture_manifest } else { corpus_manifest }).expect("read actual input manifest")).to_hex().to_string() + }) +} + +fn new_receipt_path(timer_step_ns: u128, selection: &GroupSelection) -> PathBuf { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = root.join(".artifacts/issue384-implementation/baseline/mcp"); + std::fs::create_dir_all(&output).expect("create owned MCP receipts directory"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let path = output.join(format!("metrics-{}-{stamp}.jsonl", std::process::id())); + let header = json!({ + "record": "header", + "schema_version": 2, + "transport": "direct stdio; actual bsl-analyzer-app PID; BSL_MCP_BROKER=0", + "rss_source": "/proc//status VmRSS and VmHWM; pair sampled every 25 ms; HWM sampled before cleanup", + "latency_unit": "microseconds from std::time::Instant", + "timer_calibration": { + "method": "minimum positive elapsed duration across 1024 immediate Instant measurements", + "observed_step_ns": timer_step_ns, + "recording_granularity_us": 1 + }, + "measurement_groups": if selection.partial { "selected groups only; 30 semantic cycles per selected measured group; this receipt does not claim full-baseline coverage" } else { "five cold and five warm per dataset; prime excluded; 30 semantic request/update/change-restore cycles per measured run; five independent 30-cycle source-scope groups" }, + "requested_group_selection": if selection.partial { selection.requested.as_str() } else { "all" }, + "selected_group_keys": selection.labels(), + "selection_is_partial": selection.partial, + "effective_settings": "30 disable/restore cycles in every cold/warm measured run; source-scope reconnect is a separate five-group series" + }); + let mut file = std::fs::File::create(&path).expect("create incremental MCP receipt"); + serde_json::to_writer(&mut file, &header).expect("write MCP receipt header"); + file.write_all(b"\n").expect("terminate receipt header"); + path +} + +fn append_checkpoint(path: &Path, record: Value) { + let mut file = + std::fs::OpenOptions::new().append(true).open(path).expect("open incremental MCP receipt"); + serde_json::to_writer(&mut file, &record).expect("append MCP receipt record"); + file.write_all(b"\n").expect("terminate MCP receipt record"); + file.sync_data().expect("persist completed MCP measurement receipt"); +} + +fn write_scope_modules(workspace: &Path) { + std::fs::write( + workspace.join("CommonModules/ScopePerfCaller.xml"), + "\nScopePerfCallertruetrue\n", + ) + .expect("write source-scope caller XML module"); + for index in 0..METRIC_CYCLES { + let name = format!("Scope{index:02}"); + let uuid = format!("30000000-0000-0000-0000-{index:012}"); + std::fs::write( + workspace.join("CommonModules").join(format!("{name}.xml")), + format!( + "\n{name}truetrue\n" + ), + ) + .expect("write source-scope XML module"); + let module = workspace.join("CommonModules").join(name).join("Ext"); + std::fs::create_dir_all(&module).expect("create source-scope BSL module"); + std::fs::write( + module.join("Module.bsl"), + format!("Функция Probe() Экспорт\n Возврат {index};\nКонецФункции\n"), + ) + .expect("write source-scope BSL module"); + } +} + +fn scope_caller(base: &str, module: &str) -> (String, u64) { + let mut text = base.trim_end().to_owned(); + text.push_str(&format!( + "\n\nПроцедура ScopePerfCaller() Экспорт\n {module}.Probe();\nКонецПроцедуры\n" + )); + let line = text + .lines() + .position(|line| line.contains(&format!("{module}.Probe"))) + .expect("scope call line") as u64; + (text, line) +} + +fn config_with_exclusions(base: &str, excluded: &[String]) -> String { + let exclusions = excluded + .iter() + .map(|module| format!("\"CommonModules/{module}\"")) + .collect::>() + .join(", "); + let replacement = format!("root = \".\"\nexclude = [{exclusions}]\n"); + let updated = base.replacen("root = \".\"\n", &replacement, 1); + assert_ne!(updated, base, "source config must have exactly the declared root field"); + updated +} + +fn config_with_unresolved_bare_call(base: &str, enabled: bool) -> String { + let mut replacements = 0; + let updated = base + .lines() + .map(|line| { + if line.trim_start().starts_with("UnresolvedBareCall =") { + replacements += 1; + format!("UnresolvedBareCall = {enabled}") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n") + + "\n"; + assert_eq!(replacements, 1, "pinned dataset must declare UnresolvedBareCall once"); + updated +} + +fn source_identity_snapshot(workspace: &Path) -> BTreeMap> { + let mut snapshot = BTreeMap::new(); + for entry in walkdir::WalkDir::new(workspace) { + let entry = entry.expect("source identity walk entry"); + if !entry.file_type().is_file() || entry.file_name() == "bsl-analyzer.toml" { + continue; + } + let relative = entry + .path() + .strip_prefix(workspace) + .expect("source identity relative path") + .to_path_buf(); + snapshot.insert(relative, std::fs::read(entry.path()).expect("read source identity file")); + } + snapshot +} + +fn bsl_identity_snapshot(workspace: &Path) -> BTreeMap> { + let mut snapshot = BTreeMap::new(); + for entry in walkdir::WalkDir::new(workspace) { + let entry = entry.expect("BSL identity walk entry"); + if !entry.file_type().is_file() || entry.path().extension().is_none_or(|ext| ext != "bsl") { + continue; + } + let relative = + entry.path().strip_prefix(workspace).expect("BSL identity relative path").to_path_buf(); + snapshot.insert(relative, std::fs::read(entry.path()).expect("read BSL identity file")); + } + snapshot +} + +async fn call( + client: &Client, + tool: &'static str, + arguments: serde_json::Map, +) -> Value { + client + .call_tool(CallToolRequestParams::new(tool).with_arguments(arguments)) + .await + .unwrap_or_else(|error| panic!("{tool} transport call failed: {error}")) + .structured_content + .unwrap_or_else(|| panic!("{tool} returned no structured content")) +} + +async fn wait_diagnostic(client: &Client, dataset: Dataset) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + loop { + let result = call( + client, + "diagnostics", + args(&[ + ("action", json!("file")), + ("path", json!(dataset.caller_path)), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]), + ) + .await; + if result["status"] != "loading" { + let findings = result["result"]["findings"].as_array().expect("diagnostics findings"); + assert!( + findings.iter().any(|finding| finding["code"] == "UnresolvedBareCall"), + "expected fixture diagnostic in resident-ready result: {result}" + ); + assert!( + findings.iter().any(|finding| { + finding["code"] == "UnresolvedBareCall" + && finding["message"] + .as_str() + .is_some_and(|message| message.contains(dataset.diagnostic_name)) + }), + "expected exact {} diagnostic in resident-ready result: {result}", + dataset.diagnostic_name + ); + return result; + } + assert!(tokio::time::Instant::now() < deadline, "diagnostics never became resident-ready"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_diagnostic_absent(client: &Client, dataset: Dataset) -> Value { + wait_diagnostic_named_absent(client, dataset, dataset.diagnostic_name).await +} + +async fn wait_diagnostic_named_absent(client: &Client, dataset: Dataset, name: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + loop { + let result = call( + client, + "diagnostics", + args(&[ + ("action", json!("file")), + ("path", json!(dataset.caller_path)), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]), + ) + .await; + if result["status"] != "loading" { + let findings = result["result"]["findings"] + .as_array() + .expect("diagnostics findings while effective setting is disabled"); + if !findings.iter().any(|finding| { + finding["code"] == "UnresolvedBareCall" + && finding["message"].as_str().is_some_and(|message| message.contains(name)) + }) { + return result; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "effective setting did not remove {} diagnostic: {result}", + name + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_symbol_type(client: &Client, symbol: &str, expected: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + loop { + let result = call(client, "symbol_info", args(&[("symbol", json!(symbol))])).await; + if result["status"] == "ok" && result["return_type"] == expected { + return result; + } + assert!( + tokio::time::Instant::now() < deadline, + "expected return_type {expected}: {result}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_symbol_status(client: &Client, symbol: &str, expected: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + loop { + let result = call(client, "symbol_info", args(&[("symbol", json!(symbol))])).await; + if result["status"] == expected { + return result; + } + assert!( + tokio::time::Instant::now() < deadline, + "expected symbol status {expected} for {symbol}: {result}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_graph_ready(client: &Client, started: Instant) -> (u128, u128, Value) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(300); + loop { + let result = call(client, "graph", args(&[("action", json!("status"))])).await; + if result["state"] == "ready" { + let ready_us = started.elapsed().as_micros(); + let overview_started = Instant::now(); + let overview = call(client, "graph", args(&[("action", json!("overview"))])).await; + assert!( + overview["result"].is_object(), + "graph overview must contain an expected result: {overview}" + ); + return (ready_us, overview_started.elapsed().as_micros(), overview); + } + assert!(tokio::time::Instant::now() < deadline, "graph never became ready: {result}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_scope_retirement(mcp: &mut McpChild, changed_at: Instant) -> (u128, ExitStatus) { + const SCOPE_STOP: &str = "workspace source composition changed; restart/reconnect MCP"; + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + let mut status = None; + loop { + if status.is_none() { + status = mcp.process.try_wait().expect("poll old-scope MCP process"); + } + let stderr = mcp.stderr.lock().expect("MCP stderr lock").clone(); + if let Some(exit) = status.as_ref() { + if stderr.contains(SCOPE_STOP) { + assert!(exit.success(), "old-scope MCP retirement exits successfully: {exit}"); + assert_eq!( + stderr.matches(SCOPE_STOP).count(), + 1, + "one source-scope retirement reason" + ); + return ( + changed_at.elapsed().as_micros(), + status.take().expect("retired child status"), + ); + } + } + assert!( + tokio::time::Instant::now() < deadline, + "old MCP did not retire with scope reason; stderr={stderr}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn scope_config_update_series( + workspace: &Path, + dataset: Dataset, + group: usize, + receipt_path: &Path, + input_hashes: &Value, +) -> (Vec, Vec) { + write_scope_modules(workspace); + let caller_base = std::fs::read_to_string(workspace.join(dataset.caller_path)) + .expect("read source-scope caller fixture"); + let (initial_caller, _) = scope_caller(&caller_base, "Scope00"); + let scope_caller_path = workspace.join(SCOPE_CALLER); + std::fs::create_dir_all(scope_caller_path.parent().expect("scope caller parent")) + .expect("create source-scope caller folder"); + std::fs::write(&scope_caller_path, &initial_caller).expect("write scope caller"); + let config_path = workspace.join("bsl-analyzer.toml"); + let base_config = std::fs::read_to_string(&config_path).expect("read source config"); + let cache = tempfile::tempdir().expect("owned source-scope cache"); + let mut lsp = OwnedLsp::start(workspace, cache.path(), SCOPE_CALLER, &initial_caller); + let lsp_pid = lsp.child.id(); + let mut excluded = Vec::with_capacity(METRIC_CYCLES); + let mut runs = Vec::with_capacity(METRIC_CYCLES); + let mut samples = Vec::with_capacity(METRIC_CYCLES * 3); + + for cycle in 0..METRIC_CYCLES { + let name = format!("Scope{cycle:02}"); + let symbol = format!("{name}.Probe"); + let (caller, call_line) = scope_caller(&caller_base, &name); + std::fs::write(&scope_caller_path, &caller).expect("refresh saved scope caller"); + lsp.set_caller_text(workspace, SCOPE_CALLER, &caller); + let old_lsp_poll = lsp.wait_definition_path_at(SCOPE_CALLER, workspace, call_line, &name); + + let old_started = Instant::now(); + let mut old = McpChild::start(workspace, cache.path()).await; + let old_pid = old.pid; + let old_sampler = PairSampler::start(old_pid, lsp_pid); + let _diagnostic = wait_diagnostic(&old.client, dataset).await; + let _retained = wait_symbol_type(&old.client, dataset.symbol, "Число").await; + let old_scope = wait_symbol_status(&old.client, &symbol, "ok").await; + assert_eq!(old_scope["status"], "ok", "old composition includes {symbol}: {old_scope}"); + if let Some(global_symbol) = dataset.global_symbol { + let global = wait_symbol_status(&old.client, global_symbol, "ok").await; + assert_eq!(global["status"], "ok", "old composition includes {global_symbol}"); + } + let _old_ready_us = old_started.elapsed().as_micros(); + + excluded.push(name.clone()); + let new_config = config_with_exclusions(&base_config, &excluded); + let changed_at = Instant::now(); + std::fs::write(&config_path, new_config).expect("apply source-scope config change"); + lsp.watched_config(workspace); + let lsp_future = tokio::task::spawn_blocking({ + let mut lsp = lsp; + let root = workspace.to_path_buf(); + move || { + let poll = lsp.wait_definition_absent_at(SCOPE_CALLER, &root, call_line); + (lsp, poll) + } + }); + let (retired_us, _old_exit) = wait_scope_retirement(&mut old, changed_at).await; + let (updated_lsp, lsp_new_poll) = lsp_future.await.expect("LSP config-refresh task"); + lsp = updated_lsp; + let lsp_new_result_us = lsp_new_poll.elapsed_us; + let old_pair_rss = old_sampler.finish(); + let old_mcp_stderr = old.stderr.lock().expect("old MCP stderr").clone(); + old.stop().await; + + samples.push(LatencySample { + dataset: dataset.name, + cache_group: "scope-reconnect", + run: group, + kind: "lsp-before-config-old-composition", + iteration: cycle, + expected: format!( + "textDocument/definition({symbol}) resolves old composition; attempts={}, ContentModified interruptions={}", + old_lsp_poll.attempts, old_lsp_poll.content_modified_interruptions + ), + elapsed_us: old_lsp_poll.elapsed_us, + lsp_client_poll_attempts: Some(old_lsp_poll.attempts), + lsp_content_modified_interruptions: Some( + old_lsp_poll.content_modified_interruptions, + ), + }); + samples.push(LatencySample { + dataset: dataset.name, + cache_group: "scope-reconnect", + run: group, + kind: "scope-config-to-mcp-retired", + iteration: cycle, + expected: "old stdio process exits successfully with source-composition-changed reason" + .to_owned(), + elapsed_us: retired_us, + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + samples.push(LatencySample { + dataset: dataset.name, + cache_group: "scope-reconnect", + run: group, + kind: "scope-config-to-lsp-new-composition", + iteration: cycle, + expected: format!( + "textDocument/definition({symbol}) returns no location; attempts={}, ContentModified interruptions={}", + lsp_new_poll.attempts, lsp_new_poll.content_modified_interruptions + ), + elapsed_us: lsp_new_result_us, + lsp_client_poll_attempts: Some(lsp_new_poll.attempts), + lsp_content_modified_interruptions: Some( + lsp_new_poll.content_modified_interruptions, + ), + }); + + let reconnected = McpChild::start(workspace, cache.path()).await; + let new_pid = reconnected.pid; + let reconnected_sampler = PairSampler::start(new_pid, lsp_pid); + let _diagnostic = wait_diagnostic(&reconnected.client, dataset).await; + let retained = wait_symbol_type(&reconnected.client, dataset.symbol, "Число").await; + let excluded_result = wait_symbol_status(&reconnected.client, &symbol, "not_found").await; + assert_eq!(retained["status"], "ok", "new scope retained {}: {retained}", dataset.symbol); + assert_eq!(excluded_result["status"], "not_found", "new scope excludes {symbol}"); + let global_status = if let Some(global_symbol) = dataset.global_symbol { + let global = wait_symbol_status(&reconnected.client, global_symbol, "ok").await; + Some(global["status"].as_str().unwrap().to_owned()) + } else { + None + }; + let reconnected_us = changed_at.elapsed().as_micros(); + let reconnected_pair_rss = reconnected_sampler.finish(); + let reconnected_mcp_stderr = + reconnected.stderr.lock().expect("reconnected MCP stderr").clone(); + reconnected.stop().await; + + let mut expected_composition = format!("{}=ok; ", dataset.symbol); + if let Some(global_symbol) = dataset.global_symbol { + expected_composition.push_str(&format!("{global_symbol}=ok; ")); + } + expected_composition.push_str(&format!("{symbol}=not_found")); + samples.push(LatencySample { + dataset: dataset.name, + cache_group: "scope-reconnect", + run: group, + kind: "scope-config-to-mcp-reconnected-composition", + iteration: cycle, + expected: expected_composition, + elapsed_us: reconnected_us, + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + let scope_run = ScopeRun { + dataset: dataset.name, + group, + cycle, + excluded_module: name, + old_mcp_pid: old_pid, + new_mcp_pid: new_pid, + lsp_pid, + old_scope_symbol: symbol, + new_scope_symbol_status: excluded_result["status"].as_str().unwrap().to_owned(), + retained_symbol_status: retained["status"].as_str().unwrap().to_owned(), + global_symbol_status: global_status, + lsp_old_composition_poll_attempts: old_lsp_poll.attempts, + lsp_old_composition_content_modified_interruptions: old_lsp_poll + .content_modified_interruptions, + lsp_new_composition_poll_attempts: lsp_new_poll.attempts, + lsp_new_composition_content_modified_interruptions: lsp_new_poll + .content_modified_interruptions, + config_to_old_mcp_retired_us: retired_us, + config_to_lsp_new_composition_us: lsp_new_result_us, + config_to_mcp_reconnected_us: reconnected_us, + old_mcp_stderr, + reconnected_mcp_stderr, + old_pair_rss, + reconnected_pair_rss, + }; + append_checkpoint( + receipt_path, + json!({ + "record": "source-scope-cycle", + "dataset": dataset.name, + "group": group, + "hashes": input_hashes, + "scope_run": &scope_run, + "semantic_latency_samples": &samples[samples.len() - 3..] + }), + ); + runs.push(scope_run); + } + lsp.shutdown(); + (runs, samples) +} + +fn assert_mcp_stayed_in_same_source_scope(mcp: &mut McpChild, expected_pid: u32) { + assert_eq!(mcp.pid, expected_pid, "effective settings keep the same MCP process"); + let status = mcp.process.try_wait().expect("poll MCP after effective settings reload"); + assert!( + status.is_none(), + "effective setting change must not retire MCP source scope; exit={status:?}, stderr={}", + mcp.stderr.lock().expect("MCP stderr").as_str() + ); + assert!( + !mcp.stderr + .lock() + .expect("MCP stderr") + .contains("workspace source composition changed; restart/reconnect MCP"), + "effective setting change must not signal source-composition retirement" + ); +} + +async fn retained_symbol_path(client: &Client, dataset: Dataset) -> String { + let result = wait_symbol_type(client, dataset.symbol, "Число").await; + let definitions = result["definitions"].as_array().expect("symbol definitions"); + let path = definitions + .iter() + .filter_map(|definition| definition["location"]["path"].as_str()) + .find(|path| path.ends_with(dataset.target_path)) + .unwrap_or_else(|| panic!("symbol identity moved from {}: {result}", dataset.target_path)); + path.to_owned() +} + +async fn effective_settings_update_series( + mcp: &mut McpChild, + lsp_pid: u32, + workspace: &Path, + dataset: Dataset, + cache_group: &'static str, + run: usize, +) -> (Vec, Vec) { + let config_path = workspace.join("bsl-analyzer.toml"); + let original_config = std::fs::read_to_string(&config_path).expect("read effective config"); + let enabled_config = config_with_unresolved_bare_call(&original_config, true); + assert_eq!( + original_config, enabled_config, + "pinned measurement inputs begin with the known diagnostic enabled" + ); + let disabled_config = config_with_unresolved_bare_call(&original_config, false); + let source_identity = source_identity_snapshot(workspace); + let retained_path = retained_symbol_path(&mcp.client, dataset).await; + let mcp_pid = mcp.pid; + let mut runs = Vec::with_capacity(METRIC_CYCLES); + let mut samples = Vec::with_capacity(METRIC_CYCLES * 2); + + for cycle in 0..METRIC_CYCLES { + let before = wait_diagnostic_present(&mcp.client, dataset).await; + assert_mcp_stayed_in_same_source_scope(mcp, mcp_pid); + assert!(has_known_diagnostic(&before, dataset)); + + let disabled_started = Instant::now(); + std::fs::write(&config_path, &disabled_config).expect("disable known diagnostic setting"); + let _disabled = wait_diagnostic_absent(&mcp.client, dataset).await; + let disabled_us = disabled_started.elapsed().as_micros(); + assert_mcp_stayed_in_same_source_scope(mcp, mcp_pid); + let disabled_symbol_path = retained_symbol_path(&mcp.client, dataset).await; + assert_eq!(disabled_symbol_path, retained_path, "symbol identity while rule disabled"); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "effective-settings-to-diagnostic-absent", + iteration: cycle, + expected: format!( + "{} absent; {} remains at {}", + dataset.diagnostic_name, dataset.symbol, retained_path + ), + elapsed_us: disabled_us, + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + + let restored_started = Instant::now(); + std::fs::write(&config_path, &enabled_config).expect("restore known diagnostic setting"); + let restored = wait_diagnostic_present(&mcp.client, dataset).await; + let restored_us = restored_started.elapsed().as_micros(); + assert_mcp_stayed_in_same_source_scope(mcp, mcp_pid); + let restored_symbol_path = retained_symbol_path(&mcp.client, dataset).await; + assert_eq!(restored_symbol_path, retained_path, "symbol identity after rule restore"); + assert!(has_known_diagnostic(&restored, dataset)); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "effective-settings-to-diagnostic-restored-present", + iteration: cycle, + expected: format!( + "{} present; source scope remains on PID {mcp_pid}", + dataset.diagnostic_name + ), + elapsed_us: restored_us, + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + runs.push(EffectiveSettingsRun { + dataset: dataset.name, + dataset_manifest_sha256: dataset_manifest_sha256(dataset), + cache_group, + run, + cycle, + mcp_pid, + lsp_pid, + diagnostic_code: "UnresolvedBareCall", + diagnostic_name: dataset.diagnostic_name, + retained_symbol: dataset.symbol, + retained_symbol_path: retained_path.clone(), + source_identity_unchanged: false, + config_to_disabled_absent_us: disabled_us, + config_to_restored_present_us: restored_us, + }); + } + + assert!( + source_identity_snapshot(workspace) == source_identity, + "effective settings updates must not modify any source input" + ); + for run in &mut runs { + run.source_identity_unchanged = true; + } + (runs, samples) +} + +async fn measured_launch( + workspace: &Path, + cache: &Path, + dataset: Dataset, + cache_kind: &'static str, + run: usize, +) -> (ProcessRun, McpChild, OwnedLsp, PairSampler) { + let mcp_started = Instant::now(); + let mcp = McpChild::start(workspace, cache).await; + let handshake_us = mcp_started.elapsed().as_micros(); + let handshake_at = Instant::now(); + let controls_started = Instant::now(); + let catalog = call(&mcp.client, "diagnostics", args(&[("action", json!("catalog"))])).await; + let diagnostics_schema = + call(&mcp.client, "diagnostics", args(&[("action", json!("schema"))])).await; + let graph_schema = call(&mcp.client, "graph", args(&[("action", json!("schema"))])).await; + let platform = call(&mcp.client, "syntax_help", args(&[("name", json!("Массив"))])).await; + assert!(catalog["entries"].is_array(), "cold diagnostics catalog control: {catalog}"); + assert!( + diagnostics_schema["actions"].is_array(), + "diagnostics cold schema control: {diagnostics_schema}" + ); + assert!(graph_schema["actions"].is_array(), "graph cold schema control: {graph_schema}"); + assert_eq!(platform["schema_version"], "2", "cold platform-card contract: {platform}"); + assert_eq!(platform["kind"], "type", "syntax_help resolves an actual type card: {platform}"); + assert_eq!(platform["name"], "Массив", "syntax_help returns the requested type: {platform}"); + assert!(platform["constructors"].is_array(), "type card constructors: {platform}"); + assert!(platform["methods"].is_array(), "type card methods: {platform}"); + let controls_us = controls_started.elapsed().as_micros(); + + // The source handlers keep schema/catalog static. These are the first explicit + // lifecycle controls and are timed separately because each may start its build. + let diagnostics_status_started = Instant::now(); + let diagnostics_status = + call(&mcp.client, "diagnostics", args(&[("action", json!("status"))])).await; + let diagnostics_status_us = diagnostics_status_started.elapsed().as_micros(); + assert!( + diagnostics_status["state"].is_string() || diagnostics_status["status"].is_string(), + "explicit diagnostics status control must report lifecycle state: {diagnostics_status}" + ); + let graph_status_started = Instant::now(); + let graph_status = call(&mcp.client, "graph", args(&[("action", json!("status"))])).await; + let graph_status_us = graph_status_started.elapsed().as_micros(); + assert!(graph_status["state"].is_string(), "explicit graph status control: {graph_status}"); + + let resident_future = async { + let _diagnostic = wait_diagnostic(&mcp.client, dataset).await; + let _symbol = wait_symbol_type(&mcp.client, dataset.symbol, "Число").await; + if let Some(global_symbol) = dataset.global_symbol { + let _global = wait_symbol_type(&mcp.client, global_symbol, "Число").await; + } + handshake_at.elapsed().as_micros() + }; + let lsp_future = tokio::task::spawn_blocking({ + let root = workspace.to_path_buf(); + let cache = cache.to_path_buf(); + let caller_path = dataset.caller_path; + let mcp_pid = mcp.pid; + move || { + let started = Instant::now(); + let caller_text = + std::fs::read_to_string(root.join(caller_path)).expect("read LSP caller"); + let (lsp, sampler) = OwnedLsp::start_with_pair_sampler( + &root, + &cache, + caller_path, + &caller_text, + mcp_pid, + ); + (lsp, started.elapsed().as_micros(), sampler) + } + }); + let graph_future = wait_graph_ready(&mcp.client, graph_status_started); + let (resident_us, lsp_result, (graph_ready_us, graph_overview_us, _)) = + tokio::join!(resident_future, lsp_future, graph_future); + let (lsp, lsp_us, sampler) = lsp_result.expect("LSP peer start task"); + let lsp_pid = lsp.child.id(); + ( + ProcessRun { + dataset: dataset.name, + dataset_manifest_sha256: Some(dataset_manifest_sha256(dataset)), + cache: cache_kind, + run, + mcp_pid: mcp.pid, + lsp_pid, + lazy_control_requests_us: controls_us, + process_to_handshake_us: handshake_us, + explicit_diagnostics_status_us: diagnostics_status_us, + explicit_graph_status_us: graph_status_us, + diagnostics_status_after_static_controls: diagnostics_status, + graph_status_after_static_controls: graph_status, + handshake_to_resident_result_us: resident_us, + handshake_to_graph_ready_us: graph_ready_us, + graph_overview_us, + lsp_initialize_and_open_us: lsp_us, + mcp_hwm_kib_before_cleanup: 0, + lsp_hwm_kib_before_cleanup: 0, + rss: Vec::new(), + }, + mcp, + lsp, + sampler, + ) +} + +async fn request_samples( + client: &Client, + dataset: Dataset, + cache_group: &'static str, + run: usize, +) -> Vec { + let group_count = 2 + usize::from(dataset.global_symbol.is_some()); + let mut samples = Vec::with_capacity(METRIC_CYCLES * group_count); + for iteration in 0..METRIC_CYCLES * group_count { + let kind = match iteration % group_count { + 0 => "diagnostics", + 1 => "symbol_info", + _ => "global_symbol_info", + }; + let (tool, arguments, expected) = match kind { + "diagnostics" => ( + "diagnostics", + args(&[ + ("action", json!("file")), + ("path", json!(dataset.caller_path)), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]), + "UnresolvedBareCall".to_owned(), + ), + "symbol_info" => ( + "symbol_info", + args(&[("symbol", json!(dataset.symbol))]), + "return_type is Число".to_owned(), + ), + "global_symbol_info" => ( + "symbol_info", + args(&[("symbol", json!(dataset.global_symbol.expect("global symbol")))]), + "global return_type is Число".to_owned(), + ), + _ => unreachable!("defined request group"), + }; + let started = Instant::now(); + let result = if kind == "diagnostics" { + wait_diagnostic_present(client, dataset).await + } else { + call(client, tool, arguments).await + }; + if kind == "diagnostics" { + assert!( + has_known_diagnostic(&result, dataset), + "request sample missed known {} diagnostic: {result}", + dataset.diagnostic_name + ); + } else { + assert_eq!(result["status"], "ok", "request sample missed known symbol: {result}"); + assert_eq!(result["return_type"], "Число", "symbol result: {result}"); + } + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind, + iteration, + expected, + elapsed_us: started.elapsed().as_micros(), + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + } + samples +} + +async fn semantic_update_samples( + client: &Client, + workspace: &Path, + dataset: Dataset, + cache_group: &'static str, + run: usize, +) -> Vec { + let target = workspace.join(dataset.target_path); + let original_target = std::fs::read(&target).expect("read baseline BSL semantic source"); + let mut samples = Vec::with_capacity(METRIC_CYCLES * 4); + for iteration in 0..METRIC_CYCLES { + let source = format!( + "Функция {}() Экспорт\n Возврат \"metric-update-{iteration}\";\nКонецФункции\n", + dataset.target_function + ); + let changed_started = Instant::now(); + std::fs::write(&target, source).expect("write semantic BSL update"); + let result = wait_symbol_type(client, dataset.symbol, "Строка").await; + assert_eq!(result["status"], "ok", "BSL change result: {result}"); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "bsl-update-to-string-result", + iteration, + expected: "symbol return_type is Строка".to_owned(), + elapsed_us: changed_started.elapsed().as_micros(), + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + let restored_started = Instant::now(); + std::fs::write(&target, &original_target).expect("restore semantic BSL source"); + let restored = wait_symbol_type(client, dataset.symbol, "Число").await; + assert_eq!(restored["status"], "ok", "BSL restore result: {restored}"); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "bsl-restore-to-number-result", + iteration, + expected: "symbol return_type is Число".to_owned(), + elapsed_us: restored_started.elapsed().as_micros(), + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + } + + let xml_path = workspace.join(dataset.global_xml_path); + let original_xml = std::fs::read(&xml_path).expect("read pinned Global=true descriptor"); + let original_xml_text = std::str::from_utf8(&original_xml).expect("XML descriptor is UTF-8"); + let bsl_snapshot = bsl_identity_snapshot(workspace); + let baseline = wait_diagnostic_present(client, dataset).await; + assert!(has_known_diagnostic(&baseline, dataset)); + assert!( + !has_unresolved_bare_call(&baseline, dataset.global_bare_call), + "{} resolves while Global is true: {baseline}", + dataset.global_bare_call + ); + let xml_with_global_false = xml_global_setting(original_xml_text, false); + assert_ne!(xml_with_global_false.as_bytes(), original_xml.as_slice()); + for iteration in 0..METRIC_CYCLES { + let started = Instant::now(); + std::fs::write(&xml_path, &xml_with_global_false).expect("disable Global on pinned module"); + let unresolved = + wait_diagnostic_named_present(client, dataset, dataset.global_bare_call).await; + assert!(has_unresolved_bare_call(&unresolved, dataset.global_bare_call)); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "xml-global-false-to-bare-call-diagnostic-present", + iteration, + expected: format!( + "UnresolvedBareCall({}) present after XML Global=false", + dataset.global_bare_call + ), + elapsed_us: started.elapsed().as_micros(), + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + + let restored_started = Instant::now(); + std::fs::write(&xml_path, &original_xml).expect("restore pinned Global=true descriptor"); + let restored = + wait_diagnostic_named_absent(client, dataset, dataset.global_bare_call).await; + assert!(has_known_diagnostic(&restored, dataset)); + samples.push(LatencySample { + dataset: dataset.name, + cache_group, + run, + kind: "xml-global-restore-to-bare-call-diagnostic-absent", + iteration, + expected: format!( + "UnresolvedBareCall({}) absent after restoring XML Global=true", + dataset.global_bare_call + ), + elapsed_us: restored_started.elapsed().as_micros(), + lsp_client_poll_attempts: None, + lsp_content_modified_interruptions: None, + }); + } + assert_eq!( + bsl_identity_snapshot(workspace), + bsl_snapshot, + "XML Global toggles must leave every BSL source byte unchanged" + ); + assert_eq!( + std::fs::read(&xml_path).expect("read restored pinned XML descriptor"), + original_xml, + "each XML cycle must restore the exact pinned Global=true bytes" + ); + samples +} + +fn xml_global_setting(xml: &str, enabled: bool) -> String { + assert_eq!(xml.matches("true").count(), 1, "pinned Global=true XML"); + xml.replace( + "true", + if enabled { "true" } else { "false" }, + ) +} + +fn instant_resolution_ns() -> u128 { + (0..1024) + .map(|_| { + let started = Instant::now(); + loop { + let elapsed = started.elapsed().as_nanos(); + if elapsed > 0 { + break elapsed; + } + } + }) + .min() + .expect("Instant calibration samples") +} + +fn write_receipt( + runs: &[ProcessRun], + scope_runs: &[ScopeRun], + effective_settings_runs: &[EffectiveSettingsRun], + samples: &[LatencySample], + measured_instant_step_ns: u128, + selection: &GroupSelection, +) { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = root.join(".artifacts/issue384-implementation/baseline/mcp"); + std::fs::create_dir_all(&output).expect("create owned MCP receipts directory"); + let receipt = json!({ + "schema_version": 1, + "transport": "direct stdio; actual bsl-analyzer-app PID; no broker proxy", + "rss_source": "/proc//status VmRSS and VmHWM; pair sampled every 25 ms; sequential read skew recorded per sample", + "latency_unit": "microseconds from std::time::Instant; pair RSS values are near-simultaneous process samples with recorded read skew, not summed HWM", + "timer_calibration": { + "method": "minimum positive elapsed duration across 1024 immediate Instant measurements", + "observed_step_ns": measured_instant_step_ns, + "recording_granularity_us": 1 + }, + "measurement_groups": if selection.partial { "selected groups only; 30 semantic cycles per selected measured group; this receipt does not claim full-baseline coverage" } else { "five cold and five warm per dataset; prime excluded; 30 semantic request/update/change-restore cycles per measured run; five independent 30-cycle source-scope groups" }, + "requested_group_selection": if selection.partial { selection.requested.as_str() } else { "all" }, + "selected_group_keys": selection.labels(), + "selection_is_partial": selection.partial, + "corpus_1000_v4": { + "manifest_path": ".artifacts/issue384-implementation/baseline/corpus-1000-v4/input-manifest.json", + "workspace_sha256": CORPUS_MANIFEST_HASH, + "initial_effective_settings": "copied bsl-analyzer.toml from pinned v4 manifest; UnresolvedBareCall=true before measurement" + }, + "perf_shadow_fixture": { + "manifest_path": ".artifacts/issue384-implementation/baseline/shared-perf-shadow-manifest.json", + "workspace_sha256": PERF_SHADOW_MANIFEST_HASH, + "effective_settings": "diagnostics.parameters.UnresolvedBareCall=true" + }, + "effective_settings_toggle": if selection.partial { "30 expected absent/present disable-restore cycles for each selected cold/warm run; scope retirement/reconnect is a separate selected series" } else { "30 expected absent/present disable-restore cycles for every one of five cold and five warm runs per dataset; source-scope retirement/reconnect has separate five-group series" }, + "runs": runs, + "scope_runs": scope_runs, + "effective_settings_runs": effective_settings_runs, + "semantic_latency_samples": samples, + }); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after Unix epoch") + .as_nanos(); + let path = output.join(format!("metrics-{}-{stamp}.json", std::process::id())); + std::fs::write(path, serde_json::to_vec_pretty(&receipt).expect("serialize receipt")) + .expect("write MCP metrics receipt"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "long baseline/candidate measurement; run only under the coordinated release harness"] +async fn records_external_mcp_and_paired_lsp_workspace_metrics() { + require_release_profile(cfg!(debug_assertions)); + let measured_instant_step_ns = instant_resolution_ns(); + let selection = GroupSelection::from_env(); + let receipt_path = new_receipt_path(measured_instant_step_ns, &selection); + let mut runs = Vec::new(); + let mut scope_runs = Vec::new(); + let mut effective_settings_runs = Vec::new(); + let mut latency_samples = Vec::new(); + for dataset in [FIXTURE_DATASET, CORPUS_DATASET] { + let source = if dataset.name == FIXTURE_DATASET.name { + perf_shadow_fixture() + } else { + corpus_1000_v4() + }; + let input_hashes = actual_hashes(dataset, source.path()); + + for cache_group in ["cold", "warm"] { + if !(1..=RUNS_PER_GROUP) + .any(|run_index| selection.contains(dataset.name, cache_group, run_index)) + { + continue; + } + // Keep one workspace path per cache group so the warm cache is primed + // and measured against the same source identity and path. + let group_workspace = clone_workspace(source.path()); + let warm_cache = tempfile::tempdir().expect("owned warm cache"); + if cache_group == "warm" + && (1..=RUNS_PER_GROUP) + .any(|run_index| selection.contains(dataset.name, cache_group, run_index)) + { + let (_prime, mcp, mut lsp, sampler) = + measured_launch(group_workspace.path(), warm_cache.path(), dataset, "prime", 0) + .await; + let _prime_rss = sampler.finish(); + mcp.stop().await; + lsp.shutdown(); + reset_workspace(group_workspace.path(), source.path()); + } + + for run_index in 0..RUNS_PER_GROUP { + if !selection.contains(dataset.name, cache_group, run_index + 1) { + continue; + } + reset_workspace(group_workspace.path(), source.path()); + let cold_cache; + let cache = if cache_group == "cold" { + cold_cache = tempfile::tempdir().expect("owned cold cache"); + cold_cache.path() + } else { + warm_cache.path() + }; + let (mut run, mut mcp, mut lsp, sampler) = measured_launch( + group_workspace.path(), + cache, + dataset, + cache_group, + run_index + 1, + ) + .await; + let mut run_samples = + request_samples(&mcp.client, dataset, cache_group, run_index + 1).await; + let (setting_runs, setting_samples) = effective_settings_update_series( + &mut mcp, + lsp.child.id(), + group_workspace.path(), + dataset, + cache_group, + run_index + 1, + ) + .await; + run_samples.extend(setting_samples); + run_samples.extend( + semantic_update_samples( + &mcp.client, + group_workspace.path(), + dataset, + cache_group, + run_index + 1, + ) + .await, + ); + run.rss = sampler.finish(); + if let Some(last) = run.rss.last() { + run.mcp_hwm_kib_before_cleanup = last.mcp_hwm_kib; + run.lsp_hwm_kib_before_cleanup = last.lsp_hwm_kib; + } + append_checkpoint( + &receipt_path, + json!({ + "record": "measured-run", + "hashes": input_hashes, + "timer_step_ns": measured_instant_step_ns, + "run": &run, + "effective_settings_runs": &setting_runs, + "semantic_latency_samples": &run_samples + }), + ); + runs.push(run); + effective_settings_runs.extend(setting_runs); + latency_samples.extend(run_samples); + mcp.stop().await; + lsp.shutdown(); + } + } + + for group in 1..=RUNS_PER_GROUP { + if !selection.contains(dataset.name, "scope", group) { + continue; + } + let scope_workspace = clone_workspace(source.path()); + let (group_runs, group_samples) = scope_config_update_series( + scope_workspace.path(), + dataset, + group, + &receipt_path, + &input_hashes, + ) + .await; + append_checkpoint( + &receipt_path, + json!({ + "record": "source-scope-group", + "dataset": dataset.name, + "group": group, + "hashes": input_hashes, + "scope_runs": &group_runs, + "semantic_latency_samples": &group_samples + }), + ); + scope_runs.extend(group_runs); + latency_samples.extend(group_samples); + } + } + let cold_groups = selection.count("cold", None); + let warm_groups = selection.count("warm", None); + let process_groups = cold_groups + warm_groups; + let corpus_process_groups = selection.count("cold", Some(CORPUS_DATASET.name)) + + selection.count("warm", Some(CORPUS_DATASET.name)); + let scope_groups = selection.count("scope", None); + assert_eq!(runs.iter().filter(|run| run.cache == "cold").count(), cold_groups); + assert_eq!(runs.iter().filter(|run| run.cache == "warm").count(), warm_groups); + assert_eq!(runs.len(), process_groups, "each selected process group ran once"); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind == "diagnostics").count(), + METRIC_CYCLES * process_groups, + "each selected process group needs 30 expected-result diagnostics samples" + ); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind == "symbol_info").count(), + METRIC_CYCLES * process_groups, + "each selected process group needs 30 expected-result symbol_info samples" + ); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind == "global_symbol_info").count(), + METRIC_CYCLES * corpus_process_groups, + "each selected corpus process group needs 30 expected-result global symbol_info samples" + ); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind.starts_with("bsl-")).count(), + METRIC_CYCLES * 2 * process_groups, + "each selected cold/warm run needs 30 BSL change/restore semantic results" + ); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind.starts_with("xml-")).count(), + METRIC_CYCLES * 2 * process_groups, + "each selected cold/warm run needs 30 XML change/restore semantic results" + ); + assert_eq!( + latency_samples.iter().filter(|sample| sample.kind.starts_with("scope-config-")).count(), + METRIC_CYCLES * 3 * scope_groups, + "each selected source-scope group needs 30 retirement/LSP/reconnect cycles" + ); + assert_eq!(scope_runs.len(), METRIC_CYCLES * scope_groups); + assert_eq!( + latency_samples + .iter() + .filter(|sample| sample.kind == "effective-settings-to-diagnostic-absent") + .count(), + METRIC_CYCLES * process_groups, + "30 diagnostics-disable expected-result updates in every selected measured run" + ); + assert_eq!( + latency_samples + .iter() + .filter(|sample| sample.kind == "effective-settings-to-diagnostic-restored-present") + .count(), + METRIC_CYCLES * process_groups, + "30 diagnostics-restore expected-result updates in every selected measured run" + ); + assert_eq!(effective_settings_runs.len(), METRIC_CYCLES * process_groups); + for dataset in [FIXTURE_DATASET, CORPUS_DATASET] { + for cache_group in ["cold", "warm"] { + for run in 1..=RUNS_PER_GROUP { + if !selection.contains(dataset.name, cache_group, run) { + continue; + } + let matching = |kind: &str| { + latency_samples + .iter() + .filter(|sample| { + sample.dataset == dataset.name + && sample.cache_group == cache_group + && sample.run == run + && sample.kind == kind + }) + .count() + }; + for kind in ["diagnostics", "symbol_info"] { + assert_eq!( + matching(kind), + METRIC_CYCLES, + "{}/{cache_group}/{run}/{kind}", + dataset.name + ); + } + if dataset.global_symbol.is_some() { + assert_eq!(matching("global_symbol_info"), METRIC_CYCLES); + } + for kind in [ + "effective-settings-to-diagnostic-absent", + "effective-settings-to-diagnostic-restored-present", + "bsl-update-to-string-result", + "bsl-restore-to-number-result", + "xml-global-false-to-bare-call-diagnostic-present", + "xml-global-restore-to-bare-call-diagnostic-absent", + ] { + assert_eq!( + matching(kind), + METRIC_CYCLES, + "{}/{cache_group}/{run}/{kind}", + dataset.name + ); + } + } + } + for group in 1..=RUNS_PER_GROUP { + if !selection.contains(dataset.name, "scope", group) { + continue; + } + assert_eq!( + scope_runs + .iter() + .filter(|scope| scope.dataset == dataset.name && scope.group == group) + .count(), + METRIC_CYCLES, + "{} source-scope group {group} must contain 30 cycles", + dataset.name + ); + assert_eq!( + latency_samples + .iter() + .filter(|sample| { + sample.dataset == dataset.name + && sample.cache_group == "scope-reconnect" + && sample.run == group + && sample.kind == "lsp-before-config-old-composition" + }) + .count(), + METRIC_CYCLES, + "{} source-scope group {group} records 30 old-composition LSP poll samples", + dataset.name + ); + for kind in [ + "scope-config-to-mcp-retired", + "scope-config-to-lsp-new-composition", + "scope-config-to-mcp-reconnected-composition", + ] { + assert_eq!( + latency_samples + .iter() + .filter(|sample| { + sample.dataset == dataset.name + && sample.cache_group == "scope-reconnect" + && sample.run == group + && sample.kind == kind + }) + .count(), + METRIC_CYCLES, + "{} source-scope group {group}/{kind}", + dataset.name + ); + } + } + } + write_receipt( + &runs, + &scope_runs, + &effective_settings_runs, + &latency_samples, + measured_instant_step_ns, + &selection, + ); +} diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis_s10.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis_s10.rs new file mode 100644 index 000000000..2d7add4aa --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis_s10.rs @@ -0,0 +1,657 @@ +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::Lsp; +use mcp_server::{ + serve_stream, McpProfile, McpServer, SharedState, WorkspaceCacheLayout, WorkspaceInitError, +}; +use rmcp::model::{CallToolRequestParams, ClientRequest, PingRequest}; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Map, Value}; +use tokio::task::JoinHandle; + +const CONSUMER: &str = include_str!( + "fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl" +); +const PROVIDER_A: &str = include_str!( + "fixtures/shared_workspace_analysis_s6/extensions/provider-a/CommonModules/Общий/Ext/Module.bsl" +); +const CONFIG: &str = include_str!("fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml"); +const INVALID_CONFIG: &str = "[source\nroot = \"base\"\n"; + +type Client = RunningService; +type ServerTask = JoinHandle>; + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("fixture workspace"); + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/shared_workspace_analysis_s6"); + for entry in walkdir::WalkDir::new(&fixture_root) { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(&fixture_root).expect("fixture-relative path"); + let target = root.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("fixture directory"); + } else { + std::fs::create_dir_all(target.parent().expect("fixture parent")) + .expect("fixture parent directory"); + std::fs::copy(entry.path(), target).expect("fixture file"); + } + } + root +} + +fn args(pairs: &[(&str, Value)]) -> Map { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() +} + +fn position(text: &str, needle: &str) -> (u32, u32) { + let (line, source_line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains(needle)) + .expect("position token exists"); + let byte_column = source_line.find(needle).expect("position token on line"); + (line as u32, source_line[..byte_column].encode_utf16().count() as u32) +} + +fn non_config_files(root: &Path, config_path: &Path) -> Vec<(PathBuf, Vec)> { + let mut files: Vec<_> = walkdir::WalkDir::new(root) + .into_iter() + .map(|entry| entry.expect("fixture inventory entry")) + .filter(|entry| entry.file_type().is_file() && entry.path() != config_path) + .map(|entry| { + ( + entry.path().strip_prefix(root).unwrap().to_path_buf(), + std::fs::read(entry.path()).expect("fixture inventory contents"), + ) + }) + .collect(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files +} + +async fn mcp_session( + root: &Path, + cache: WorkspaceCacheLayout, +) -> (Client, ServerTask, SharedState) { + let state = SharedState::workspace_with_cache(root.to_path_buf(), cache) + .expect("workspace scope boots from valid project config"); + let server = McpServer::new(McpProfile::Workspace, state.clone()); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + let task = tokio::spawn(serve_stream(server, server_io)); + let client = ().serve(client_io).await.expect("real rmcp handshake"); + (client, task, state) +} + +async fn symbol_info(client: &Client, path: &Path, needle: &str) -> Value { + let (line, column) = position(CONSUMER, needle); + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "MCP symbol_info never settled for {needle}"); + let result = tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("symbol_info").with_arguments(args(&[ + ("path", json!(path.to_string_lossy())), + ("line", json!(line)), + ("column", json!(column)), + ]))), + ) + .await + .expect("MCP symbol_info deadline") + .expect("MCP symbol_info transport"); + let card = result.structured_content.expect("symbol_info structured result"); + if card["status"] != "loading" { + return card; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn rooted_symbol_info( + client: &Client, + root_id: &str, + relative_path: &Path, + source_text: &str, + needle: &str, +) -> Value { + let (line, column) = position(source_text, needle); + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "rooted MCP symbol_info never settled for {needle}"); + let result = tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("symbol_info").with_arguments(args(&[ + ("root_id", json!(root_id)), + ("path", json!(relative_path.to_string_lossy())), + ("line", json!(line)), + ("column", json!(column)), + ]))), + ) + .await + .expect("rooted MCP symbol_info deadline") + .expect("rooted MCP symbol_info transport"); + let card = result.structured_content.expect("rooted symbol_info structured result"); + if card["status"] != "loading" { + return card; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_mcp_target(client: &Client, source_path: &Path) -> Value { + let provider_a_root_id = Path::new("extensions").join("provider-a"); + let card = symbol_info(client, source_path, "ДатьСтроку").await; + assert_eq!(card["status"], "ok", "provider A symbol resolves: {card}"); + assert_eq!(card["return_type"], "Строка", "provider A semantic type: {card}"); + let definitions = card["definitions"].as_array().expect("definition list"); + assert!( + definitions.iter().any(|definition| { + definition["location"]["root_id"] == provider_a_root_id.to_string_lossy().as_ref() + && definition["location"]["path"] == "CommonModules/Общий/Ext/Module.bsl" + }), + "provider A resolves through the expected source root and path: {card}" + ); + card +} + +async fn assert_mcp_missing(client: &Client, source_path: &Path) { + let card = symbol_info(client, source_path, "ДатьЧисло").await; + let no_target = + card["definitions"].is_null() || card["definitions"].as_array().is_some_and(Vec::is_empty); + assert!( + card["status"] == "not_found" + || (card["status"] == "ok" + && card["kind"] == "unresolved" + && card["return_type"].is_null()), + "provider B is not active in this project: {card}" + ); + assert!(no_target, "excluded provider B has no definition target: {card}"); +} + +async fn mcp_diagnostic_body(client: &Client, source_path: &Path) -> Value { + mcp_rooted_diagnostic_body(client, None, source_path).await +} + +async fn mcp_rooted_diagnostic_body( + client: &Client, + root_id: Option<&str>, + source_path: &Path, +) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "MCP diagnostics never settled"); + let mut arguments = args(&[ + ("action", json!("file")), + ("path", json!(source_path.to_string_lossy())), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]); + if let Some(root_id) = root_id { + arguments.insert("root_id".to_owned(), json!(root_id)); + } + let result = tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("diagnostics").with_arguments(arguments)), + ) + .await + .expect("MCP diagnostics deadline") + .expect("MCP diagnostics transport"); + assert!( + result.is_error != Some(true), + "MCP diagnostics returned an error: {:?}", + result.structured_content + ); + let body = result.structured_content.expect("diagnostics structured result"); + if body["status"] != "loading" { + return body; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn mcp_diagnostic(client: &Client, source_path: &Path) -> Value { + let body = mcp_diagnostic_body(client, source_path).await; + assert!(body["result"].is_object(), "MCP diagnostics response has no result object: {body}"); + body["result"].clone() +} + +fn assert_mcp_diagnostic(body: &Value) { + let findings = body["findings"] + .as_array() + .unwrap_or_else(|| panic!("diagnostics result has no findings array: {body}")); + let (line, character) = position(CONSUMER, "НетТакойПроцедуры"); + let finding = findings + .iter() + .find(|finding| finding["code"] == "UnresolvedBareCall") + .expect("fixture has a known unresolved-call diagnostic"); + assert_eq!(finding["severity"], "error", "diagnostic severity: {finding}"); + assert_eq!(finding["location"]["range"]["start_line"], line); + assert_eq!(finding["location"]["range"]["start_character"], character); +} + +fn send_config_change(lsp: &mut Lsp, config_path: &Path) { + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{ + "uri": lsp_types::Url::from_file_path(config_path).expect("config file URI"), + "type": 2 + }]} + })); +} + +fn lsp_definition( + lsp: &mut Lsp, + id: u64, + uri: &lsp_types::Url, + needle: &str, + timeout: Duration, +) -> Value { + lsp_definition_at(lsp, id, uri, CONSUMER, needle, timeout) +} + +fn lsp_definition_at( + lsp: &mut Lsp, + id: u64, + uri: &lsp_types::Url, + text: &str, + needle: &str, + timeout: Duration, +) -> Value { + let (line, character) = position(text, needle); + lsp.send(json!({ + "jsonrpc": "2.0", "id": id, "method": "textDocument/definition", + "params": { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character} + } + })); + lsp.wait_for_within(timeout, |message| { + message["id"] == id + && message["method"].is_null() + && (message.get("result").is_some() || message.get("error").is_some()) + }) + .unwrap_or_else(|| panic!("LSP definition {needle} exceeded {timeout:?}")) +} + +fn lsp_hover(lsp: &mut Lsp, id: u64, uri: &lsp_types::Url, text: &str, needle: &str) -> Value { + let (line, character) = position(text, needle); + lsp.request( + id, + "textDocument/hover", + json!({ + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character} + }), + ) +} + +fn wait_lsp_target( + lsp: &mut Lsp, + first_id: u64, + uri: &lsp_types::Url, + needle: &str, + target_uri: &lsp_types::Url, +) { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let mut id = first_id; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP never resolved {needle}"); + let response = lsp_definition(lsp, id, uri, needle, remaining); + if response["error"]["code"] == -32801 { + assert!( + std::time::Instant::now() < deadline, + "LSP definition remained ContentModified for {needle}: {response}" + ); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + continue; + } + assert!(response["error"].is_null(), "LSP definition failed: {response}"); + if response["result"].to_string().contains(target_uri.as_str()) { + return; + } + assert!( + response["result"].is_null() + || response["result"].as_array().is_some_and(Vec::is_empty), + "definition resolved to an unexpected source-set target: {response}" + ); + assert!(std::time::Instant::now() < deadline, "LSP never resolved {needle}"); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn assert_lsp_missing(lsp: &mut Lsp, id: u64, uri: &lsp_types::Url) { + let response = lsp_definition(lsp, id, uri, "ДатьЧисло", Duration::from_secs(45)); + assert!(response["error"].is_null(), "LSP negative control failed: {response}"); + assert!( + response["result"].is_null() || response["result"].as_array().is_some_and(Vec::is_empty), + "unconfigured provider B has no definition target: {response}" + ); +} + +fn assert_lsp_diagnostic(published: &Value) { + let diagnostics = published["params"]["diagnostics"].as_array().expect("diagnostic list"); + let (line, character) = position(CONSUMER, "НетТакойПроцедуры"); + let unresolved = diagnostics + .iter() + .find(|diagnostic| diagnostic["code"] == "UnresolvedBareCall") + .expect("fixture retains a known unresolved-call diagnostic"); + assert_eq!(unresolved["severity"], 1, "known diagnostic severity: {unresolved}"); + assert_eq!(unresolved["range"]["start"]["line"], line); + assert_eq!(unresolved["range"]["start"]["character"], character); + assert_eq!( + unresolved["message"], + "Процедура или функция с указанным именем не определена: 'НетТакойПроцедуры'" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn invalid_project_config_preserves_lsp_and_rejects_mcp_until_repair() { + let root = fixture(); + let config_path = root.path().join("bsl-analyzer.toml"); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), CONFIG); + + let consumer_relative = + PathBuf::from("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_path = root.path().join(&consumer_relative); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let provider_a_uri = lsp_types::Url::from_file_path( + root.path().join("extensions/provider-a/CommonModules/Общий/Ext/Module.bsl"), + ) + .expect("provider A URI"); + let source_files_before = non_config_files(root.path(), &config_path); + + let cache_parent = tempfile::tempdir().expect("owned MCP cache parent"); + let initial_project = mcp_server::project::at(root.path()).expect("initial project is valid"); + let cache = WorkspaceCacheLayout::for_project( + &initial_project, + Some(cache_parent.path()), + root.path(), + None, + ) + .expect("initial explicit cache scope"); + let cache_root = cache.root().to_path_buf(); + let cache_scope = cache.scope_stamp(); + let (old_client, old_task, old_state) = mcp_session(root.path(), cache.clone()).await; + + let mut lsp = Lsp::start(root.path()); + let initial_diagnostics = lsp.open(&consumer_path, CONSUMER); + assert_lsp_diagnostic(&initial_diagnostics); + wait_lsp_target(&mut lsp, 2, &consumer_uri, "ДатьСтроку", &provider_a_uri); + assert_lsp_missing(&mut lsp, 3, &consumer_uri); + let initial_symbol = wait_mcp_target(&old_client, &consumer_path).await; + assert_eq!(initial_symbol["return_type"], "Строка"); + assert_mcp_missing(&old_client, &consumer_path).await; + assert_mcp_diagnostic(&mcp_diagnostic(&old_client, &consumer_path).await); + + std::fs::write(&config_path, INVALID_CONFIG).expect("write only invalid TOML config"); + send_config_change(&mut lsp, &config_path); + let error = lsp + .wait_for_within(Duration::from_secs(45), |message| { + message["method"] == "window/showMessage" + && message["params"]["message"] + .as_str() + .is_some_and(|text| text.contains("project config reload failed")) + }) + .expect("LSP reports invalid project config reload"); + assert_eq!(error["params"]["type"], 1, "invalid config is an LSP error: {error}"); + assert!( + error["params"]["message"].as_str().unwrap().contains("bsl-analyzer.toml"), + "LSP identifies the invalid project input: {error}" + ); + wait_lsp_target(&mut lsp, 4, &consumer_uri, "ДатьСтроку", &provider_a_uri); + assert_lsp_missing(&mut lsp, 5, &consumer_uri); + + let rejected_cache_parent = tempfile::tempdir().expect("owned rejected-start cache parent"); + let rejected_cache = WorkspaceCacheLayout::for_project( + &initial_project, + Some(rejected_cache_parent.path()), + root.path(), + None, + ) + .expect("candidate cache path from last valid project"); + assert!(!rejected_cache.root().exists(), "rejected-start cache starts absent"); + let invalid_start = + SharedState::workspace_with_cache(root.path().to_path_buf(), rejected_cache.clone()); + assert!( + matches!(invalid_start, Err(WorkspaceInitError::Project(_))), + "new MCP workspace construction rejects invalid project config before storage" + ); + assert!( + !rejected_cache.root().exists(), + "invalid MCP construction creates no cache storage or fallback workspace" + ); + + tokio::time::timeout(Duration::from_secs(15), old_task) + .await + .expect("invalid project input retires the active MCP scope") + .expect("retired MCP task joins") + .expect("retired MCP stream closes cleanly"); + assert!( + old_client.send_request(ClientRequest::PingRequest(PingRequest::default())).await.is_err(), + "the old MCP transport no longer serves a retired project scope" + ); + old_state.shutdown(); + + std::fs::write(&config_path, CONFIG).expect("restore exact known-good TOML config"); + send_config_change(&mut lsp, &config_path); + let restored_diagnostics = lsp + .wait_for_within(Duration::from_secs(45), |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == consumer_uri.as_str() + }) + .expect("LSP republishes diagnostics after project repair"); + assert_lsp_diagnostic(&restored_diagnostics); + wait_lsp_target(&mut lsp, 6, &consumer_uri, "ДатьСтроку", &provider_a_uri); + assert_lsp_missing(&mut lsp, 7, &consumer_uri); + + let restored_project = mcp_server::project::at(root.path()).expect("restored project parses"); + let restored_cache = WorkspaceCacheLayout::for_project( + &restored_project, + Some(cache_parent.path()), + root.path(), + None, + ) + .expect("restored original cache scope"); + assert_eq!(restored_cache.root(), cache_root, "repair reconnects to the original cache leaf"); + assert_eq!( + restored_cache.scope_stamp(), + cache_scope, + "repair restores the original scope identity" + ); + let (new_client, new_task, new_state) = mcp_session(root.path(), restored_cache).await; + assert!(cache_root.exists(), "the restored valid scope owns its original cache"); + let restored_symbol = wait_mcp_target(&new_client, &consumer_path).await; + assert_eq!(restored_symbol["return_type"], "Строка"); + assert_mcp_missing(&new_client, &consumer_path).await; + assert_mcp_diagnostic(&mcp_diagnostic(&new_client, &consumer_path).await); + + new_client.cancel().await.expect("restored MCP client disconnects"); + tokio::time::timeout(Duration::from_secs(5), new_task) + .await + .expect("restored MCP session exits") + .expect("restored MCP task joins") + .expect("restored MCP stream closes cleanly"); + new_state.shutdown(); + assert!(lsp.shutdown(8).success(), "LSP exits cleanly"); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), CONFIG); + let source_files_after = non_config_files(root.path(), &config_path); + assert_eq!(source_files_after, source_files_before, "only TOML changed during this scenario"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn unknown_and_excluded_rooted_paths_do_not_collide() { + let root = fixture(); + let config_path = root.path().join("bsl-analyzer.toml"); + let excluded_relative = Path::new("CommonModules/Общий/Ext/Module.bsl"); + let excluded_path = root.path().join("base").join(excluded_relative); + std::fs::create_dir_all(excluded_path.parent().expect("excluded module parent")) + .expect("create excluded source directory"); + std::fs::write( + &excluded_path, + "&НаСервере\nФункция ДатьСтроку() Экспорт\n Возврат \"excluded base\";\nКонецФункции\n", + ) + .expect("create an excluded base copy with the provider's relative path"); + let excluded_config = CONFIG.replace( + "[source]\nroot = \"base\"\n", + "[source]\nroot = \"base\"\nexclude = [\"base/CommonModules/Общий\"]\n", + ); + assert_ne!(excluded_config, CONFIG, "the exclusion was added to the project config"); + std::fs::write(&config_path, &excluded_config).expect("write explicit source exclusion"); + + let consumer_relative = + PathBuf::from("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_path = root.path().join(&consumer_relative); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let provider_a_uri = lsp_types::Url::from_file_path( + root.path().join("extensions/provider-a/CommonModules/Общий/Ext/Module.bsl"), + ) + .expect("provider A URI"); + let mut lsp = Lsp::start(root.path()); + let consumer_diagnostics = lsp.open(&consumer_path, CONSUMER); + assert_lsp_diagnostic(&consumer_diagnostics); + wait_lsp_target(&mut lsp, 2, &consumer_uri, "ДатьСтроку", &provider_a_uri); + let consumer_hover = lsp_hover(&mut lsp, 3, &consumer_uri, CONSUMER, "ОтветСтроки"); + assert!(consumer_hover["error"].is_null(), "consumer hover failed: {consumer_hover}"); + assert!( + consumer_hover["result"].to_string().contains("Строка"), + "consumer sees provider A's String result: {consumer_hover}" + ); + + let excluded_text = std::fs::read_to_string(&excluded_path).expect("excluded source text"); + let excluded_uri = lsp_types::Url::from_file_path(&excluded_path).expect("excluded URI"); + lsp.send(json!({ + "jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": {"textDocument": { + "uri": excluded_uri, + "languageId": "bsl", + "version": 1, + "text": excluded_text + }} + })); + let excluded_definition = lsp_definition_at( + &mut lsp, + 4, + &excluded_uri, + &excluded_text, + "ДатьСтроку", + Duration::from_secs(45), + ); + assert!( + excluded_definition["error"].is_object() && excluded_definition["result"].is_null(), + "excluded root-relative duplicate must be rejected before resolving provider A: {excluded_definition}" + ); + assert!( + excluded_definition["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("File not in frozen VFS snapshot")), + "excluded didOpen is retained only in the editor buffer, not the analyzed VFS: {excluded_definition}" + ); + assert_eq!( + excluded_definition["error"]["code"], + json!(lsp_server::ErrorCode::InternalError as i32), + "frontend admission errors retain the existing JSON-RPC mapping: {excluded_definition}" + ); + + let unknown_path = root.path().join("not-present/CommonModules/Неизвестный/Ext/Module.bsl"); + let unknown_uri = lsp_types::Url::from_file_path(&unknown_path).expect("unknown URI"); + let unknown_definition = lsp_definition_at( + &mut lsp, + 5, + &unknown_uri, + &excluded_text, + "ДатьСтроку", + Duration::from_secs(45), + ); + assert!( + unknown_definition["error"].is_object() && unknown_definition["result"].is_null(), + "unknown path cannot resolve through another root with the same relative path: {unknown_definition}" + ); + assert!( + unknown_definition["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("File not in frozen VFS snapshot")), + "a document without didOpen is absent from the request's frozen VFS paths: {unknown_definition}" + ); + assert_eq!( + unknown_definition["error"]["code"], + json!(lsp_server::ErrorCode::InternalError as i32), + "frontend admission errors retain the existing JSON-RPC mapping: {unknown_definition}" + ); + + wait_lsp_target(&mut lsp, 6, &consumer_uri, "ДатьСтроку", &provider_a_uri); + let consumer_hover_after_rejections = + lsp_hover(&mut lsp, 7, &consumer_uri, CONSUMER, "ОтветСтроки"); + assert!( + consumer_hover_after_rejections["error"].is_null(), + "consumer hover remains available after the rejected requests: {consumer_hover_after_rejections}" + ); + assert!( + consumer_hover_after_rejections["result"].to_string().contains("Строка"), + "active provider-A type remains available after the rejected requests: {consumer_hover_after_rejections}" + ); + + let project = mcp_server::project::at(root.path()).expect("excluded fixture project parses"); + let cache_parent = tempfile::tempdir().expect("owned MCP cache parent"); + let cache = + WorkspaceCacheLayout::for_project(&project, Some(cache_parent.path()), root.path(), None) + .expect("explicit cache scope for excluded fixture"); + let (client, task, state) = mcp_session(root.path(), cache).await; + + let provider_a_relative = Path::new("CommonModules/Общий/Ext/Module.bsl"); + let provider_a_root_id = Path::new("extensions").join("provider-a"); + let active_provider = rooted_symbol_info( + &client, + provider_a_root_id.to_str().expect("UTF-8 fixture root id"), + provider_a_relative, + PROVIDER_A, + "ДатьСтроку", + ) + .await; + assert_eq!(active_provider["status"], "ok", "the selected extension root remains active"); + assert_eq!(active_provider["return_type"], "Строка"); + assert!( + active_provider["definitions"].as_array().is_some_and(|definitions| { + definitions.iter().any(|definition| { + definition["location"]["root_id"] == provider_a_root_id.to_string_lossy().as_ref() + && definition["location"]["path"] == provider_a_relative.to_string_lossy().as_ref() + }) + }), + "the same root-relative path resolves to provider A, not the excluded base copy: {active_provider}" + ); + + let excluded = mcp_rooted_diagnostic_body(&client, Some(""), excluded_relative).await; + let excluded_result = excluded["result"] + .as_object() + .expect("excluded-path diagnostics envelope has a result object"); + assert_eq!( + excluded_result["error"], + "not_in_workspace", + "configuration-root path is excluded even though provider A has the same relative path: {excluded}" + ); + + let unknown_path = root.path().join("not-present/CommonModules/Неизвестный/Ext/Module.bsl"); + let unknown = mcp_diagnostic_body(&client, &unknown_path).await; + let unknown_result = unknown["result"] + .as_object() + .expect("unknown-path diagnostics envelope has a result object"); + assert_eq!(unknown_result["error"], "not_in_workspace", "unknown file path: {unknown}"); + + client.cancel().await.expect("MCP client disconnects"); + tokio::time::timeout(Duration::from_secs(5), task) + .await + .expect("MCP task exits") + .expect("MCP task joins") + .expect("MCP stream closes cleanly"); + state.shutdown(); + assert!(lsp.shutdown(8).success(), "LSP exits cleanly"); +} diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis_s4.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis_s4.rs new file mode 100644 index 000000000..d9dc7554d --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis_s4.rs @@ -0,0 +1,241 @@ +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::Lsp; +use mcp_server::{serve_stream, McpProfile, McpServer, SharedState}; +use rmcp::model::CallToolRequestParams; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Map, Value}; + +type Client = RunningService; + +const OBJECT_MODULE: &str = include_str!( + "fixtures/shared_workspace_analysis_s4/Catalogs/КонтрольныйКаталог/Ext/ObjectModule.bsl" +); +const CATALOG_XML: &str = + include_str!("fixtures/shared_workspace_analysis_s4/Catalogs/КонтрольныйКаталог.xml"); +const XML_STRING_TYPE: &str = "xs:string"; +const XML_NUMBER_TYPE: &str = "xs:decimal"; + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("fixture workspace"); + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/shared_workspace_analysis_s4"); + for entry in walkdir::WalkDir::new(&fixture_root) { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(&fixture_root).expect("fixture-relative path"); + let target = root.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("fixture directory"); + } else { + std::fs::create_dir_all(target.parent().expect("fixture parent")) + .expect("fixture parent directory"); + std::fs::copy(entry.path(), target).expect("fixture file"); + } + } + root +} + +async fn mcp_client(root: &Path) -> Client { + let server = McpServer::new( + McpProfile::Workspace, + SharedState::workspace(root.to_path_buf()).expect("valid fixture workspace"), + ); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + tokio::spawn(serve_stream(server, server_io)); + ().serve(client_io).await.expect("real rmcp handshake") +} + +fn args(pairs: &[(&str, Value)]) -> Map { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() +} + +fn position(text: &str, needle: &str) -> (u32, u32) { + let (line, source_line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains(needle)) + .expect("position token exists"); + let byte_column = source_line.find(needle).expect("position token on line"); + let column = source_line[..byte_column].encode_utf16().count(); + (line as u32, column as u32) +} + +async fn wait_mcp_method_type( + client: &Client, + path: &str, + method: &str, + expected: &str, + stale: Option<&str>, + allow_loading: bool, +) -> Value { + let (line, column) = position(OBJECT_MODULE, method); + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "MCP symbol_info never returned {expected}"); + let call = + tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("symbol_info").with_arguments(args( + &[("path", json!(path)), ("line", json!(line)), ("column", json!(column))], + ))), + ) + .await + .expect("MCP symbol_info deadline") + .expect("MCP symbol_info transport"); + let card = call.structured_content.expect("symbol_info structured result"); + match card["status"].as_str() { + Some("loading") => assert!(allow_loading, "MCP workspace regressed to loading"), + Some("ok") => { + let actual = card["return_type"].as_str().expect("method return_type"); + if actual == expected { + return card; + } + assert_eq!(Some(actual), stale, "unexpected method card: {card}"); + } + _ => panic!("symbol_info did not resolve the local method: {card}"), + } + assert!(tokio::time::Instant::now() < deadline, "MCP symbol_info timeout"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn wait_lsp_hover_type( + lsp: &mut Lsp, + first_id: u64, + params: Value, + expected: &str, + stale: Option<&str>, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let mut id = first_id; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP hover never returned {expected}"); + lsp.send(json!({ + "jsonrpc": "2.0", "id": id, "method": "textDocument/hover", "params": params + })); + let response = lsp + .wait_for_within(remaining, |message| { + message["id"] == id + && message["method"].is_null() + && (message.get("result").is_some() || message.get("error").is_some()) + }) + .unwrap_or_else(|| panic!("LSP hover request {id} exceeded its deadline")); + assert!(response["error"].is_null(), "LSP hover failed: {response}"); + let result = response["result"].to_string(); + if result.contains(expected) { + return response; + } + let stale_type = stale.expect("unexpected LSP hover result"); + assert!(result.contains(stale_type), "unexpected LSP hover response: {response}"); + assert!(std::time::Instant::now() < deadline, "LSP hover timeout"); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn signal_xml_change(lsp: &mut Lsp, xml_path: &Path) { + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{ + "uri": lsp_types::Url::from_file_path(xml_path).expect("XML file URI"), + "type": 2 + }]} + })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn xml_attribute_type_refreshes_dependent_lsp_and_mcp_semantics() { + let root = fixture(); + let object_relative = PathBuf::from("Catalogs/КонтрольныйКаталог/Ext/ObjectModule.bsl"); + let object_path = root.path().join(&object_relative); + let xml_path = root.path().join("Catalogs/КонтрольныйКаталог.xml"); + let object_uri = lsp_types::Url::from_file_path(&object_path).expect("object module URI"); + let bsl_before = std::fs::read(&object_path).expect("read fixture BSL"); + + let mut lsp = Lsp::start(root.path()); + lsp.open(&object_path, OBJECT_MODULE); + let client = mcp_client(root.path()).await; + let hover_params = |needle: &str| { + let (line, character) = position(OBJECT_MODULE, needle); + json!({ + "textDocument": {"uri": object_uri.clone()}, + "position": {"line": line, "character": character} + }) + }; + + let changed_before = wait_mcp_method_type( + &client, + object_relative.to_str().expect("UTF-8 fixture path"), + "ПолучитьПоле", + "Строка", + None, + true, + ) + .await; + assert_eq!(changed_before["return_type"], "Строка"); + let stable_before = wait_mcp_method_type( + &client, + object_relative.to_str().expect("UTF-8 fixture path"), + "ПолучитьКонтрольноеПоле", + "Строка", + None, + false, + ) + .await; + assert_eq!(stable_before["return_type"], "Строка"); + wait_lsp_hover_type(&mut lsp, 2, hover_params("ИзменяемыйТип"), "Строка", None); + wait_lsp_hover_type(&mut lsp, 3, hover_params("СтабильныйТип"), "Строка", None); + + let changed_xml = CATALOG_XML.replacen(XML_STRING_TYPE, XML_NUMBER_TYPE, 1); + assert_ne!(changed_xml, CATALOG_XML, "fixture contains the expected XML type"); + std::fs::write(&xml_path, &changed_xml).expect("change only the metadata XML type"); + signal_xml_change(&mut lsp, &xml_path); + + let changed_after = wait_mcp_method_type( + &client, + object_relative.to_str().expect("UTF-8 fixture path"), + "ПолучитьПоле", + "Число", + Some("Строка"), + false, + ) + .await; + assert_eq!(changed_after["return_type"], "Число"); + let stable_after = wait_mcp_method_type( + &client, + object_relative.to_str().expect("UTF-8 fixture path"), + "ПолучитьКонтрольноеПоле", + "Строка", + None, + false, + ) + .await; + assert_eq!(stable_after["return_type"], "Строка"); + wait_lsp_hover_type(&mut lsp, 4, hover_params("ИзменяемыйТип"), "Число", Some("Строка")); + wait_lsp_hover_type(&mut lsp, 5, hover_params("СтабильныйТип"), "Строка", None); + assert_eq!(std::fs::read(&object_path).expect("BSL unchanged"), bsl_before); + + let restored_xml = changed_xml.replacen(XML_NUMBER_TYPE, XML_STRING_TYPE, 1); + std::fs::write(&xml_path, restored_xml).expect("restore metadata XML type"); + signal_xml_change(&mut lsp, &xml_path); + wait_mcp_method_type( + &client, + object_relative.to_str().expect("UTF-8 fixture path"), + "ПолучитьПоле", + "Строка", + Some("Число"), + false, + ) + .await; + wait_lsp_hover_type(&mut lsp, 6, hover_params("ИзменяемыйТип"), "Строка", Some("Число")); + assert_eq!(std::fs::read(&object_path).expect("BSL unchanged after restore"), bsl_before); + + client.cancel().await.ok(); + assert!(lsp.shutdown(7).success(), "LSP exits cleanly"); +} diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis_s5.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis_s5.rs new file mode 100644 index 000000000..f7df37e43 --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis_s5.rs @@ -0,0 +1,793 @@ +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::Lsp; +use mcp_server::{serve_stream, McpProfile, McpServer, SharedState, WorkspaceCacheLayout}; +use rmcp::model::CallToolRequestParams; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Map, Value}; + +type Client = RunningService; + +const CONSUMER: &str = include_str!( + "fixtures/shared_workspace_analysis_s5/extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl" +); + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("fixture workspace"); + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/shared_workspace_analysis_s5"); + for entry in walkdir::WalkDir::new(&fixture_root) { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(&fixture_root).expect("fixture-relative path"); + let target = root.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("fixture directory"); + } else { + std::fs::create_dir_all(target.parent().expect("fixture parent")) + .expect("fixture parent directory"); + std::fs::copy(entry.path(), target).expect("fixture file"); + } + } + root +} + +async fn mcp_client(root: &Path) -> Client { + let server = McpServer::new( + McpProfile::Workspace, + SharedState::workspace(root.to_path_buf()).expect("valid fixture workspace"), + ); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + tokio::spawn(serve_stream(server, server_io)); + ().serve(client_io).await.expect("real rmcp handshake") +} + +fn args(pairs: &[(&str, Value)]) -> Map { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() +} + +fn position(text: &str, needle: &str) -> (u32, u32) { + let (line, source_line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains(needle)) + .expect("position token exists"); + let byte_column = source_line.find(needle).expect("position token on line"); + let column = source_line[..byte_column].encode_utf16().count(); + (line as u32, column as u32) +} + +fn last_range_positions(text: &str, range_text: &str) -> ((u32, u32), (u32, u32)) { + let start = text.rfind(range_text).expect("diagnostic range exists"); + let end = start + range_text.len(); + let position_at = |offset: usize| { + let prefix = &text[..offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as u32; + let source_line = prefix.rsplit('\n').next().expect("diagnostic source line"); + (line, source_line.encode_utf16().count() as u32) + }; + (position_at(start), position_at(end)) +} + +fn source_section(text: &str) -> &str { + let tail = text.split_once("[source]").expect("source section").1; + tail.split_once("[diagnostics.parameters]").map_or(tail, |(source, _)| source).trim() +} + +fn analysis_config( + base: &str, + compatibility_mode: Option<&str>, + unresolved_bare_call: bool, +) -> String { + assert_eq!(base.matches("[source]").count(), 1, "fixture has one source section"); + let mut config = base.to_owned(); + if let Some(mode) = compatibility_mode { + config = + config.replacen("[source]", &format!("compatibility_mode = \"{mode}\"\n\n[source]"), 1); + } + config.push_str(&format!( + "\n[diagnostics.parameters]\nUnresolvedBareCall = {unresolved_bare_call}\n" + )); + config +} + +fn send_config_change(lsp: &mut Lsp, config_path: &Path) { + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{ + "uri": lsp_types::Url::from_file_path(config_path).expect("config file URI"), + "type": 2 + }]} + })); +} + +fn lsp_diagnostic_state( + lsp: &mut Lsp, + uri: &lsp_types::Url, + expected_compatibility: bool, + expected_unresolved: bool, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP did not publish the expected settings state"); + let published = lsp + .wait_for_within(remaining, |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == uri.as_str() + }) + .expect("LSP diagnostics notification deadline"); + let diagnostics = + published["params"]["diagnostics"].as_array().expect("LSP diagnostics array"); + let compatibility = diagnostics + .iter() + .any(|diagnostic| diagnostic["code"] == "PlatformMemberHiddenByCompatibilityMode"); + let unresolved = + diagnostics.iter().any(|diagnostic| diagnostic["code"] == "UnresolvedBareCall"); + if compatibility == expected_compatibility && unresolved == expected_unresolved { + return published; + } + assert!( + std::time::Instant::now() < deadline, + "LSP settings state did not converge: {published}" + ); + } +} + +async fn mcp_diagnostic_state( + client: &Client, + relative_path: &str, + expected_compatibility: bool, + expected_unresolved: bool, +) -> Value { + let describe = |body: Option<&Value>| { + let state = match body { + Some(body) => match body["result"]["findings"].as_array() { + Some(findings) => { + let compatibility = findings.iter().any(|finding| { + finding["code"] == "PlatformMemberHiddenByCompatibilityMode" + }); + let unresolved = + findings.iter().any(|finding| finding["code"] == "UnresolvedBareCall"); + format!( + "status={}, compatibility={compatibility}, unresolved={unresolved}", + body["status"].as_str().unwrap_or("") + ) + } + None => format!( + "status={}, findings=", + body["status"].as_str().unwrap_or("") + ), + }, + None => "no MCP body observed".to_owned(), + }; + format!( + "expected compatibility={expected_compatibility}, unresolved={expected_unresolved}; observed {state}; last body={body:?}" + ) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + let mut last_body = None; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!( + !remaining.is_zero(), + "MCP diagnostics did not reach the expected settings state: {}", + describe(last_body.as_ref()) + ); + let response = match tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("diagnostics").with_arguments(args(&[ + ("action", json!("file")), + ("path", json!(relative_path)), + ("min_severity", json!("hint")), + ("detail", json!("detailed")), + ]))), + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(error)) => panic!( + "MCP diagnostics transport failed ({error:?}): {}", + describe(last_body.as_ref()) + ), + Err(_) => panic!("MCP diagnostics request timed out: {}", describe(last_body.as_ref())), + }; + assert_ne!( + response.is_error, + Some(true), + "MCP diagnostics returned an error {response:?}: {}", + describe(last_body.as_ref()) + ); + let response_debug = format!("{response:?}"); + let body = response.structured_content.unwrap_or_else(|| { + panic!( + "MCP diagnostics response has no structured content {response_debug}: {}", + describe(last_body.as_ref()) + ) + }); + last_body = Some(body.clone()); + if body["status"] == "loading" { + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + let findings = body["result"]["findings"].as_array().unwrap_or_else(|| { + panic!("MCP diagnostics has no findings array: {}", describe(Some(&body))) + }); + let compatibility = findings + .iter() + .any(|finding| finding["code"] == "PlatformMemberHiddenByCompatibilityMode"); + let unresolved = findings.iter().any(|finding| finding["code"] == "UnresolvedBareCall"); + if compatibility == expected_compatibility && unresolved == expected_unresolved { + return body; + } + assert!( + tokio::time::Instant::now() < deadline, + "MCP settings state did not converge: {}", + describe(Some(&body)) + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn assert_lsp_finding( + published: &Value, + source: &str, + code: &str, + range_text: &str, + severity: u64, + expected_message: &str, +) { + let diagnostics = published["params"]["diagnostics"].as_array().expect("LSP diagnostics"); + let matching: Vec<_> = + diagnostics.iter().filter(|diagnostic| diagnostic["code"] == code).collect(); + assert_eq!(matching.len(), 1, "expected one {code} diagnostic: {published}"); + let ((line, character), (end_line, end_character)) = last_range_positions(source, range_text); + assert_eq!(matching[0]["severity"], severity, "{code} severity: {published}"); + assert_eq!(matching[0]["message"], expected_message, "{code} identity: {published}"); + assert_eq!(matching[0]["range"]["start"]["line"], line, "{code} range: {published}"); + assert_eq!(matching[0]["range"]["start"]["character"], character, "{code} range: {published}"); + assert_eq!(matching[0]["range"]["end"]["line"], end_line, "{code} range: {published}"); + assert_eq!( + matching[0]["range"]["end"]["character"], end_character, + "{code} range: {published}" + ); +} + +fn assert_mcp_finding( + body: &Value, + source: &str, + code: &str, + range_text: &str, + expected_message: &str, +) { + let findings = body["result"]["findings"].as_array().expect("MCP findings"); + let matching: Vec<_> = findings.iter().filter(|finding| finding["code"] == code).collect(); + assert_eq!(matching.len(), 1, "expected one {code} finding: {body}"); + let finding = matching[0]; + let ((line, utf16_column), (end_line, end_utf16_column)) = + last_range_positions(source, range_text); + assert_eq!(finding["internal_severity"], "Major", "{code} severity: {body}"); + assert_eq!(finding["message"], expected_message, "{code} identity: {body}"); + if code == "ModuleAccessibility" { + assert_eq!( + finding["standards"], + json!([{ "id": 469, "url": "https://v8std.ru/std/469/" }]), + "{code} standard identity: {body}" + ); + } + assert_eq!(finding["range"]["start_line"], line, "{code} range: {body}"); + assert_eq!(finding["range"]["start_column"], utf16_column, "{code} range: {body}"); + assert_eq!(finding["range"]["end_line"], end_line, "{code} range: {body}"); + assert_eq!(finding["range"]["end_column"], end_utf16_column, "{code} range: {body}"); + assert_eq!(finding["location"]["position_encoding"], "utf-16", "{code} location: {body}"); + assert_eq!(finding["location"]["range"]["start_line"], line, "{code} location: {body}"); + assert_eq!( + finding["location"]["range"]["start_character"], utf16_column, + "{code} location: {body}" + ); + assert_eq!(finding["location"]["range"]["end_line"], end_line, "{code} location: {body}"); + assert_eq!( + finding["location"]["range"]["end_character"], end_utf16_column, + "{code} location: {body}" + ); +} + +async fn mcp_call_at( + client: &Client, + source_path: &Path, + needle: &str, + allow_loading: bool, +) -> Value { + let (line, column) = position(CONSUMER, needle); + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "MCP symbol_info never returned a card for {needle}"); + let call = tokio::time::timeout( + remaining, + client.call_tool(CallToolRequestParams::new("symbol_info").with_arguments(args(&[ + ("path", json!(source_path.to_string_lossy())), + ("line", json!(line)), + ("column", json!(column)), + ]))), + ) + .await + .expect("MCP symbol_info deadline") + .expect("MCP symbol_info transport"); + let card = call.structured_content.expect("symbol_info structured result"); + match card["status"].as_str() { + Some("loading") => assert!(allow_loading, "ready MCP workspace regressed to loading"), + Some("ok" | "not_found") => return card, + _ => panic!("symbol_info returned an invalid result for {needle}: {card}"), + } + assert!(tokio::time::Instant::now() < deadline, "MCP symbol_info timeout"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_mcp_call_type( + client: &Client, + source_path: &Path, + needle: &str, + expected: &str, + allow_loading: bool, +) -> Value { + let card = mcp_call_at(client, source_path, needle, allow_loading).await; + assert_eq!(card["status"], "ok", "symbol_info did not resolve {needle}: {card}"); + assert_eq!(card["return_type"], expected, "semantic type for {needle}: {card}"); + card +} + +async fn symbol_status(client: &Client, symbol: &str) -> Value { + let result = tokio::time::timeout( + Duration::from_secs(45), + client.call_tool( + CallToolRequestParams::new("symbol_info") + .with_arguments(args(&[("symbol", json!(symbol))])), + ), + ) + .await + .expect("MCP symbol_info deadline") + .expect("MCP symbol_info transport"); + let card = result.structured_content.expect("symbol_info structured result"); + assert_ne!(card["status"], "loading", "ready MCP workspace regressed to loading"); + card +} + +fn hover(lsp: &mut Lsp, id: u64, uri: &lsp_types::Url, needle: &str) -> Value { + let (line, character) = position(CONSUMER, needle); + lsp.request( + id, + "textDocument/hover", + json!({ + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character} + }), + ) +} + +fn definition(lsp: &mut Lsp, id: u64, uri: &lsp_types::Url, needle: &str) -> Value { + let (line, character) = position(CONSUMER, needle); + lsp.request( + id, + "textDocument/definition", + json!({ + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character} + }), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn extension_dependency_exclusion_and_duplicate_names_match_across_protocols() { + let root = fixture(); + let consumer_relative = + PathBuf::from("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_path = root.path().join(&consumer_relative); + let provider_path = root.path().join("extensions/provider/CommonModules/Общий/Ext/Module.bsl"); + let base_path = root.path().join("CommonModules/Общий/Ext/Module.bsl"); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let provider_uri = lsp_types::Url::from_file_path(&provider_path).expect("provider URI"); + let base_uri = lsp_types::Url::from_file_path(&base_path).expect("base URI"); + + let mut lsp = Lsp::start(root.path()); + lsp.open(&consumer_path, CONSUMER); + let client = mcp_client(root.path()).await; + + let provider_call = + wait_mcp_call_type(&client, &consumer_path, "ДатьСтроку", "Строка", true).await; + let provider_definition = provider_call["definitions"] + .as_array() + .expect("provider definition list") + .first() + .expect("provider definition"); + assert!( + provider_definition["source_extension"] == "Поставщик", + "the declaration must identify its source extension: {provider_call}" + ); + assert_eq!( + provider_definition["location"]["path"], + "extensions/provider/CommonModules/Общий/Ext/Module.bsl", + "the dependency call must resolve into the provider extension: {provider_call}" + ); + let provider_hover = hover(&mut lsp, 2, &consumer_uri, "ОтветПоставщика"); + assert!(provider_hover["error"].is_null(), "LSP hover failed: {provider_hover}"); + assert!( + provider_hover["result"].to_string().contains("Строка"), + "LSP sees the provider's String result: {provider_hover}" + ); + let provider_target = definition(&mut lsp, 3, &consumer_uri, "ДатьСтроку"); + assert!(provider_target["error"].is_null(), "LSP definition failed: {provider_target}"); + assert!( + provider_target["result"].to_string().contains(provider_uri.as_str()), + "LSP definition comes from the declared dependency: {provider_target}" + ); + + let base_call = wait_mcp_call_type(&client, &consumer_path, "Спорный", "Число", false).await; + let base_definition = base_call["definitions"] + .as_array() + .expect("base definition list") + .first() + .expect("base definition"); + assert_eq!(base_definition["location"]["root_id"], "", "configuration wins duplicate name"); + assert_eq!( + base_definition["location"]["path"], "CommonModules/Общий/Ext/Module.bsl", + "configuration duplicate has the canonical base path: {base_call}" + ); + let base_hover = hover(&mut lsp, 4, &consumer_uri, "ОтветКонфигурации"); + assert!(base_hover["error"].is_null(), "LSP hover failed: {base_hover}"); + assert!( + base_hover["result"].to_string().contains("Число"), + "LSP applies configuration precedence to the duplicate: {base_hover}" + ); + let base_target = definition(&mut lsp, 5, &consumer_uri, "Спорный"); + assert!(base_target["error"].is_null(), "LSP definition failed: {base_target}"); + assert!( + base_target["result"].to_string().contains(base_uri.as_str()), + "LSP definition uses the base configuration body: {base_target}" + ); + + let excluded = symbol_status(&client, "СкрытыйМодуль.СекретныйМетод").await; + assert_eq!(excluded["status"], "not_found", "excluded extension is absent: {excluded}"); + let excluded_target = definition(&mut lsp, 6, &consumer_uri, "СкрытыйМодуль"); + assert!(excluded_target["error"].is_null(), "LSP definition failed: {excluded_target}"); + assert!( + excluded_target["result"].is_null() + || excluded_target["result"].as_array().is_some_and(Vec::is_empty), + "LSP cannot navigate into the excluded extension: {excluded_target}" + ); + + client.cancel().await.ok(); + assert!(lsp.shutdown(7).success(), "LSP exits cleanly"); + + let without_dependency = fixture(); + let config_path = without_dependency.path().join("bsl-analyzer.toml"); + let config = std::fs::read_to_string(&config_path).expect("read fixture config"); + let independent_config = config.replacen(", dependsOn = [\"Поставщик\"]", "", 1); + assert_ne!(independent_config, config, "fixture has exactly one declared dependency"); + std::fs::write(config_path, independent_config).expect("remove only the dependency edge"); + let isolated_consumer = without_dependency + .path() + .join("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let isolated_uri = + lsp_types::Url::from_file_path(&isolated_consumer).expect("isolated consumer URI"); + let mut isolated_lsp = Lsp::start(without_dependency.path()); + isolated_lsp.open(&isolated_consumer, CONSUMER); + let isolated_client = mcp_client(without_dependency.path()).await; + + let hidden_provider = + mcp_call_at(&isolated_client, &isolated_consumer, "ДатьСтроку", true).await; + assert!( + hidden_provider["status"] == "not_found" + || (hidden_provider["status"] == "ok" + && hidden_provider["kind"] == "unresolved" + && hidden_provider["return_type"].is_null()), + "without dependsOn the provider-only call has no semantic target: {hidden_provider}" + ); + assert!( + hidden_provider["definitions"].is_null() + || hidden_provider["definitions"].as_array().is_some_and(Vec::is_empty), + "without dependsOn MCP publishes no semantic definition: {hidden_provider}" + ); + let hidden_target = definition(&mut isolated_lsp, 2, &isolated_uri, "ДатьСтроку"); + assert!(hidden_target["error"].is_null(), "LSP definition failed: {hidden_target}"); + assert!( + hidden_target["result"].is_null() + || hidden_target["result"].as_array().is_some_and(Vec::is_empty), + "without dependsOn LSP has no provider definition: {hidden_target}" + ); + let isolated_base = + wait_mcp_call_type(&isolated_client, &isolated_consumer, "Спорный", "Число", false).await; + assert!(isolated_base["definitions"][0]["location"]["root_id"] == ""); + let isolated_base_hover = hover(&mut isolated_lsp, 3, &isolated_uri, "ОтветКонфигурации"); + assert!(isolated_base_hover["error"].is_null(), "LSP hover failed: {isolated_base_hover}"); + assert!( + isolated_base_hover["result"].to_string().contains("Число"), + "dependency removal leaves base visibility intact: {isolated_base_hover}" + ); + + isolated_client.cancel().await.ok(); + assert!(isolated_lsp.shutdown(4).success(), "isolated LSP exits cleanly"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn effective_settings_compatibility_and_execution_context_match_live_adapters() { + const COMPATIBILITY_CODE: &str = "PlatformMemberHiddenByCompatibilityMode"; + const SETTINGS_CODE: &str = "UnresolvedBareCall"; + const CONTEXT_CODE: &str = "ModuleAccessibility"; + const CONTEXT_RANGE: &str = "Общий.ДатьСтроку"; + const COMPATIBILITY_MESSAGE: &str = + "'СтрРазделить' недоступен в режиме совместимости 8.2.13: виден с режима 8.3.6"; + const SETTINGS_MESSAGE: &str = + "Процедура или функция с указанным именем не определена: 'НетТакойПроцедуры'"; + const MCP_CONTEXT_MESSAGE: &str = "Модуль 'Общий' недоступен [Тонкий клиент, Веб-клиент, Толстый клиент (управляемое приложение)]"; + const CONTEXT_MESSAGE: &str = "Модуль 'Общий' недоступен [Тонкий клиент, Веб-клиент, Толстый клиент (управляемое приложение)] (Стандарт 469: https://v8std.ru/std/469/)"; + + let root = fixture(); + let consumer_relative = + PathBuf::from("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_path = root.path().join(&consumer_relative); + let provider_path = root.path().join("extensions/provider/CommonModules/Общий/Ext/Module.bsl"); + let provider_uri = lsp_types::Url::from_file_path(&provider_path).expect("provider URI"); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let consumer_xml_path = root.path().join("extensions/consumer/CommonModules/Потребитель.xml"); + let config_path = root.path().join("bsl-analyzer.toml"); + let xml_path = root.path().join("Configuration.xml"); + let original_config = std::fs::read_to_string(&config_path).expect("read original config"); + let original_xml = std::fs::read_to_string(&xml_path).expect("read original Configuration.xml"); + let original_consumer_xml = + std::fs::read_to_string(&consumer_xml_path).expect("read consumer module metadata"); + + // Runtime-only controls cover analysis context without changing source composition + // during the settings changes below. + let mut source = CONSUMER.to_owned(); + source.push_str(concat!( + "\n&НаСервере\n", + "Процедура ПроверитьСовместимость() Экспорт\n", + " Части = СтрРазделить(\"a,b\", \",\");\n", + "КонецПроцедуры\n\n", + "&НаСервере\n", + "Процедура ПроверитьНастройки() Экспорт\n", + " НетТакойПроцедуры();\n", + "КонецПроцедуры\n\n", + "&НаКлиенте\n", + "Процедура ПроверитьКонтекстКлиента() Экспорт\n", + " Ответ = Общий.ДатьСтроку();\n", + "КонецПроцедуры\n", + )); + std::fs::write(&consumer_path, &source).expect("write disposable S5 control source"); + let client_enabled_consumer_xml = original_consumer_xml.replace( + "false", + "true", + ); + assert_ne!( + client_enabled_consumer_xml, original_consumer_xml, + "consumer metadata has client support disabled" + ); + std::fs::write(&consumer_xml_path, &client_enabled_consumer_xml) + .expect("enable client context in disposable module metadata"); + let xml_with_later_mode = original_xml.replace( + "БазоваяКонфигурация", + "БазоваяКонфигурацияVersion8_3_17", + ); + assert_ne!(xml_with_later_mode, original_xml, "fixture XML has the expected properties"); + std::fs::write(&xml_path, &xml_with_later_mode).expect("set project XML compatibility mode"); + let initial_config = analysis_config(&original_config, Some("Version8_2_13"), true); + std::fs::write(&config_path, &initial_config).expect("set compatibility override and rule"); + + let mut lsp = Lsp::start(root.path()); + let initial_lsp = lsp.open(&consumer_path, &source); + // Give MCP its own disposable cache so this acceptance scenario cannot observe or + // modify the user's workspace cache. + let cache_parent = tempfile::tempdir().expect("owned MCP cache parent"); + let project = mcp_server::project::at(root.path()).expect("fixture project"); + let cache = + WorkspaceCacheLayout::for_project(&project, Some(cache_parent.path()), root.path(), None) + .expect("owned workspace cache"); + let state = SharedState::workspace_with_cache(root.path().to_path_buf(), cache) + .expect("MCP workspace from fixture settings"); + let server = McpServer::new(McpProfile::Workspace, state.clone()); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + let server_task = tokio::spawn(serve_stream(server, server_io)); + let client = ().serve(client_io).await.expect("real rmcp handshake"); + let relative = consumer_relative.to_str().expect("UTF-8 fixture path"); + + assert_eq!( + initial_lsp["params"]["uri"], + consumer_uri.as_str(), + "LSP opened the controlled source" + ); + assert_lsp_finding( + &initial_lsp, + &source, + COMPATIBILITY_CODE, + "СтрРазделить", + 1, + COMPATIBILITY_MESSAGE, + ); + assert_lsp_finding( + &initial_lsp, + &source, + SETTINGS_CODE, + "НетТакойПроцедуры", + 1, + SETTINGS_MESSAGE, + ); + assert_lsp_finding(&initial_lsp, &source, CONTEXT_CODE, CONTEXT_RANGE, 1, CONTEXT_MESSAGE); + let initial_mcp = mcp_diagnostic_state(&client, relative, true, true).await; + assert_mcp_finding( + &initial_mcp, + &source, + COMPATIBILITY_CODE, + "СтрРазделить", + COMPATIBILITY_MESSAGE, + ); + assert_mcp_finding(&initial_mcp, &source, SETTINGS_CODE, "НетТакойПроцедуры", SETTINGS_MESSAGE); + assert_mcp_finding(&initial_mcp, &source, CONTEXT_CODE, CONTEXT_RANGE, MCP_CONTEXT_MESSAGE); + + let provider_call = + wait_mcp_call_type(&client, &consumer_path, "ДатьСтроку", "Строка", false).await; + assert_eq!( + provider_call["definitions"][0]["location"]["path"], + "extensions/provider/CommonModules/Общий/Ext/Module.bsl", + "active provider target survives the effective settings" + ); + let provider_hover = hover(&mut lsp, 2, &consumer_uri, "ОтветПоставщика"); + assert!(provider_hover["error"].is_null(), "LSP hover failed: {provider_hover}"); + assert!( + provider_hover["result"].to_string().contains("Строка"), + "provider type: {provider_hover}" + ); + let provider_definition = definition(&mut lsp, 3, &consumer_uri, "ДатьСтроку"); + assert!(provider_definition["error"].is_null(), "LSP definition failed: {provider_definition}"); + assert!( + provider_definition["result"].to_string().contains(provider_uri.as_str()), + "provider target: {provider_definition}" + ); + + let disabled_config = analysis_config(&original_config, Some("Version8_2_13"), false); + std::fs::write(&config_path, &disabled_config).expect("disable one known analysis rule"); + send_config_change(&mut lsp, &config_path); + let disabled_lsp = lsp_diagnostic_state(&mut lsp, &consumer_uri, true, false); + assert_lsp_finding( + &disabled_lsp, + &source, + COMPATIBILITY_CODE, + "СтрРазделить", + 1, + COMPATIBILITY_MESSAGE, + ); + assert_lsp_finding(&disabled_lsp, &source, CONTEXT_CODE, CONTEXT_RANGE, 1, CONTEXT_MESSAGE); + let disabled_mcp = mcp_diagnostic_state(&client, relative, true, false).await; + assert_mcp_finding( + &disabled_mcp, + &source, + COMPATIBILITY_CODE, + "СтрРазделить", + COMPATIBILITY_MESSAGE, + ); + assert_mcp_finding(&disabled_mcp, &source, CONTEXT_CODE, CONTEXT_RANGE, MCP_CONTEXT_MESSAGE); + let retained_provider = + wait_mcp_call_type(&client, &consumer_path, "ДатьСтроку", "Строка", false).await; + assert_eq!( + retained_provider["definitions"][0]["location"]["path"], + provider_call["definitions"][0]["location"]["path"], + "rule toggle does not change source target" + ); + let retained_hover = hover(&mut lsp, 4, &consumer_uri, "ОтветПоставщика"); + assert!( + retained_hover["error"].is_null(), + "LSP hover failed after rule toggle: {retained_hover}" + ); + assert!( + retained_hover["result"].to_string().contains("Строка"), + "provider type after toggle: {retained_hover}" + ); + let retained_definition = definition(&mut lsp, 5, &consumer_uri, "ДатьСтроку"); + assert!( + retained_definition["error"].is_null(), + "LSP definition failed after rule toggle: {retained_definition}" + ); + assert!( + retained_definition["result"].to_string().contains(provider_uri.as_str()), + "provider target survives the rule toggle: {retained_definition}" + ); + + let restored_config = analysis_config(&original_config, Some("Version8_2_13"), true); + std::fs::write(&config_path, &restored_config).expect("restore known analysis rule"); + send_config_change(&mut lsp, &config_path); + let restored_lsp = lsp_diagnostic_state(&mut lsp, &consumer_uri, true, true); + assert_lsp_finding( + &restored_lsp, + &source, + SETTINGS_CODE, + "НетТакойПроцедуры", + 1, + SETTINGS_MESSAGE, + ); + let restored_mcp = mcp_diagnostic_state(&client, relative, true, true).await; + assert_mcp_finding( + &restored_mcp, + &source, + SETTINGS_CODE, + "НетТакойПроцедуры", + SETTINGS_MESSAGE, + ); + + // Removing the TOML override exposes the fixed XML 8.3.17 mode: the platform + // diagnostic disappears while the independent setting and execution context stay. + let xml_fallback = analysis_config(&original_config, None, true); + std::fs::write(&config_path, &xml_fallback).expect("use Configuration.xml compatibility mode"); + send_config_change(&mut lsp, &config_path); + let fallback_lsp = lsp_diagnostic_state(&mut lsp, &consumer_uri, false, true); + assert_lsp_finding( + &fallback_lsp, + &source, + SETTINGS_CODE, + "НетТакойПроцедуры", + 1, + SETTINGS_MESSAGE, + ); + let fallback_mcp = mcp_diagnostic_state(&client, relative, false, true).await; + assert_mcp_finding( + &fallback_mcp, + &source, + SETTINGS_CODE, + "НетТакойПроцедуры", + SETTINGS_MESSAGE, + ); + assert_lsp_finding(&fallback_lsp, &source, CONTEXT_CODE, CONTEXT_RANGE, 1, CONTEXT_MESSAGE); + assert_mcp_finding(&fallback_mcp, &source, CONTEXT_CODE, CONTEXT_RANGE, MCP_CONTEXT_MESSAGE); + let fallback_provider = + wait_mcp_call_type(&client, &consumer_path, "ДатьСтроку", "Строка", false).await; + assert_eq!( + fallback_provider["definitions"][0]["location"]["path"], + provider_call["definitions"][0]["location"]["path"], + "XML compatibility fallback preserves MCP provider target" + ); + let fallback_hover = hover(&mut lsp, 6, &consumer_uri, "ОтветПоставщика"); + assert!( + fallback_hover["error"].is_null(), + "LSP hover failed after XML fallback: {fallback_hover}" + ); + assert!( + fallback_hover["result"].to_string().contains("Строка"), + "provider type after XML fallback: {fallback_hover}" + ); + let fallback_definition = definition(&mut lsp, 7, &consumer_uri, "ДатьСтроку"); + assert!( + fallback_definition["error"].is_null(), + "LSP definition failed after XML fallback: {fallback_definition}" + ); + assert!( + fallback_definition["result"].to_string().contains(provider_uri.as_str()), + "XML compatibility fallback preserves LSP provider target: {fallback_definition}" + ); + + assert_eq!(source_section(&xml_fallback), source_section(&original_config)); + assert_eq!( + std::fs::read_to_string(&consumer_path).unwrap(), + source, + "source scope stays fixed" + ); + assert!(lsp.shutdown(8).success(), "LSP exits cleanly after compatibility fallback"); + client.cancel().await.ok(); + tokio::time::timeout(Duration::from_secs(15), server_task) + .await + .expect("MCP server task joins after transport cancellation") + .expect("MCP server task joins cleanly") + .expect("MCP server exits cleanly"); + state.shutdown(); + std::fs::write(&consumer_path, CONSUMER).expect("restore disposable BSL fixture"); + std::fs::write(&consumer_xml_path, &original_consumer_xml) + .expect("restore disposable consumer metadata"); + std::fs::write(&config_path, &original_config).expect("restore disposable project config"); + std::fs::write(&xml_path, &original_xml).expect("restore disposable Configuration.xml"); + assert_eq!(std::fs::read_to_string(&consumer_path).unwrap(), CONSUMER); + assert_eq!(std::fs::read_to_string(&consumer_xml_path).unwrap(), original_consumer_xml); + assert_eq!(std::fs::read_to_string(&config_path).unwrap(), original_config); + assert_eq!(std::fs::read_to_string(&xml_path).unwrap(), original_xml); +} diff --git a/crates/bsl-analyzer/tests/shared_workspace_analysis_s6.rs b/crates/bsl-analyzer/tests/shared_workspace_analysis_s6.rs new file mode 100644 index 000000000..7b430dcbc --- /dev/null +++ b/crates/bsl-analyzer/tests/shared_workspace_analysis_s6.rs @@ -0,0 +1,427 @@ +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use common::Lsp; +use mcp_server::{serve_stream, McpProfile, McpServer, SharedState, WorkspaceCacheLayout}; +use rmcp::model::{CallToolRequestParams, ClientRequest, PingRequest}; +use rmcp::service::RunningService; +use rmcp::{RoleClient, ServiceExt}; +use serde_json::{json, Map, Value}; +use tokio::task::JoinHandle; + +type Client = RunningService; +type ServerTask = JoinHandle>; + +const CONSUMER: &str = include_str!( + "fixtures/shared_workspace_analysis_s6/extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl" +); +const CONFIG_A: &str = include_str!("fixtures/shared_workspace_analysis_s6/bsl-analyzer.toml"); +const CONFIG_B: &str = r#"[source] +root = "base" +extensions = [ + { name = "Поставщик", path = "extensions/provider-b", scope = "InfoBase" }, + { name = "Потребитель", path = "extensions/consumer", scope = "InfoBase", dependsOn = ["Поставщик"] }, +] +"#; + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().expect("fixture workspace"); + let fixture_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/shared_workspace_analysis_s6"); + for entry in walkdir::WalkDir::new(&fixture_root) { + let entry = entry.expect("fixture entry"); + let relative = entry.path().strip_prefix(&fixture_root).expect("fixture-relative path"); + let target = root.path().join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target).expect("fixture directory"); + } else { + std::fs::create_dir_all(target.parent().expect("fixture parent")) + .expect("fixture parent directory"); + std::fs::copy(entry.path(), target).expect("fixture file"); + } + } + root +} + +fn args(pairs: &[(&str, Value)]) -> Map { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() +} + +fn position(text: &str, needle: &str) -> (u32, u32) { + let (line, source_line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains(needle)) + .expect("position token exists"); + let byte_column = source_line.find(needle).expect("position token on line"); + (line as u32, source_line[..byte_column].encode_utf16().count() as u32) +} + +async fn mcp_session( + root: &Path, + cache: WorkspaceCacheLayout, +) -> (Client, ServerTask, SharedState) { + let state = SharedState::workspace_with_cache(root.to_path_buf(), cache) + .expect("workspace scope boots from the current project config"); + let server = McpServer::new(McpProfile::Workspace, state.clone()); + let (client_io, server_io) = tokio::io::duplex(4 * 1024 * 1024); + let task = tokio::spawn(serve_stream(server, server_io)); + let client = ().serve(client_io).await.expect("real rmcp handshake"); + (client, task, state) +} + +async fn symbol_info(client: &Client, path: &Path, needle: &str, timeout: Duration) -> Value { + let (line, column) = position(CONSUMER, needle); + let call = tokio::time::timeout( + timeout, + client.call_tool(CallToolRequestParams::new("symbol_info").with_arguments(args(&[ + ("path", json!(path.to_string_lossy())), + ("line", json!(line)), + ("column", json!(column)), + ]))), + ) + .await + .expect("MCP symbol_info deadline") + .expect("MCP symbol_info transport"); + call.structured_content.expect("symbol_info structured result") +} + +async fn wait_mcp_target( + client: &Client, + path: &Path, + needle: &str, + expected_type: &str, + expected_target: &str, +) -> Value { + let (expected_root_id, expected_path_suffix) = expected_target + .split_once("/CommonModules/") + .expect("expected target includes source root and module path"); + let expected_root_id = expected_root_id.split('/').collect::(); + let expected_path = format!("CommonModules/{expected_path_suffix}"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(45); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!(!remaining.is_zero(), "MCP never resolved {needle}"); + let card = symbol_info(client, path, needle, remaining).await; + match card["status"].as_str() { + Some("loading") => {} + Some("ok") => { + assert_eq!( + card["return_type"], expected_type, + "semantic type for {needle}: {card}" + ); + let definitions = card["definitions"].as_array().expect("definition list"); + assert!( + definitions.iter().any(|definition| { + definition["location"]["root_id"] + == expected_root_id.to_string_lossy().as_ref() + && definition["location"]["path"] == expected_path + }), + "active composition resolves {needle} into {expected_target}: {card}" + ); + return card; + } + other => panic!("MCP returned {other:?} for active method {needle}: {card}"), + } + assert!(tokio::time::Instant::now() < deadline, "MCP never resolved {needle}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn assert_mcp_missing(client: &Client, path: &Path, needle: &str) { + let card = symbol_info(client, path, needle, Duration::from_secs(45)).await; + let no_target = + card["definitions"].is_null() || card["definitions"].as_array().is_some_and(Vec::is_empty); + assert!( + card["status"] == "not_found" + || (card["status"] == "ok" + && card["kind"] == "unresolved" + && card["return_type"].is_null()), + "inactive source-set method is not presented as resolved: {card}" + ); + assert!(no_target, "inactive source-set method has no definition target: {card}"); +} + +fn send_config_change(lsp: &mut Lsp, config_path: &Path) { + lsp.send(json!({ + "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", + "params": {"changes": [{ + "uri": lsp_types::Url::from_file_path(config_path).expect("config file URI"), + "type": 2 + }]} + })); +} + +fn lsp_definition( + lsp: &mut Lsp, + id: u64, + uri: &lsp_types::Url, + needle: &str, + timeout: Duration, +) -> Value { + let (line, character) = position(CONSUMER, needle); + lsp.send(json!({ + "jsonrpc": "2.0", "id": id, "method": "textDocument/definition", + "params": { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character} + } + })); + lsp.wait_for_within(timeout, |message| { + message["id"] == id + && message["method"].is_null() + && (message.get("result").is_some() || message.get("error").is_some()) + }) + .unwrap_or_else(|| panic!("LSP definition {needle} exceeded {timeout:?}")) +} + +fn wait_lsp_target( + lsp: &mut Lsp, + first_id: u64, + uri: &lsp_types::Url, + needle: &str, + target_uri: &lsp_types::Url, +) { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let mut id = first_id; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP never resolved {needle}"); + let response = lsp_definition(lsp, id, uri, needle, remaining); + if response["error"]["code"] == -32801 { + assert!( + std::time::Instant::now() < deadline, + "LSP definition remained ContentModified for {needle}: {response}" + ); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + continue; + } + assert!(response["error"].is_null(), "LSP definition failed: {response}"); + if response["result"].to_string().contains(target_uri.as_str()) { + return; + } + assert!( + response["result"].is_null() + || response["result"].as_array().is_some_and(Vec::is_empty), + "definition resolved to an unexpected source-set target: {response}" + ); + assert!(std::time::Instant::now() < deadline, "LSP never resolved {needle}"); + id += 1; + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn assert_lsp_missing(lsp: &mut Lsp, id: u64, uri: &lsp_types::Url, needle: &str) { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let mut request_id = id; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!remaining.is_zero(), "LSP definition stayed busy for {needle}"); + let response = lsp_definition(lsp, request_id, uri, needle, remaining); + if response["error"]["code"] == -32801 { + assert!( + std::time::Instant::now() < deadline, + "LSP definition remained ContentModified for {needle}: {response}" + ); + request_id += 1; + std::thread::sleep(Duration::from_millis(50)); + continue; + } + assert!(response["error"].is_null(), "LSP definition failed: {response}"); + assert!( + response["result"].is_null() + || response["result"].as_array().is_some_and(Vec::is_empty), + "inactive source-set method has no definition: {response}" + ); + return; + } +} + +fn assert_diagnostic(published: &Value) { + let diagnostics = published["params"]["diagnostics"].as_array().expect("diagnostic list"); + let (line, character) = position(CONSUMER, "НетТакойПроцедуры"); + let unresolved = diagnostics + .iter() + .find(|diagnostic| diagnostic["code"] == "UnresolvedBareCall") + .expect("fixture retains a known unresolved-call diagnostic"); + assert_eq!(unresolved["severity"], 1, "known diagnostic severity: {unresolved}"); + assert_eq!(unresolved["range"]["start"]["line"], line); + assert_eq!(unresolved["range"]["start"]["character"], character); + assert_eq!( + unresolved["message"], + "Процедура или функция с указанным именем не определена: 'НетТакойПроцедуры'" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn source_set_change_retires_mcp_scope_and_reconnects_to_new_semantics() { + eprintln!("S6 phase=source-set/setup"); + let root = fixture(); + assert_eq!(std::fs::read_to_string(root.path().join("bsl-analyzer.toml")).unwrap(), CONFIG_A); + let consumer_relative = + PathBuf::from("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_path = root.path().join(&consumer_relative); + let config_path = root.path().join("bsl-analyzer.toml"); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let provider_a_uri = lsp_types::Url::from_file_path( + root.path().join("extensions/provider-a/CommonModules/Общий/Ext/Module.bsl"), + ) + .expect("provider A URI"); + let provider_b_uri = lsp_types::Url::from_file_path( + root.path().join("extensions/provider-b/CommonModules/Общий/Ext/Module.bsl"), + ) + .expect("provider B URI"); + + let cache_parent = tempfile::tempdir().expect("owned MCP cache parent"); + let old_project = mcp_server::project::at(root.path()).expect("initial source-set project"); + let old_cache = WorkspaceCacheLayout::for_project( + &old_project, + Some(cache_parent.path()), + cache_parent.path(), + None, + ) + .expect("initial cache scope"); + let old_cache_root = old_cache.root().to_path_buf(); + let old_scope_stamp = old_cache.scope_stamp(); + let (old_client, old_task, old_state) = mcp_session(root.path(), old_cache).await; + + eprintln!("S6 phase=source-set/lsp-initialize"); + let mut lsp = Lsp::start(root.path()); + eprintln!("S6 phase=source-set/open-consumer"); + let initial_diagnostics = lsp.open(&consumer_path, CONSUMER); + assert_diagnostic(&initial_diagnostics); + let initial_mcp = wait_mcp_target( + &old_client, + &consumer_path, + "ДатьСтроку", + "Строка", + "extensions/provider-a/CommonModules/Общий/Ext/Module.bsl", + ) + .await; + assert_eq!(initial_mcp["return_type"], "Строка"); + wait_lsp_target(&mut lsp, 2, &consumer_uri, "ДатьСтроку", &provider_a_uri); + assert_lsp_missing(&mut lsp, 3, &consumer_uri, "ДатьЧисло"); + assert_mcp_missing(&old_client, &consumer_path, "ДатьЧисло").await; + + eprintln!("S6 phase=source-set/change-config"); + std::fs::write(&config_path, CONFIG_B).expect("switch the configured provider source root"); + send_config_change(&mut lsp, &config_path); + let updated_diagnostics = lsp + .wait_for_within(Duration::from_secs(45), |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == consumer_uri.as_str() + }) + .expect("LSP republishes diagnostics after source-set reload"); + assert_diagnostic(&updated_diagnostics); + wait_lsp_target(&mut lsp, 4, &consumer_uri, "ДатьЧисло", &provider_b_uri); + assert_lsp_missing(&mut lsp, 5, &consumer_uri, "ДатьСтроку"); + + eprintln!("S6 phase=source-set/retire-old-mcp"); + let new_project = mcp_server::project::at(root.path()).expect("updated source-set project"); + let new_cache = WorkspaceCacheLayout::for_project( + &new_project, + Some(cache_parent.path()), + cache_parent.path(), + None, + ) + .expect("updated cache scope"); + assert_ne!(new_cache.root(), old_cache_root, "source-set identity selects a new cache leaf"); + assert_ne!(new_cache.scope_stamp(), old_scope_stamp, "source-set identity changes scope stamp"); + assert!( + !new_cache.root().exists(), + "the retired owner has not created or published into the new cache scope" + ); + tokio::time::timeout(Duration::from_secs(15), old_task) + .await + .expect("source-set drift retires the old MCP transport") + .expect("old MCP task joins") + .expect("retired MCP session closes cleanly"); + assert!( + old_client.send_request(ClientRequest::PingRequest(PingRequest::default())).await.is_err(), + "retired MCP scope does not answer requests" + ); + old_state.shutdown(); + + let new_cache_root = new_cache.root().to_path_buf(); + let (new_client, new_task, new_state) = mcp_session(root.path(), new_cache).await; + assert!( + old_cache_root.exists() && new_cache_root != old_cache_root && new_cache_root.exists(), + "reconnection serves a distinct validated scope" + ); + let new_mcp = wait_mcp_target( + &new_client, + &consumer_path, + "ДатьЧисло", + "Число", + "extensions/provider-b/CommonModules/Общий/Ext/Module.bsl", + ) + .await; + assert_eq!(new_mcp["return_type"], "Число"); + assert_mcp_missing(&new_client, &consumer_path, "ДатьСтроку").await; + + eprintln!("S6 phase=source-set/lsp-shutdown"); + new_client.cancel().await.expect("new MCP client disconnects"); + tokio::time::timeout(Duration::from_secs(5), new_task) + .await + .expect("reconnected MCP session exits") + .expect("new MCP task joins") + .expect("new MCP session closes cleanly"); + new_state.shutdown(); + assert!(lsp.shutdown(6).success(), "LSP exits cleanly"); + eprintln!("S6 phase=source-set/complete"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn excluding_and_readmitting_a_provider_tombstones_and_restores_its_definition() { + eprintln!("S6 phase=exclude/setup"); + let root = fixture(); + let consumer_path = + root.path().join("extensions/consumer/CommonModules/Потребитель/Ext/Module.bsl"); + let consumer_uri = lsp_types::Url::from_file_path(&consumer_path).expect("consumer URI"); + let provider_uri = lsp_types::Url::from_file_path( + root.path().join("extensions/provider-a/CommonModules/Общий/Ext/Module.bsl"), + ) + .expect("provider URI"); + let config_path = root.path().join("bsl-analyzer.toml"); + eprintln!("S6 phase=exclude/lsp-initialize"); + let mut lsp = Lsp::start(root.path()); + + eprintln!("S6 phase=exclude/open-consumer"); + let initial_diagnostics = lsp.open(&consumer_path, CONSUMER); + assert_diagnostic(&initial_diagnostics); + wait_lsp_target(&mut lsp, 2, &consumer_uri, "ДатьСтроку", &provider_uri); + + let excluded_config = CONFIG_A + .replace("root = \"base\"", "root = \"base\"\nexclude = [\"extensions/provider-a\"]"); + assert_ne!(excluded_config, CONFIG_A, "the exclusion edit was applied"); + eprintln!("S6 phase=exclude/write-and-notify"); + std::fs::write(&config_path, excluded_config).expect("exclude provider source root"); + send_config_change(&mut lsp, &config_path); + let excluded_diagnostics = lsp + .wait_for_within(Duration::from_secs(45), |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == consumer_uri.as_str() + }) + .expect("LSP republishes diagnostics after source exclusion"); + assert_diagnostic(&excluded_diagnostics); + assert_lsp_missing(&mut lsp, 3, &consumer_uri, "ДатьСтроку"); + + eprintln!("S6 phase=exclude/restore-config"); + std::fs::write(&config_path, CONFIG_A).expect("restore provider source root"); + send_config_change(&mut lsp, &config_path); + let restored_diagnostics = lsp + .wait_for_within(Duration::from_secs(45), |message| { + message["method"] == "textDocument/publishDiagnostics" + && message["params"]["uri"] == consumer_uri.as_str() + }) + .expect("LSP republishes diagnostics after provider readmission"); + assert_diagnostic(&restored_diagnostics); + wait_lsp_target(&mut lsp, 4, &consumer_uri, "ДатьСтроку", &provider_uri); + + eprintln!("S6 phase=exclude/lsp-shutdown"); + assert!(lsp.shutdown(6).success(), "LSP exits cleanly"); + eprintln!("S6 phase=exclude/complete"); +} diff --git a/crates/ide-host-core/src/lib.rs b/crates/ide-host-core/src/lib.rs index 20a733151..ab35a40a9 100644 --- a/crates/ide-host-core/src/lib.rs +++ b/crates/ide-host-core/src/lib.rs @@ -10,7 +10,8 @@ mod load; mod metadata; pub use load::{ - build_source_root, register_files_disk_backed, set_file_text_source, FileTextSource, + apply_source_batch, build_source_root, register_files_disk_backed, set_file_text_source, + BatchTextSource, FileTextSource, SourceBatchChange, SourceBatchEffects, }; pub use metadata::{bootstrap_metadata_substrate, refresh_metadata_substrate, VfsWrite}; diff --git a/crates/ide-host-core/src/load.rs b/crates/ide-host-core/src/load.rs index d82bc370e..37f14dd64 100644 --- a/crates/ide-host-core/src/load.rs +++ b/crates/ide-host-core/src/load.rs @@ -9,6 +9,7 @@ //! it lives here once. use std::path::PathBuf; +use std::sync::Arc; use base_db::{content_revision, read_disk_text, SourceDatabase, SourceRoot}; use ide::RootDatabaseImpl; @@ -42,6 +43,194 @@ pub enum FileTextSource<'a> { Unreadable, } +/// Owned payload for one item in a source batch. LSP changes retain the VFS +/// `Arc`; disk readers can pass their one-file `String` through without +/// collecting the workspace text first. +pub enum BatchTextSource { + /// Open editor content, stored as a resident overlay. + Overlay(Arc), + /// Closed-file content, represented by its revision and read on demand. + Disk(Arc), + /// The same disk-backed source for a caller that owns a one-file read. + DiskOwned(String), + /// A deleted file; retain an empty readable tombstone. + Deleted, + /// A present file whose bytes could not be read. + Unreadable, +} + +/// One ordered file-universe or source-input mutation in a prepared batch. +pub enum SourceBatchChange { + /// Insert or update a source path. Missing source is reserved for non-BSL service files. + Upsert { + /// The VFS identity used by Salsa and the FileSet. + file_id: FileId, + /// The path published in the source root. + path: VfsPath, + /// Source input, omitted only for non-BSL service entries. + source: Option, + }, + /// Remove a registered file from the source root and tombstone BSL content. + Remove { + /// The VFS identity to remove. + file_id: FileId, + }, +} + +/// Observable source changes produced by applying a batch. +#[derive(Debug, Default)] +pub struct SourceBatchEffects { + /// Whether the published FileSet differs from the input FileSet. + pub file_set_changed: bool, + /// Whether BSL membership or content changed. + pub bsl_source_changed: bool, + /// IDs whose path or source inputs actually changed. + pub changed_file_ids: Vec, + /// Paths associated with actual source or membership changes. + pub affected_paths: Vec, +} + +/// Apply a prepared source batch to one existing root. Drivers retain path +/// policy, metadata lifecycle, and admission decisions; this function only +/// orders file inputs before the single FileSet publication. +pub fn apply_source_batch( + db: &mut RootDatabaseImpl, + source_root_id: base_db::SourceRootId, + changes: impl IntoIterator, +) -> SourceBatchEffects { + use base_db::{SourceDatabase, SourceRoot}; + + let mut changes = changes.into_iter().peekable(); + if changes.peek().is_none() { + return SourceBatchEffects::default(); + } + + // Finish the read and drop the Salsa value before the first tracked write. + let mut file_set = { + let source_root = db.source_root_input(source_root_id).root(db); + source_root.file_set().clone() + }; + let mut effects = SourceBatchEffects::default(); + + for change in changes { + match change { + SourceBatchChange::Upsert { file_id, path, source } => { + let is_bsl = project_model::is_bsl_source_path(path.as_path()); + assert!(source.is_some() || !is_bsl, "visible BSL sources need a content revision"); + let path_changed = file_set.path_for_file(&file_id) != Some(&path); + let (source_changed, disk_revision) = + source.as_ref().map_or((false, None), |source| { + let (matches, revision) = source_matches(db, file_id, source); + (!matches, revision) + }); + if !path_changed && !source_changed { + continue; + } + + db.set_file_source_root(file_id, source_root_id); + if let Some(source) = source { + apply_owned_source(db, file_id, source, disk_revision); + } + if path_changed { + file_set.remove(file_id); + file_set.insert(file_id, path.clone()); + effects.file_set_changed = true; + } + if source_changed || path_changed { + effects.changed_file_ids.push(file_id); + effects.affected_paths.push(path.as_path().to_path_buf()); + effects.bsl_source_changed |= is_bsl; + } + } + SourceBatchChange::Remove { file_id } => { + let Some(path) = file_set.path_for_file(&file_id).cloned() else { + continue; + }; + let is_bsl = project_model::is_bsl_source_path(path.as_path()); + if is_bsl { + let source = BatchTextSource::Deleted; + let (matches, disk_revision) = source_matches(db, file_id, &source); + if !matches { + apply_owned_source(db, file_id, source, disk_revision); + } + } + file_set.remove(file_id); + effects.file_set_changed = true; + effects.changed_file_ids.push(file_id); + effects.affected_paths.push(path.as_path().to_path_buf()); + effects.bsl_source_changed |= is_bsl; + } + } + } + + if effects.file_set_changed { + db.set_source_root(source_root_id, SourceRoot::new_local(file_set)); + } + effects +} + +fn source_matches( + db: &RootDatabaseImpl, + file_id: FileId, + source: &BatchTextSource, +) -> (bool, Option) { + use base_db::SourceDatabase; + + let disk_revision = match source { + BatchTextSource::Disk(text) => Some(content_revision(text)), + BatchTextSource::DiskOwned(text) => Some(content_revision(text)), + _ => None, + }; + let Some(revision_input) = db.try_file_revision_input(file_id) else { + return (false, disk_revision); + }; + let unreadable = matches!(source, BatchTextSource::Unreadable); + if revision_input.unreadable(db) != unreadable { + return (false, disk_revision); + } + + let matches = match source { + BatchTextSource::Overlay(text) => { + let Some(input) = db.try_file_text_input(file_id) else { return (false, None) }; + if input.text(db) != text.as_ref() { + return (false, None); + } + revision_input.revision(db) == content_revision(text) + } + BatchTextSource::Disk(_) | BatchTextSource::DiskOwned(_) => { + db.try_file_text_input(file_id).is_none() + && revision_input.revision(db) + == disk_revision.expect("disk source has a precomputed revision") + } + BatchTextSource::Deleted | BatchTextSource::Unreadable => { + db.try_file_text_input(file_id).is_some_and(|input| input.text(db).is_empty()) + && revision_input.revision(db) == content_revision("") + } + }; + (matches, disk_revision) +} + +fn apply_owned_source( + db: &mut RootDatabaseImpl, + file_id: FileId, + source: BatchTextSource, + disk_revision: Option, +) { + match source { + BatchTextSource::Overlay(text) => { + set_file_text_source(db, file_id, FileTextSource::Overlay(&text)) + } + BatchTextSource::Disk(_) | BatchTextSource::DiskOwned(_) => db.set_file_revision_from_disk( + file_id, + disk_revision.expect("disk source revision was computed during comparison"), + ), + BatchTextSource::Deleted => set_file_text_source(db, file_id, FileTextSource::Deleted), + BatchTextSource::Unreadable => { + set_file_text_source(db, file_id, FileTextSource::Unreadable) + } + } +} + /// Apply one file's [`FileTextSource`] to `db`. A leaf salsa mutation only: it does /// not touch the source root / FileSet, project config, or any driver lifecycle. pub fn set_file_text_source( @@ -79,23 +268,30 @@ pub fn build_source_root(all_files: &[(FileId, PathBuf)]) -> SourceRoot { /// overlay (so a later query yields `""` instead of panicking on the disk re-read) /// and its `(path, error)` is returned, letting a strict caller (e.g. CLI) surface /// the real read error while a lenient caller (e.g. the MCP resident) keeps serving. +/// The existing root is updated through [`apply_source_batch`], so new file inputs +/// are installed before a changed FileSet is published and unrelated paths survive. pub fn register_files_disk_backed( db: &mut RootDatabaseImpl, source_root_id: base_db::SourceRootId, files: &[(FileId, PathBuf)], ) -> Vec<(PathBuf, std::io::Error)> { let mut unreadable = Vec::new(); - for (file_id, path) in files { - db.set_file_source_root(*file_id, source_root_id); - match read_disk_text(path) { - Ok(text) => set_file_text_source(db, *file_id, FileTextSource::Disk(&text)), - Err(e) => { - tracing::warn!(path = %path.display(), "resident load: read failed: {e}"); - set_file_text_source(db, *file_id, FileTextSource::Unreadable); - unreadable.push((path.clone(), e)); + let changes = files.iter().map(|(file_id, path)| { + let source = match read_disk_text(path) { + Ok(text) => BatchTextSource::DiskOwned(text), + Err(error) => { + tracing::warn!(path = %path.display(), "resident load: read failed: {error}"); + unreadable.push((path.clone(), error)); + BatchTextSource::Unreadable } + }; + SourceBatchChange::Upsert { + file_id: *file_id, + path: VfsPath::new(path.clone()), + source: Some(source), } - } + }); + apply_source_batch(db, source_root_id, changes); unreadable } @@ -104,6 +300,246 @@ mod tests { use super::*; use ide::Analysis; + fn empty_root_db() -> RootDatabaseImpl { + use base_db::SourceDatabase; + + let mut db = RootDatabaseImpl::default(); + db.set_source_root(base_db::SourceRootId(0), SourceRoot::new_local(FileSet::new())); + db + } + + fn upsert(file_id: FileId, path: &str, source: BatchTextSource) -> SourceBatchChange { + SourceBatchChange::Upsert { + file_id, + path: VfsPath::new(PathBuf::from(path)), + source: Some(source), + } + } + + #[test] + fn source_batch_registers_inputs_before_visibility_and_never_creates_root_one() { + use base_db::SourceDatabase; + + let mut db = empty_root_db(); + let file_id = FileId(7); + let path = VfsPath::new(PathBuf::from("/workspace/a.bsl")); + let effects = apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert( + file_id, + "/workspace/a.bsl", + BatchTextSource::Disk(Arc::from("Процедура A() КонецПроцедуры")), + )], + ); + + assert!(effects.file_set_changed); + assert!(effects.bsl_source_changed); + assert!(db.try_file_revision_input(file_id).is_some()); + assert_eq!( + db.file_source_root_input(file_id).source_root_id(&db), + base_db::SourceRootId(0) + ); + assert_eq!( + db.source_root_input(base_db::SourceRootId(0)) + .root(&db) + .file_set() + .path_for_file(&file_id), + Some(&path) + ); + assert!(db.try_source_root_input(base_db::SourceRootId(1)).is_none()); + } + + #[test] + fn source_batch_repeated_inputs_are_a_noop_and_same_id_can_be_recreated() { + use base_db::SourceDatabase; + + let mut db = empty_root_db(); + let file_id = FileId(3); + let add = || { + upsert( + file_id, + "/workspace/recreated.bsl", + BatchTextSource::Overlay(Arc::from("Текст")), + ) + }; + apply_source_batch(&mut db, base_db::SourceRootId(0), [add()]); + let unchanged = apply_source_batch(&mut db, base_db::SourceRootId(0), [add()]); + assert!(unchanged.changed_file_ids.is_empty()); + assert!(!unchanged.file_set_changed); + + apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [SourceBatchChange::Remove { file_id }], + ); + let recreated = apply_source_batch(&mut db, base_db::SourceRootId(0), [add()]); + assert!(recreated.file_set_changed); + assert_eq!( + db.source_root_input(base_db::SourceRootId(0)) + .root(&db) + .file_set() + .path_for_file(&file_id), + Some(&VfsPath::new(PathBuf::from("/workspace/recreated.bsl"))) + ); + assert_eq!(&*Analysis::from_database(db.clone()).file_text(file_id), "Текст"); + } + + #[test] + fn source_batch_overlay_change_and_empty_overlay_remain_distinct_from_unreadable() { + use base_db::SourceDatabase; + + let mut db = empty_root_db(); + let file_id = FileId(11); + let path = "/workspace/overlay.bsl"; + let apply_overlay = |db: &mut RootDatabaseImpl, text: &str| { + apply_source_batch( + db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Overlay(Arc::from(text)))], + ) + }; + + apply_overlay(&mut db, ""); + let unchanged = apply_overlay(&mut db, ""); + assert!(unchanged.changed_file_ids.is_empty()); + assert!(!db.file_is_unread(file_id)); + + let changed = apply_overlay(&mut db, "changed"); + assert_eq!(changed.changed_file_ids.as_slice(), &[file_id]); + assert_eq!(&*Analysis::from_database(db.clone()).file_text(file_id), "changed"); + + let unreadable = apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Unreadable)], + ); + assert_eq!(unreadable.changed_file_ids.as_slice(), &[file_id]); + assert!(db.file_is_unread(file_id)); + + let still_unreadable = apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Unreadable)], + ); + assert!(still_unreadable.changed_file_ids.is_empty()); + + let readable_empty = apply_overlay(&mut db, ""); + assert_eq!(readable_empty.changed_file_ids.as_slice(), &[file_id]); + assert!(!db.file_is_unread(file_id)); + } + + #[test] + fn source_batch_distinguishes_overlay_disk_deleted_and_unreadable() { + use base_db::SourceDatabase; + + let mut db = empty_root_db(); + let file_id = FileId(9); + let path = "/workspace/modes.bsl"; + apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Overlay(Arc::from("same")))], + ); + let disk = apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Disk(Arc::from("same")))], + ); + assert!(disk.bsl_source_changed, "overlay-to-disk is a source-mode change"); + assert!(db.try_file_text_input(file_id).is_none()); + + apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Unreadable)], + ); + assert!(db.file_is_unread(file_id)); + apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [SourceBatchChange::Remove { file_id }], + ); + assert!(!db.file_is_unread(file_id), "deletion clears unreadable state"); + } + + #[test] + fn source_batch_disk_owned_noop_and_unreadable_healing_remain_disk_backed() { + use base_db::SourceDatabase; + + let mut db = empty_root_db(); + let file_id = FileId(13); + let path = "/workspace/owned-disk.bsl"; + let apply_disk = |db: &mut RootDatabaseImpl, text: &str| { + apply_source_batch( + db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::DiskOwned(text.to_owned()))], + ) + }; + + let initial = apply_disk(&mut db, "initial"); + assert_eq!(initial.changed_file_ids.as_slice(), &[file_id]); + assert!(db.try_file_text_input(file_id).is_none(), "disk text is not pinned"); + assert_eq!( + db.try_file_revision_input(file_id).unwrap().revision(&db), + content_revision("initial") + ); + + let unchanged = apply_disk(&mut db, "initial"); + assert!(unchanged.changed_file_ids.is_empty()); + assert!(!unchanged.file_set_changed); + assert!(db.try_file_text_input(file_id).is_none(), "no-op keeps disk text unpinned"); + assert_eq!( + db.try_file_revision_input(file_id).unwrap().revision(&db), + content_revision("initial") + ); + + let unreadable = apply_source_batch( + &mut db, + base_db::SourceRootId(0), + [upsert(file_id, path, BatchTextSource::Unreadable)], + ); + assert_eq!(unreadable.changed_file_ids.as_slice(), &[file_id]); + assert!(db.file_is_unread(file_id)); + + let healed = apply_disk(&mut db, "recovered"); + assert_eq!(healed.changed_file_ids.as_slice(), &[file_id]); + assert!(!db.file_is_unread(file_id)); + assert!(db.try_file_text_input(file_id).is_none(), "healed disk text is not pinned"); + assert_eq!( + db.try_file_revision_input(file_id).unwrap().revision(&db), + content_revision("recovered") + ); + } + + #[test] + fn source_batch_consumes_owned_text_one_item_at_a_time() { + use std::sync::Weak; + + let mut db = empty_root_db(); + let mut next = 0u32; + let mut previous: Option> = None; + let stream = std::iter::from_fn(|| { + if let Some(previous) = previous.take() { + assert!(previous.upgrade().is_none(), "previous batch text is still retained"); + } + if next == 8 { + return None; + } + let file_id = FileId(next); + let path = format!("/workspace/{next}.bsl"); + let text: Arc = Arc::from(format!("text {next}")); + previous = Some(Arc::downgrade(&text)); + next += 1; + Some(upsert(file_id, &path, BatchTextSource::Disk(text))) + }); + + let effects = apply_source_batch(&mut db, base_db::SourceRootId(0), stream); + assert_eq!(next, 8); + assert_eq!(effects.changed_file_ids.len(), 8); + } + #[test] fn disk_backed_registration_and_unreadable_report() { let dir = tempfile::tempdir().unwrap(); @@ -114,14 +550,20 @@ mod tests { let files = vec![(FileId(0), good.clone()), (FileId(1), missing.clone())]; let root = base_db::SourceRootId(0); let mut db = RootDatabaseImpl::default(); - db.set_source_root(root, build_source_root(&files)); + db.set_source_root(root, SourceRoot::new_local(FileSet::new())); let unreadable = register_files_disk_backed(&mut db, root, &files); // The missing file is reported with its read error (and overlaid empty); the // readable file is disk-backed — no `FileTextInput` overlay pinned — yet queryable. assert_eq!(unreadable.len(), 1); assert_eq!(unreadable[0].0, missing); + let source_root = db.source_root_input(root).root(&db); + assert_eq!(source_root.file_set().path_for_file(&FileId(0)), Some(&VfsPath::new(good))); + assert_eq!(source_root.file_set().path_for_file(&FileId(1)), Some(&VfsPath::new(missing))); + assert_eq!(db.file_source_root_input(FileId(0)).source_root_id(&db), root); + assert_eq!(db.file_source_root_input(FileId(1)).source_root_id(&db), root); assert!(db.try_file_text(FileId(0)).is_none(), "readable file must be disk-backed"); + assert!(db.file_is_unread(FileId(1))); let text = Analysis::from_database(db.clone()).file_text(FileId(0)); assert!(text.contains("Процедура"), "disk-backed text is read on demand"); } diff --git a/crates/mcp-server/src/diagnostics_state/lifecycle.rs b/crates/mcp-server/src/diagnostics_state/lifecycle.rs index 546492066..b839e0f28 100644 --- a/crates/mcp-server/src/diagnostics_state/lifecycle.rs +++ b/crates/mcp-server/src/diagnostics_state/lifecycle.rs @@ -812,6 +812,7 @@ impl DiagnosticsState { // model the LSP server and CLI `analyze` use. let crate::graph::input::BatchLoad { mut db, unread } = db_for_files_lazy(&source_root, files, &configs, None); + super::resident::apply_project_compatibility_mode(&mut db, project.compatibility_mode()); // A file whose bytes could not be read is NOT served: it is held out of // `by_path` and answered as "known but unreadable", and the retry list // re-reads it every window. Its baseline fingerprint stays — the scan did diff --git a/crates/mcp-server/src/diagnostics_state/resident.rs b/crates/mcp-server/src/diagnostics_state/resident.rs index 0f68ecb76..f05e4c187 100644 --- a/crates/mcp-server/src/diagnostics_state/resident.rs +++ b/crates/mcp-server/src/diagnostics_state/resident.rs @@ -48,8 +48,8 @@ impl ide_host_core::VfsWrite for ResidentVfs { } } -/// Retract everything a path's registration owns: its text input, its file-set entry -/// and its `by_path` back-link. Returns whether the file set moved. +/// Resolve a retired path to its registered id, then forget the adapter's backlink. +/// The shared batch owns the Salsa source and FileSet removal. /// /// Where the id lives depends on whether the path was still serving, and the two /// removal routes disagree about that: the retry list owns paths that left `by_path` @@ -63,28 +63,44 @@ impl ide_host_core::VfsWrite for ResidentVfs { /// so a path it merely REMEMBERS from an earlier life looks exactly like one this /// resident registered. Only the caller knows which it is. fn retire_registration( - resident: &mut DiagnosticsResident, - file_set: &mut vfs::FileSet, + vfs: &ResidentVfs, + by_path: &mut HashMap, key: &str, registered: bool, -) -> bool { - use ide_host_core::{set_file_text_source, FileTextSource, VfsWrite}; +) -> Option { + use ide_host_core::VfsWrite; - let file_id = match resident.by_path.get(key) { + let file_id = match by_path.get(key) { Some(&file_id) => Some(file_id), - None if registered => { - resident.vfs.with_write(|vfs| vfs.file_id(&VfsPath::new(PathBuf::from(key)))) - } + None if registered => vfs.with_write(|vfs| vfs.file_id(&VfsPath::new(PathBuf::from(key)))), None => None, }; - let Some(file_id) = file_id else { return false }; - set_file_text_source(&mut resident.db, file_id, FileTextSource::Deleted); - resident.by_path.remove(key); - if file_set.path_for_file(&file_id).is_some() { - file_set.remove(file_id); - return true; - } - false + if file_id.is_some() { + by_path.remove(key); + } + file_id +} + +pub(super) fn apply_project_compatibility_mode( + db: &mut RootDatabaseImpl, + resolved: Option<(String, project_model::CompatibilityModeSource)>, +) { + use std::sync::Arc; + + if let Some((value, source)) = &resolved { + if bsl_platform::PlatformVersion::parse_compatibility_mode(value).is_none() { + tracing::warn!( + compatibility_mode = %value, + ?source, + "compatibility mode is not a recognized value; the compatibility-mode check stays off" + ); + } + } + let mode = resolved.map(|(value, _)| Arc::::from(value)); + if db.compatibility_mode().as_deref() != mode.as_deref() { + tracing::info!(compatibility_mode = ?mode, "updated compatibility mode"); + db.set_compatibility_mode(mode); + } } /// Re-read every held hole. Returns `(healed, vanished)` by canonical key, so the @@ -101,8 +117,7 @@ pub(super) fn retry_resident_holes( config_is_current: bool, only: Option<&str>, ) -> (Vec<(String, Option)>, Vec) { - use base_db::{SourceDatabase, SourceRoot}; - use ide_host_core::{set_file_text_source, FileTextSource, VfsWrite}; + use ide_host_core::{apply_source_batch, BatchTextSource, SourceBatchChange, VfsWrite}; let mut healed = Vec::new(); let mut vanished = Vec::new(); @@ -113,68 +128,50 @@ pub(super) fn retry_resident_holes( .map(|(k, o)| (k.clone(), *o)) .collect(); - let mut file_set = { - let db = &resident.db; - db.source_root_input(crate::graph::input::GRAPH_SOURCE_ROOT).root(db).file_set().clone() - }; - let mut file_set_modified = false; - - for (key, origin) in candidates { - let path = Path::new(&key); - // Stat BEFORE the read, the same order every other applier uses. The baseline - // must describe the bytes actually applied: stat-after-read would record a - // write that landed between the two, so the baseline would match a disk state - // whose text was never served, no scan would ever see drift again, and the - // file would serve the older text at `stale: false` forever. - let fp_before = crate::graph::scan::file_fingerprint(path); - // Absence is established by trying to open the file, not by its absence from - // someone else's listing — an incomplete walk must not retire a hole. - match base_db::read_disk_text(path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Gone. For a path that was serving, this is a removal and owes - // everything the ordinary removal branch does — otherwise the - // metadata back-link keeps pointing at a tombstoned FileId. - file_set_modified |= retire_registration( - resident, - &mut file_set, - &key, - origin == HoleOrigin::Admitted, - ); - resident.holes.remove(&key); - vanished.push(key); - } - Err(_) => {} // still unreadable → stays a hole - Ok(text) => { - // Returning a NEW path to service asserts it belongs to the - // configuration being served, which is the one thing the retry list - // must not assume: it is memory, and the gate is deliberately asked - // of the disk. A path that was already serving is not re-admitted. - if origin == HoleOrigin::Pending && !config_is_current { - continue; - } - let vfs_path = VfsPath::new(path.to_path_buf()); - let file_id = resident.vfs.with_write(|vfs| vfs.alloc_file_id(vfs_path.clone())); - resident.db.set_file_source_root(file_id, crate::graph::input::GRAPH_SOURCE_ROOT); - set_file_text_source(&mut resident.db, file_id, FileTextSource::Disk(&text)); - if file_set.path_for_file(&file_id).is_none() { - file_set.insert(file_id, vfs_path); - file_set_modified = true; + { + let vfs = &resident.vfs; + let db = &mut resident.db; + let by_path = &mut resident.by_path; + let holes = &mut resident.holes; + let mut candidates = candidates.into_iter(); + let changes = std::iter::from_fn(|| { + loop { + let (key, origin) = candidates.next()?; + let path = Path::new(&key); + // Stat before reading so the baseline describes the bytes applied. + let fp_before = crate::graph::scan::file_fingerprint(path); + // Absence is established by opening the file, not by another walk. + match base_db::read_disk_text(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let file_id = + retire_registration(vfs, by_path, &key, origin == HoleOrigin::Admitted); + holes.remove(&key); + vanished.push(key); + if let Some(file_id) = file_id { + return Some(SourceBatchChange::Remove { file_id }); + } + } + Err(_) => {} // still unreadable → stays a hole + Ok(text) => { + // A pending hole is admitted only while its config identity is current. + if origin == HoleOrigin::Pending && !config_is_current { + continue; + } + let vfs_path = VfsPath::new(path.to_path_buf()); + let file_id = vfs.with_write(|vfs| vfs.alloc_file_id(vfs_path.clone())); + by_path.insert(key.clone(), file_id); + holes.remove(&key); + healed.push((key, fp_before)); + return Some(SourceBatchChange::Upsert { + file_id, + path: vfs_path, + source: Some(BatchTextSource::DiskOwned(text)), + }); + } } - resident.by_path.insert(key.clone(), file_id); - resident.holes.remove(&key); - healed.push((key, fp_before)); } - } - } - - // The clone is published ONCE, after the loop — the same shape the add/remove - // branches use. An insert that never reaches the db leaves `path_for_file` empty - // and the first query panics. - if file_set_modified { - resident.db.set_source_root( - crate::graph::input::GRAPH_SOURCE_ROOT, - SourceRoot::new_local(file_set), - ); + }); + apply_source_batch(db, crate::graph::input::GRAPH_SOURCE_ROOT, changes); } // The substrate is re-issued for every transition, not just for paths that were @@ -1304,8 +1301,8 @@ pub(super) fn apply_resident_changes( fp_of: impl Fn(&str) -> Option, stats: &HashMap, ) -> (bool, bool) { - use base_db::{SourceDatabase, SourceRoot}; - use ide_host_core::{set_file_text_source, FileTextSource, VfsWrite}; + use ide_host_core::{apply_source_batch, BatchTextSource, SourceBatchChange, VfsWrite}; + use rustc_hash::FxHashSet; // Pre-classification: an XML path resolving outside every registered config root is // drift the point-refresh cannot express — `refresh_metadata_substrate` gates its @@ -1319,92 +1316,135 @@ pub(super) fn apply_resident_changes( } let mut moved = false; - - // (1) Reconcile the file universe FIRST — mirrors the LSP's `process_changes` - // discipline (one FileSet clone, per-file inputs, one `set_source_root`), so the - // substrate refresh below resolves module back-links through an up-to-date VFS and - // root. Per-file ordering matters: the source-root + content-revision inputs are - // registered BEFORE the file becomes visible through the FileSet - // (`file_text_query` panics on a visible file with no revision). - let mut file_set_modified = false; - let mut file_set = { - let db = &resident.db; - db.source_root_input(crate::graph::input::GRAPH_SOURCE_ROOT).root(db).file_set().clone() - }; - for path in added_bsl { - // Vanished again before we got here (create+delete coalesced apart): the - // removal pass — or the next drift window — settles it. - if fp_of(path).is_none() && !Path::new(path).is_file() { - continue; - } - // Read BEFORE interning. A file that cannot be read is not registered at all, - // and "at all" has to include the VFS: `alloc_file_id` used to run first, so - // skipping only from the read onwards would leave a `FileId` with no file-set - // entry, which the next query resolves into a `path_for_file` panic. - let text = match base_db::read_disk_text(Path::new(path)) { - Ok(text) => text, - Err(_) => { - // Never admitted into this resident, so healing it later must ask the - // configuration gate first. - resident.holes.insert(path.clone(), HoleOrigin::Pending); - moved = true; - continue; + let mut needs_rebuild = false; + let mut became_holes: Vec = Vec::new(); + let mut changed_membership_paths: Vec = Vec::new(); + let mut phase = 0; + let (mut added, mut removed, mut modified) = (0, 0, 0); + + // Keep the operation plan path-only. This iterator performs each disk read just + // before the shared batch applies it, so at most one MCP source text is resident + // in the reconciliation path at a time. + let db = &mut resident.db; + let vfs = &resident.vfs; + let by_path = &mut resident.by_path; + let holes = &mut resident.holes; + let changes = std::iter::from_fn(|| { + loop { + if needs_rebuild { + return None; } - }; - let vfs_path = VfsPath::new(path.clone()); - let file_id = resident.vfs.with_write(|vfs| vfs.alloc_file_id(vfs_path.clone())); - if let Some(&known) = resident.by_path.get(path.as_str()) { - if known != file_id { - // The path is already registered under a different id — an aliasing - // (symlink/canonicalisation) case registration cannot express safely. - return (true, moved); + match phase { + 0 if added < added_bsl.len() => { + let path = &added_bsl[added]; + added += 1; + if fp_of(path).is_none() && !Path::new(path).is_file() { + continue; + } + // Read BEFORE interning: a pending unreadable path has no FileId or + // FileSet entry, and its next attempt still passes the admission gate. + let text = match base_db::read_disk_text(Path::new(path)) { + Ok(text) => text, + Err(_) => { + let inserted = + holes.insert(path.clone(), HoleOrigin::Pending).is_none(); + moved |= inserted; + if inserted { + changed_membership_paths.push(PathBuf::from(path)); + } + continue; + } + }; + let vfs_path = VfsPath::new(path.clone()); + let file_id = vfs.with_write(|vfs| vfs.alloc_file_id(vfs_path.clone())); + if by_path.get(path.as_str()).is_some_and(|known| *known != file_id) { + // The path is already registered under another id; aliasing needs + // a full rescan, preserving the existing registration unchanged. + needs_rebuild = true; + return None; + } + by_path.insert(path.clone(), file_id); + holes.remove(path.as_str()); + return Some(SourceBatchChange::Upsert { + file_id, + path: vfs_path, + source: Some(BatchTextSource::DiskOwned(text)), + }); + } + 0 => phase = 1, + 1 if removed < removed_bsl.len() => { + let path = &removed_bsl[removed]; + removed += 1; + let was_hole = holes.remove(path.as_str()); + let registered = was_hole == Some(HoleOrigin::Admitted); + let moved_here = was_hole.is_some() || by_path.contains_key(path.as_str()); + let file_id = by_path.remove(path.as_str()).or_else(|| { + registered + .then(|| { + vfs.with_write(|vfs| { + vfs.file_id(&VfsPath::new(PathBuf::from(path))) + }) + }) + .flatten() + }); + moved |= moved_here; + if was_hole.is_some() { + changed_membership_paths.push(PathBuf::from(path)); + } + if let Some(file_id) = file_id { + return Some(SourceBatchChange::Remove { file_id }); + } + } + 1 => phase = 2, + 2 if modified < modified_bsl.len() => { + let path = &modified_bsl[modified]; + modified += 1; + let Some(fp) = fp_of(path) else { continue }; + if stats.get(path).copied() == Some(fp) { + continue; + } + let Some(&file_id) = by_path.get(path) else { + if holes.contains_key(path) { + continue; + } + needs_rebuild = true; + return None; + }; + let vfs_path = VfsPath::new(PathBuf::from(path)); + match base_db::read_disk_text(Path::new(path)) { + Ok(text) => { + return Some(SourceBatchChange::Upsert { + file_id, + path: vfs_path, + source: Some(BatchTextSource::DiskOwned(text)), + }); + } + Err(_) => { + // Keep the FileSet registration but mark the body unreadable; + // this preserves the admitted module identity during retries. + by_path.remove(path); + holes.insert(path.clone(), HoleOrigin::Admitted); + became_holes.push(PathBuf::from(path)); + return Some(SourceBatchChange::Upsert { + file_id, + path: vfs_path, + source: Some(BatchTextSource::Unreadable), + }); + } + } + } + 2 => phase = 3, + _ => return None, } } - resident.db.set_file_source_root(file_id, crate::graph::input::GRAPH_SOURCE_ROOT); - set_file_text_source(&mut resident.db, file_id, FileTextSource::Disk(&text)); - if file_set.path_for_file(&file_id).is_none() { - file_set.insert(file_id, vfs_path); - file_set_modified = true; - } - // The classifier's `key` IS the canonical by_path spelling (both come from the - // scan-universe canonicalisation), so insert it verbatim — re-canonicalising - // here could diverge on a path that vanished between classify and apply. - resident.by_path.insert(path.clone(), file_id); - // A path returning to service leaves the hole list, whichever branch brings it - // back. `by_path` and `holes` must not intersect: the workspace denominator is - // their sum, and the hole list drives the retry pass — a served path left in it - // would be re-read from disk every drift window for nothing. - resident.holes.remove(path.as_str()); - moved = true; - } - for path in removed_bsl { - // A deleted file is no longer a hole either: the retry list must not keep - // probing a path the workspace no longer has, and leaving it there would - // report a phantom in `unread_files` for as long as it is never recreated. - // The origin decides whether anything was ever registered under this path — - // a hole is not always the retry list's to retire, because the retry window - // is throttled and `reconcile_tick` never opens it at all. - let was_hole = resident.holes.remove(path.as_str()); - let registered = was_hole == Some(HoleOrigin::Admitted); - // The resident moved if the removal changed anything the workspace REPORTS, - // and the hole list is reported: it is `unread_files`, it is half of - // `files_total`, and a non-empty one is what makes the answer stale. A - // `Pending` hole has no registration by construction, so counting only - // registrations would let all three change under an unmoved generation — the - // same `result_id` answering "unreadable" and then "not in workspace". The - // creation of that hole bumps the generation; its removal owes the same. - // Never indexed and never a hole → nothing moved (an untracked removal is not - // drift). - let moved_here = was_hole.is_some() || resident.by_path.contains_key(path.as_str()); - file_set_modified |= retire_registration(resident, &mut file_set, path, registered); - moved |= moved_here; - } - if file_set_modified { - resident.db.set_source_root( - crate::graph::input::GRAPH_SOURCE_ROOT, - SourceRoot::new_local(file_set), - ); + }); + let source_effects = apply_source_batch(db, crate::graph::input::GRAPH_SOURCE_ROOT, changes); + moved |= source_effects.bsl_source_changed; + if needs_rebuild { + return (true, moved); } + let changed_source_paths: FxHashSet<_> = + source_effects.affected_paths.iter().chain(&changed_membership_paths).cloned().collect(); // (2) Refresh the per-MDO substrate. Beside the drifted `.xml`, a created or // deleted common-module/service body changes its listing's `module_file` @@ -1418,6 +1458,7 @@ pub(super) fn apply_resident_changes( .map(PathBuf::from) .filter(|p| project_model::is_substrate_listed_body_path(p)) .filter(|p| config_roots.iter().any(|(_, root)| p.starts_with(root))) + .filter(|p| changed_source_paths.contains(p)) .collect(); if !xml_paths.is_empty() || !structural_listing_bodies.is_empty() { let mut refresh: Vec = xml_paths.to_vec(); @@ -1425,47 +1466,16 @@ pub(super) fn apply_resident_changes( ide_host_core::refresh_metadata_substrate(&mut resident.db, &resident.vfs, &refresh); if !xml_paths.is_empty() { resident.db.bump_config_for_paths(xml_paths.iter().map(|p| p.as_path())); + apply_project_compatibility_mode( + &mut resident.db, + resident.project.compatibility_mode(), + ); } moved = true; } // `.bsl` bodies: disk-backed re-key. A body already at its on-disk fingerprint (a // racing caller beat us) is skipped. - let mut became_holes: Vec = Vec::new(); - for path in modified_bsl { - let Some(fp) = fp_of(path) else { continue }; - if stats.get(path).copied() == Some(fp) { - continue; - } - let Some(&file_id) = resident.by_path.get(path) else { - // A path already held as a hole is not "never indexed": the retry cycle - // owns it, and healing it HERE would be wrong twice over — the file set - // was already published above, so the insert would not reach the db, and - // an admission would slip past the configuration gate. - if resident.holes.contains_key(path) { - continue; - } - return (true, moved); // a modified `.bsl` we never indexed → structural - }; - match base_db::read_disk_text(Path::new(path)) { - Ok(text) => { - set_file_text_source(&mut resident.db, file_id, FileTextSource::Disk(&text)) - } - Err(_) => { - // Unreadable now. The empty overlay is mandatory (a disk-backed re-read - // would panic), but it is no longer passed off as content: the file - // leaves service and joins the retry list, so consumers see "known - // but unreadable" instead of an empty module. `Admitted` — it was - // already serving under this configuration. - set_file_text_source(&mut resident.db, file_id, FileTextSource::Unreadable); - resident.by_path.remove(path); - resident.holes.insert(path.clone(), HoleOrigin::Admitted); - became_holes.push(PathBuf::from(path)); - } - } - moved = true; - } - // A body that just became a hole has to LOSE its `module_file` back-link, and the // substrate pass above already ran — it keys off `added_bsl`/`removed_bsl`, and // this transition is neither. Without re-issuing it here, the same disk state @@ -1689,6 +1699,89 @@ mod tests { } } + /// A path/id disagreement is not repaired by overwriting the resident's existing + /// registration: the caller must rebuild from the filesystem's complete mapping. + #[test] + fn an_added_path_id_collision_requires_rebuild_without_mutating_registration() { + use base_db::SourceDatabase; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + sample_workspace(root); + write(root, "Other.bsl", "Процедура Другая()\nКонецПроцедуры\n"); + + let state = DiagnosticsState::for_workspace(root.to_path_buf()); + state.ensure_loading(); + wait_ready(&state); + + let target_path = module_path(root, "Сервер"); + let other_path = root.join("Other.bsl"); + let outcome = state.read_mut(|resident, _| { + let target_id = resident.file_id_for(&target_path).expect("target is resident"); + let other_id = resident.file_id_for(&other_path).expect("other file is resident"); + assert_ne!(target_id, other_id, "collision uses a different existing FileId"); + + let source_root = resident + .db + .source_root_input(crate::graph::input::GRAPH_SOURCE_ROOT) + .root(&resident.db); + let file_set_before = source_root.file_set().clone(); + let target_vfs_path = file_set_before + .path_for_file(&target_id) + .expect("target has an existing FileSet path") + .clone(); + assert_eq!(file_set_before.file_for_path(&target_vfs_path), Some(&target_id)); + assert_eq!(file_set_before.path_for_file(&target_id), Some(&target_vfs_path)); + let revision_before = resident.db.file_revision_input(target_id); + let revision_value_before = revision_before.revision(&resident.db); + let unreadable_before = revision_before.unreadable(&resident.db); + let salsa_revision_before = salsa::plumbing::current_revision(&resident.db); + let text_before = resident.analysis().file_text(target_id).to_string(); + assert!( + resident.db.try_file_text(target_id).is_none(), + "resident text stays disk-backed" + ); + + let key = canonical_key(&target_path); + resident.by_path.insert(key.clone(), other_id); + let by_path_before = resident.by_path.get(&key).copied(); + let added = [key.clone()]; + let stats = HashMap::new(); + + let result = + apply_resident_changes(resident, &[], &added, &[], &[], |_| Some(1), &stats); + + assert_eq!(result, (true, false), "an existing path/id collision requests rebuild"); + assert_eq!(resident.by_path.get(&key).copied(), by_path_before); + let source_root_after = resident + .db + .source_root_input(crate::graph::input::GRAPH_SOURCE_ROOT) + .root(&resident.db); + let file_set_after = source_root_after.file_set(); + assert_eq!(file_set_after, &file_set_before, "the FileSet registration is untouched"); + let revision_after = resident.db.file_revision_input(target_id); + assert_eq!(revision_after.revision(&resident.db), revision_value_before); + assert_eq!(revision_after.unreadable(&resident.db), unreadable_before); + assert_eq!( + salsa::plumbing::current_revision(&resident.db), + salsa_revision_before, + "the collision path performs no same-value Salsa write" + ); + assert!(resident.db.try_file_text(target_id).is_none(), "no pinned text was installed"); + assert_eq!( + resident.analysis().file_text(target_id).as_str(), + text_before.as_str(), + "the known FileId still serves its original disk text" + ); + result + }); + + assert!( + matches!(outcome, ResidentOutcome::Ready((true, false), _)), + "the resident reports rebuild required without a partial source mutation" + ); + } + /// The resident loads the project's `bsl-analyzer.toml` and exposes it as the /// effective config, so `file`/`workspace` honour the same disabled rules and tuned /// thresholds as LSP and CLI — not analyzer defaults. diff --git a/crates/mcp-server/src/lib.rs b/crates/mcp-server/src/lib.rs index 0ad100860..48be7f706 100644 --- a/crates/mcp-server/src/lib.rs +++ b/crates/mcp-server/src/lib.rs @@ -4780,6 +4780,7 @@ mod graph_supersession_contract { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn references_misses_immediately_when_preopened_handles_are_busy() { + let _walk_serial = crate::walk_probe::WALK_GATE.lock().await; assert_busy_graph_handler_returns_immediately(BusyGraphHandler::References).await; } @@ -5735,6 +5736,7 @@ mod rescan_hatch_consultations { /// The same for the answer-shaped miss this tool decides by its outcome. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn references_consults_the_rescan_hatch_on_a_named_symbol_miss() { + let _walk_serial = crate::walk_probe::WALK_GATE.lock().await; let (_dir, server, diag) = stand(); let before = diag.stale_miss_consultations(); diff --git a/crates/mcp-server/src/serve_stream_tests.rs b/crates/mcp-server/src/serve_stream_tests.rs index 61676b0a5..2a27fed1a 100644 --- a/crates/mcp-server/src/serve_stream_tests.rs +++ b/crates/mcp-server/src/serve_stream_tests.rs @@ -1,16 +1,34 @@ -use std::time::Duration; +use std::path::Path; +use std::time::{Duration, Instant}; -use rmcp::model::{CallToolRequestParams, ClientRequest, PingRequest}; -use rmcp::service::RunningService; +use rmcp::model::{ + CallToolRequest, CallToolRequestParams, CancelledNotification, CancelledNotificationParam, + ClientRequest, PingRequest, +}; +use rmcp::service::{PeerRequestOptions, RunningService, ServiceError}; use rmcp::{RoleClient, ServiceExt}; use tokio::io::AsyncReadExt; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use crate::{serve_stream_with_shutdown, McpProfile, McpServer, SharedState}; +use crate::{serve_stream_with_shutdown, McpProfile, McpServer, SharedState, ToolGate}; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +struct WorkspaceStreamCleanup { + state: SharedState, + shutdowns: [CancellationToken; 2], +} + +impl Drop for WorkspaceStreamCleanup { + fn drop(&mut self) { + for shutdown in &self.shutdowns { + shutdown.cancel(); + } + self.state.shutdown(); + } +} + /// A completed MCP handshake exercises the service task that owns the transport. async fn initialized_session( shutdown: &CancellationToken, @@ -34,6 +52,16 @@ async fn initialized_workspace_session( (client, task) } +async fn initialized_server_session( + server: McpServer, + shutdown: &CancellationToken, +) -> (RunningService, JoinHandle>) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let task = tokio::spawn(serve_stream_with_shutdown(server, server_io, shutdown.clone())); + let client = ().serve(client_io).await.expect("client initializes"); + (client, task) +} + /// A stalled handshake or detached service task must not make shutdown wait forever. async fn session_finished(task: JoinHandle>) { tokio::time::timeout(SHUTDOWN_TIMEOUT, task) @@ -268,3 +296,356 @@ async fn legacy_scope_change_retires_mcp_stream_and_reconnects_with_explicit_cac session_finished(explicit_task).await; explicit_state.shutdown(); } + +/// Static Workspace-profile tools answer while the graph's first publication is held and +/// without starting the resident diagnostics lifecycle. Background startup owners may still +/// run; this checks that these requests do not claim or publish the graph themselves. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_catalog_schema_and_platform_calls_do_not_build_resident_or_graph() { + let workspace = tempfile::tempdir().expect("owned workspace"); + let cache_parent = tempfile::tempdir().expect("owned cache parent"); + std::fs::write(workspace.path().join("Configuration.xml"), "") + .expect("minimal project configuration"); + + let project = crate::project::at(workspace.path()).expect("minimal workspace resolves"); + let cache = crate::cache::WorkspaceCacheLayout::for_project( + &project, + Some(&cache_parent.path().join("cache")), + workspace.path(), + None, + ) + .expect("owned workspace cache resolves"); + let (state, held) = crate::graph::test_support::holding_the_first_build(|| { + SharedState::workspace_with_cache(workspace.path().to_path_buf(), cache) + .expect("workspace state initializes") + }); + assert!(held.holds_unconsulted(), "first graph publication is held at startup"); + + let assert_lazy_state = || { + let status = state.diagnostics().status_report(); + assert_eq!(status.state, "idle", "static requests must not start resident loading"); + assert_eq!(status.generation, 0, "static requests must not publish a resident generation"); + assert!(held.holds_unconsulted(), "a static request claimed or published the graph"); + }; + assert_lazy_state(); + + let shutdown = CancellationToken::new(); + let (client, task) = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + initialized_workspace_session(state.clone(), McpProfile::Workspace, &shutdown), + ) + .await + .expect("workspace MCP handshake finishes within the shutdown budget"); + assert_lazy_state(); + + let invalid_action = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.call_tool(CallToolRequestParams::new("diagnostics").with_arguments( + serde_json::json!({"action": "not_a_diagnostics_action"}).as_object().unwrap().clone(), + )), + ) + .await + .expect("unknown-action response finishes within the shutdown budget"); + match invalid_action { + Err(rmcp::service::ServiceError::McpError(error)) => { + assert_eq!(error.code.0, -32602, "unknown action is invalid params"); + assert!( + error.message.contains("Unknown action 'not_a_diagnostics_action'"), + "the error identifies the submitted action: {error:?}" + ); + } + Err(other) => panic!("unknown diagnostics action returned a non-MCP error: {other:?}"), + Ok(response) => panic!("unknown diagnostics action unexpectedly answered: {response:?}"), + } + assert_lazy_state(); + + let schema = + tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.call_tool(CallToolRequestParams::new("diagnostics").with_arguments( + serde_json::json!({"action": "schema"}).as_object().unwrap().clone(), + )), + ) + .await + .expect("diagnostics schema finishes within the shutdown budget") + .expect("diagnostics schema answers over rmcp"); + let schema = schema.structured_content.expect("schema has structured content"); + assert_eq!(schema["schema_version"], "16"); + assert_eq!( + schema["actions"], + serde_json::json!(["catalog", "schema", "status", "file", "workspace"]) + ); + assert_lazy_state(); + + let catalog = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.call_tool( + CallToolRequestParams::new("diagnostics").with_arguments( + serde_json::json!({ + "action": "catalog", + "codes": ["UnresolvedBareCall"], + "locale": "ru" + }) + .as_object() + .unwrap() + .clone(), + ), + ), + ) + .await + .expect("diagnostics catalog finishes within the shutdown budget") + .expect("diagnostics catalog answers over rmcp"); + let catalog = catalog.structured_content.expect("catalog has structured content"); + assert_eq!(catalog["action"], "catalog"); + assert_eq!(catalog["count"], 1); + assert_eq!(catalog["entries"][0]["code"], "UnresolvedBareCall"); + assert_lazy_state(); + + let platform = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client + .call_tool(CallToolRequestParams::new("syntax_help").with_arguments( + serde_json::json!({"name": "Массив"}).as_object().unwrap().clone(), + )), + ) + .await + .expect("platform syntax card finishes within the shutdown budget") + .expect("platform syntax card answers over rmcp"); + let platform = platform.structured_content.expect("platform card has structured content"); + assert_eq!(platform["schema_version"], "2"); + assert_eq!(platform["kind"], "type"); + assert_eq!(platform["name"], "Массив"); + assert_eq!(platform["english_name"], "Array"); + assert!(platform["constructors"].is_array()); + let methods = platform["methods"].as_array().expect("type card lists methods"); + assert!(!methods.is_empty(), "the Array type has compiled methods"); + assert!( + methods + .iter() + .any(|method| method["name"] == "Количество" && method["english_name"] == "Count"), + "the Array card includes its known Count method: {methods:?}" + ); + assert_lazy_state(); + + client.cancel().await.expect("client disconnects"); + session_finished(task).await; + state.shutdown(); +} + +/// A real wire cancellation stops a references walk at its next Salsa checkpoint, releases +/// the resident read slot, and lets the next request publish diagnostics for a disk edit. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_references_cancel_unwinds_first_walk_span_before_a_writer() { + let _walk_serial = crate::walk_probe::WALK_GATE.lock().await; + let workspace = tempfile::tempdir().expect("owned workspace"); + let cache_parent = tempfile::tempdir().expect("owned cache parent"); + let root = workspace.path(); + std::fs::write(root.join("Configuration.xml"), "") + .expect("configuration fixture"); + std::fs::write( + root.join("bsl-analyzer.toml"), + "[source]\nroot = \".\"\n[diagnostics.parameters]\nUnresolvedBareCall = true\n", + ) + .expect("enable the semantic writer assertion"); + + crate::diagnostics_state::test_support::write_common_module( + root, + "Сервер", + true, + "&НаСервере\nФункция Считать() Экспорт\n Возврат 1;\nКонецФункции\n", + ); + let mut caller = String::from("&НаСервере\nПроцедура Вызвать()\n"); + for _ in 0..1000 { + caller.push_str(" Сервер.Считать();\n"); + } + caller.push_str("КонецПроцедуры\n"); + crate::diagnostics_state::test_support::write_common_module(root, "Caller0000", true, &caller); + + let project = crate::project::at(root).expect("generated project resolves"); + let cache = crate::cache::WorkspaceCacheLayout::for_project( + &project, + Some(&cache_parent.path().join("cache")), + cache_parent.path(), + None, + ) + .expect("owned cache layout resolves"); + let state = SharedState::workspace_with_cache(root.to_path_buf(), cache) + .expect("workspace resident initializes"); + let first_shutdown = CancellationToken::new(); + let sibling_shutdown = CancellationToken::new(); + let _cleanup = WorkspaceStreamCleanup { + state: state.clone(), + shutdowns: [first_shutdown.clone(), sibling_shutdown.clone()], + }; + state.diagnostics().ensure_loading(); + let diagnostics_state = state.diagnostics().clone(); + tokio::task::spawn_blocking(move || { + crate::diagnostics_state::test_support::wait_ready(&diagnostics_state) + }) + .await + .expect("resident readiness waiter joins"); + + let enabled = vec!["references".to_owned()]; + let gate = ToolGate::for_launch(McpProfile::Workspace, &enabled); + let (client, first_task) = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + initialized_server_session( + McpServer::with_gate(McpProfile::Workspace, state.clone(), &gate), + &first_shutdown, + ), + ) + .await + .expect("first workspace handshake is bounded"); + let (sibling, sibling_task) = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + initialized_server_session( + McpServer::with_gate(McpProfile::Workspace, state.clone(), &gate), + &sibling_shutdown, + ), + ) + .await + .expect("sibling workspace handshake is bounded"); + + crate::walk_probe::install(); + crate::walk_probe::reset(); + let reference_arguments = || { + serde_json::json!({ + "symbol": "Сервер.Считать", + "kinds": ["call"], + "limit": 1, + "max_files": 10000 + }) + .as_object() + .expect("reference arguments are an object") + .clone() + }; + let positive = tokio::time::timeout( + Duration::from_secs(60), + client.call_tool( + CallToolRequestParams::new("references").with_arguments(reference_arguments()), + ), + ) + .await + .expect("positive full reference walk is bounded") + .expect("positive full reference walk answers"); + let positive = positive.structured_content.expect("references are structured"); + assert_eq!(positive["outcome"], "resolved"); + assert_eq!(positive["total"], 1000, "all 1000 caller references are present"); + assert_eq!( + crate::walk_probe::entered(), + 2, + "the full walk enters the declaration and the 1000-call caller module" + ); + + crate::walk_probe::reset(); + let (span_hold, span_waiter) = crate::walk_probe::hold_first_span(); + let request = ClientRequest::CallToolRequest(CallToolRequest::new( + CallToolRequestParams::new("references").with_arguments(reference_arguments()), + )); + let handle = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.peer().send_cancellable_request(request, PeerRequestOptions::no_options()), + ) + .await + .expect("cancellable request is sent within the bound") + .expect("cancellable request reaches the peer"); + let waiter = span_waiter.clone(); + assert!( + tokio::time::timeout( + SHUTDOWN_TIMEOUT, + tokio::task::spawn_blocking(move || waiter.wait_entered(SHUTDOWN_TIMEOUT)), + ) + .await + .expect("span waiter is bounded") + .expect("span waiter joins"), + "the real request enters its first find_references_in_file span" + ); + assert_eq!(crate::walk_probe::entered(), 1, "only the held first span has started"); + + tokio::time::timeout( + SHUTDOWN_TIMEOUT, + sibling.send_request(ClientRequest::PingRequest(PingRequest::default())), + ) + .await + .expect("sibling Ping remains responsive during the held walk") + .expect("sibling session remains connected"); + let cancel = CancelledNotification::new(CancelledNotificationParam::new( + Some(handle.id.clone()), + Some("test cancels held reference walk".to_owned()), + )); + tokio::time::timeout(SHUTDOWN_TIMEOUT, client.peer().send_notification(cancel.into())) + .await + .expect("cancellation notification is bounded") + .expect("cancellation notification is delivered"); + let cancelled = tokio::time::timeout(SHUTDOWN_TIMEOUT, handle.await_response()) + .await + .expect("cancelled request response is bounded"); + assert!( + matches!(&cancelled, Err(ServiceError::Cancelled { .. })), + "the RMCP request receives its cancellation outcome: {cancelled:?}" + ); + tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.send_request(ClientRequest::PingRequest(PingRequest::default())), + ) + .await + .expect("same-client Ping after cancellation is bounded") + .expect("same client can issue the ordered next request"); + + let edited_path = Path::new("CommonModules/Caller0000/Ext/Module.bsl"); + crate::diagnostics_state::test_support::write( + root, + edited_path.to_str().unwrap(), + "&НаСервере\nПроцедура Вызвать()\n Сервер.Считать();\n НесуществующаяПроцедура();\nКонецПроцедуры\n", + ); + span_hold.release(); + + let deadline = Instant::now() + Duration::from_secs(30); + let mut diagnostic = serde_json::Value::Null; + while Instant::now() < deadline { + let response = tokio::time::timeout( + SHUTDOWN_TIMEOUT, + client.call_tool( + CallToolRequestParams::new("diagnostics").with_arguments( + serde_json::json!({ + "action": "file", + "path": edited_path.to_string_lossy() + }) + .as_object() + .unwrap() + .clone(), + ), + ), + ) + .await + .expect("post-cancel diagnostic query is bounded") + .expect("post-cancel diagnostic query answers after the resident writer"); + diagnostic = response.structured_content.expect("diagnostic is structured"); + let has_updated_finding = + diagnostic["result"]["findings"].as_array().is_some_and(|findings| { + findings.iter().any(|finding| finding["code"] == "UnresolvedBareCall") + }); + if has_updated_finding { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let findings = diagnostic["result"]["findings"].as_array().expect("findings are present"); + assert!( + findings.iter().any(|finding| finding["code"] == "UnresolvedBareCall"), + "the writer applied the changed BSL body after the cancelled read released its slot: {diagnostic}" + ); + assert_eq!(crate::walk_probe::entered(), 1, "the cancelled walk stopped at its first file"); + + tokio::time::timeout(SHUTDOWN_TIMEOUT, client.cancel()) + .await + .expect("first client disconnect is bounded") + .expect("first client disconnects"); + session_finished(first_task).await; + tokio::time::timeout(SHUTDOWN_TIMEOUT, sibling.cancel()) + .await + .expect("sibling client disconnect is bounded") + .expect("sibling client disconnects"); + session_finished(sibling_task).await; + state.shutdown(); +} diff --git a/crates/mcp-server/src/walk_probe.rs b/crates/mcp-server/src/walk_probe.rs index 690444cd9..0ba5eeb8e 100644 --- a/crates/mcp-server/src/walk_probe.rs +++ b/crates/mcp-server/src/walk_probe.rs @@ -5,6 +5,8 @@ //! somebody else's file, and the gates then measure an interleaving instead of a //! cancellation. Any test that reaches `ide::find_references_by_name` takes it. -pub(crate) use test_utils::walk_probe::{await_walk_start, entered, install, reset}; +pub(crate) use test_utils::walk_probe::{ + await_walk_start, entered, hold_first_span, install, reset, +}; pub(crate) static WALK_GATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); diff --git a/crates/test-utils/src/walk_probe.rs b/crates/test-utils/src/walk_probe.rs index ab9980be7..be1ac57af 100644 --- a/crates/test-utils/src/walk_probe.rs +++ b/crates/test-utils/src/walk_probe.rs @@ -10,6 +10,7 @@ //! interleaving instead of a cancellation. use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::{Duration, Instant}; const WALK_SPAN: &str = "find_references_in_file"; @@ -17,6 +18,24 @@ const WALK_SPAN: &str = "find_references_in_file"; /// Files the walk has entered since the last [`reset`]. static WALKED: AtomicUsize = AtomicUsize::new(0); +struct FirstSpanGate { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct FirstSpanState { + claimed: bool, + entered: bool, + released: bool, +} + +static FIRST_SPAN_GATE: Mutex>> = Mutex::new(None); + +fn lock_recover(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + struct WalkCounter; impl tracing_subscriber::Layer for WalkCounter { @@ -28,6 +47,21 @@ impl tracing_subscriber::Layer for WalkCounter { ) { if attrs.metadata().name() == WALK_SPAN { WALKED.fetch_add(1, Ordering::SeqCst); + let gate = lock_recover(&FIRST_SPAN_GATE).clone(); + if let Some(gate) = gate { + let mut state = lock_recover(&gate.state); + if !state.claimed { + state.claimed = true; + state.entered = true; + gate.changed.notify_all(); + while !state.released { + state = gate + .changed + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + } } } } @@ -56,6 +90,74 @@ pub fn entered() -> usize { WALKED.load(Ordering::SeqCst) } +/// Hold the first subsequent reference-walk span until the returned guard is released. +/// +/// The guard releases the span and removes the process-global gate on drop, including when +/// a test assertion unwinds. +pub fn hold_first_span() -> (FirstSpanGuard, FirstSpanWaiter) { + let gate = Arc::new(FirstSpanGate { + state: Mutex::new(FirstSpanState::default()), + changed: Condvar::new(), + }); + let mut active = lock_recover(&FIRST_SPAN_GATE); + assert!(active.is_none(), "a first-span gate is already active"); + *active = Some(Arc::clone(&gate)); + (FirstSpanGuard { gate: Arc::clone(&gate) }, FirstSpanWaiter { gate }) +} + +/// RAII handle that releases a held reference-walk span on drop. +pub struct FirstSpanGuard { + gate: Arc, +} + +impl FirstSpanGuard { + pub fn release(&self) { + let mut state = lock_recover(&self.gate.state); + state.released = true; + self.gate.changed.notify_all(); + } +} + +impl Drop for FirstSpanGuard { + fn drop(&mut self) { + self.release(); + let mut active = lock_recover(&FIRST_SPAN_GATE); + if active.as_ref().is_some_and(|gate| Arc::ptr_eq(gate, &self.gate)) { + *active = None; + } + } +} + +/// Bounded observer for the first span held by [`hold_first_span`]. +#[derive(Clone)] +pub struct FirstSpanWaiter { + gate: Arc, +} + +impl FirstSpanWaiter { + /// Wait for the tracing callback to reach the held span, bounded by `timeout`. + pub fn wait_entered(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut state = lock_recover(&self.gate.state); + while !state.entered { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (next, result) = self + .gate + .changed + .wait_timeout(state, remaining) + .unwrap_or_else(std::sync::PoisonError::into_inner); + state = next; + if result.timed_out() && !state.entered { + return false; + } + } + true + } +} + /// Block until the walk has entered its first file, so a cancel lands mid-flight. A /// fixed sleep would race the build of whatever the walk reads first. pub fn await_walk_start() { diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index cebb17094..c8abf4063 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -132,6 +132,7 @@ bsl-analyzer | Крейты | Назначение | |--------|------------| | `base-db` | базовые Salsa-input'ы, source roots, file text | +| `ide-host-core` | общий алгоритм регистрации исходников, source effects и metadata bootstrap/refresh | | `vfs`, `vfs-notify` | виртуальная файловая система и слежение за файлами | | `project-model` | загрузка конфигурации проекта, legacy JSON-конфигов и search-настроек | | `bsl-metadata` | чтение `Configuration.xml` и метаданных 1С | @@ -141,6 +142,38 @@ bsl-analyzer | `bsl-search` | индексирование и поиск по коду/документации | | `mcp-server` | MCP runtime и интеграция с AI-клиентами | +### Обновление исходников через общий алгоритм + +`ide-host-core::apply_source_batch(db, source_root_id, changes)` применяет +`SourceBatchChange::Upsert` и `Remove` к существующей базе. Он устанавливает +source-root backlink и revision/text до публикации изменённого `FileSet`. +`SourceBatchEffects` сообщает об изменении состава, BSL, FileId и путей; +повтор неизменённых входов не создаёт новых effects. Алгоритм работает до +metadata bootstrap и не обращается к metadata root(1). + +LSP `workspace::process_changes` выбирает overlay редактора или disk input. +При перезагрузке конфигурации исключённые файлы и удалённые source roots +проходят через VFS deletion и тот же batch: deleted text устанавливается, +пока FileId ещё присутствует в FileSet, затем обновляются metadata back-links. +Служебные inputs вне source roots сохраняются в VFS для watcher; общий `Remove` +удаляет только их лишнюю регистрацию в SourceRoot, без нового config-change event. +MCP resident использует тот же алгоритм для normal/scan/event drift, удаления +и восстановления unreadable файлов. Admission, holes, fingerprint до чтения, +mutex, generation и scope retirement остаются у MCP; buffers, initial +suppression, публикация диагностик и semantic refresh — у LSP. После bootstrap +адаптер вызывает существующий metadata refresh по затронутым путям. + +`register_files_disk_backed` передаёт файлы этому алгоритму по одному, сохраняя +ошибки чтения и disk-backed inputs. Его используют CLI и MCP graph builder. +Bounded graph batch сохраняет полный контекстный SourceRoot, загружая тексты +только своей порции. Начальная MCP DB публикуется для запросов после завершения +регистрации и metadata bootstrap. + +Процессы и DB LSP/MCP сохраняют собственные lifecycle и views. Общий алгоритм +может вызываться будущим владельцем workspace; совместное хранение DB и +одновременные session views требуют отдельного решения. Сокращение кода само +по себе не доказывает уменьшение памяти или latency. + ### Владение данными и диагностиками В проекте соблюдается строгое разделение ответственности между слоями: diff --git a/scripts/lsp-bench/bench_lsp.py b/scripts/lsp-bench/bench_lsp.py index 4da56c1b6..c13dfea40 100644 --- a/scripts/lsp-bench/bench_lsp.py +++ b/scripts/lsp-bench/bench_lsp.py @@ -18,7 +18,9 @@ partial-result streaming, then a cancelled second request. Outputs under --out: run.json (timings, per-file latencies, errors) and -rss.csv (t_s,rss_kb). +rss.csv (synchronized RSS/VmHWM inputs), and input-manifest.json (relative +paths and content hashes). Optional --request-samples and --update-cycles +retain every warm request and BSL/XML/config update latency as individual samples. """ from __future__ import annotations @@ -27,6 +29,7 @@ import hashlib import json import os +import platform import shutil import sys import tempfile @@ -48,6 +51,108 @@ def sha256_file(path): return digest.hexdigest() +def workspace_manifest(workspace): + entries = [] + for dirpath, dirnames, filenames in os.walk(workspace): + dirnames[:] = sorted( + name for name in dirnames + if name not in (".git", ".bsl-analyzer", "target") + and not os.path.islink(os.path.join(dirpath, name)) + ) + for name in sorted(filenames): + if name == "input-manifest.json": + continue + path = os.path.join(dirpath, name) + if os.path.islink(path): + continue + relative = os.path.relpath(path, workspace).replace(os.sep, "/") + entries.append({"path": relative, "sha256": sha256_file(path), "bytes": os.path.getsize(path)}) + digest = hashlib.sha256() + for entry in entries: + digest.update(entry["path"].encode("utf-8")) + digest.update(b"\0") + digest.update(bytes.fromhex(entry["sha256"])) + return {"sha256": digest.hexdigest(), "files": entries} + + +def generate_1000_workspace(workspace): + """Create the fixed protocol corpus; refuse to overwrite any existing data.""" + if os.path.exists(workspace) and os.listdir(workspace): + raise SystemExit(f"bench_lsp: refusing to generate into non-empty path: {workspace}") + os.makedirs(workspace, exist_ok=True) + with open(os.path.join(workspace, CONFIG_NAME), "w", encoding="utf-8") as fh: + fh.write('[source]\nroot = "."\n\n[diagnostics.parameters]\nUnresolvedBareCall = true\n') + with open(os.path.join(workspace, "Configuration.xml"), "w", encoding="utf-8") as fh: + fh.write( + '\n' + ' \n' + " Benchmark\n" + " \n\n" + ) + for index in range(1000): + name = f"Module{index:04d}" + directory = os.path.join(workspace, "CommonModules", name) + source = os.path.join(directory, "Ext", "Module.bsl") + metadata = os.path.join(workspace, "CommonModules", name + ".xml") + os.makedirs(os.path.dirname(source), exist_ok=True) + with open(metadata, "w", encoding="utf-8") as fh: + fh.write( + '\n' + '\n' + f' \n' + f" {name}true" + "true\n" + " \n\n" + ) + # Keep the symbol graph broad but shallow: a deep call chain overflows + # the server's analysis stack and measures a crash instead of scale. + previous = "1" + if index == 1: + body = ( + "Функция Значение() Экспорт\n" + " Возврат 1;\n" + "КонецФункции\n\n" + "Процедура Benchmark() Экспорт\n" + " Module0999.Значение();\n" + " BenchmarkGlobalCall();\n" + " НетТакойПроцедуры();\n" + "КонецПроцедуры\n" + ) + else: + body = ( + "Функция Значение() Экспорт\n" + f" Возврат {previous};\n" + "КонецФункции\n" + + ( + "\nФункция BenchmarkGlobalCall() Экспорт\n" + " Возврат 1;\n" + "КонецФункции\n" + if index == 0 + else "" + ) + ) + with open(source, "w", encoding="utf-8") as fh: + fh.write(body) + if index == 0: + metadata_text = open(metadata, encoding="utf-8").read() + metadata_text = metadata_text.replace( + "true", "truetrue", 1 + ) + with open(metadata, "w", encoding="utf-8") as fh: + fh.write(metadata_text) + manifest = workspace_manifest(workspace) + if len(manifest["files"]) != 2002: + raise AssertionError(f"expected 1000 BSL/XML pairs and two config files, got {len(manifest['files'])}") + with open(os.path.join(workspace, "input-manifest.json"), "w", encoding="utf-8") as fh: + json.dump( + {"module_count": 1000, "workspace_sha256": manifest["sha256"], "files": manifest["files"]}, + fh, + ensure_ascii=False, + indent=2, + ) + return manifest["sha256"] + + def source_root(workspace): config = os.path.join(workspace, CONFIG_NAME) if os.path.exists(config): @@ -70,10 +175,94 @@ def pick_bsl_files(workspace, count): return files +def pull_diagnostics(client, uri, expected_code, expected_token, should_exist, timeout, + include_received_at=False): + """Pull until a successful response contains the expected semantic signal.""" + deadline = time.monotonic() + timeout + attempts = 0 + last = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + attempts += 1 + response = client.request( + "textDocument/diagnostic", {"textDocument": {"uri": uri}}, + timeout=min(5.0, remaining), + ) + if response.received_at is not None and response.received_at > deadline: + break + if not response.ok: + if response.error_code == CONTENT_MODIFIED: + continue + raise LspError(f"textDocument/diagnostic failed: {response.error}") + last = (response.result or {}).get("items", []) + matched = [ + diagnostic for diagnostic in last + if diagnostic.get("code") == expected_code + and expected_token in str(diagnostic.get("message", "")) + ] + if bool(matched) == should_exist: + proof = {"attempts": attempts, "diagnostics": len(last)} + if include_received_at: + if response.received_at is None: + raise LspError("textDocument/diagnostic response has no receive timestamp") + proof["received_at"] = response.received_at + return proof + raise RequestTimeout( + f"semantic result did not reach code={expected_code} token={expected_token!r} " + f"present={should_exist}; attempts={attempts}; last_diagnostic_count=" + f"{None if last is None else len(last)}; last_diagnostics=" + f"{None if last is None else [(item.get('code'), item.get('message')) for item in last]}" + ) + + +def request_pull_document(client, uri, timeout): + """Retry transient ContentModified responses until a current document result arrives.""" + deadline = time.monotonic() + timeout + attempts = 0 + while time.monotonic() < deadline: + attempts += 1 + response = client.request( + "textDocument/diagnostic", {"textDocument": {"uri": uri}}, + timeout=min(5.0, max(0.1, deadline - time.monotonic())), + ) + if response.error_code != CONTENT_MODIFIED: + return response, attempts + time.sleep(0.025) + raise RequestTimeout( + f"textDocument/diagnostic remained ContentModified for {attempts} attempts" + ) + + +def benchmark_controls(workspace): + """Return the known caller and metadata signal for the corpus or perf-shadow.""" + source = source_root(workspace) + corpus_caller = os.path.join( + source, "CommonModules", "Module0001", "Ext", "Module.bsl" + ) + if os.path.isfile(corpus_caller): + return { + "caller": corpus_caller, + "metadata": os.path.join(source, "CommonModules", "Module0000.xml"), + "xml_token": "BenchmarkGlobalCall", + "dataset": "corpus-1000-v4", + } + return { + "caller": os.path.join( + source, "CommonModules", "Потребитель", "Ext", "Module.bsl" + ), + "metadata": os.path.join(source, "CommonModules", "ОбщийМодуль.xml"), + "xml_token": "ЧисловоеЗначение", + "dataset": "shared-workspace-perf-shadow", + } + + def make_shadow_workspace(workspace, scope): - """Tempdir mirroring `workspace` via symlinks, with pull diagnostics - forced on in a *copied* config. The original config is never modified. - The tempdir is removed on any failure inside this builder.""" + """Mirror physical directories and symlink leaf inputs, with pull diagnostics + forced on in a copied config. Physical dirs let filesystem walkers discover + module files; leaf links avoid copying or warming the corpus. The original + config is never modified. The tempdir is removed on builder failure.""" shadow = tempfile.mkdtemp(prefix="bsl-bench-shadow-") try: return _fill_shadow_workspace(shadow, workspace, scope) @@ -83,10 +272,18 @@ def make_shadow_workspace(workspace, scope): def _fill_shadow_workspace(shadow, workspace, scope): - for entry in sorted(os.listdir(workspace)): - if entry == CONFIG_NAME: - continue - os.symlink(os.path.join(workspace, entry), os.path.join(shadow, entry)) + for dirpath, dirnames, filenames in os.walk(workspace): + relative_dir = os.path.relpath(dirpath, workspace) + target_dir = shadow if relative_dir == "." else os.path.join(shadow, relative_dir) + os.makedirs(target_dir, exist_ok=True) + dirnames.sort() + for name in sorted(filenames): + if relative_dir == "." and name == CONFIG_NAME: + continue + source = os.path.join(dirpath, name) + if os.path.islink(source): + continue + os.symlink(source, os.path.join(target_dir, name)) original = os.path.join(workspace, CONFIG_NAME) text = "" @@ -122,41 +319,70 @@ def _fill_shadow_workspace(shadow, workspace, scope): class RssSampler: - """1 Hz VmRSS timeline of the server process -> CSV.""" + """Synchronized process RSS timeline and Linux high-water mark.""" - def __init__(self, pid, out_path): + def __init__(self, pid, out_path, peer_pid=None): self.pid = pid + self.peer_pid = peer_pid self.out_path = out_path self.rows = [] + self.summary = None self._stop = threading.Event() self._thread = threading.Thread(target=self._loop, daemon=True) self._t0 = time.monotonic() self._thread.start() - def _read_rss_kb(self): + @staticmethod + def _read_status(pid): + values = {} try: - with open(f"/proc/{self.pid}/status", encoding="ascii") as fh: + with open(f"/proc/{pid}/status", encoding="ascii") as fh: for line in fh: - if line.startswith("VmRSS:"): - return int(line.split()[1]) + if line.startswith(("VmRSS:", "VmHWM:")): + name, value, *_ = line.split() + values[name[:-1]] = int(value) except OSError: - return None - return None + return values + return values def _loop(self): while not self._stop.is_set(): - rss = self._read_rss_kb() - if rss is not None: - self.rows.append((time.monotonic() - self._t0, rss)) + local = self._read_status(self.pid) + peer = self._read_status(self.peer_pid) if self.peer_pid else {} + if "VmRSS" in local: + self.rows.append( + ( + time.monotonic() - self._t0, + local["VmRSS"], + local.get("VmHWM"), + peer.get("VmRSS"), + peer.get("VmHWM"), + ) + ) self._stop.wait(1.0) def stop(self): + if self.summary is not None: + return self.summary self._stop.set() self._thread.join(5) with open(self.out_path, "w", encoding="ascii") as fh: - fh.write("t_s,rss_kb\n") - for t, rss in self.rows: - fh.write(f"{t:.1f},{rss}\n") + fh.write("t_s,rss_kb,vmhwm_kb,peer_rss_kb,peer_vmhwm_kb,pair_rss_kb\n") + for t, rss, hwm, peer_rss, peer_hwm in self.rows: + pair_rss = rss + peer_rss if peer_rss is not None else "" + fh.write( + f"{t:.3f},{rss},{hwm or ''},{peer_rss or ''},{peer_hwm or ''},{pair_rss}\n" + ) + local = self._read_status(self.pid) + peer = self._read_status(self.peer_pid) if self.peer_pid else {} + sampled_hwm = [hwm for _, _, hwm, _, _ in self.rows if hwm is not None] + sampled_peer_hwm = [hwm for _, _, _, _, hwm in self.rows if hwm is not None] + self.summary = { + "vmhwm_kb": local.get("VmHWM") or (max(sampled_hwm) if sampled_hwm else None), + "peer_vmhwm_kb": peer.get("VmHWM") or (max(sampled_peer_hwm) if sampled_peer_hwm else None), + "pair_rss_samples": sum(peer_rss is not None for _, _, _, peer_rss, _ in self.rows), + } + return self.summary def uri_of(path): @@ -210,8 +436,32 @@ def run_profile(args, workspace, run): # The client spawns with cwd=workspace, so a relative binary path would # resolve against the workspace, not the invoker's cwd. binary = os.path.abspath(args.binary) - client = LspClient([binary, "lsp"], cwd=workspace, default_timeout=args.timeout) - sampler = RssSampler(client.proc.pid, os.path.join(args.out, "rss.csv")) + stderr_path = os.path.join(args.out, "server-stderr.log") + stderr_log = open(stderr_path, "wb") + env = os.environ.copy() + if args.owned_xdg_root: + owned_xdg_root = os.path.abspath(args.owned_xdg_root) + if args.run_label == "cold" and os.path.isdir(owned_xdg_root) and os.listdir(owned_xdg_root): + raise SystemExit("bench_lsp: cold run requires an empty owned XDG root") + env.update( + { + "XDG_CACHE_HOME": os.path.join(owned_xdg_root, "cache"), + "XDG_STATE_HOME": os.path.join(owned_xdg_root, "state"), + "XDG_DATA_HOME": os.path.join(owned_xdg_root, "data"), + } + ) + for name in ("XDG_CACHE_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME"): + os.makedirs(env[name], exist_ok=True) + run["owned_xdg_root"] = owned_xdg_root + run["owned_xdg_policy"] = ( + "fresh-empty-root-per-run" if args.run_label == "cold" + else "reused-group-root-no-lsp-semantic-database" + ) + client = LspClient( + [binary, "lsp"], cwd=workspace, env=env, + default_timeout=args.timeout, stderr=stderr_log + ) + sampler = RssSampler(client.proc.pid, os.path.join(args.out, "rss.csv"), args.peer_pid) try: response = client.initialize(uri_of(workspace), client_capabilities(args.profile)) if not response.ok: @@ -259,10 +509,10 @@ def run_profile(args, workspace, run): entry["didopen_to_publish_s"] = time.monotonic() - t_open entry["diagnostics"] = len(message["params"]["diagnostics"]) else: - pulled = client.request( - "textDocument/diagnostic", {"textDocument": {"uri": uri}} - ) - entry["pull_s"] = pulled.elapsed_s + pull_started = time.monotonic() + pulled, attempts = request_pull_document(client, uri, args.timeout) + entry["pull_s"] = time.monotonic() - pull_started + entry["pull_attempts"] = attempts if pulled.ok: entry["diagnostics"] = len( (pulled.result or {}).get("items", []) @@ -281,14 +531,228 @@ def run_profile(args, workspace, run): run["errors"].append({"uri": uri, "method": method, "error": feature.error}) run["files"].append(entry) + controls = benchmark_controls(workspace) + caller_uri = uri_of(controls["caller"]) + if args.update_cycles: + run["semantic_baseline_controls"] = { + "known_diagnostic": pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", "НетТакойПроцедуры", + True, args.timeout, + ), + "bsl_toggle_initially_absent": pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", "BenchmarkUpdateMissing", + False, args.timeout, + ), + "xml_toggle_initially_absent": pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", controls["xml_token"], + False, args.timeout, + ), + } + + if args.request_samples: + controls = benchmark_controls(workspace) + uri = uri_of(controls["caller"] if controls["caller"] in files else files[0]) + samples = [] + for sample in range(1, args.request_samples + 1): + response = client.request( + "textDocument/documentSymbol", {"textDocument": {"uri": uri}} + ) + item = { + "sample": sample, + "method": "textDocument/documentSymbol", + "elapsed_s": response.elapsed_s, + "ok": response.ok, + } + if not response.ok: + item["error"] = response.error + run["errors"].append( + {"sample": sample, "method": item["method"], "error": response.error} + ) + samples.append(item) + run["request_samples"] = samples + + if args.update_cycles: + controls = benchmark_controls(workspace) + path = controls["caller"] + if path not in files: + raise SystemExit("bench_lsp: benchmark semantic caller was not opened") + uri = uri_of(path) + with open(path, encoding="utf-8-sig", errors="replace") as fh: + original_text = fh.read() + cycles = [] + for cycle in range(1, args.update_cycles + 1): + enabled = True + text = original_text.replace( + "КонецПроцедуры", + (" BenchmarkUpdateMissing();\n" if enabled else "") + + "КонецПроцедуры", + 1, + ) + started = time.monotonic() + client.notify( + "textDocument/didChange", + { + "textDocument": {"uri": uri, "version": cycle * 2}, + "contentChanges": [{"text": text}], + }, + ) + try: + proof = pull_diagnostics( + client, uri, "UnresolvedBareCall", "BenchmarkUpdateMissing", + enabled, args.timeout, include_received_at=True, + ) + changed_elapsed = proof.pop("received_at") - started + restore_started = time.monotonic() + client.notify( + "textDocument/didChange", + { + "textDocument": {"uri": uri, "version": cycle * 2 + 1}, + "contentChanges": [{"text": original_text}], + }, + ) + restore = pull_diagnostics( + client, uri, "UnresolvedBareCall", "BenchmarkUpdateMissing", + False, args.timeout, include_received_at=True, + ) + cycles.append( + {"cycle": cycle, "elapsed_s": changed_elapsed, + "ok": True, "expected_present": enabled, **proof, + "restore_elapsed_s": restore.pop("received_at") - restore_started, + "restored_expected_absent": True, "restore_attempts": restore["attempts"]} + ) + except LspError as exc: + cycles.append( + { + "cycle": cycle, + "elapsed_s": time.monotonic() - started, + "ok": False, + "expected_present": enabled, + "error": str(exc), + } + ) + run["errors"].append( + {"cycle": cycle, "method": "textDocument/didChange", "error": str(exc)} + ) + break + run["bsl_update_cycles"] = cycles + + module_xml = controls["metadata"] + caller_uri = uri_of(path) + config_path = os.path.join(workspace, CONFIG_NAME) + if not all(os.path.isfile(path) for path in (module_xml, config_path)): + raise SystemExit("bench_lsp: XML/config cycles require the generated 1000-module workspace") + + xml_original = open(module_xml, encoding="utf-8").read() + xml_enabled = "true" in xml_original + if not xml_enabled: + raise SystemExit("bench_lsp: benchmark target must begin with Global=true") + xml_cycles = [] + try: + for cycle in range(1, args.update_cycles + 1): + enabled = False + changed = xml_original.replace( + f"{str(not enabled).lower()}", + f"{str(enabled).lower()}", + 1, + ) + started = time.monotonic() + if os.path.islink(module_xml): + os.unlink(module_xml) + with open(module_xml, "w", encoding="utf-8") as fh: + fh.write(changed) + client.notify( + "workspace/didChangeWatchedFiles", + {"changes": [{"uri": uri_of(module_xml), "type": 2}]}, + ) + try: + proof = pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", controls["xml_token"], + not enabled, args.timeout, include_received_at=True, + ) + changed_elapsed = proof.pop("received_at") - started + restore_started = time.monotonic() + with open(module_xml, "w", encoding="utf-8") as fh: + fh.write(xml_original) + client.notify( + "workspace/didChangeWatchedFiles", + {"changes": [{"uri": uri_of(module_xml), "type": 2}]}, + ) + restore = pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", controls["xml_token"], + False, args.timeout, include_received_at=True, + ) + xml_cycles.append({"cycle": cycle, "elapsed_s": changed_elapsed, + "ok": True, "global_enabled": enabled, **proof, + "restore_elapsed_s": restore.pop("received_at") - restore_started, + "restored_expected_absent": True, + "restore_attempts": restore["attempts"]}) + except LspError as exc: + xml_cycles.append({"cycle": cycle, "elapsed_s": time.monotonic() - started, + "ok": False, "error": str(exc), "global_enabled": enabled}) + run["errors"].append({"cycle": cycle, "kind": "xml", "error": str(exc)}) + break + finally: + with open(module_xml, "w", encoding="utf-8") as fh: + fh.write(xml_original) + run["xml_update_cycles"] = xml_cycles + + with open(config_path, encoding="utf-8") as fh: + config_original = fh.read() + if "UnresolvedBareCall = true" not in config_original: + raise SystemExit("bench_lsp: generated config lacks the UnresolvedBareCall cycle setting") + config_cycles = [] + try: + for cycle in range(1, args.update_cycles + 1): + enabled = False + changed = config_original.replace( + f"UnresolvedBareCall = {str(not enabled).lower()}", + f"UnresolvedBareCall = {str(enabled).lower()}", + 1, + ) + started = time.monotonic() + with open(config_path, "w", encoding="utf-8") as fh: + fh.write(changed) + client.notify("textDocument/didSave", {"textDocument": {"uri": uri_of(config_path)}}) + try: + proof = pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", "НетТакойПроцедуры", + enabled, args.timeout, include_received_at=True, + ) + changed_elapsed = proof.pop("received_at") - started + restore_started = time.monotonic() + with open(config_path, "w", encoding="utf-8") as fh: + fh.write(config_original) + client.notify("textDocument/didSave", {"textDocument": {"uri": uri_of(config_path)}}) + restore = pull_diagnostics( + client, caller_uri, "UnresolvedBareCall", "НетТакойПроцедуры", + True, args.timeout, include_received_at=True, + ) + config_cycles.append({"cycle": cycle, "elapsed_s": changed_elapsed, + "ok": True, "rule_enabled": enabled, **proof, + "restore_elapsed_s": restore.pop("received_at") - restore_started, + "restored_expected_present": True, + "restore_attempts": restore["attempts"]}) + except LspError as exc: + config_cycles.append({"cycle": cycle, "elapsed_s": time.monotonic() - started, + "ok": False, "error": str(exc), "rule_enabled": enabled}) + run["errors"].append({"cycle": cycle, "kind": "config", "error": str(exc)}) + break + finally: + with open(config_path, "w", encoding="utf-8") as fh: + fh.write(config_original) + run["config_update_cycles"] = config_cycles + if args.profile == "workspace-stream": run["stream"] = run_stream(client, args) + run["memory"] = sampler.stop() code = client.shutdown() run["timings"]["server_exit_code"] = code finally: - sampler.stop() + run.setdefault("memory", sampler.stop()) + run["server_exit_code_before_cleanup"] = client.proc.poll() client.close() + stderr_log.close() def run_stream(client, args): @@ -325,14 +789,29 @@ def run_stream(client, args): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--binary", required=True, help="path to bsl-analyzer-app") - parser.add_argument("--workspace", required=True) - parser.add_argument("--out", required=True) + parser.add_argument("--binary", help="path to bsl-analyzer-app") + parser.add_argument("--workspace") + parser.add_argument("--out") + parser.add_argument("--run-label", choices=("cold", "warm")) + parser.add_argument("--owned-xdg-root", help="dedicated XDG cache/state/data tree for this run or warm group") + parser.add_argument("--generate-1000-workspace", help="generate the deterministic 1000-module corpus and exit") parser.add_argument( "--profile", choices=("push", "pull", "workspace-stream"), default="push" ) parser.add_argument("--pull-scope", choices=("all", "extensions"), default="all") parser.add_argument("--files", type=int, default=5) + parser.add_argument( + "--request-samples", type=int, default=0, + help="record this many extra documentSymbol request latencies", + ) + parser.add_argument( + "--update-cycles", type=int, default=0, + help="record this many BSL, metadata XML, and config update-to-diagnostics cycles", + ) + parser.add_argument( + "--peer-pid", type=int, + help="sample a peer process on the same 1 Hz RSS clock", + ) parser.add_argument("--timeout", type=float, default=120.0) parser.add_argument("--boot-timeout", type=float, default=600.0) parser.add_argument( @@ -340,6 +819,18 @@ def main(): ) args = parser.parse_args() + if args.generate_1000_workspace: + path = os.path.abspath(args.generate_1000_workspace) + digest = generate_1000_workspace(path) + print(json.dumps({"module_count": 1000, "workspace_sha256": digest}, indent=2)) + return + if not args.binary or not args.workspace or not args.out: + parser.error("--binary, --workspace and --out are required unless generating the corpus") + if args.request_samples < 0 or args.update_cycles < 0: + parser.error("sample and update cycle counts must be non-negative") + if args.update_cycles and args.files < 1: + parser.error("--update-cycles requires at least one opened BSL document") + os.makedirs(args.out, exist_ok=True) workspace = os.path.abspath(args.workspace) original_config = os.path.join(workspace, CONFIG_NAME) @@ -349,12 +840,41 @@ def main(): run = { "profile": args.profile, + "run_label": args.run_label, "workspace": workspace, "binary": args.binary, + "binary_sha256": sha256_file(os.path.abspath(args.binary)), + "controller_sha256": sha256_file(__file__), + "client_sha256": sha256_file(os.path.join(os.path.dirname(__file__), "lsp_client.py")), + "platform": { + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + "python": platform.python_version(), + }, "timings": {}, "files": [], "errors": [], + "measurement": { + "dataset": benchmark_controls(workspace)["dataset"], + "run_label": args.run_label, + "pull_scope": args.pull_scope, + "update_latency_source": ( + "successful textDocument/diagnostic response receive timestamp; " + "includes XML/config file write; no fixed polling sleep" + ), + "home_override": False, + "persistent_lsp_semantic_storage": False, + "request_samples_requested": args.request_samples, + "bsl_xml_config_update_cycles_requested_each": args.update_cycles, + "peer_pid": args.peer_pid, + }, } + manifest = workspace_manifest(workspace) + run["input_manifest_sha256"] = manifest["sha256"] + run["input_file_count"] = len(manifest["files"]) + with open(os.path.join(args.out, "input-manifest.json"), "w", encoding="utf-8") as fh: + json.dump(manifest, fh, ensure_ascii=False, indent=2) shadow = None try: @@ -374,6 +894,18 @@ def main(): run["config_untouched"] = config_hash_before == config_hash_after if not run["config_untouched"]: run["errors"].append({"fatal": "workspace config hash changed during the run"}) + manifest_after = workspace_manifest(workspace) + run["workspace_unchanged"] = manifest_after["sha256"] == run["input_manifest_sha256"] + if not run["workspace_unchanged"]: + run["errors"].append({"fatal": "workspace input hash changed during the run"}) + run["request_samples_recorded"] = len(run.get("request_samples", [])) + for kind in ("bsl", "xml", "config"): + run[f"{kind}_update_cycles_recorded"] = len(run.get(f"{kind}_update_cycles", [])) + memory = run.get("memory", {}) + if memory.get("vmhwm_kb") is None: + run["errors"].append({"fatal": "Linux VmHWM was unavailable for the LSP process"}) + if args.peer_pid and memory.get("pair_rss_samples", 0) == 0: + run["errors"].append({"fatal": "peer RSS had no synchronized samples"}) with open(os.path.join(args.out, "run.json"), "w", encoding="utf-8") as fh: json.dump(run, fh, ensure_ascii=False, indent=2) diff --git a/scripts/lsp-bench/lsp_client.py b/scripts/lsp-bench/lsp_client.py index 300ba33ab..24b45eb39 100644 --- a/scripts/lsp-bench/lsp_client.py +++ b/scripts/lsp-bench/lsp_client.py @@ -53,6 +53,7 @@ class Response: result: object error: dict | None elapsed_s: float + received_at: float | None = None @property def ok(self) -> bool: @@ -162,11 +163,12 @@ def _reader_loop(self) -> None: message = self._read_message() if message is None: break + received_at = time.monotonic() self.transcript.add("in", message) if "method" in message and "id" in message: self._handle_server_request(message) elif "id" in message: - self._route_response(message) + self._route_response(message, received_at) else: self._handle_notification(message) except ProtocolError as exc: @@ -184,11 +186,12 @@ def _fail_pending(self, reason: str) -> None: waiter["error"] = reason waiter["event"].set() - def _route_response(self, message: dict) -> None: + def _route_response(self, message: dict, received_at: float) -> None: with self._pending_lock: waiter = self._pending.pop(message["id"], None) if waiter is not None: waiter["message"] = message + waiter["received_at"] = received_at waiter["event"].set() def _handle_server_request(self, message: dict) -> None: @@ -230,17 +233,22 @@ def request_async(self, method: str, params) -> tuple[int, dict]: def wait_response(self, request_id: int, waiter: dict, timeout: float | None = None) -> Response: timeout = self.default_timeout if timeout is None else timeout - if not waiter["event"].wait(timeout): + deadline = time.monotonic() + timeout + if not waiter["event"].wait(max(0.0, deadline - time.monotonic())): with self._pending_lock: self._pending.pop(request_id, None) raise RequestTimeout(f"request {request_id} timed out after {timeout}s") if waiter["error"] is not None: raise ProtocolError(waiter["error"]) + received_at = waiter["received_at"] + if received_at is None or received_at > deadline: + raise RequestTimeout(f"request {request_id} response arrived after {timeout}s deadline") message = waiter["message"] return Response( result=message.get("result"), error=message.get("error"), - elapsed_s=time.monotonic() - waiter["t0"], + elapsed_s=received_at - waiter["t0"], + received_at=received_at, ) def request(self, method: str, params, timeout: float | None = None) -> Response: diff --git a/scripts/lsp-bench/tests/test_bench_lsp.py b/scripts/lsp-bench/tests/test_bench_lsp.py new file mode 100644 index 000000000..01290dc22 --- /dev/null +++ b/scripts/lsp-bench/tests/test_bench_lsp.py @@ -0,0 +1,127 @@ +"""Small controller tests for transient pull-diagnostic retries.""" + +import os +import sys +import tempfile +import time +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import bench_lsp # noqa: E402 +from bench_lsp import pull_diagnostics, request_pull_document # noqa: E402 +from lsp_client import CONTENT_MODIFIED, LspClient, RequestTimeout, Response # noqa: E402 + +FAKE_SERVER = os.path.join(os.path.dirname(__file__), "fake_server.py") + + +class PullRetryCase(unittest.TestCase): + def test_content_modified_is_retried_until_current_result(self): + class Client: + def __init__(self): + self.calls = 0 + + def request(self, method, params, timeout=None): + self.calls += 1 + if self.calls == 1: + return Response( + result=None, + error={"code": CONTENT_MODIFIED, "message": "busy"}, + elapsed_s=0.001, + ) + return Response( + result={"kind": "full", "items": []}, error=None, elapsed_s=0.001 + ) + + client = Client() + response, attempts = request_pull_document(client, "file:///caller.bsl", 1.0) + self.assertTrue(response.ok) + self.assertEqual(attempts, 2) + self.assertEqual(client.calls, 2) + + def test_semantic_pull_retries_busy_and_wrong_state_without_fixed_sleep(self): + class Client: + def __init__(self): + self.responses = [ + Response(None, {"code": CONTENT_MODIFIED, "message": "busy"}, 0.001, + time.monotonic()), + Response({"items": [{"code": "Rule", "message": "Token"}]}, None, + 0.001, time.monotonic()), + Response({"items": []}, None, 0.001, time.monotonic()), + ] + self.timeouts = [] + + def request(self, method, params, timeout=None): + self.timeouts.append(timeout) + return self.responses.pop(0) + + client = Client() + with mock.patch.object(bench_lsp.time, "sleep", side_effect=AssertionError("poll sleep")): + result = pull_diagnostics( + client, "file:///caller.bsl", "Rule", "Token", False, 1.0, + include_received_at=True, + ) + self.assertEqual(result["attempts"], 3) + self.assertEqual(result["diagnostics"], 0) + self.assertIsNotNone(result["received_at"]) + self.assertTrue(all(0 < timeout <= 5.0 for timeout in client.timeouts)) + + +class ResponseReceiveTimingCase(unittest.TestCase): + def setUp(self): + self.client = object.__new__(LspClient) + + def test_response_elapsed_uses_receive_time_before_delayed_waiter_wakeup(self): + class DelayedEvent: + def wait(self, _timeout): + time.sleep(0.05) + return True + + t0 = time.monotonic() + waiter = { + "event": DelayedEvent(), + "error": None, + "message": {"result": {"items": []}}, + "received_at": t0 + 0.001, + "t0": t0, + } + wall_started = time.monotonic() + response = self.client.wait_response(1, waiter, timeout=1.0) + wall_elapsed = time.monotonic() - wall_started + + self.assertAlmostEqual(response.elapsed_s, 0.001, delta=0.005) + self.assertGreater(wall_elapsed - response.elapsed_s, 0.03) + + def test_happy_fake_server_transcript_has_receive_timestamps(self): + with tempfile.TemporaryDirectory() as tmp: + client = LspClient( + [sys.executable, FAKE_SERVER, "happy", os.path.join(tmp, "received.jsonl")], + default_timeout=5.0, + ) + try: + initialized = client.initialize("file:///ws") + hover = client.request("textDocument/hover", {}) + self.assertTrue(initialized.ok) + self.assertTrue(hover.ok) + self.assertIsNotNone(initialized.received_at) + self.assertIsNotNone(hover.received_at) + self.assertGreater(hover.elapsed_s, 0) + self.assertEqual(client.shutdown(), 0) + finally: + client.close() + + def test_response_received_after_absolute_deadline_is_rejected(self): + class ReadyEvent: + def wait(self, _timeout): + return True + + waiter = { + "event": ReadyEvent(), + "error": None, + "message": {"result": {"items": []}}, + "received_at": time.monotonic() + 1.0, + "t0": time.monotonic(), + } + with self.assertRaises(RequestTimeout): + self.client.wait_response(1, waiter, timeout=0.01) From 09fb4ef25dc6c139af1aaa520338117265206638 Mon Sep 17 00:00:00 2001 From: defin85 <31535407+defin85@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:46:17 +0300 Subject: [PATCH 02/58] fix(graph): resolve metadata paths across batch boundaries --- crates/base-db/src/lib.rs | 6 ++ crates/ide-db/src/database.rs | 30 ++++--- crates/ide-db/src/database_impl_tests.rs | 101 ++++++++++++++++++++++- crates/mcp-server/src/graph/build.rs | 52 ++++++++++++ crates/mcp-server/src/graph/input.rs | 12 +-- 5 files changed, 182 insertions(+), 19 deletions(-) diff --git a/crates/base-db/src/lib.rs b/crates/base-db/src/lib.rs index d0dc87c15..d2078822c 100644 --- a/crates/base-db/src/lib.rs +++ b/crates/base-db/src/lib.rs @@ -440,6 +440,12 @@ impl Files { }) } + /// Whether an explicit file→root registration exists, without weakening the + /// strict [`Self::file_source_root`] accessor used by source and text queries. + pub fn try_file_source_root(&self, file_id: FileId) -> Option { + self.file_source_roots.get(&file_id).map(|entry| *entry.value()) + } + /// The file→root mapping inherits the target root's durability (the root input /// must already be registered; an unknown root falls back to LOW). Same reason /// as [`Self::set_source_root`]: `file_text_query`'s disk branch reads the diff --git a/crates/ide-db/src/database.rs b/crates/ide-db/src/database.rs index 5e34fffce..1b71af50d 100644 --- a/crates/ide-db/src/database.rs +++ b/crates/ide-db/src/database.rs @@ -7,7 +7,9 @@ use stdx::case::CaseExt; use dashmap::DashMap; use rustc_hash::FxHasher; -use base_db::{FileIdInput, Files, RootQueryDb, SourceDatabase, SourceRoot, SourceRootId}; +use base_db::{ + FileIdInput, Files, RootQueryDb, SourceDatabase, SourceRoot, SourceRootId, BSL_SOURCE_ROOT, +}; use hir::{ ConditionalTree, DefDatabase, ItemTree, ModuleBodies, ModuleData, ModuleId, RegionTree, SymbolTree, @@ -800,10 +802,10 @@ impl RootDatabaseImpl { /// The same ranking, addressed by PATH. /// /// A caller that already holds the path must not go back through the file id: - /// resolving a path from an id needs the global file→root mapping, which - /// PANICS for a file the database was never told about — and the graph builder - /// runs against per-batch databases that legitimately know only their own - /// slice. See `CommonModuleCandidates::reflagged` for the same asymmetry. + /// graph batches intentionally register text and file→root inputs only for + /// their own slice. Path lookup can use the shared root's exact FileId without + /// those registrations, but this method's caller already has the path. + /// See `CommonModuleCandidates::reflagged` for the same asymmetry. pub fn config_root_rank_for_path( &self, file_path: &std::path::Path, @@ -2449,10 +2451,9 @@ impl RootDatabaseImpl { hir::MdoModuleRole::RecordSet => index.record_set_candidates(mdo_type, &name).to_vec(), }; - // Paths come from the source root's own file set, never from the global - // file→root mapping: that mapping panics for a file the database has not - // been told about, and the graph builder resolves against per-batch - // databases holding only their own slice. + // Paths come from the source root's own file set: graph batches resolve + // against the full root while registering file→root inputs only for their + // own slice. let source_root = self.source_root_input(source_root_id).root(self); let file_set = source_root.file_set(); let rank_of = |file: FileId| -> Option { @@ -2673,9 +2674,14 @@ impl RootDatabaseImpl { } pub(crate) fn get_file_path(&self, file_id: FileId) -> Option { - let source_root_input = self.file_source_root_input(file_id); - let source_root_id = source_root_input.source_root_id(self); - let source_root_input = self.source_root_input(source_root_id); + // Graph batch databases publish the full BSL FileSet but register a + // file→root input only for the batch whose text they load. + let source_root_id = self + .files + .try_file_source_root(file_id) + .map(|input| input.source_root_id(self)) + .unwrap_or(BSL_SOURCE_ROOT); + let source_root_input = self.try_source_root_input(source_root_id)?; let source_root = source_root_input.root(self); let file_set = source_root.file_set(); let vfs_path = file_set.path_for_file(&file_id)?; diff --git a/crates/ide-db/src/database_impl_tests.rs b/crates/ide-db/src/database_impl_tests.rs index ad8f17053..4aca7162a 100644 --- a/crates/ide-db/src/database_impl_tests.rs +++ b/crates/ide-db/src/database_impl_tests.rs @@ -8003,6 +8003,18 @@ fn borrowed_form_module(root: &std::path::Path, owner: &str, form: &str) -> std: fn borrowed_form_db( roots: &[(Option<&str>, &std::path::Path, RootKind)], modules: &[std::path::PathBuf], +) -> RootDatabaseImpl { + borrowed_form_db_with_registered_modules( + roots, + modules, + &(0..modules.len()).collect::>(), + ) +} + +fn borrowed_form_db_with_registered_modules( + roots: &[(Option<&str>, &std::path::Path, RootKind)], + modules: &[std::path::PathBuf], + registered_modules: &[usize], ) -> RootDatabaseImpl { let mut db = RootDatabaseImpl::new(); let mut file_set = FileSet::new(); @@ -8010,7 +8022,8 @@ fn borrowed_form_db( file_set.insert(FileId(idx as u32), VfsPath::new(path.to_string_lossy().as_ref())); } db.set_source_root(SourceRootId(0), SourceRoot::new_local(file_set)); - for (idx, path) in modules.iter().enumerate() { + for &idx in registered_modules { + let path = &modules[idx]; db.set_file_source_root(FileId(idx as u32), SourceRootId(0)); db.set_file_text(FileId(idx as u32), &std::fs::read_to_string(path).unwrap()); } @@ -8043,6 +8056,92 @@ fn borrowed_form_db( db } +#[test] +fn borrowed_form_batch_resolves_base_before_base_registration() { + let root = borrowed_form_fixture_root(); + let base = root.join("base"); + let extension = root.join("extension"); + let modules = vec![ + borrowed_form_module(&extension, "Заказ", "ФормаДокумента"), + borrowed_form_module(&base, "Заказ", "ФормаДокумента"), + ]; + let mut db = borrowed_form_db_with_registered_modules( + &[(None, &base, RootKind::Base), (Some("Расширение"), &extension, RootKind::Extension)], + &modules, + &[0], + ); + + let inherited = borrowed_form(&db, FileId(0)) + .expect("extension form metadata resolves its base path across batch boundaries"); + assert_eq!(borrowed_form_names(&inherited), ["Объект", "БазовыйРеквизитФормы"]); + assert!(db.files.try_file_text(FileId(1)).is_none()); + assert!(db.files.try_file_revision(FileId(1)).is_none()); + + let unknown = FileId(99); + assert_eq!( + crate::vfs_helpers::get_file_path(&db, unknown), + None, + "only exact members of the known BSL root have a fallback path" + ); + let unknown_metadata = + crate::queries::module_metadata_query(&db, base_db::FileIdInput::new(&db, unknown)); + assert_eq!(unknown_metadata.module_type, bsl_metadata::ModuleType::Unknown); + + let root_input = db.source_root_input(SourceRootId(0)); + let mut file_set = root_input.root(&db).file_set().clone(); + file_set.remove(FileId(1)); + db.set_source_root(SourceRootId(0), SourceRoot::new_local(file_set)); + let after_remove = borrowed_form(&db, FileId(0)).expect("extension form still loads"); + assert!( + !borrowed_form_names(&after_remove).contains(&"БазовыйРеквизитФормы"), + "removing the base FileId invalidates the inherited metadata query" + ); + + let root_input = db.source_root_input(SourceRootId(0)); + let mut file_set = root_input.root(&db).file_set().clone(); + file_set.insert(FileId(1), VfsPath::new(modules[1].to_string_lossy().as_ref())); + db.set_source_root(SourceRootId(0), SourceRoot::new_local(file_set)); + let after_readd = borrowed_form(&db, FileId(0)).expect("re-added base form resolves"); + assert!(borrowed_form_names(&after_readd).contains(&"БазовыйРеквизитФормы")); +} + +#[test] +fn file_path_lookup_uses_explicit_root_and_does_not_fallback_from_it() { + let mut db = RootDatabaseImpl::new(); + let explicitly_mapped = FileId(7); + let explicitly_missing = FileId(8); + let mut bsl_files = FileSet::new(); + bsl_files.insert(explicitly_mapped, VfsPath::new("/bsl/registered.bsl")); + bsl_files.insert(explicitly_missing, VfsPath::new("/bsl/missing-from-explicit-root.bsl")); + let mut other_files = FileSet::new(); + other_files.insert(explicitly_mapped, VfsPath::new("/other/registered.bsl")); + let root1_only = FileId(9); + other_files.insert(root1_only, VfsPath::new("/other/root1-only.bsl")); + db.set_source_root(SourceRootId(0), SourceRoot::new_local(bsl_files)); + db.set_source_root(SourceRootId(1), SourceRoot::new_local(other_files)); + db.set_file_source_root(explicitly_mapped, SourceRootId(1)); + db.set_file_source_root(explicitly_missing, SourceRootId(1)); + + assert_eq!( + crate::vfs_helpers::get_file_path(&db, explicitly_mapped), + Some(std::path::PathBuf::from("/other/registered.bsl")), + "an explicit root wins when the ID appears in more than one FileSet" + ); + assert_eq!( + crate::vfs_helpers::get_file_path(&db, explicitly_missing), + None, + "an explicit mapping with no matching FileSet path must not fall back to root 0" + ); + assert_eq!( + crate::vfs_helpers::get_file_path(&db, root1_only), + None, + "an unregistered ID in root 1 is not eligible for the BSL-root fallback" + ); + let root1_metadata = + crate::queries::module_metadata_query(&db, base_db::FileIdInput::new(&db, root1_only)); + assert_eq!(root1_metadata.module_type, bsl_metadata::ModuleType::Unknown); +} + fn borrowed_form(db: &RootDatabaseImpl, file: FileId) -> Option> { crate::queries::module_metadata_query(db, base_db::FileIdInput::new(db, file)).form.clone() } diff --git a/crates/mcp-server/src/graph/build.rs b/crates/mcp-server/src/graph/build.rs index e7352c429..bc2ab948b 100644 --- a/crates/mcp-server/src/graph/build.rs +++ b/crates/mcp-server/src/graph/build.rs @@ -4633,6 +4633,58 @@ mod tests { .unwrap(); } + #[test] + fn borrowed_form_graph_is_independent_of_batch_size() { + fn copy_dir(from: &Path, to: &Path) { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).unwrap(); + } + } + } + + let fixture = PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../bsl-metadata/fixtures/extension_metadata" + )); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + copy_dir(&fixture.join("base"), root); + let extension = root.join("cfe/Расширение"); + copy_dir(&fixture.join("extension"), &extension); + declare_scoped_extension(root, "Расширение", "cfe/Расширение"); + + let meta = crate::graph_db::GraphMeta { + revision: 1, + fingerprint: crate::graph_db::GraphFp::default(), + files: 0, + built_at: "t".to_string(), + publication_id: "test-1".to_owned(), + }; + let one_batch = root.join(".build/one-batch.db"); + let all_batches = root.join(".build/all-modules.db"); + fs::create_dir_all(one_batch.parent().unwrap()).unwrap(); + build_whole_graph(root, &one_batch, 1, &meta).expect("single-file batches build"); + build_whole_graph(root, &all_batches, usize::MAX, &meta) + .expect("the whole workspace batch builds"); + + let (one_nodes, one_edges, _, _) = dump_data(&one_batch); + let (all_nodes, all_edges, _, _) = dump_data(&all_batches); + assert_eq!(one_nodes, all_nodes, "all graph nodes match across batch sizes"); + assert_eq!(one_edges, all_edges, "all graph edges match across batch sizes"); + assert!( + one_nodes.iter().any(|row| { + row.contains("form_attr/Document/Заказ/ФормаДокумента/БазовыйРеквизитФормы") + }), + "batch size one must retain the inherited base-form attribute" + ); + } + /// The straddle verdict is more than a fingerprint comparison: either /// bracketing scan failing to cover the whole tree loses the coherence claim /// even when the fingerprints match exactly. diff --git a/crates/mcp-server/src/graph/input.rs b/crates/mcp-server/src/graph/input.rs index 1258f34dd..72105e489 100644 --- a/crates/mcp-server/src/graph/input.rs +++ b/crates/mcp-server/src/graph/input.rs @@ -238,12 +238,12 @@ pub(crate) struct BatchLoad { /// target is addressable by path through the module index) but loads text only for /// `batch_files` — the only modules this database lowers. /// -/// `file_source_root` is set ONLY for `batch_files`: the per-file source-root input -/// is read solely for the file being lowered (resolver / infer / `get_file_path`), -/// and the build never lowers a non-batch file. Cross-batch call targets resolve -/// through the path-keyed module index built from the shared source root, which -/// never consults `file_source_root`. Setting it for all files would re-pay a -/// whole-config-sized loop on every batch database for no resolution benefit. +/// `file_source_root` is set ONLY for `batch_files`: text is loaded only for this +/// slice, and semantic lowering starts from these files. Cross-batch targets use +/// the path-keyed module index in the shared source root; metadata path lookup may +/// also resolve an exact FileId from that root without registering its text or +/// revision. Registering every file here would re-pay a whole-config-sized loop +/// for every batch database. pub(crate) fn db_for_files( source_root: &SourceRoot, batch_files: &[(FileId, PathBuf)], From a2987a9b8923beedbfeb98f43aa1696869fe0a04 Mon Sep 17 00:00:00 2001 From: flamber-dev <337347856+flamber-dev@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:54:54 +0300 Subject: [PATCH 03/58] feat(bsl-metadata): read the accounting properties query tables depend on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query field names of an accounting register are not its metadata names: a correspondence register names a non-balance dimension or resource by its sides (<Измерение>Дт, <Ресурс>Кт), only non-balance resources have the correspondent-turnover fields (<Ресурс>КорОборот of a balance resource does not exist), and the number of Субконто fields comes from the chart of accounts. None of that was parsed. Parsed now, all as "absent means unknown" (None), never as false: - Dimension / RegisterResource: Balance; - Register: Correspondence and ChartOfAccounts (accounting registers only); - MetadataObject: MaxExtDimensionCount of a chart of accounts. Co-Authored-By: Claude Opus 5.5 --- crates/bsl-metadata/src/dimension.rs | 18 ++++ crates/bsl-metadata/src/metadata_object.rs | 10 ++ crates/bsl-metadata/src/register.rs | 57 +++++++++++- crates/bsl-metadata/src/xml_parser/catalog.rs | 5 +- crates/bsl-metadata/src/xml_parser/mod.rs | 91 +++++++++++++++++++ .../bsl-metadata/src/xml_parser/register.rs | 20 ++++ .../sdbl/use_cases/complete_cast_fields.rs | 1 + .../use_cases/complete_nested_elements.rs | 1 + .../sdbl/use_cases/complete_nested_fields.rs | 1 + crates/sdbl-hir/src/lower/tests.rs | 1 + 10 files changed, 203 insertions(+), 2 deletions(-) diff --git a/crates/bsl-metadata/src/dimension.rs b/crates/bsl-metadata/src/dimension.rs index 3c2c95705..2a0f89a76 100644 --- a/crates/bsl-metadata/src/dimension.rs +++ b/crates/bsl-metadata/src/dimension.rs @@ -18,6 +18,13 @@ pub struct Dimension { #[serde(rename = "indexing", default)] indexing: String, + /// `Балансовый` of an accounting-register dimension; `None` where the XML carries no such + /// property (every other register kind). A query names a NON-balance dimension of a + /// correspondence register by its debit and credit sides (`<Измерение>Дт`, `<Измерение>Кт`), so this + /// flag decides which field names exist at all. + #[serde(rename = "balance", default)] + balance: Option, + #[serde(default, skip)] type_str: String, @@ -50,6 +57,10 @@ impl Dimension { &self.indexing } + pub fn balance(&self) -> Option { + self.balance + } + pub fn type_str(&self) -> &str { &self.type_str } @@ -86,6 +97,7 @@ pub struct DimensionBuilder { deny_incomplete_values: bool, master: bool, indexing: String, + balance: Option, } impl DimensionBuilder { @@ -114,6 +126,11 @@ impl DimensionBuilder { self } + pub fn balance(mut self, balance: Option) -> Self { + self.balance = balance; + self + } + pub fn build(self) -> Dimension { Dimension { uuid: self.uuid.unwrap_or_else(Uuid::new_v4), @@ -121,6 +138,7 @@ impl DimensionBuilder { deny_incomplete_values: self.deny_incomplete_values, master: self.master, indexing: self.indexing, + balance: self.balance, type_str: String::new(), attr_type: None, } diff --git a/crates/bsl-metadata/src/metadata_object.rs b/crates/bsl-metadata/src/metadata_object.rs index b83728357..2862d7d3a 100644 --- a/crates/bsl-metadata/src/metadata_object.rs +++ b/crates/bsl-metadata/src/metadata_object.rs @@ -469,6 +469,10 @@ pub struct MetadataObject { pub constant_type: Option, #[serde(default)] pub register_records: Vec<(MdoType, Name)>, + /// `МаксКоличествоСубконто` of a chart of accounts; `None` for every other kind. The + /// accounting registers kept on the chart expose exactly this many `Субконто` fields. + #[serde(default)] + pub max_ext_dimension_count: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub uuid: Option, #[serde(default)] @@ -837,6 +841,7 @@ impl MetadataObject { code_series: crate::enums::CodeSeries::default(), constant_type: None, register_records: Vec::new(), + max_ext_dimension_count: None, uuid: None, object_belonging: crate::ObjectBelonging::Own, extended_configuration_object: None, @@ -864,6 +869,7 @@ impl MetadataObject { code_series: crate::enums::CodeSeries::default(), constant_type: None, register_records: Vec::new(), + max_ext_dimension_count: None, uuid: None, object_belonging: crate::ObjectBelonging::Own, extended_configuration_object: None, @@ -880,6 +886,10 @@ impl MetadataObject { self.register_records = records; } + pub fn set_max_ext_dimension_count(&mut self, count: Option) { + self.max_ext_dimension_count = count; + } + /// Apply an extension overlay onto this object, replacing same-named members, /// merging same-named tabular sections and recursing into children. Names /// match case-insensitively, Cyrillic included. This is the single source of diff --git a/crates/bsl-metadata/src/register.rs b/crates/bsl-metadata/src/register.rs index 55e176c23..34b5e3bf3 100644 --- a/crates/bsl-metadata/src/register.rs +++ b/crates/bsl-metadata/src/register.rs @@ -18,13 +18,34 @@ pub struct RegisterResource { #[serde(default)] type_str: String, + /// `Балансовый` of an accounting-register resource; `None` for every other register kind. + /// A non-balance resource of a correspondence register is named by its sides (`<Ресурс>Дт`) + /// and is the only kind that has the correspondent-turnover fields (`<Ресурс>КорОборот`). + #[serde(default)] + balance: Option, + #[serde(skip)] attr_type: Option, } impl RegisterResource { pub fn new(uuid: Uuid, name: impl Into) -> Self { - Self { uuid, name: name.into(), name_en: None, type_str: String::new(), attr_type: None } + Self { + uuid, + name: name.into(), + name_en: None, + type_str: String::new(), + balance: None, + attr_type: None, + } + } + + pub fn balance(&self) -> Option { + self.balance + } + + pub fn set_balance(&mut self, balance: Option) { + self.balance = balance; } pub fn uuid(&self) -> &Uuid { @@ -201,6 +222,17 @@ pub struct Register { #[serde(rename = "commonAttributesOpen", default)] common_attributes_open: bool, + + /// `ПоддержкаКорреспонденции` of an accounting register; `None` for other kinds. It decides + /// whether the records carry one account (`Счет`, `ВидДвижения`) or two (`СчетДт`, `СчетКт`). + #[serde(rename = "correspondence", default)] + correspondence: Option, + + /// Name of the chart of accounts an accounting register is kept on, without the + /// `ChartOfAccounts.` prefix. Its `МаксКоличествоСубконто` fixes how many `Субконто` + /// fields the register's query tables have. + #[serde(rename = "chartOfAccounts", default)] + chart_of_accounts: Option, } impl Register { @@ -306,6 +338,14 @@ impl Register { self.common_attributes_open = applied.open; } + pub fn correspondence(&self) -> Option { + self.correspondence + } + + pub fn chart_of_accounts(&self) -> Option<&str> { + self.chart_of_accounts.as_deref() + } + /// Overlay an extension's adopted copy of this register onto the base: merge /// dimensions, resources, and attributes by name (1C lets an extension add new /// measurements/resources/attributes to a borrowed register, or replace a @@ -379,6 +419,7 @@ impl Register { /// heap-owning fields must be added here too. pub fn estimated_heap_size(&self) -> usize { self.name.capacity() + + self.chart_of_accounts.as_ref().map_or(0, String::capacity) + stdx::heap::vec_bytes::(self.dimensions.len()) + self.dimensions.iter().map(Dimension::estimated_heap_size).sum::() + stdx::heap::vec_bytes::(self.resources.len()) @@ -433,6 +474,8 @@ pub struct RegisterBuilder { recorder_subordinate: bool, enable_totals_slice_first: bool, enable_totals_slice_last: bool, + correspondence: Option, + chart_of_accounts: Option, } impl RegisterBuilder { @@ -513,6 +556,16 @@ impl RegisterBuilder { self } + pub fn correspondence(mut self, correspondence: Option) -> Self { + self.correspondence = correspondence; + self + } + + pub fn chart_of_accounts(mut self, chart_of_accounts: Option) -> Self { + self.chart_of_accounts = chart_of_accounts; + self + } + pub fn build(self) -> Register { Register { uuid: self.uuid.unwrap_or_else(Uuid::new_v4), @@ -530,6 +583,8 @@ impl RegisterBuilder { enable_totals_slice_last: self.enable_totals_slice_last, common_attributes: Vec::new(), common_attributes_open: false, + correspondence: self.correspondence, + chart_of_accounts: self.chart_of_accounts, } } } diff --git a/crates/bsl-metadata/src/xml_parser/catalog.rs b/crates/bsl-metadata/src/xml_parser/catalog.rs index 318039bcc..e3f3fa882 100644 --- a/crates/bsl-metadata/src/xml_parser/catalog.rs +++ b/crates/bsl-metadata/src/xml_parser/catalog.rs @@ -4,7 +4,7 @@ use crate::metadata_object::{Attribute, AttributeType, MdoType, MetadataObject}; use crate::tabular_section::{TabularSection, TabularSectionAttribute}; use std::str::FromStr; -use super::helpers::{child_text, find_child, find_mdo_element, parse_uuid, parse_xml}; +use super::helpers::{child_text, child_u32, find_child, find_mdo_element, parse_uuid, parse_xml}; use super::standard_attributes::{ add_business_process_standard_attributes, add_catalog_standard_attributes, add_chart_of_accounts_standard_attributes, add_chart_of_calculation_types_standard_attributes, @@ -204,6 +204,9 @@ fn parse_metadata_object_xml(xml: &str, mdo_type: MdoType) -> Result + + + + План1 + 3 + + +"#; + + let coa = parse_chart_of_accounts_xml(xml).unwrap(); + assert_eq!(coa.max_ext_dimension_count, Some(3)); + } + + /// The shape of an accounting register's query tables follows from four properties — the + /// correspondence flag, the chart of accounts, and each dimension's and resource's balance + /// flag — so all four must survive the parse. Other register kinds have none of them and + /// must report "unknown", not "false". + #[test] + fn accounting_register_reads_the_properties_its_query_tables_depend_on() { + let xml = r#" + + + + План1 + ChartOfAccounts.План1 + true + + + + + Измерение1 + xs:string + true + + + + + Измерение2 + xs:string + false + + + + + Ресурс1 + xs:decimal + true + + + + + Ресурс2 + xs:decimal + false + + + + +"#; + + let register = parse_accounting_register_xml(xml).unwrap(); + assert_eq!(register.correspondence(), Some(true)); + assert_eq!(register.chart_of_accounts(), Some("План1")); + let balance = |name: &str| { + register.dimensions().iter().find(|d| d.name() == name).and_then(|d| d.balance()) + }; + assert_eq!(balance("Измерение1"), Some(true)); + assert_eq!(balance("Измерение2"), Some(false)); + let resource_balance = |name: &str| { + register.resources().iter().find(|r| r.name() == name).and_then(|r| r.balance()) + }; + assert_eq!(resource_balance("Ресурс1"), Some(true)); + assert_eq!(resource_balance("Ресурс2"), Some(false)); + + let xml = r#" + + + + Остатки1 + + +"#; + let register = parse_accumulation_register_xml(xml).unwrap(); + assert_eq!(register.correspondence(), None); + assert_eq!(register.chart_of_accounts(), None); } } diff --git a/crates/bsl-metadata/src/xml_parser/register.rs b/crates/bsl-metadata/src/xml_parser/register.rs index 1edf90c79..a6bd2c286 100644 --- a/crates/bsl-metadata/src/xml_parser/register.rs +++ b/crates/bsl-metadata/src/xml_parser/register.rs @@ -53,6 +53,16 @@ fn parse_register_xml(xml: &str, mdo_type: MdoType) -> Result { let enable_totals_slice_first = child_bool(props, "EnableTotalsSliceFirst"); let enable_totals_slice_last = child_bool(props, "EnableTotalsSliceLast"); let register_type_str = child_text(props, "RegisterType").map(|s| s.to_string()); + let (correspondence, chart_of_accounts) = if mdo_type == MdoType::AccountingRegister { + ( + optional_bool(props, "Correspondence"), + child_text(props, "ChartOfAccounts") + .and_then(|raw| raw.split_once('.')) + .map(|(_, name)| name.to_string()), + ) + } else { + (None, None) + }; let mut attributes = Vec::new(); match mdo_type { @@ -105,6 +115,8 @@ fn parse_register_xml(xml: &str, mdo_type: MdoType) -> Result { .register_type(register_type) .enable_totals_slice_first(enable_totals_slice_first) .enable_totals_slice_last(enable_totals_slice_last) + .correspondence(correspondence) + .chart_of_accounts(chart_of_accounts) .build(); tracing::debug!( @@ -141,6 +153,7 @@ fn parse_dimension_node(node: roxmltree::Node<'_, '_>) -> Result { .deny_incomplete_values(deny_incomplete_values) .master(master) .indexing(indexing) + .balance(optional_bool(props, "Balance")) .build(); if let Some(type_node) = find_child(props, "Type") { @@ -164,6 +177,7 @@ fn parse_resource_node(node: roxmltree::Node<'_, '_>) -> Result, tag: &str) -> Option { + child_text(props, tag).map(|text| text.trim().eq_ignore_ascii_case("true")) +} + fn parse_periodicity(periodicity: Option<&str>, mdo_type: MdoType) -> Option { if mdo_type != MdoType::InformationRegister { return None; diff --git a/crates/ide/src/completion/sdbl/use_cases/complete_cast_fields.rs b/crates/ide/src/completion/sdbl/use_cases/complete_cast_fields.rs index 44f244297..25cf3efc0 100644 --- a/crates/ide/src/completion/sdbl/use_cases/complete_cast_fields.rs +++ b/crates/ide/src/completion/sdbl/use_cases/complete_cast_fields.rs @@ -172,6 +172,7 @@ mod tests { code_series: bsl_metadata::CodeSeries::default(), constant_type: None, register_records: vec![], + max_ext_dimension_count: None, uuid: None, object_belonging: bsl_metadata::ObjectBelonging::Own, extended_configuration_object: None, diff --git a/crates/ide/src/completion/sdbl/use_cases/complete_nested_elements.rs b/crates/ide/src/completion/sdbl/use_cases/complete_nested_elements.rs index be4314c78..bfb2f856e 100644 --- a/crates/ide/src/completion/sdbl/use_cases/complete_nested_elements.rs +++ b/crates/ide/src/completion/sdbl/use_cases/complete_nested_elements.rs @@ -152,6 +152,7 @@ mod tests { code_series: bsl_metadata::CodeSeries::default(), constant_type: None, register_records: vec![], + max_ext_dimension_count: None, uuid: None, object_belonging: bsl_metadata::ObjectBelonging::Own, extended_configuration_object: None, diff --git a/crates/ide/src/completion/sdbl/use_cases/complete_nested_fields.rs b/crates/ide/src/completion/sdbl/use_cases/complete_nested_fields.rs index 8f3025714..2ab6413ab 100644 --- a/crates/ide/src/completion/sdbl/use_cases/complete_nested_fields.rs +++ b/crates/ide/src/completion/sdbl/use_cases/complete_nested_fields.rs @@ -134,6 +134,7 @@ mod tests { code_series: bsl_metadata::CodeSeries::default(), constant_type: None, register_records: vec![], + max_ext_dimension_count: None, uuid: None, object_belonging: bsl_metadata::ObjectBelonging::Own, extended_configuration_object: None, diff --git a/crates/sdbl-hir/src/lower/tests.rs b/crates/sdbl-hir/src/lower/tests.rs index 6686ecb97..79fbe118f 100644 --- a/crates/sdbl-hir/src/lower/tests.rs +++ b/crates/sdbl-hir/src/lower/tests.rs @@ -1702,6 +1702,7 @@ fn test_join_paren_field_resolution() { code_series: bsl_metadata::CodeSeries::default(), constant_type: None, register_records: vec![], + max_ext_dimension_count: None, uuid: None, object_belonging: bsl_metadata::ObjectBelonging::Own, extended_configuration_object: None, From b7dbfbb70497e04e3d709b9eeebf004ad4253017 Mon Sep 17 00:00:00 2001 From: flamber-dev <337347856+flamber-dev@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:57:06 +0300 Subject: [PATCH 04/58] fix(debug): report what the debug server actually answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool's first live run against 8.3.17 and 8.3.27 found thirteen defects. The root of most of them: `DebugClient::post` never looked at the HTTP status, so every rejection was handed back as success. - Check the status and carry the server's own `descr` text (it is the only place the platform explains itself, e.g. "Выполнение вычислений возможно только в остановленном предмете отладки"). - Negotiate `breakOnCondition`: 8.3.27 needs it, 8.3.17 refuses the whole request over it, and nothing announces which. One retry without it, remembered for the session. Until now a single conditional breakpoint silently disabled every breakpoint of the session on 8.3.17. - Roll the breakpoint list back when `setBreakpoints` fails, so one rejected entry cannot poison every later request. - Parse `getDbgTargets` by depth: the repeating wrapper is named `id` in one command and `item` in another, and the fields sit inside `targetID`. The name-based parser matched neither, so the target list was always empty. - Attach the targets that are already running. `setAutoAttachSettings` only covers targets that start later, so a client started before the attach was never debugged and no breakpoint ever hit. - Honour `auto_attach` and give it a default a file infobase can use: it is a list of target TYPES, it was ignored entirely, and its default named none of the two types a file infobase produces. - Surface evaluation errors. They arrive asynchronously in `exceptionStr`, and were dropped, so a mistyped name was indistinguishable from an empty value. - Refuse a command when no target is stopped instead of sending it to a resumed one and waiting five seconds for nothing. - Return the freshest queued stop, not the first: an older one describes a position the caller has already resumed past. - Number stack frames from the current frame in both printers, matching `stack_level`; they disagreed with it and with each other. - Decide the stop reason by position. `stopByBP` stays true after a step, and the event carries no top-level module or line at all — both sit in the innermost frame. - Index the configuration-level modules by name, so a breakpoint can be set in the managed application or session module at all. - Root the module index at the project's configuration root when `config_root` is omitted, and stop printing its `\?\` prefix. `attach` no longer claims to have reached the infobase: the debug server registers a debugger under any alias, a misspelled one included. A scripted debug server in crates/bsl-debug/tests/debug_server_replies.rs pins the three user-visible cases: a refused breakpoint list is an error carrying the server's text, a conditional breakpoint refused over breakOnCondition is re-sent without it, and an evaluation error carries the platform's text instead of an empty value. Co-Authored-By: Claude Opus 5.5 --- crates/bsl-debug/src/client.rs | 78 ++++- crates/bsl-debug/src/dap/mod.rs | 4 +- crates/bsl-debug/src/module_index.rs | 71 ++++ crates/bsl-debug/src/session.rs | 323 ++++++++++++++---- crates/bsl-debug/src/types/events.rs | 28 +- crates/bsl-debug/src/types/responses.rs | 271 ++++++++++++--- crates/bsl-debug/src/types/xml.rs | 49 ++- .../bsl-debug/tests/debug_server_replies.rs | 300 ++++++++++++++++ crates/mcp-server/src/lib.rs | 12 +- crates/mcp-server/src/tools/debug.rs | 96 +++++- 10 files changed, 1089 insertions(+), 143 deletions(-) create mode 100644 crates/bsl-debug/tests/debug_server_replies.rs diff --git a/crates/bsl-debug/src/client.rs b/crates/bsl-debug/src/client.rs index 47b953a5e..e5316615e 100644 --- a/crates/bsl-debug/src/client.rs +++ b/crates/bsl-debug/src/client.rs @@ -1,9 +1,11 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use reqwest::blocking::Client; -use tracing::debug; +use tracing::{debug, warn}; use crate::error::DebugError; use crate::types::events::{self, DebugEvent}; -use crate::types::responses::{self, AttachResult, DebugTarget, StackFrame, VarValue}; +use crate::types::responses::{self, AttachResult, DebugTarget, EvalPayload, StackFrame}; use crate::types::xml; pub struct DebugClient { @@ -11,6 +13,9 @@ pub struct DebugClient { base_url: String, pub debugger_id: String, pub infobase: String, + /// Negotiated on the first rejected `setBreakpoints`: 8.3.27 needs `breakOnCondition`, + /// 8.3.17 refuses the request over it, and nothing in the protocol announces which. + break_on_condition: AtomicBool, } #[derive(Debug, thiserror::Error)] @@ -21,6 +26,15 @@ pub enum ClientError { #[error("attach failed: {0}")] AttachFailed(String), + #[error("no target is stopped — call wait_stop first")] + NotStopped, + + #[error("debug server refused {cmd} (HTTP {status}): {message}")] + Server { cmd: String, status: u16, message: String }, + + #[error("evaluation failed: {0}")] + EvalFailed(String), + #[error("debug error: {0}")] Debug(#[from] DebugError), } @@ -35,6 +49,7 @@ impl DebugClient { base_url: format!("http://{host}:{port}/e1crdbg"), debugger_id: debugger_id.to_string(), infobase: infobase.to_string(), + break_on_condition: AtomicBool::new(true), } } @@ -84,8 +99,39 @@ impl DebugClient { } pub fn set_breakpoints(&self, breakpoints: &[xml::BreakpointDef]) -> Result<(), ClientError> { - let body = - xml::build_set_breakpoints_request(&self.debugger_id, &self.infobase, breakpoints); + let flag = self.break_on_condition.load(Ordering::Relaxed); + let body = xml::build_set_breakpoints_request( + &self.debugger_id, + &self.infobase, + breakpoints, + flag, + ); + let err = match self.post("setBreakpoints", &body) { + Ok(_) => return Ok(()), + Err(err) => err, + }; + + // The rejection takes down the whole list, not just the conditional breakpoint, so the + // one retry that re-shapes the request is what keeps the rest of the session alive. + let over_break_on_condition = matches!( + &err, + ClientError::Server { message, .. } if message.contains("breakOnCondition") + ); + if !flag || !over_break_on_condition { + return Err(err); + } + + self.break_on_condition.store(false, Ordering::Relaxed); + warn!( + "debug server does not accept breakOnCondition; conditional breakpoints now go \ + without it (8.3.17-shaped server)" + ); + let body = xml::build_set_breakpoints_request( + &self.debugger_id, + &self.infobase, + breakpoints, + false, + ); self.post("setBreakpoints", &body)?; Ok(()) } @@ -115,7 +161,7 @@ impl DebugClient { expression: &str, stack_level: i64, result_id: &str, - ) -> Result, ClientError> { + ) -> Result { let body = xml::build_eval_expr_request( &self.debugger_id, &self.infobase, @@ -133,7 +179,7 @@ impl DebugClient { target_id: &str, stack_level: i64, result_id: &str, - ) -> Result, ClientError> { + ) -> Result { let body = xml::build_eval_local_vars_request( &self.debugger_id, &self.infobase, @@ -152,7 +198,7 @@ impl DebugClient { view: crate::types::base::ViewInterface, stack_level: i64, result_id: &str, - ) -> Result, ClientError> { + ) -> Result { let body = xml::build_eval_expand_request( &self.debugger_id, &self.infobase, @@ -170,6 +216,8 @@ impl DebugClient { let url = format!("{}/rdbg?cmd=pingDebugUIParams&dbgui={}", self.base_url, self.debugger_id); let resp = self.client.post(&url).send()?; + // Deliberately tolerant, unlike `post`: a transient non-200 on the polling channel must + // not kill the session. let status = resp.status(); let data = resp.bytes()?.to_vec(); debug!(status = %status, len = data.len(), "ping response"); @@ -190,8 +238,22 @@ impl DebugClient { .body(body.to_vec()) .send()?; + let status = resp.status(); let data = resp.bytes()?.to_vec(); - debug!(cmd, response = %String::from_utf8_lossy(&data), "debug server response"); + debug!(cmd, status = %status, response = %String::from_utf8_lossy(&data), + "debug server response"); + + if !status.is_success() { + let message = responses::exception_descr(&data).unwrap_or_else(|| { + String::from_utf8_lossy(&data).chars().take(300).collect::().trim().into() + }); + return Err(ClientError::Server { + cmd: cmd.to_string(), + status: status.as_u16(), + message, + }); + } + Ok(data) } } diff --git a/crates/bsl-debug/src/dap/mod.rs b/crates/bsl-debug/src/dap/mod.rs index 1d4d1e277..2355cb7cc 100644 --- a/crates/bsl-debug/src/dap/mod.rs +++ b/crates/bsl-debug/src/dap/mod.rs @@ -515,8 +515,8 @@ fn do_variables( } fn do_step(action: StepAction, state: &Arc>) -> Result<(), String> { - let guard = state.lock().unwrap(); - let session = guard.session.as_ref().ok_or("no active debug session")?; + let mut guard = state.lock().unwrap(); + let session = guard.session.as_mut().ok_or("no active debug session")?; session.step(action).map_err(|e| e.to_string()) } diff --git a/crates/bsl-debug/src/module_index.rs b/crates/bsl-debug/src/module_index.rs index deef74cae..c6f63b093 100644 --- a/crates/bsl-debug/src/module_index.rs +++ b/crates/bsl-debug/src/module_index.rs @@ -149,6 +149,7 @@ impl ModuleIndex { ); if let Some(ext_dir) = ext_dir.filter(|d| d.is_dir()) { self.index_bsl_files(extension, &config_object_id, "", &ext_dir)?; + self.register_root_names(extension, &config_object_id, &ext_dir)?; } } @@ -375,6 +376,52 @@ impl ModuleIndex { Ok(()) } + /// Names the configuration's own modules — `МодульУправляемогоПриложения`, `МодульСеанса` + /// and friends. Their name is the kind label alone: they belong to the configuration, so + /// there is no type or object to prefix: [`Self::register_names`]'s `{type}.{object}.{kind}` + /// format does not apply here. + fn register_root_names( + &mut self, + extension: &str, + object_id: &str, + ext_dir: &Path, + ) -> Result<(), DebugError> { + for entry in fs::read_dir(ext_dir)? { + let entry = entry?; + let path = entry.path(); + + if !bsl_conventions::has_extension(&path, bsl_conventions::BSL_EXTENSION) { + continue; + } + + let kind = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(bsl_conventions::conventional_of); + + let property_id = match property_id_for_module("", kind) { + Some(id) => id, + None => continue, + }; + + let name = crate::constants::module_kind_label(property_id).to_string(); + // An extension has a session module of its own and would claim the same bare name; + // the configuration is scanned first, so first writer wins. + if self.by_name.contains_key(&name) { + continue; + } + + let module_id = ModuleId { + extension: extension.to_string(), + object_id: object_id.to_string(), + property_id: property_id.to_string(), + }; + self.by_name.insert(name, (module_id, path)); + } + + Ok(()) + } + fn register_names( &mut self, extension: &str, @@ -544,6 +591,30 @@ mod case_parity_tests { ); } + /// Модули самой конфигурации платформа называет одним видом («МодульУправляемогоПриложения»), + /// и именно так их называют стеки вызовов, — без этих имён точку останова в startup-коде + /// поставить нечем. + #[test] + fn configuration_level_modules_resolve_by_their_bare_kind_label() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + root_with_config(root, "Configuration.xml"); + write(&root.join("Ext/ManagedApplicationModule.bsl"), "//"); + write(&root.join("Ext/SessionModule.bsl"), "//"); + + let index = ModuleIndex::scan(root, &[]).unwrap(); + + for label in ["МодульУправляемогоПриложения", "МодульСеанса"] + { + assert!( + index.resolve_name(label).is_some(), + "{label} должен разрешаться по имени; есть: {:?}", + index.all_names() + ); + assert!(index.all_names().contains(&label), "{label} должен быть в all_names()"); + } + } + /// В семействах одиночных модулей вид даёт КАТАЛОГ, а не имя файла: /// неконвенционное имя тела остаётся индексируемым, как и раньше. #[test] diff --git a/crates/bsl-debug/src/session.rs b/crates/bsl-debug/src/session.rs index 48a01d61d..d5b70fa56 100644 --- a/crates/bsl-debug/src/session.rs +++ b/crates/bsl-debug/src/session.rs @@ -12,7 +12,7 @@ use crate::listener::EventListener; use crate::module_index::ModuleIndex; use crate::types::base::{CalcPathItem, ModuleId, StepAction, ViewInterface}; use crate::types::events::DebugEvent; -use crate::types::responses::{self, VarValue}; +use crate::types::responses::{self, EvalPayload}; use crate::types::xml::BreakpointDef; pub struct DebugSession { @@ -21,11 +21,20 @@ pub struct DebugSession { events: mpsc::UnboundedReceiver, index: ModuleIndex, attached_targets: HashMap, + auto_attach: Vec, breakpoints: Vec, + /// The target commands act on. stopped_target: Option, + /// Client and server can be stopped at once, so one slot cannot tell whether the target a + /// command is about to reach is still halted. + stopped_targets: std::collections::HashSet, last_stop: Option, watches: Vec, pending_eval_results: HashMap>, + /// Listed once while connecting, because a second `getDbgTargets` can answer differently and + /// its failure must not undo an attach that already happened. + known_targets: Vec, + setup_warnings: Vec, } pub struct DebugConfig { @@ -80,6 +89,8 @@ pub struct TargetInfo { pub id: String, pub target_type: String, pub user_name: String, + pub infobase_alias: String, + pub state: String, } #[derive(Debug, Clone, serde::Serialize)] @@ -136,31 +147,118 @@ impl DebugSession { } } - client.init_settings()?; + // Both are conveniences the session works without, and the HTTP status of neither used to + // be read at all: a server that refuses one must not now cost the caller the whole attach. + let mut setup_warnings: Vec = Vec::new(); + if let Err(e) = client.init_settings() { + setup_warnings.push(format!("initSettings refused: {e}")); + } if !config.auto_attach.is_empty() { let types: Vec<&str> = config.auto_attach.iter().map(|s| s.as_str()).collect(); - client.set_auto_attach(&types)?; + if let Err(e) = client.set_auto_attach(&types) { + setup_warnings.push(format!("setAutoAttachSettings refused: {e}")); + } } let (listener, events) = EventListener::start(client.clone(), 50); - Ok(Self { + let mut session = Self { client, _listener: listener, events, index, attached_targets: HashMap::new(), + auto_attach: config.auto_attach, breakpoints: Vec::new(), stopped_target: None, + stopped_targets: std::collections::HashSet::new(), last_stop: None, watches: Vec::new(), pending_eval_results: HashMap::new(), - }) + known_targets: Vec::new(), + setup_warnings, + }; + + // `setAutoAttachSettings` only covers targets that start later, so a client that was + // already running would never be attached and no breakpoint would ever be hit. + session.attach_running_targets(); + + Ok(session) + } + + fn attach_running_targets(&mut self) { + let targets = match self.client.get_targets() { + Ok(targets) => targets, + Err(e) => { + tracing::warn!(error = %e, "could not list running debug targets"); + self.setup_warnings.push(format!("getDbgTargets refused: {e}")); + return; + } + }; + + self.known_targets = targets + .iter() + .map(|t| TargetInfo { + id: t.id.clone(), + target_type: t.target_type.clone(), + user_name: t.user_name.clone(), + infobase_alias: t.infobase_alias.clone(), + state: t.state.clone(), + }) + .collect(); + + for target in targets { + if !self.auto_attach_allows(&target.target_type) { + debug!( + target_id = %target.id, + target_type = %target.target_type, + "running target skipped by auto_attach" + ); + continue; + } + match self.client.attach_targets(&[&target.id]) { + Ok(()) => { + info!(target_id = %target.id, target_type = %target.target_type, + "attached to a running target"); + self.attached_targets.insert(target.id, target.target_type); + } + Err(e) => tracing::warn!(target_id = %target.id, error = %e, + "could not attach a running debug target"), + } + } + } + + /// An empty `auto_attach` attaches everything; target types are compared case-insensitively + /// because they come back from the server, not from our own table. + fn auto_attach_allows(&self, target_type: &str) -> bool { + self.auto_attach.is_empty() + || self.auto_attach.iter().any(|t| t.eq_ignore_ascii_case(target_type)) + } + + pub fn attached_count(&self) -> usize { + self.attached_targets.len() + } + + pub fn known_targets(&self) -> &[TargetInfo] { + &self.known_targets + } + + /// Non-fatal refusals collected during the handshake, so `attach` can show them instead of + /// leaving the caller with a session that is quietly less capable than it looks. + pub fn setup_warnings(&self) -> &[String] { + &self.setup_warnings } pub fn disconnect(self) -> Result<(), ClientError> { self._listener.stop(); + // Whether a target left attached is why the thin client then refuses to close on + // WM_CLOSE is unestablished; this only makes the teardown symmetric. + for id in self.attached_targets.keys() { + if let Err(e) = self.client.detach_targets(&[id.as_str()]) { + debug!(target_id = %id, error = %e, "detaching a target on disconnect failed"); + } + } self.client.detach()?; info!("detached from debug server"); Ok(()) @@ -184,7 +282,12 @@ impl DebugSession { condition: condition.map(|s| s.to_string()), }); - self.client.set_breakpoints(&self.breakpoints)?; + // A rejected list must not leave a phantom breakpoint behind: every later + // `setBreakpoints` sends the whole list and would be rejected for the same reason. + if let Err(e) = self.client.set_breakpoints(&self.breakpoints) { + self.breakpoints.pop(); + return Err(e); + } info!(module_name, line, "breakpoint set"); Ok(()) } @@ -207,7 +310,10 @@ impl DebugSession { condition: condition.map(|s| s.to_string()), }); - self.client.set_breakpoints(&self.breakpoints)?; + if let Err(e) = self.client.set_breakpoints(&self.breakpoints) { + self.breakpoints.pop(); + return Err(e); + } Ok(()) } @@ -216,15 +322,18 @@ impl DebugSession { Some(v) => v, None => return Ok(false), }; - let before = self.breakpoints.len(); + let before = self.breakpoints.clone(); self.breakpoints.retain(|bp| { !(bp.object_id == module_id.object_id && bp.property_id == module_id.property_id && bp.extension == module_id.extension && bp.line == line) }); - if self.breakpoints.len() != before { - self.client.set_breakpoints(&self.breakpoints)?; + if self.breakpoints.len() != before.len() { + if let Err(e) = self.client.set_breakpoints(&self.breakpoints) { + self.breakpoints = before; + return Err(e); + } Ok(true) } else { Ok(false) @@ -240,15 +349,18 @@ impl DebugSession { Some(v) => v, None => return Ok(false), }; - let before = self.breakpoints.len(); + let before = self.breakpoints.clone(); self.breakpoints.retain(|bp| { !(bp.object_id == module_id.object_id && bp.property_id == module_id.property_id && bp.extension == module_id.extension && bp.line == line) }); - if self.breakpoints.len() != before { - self.client.set_breakpoints(&self.breakpoints)?; + if self.breakpoints.len() != before.len() { + if let Err(e) = self.client.set_breakpoints(&self.breakpoints) { + self.breakpoints = before; + return Err(e); + } Ok(true) } else { Ok(false) @@ -256,8 +368,11 @@ impl DebugSession { } pub fn clear_breakpoints(&mut self) -> Result<(), ClientError> { - self.breakpoints.clear(); - self.client.set_breakpoints(&self.breakpoints)?; + let before = std::mem::take(&mut self.breakpoints); + if let Err(e) = self.client.set_breakpoints(&self.breakpoints) { + self.breakpoints = before; + return Err(e); + } Ok(()) } @@ -281,7 +396,13 @@ impl DebugSession { let targets = self.client.get_targets()?; Ok(targets .into_iter() - .map(|t| TargetInfo { id: t.id, target_type: t.target_type, user_name: t.user_name }) + .map(|t| TargetInfo { + id: t.id, + target_type: t.target_type, + user_name: t.user_name, + infobase_alias: t.infobase_alias, + state: t.state, + }) .collect()) } @@ -289,11 +410,32 @@ impl DebugSession { self.stopped_target.as_deref() } + pub fn stopped_count(&self) -> usize { + self.stopped_targets.len() + } + + pub fn target_type_of(&self, id: &str) -> Option<&str> { + self.attached_targets.get(id).map(|t| t.as_str()) + } + pub fn last_stop(&self) -> Option<&StopEvent> { self.last_stop.as_ref() } pub fn wait_for_stop(&mut self, timeout: Duration) -> Result, ClientError> { + // The freshest queued stop wins: an older one describes a position the caller has + // already resumed past, and commands would then act somewhere else entirely. + let mut queued: Option = None; + while let Ok(event) = self.events.try_recv() { + if let Some(stop) = self.handle_event(event)? { + queued = Some(stop); + } + } + if let Some(stop) = queued { + self.last_stop = Some(stop.clone()); + return Ok(Some(stop)); + } + let deadline = std::time::Instant::now() + timeout; loop { @@ -317,8 +459,8 @@ impl DebugSession { } } - pub fn step(&self, action: StepAction) -> Result<(), ClientError> { - let target_id = self.require_stopped_target()?; + pub fn step(&mut self, action: StepAction) -> Result<(), ClientError> { + let target_id = self.require_stopped_target()?.to_string(); let action_str = match action { StepAction::Next => "Step", @@ -327,21 +469,28 @@ impl DebugSession { StepAction::Continue => "Continue", }; - self.client.step(target_id, action_str) + self.client.step(&target_id, action_str)?; + + self.stopped_targets.remove(&target_id); + if self.stopped_target.as_deref() == Some(target_id.as_str()) { + self.stopped_target = self.stopped_targets.iter().next().cloned(); + } + Ok(()) } - pub fn continue_execution(&self) -> Result<(), ClientError> { + pub fn continue_execution(&mut self) -> Result<(), ClientError> { self.step(StepAction::Continue) } + /// The target commands act on, and only while it is still halted: the debug server answers + /// HTTP 400 for an evaluation against a running target, and a stale id sends it there. fn require_stopped_target(&self) -> Result<&str, ClientError> { if let Some(ref id) = self.stopped_target { - return Ok(id); - } - if let Some(ref stop) = self.last_stop { - return Ok(&stop.target_id); + if self.stopped_targets.contains(id) { + return Ok(id); + } } - Err(ClientError::AttachFailed("no stopped target".to_string())) + Err(ClientError::NotStopped) } pub fn call_stack(&self) -> Result, ClientError> { @@ -377,15 +526,15 @@ impl DebugSession { let target_id = self.require_stopped_target()?.to_string(); let result_id = Uuid::new_v4().to_string(); - let vars = self.client.eval_local_vars(&target_id, stack_level as i64, &result_id)?; + let payload = self.client.eval_local_vars(&target_id, stack_level as i64, &result_id)?; + let payload = self.settle_eval(payload, &result_id); - let vars = if vars.is_empty() { - self.wait_for_eval_result(&result_id, Duration::from_secs(5)) - } else { - vars - }; + if let Some(message) = payload.error { + return Err(ClientError::EvalFailed(message)); + } - Ok(vars + Ok(payload + .values .into_iter() .map(|v| Variable { name: v.name, @@ -396,7 +545,17 @@ impl DebugSession { .collect()) } - fn wait_for_eval_result(&mut self, result_id: &str, timeout: Duration) -> Vec { + /// An evaluation answers HTTP 200 with an empty body and reports on the ping channel, so an + /// empty synchronous payload is a promise of a later one, not a result. + fn settle_eval(&mut self, payload: EvalPayload, result_id: &str) -> EvalPayload { + if payload.values.is_empty() && payload.error.is_none() { + self.wait_for_eval_result(result_id, Duration::from_secs(5)) + } else { + payload + } + } + + fn wait_for_eval_result(&mut self, result_id: &str, timeout: Duration) -> EvalPayload { let deadline = std::time::Instant::now() + timeout; loop { @@ -411,12 +570,12 @@ impl DebugSession { Err(mpsc::error::TryRecvError::Empty) => { if std::time::Instant::now() >= deadline { debug!(result_id, "eval result timeout"); - return Vec::new(); + return EvalPayload::default(); } std::thread::sleep(Duration::from_millis(10)); } Err(mpsc::error::TryRecvError::Disconnected) => { - return Vec::new(); + return EvalPayload::default(); } } } @@ -426,23 +585,22 @@ impl DebugSession { let target_id = self.require_stopped_target()?.to_string(); let result_id = Uuid::new_v4().to_string(); - let vars = self.client.eval_expr(&target_id, expression, stack_level as i64, &result_id)?; + let payload = + self.client.eval_expr(&target_id, expression, stack_level as i64, &result_id)?; + let payload = self.settle_eval(payload, &result_id); - let vars = if vars.is_empty() { - self.wait_for_eval_result(&result_id, Duration::from_secs(5)) - } else { - vars - }; - - let first = vars.into_iter().next().unwrap_or(VarValue { - name: String::new(), - type_name: String::new(), - presentation: String::new(), - is_expandable: false, - error: None, - }); + if let Some(message) = payload.error { + return Err(ClientError::EvalFailed(message)); + } - Ok(EvalResult { value: first.presentation, type_name: first.type_name }) + match payload.values.into_iter().next() { + Some(first) => Ok(EvalResult { value: first.presentation, type_name: first.type_name }), + // An empty value here is not an empty variable: nothing came back at all, and + // returning one would read as a successful evaluation of nothing. + None => Err(ClientError::EvalFailed( + "the debug server returned no result within 5 s".into(), + )), + } } pub fn expand( @@ -454,16 +612,16 @@ impl DebugSession { let target_id = self.require_stopped_target()?.to_string(); let result_id = Uuid::new_v4().to_string(); - let vars = + let payload = self.client.eval_expand(&target_id, path, view, stack_level as i64, &result_id)?; + let payload = self.settle_eval(payload, &result_id); - let vars = if vars.is_empty() { - self.wait_for_eval_result(&result_id, Duration::from_secs(5)) - } else { - vars - }; + if let Some(message) = payload.error { + return Err(ClientError::EvalFailed(message)); + } - Ok(vars + Ok(payload + .values .into_iter() .map(|v| Variable { name: v.name, @@ -570,9 +728,22 @@ impl DebugSession { &self.index } + fn breakpoint_at(&self, module_id: &ModuleId, line: u32) -> bool { + self.breakpoints.iter().any(|bp| { + bp.extension == module_id.extension + && bp.object_id == module_id.object_id + && bp.property_id == module_id.property_id + && bp.line == line + }) + } + fn handle_event(&mut self, event: DebugEvent) -> Result, ClientError> { match event { DebugEvent::TargetStarted { target_id, target_type } => { + if !self.auto_attach_allows(&target_type) { + debug!(target_id, target_type, "target skipped by auto_attach"); + return Ok(None); + } info!(target_id, target_type, "target started"); self.client.attach_targets(&[&target_id])?; self.attached_targets.insert(target_id.clone(), target_type); @@ -581,14 +752,14 @@ impl DebugSession { DebugEvent::TargetQuit { target_id } => { info!(target_id, "target quit"); self.attached_targets.remove(&target_id); + self.stopped_targets.remove(&target_id); if self.stopped_target.as_deref() == Some(&target_id) { - self.stopped_target = None; + self.stopped_target = self.stopped_targets.iter().next().cloned(); } Ok(None) } DebugEvent::CallStackFormed { target_id, - stop_by_bp, line_no, module_extension, module_object_id, @@ -602,6 +773,7 @@ impl DebugSession { return Ok(None); } + self.stopped_targets.insert(target_id.clone()); self.stopped_target = Some(target_id.clone()); let module_id = ModuleId { @@ -615,7 +787,32 @@ impl DebugSession { .map(|p| p.display().to_string()) .unwrap_or_else(|| module_id.to_string()); - let reason = if stop_by_bp { StopReason::Breakpoint } else { StopReason::Step }; + // Neither 8.3.17 nor 8.3.27 puts a moduleID or a lineNo at the top level of this + // event: both sit inside the innermost `callStack` frame, which is therefore the + // only description of where execution stopped. + let innermost = call_stack.first().map(|f| { + ( + ModuleId { + extension: f.extension.clone(), + object_id: f.object_id.clone(), + property_id: f.property_id.clone(), + }, + f.line_no, + ) + }); + + // `stopByBP` stays true after a step too, so only the position can tell the two + // apart: a stop is a breakpoint stop exactly where this session put one. A + // breakpoint removed between the stop and this classification reads as a step. + let reason_at = if line_no > 0 && !module_id.object_id.is_empty() { + Some((module_id.clone(), line_no)) + } else { + innermost + }; + let reason = match &reason_at { + Some((mid, line)) if self.breakpoint_at(mid, *line) => StopReason::Breakpoint, + _ => StopReason::Step, + }; let stack: Vec = call_stack .into_iter() @@ -639,10 +836,11 @@ impl DebugSession { }) .collect(); + // Index 0 is the current frame, so the innermost position is the first one. let (stop_module, stop_line) = if line_no > 0 && !module_path.is_empty() { (module_path, line_no) - } else if let Some(last) = stack.last() { - (last.module_path.clone(), last.line) + } else if let Some(first) = stack.first() { + (first.module_path.clone(), first.line) } else { (module_path, line_no) }; @@ -663,6 +861,7 @@ impl DebugSession { module_object_id, module_property_id, } => { + self.stopped_targets.insert(target_id.clone()); self.stopped_target = Some(target_id.clone()); let module_id = ModuleId { diff --git a/crates/bsl-debug/src/types/events.rs b/crates/bsl-debug/src/types/events.rs index c1e574fd7..1718a05ae 100644 --- a/crates/bsl-debug/src/types/events.rs +++ b/crates/bsl-debug/src/types/events.rs @@ -188,17 +188,23 @@ pub fn parse_ping_events(data: &[u8]) -> Vec { "targetQuit" => { Some(DebugEvent::TargetQuit { target_id: target_id.clone() }) } - "callStackFormed" => Some(DebugEvent::CallStackFormed { - target_id: target_id.clone(), - stop_by_bp, - line_no, - module_extension: mod_ext.clone(), - module_object_id: mod_obj.clone(), - module_property_id: mod_prop.clone(), - message: message.clone(), - send_message_only, - call_stack: std::mem::take(&mut call_stack_frames), - }), + "callStackFormed" => { + // The server sends the stack outermost-first; index 0 is the + // current frame everywhere in this crate, as stackLevel counts. + let mut frames = std::mem::take(&mut call_stack_frames); + frames.reverse(); + Some(DebugEvent::CallStackFormed { + target_id: target_id.clone(), + stop_by_bp, + line_no, + module_extension: mod_ext.clone(), + module_object_id: mod_obj.clone(), + module_property_id: mod_prop.clone(), + message: message.clone(), + send_message_only, + call_stack: frames, + }) + } "exprEvaluated" => Some(DebugEvent::ExprEvaluated { result_id: result_id.clone(), raw_xml: data.to_vec(), diff --git a/crates/bsl-debug/src/types/responses.rs b/crates/bsl-debug/src/types/responses.rs index f364360bd..bd04eb635 100644 --- a/crates/bsl-debug/src/types/responses.rs +++ b/crates/bsl-debug/src/types/responses.rs @@ -41,64 +41,95 @@ impl AttachResult { } } -#[derive(Debug, Clone)] +/// One-line text of an `` the debug server answers a rejected request with. +/// +/// Two shapes carry it — `` and `` — and the platform puts newlines and +/// tabs inside it, so every such run becomes `"; "` and the result fits on one log line. +pub fn exception_descr(data: &[u8]) -> Option { + let raw = read_element_text(data, "descr")?; + let mut out = String::with_capacity(raw.len()); + let mut rest = raw.trim(); + while let Some(start) = rest.find(char::is_whitespace) { + let (head, tail) = rest.split_at(start); + out.push_str(head); + let run_len = tail.find(|c: char| !c.is_whitespace()).unwrap_or(tail.len()); + let (run, tail) = tail.split_at(run_len); + if run.contains('\n') || run.contains('\t') { + out.push_str("; "); + } else { + out.push_str(run); + } + rest = tail; + } + out.push_str(rest); + Some(out.trim().to_string()) +} + +#[derive(Debug, Clone, Default)] pub struct DebugTarget { pub id: String, pub seq_no: String, pub target_type: String, pub user_name: String, + pub infobase_alias: String, + pub state: String, } +/// Targets come back under a per-command wrapper — `<…:id>` from `getDbgTargets`, `<…:item>` +/// from `step` — and the fields sit one level deeper in the second shape, inside ``. +/// Depth carries both: depth 2 starts a target whatever it is called, and only leaves at depth +/// 3 or more are collected, so the wrapper named `id` cannot be mistaken for the inner ``. pub fn parse_targets(data: &[u8]) -> Vec { let mut reader = Reader::from_reader(data); reader.config_mut().trim_text(true); let mut targets = Vec::new(); - let mut in_item = false; - let mut current = DebugTarget { - id: String::new(), - seq_no: String::new(), - target_type: String::new(), - user_name: String::new(), - }; + let mut depth = 0usize; + let mut current: Option = None; let mut current_element = Vec::new(); loop { match reader.read_event() { Ok(Event::Start(ref e)) => { - let local = local_name(e.name().as_ref()); - if local.as_slice() == b"item" { - in_item = true; - current = DebugTarget { - id: String::new(), - seq_no: String::new(), - target_type: String::new(), - user_name: String::new(), - }; - } else if in_item { - current_element = local.to_vec(); + depth += 1; + if depth == 2 { + current = Some(DebugTarget::default()); + } else if depth >= 3 { + current_element = local_name(e.name().as_ref()); } } - Ok(Event::Text(ref e)) if in_item => { - let text = String::from_utf8_lossy(e.as_ref()).to_string(); - match current_element.as_slice() { - b"id" => current.id = text, - b"seqNo" => current.seq_no = text, - b"targetType" => current.target_type = text, - b"userName" => { - current.user_name = decode_base64_utf8(&text).unwrap_or(text); + Ok(Event::Text(ref e)) => { + if let Some(target) = current.as_mut() { + let text = String::from_utf8_lossy(e.as_ref()).to_string(); + match current_element.as_slice() { + b"id" => target.id = text, + b"seqNo" => target.seq_no = text, + // Neither recorded shape sends seqNo; seanceNo is the only sequence + // number there is, so it fills the field without shadowing a real seqNo. + b"seanceNo" => { + if target.seq_no.is_empty() { + target.seq_no = text; + } + } + b"targetType" => target.target_type = text, + b"infoBaseAlias" => target.infobase_alias = text, + b"state" => target.state = text, + b"userName" => { + target.user_name = decode_base64_utf8(&text).unwrap_or(text); + } + _ => {} } - _ => {} } } - Ok(Event::End(ref e)) => { - let local = local_name(e.name().as_ref()); - if local.as_slice() == b"item" { - in_item = false; - if !current.id.is_empty() { - targets.push(current.clone()); + Ok(Event::End(_)) => { + if depth == 2 { + if let Some(target) = current.take() { + if !target.id.is_empty() { + targets.push(target); + } } } + depth = depth.saturating_sub(1); current_element.clear(); } Ok(Event::Eof) => break, @@ -202,10 +233,22 @@ pub struct VarValue { pub type_name: String, pub presentation: String, pub is_expandable: bool, +} + +/// What one evaluation answered: values, or the platform's own error text. +/// +/// The two are exclusive in practice but not in the protocol, and an empty `values` with no +/// `error` is a real answer (an empty frame) — which is why the error cannot be a value. +#[derive(Debug, Clone, Default)] +pub struct EvalPayload { + pub values: Vec, pub error: Option, } -pub fn parse_eval_result(data: &[u8]) -> Vec { +const EVAL_ERROR_WITHOUT_DESCRIPTION: &str = + "the debug server reported an evaluation error without a description"; + +pub fn parse_eval_result(data: &[u8]) -> EvalPayload { let mut reader = Reader::from_reader(data); reader.config_mut().trim_text(true); @@ -259,7 +302,6 @@ pub fn parse_eval_result(data: &[u8]) -> Vec { type_name: current_type.clone(), presentation: current_pres.clone(), is_expandable: current_expandable, - error: None, }); } b"valueInfo" => in_value_info = false, @@ -282,12 +324,27 @@ pub fn parse_eval_result(data: &[u8]) -> Vec { type_name, presentation: pres, is_expandable: false, - error: None, }); } } - vars + EvalPayload { values: vars, error: parse_eval_error(data) } +} + +/// A failed evaluation answers HTTP 200 with an empty body and reports asynchronously, so this +/// text is the only description of what went wrong that ever reaches the caller. +fn parse_eval_error(data: &[u8]) -> Option { + let state = read_element_text(data, "evalResultState").unwrap_or_default(); + let occurred = read_element_text(data, "errorOccurred").as_deref() == Some("true"); + if !occurred && state != "withErrors" { + return None; + } + // exceptionStr is base64 and may be wrapped across lines. + let decoded = read_element_text(data, "exceptionStr") + .map(|s| s.chars().filter(|c| !c.is_whitespace()).collect::()) + .and_then(|s| decode_base64_utf8(&s)) + .filter(|s| !s.trim().is_empty()); + Some(decoded.unwrap_or_else(|| EVAL_ERROR_WITHOUT_DESCRIPTION.to_string())) } fn read_element_text(data: &[u8], target: &str) -> Option { @@ -358,3 +415,141 @@ fn base64_decode(input: &[u8]) -> Option> { Some(out) } + +#[cfg(test)] +mod tests { + //! Every document below is recorded traffic (27.09.2026, 8.3.17.1549 and 8.3.27.2214), + //! not an illustration — the shapes are what the parsers have to survive. + + use super::*; + + const GET_DBG_TARGETS: &str = concat!( + r#""#, + "03432706-965e-4885-90d4-e179f5a08062", + "x2y", + "DefAliasundefined", + "zServerEmulation", + "", + "f79f9782-45e7-4241-ba43-b638e458cade", + "2DefAlias", + "ManagedClient", + ); + + const STEP_RESPONSE: &str = concat!( + r#""#, + "SwqBgY", + "78791c83-e256-4569-be28-8379e7c77de8x2", + "yDefAlias", + "undefinedz", + "ServerEmulation16", + "Worked", + "Zzz", + "f79f9782-45e7-4241-ba43-b638e458cadeDefAlias", + "ManagedClient16", + "Worked", + ); + + #[test] + fn get_dbg_targets_shape_wraps_each_target_in_an_element_named_id() { + let targets = parse_targets(GET_DBG_TARGETS.as_bytes()); + assert_eq!(targets.len(), 2, "two targets, got {targets:?}"); + assert_eq!(targets[0].id, "03432706-965e-4885-90d4-e179f5a08062"); + assert_eq!(targets[0].target_type, "ServerEmulation"); + assert_eq!(targets[0].infobase_alias, "DefAlias"); + assert_eq!(targets[0].seq_no, "2", "seanceNo stands in for the absent seqNo"); + assert_eq!(targets[1].id, "f79f9782-45e7-4241-ba43-b638e458cade"); + assert_eq!(targets[1].target_type, "ManagedClient"); + } + + #[test] + fn step_shape_nests_the_fields_inside_target_id_and_adds_the_run_state() { + let targets = parse_targets(STEP_RESPONSE.as_bytes()); + assert_eq!(targets.len(), 2, "two targets, got {targets:?}"); + assert_eq!(targets[0].id, "78791c83-e256-4569-be28-8379e7c77de8"); + assert_eq!(targets[0].target_type, "ServerEmulation"); + assert_eq!(targets[0].infobase_alias, "DefAlias"); + assert_eq!(targets[0].state, "Worked"); + assert_eq!(targets[1].target_type, "ManagedClient"); + } + + #[test] + fn an_empty_answer_is_a_bare_response_element() { + let targets = parse_targets(br#""#); + assert!(targets.is_empty(), "a bare holds no targets: {targets:?}"); + } + + #[test] + fn a_failed_evaluation_carries_the_platform_error_text() { + let xml = concat!( + r#""#, + "withErrors", + "4c174628", + "false", + "", + "true", + // {(1,1)}: Переменная не определена (НесуществующаяПеременная) + "eygxLDEpfTog0J/QtdGA0LXQvNC10L3QvdCw0Y8g0L3QtSDQvtC/\n", + "0YDQtdC00LXQu9C10L3QsCAo0J3QtdGB0YPRidC10YHRgtCy0YPRjtGJ0LDRj9Cf0LXRgNC10LzQtdC90L3QsNGP", + "KQ==", + ); + let payload = parse_eval_result(xml.as_bytes()); + let error = payload.error.expect("withErrors must produce an error"); + assert!( + error.contains("Переменная не определена"), + "the platform text must survive base64 and line wrapping: {error}" + ); + } + + #[test] + fn a_successful_evaluation_carries_one_value_and_no_error() { + let xml = concat!( + r#""#, + "correctly", + "2eea4a5a", + "4", + "Строка", + "MjAyNg==", + "IjIwMjYi", + "", + ); + let payload = parse_eval_result(xml.as_bytes()); + assert!(payload.error.is_none(), "a correct result is not an error: {payload:?}"); + assert_eq!(payload.values.len(), 1, "one value, got {:?}", payload.values); + assert_eq!(payload.values[0].type_name, "Строка"); + assert_eq!(payload.values[0].presentation, "\"2026\""); + } + + #[test] + fn exception_descr_stays_on_one_line_in_both_recorded_shapes() { + let unprefixed = concat!( + r#""#, + r#""#, + "Ошибка при выполнении запроса POST к ресурсу /e1crdbg/rdbg:\n", + "Выполнение вычислений возможно только в остановленном предмете отладки", + "", + ); + let message = exception_descr(unprefixed.as_bytes()).expect("descr without a prefix"); + assert!(!message.contains('\n'), "must be one line: {message}"); + assert!( + message.contains("rdbg:; Выполнение вычислений"), + "the embedded newline becomes a separator: {message}" + ); + + let prefixed = concat!( + r#"Ошибка преобразования данных XDTO:"#, + "\nНачалоСвойства: {ns}breakOnCondition\tФорма: Элемент", + "junk", + ); + let message = exception_descr(prefixed.as_bytes()).expect("d1p1:descr"); + assert!(!message.contains('\t'), "the tab must go too: {message}"); + assert!( + message.contains("breakOnCondition"), + "the marker the version negotiation keys on must survive: {message}" + ); + } + + #[test] + fn a_body_without_a_descr_element_has_no_message() { + assert!(exception_descr(b"Bad Request").is_none()); + } +} diff --git a/crates/bsl-debug/src/types/xml.rs b/crates/bsl-debug/src/types/xml.rs index dd389be85..897cf7e3e 100644 --- a/crates/bsl-debug/src/types/xml.rs +++ b/crates/bsl-debug/src/types/xml.rs @@ -240,10 +240,13 @@ pub fn build_attach_targets_request( b.build() } +/// `break_on_condition` is a version split, not a preference: 8.3.27 needs the element or it +/// ignores the condition without saying so, and 8.3.17 rejects the whole request over it. pub fn build_set_breakpoints_request( debugger_id: &str, infobase: &str, breakpoints: &[BreakpointDef], + break_on_condition: bool, ) -> Vec { let mut b = XmlRequestBuilder::new("RDBGSetBreakpointsRequest", debugger_id, infobase); @@ -276,7 +279,9 @@ pub fn build_set_breakpoints_request( b.bool_element("isActive", true); if let Some(ref cond) = bp.condition { b.text_element("condition", cond); - b.bool_element("breakOnCondition", true); + if break_on_condition { + b.bool_element("breakOnCondition", true); + } } b.end("bpInfo"); } @@ -436,6 +441,7 @@ pub fn build_eval_expand_request( b.build() } +#[derive(Clone)] pub struct BreakpointDef { pub extension: String, pub object_id: String, @@ -465,4 +471,45 @@ mod tests { assert!(s.contains(r#"Client"#), "all targetType elements must have xmlns. Got: {s}"); } + + fn bp(condition: Option<&str>) -> BreakpointDef { + BreakpointDef { + extension: String::new(), + object_id: "obj".to_string(), + property_id: "prop".to_string(), + line: 9, + condition: condition.map(|s| s.to_string()), + } + } + + fn breakpoints_xml(condition: Option<&str>, break_on_condition: bool) -> String { + let xml = build_set_breakpoints_request("id", "db", &[bp(condition)], break_on_condition); + String::from_utf8(xml).unwrap() + } + + #[test] + fn a_conditional_breakpoint_carries_break_on_condition_for_8327() { + let s = breakpoints_xml(Some("i = 3"), true); + assert!(s.contains("i = 3"), "condition is always sent: {s}"); + assert!(s.contains("true"), "8.3.27 needs it: {s}"); + } + + #[test] + fn a_conditional_breakpoint_drops_break_on_condition_for_8317() { + let s = breakpoints_xml(Some("i = 3"), false); + assert!(s.contains("i = 3"), "condition still goes: {s}"); + assert!( + !s.contains("breakOnCondition"), + "8.3.17 rejects the whole request over this element: {s}" + ); + } + + #[test] + fn an_unconditional_breakpoint_carries_neither_element_in_either_mode() { + for break_on_condition in [true, false] { + let s = breakpoints_xml(None, break_on_condition); + assert!(!s.contains(""), "no condition to send: {s}"); + assert!(!s.contains("breakOnCondition"), "and nothing to qualify: {s}"); + } + } } diff --git a/crates/bsl-debug/tests/debug_server_replies.rs b/crates/bsl-debug/tests/debug_server_replies.rs new file mode 100644 index 000000000..a0d53b5e0 --- /dev/null +++ b/crates/bsl-debug/tests/debug_server_replies.rs @@ -0,0 +1,300 @@ +//! The session against a scripted debug server, through the public API only: what the caller is +//! told must be what the server answered. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bsl_debug::session::{DebugConfig, DebugSession}; + +const SERVER_TARGET: &str = "2a887b1e-1151-46c8-ab56-e0078edee53d"; +const MODULE_OBJECT: &str = "5b0c1d3e-7a01-4c11-9a01-000000000001"; +const MODULE_PROPERTY: &str = "d5963243-262e-4398-b4d7-fb16d06484f6"; +/// `ОбщийМодуль.ОбщийМодуль1.Модуль.Процедура1()`, base64 as the server sends it. +const PRESENTATION: &str = + "0J7QsdGJ0LjQudCc0L7QtNGD0LvRjC7QntCx0YnQuNC50JzQvtC00YPQu9GMMS7QnNC+0LTRg9C70Ywu0J/RgNC+0YbQtdC00YPRgNCwMSgp"; + +const REGISTERED: &str = concat!( + r#""#, + "registered" +); + +/// The 8.3.17 answer to a `setBreakpoints` that carries `breakOnCondition`, shortened. +const BREAK_ON_CONDITION_REFUSED: &str = concat!( + r#"Ошибка преобразования данных XDTO:"#, + "\nНачалоСвойства: {http://v8.1c.ru/8.3/debugger/debugBreakpoints}breakOnCondition\tФорма: Элемент", + "" +); + +const ANY_REQUEST_REFUSED: &str = concat!( + r#""#, + r#"Ошибка при выполнении запроса POST к ресурсу /e1crdbg/rdbg:"#, + "\nUI+ - часть отладки не зарегистрирована" +); + +type Script = dyn Fn(&str, &str) -> (u16, String) + Send + Sync; + +/// A debug server that answers by script and records every request but the event polls. +struct FakeServer { + port: u16, + requests: Arc>>, +} + +impl FakeServer { + fn start(script: Box