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:
Levi Neuwirth 2026-07-29 18:23:41 -04:00
parent 7e3e630c3b
commit aa813ec3b6
3 changed files with 811 additions and 0 deletions

View File

@ -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]
fn dispatch_sum_completes_with_correct_value() {
let rt = AsyncRuntime::with_pool_size(2);

View File

@ -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]
fn from_lsp_value_parses_minimal_diagnostic() {
let v = json!({

View File

@ -4074,3 +4074,657 @@ mod tests {
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"
);
}
}