test(stage2a): unit pins for the URI teardown and the bus order
`src/lsp.rs` gains nine: the fourteen-family store inventory with a 17-entry precondition so it cannot pass vacuously, the route purge with `workspace/symbol` and another server's route both surviving, the awaiter drain joined on the rid, the error contract's two arms, the late-publish drop with its does-not-over-reach companion, the `mark_document_stale` gate across all three stale stores, exact-pair tombstone identity, and reclamation under both `start_generation` and terminal `forget`. `src/diag.rs` pins that `forget` drops the epoch while `clear` deliberately bumps it — the leak a `clear`-based forget would leave in the one map nothing prunes. `src/async_runtime.rs` injects two resource replies onto the private bus in each order and asserts `TickOutcome.resources` reports arrival order, not allocation order; plus that a failed or cancelled mutation is not harvested at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
parent
7e3e630c3b
commit
aa813ec3b6
|
|
@ -1860,6 +1860,126 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// dired Stage 2a, acceptance 54 (controlled-bus layer). Allocate
|
||||||
|
/// two resource jobs **without dispatching workers**, inject their
|
||||||
|
/// successful replies in a chosen order, and assert
|
||||||
|
/// `TickOutcome.resources` reports exactly that order.
|
||||||
|
///
|
||||||
|
/// This is the honest statement of what the runtime guarantees:
|
||||||
|
/// `tick` drains the reply bus with `try_recv` and establishes no
|
||||||
|
/// execution token, so what a consumer sees is bus-arrival order.
|
||||||
|
/// The test fails against sorting by job id or kind, and against any
|
||||||
|
/// claim that the order recovers dispatch or filesystem-execution
|
||||||
|
/// order — because the injection order here is *deliberately* the
|
||||||
|
/// reverse of the allocation order in the first case.
|
||||||
|
#[test]
|
||||||
|
fn tick_reports_resources_in_bus_arrival_order_not_allocation_order() {
|
||||||
|
fn run(reverse: bool) -> Vec<ResourceOp> {
|
||||||
|
let rt = AsyncRuntime::with_pool_size(1);
|
||||||
|
let (a, _) = rt.allocate_with_resource(
|
||||||
|
JobKind::FsRename,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ResourceOp::Rename {
|
||||||
|
from: PathBuf::from("/tmp/a-from"),
|
||||||
|
to: PathBuf::from("/tmp/a-to"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let (b, _) = rt.allocate_with_resource(
|
||||||
|
JobKind::FsRemove,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ResourceOp::Remove {
|
||||||
|
path: PathBuf::from("/tmp/b-gone"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let order = if reverse { [b, a] } else { [a, b] };
|
||||||
|
for id in order {
|
||||||
|
rt.workers
|
||||||
|
.send(
|
||||||
|
ASYNC_REPLY_TOPIC,
|
||||||
|
&WorkerReply {
|
||||||
|
job_id: id,
|
||||||
|
kind: ReplyKind::FsUnit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("inject reply");
|
||||||
|
}
|
||||||
|
let outcome = rt.tick();
|
||||||
|
assert_eq!(outcome.settled.len(), 2, "both jobs settled");
|
||||||
|
outcome.resources
|
||||||
|
}
|
||||||
|
|
||||||
|
let a_first = ResourceOp::Rename {
|
||||||
|
from: PathBuf::from("/tmp/a-from"),
|
||||||
|
to: PathBuf::from("/tmp/a-to"),
|
||||||
|
};
|
||||||
|
let b_first = ResourceOp::Remove {
|
||||||
|
path: PathBuf::from("/tmp/b-gone"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
run(true),
|
||||||
|
vec![b_first.clone(), a_first.clone()],
|
||||||
|
"B injected first must be reported first, even though A was \
|
||||||
|
allocated first"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
run(false),
|
||||||
|
vec![a_first, b_first],
|
||||||
|
"and the reverse arrival order reverses the report"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed or cancelled mutation reconciles nothing, so it must not
|
||||||
|
/// appear in `resources` at all (acceptance 37's runtime half).
|
||||||
|
#[test]
|
||||||
|
fn a_failed_or_cancelled_resource_job_is_not_harvested() {
|
||||||
|
let rt = AsyncRuntime::with_pool_size(1);
|
||||||
|
let (failed, _) = rt.allocate_with_resource(
|
||||||
|
JobKind::FsRename,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ResourceOp::Rename {
|
||||||
|
from: PathBuf::from("/tmp/nope"),
|
||||||
|
to: PathBuf::from("/tmp/also-nope"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let (cancelled, _) = rt.allocate_with_resource(
|
||||||
|
JobKind::FsRemove,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(ResourceOp::Remove {
|
||||||
|
path: PathBuf::from("/tmp/never"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
rt.workers
|
||||||
|
.send(
|
||||||
|
ASYNC_REPLY_TOPIC,
|
||||||
|
&WorkerReply {
|
||||||
|
job_id: failed,
|
||||||
|
kind: ReplyKind::Error("ENOENT".to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("inject");
|
||||||
|
rt.workers
|
||||||
|
.send(
|
||||||
|
ASYNC_REPLY_TOPIC,
|
||||||
|
&WorkerReply {
|
||||||
|
job_id: cancelled,
|
||||||
|
kind: ReplyKind::Cancelled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("inject");
|
||||||
|
let outcome = rt.tick();
|
||||||
|
assert_eq!(outcome.settled.len(), 2, "both settled");
|
||||||
|
assert!(
|
||||||
|
outcome.resources.is_empty(),
|
||||||
|
"only Complete mutations are harvested; got {:?}",
|
||||||
|
outcome.resources
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_sum_completes_with_correct_value() {
|
fn dispatch_sum_completes_with_correct_value() {
|
||||||
let rt = AsyncRuntime::with_pool_size(2);
|
let rt = AsyncRuntime::with_pool_size(2);
|
||||||
|
|
|
||||||
37
src/diag.rs
37
src/diag.rs
|
|
@ -772,6 +772,43 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// dired Stage 2a §5, finding 4. `clear` *creates* an `epochs`
|
||||||
|
/// entry, because a consumer caching against the epoch has to see
|
||||||
|
/// that the diagnostics went away; nothing ever removes one. So a
|
||||||
|
/// `forget_uri` that called `clear` would leave a URI-keyed leak
|
||||||
|
/// behind in the one map nothing prunes — which is why the forget
|
||||||
|
/// path is its own store method.
|
||||||
|
#[test]
|
||||||
|
fn forget_drops_the_epoch_while_clear_deliberately_bumps_it() {
|
||||||
|
let mut store = DiagnosticStore::new();
|
||||||
|
store.set("file:///a.rs", vec![diag(0, DiagnosticSeverity::Error, "boom")]);
|
||||||
|
store.mark_stale("file:///a.rs");
|
||||||
|
assert_eq!(store.epoch_for("file:///a.rs"), 1);
|
||||||
|
|
||||||
|
store.clear("file:///a.rs");
|
||||||
|
assert_eq!(
|
||||||
|
store.epoch_for("file:///a.rs"),
|
||||||
|
2,
|
||||||
|
"clear announces the removal to epoch-keyed caches"
|
||||||
|
);
|
||||||
|
|
||||||
|
store.set("file:///a.rs", vec![diag(0, DiagnosticSeverity::Error, "boom")]);
|
||||||
|
store.mark_stale("file:///a.rs");
|
||||||
|
store.forget("file:///a.rs");
|
||||||
|
assert!(store.for_uri("file:///a.rs").is_empty(), "diagnostics");
|
||||||
|
assert!(!store.is_stale("file:///a.rs"), "stale flag");
|
||||||
|
assert_eq!(
|
||||||
|
store.severity_counts_for("file:///a.rs"),
|
||||||
|
(0, 0, 0, 0),
|
||||||
|
"severity counts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.epoch_for("file:///a.rs"),
|
||||||
|
0,
|
||||||
|
"forget leaves no trace at all, epoch included"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_lsp_value_parses_minimal_diagnostic() {
|
fn from_lsp_value_parses_minimal_diagnostic() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
|
|
|
||||||
654
src/lsp.rs
654
src/lsp.rs
|
|
@ -4074,3 +4074,657 @@ mod tests {
|
||||||
assert_eq!(resolve_config_section(&s, Some("")), s);
|
assert_eq!(resolve_config_section(&s, Some("")), s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// dired Stage 2a — `forget_uri`, and the tombstone that gates the
|
||||||
|
// uncorrelated resurrection paths (§5, acceptance 31 / 31b / 31c / 31d).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod resource_reconciliation_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::async_runtime::JobOutcome;
|
||||||
|
|
||||||
|
/// A manager plus two live-enough clients. `/bin/cat` blocks on
|
||||||
|
/// stdin, so both stay in `Starting` for the whole test and every
|
||||||
|
/// notification is deferred rather than written — which is exactly
|
||||||
|
/// what these tests want: they assert on manager-owned state, not on
|
||||||
|
/// wire traffic.
|
||||||
|
fn manager_with_two_servers() -> (LspManager, LspServerId, LspServerId) {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
let sup = Rc::new(RefCell::new(crate::process::ProcessSupervisor::new()));
|
||||||
|
let runtime = Rc::new(crate::async_runtime::AsyncRuntime::with_pool_size(1));
|
||||||
|
let mut mgr = LspManager::new(sup, runtime);
|
||||||
|
let mut spec_a = LspServerSpec::new("a", "rust", "/bin/cat");
|
||||||
|
spec_a.restart = LspRestartPolicy::Never;
|
||||||
|
let mut spec_b = LspServerSpec::new("b", "rust", "/bin/cat");
|
||||||
|
spec_b.restart = LspRestartPolicy::Never;
|
||||||
|
let a = mgr.spawn(spec_a).expect("spawn a");
|
||||||
|
let b = mgr.spawn(spec_b).expect("spawn b");
|
||||||
|
(mgr, a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(mgr: &mut LspManager, sid: LspServerId, uri: &str, message: &str) {
|
||||||
|
mgr.handle_notification(
|
||||||
|
sid,
|
||||||
|
"textDocument/publishDiagnostics".to_owned(),
|
||||||
|
json!({
|
||||||
|
"uri": uri,
|
||||||
|
"diagnostics": [{
|
||||||
|
"range": {
|
||||||
|
"start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 1 },
|
||||||
|
},
|
||||||
|
"severity": 1,
|
||||||
|
"message": message,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
Instant::now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diag_messages(mgr: &LspManager, uri: &str) -> Vec<String> {
|
||||||
|
mgr.diag_store
|
||||||
|
.lock()
|
||||||
|
.expect("diag store")
|
||||||
|
.for_uri(uri)
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.message.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate every one of the fourteen URI-keyed store families plus
|
||||||
|
/// the `documents` mirror for `(sid, uri)`.
|
||||||
|
fn populate_all_stores(mgr: &mut LspManager, sid: LspServerId, uri: &str) {
|
||||||
|
let server = sid.raw().to_string();
|
||||||
|
publish(mgr, sid, uri, "a diagnostic");
|
||||||
|
mgr.completion_store.lock().unwrap().set(
|
||||||
|
crate::completion::CompletionKey::new(server.clone(), uri),
|
||||||
|
crate::completion::CompletionResponse::from_lsp_value(&json!([{ "label": "x" }])),
|
||||||
|
);
|
||||||
|
mgr.hover_store.lock().unwrap().set(
|
||||||
|
crate::hover::HoverKey::new(server.clone(), uri),
|
||||||
|
crate::hover::Hover::from_lsp_value(&json!({ "contents": "doc" }))
|
||||||
|
.expect("a hover payload with contents parses"),
|
||||||
|
);
|
||||||
|
mgr.signature_store.lock().unwrap().set(
|
||||||
|
crate::signature::SignatureKey::new(server.clone(), uri),
|
||||||
|
crate::signature::SignatureHelp::from_lsp_value(
|
||||||
|
&json!({ "signatures": [{ "label": "f()" }] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mgr.definition_store.lock().unwrap().set(
|
||||||
|
crate::definition::DefinitionKey::new(server.clone(), uri),
|
||||||
|
crate::definition::DefinitionResponse::from_lsp_value(&json!({
|
||||||
|
"uri": uri,
|
||||||
|
"range": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 0 } },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
for kind in [
|
||||||
|
crate::locations::LocationKind::References,
|
||||||
|
crate::locations::LocationKind::Declaration,
|
||||||
|
crate::locations::LocationKind::TypeDefinition,
|
||||||
|
crate::locations::LocationKind::Implementation,
|
||||||
|
] {
|
||||||
|
mgr.locations_store.lock().unwrap().set(
|
||||||
|
crate::locations::LocationsKey::new(server.clone(), uri, kind),
|
||||||
|
crate::definition::DefinitionResponse::from_lsp_value(&json!({
|
||||||
|
"uri": uri,
|
||||||
|
"range": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 0 } },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mgr.symbol_store.lock().unwrap().set(
|
||||||
|
crate::symbol::SymbolKey::document(server.clone(), uri),
|
||||||
|
crate::symbol::SymbolResponse::from_lsp_value(
|
||||||
|
&json!([{
|
||||||
|
"name": "S", "kind": 5,
|
||||||
|
"range": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 0 } },
|
||||||
|
"selectionRange": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 0 } },
|
||||||
|
}]),
|
||||||
|
uri,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mgr.document_highlight_store.lock().unwrap().set(
|
||||||
|
crate::document_highlight::DocumentHighlightKey::new(server.clone(), uri),
|
||||||
|
crate::document_highlight::DocumentHighlightResponse::from_lsp_value(&json!([{
|
||||||
|
"range": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 1 } },
|
||||||
|
}])),
|
||||||
|
);
|
||||||
|
mgr.formatting_store.lock().unwrap().set(
|
||||||
|
crate::formatting::FormattingKey::new(server.clone(), uri),
|
||||||
|
crate::formatting::FormattingResponse::from_lsp_value(&json!([{
|
||||||
|
"range": { "start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 0 } },
|
||||||
|
"newText": "x",
|
||||||
|
}])),
|
||||||
|
);
|
||||||
|
mgr.rename_store.lock().unwrap().set(
|
||||||
|
crate::rename::RenameKey::new(server.clone(), uri),
|
||||||
|
crate::rename::WorkspaceEditResponse::from_lsp_value(&json!({ "changes": {} })),
|
||||||
|
);
|
||||||
|
mgr.prepare_rename_store.lock().unwrap().set(
|
||||||
|
crate::prepare_rename::PrepareRenameKey::new(server.clone(), uri),
|
||||||
|
crate::prepare_rename::PrepareRenameResponse::from_lsp_value(&json!({
|
||||||
|
"start": { "line": 0, "character": 0 },
|
||||||
|
"end": { "line": 0, "character": 1 },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
mgr.code_action_store.lock().unwrap().set(
|
||||||
|
crate::code_action::CodeActionKey::new(server.clone(), uri),
|
||||||
|
crate::code_action::CodeActionResponse::from_lsp_value(&json!([{ "title": "fix" }])),
|
||||||
|
);
|
||||||
|
mgr.inlay_hint_store.lock().unwrap().set(
|
||||||
|
crate::inlay_hint::InlayHintKey::new(server.clone(), uri),
|
||||||
|
crate::inlay_hint::InlayHintResponse::from_lsp_value(&json!([{
|
||||||
|
"position": { "line": 0, "character": 0 },
|
||||||
|
"label": ": i32",
|
||||||
|
}])),
|
||||||
|
);
|
||||||
|
mgr.semantic_token_store.lock().unwrap().set(
|
||||||
|
crate::semantic_tokens::SemanticTokenKey::new(server, uri),
|
||||||
|
crate::semantic_tokens::SemanticTokensResponse::from_lsp_value(&json!({
|
||||||
|
"data": [0, 0, 1, 0, 0],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
mgr.documents.insert((sid, uri.to_owned()), "text".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the fourteen families still hold an entry for
|
||||||
|
/// `(sid, uri)`, by name. An empty vector is the post-forget
|
||||||
|
/// expectation; naming the survivors is what makes a failure
|
||||||
|
/// actionable instead of "assert!(false)".
|
||||||
|
fn populated_families(mgr: &LspManager, sid: LspServerId, uri: &str) -> Vec<&'static str> {
|
||||||
|
let server = sid.raw().to_string();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if !diag_messages(mgr, uri).is_empty() {
|
||||||
|
out.push("diag");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.completion_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::completion::CompletionKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("completion");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.hover_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::hover::HoverKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("hover");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.signature_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::signature::SignatureKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("signature");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.definition_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::definition::DefinitionKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("definition");
|
||||||
|
}
|
||||||
|
for (kind, label) in [
|
||||||
|
(crate::locations::LocationKind::References, "references"),
|
||||||
|
(crate::locations::LocationKind::Declaration, "declaration"),
|
||||||
|
(
|
||||||
|
crate::locations::LocationKind::TypeDefinition,
|
||||||
|
"typeDefinition",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::locations::LocationKind::Implementation,
|
||||||
|
"implementation",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if mgr
|
||||||
|
.locations_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::locations::LocationsKey::new(
|
||||||
|
server.clone(),
|
||||||
|
uri,
|
||||||
|
kind,
|
||||||
|
))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.symbol_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::symbol::SymbolKey::document(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("symbol");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.document_highlight_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::document_highlight::DocumentHighlightKey::new(
|
||||||
|
server.clone(),
|
||||||
|
uri,
|
||||||
|
))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("documentHighlight");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.formatting_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::formatting::FormattingKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("formatting");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.rename_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::rename::RenameKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("rename");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.prepare_rename_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::prepare_rename::PrepareRenameKey::new(
|
||||||
|
server.clone(),
|
||||||
|
uri,
|
||||||
|
))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("prepareRename");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.code_action_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::code_action::CodeActionKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("codeAction");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.inlay_hint_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::inlay_hint::InlayHintKey::new(server.clone(), uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("inlayHint");
|
||||||
|
}
|
||||||
|
if mgr
|
||||||
|
.semantic_token_store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&crate::semantic_tokens::SemanticTokenKey::new(server, uri))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
out.push("semanticTokens");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31, store half — every one of the fourteen families
|
||||||
|
/// plus `documents` loses its entry. The `populated_families`
|
||||||
|
/// precondition is what makes this bite: an assertion that the
|
||||||
|
/// stores are empty afterwards passes vacuously if nothing filled
|
||||||
|
/// them.
|
||||||
|
#[test]
|
||||||
|
fn forget_uri_clears_all_fourteen_store_families_and_the_document_mirror() {
|
||||||
|
let (mut mgr, a, _b) = manager_with_two_servers();
|
||||||
|
let uri = "file:///tmp/old.rs";
|
||||||
|
populate_all_stores(&mut mgr, a, uri);
|
||||||
|
let before = populated_families(&mgr, a, uri);
|
||||||
|
assert_eq!(
|
||||||
|
before.len(),
|
||||||
|
17,
|
||||||
|
"precondition: every family must hold an entry before the forget \
|
||||||
|
(14 families, of which `locations` counts four kinds); got {before:?}"
|
||||||
|
);
|
||||||
|
assert!(mgr.documents.contains_key(&(a, uri.to_owned())));
|
||||||
|
|
||||||
|
mgr.forget_uri(a, uri).expect("forget a known server");
|
||||||
|
|
||||||
|
let after = populated_families(&mgr, a, uri);
|
||||||
|
assert!(
|
||||||
|
after.is_empty(),
|
||||||
|
"these families survived the forget: {after:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.documents.contains_key(&(a, uri.to_owned())),
|
||||||
|
"the `documents` mirror is what didChange diffs against, so a \
|
||||||
|
stale entry under the old URI is a correctness problem"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31, route half, plus W3's exemption. A response
|
||||||
|
/// already in flight at rename time must not repopulate the old key
|
||||||
|
/// after the clear — and a `workspace/symbol` route, which carries a
|
||||||
|
/// query and no URI at all, must survive.
|
||||||
|
#[test]
|
||||||
|
fn forget_uri_purges_routes_for_the_uri_and_retains_workspace_symbol() {
|
||||||
|
let (mut mgr, a, b) = manager_with_two_servers();
|
||||||
|
let old = "file:///tmp/old.rs";
|
||||||
|
let other = "file:///tmp/other.rs";
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(a, 1),
|
||||||
|
ResponseRoute::Hover {
|
||||||
|
uri: old.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(a, 2),
|
||||||
|
ResponseRoute::Locations {
|
||||||
|
uri: old.to_owned(),
|
||||||
|
kind: crate::locations::LocationKind::References,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(a, 3),
|
||||||
|
ResponseRoute::WorkspaceSymbol {
|
||||||
|
query: "Widget".to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(a, 4),
|
||||||
|
ResponseRoute::Hover {
|
||||||
|
uri: other.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Same URI, different server: another server's in-flight work is
|
||||||
|
// not ours to cancel.
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(b, 5),
|
||||||
|
ResponseRoute::Hover {
|
||||||
|
uri: old.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.forget_uri(a, old).expect("forget");
|
||||||
|
|
||||||
|
assert!(!mgr.pending_routes.contains_key(&(a, 1)), "hover for old");
|
||||||
|
assert!(
|
||||||
|
!mgr.pending_routes.contains_key(&(a, 2)),
|
||||||
|
"locations for old"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.pending_routes.contains_key(&(a, 3)),
|
||||||
|
"workspace/symbol carries no URI and is not scoped to any \
|
||||||
|
document, so a rename does not invalidate it"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.pending_routes.contains_key(&(a, 4)),
|
||||||
|
"an unrelated document's route must survive"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.pending_routes.contains_key(&(b, 5)),
|
||||||
|
"another server's route for the same URI must survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31, drain half. `pending_external` holds the
|
||||||
|
/// `Handle:await()` side, and its own contract says it is
|
||||||
|
/// drained-cancelled wherever `pending_routes` is purged. Neither
|
||||||
|
/// existing sweep is URI-scoped, and the one with the similar name
|
||||||
|
/// (`drain_cancelled_externals`) removes only awaiters whose token
|
||||||
|
/// was flipped or which timed out — **a rename flips no token**, so
|
||||||
|
/// modelling on it would drain nothing and park the coroutine
|
||||||
|
/// forever.
|
||||||
|
#[test]
|
||||||
|
fn forget_uri_settles_the_awaiters_joined_to_the_purged_routes() {
|
||||||
|
let (mut mgr, a, _b) = manager_with_two_servers();
|
||||||
|
let old = "file:///tmp/old.rs";
|
||||||
|
let other = "file:///tmp/other.rs";
|
||||||
|
let runtime = mgr.runtime.clone();
|
||||||
|
|
||||||
|
let mut register = |rid: u64, uri: &str| {
|
||||||
|
let (job_id, token) = runtime.register_external(JobKind::LspRequest, None);
|
||||||
|
mgr.pending_routes.insert(
|
||||||
|
(a, rid),
|
||||||
|
ResponseRoute::Hover {
|
||||||
|
uri: uri.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mgr.pending_external.insert(
|
||||||
|
(a, rid),
|
||||||
|
PendingExternal {
|
||||||
|
method: "textDocument/hover".to_owned(),
|
||||||
|
awaiters: vec![Awaiter { job_id, token }],
|
||||||
|
dispatched_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
job_id
|
||||||
|
};
|
||||||
|
let doomed = register(1, old);
|
||||||
|
let survivor = register(2, other);
|
||||||
|
|
||||||
|
// Nothing has settled yet: the drain, not the registration, is
|
||||||
|
// what must produce the outcome.
|
||||||
|
let _ = runtime.tick();
|
||||||
|
assert!(!runtime.is_complete(doomed));
|
||||||
|
assert!(!runtime.is_complete(survivor));
|
||||||
|
|
||||||
|
mgr.forget_uri(a, old).expect("forget");
|
||||||
|
let _ = runtime.tick();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(runtime.take_result(doomed), Some(JobOutcome::Cancelled)),
|
||||||
|
"an awaiter parked on a route we just purged must wake cancelled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.pending_external.contains_key(&(a, 1)),
|
||||||
|
"and its entry must be gone, not merely settled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
runtime.take_result(survivor).is_none(),
|
||||||
|
"an unrelated document's awaiter keeps waiting"
|
||||||
|
);
|
||||||
|
assert!(mgr.pending_external.contains_key(&(a, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31c — the error contract, both arms. The second is the
|
||||||
|
/// one that matters: the subscriber runs per attachment, an
|
||||||
|
/// attachment need not have any pending route or populated result,
|
||||||
|
/// and repeated cleanup after a partial teardown must stay safe.
|
||||||
|
#[test]
|
||||||
|
fn forget_uri_raises_for_an_unknown_server_and_succeeds_with_no_state() {
|
||||||
|
let (mut mgr, a, _b) = manager_with_two_servers();
|
||||||
|
let unknown = LspServerId::next();
|
||||||
|
let err = mgr
|
||||||
|
.forget_uri(unknown, "file:///tmp/x.rs")
|
||||||
|
.expect_err("unknown server must raise, matching `forget`");
|
||||||
|
assert!(err.contains("unknown server"), "{err}");
|
||||||
|
|
||||||
|
mgr.forget_uri(a, "file:///tmp/never-touched.rs")
|
||||||
|
.expect("a URI with no state under a known server is an \
|
||||||
|
idempotent success, not an error");
|
||||||
|
mgr.forget_uri(a, "file:///tmp/never-touched.rs")
|
||||||
|
.expect("and repeating it stays safe");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31b — the uncorrelated write. This notification
|
||||||
|
/// carries no request id, so the route purge cannot see it, and
|
||||||
|
/// `diag_store` has no correlated writers at all. The companion
|
||||||
|
/// assertion is that the tombstone does **not** over-reach: a
|
||||||
|
/// publish for a different, never-opened URI is still absorbed,
|
||||||
|
/// which is exactly what a `documents` membership gate would have
|
||||||
|
/// broken.
|
||||||
|
#[test]
|
||||||
|
fn a_late_publish_for_a_forgotten_uri_is_dropped_and_an_unopened_uri_is_not() {
|
||||||
|
let (mut mgr, a, _b) = manager_with_two_servers();
|
||||||
|
let old = "file:///tmp/old.rs";
|
||||||
|
publish(&mut mgr, a, old, "before");
|
||||||
|
assert_eq!(diag_messages(&mgr, old), vec!["before".to_owned()]);
|
||||||
|
|
||||||
|
mgr.forget_uri(a, old).expect("forget");
|
||||||
|
assert!(diag_messages(&mgr, old).is_empty(), "cleared by the forget");
|
||||||
|
|
||||||
|
publish(&mut mgr, a, old, "late arrival");
|
||||||
|
assert!(
|
||||||
|
diag_messages(&mgr, old).is_empty(),
|
||||||
|
"a publish naming a URI we explicitly forgot must be dropped"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Servers legitimately publish for files the editor never
|
||||||
|
// opened — a crate-wide push naming a dependency.
|
||||||
|
let never_opened = "file:///tmp/dependency.rs";
|
||||||
|
publish(&mut mgr, a, never_opened, "third-party");
|
||||||
|
assert_eq!(
|
||||||
|
diag_messages(&mgr, never_opened),
|
||||||
|
vec!["third-party".to_owned()],
|
||||||
|
"the tombstone drops only what we forgot, never everything \
|
||||||
|
outside `documents`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31b, second gate. `mark_document_stale` creates URI
|
||||||
|
/// keys in three stores, so without its own check a forgotten URI
|
||||||
|
/// regains a stale flag in all three.
|
||||||
|
#[test]
|
||||||
|
fn mark_document_stale_cannot_flag_a_forgotten_pair_in_any_of_the_three_stores() {
|
||||||
|
let (mut mgr, a, _b) = manager_with_two_servers();
|
||||||
|
let old = "file:///tmp/old.rs";
|
||||||
|
mgr.forget_uri(a, old).expect("forget");
|
||||||
|
|
||||||
|
mgr.mark_document_stale(a, old);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!mgr.diag_store.lock().unwrap().is_stale(old),
|
||||||
|
"diagnostics"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.semantic_token_store.lock().unwrap().is_stale(old),
|
||||||
|
"semantic tokens"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.inlay_hint_store.lock().unwrap().is_stale(old),
|
||||||
|
"inlay hints"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it still works for a URI we did not forget, so the gate is
|
||||||
|
// the tombstone and not a blanket disable.
|
||||||
|
let live = "file:///tmp/live.rs";
|
||||||
|
mgr.mark_document_stale(a, live);
|
||||||
|
assert!(mgr.diag_store.lock().unwrap().is_stale(live));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31d — identity and reclamation are exact. Tombstone
|
||||||
|
/// one URI under two servers; `did_open(A, uri)` clears only A's
|
||||||
|
/// pair, so an A write is admitted while a later B write is dropped.
|
||||||
|
#[test]
|
||||||
|
fn the_tombstone_is_keyed_by_the_exact_server_uri_pair() {
|
||||||
|
let (mut mgr, a, b) = manager_with_two_servers();
|
||||||
|
let uri = "file:///tmp/shared.rs";
|
||||||
|
mgr.forget_uri(a, uri).expect("forget under a");
|
||||||
|
mgr.forget_uri(b, uri).expect("forget under b");
|
||||||
|
assert!(mgr.is_forgotten(a, uri));
|
||||||
|
assert!(mgr.is_forgotten(b, uri));
|
||||||
|
|
||||||
|
mgr.did_open(a, uri, 1, "text").expect("reopen under a");
|
||||||
|
assert!(
|
||||||
|
!mgr.is_forgotten(a, uri),
|
||||||
|
"reopening is the editor saying it holds the URI again"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.is_forgotten(b, uri),
|
||||||
|
"and it says nothing about another server's tombstone"
|
||||||
|
);
|
||||||
|
|
||||||
|
publish(&mut mgr, a, uri, "from A");
|
||||||
|
assert_eq!(
|
||||||
|
diag_messages(&mgr, uri),
|
||||||
|
vec!["from A".to_owned()],
|
||||||
|
"A's write is admitted after A reopened"
|
||||||
|
);
|
||||||
|
publish(&mut mgr, b, uri, "from B");
|
||||||
|
assert_eq!(
|
||||||
|
diag_messages(&mgr, uri),
|
||||||
|
vec!["from A".to_owned()],
|
||||||
|
"B is still tombstoned for this URI, so its later write is \
|
||||||
|
dropped and A's payload survives untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31d — a restart generation flip drops every pair for
|
||||||
|
/// its own server and retains every other server's.
|
||||||
|
#[test]
|
||||||
|
fn start_generation_reclaims_only_the_flipped_servers_tombstones() {
|
||||||
|
let (mut mgr, a, b) = manager_with_two_servers();
|
||||||
|
mgr.forget_uri(a, "file:///tmp/a1.rs").expect("forget");
|
||||||
|
mgr.forget_uri(a, "file:///tmp/a2.rs").expect("forget");
|
||||||
|
mgr.forget_uri(b, "file:///tmp/b1.rs").expect("forget");
|
||||||
|
assert_eq!(mgr.forgotten_document_count(), 3);
|
||||||
|
|
||||||
|
let mut client = mgr.clients.remove(&b).expect("client b");
|
||||||
|
mgr.start_generation(b, &mut client).expect("restart b");
|
||||||
|
mgr.clients.insert(b, client);
|
||||||
|
|
||||||
|
assert!(mgr.is_forgotten(a, "file:///tmp/a1.rs"));
|
||||||
|
assert!(mgr.is_forgotten(a, "file:///tmp/a2.rs"));
|
||||||
|
assert!(
|
||||||
|
!mgr.is_forgotten(b, "file:///tmp/b1.rs"),
|
||||||
|
"B's generation is gone, so B's tombstones go with it"
|
||||||
|
);
|
||||||
|
assert_eq!(mgr.forgotten_document_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance 31d — terminal `forget` likewise, and the set is empty
|
||||||
|
/// once the only owning generation is torn down. This is what makes
|
||||||
|
/// the set reclaimed rather than a leak; it deliberately is **not**
|
||||||
|
/// size-bounded, because a capacity or LRU eviction would let an
|
||||||
|
/// arbitrarily late notification resurrect an evicted key.
|
||||||
|
#[test]
|
||||||
|
fn terminal_forget_reclaims_only_its_own_servers_tombstones() {
|
||||||
|
let (mut mgr, a, b) = manager_with_two_servers();
|
||||||
|
mgr.forget_uri(a, "file:///tmp/a1.rs").expect("forget");
|
||||||
|
mgr.forget_uri(b, "file:///tmp/b1.rs").expect("forget");
|
||||||
|
assert_eq!(mgr.forgotten_document_count(), 2);
|
||||||
|
|
||||||
|
if let Some(client) = mgr.clients.get_mut(&b) {
|
||||||
|
client.state = LspClientState::Stopped {
|
||||||
|
ended: Instant::now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mgr.forget(b).expect("forget b");
|
||||||
|
assert!(mgr.is_forgotten(a, "file:///tmp/a1.rs"));
|
||||||
|
assert!(!mgr.is_forgotten(b, "file:///tmp/b1.rs"));
|
||||||
|
assert_eq!(mgr.forgotten_document_count(), 1);
|
||||||
|
|
||||||
|
if let Some(client) = mgr.clients.get_mut(&a) {
|
||||||
|
client.state = LspClientState::Stopped {
|
||||||
|
ended: Instant::now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
mgr.forget(a).expect("forget a");
|
||||||
|
assert_eq!(
|
||||||
|
mgr.forgotten_document_count(),
|
||||||
|
0,
|
||||||
|
"the set is empty once the owning generations are gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue