1883 lines
63 KiB
Rust
1883 lines
63 KiB
Rust
//! Arc 8 Stage 3b acceptance — the Lean 4 language server.
|
||
//!
|
||
//! `docs/lean4-mode-framing.md` Q#LN7, Q#LN8, Q#LN16; acceptance 22–28,
|
||
//! 24a/24b, 35, 36, 36a, 37.
|
||
//!
|
||
//! No live toolchain required. The server side is `pmacs_fake_lsp`
|
||
//! configured under the `lean4` language id; the probe and latch are
|
||
//! driven through shell stubs the fixture writes, so nothing here needs
|
||
//! `lake`, `lean`, or an elan toolchain on PATH (§2.9).
|
||
//!
|
||
//! Every fixture sets `pmacs.project.set_search_boundary` at its own
|
||
//! tempdir root. Without it the `lean-toolchain` walk climbs to the
|
||
//! filesystem root and acceptance 23's outermost assertion stops being
|
||
//! hermetic.
|
||
|
||
#![cfg(unix)]
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Duration;
|
||
|
||
use pmacs::editor::EditorState;
|
||
|
||
fn exec(state: &EditorState, source: &str) {
|
||
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
|
||
}
|
||
|
||
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
|
||
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
|
||
}
|
||
|
||
fn fake_lsp_path() -> String {
|
||
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||
}
|
||
|
||
fn lua_str(path: &Path) -> String {
|
||
path.display()
|
||
.to_string()
|
||
.replace('\\', "\\\\")
|
||
.replace('"', "\\\"")
|
||
}
|
||
|
||
struct Fixture {
|
||
_dir: tempfile::TempDir,
|
||
root: PathBuf,
|
||
}
|
||
|
||
impl Fixture {
|
||
fn new() -> Self {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let root = std::fs::canonicalize(dir.path()).unwrap();
|
||
Self { _dir: dir, root }
|
||
}
|
||
|
||
fn write(&self, rel: &str, contents: &str) -> PathBuf {
|
||
let path = self.root.join(rel);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(&path, contents).unwrap();
|
||
path
|
||
}
|
||
|
||
fn mkdir(&self, rel: &str) -> PathBuf {
|
||
let path = self.root.join(rel);
|
||
std::fs::create_dir_all(&path).unwrap();
|
||
path
|
||
}
|
||
|
||
fn dir(&self, rel: &str) -> PathBuf {
|
||
self.root.join(rel)
|
||
}
|
||
|
||
/// A `lean-toolchain` marker file. Content is irrelevant to the
|
||
/// resolver by design (existence semantics), which 24b pins.
|
||
fn toolchain(&self, rel_dir: &str, body: &str) {
|
||
self.write(&format!("{rel_dir}/lean-toolchain"), body);
|
||
}
|
||
|
||
fn bind(&self, state: &EditorState) {
|
||
exec(
|
||
state,
|
||
&format!(
|
||
"pmacs.project.set_search_boundary(\"{}\")",
|
||
lua_str(&self.root)
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A fresh editor with every shipped language config cleared, then the
|
||
/// `lean4` entry rebuilt against the fake server while KEEPING the real
|
||
/// resolver. That combination is the point: the root rule under test is
|
||
/// production code, only the command is a stand-in.
|
||
fn editor(fx: &Fixture) -> EditorState {
|
||
let state = EditorState::new();
|
||
exec(&state, "pmacs.lsp.config = {}");
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4 = {{
|
||
command = "{}",
|
||
args = {{}},
|
||
root = pmacs.lean.root_for,
|
||
}}
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
fx.bind(&state);
|
||
state
|
||
}
|
||
|
||
fn settle(state: &mut EditorState) {
|
||
for _ in 0..10 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
}
|
||
|
||
fn open(state: &EditorState, path: &Path) {
|
||
exec(
|
||
state,
|
||
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
|
||
);
|
||
}
|
||
|
||
/// `language_id|root_uri|cwd` for every live server, sorted.
|
||
fn rows(state: &EditorState) -> Vec<String> {
|
||
let joined: String = eval(
|
||
state,
|
||
r#"
|
||
local out = {}
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
out[#out + 1] = table.concat({
|
||
s.language_id or "", s.root_uri or "", s.cwd or "",
|
||
}, "|")
|
||
end
|
||
table.sort(out)
|
||
return table.concat(out, "\n")
|
||
"#,
|
||
);
|
||
if joined.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
joined.lines().map(str::to_owned).collect()
|
||
}
|
||
}
|
||
|
||
fn resolved_root(state: &EditorState, file: &Path) -> String {
|
||
eval(
|
||
state,
|
||
&format!(
|
||
"return tostring(pmacs.lean.root_for(\"{}\"))",
|
||
lua_str(file)
|
||
),
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 22 — a Lean file in a Lake package spawns one server rooted
|
||
// at the package.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc22_lean_file_in_a_lake_package_spawns_one_server_at_the_package_root() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n");
|
||
let file = fx.write("pkg/Pkg/Basic.lean", "def x : Nat := 1\n");
|
||
let mut state = editor(&fx);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let pkg = fx.dir("pkg").display().to_string();
|
||
assert_eq!(
|
||
rows(&state),
|
||
vec![format!("lean4|file://{pkg}|{pkg}")],
|
||
"one server, rooted and cwd'd at the Lake package"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 23 — outermost wins.
|
||
//
|
||
// The case `pmacs.project.detect` cannot express: it is innermost-wins by
|
||
// construction, so a dependency vendored under `.lake/packages` would get
|
||
// its own server and its own (wrong) view of the world.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc23_nested_toolchains_resolve_to_the_outermost_package() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n");
|
||
fx.toolchain("pkg/.lake/packages/dep", "leanprover/lean4:v4.8.0\n");
|
||
let inner = fx.write(
|
||
"pkg/.lake/packages/dep/Dep/Core.lean",
|
||
"def dep : Nat := 2\n",
|
||
);
|
||
let state = editor(&fx);
|
||
|
||
assert_eq!(
|
||
resolved_root(&state, &inner),
|
||
fx.dir("pkg").display().to_string(),
|
||
"a file under .lake/packages/dep belongs to the outer package"
|
||
);
|
||
// Non-vacuity: the inner marker really exists, so "outermost" is a
|
||
// choice between two candidates rather than the only one found.
|
||
assert!(fx.dir("pkg/.lake/packages/dep/lean-toolchain").exists());
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 24 — the walk stops at the search boundary.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc24_walk_stops_at_the_search_boundary() {
|
||
let fx = Fixture::new();
|
||
// Boundary is the fixture root; this marker sits INSIDE it.
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
// And this one sits AT the fixture root, i.e. above `pkg` but still
|
||
// within the boundary — it must win, being outermost.
|
||
fx.toolchain(".", "v4.7.0\n");
|
||
let state = editor(&fx);
|
||
assert_eq!(
|
||
resolved_root(&state, &file),
|
||
fx.root.display().to_string(),
|
||
"within the boundary, the outermost marker wins"
|
||
);
|
||
|
||
// Now move the boundary IN to `pkg`. The root-level marker is above
|
||
// it and must not be reached.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
"pmacs.project.set_search_boundary(\"{}\")",
|
||
lua_str(&fx.dir("pkg"))
|
||
),
|
||
);
|
||
assert_eq!(
|
||
resolved_root(&state, &file),
|
||
fx.dir("pkg").display().to_string(),
|
||
"a marker above the boundary is not consulted"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 24a / 24b — the marker test, both directions.
|
||
//
|
||
// These two must each fail against the implementation that satisfies only
|
||
// the other. 24a bites the bare `io.open` truth test (which succeeds on a
|
||
// directory); 24b bites the read-a-byte-and-require-non-nil rule (which
|
||
// rejects an empty file at EOF).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc24a_a_lean_toolchain_directory_is_not_a_marker() {
|
||
let fx = Fixture::new();
|
||
fx.mkdir("pkg/lean-toolchain");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let state = editor(&fx);
|
||
assert_eq!(
|
||
resolved_root(&state, &file),
|
||
"nil",
|
||
"a `lean-toolchain` DIRECTORY must not mark a root"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc24b_an_empty_lean_toolchain_file_is_a_marker() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let state = editor(&fx);
|
||
assert_eq!(
|
||
resolved_root(&state, &file),
|
||
fx.dir("pkg").display().to_string(),
|
||
"marker semantics are existence, not content — an empty \
|
||
`lean-toolchain` still marks the package"
|
||
);
|
||
// Non-vacuity: the file really is empty.
|
||
assert_eq!(
|
||
std::fs::read(fx.dir("pkg/lean-toolchain")).unwrap().len(),
|
||
0
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc24_resolver_declines_when_no_marker_exists() {
|
||
let fx = Fixture::new();
|
||
let file = fx.write("loose/A.lean", "def a := 1\n");
|
||
let state = editor(&fx);
|
||
assert_eq!(
|
||
resolved_root(&state, &file),
|
||
"nil",
|
||
"no marker anywhere is a decline, which falls through to \
|
||
`pmacs.project.detect`"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 25 — a string-valued root still works.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc25_string_valued_root_still_works() {
|
||
let fx = Fixture::new();
|
||
let pkg = fx.mkdir("elsewhere");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!("pmacs.lsp.config.lean4.root = \"{}\"", lua_str(&pkg)),
|
||
);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let want = pkg.display().to_string();
|
||
assert_eq!(
|
||
rows(&state),
|
||
vec![format!("lean4|file://{want}|{want}")],
|
||
"the Q#LN8 generalization is additive; a plain string still wins"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 26 — didOpen carries languageId = "lean4".
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc26_did_open_carries_the_lean4_language_id() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let lang: String = eval(
|
||
&state,
|
||
"return tostring(pmacs.lsp.list()[1] and pmacs.lsp.list()[1].language_id)",
|
||
);
|
||
assert_eq!(
|
||
lang, "lean4",
|
||
"the grammar entry name is the didOpen language id (Q#LN2)"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 27 / 28 / 35 / 36 — the probe and the fallback latch.
|
||
//
|
||
// **Driven through the production path**, not by calling internals.
|
||
// Round 1's versions poked `_fire_latch` directly and asserted on config
|
||
// mutation, which proved nothing about whether a server ever starts —
|
||
// and acceptance 36 went further and asserted every server was terminal,
|
||
// pinning the ABSENCE of the fallback it claimed to test. These go
|
||
// `buffer.after-load` -> ticks -> probe drain -> latch -> re-attach, and
|
||
// assert the originally opened buffer ends up on a LIVE server.
|
||
//
|
||
// The stubs are real executables the fixture writes. `M.fallback` is a
|
||
// table precisely so it can point at `pmacs_fake_lsp` here.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
impl Fixture {
|
||
/// An executable shell stub. `serve` sleeps (so the "server" does not
|
||
/// die and only the named failure mode is under test); `--version`
|
||
/// prints `version_line`.
|
||
fn lake_stub(&self, rel: &str, version_line: &str) -> PathBuf {
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
let path = self.root.join(rel);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
format!(
|
||
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo '{version_line}'\n exit 0\nfi\nexec sleep 300\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
path
|
||
}
|
||
}
|
||
|
||
/// Point `command` at `lake_cmd` and the latch's fallback at the fake
|
||
/// LSP server, so a fallback that fires produces a server that works.
|
||
fn with_fallback(state: &EditorState, lake_cmd: &Path) {
|
||
exec(
|
||
state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{ "serve" }}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(lake_cmd),
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The active buffer's attached server id, or "none".
|
||
fn attached_sid(state: &EditorState) -> String {
|
||
eval(
|
||
state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
return rec and tostring(rec.server) or "none"
|
||
"#,
|
||
)
|
||
}
|
||
|
||
/// State kind of the active buffer's attached server, or "none".
|
||
fn attached_state(state: &EditorState) -> String {
|
||
eval(
|
||
state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.state and s.state.kind)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn acc28_version_predicate_triggers_only_below_3_1() {
|
||
let fx = Fixture::new();
|
||
let state = editor(&fx);
|
||
let check = |v: &str| -> bool {
|
||
eval(
|
||
&state,
|
||
&format!("return pmacs.lean._version_below_3_1(\"{v}\")"),
|
||
)
|
||
};
|
||
assert!(check("Lake version 3.0.0"), "3.0.0 is below 3.1");
|
||
assert!(!check("Lake version 3.1.0"), "3.1.0 is not below 3.1");
|
||
assert!(!check("Lake version 5.0.0-abc"), "5.0.0 is not below 3.1");
|
||
assert!(check("Lake version 2.9.9"), "2.9.9 is below 3.1");
|
||
assert!(
|
||
!check("no default toolchain configured"),
|
||
"an unparseable line must NOT trigger the fallback — that is the \
|
||
elan-shim case, which the failure latch handles better"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc28_an_old_lake_falls_back_and_the_buffer_lands_on_a_live_server() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &old_lake);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
// The stub's `serve` sleeps rather than dying, so ONLY the probe can
|
||
// have caused a fallback here. That isolation is the point.
|
||
for _ in 0..40 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
if attached_state(&state) == "initialized" {
|
||
break;
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
attached_state(&state),
|
||
"initialized",
|
||
"an old lake must leave the buffer on a LIVE fallback server, not \
|
||
merely rewrite the config"
|
||
);
|
||
let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command");
|
||
assert_eq!(cmd, fake_lsp_path(), "the fallback command is in effect");
|
||
}
|
||
|
||
#[test]
|
||
fn acc28_a_current_lake_does_not_trigger_the_fallback() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let new_lake = fx.lake_stub("bin/lake", "Lake version 3.1.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &new_lake);
|
||
|
||
open(&state, &file);
|
||
for _ in 0..20 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
|
||
// Non-vacuity against the test above: same harness, same stub shape,
|
||
// only the version differs — so a latch that fired unconditionally
|
||
// would be caught here.
|
||
let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command");
|
||
assert_eq!(
|
||
cmd,
|
||
new_lake.display().to_string(),
|
||
"a current lake keeps its command; the probe must not fall back"
|
||
);
|
||
let latched: bool = eval(&state, "return pmacs.lean._probe.latched");
|
||
assert!(!latched, "the latch did not arm");
|
||
}
|
||
|
||
#[test]
|
||
fn acc27_a_missing_lake_falls_back_and_the_buffer_lands_on_a_live_server() {
|
||
// The case round 1 could not see at all: `ensure_server` swallows a
|
||
// synchronous ENOENT and returns nil, so there is no attachment to
|
||
// key off. This is also the most likely real-world failure — a user
|
||
// with `lean` but no `lake`.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lake");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
|
||
open(&state, &file);
|
||
for _ in 0..40 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
if attached_state(&state) == "initialized" {
|
||
break;
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
attached_state(&state),
|
||
"initialized",
|
||
"a missing `lake` must fall back to a live server and re-attach \
|
||
the buffer that was already open"
|
||
);
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.contains("lean4"),
|
||
"and it says so on the status line; saw {status:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc27_the_latch_is_one_shot_and_does_not_re_arm() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lake");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
let after_first: String = eval(&state, "return pmacs.lsp.config.lean4.command");
|
||
assert_eq!(after_first, fake_lsp_path(), "the fallback fired once");
|
||
|
||
// A user who deliberately sets something else after the fallback must
|
||
// not have it silently replaced by a second firing.
|
||
exec(&state, "pmacs.lsp.config.lean4.command = \"user-choice\"");
|
||
exec(&state, "pmacs.lean._fire_latch(nil, \"a second failure\")");
|
||
assert_eq!(
|
||
eval::<String>(&state, "return pmacs.lsp.config.lean4.command"),
|
||
"user-choice",
|
||
"the latch never re-arms within a session"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lake");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
exec(
|
||
&state,
|
||
r"
|
||
pmacs.lsp.config.lean4.settings = { lean = { verbose = true } }
|
||
pmacs.lsp.config.lean4.init_options = { hasWidgets = false }
|
||
_G.root_before = pmacs.lsp.config.lean4.root
|
||
",
|
||
);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let after: String = eval(
|
||
&state,
|
||
r#"
|
||
local c = pmacs.lsp.config.lean4
|
||
return table.concat({
|
||
tostring(c.settings and c.settings.lean and c.settings.lean.verbose),
|
||
tostring(c.init_options and c.init_options.hasWidgets),
|
||
tostring(c.root == _G.root_before),
|
||
}, "|")
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
after, "true|false|true",
|
||
"settings, init_options and root survive the swap; only \
|
||
command/args change"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() {
|
||
// A stub whose `serve` exits immediately: the server dies before
|
||
// `initialize` completes, which is the failure the latch polls for.
|
||
// `RestartPolicy::OnCrash` would otherwise respawn it forever
|
||
// underneath the latch, with no attempt ceiling.
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let dying = fx.root.join("bin/dying-lake");
|
||
std::fs::create_dir_all(dying.parent().unwrap()).unwrap();
|
||
std::fs::write(
|
||
&dying,
|
||
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'Lake version 9.9.9'; exit 0; fi\nexit 3\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &dying);
|
||
open(&state, &file);
|
||
for _ in 0..60 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
if attached_state(&state) == "initialized" {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// The load-bearing assertion: the buffer ends up on a LIVE server.
|
||
assert_eq!(
|
||
attached_state(&state),
|
||
"initialized",
|
||
"the failing server is stopped and the buffer re-attached to the \
|
||
fallback — not left terminal"
|
||
);
|
||
// And the dead one really is stopped, so nothing is respawning it.
|
||
let dying_still_running: bool = eval(
|
||
&state,
|
||
r#"
|
||
local live = tostring(pmacs.lsp.active_attachment().server)
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) ~= live then
|
||
local k = s.state and s.state.kind
|
||
if k ~= "stopped" and k ~= "crashed" then return true end
|
||
end
|
||
end
|
||
return false
|
||
"#,
|
||
);
|
||
assert!(
|
||
!dying_still_running,
|
||
"the failing server is not respawning underneath the latch"
|
||
);
|
||
assert_ne!(attached_sid(&state), "none");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 36a — attribution (COHERENCE §9 / §1.2).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc36a_latch_leaves_a_status_line_trace() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lake");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.contains("lean4") && status.contains("falling back"),
|
||
"the fallback names the language and says it fell back; saw {status:?}"
|
||
);
|
||
// The channel assertion is the point (COHERENCE §1.2): a report made
|
||
// only through `pmacs.error` — undefined in production — would leave
|
||
// this empty while the fallback itself still worked, so the user
|
||
// would silently be on a different server than they configured.
|
||
assert!(!status.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn acc36a_probe_carries_a_lean_owned_process_label() {
|
||
// `ProcessSpec.label` is the only identity a process has, and it is
|
||
// what `pmacs.process.list` renders. Asserted on the spec the module
|
||
// builds rather than on a live `lake`, which CI does not have.
|
||
let fx = Fixture::new();
|
||
let state = editor(&fx);
|
||
let src = std::fs::read_to_string(
|
||
Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/runtime/lean.lua"),
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
src.contains("label = \"lean:lake-version-probe\""),
|
||
"the probe process is attributed to Lean by label"
|
||
);
|
||
// And it is genuinely lazy: no probe without an attachment.
|
||
let procs: i64 = eval(&state, "return #pmacs.process.list()");
|
||
assert_eq!(procs, 0, "configuring Lean does not start the probe");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 37 — waitForDiagnostics resolves through the response seam.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a : Nat := 1\n");
|
||
let mut state = editor(&fx);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
exec(
|
||
&state,
|
||
r#"
|
||
_G.settled = "never"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
|
||
_G.settled = tostring(err)
|
||
end)
|
||
"#,
|
||
);
|
||
settle(&mut state);
|
||
|
||
assert_eq!(
|
||
eval::<String>(&state, "return _G.settled"),
|
||
"nil",
|
||
"the reply reaches the callback with no error — this is the \
|
||
Stage 3a response seam carrying its first production caller"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 29, Lean's side — `$/lean/fileProgress` reaches the module.
|
||
//
|
||
// Driven end-to-end through the real drain: the fake server's
|
||
// `leanprogress` mode emits the notification on didOpen. Calling the
|
||
// handler directly would pin nothing about the wiring, which is the only
|
||
// part that can break.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn file_progress_notification_is_recorded_for_its_document() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
"pmacs.lsp.config.lean4.env = { PMACS_FAKE_LSP_MODE = \"leanprogress\" }",
|
||
);
|
||
|
||
// Nothing recorded before the server speaks — so the assertion below
|
||
// cannot pass on a pre-populated table.
|
||
let before: i64 = eval(
|
||
&state,
|
||
"local n = 0 for _ in pairs(pmacs.lean.file_progress) do n = n + 1 end return n",
|
||
);
|
||
assert_eq!(before, 0);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let uri: String = eval(
|
||
&state,
|
||
r#"
|
||
for k, v in pairs(pmacs.lean.file_progress) do
|
||
if type(v) == "table" and v[1] and v[1].range then return k end
|
||
end
|
||
return "none"
|
||
"#,
|
||
);
|
||
assert!(
|
||
uri.starts_with("file://") && uri.ends_with("A.lean"),
|
||
"the subscriber recorded the processing ranges under the \
|
||
document uri; saw {uri:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Q#LN20 in the Lean resolver — a symlinked open reuses one server.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn lean_root_is_canonical_so_a_symlinked_open_reuses_one_server() {
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let real = fx.write("pkg/A.lean", "def a := 1\n");
|
||
std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap();
|
||
let linked = fx.dir("linkpkg").join("A.lean");
|
||
|
||
let mut state = editor(&fx);
|
||
open(&state, &real);
|
||
settle(&mut state);
|
||
assert_eq!(rows(&state).len(), 1, "the real path spawns one server");
|
||
|
||
open(&state, &linked);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
rows(&state).len(),
|
||
1,
|
||
"the symlinked path reuses it — the resolver canonicalizes, so \
|
||
both spellings produce the same affinity key"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round-2 review findings. Each of these fails against the code as it
|
||
// stood at cdaea66, where the focused suite was already 20/20 — the
|
||
// lifecycle defects were invisible to it.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Tick for at least `ms`, so a 500ms restart backoff actually elapses.
|
||
/// The round-2 defect was invisible precisely because the suite stopped
|
||
/// ticking as soon as the fallback initialized, ~300ms in.
|
||
fn tick_for(state: &mut EditorState, ms: u64) {
|
||
let deadline = std::time::Instant::now() + Duration::from_millis(ms);
|
||
while std::time::Instant::now() < deadline {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn r2_crashed_primary_does_not_respawn_underneath_the_fallback() {
|
||
// The crash schedules `next_restart_at`; `maybe_restart` fires after
|
||
// the 500ms backoff with no attempt ceiling. Skipping the retire
|
||
// call (round 2) left that armed, so the broken command kept
|
||
// respawning under the live fallback — forever, unobserved.
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let dying = fx.root.join("bin/dying-lake");
|
||
std::fs::create_dir_all(dying.parent().unwrap()).unwrap();
|
||
std::fs::write(&dying, "#!/bin/sh\nexit 3\n").unwrap();
|
||
std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &dying);
|
||
open(&state, &file);
|
||
// Well past one backoff.
|
||
tick_for(&mut state, 1400);
|
||
|
||
// **`attempt`, not liveness.** A respawning server spends most of
|
||
// its life in `crashed` waiting out the backoff, so "no live
|
||
// non-fallback server" is satisfied while it loops forever — that
|
||
// weaker assertion passed against the round-2 code and caught
|
||
// nothing. `attempt` increments on every spawn, so it counts the
|
||
// respawns directly. A retired server is absent from the list
|
||
// entirely (`forget` removes the client); one left with
|
||
// `next_restart_at` armed climbs past 1.
|
||
let worst_attempt: i64 = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
local live = rec and tostring(rec.server) or ""
|
||
local worst = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) ~= live then
|
||
local a = s.attempt or 0
|
||
if a > worst then worst = a end
|
||
end
|
||
end
|
||
return worst
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
worst_attempt, 0,
|
||
"the retired primary is gone from the manager, not respawning after the backoff (attempt > 0 means it is still there; > 1 means it respawned)"
|
||
);
|
||
assert_eq!(
|
||
attached_state(&state),
|
||
"initialized",
|
||
"and the buffer is on the live fallback"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r2_reattach_targets_the_originating_buffer_not_whatever_is_active() {
|
||
// `_attach_buffer` is an active-buffer-only seam and the latch's
|
||
// verdict arrives asynchronously. Round 2 accepted "some attachment
|
||
// now names a different server", which an unrelated Rust buffer
|
||
// satisfies — clearing the retry and stranding the Lean buffer.
|
||
//
|
||
// **Driven through the PROBE**, not through a missing executable: a
|
||
// missing command fails synchronously inside `buffer.after-load`,
|
||
// where the Lean buffer is still active and the rebuild happens
|
||
// inline, so the race cannot occur and the test proves nothing. The
|
||
// probe's verdict lands on a later tick, which is the whole point.
|
||
// The stub's `serve` sleeps, so only the probe can trigger anything.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
fx.write("pkg/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let lean_file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let rust_file = fx.write("pkg/src/main.rs", "fn main() {}\n");
|
||
let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0");
|
||
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &old_lake);
|
||
// A working Rust server, so switching away lands on a real
|
||
// attachment with a different server id — the decoy.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
"pmacs.lsp.config.rust = {{ command = \"{}\" }}",
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
|
||
open(&state, &lean_file);
|
||
exec(&state, "_G.lean_buf = pmacs.window.buffer()");
|
||
// Switch away before the probe's verdict can land.
|
||
open(&state, &rust_file);
|
||
tick_for(&mut state, 500);
|
||
|
||
// Come back with a buffer SWITCH, not `find_or_open`. Re-opening
|
||
// fires `buffer.after-load`, which re-runs lsp.lua's own attach and
|
||
// would repair the record no matter what the latch did.
|
||
exec(&state, "pmacs.window.switch_buffer(_G.lean_buf)");
|
||
tick_for(&mut state, 400);
|
||
|
||
let lang: String = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
return rec and tostring(rec.language) or "none"
|
||
"#,
|
||
);
|
||
assert_eq!(lang, "lean4", "we are back on the Lean buffer");
|
||
|
||
// The observable that discriminates: WHICH command the Lean buffer's
|
||
// server is running. A retry cleared by the decoy leaves it on the
|
||
// original `lake` stub.
|
||
let cmd: String = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.command)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
cmd,
|
||
fake_lsp_path(),
|
||
"the ORIGINATING Lean buffer ends up on the fallback — a decoy \
|
||
Rust attachment must not satisfy the retry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r2_a_failing_fallback_is_reported_once_and_does_not_retry_forever() {
|
||
// Acceptance 27 promises a second failure surfaces rather than
|
||
// loops. Round 2 retried `_attach_buffer` every tick with nothing
|
||
// reported.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent_primary = fx.dir("bin/no-such-lake");
|
||
let absent_fallback = fx.dir("bin/no-such-lean");
|
||
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{ "serve" }}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&absent_primary),
|
||
lua_str(&absent_fallback)
|
||
),
|
||
);
|
||
|
||
open(&state, &file);
|
||
tick_for(&mut state, 300);
|
||
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.contains("did not start either"),
|
||
"a failing fallback surfaces rather than retrying silently; saw \
|
||
{status:?}"
|
||
);
|
||
// And the repair was ATTEMPTED and recorded, so it is bounded rather
|
||
// than spinning. Asserting on a field that no longer exists would
|
||
// read as nil and pass for nothing — the vacuity shape this branch
|
||
// keeps producing, so the assertion is on a positive count.
|
||
let attempted: i64 = eval(
|
||
&state,
|
||
"local n = 0 for _ in pairs(pmacs.lean._probe.repaired) do n = n + 1 end return n",
|
||
);
|
||
assert_eq!(
|
||
attempted, 1,
|
||
"exactly one repair attempt was made and recorded, so a failing \
|
||
fallback cannot retry every tick forever"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r2_a_working_wrapper_is_not_version_probed_as_lake() {
|
||
// `version_below_3_1` encodes LAKE's output contract. Applying it to
|
||
// an arbitrary wrapper is a category error: a working wrapper
|
||
// reporting its own "wrapper 1.0" would be replaced despite its
|
||
// server initializing fine.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
// Named something other than `lake`, reporting a sub-3.1 version,
|
||
// but which serves fine.
|
||
let wrapper = fx.lake_stub("bin/my-lean-wrapper", "wrapper 1.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &wrapper);
|
||
|
||
open(&state, &file);
|
||
tick_for(&mut state, 400);
|
||
|
||
let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command");
|
||
assert_eq!(
|
||
cmd,
|
||
wrapper.display().to_string(),
|
||
"a wrapper's own version string is not Lake's; the version probe \
|
||
must not run against it"
|
||
);
|
||
let latched: bool = eval(&state, "return pmacs.lean._probe.latched");
|
||
assert!(!latched, "and the latch stayed disarmed");
|
||
}
|
||
|
||
#[test]
|
||
fn r2_an_unconfigured_lean_server_is_disabled_not_failed() {
|
||
// Setting `pmacs.lsp.config.lean4 = nil` means "off". Reporting that
|
||
// `nil` could not start is a false alarm, and latching poisons the
|
||
// session so a later configuration can never take effect.
|
||
let fx = Fixture::new();
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
exec(&state, "pmacs.lsp.config.lean4 = nil");
|
||
exec(&state, "pmacs.editor.set_status(\"\")");
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
assert_eq!(
|
||
state.core.borrow().status.clone(),
|
||
"",
|
||
"an unconfigured Lean server reports nothing — it is disabled"
|
||
);
|
||
let latched: bool = eval(&state, "return pmacs.lean._probe.latched");
|
||
assert!(
|
||
!latched,
|
||
"and the session is not poisoned: a later config must still work"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round-3 review findings — asynchronous correlation.
|
||
//
|
||
// Both fail against 3377db0, where the suite was 25/25.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
impl Fixture {
|
||
/// A `lake` whose `serve` really works (it execs the fake LSP) but
|
||
/// whose `--version` answers slowly with an old version. This is the
|
||
/// ordering the previous fixtures could not produce: the primary
|
||
/// INITIALIZES before the version verdict arrives.
|
||
fn slow_version_lake(&self, rel: &str, server: &str, version_line: &str) -> PathBuf {
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
let path = self.root.join(rel);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
format!(
|
||
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n sleep 0.6\n echo '{version_line}'\n exit 0\nfi\nexec '{server}'\n"
|
||
),
|
||
)
|
||
.unwrap();
|
||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
path
|
||
}
|
||
}
|
||
|
||
/// The command backing the active buffer's attached server.
|
||
fn attached_command(state: &EditorState) -> String {
|
||
eval(
|
||
state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.command)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn r3_a_late_version_verdict_still_retires_an_initialized_primary() {
|
||
// `probe.watching` is cleared the moment the server initializes. A
|
||
// verdict arriving after that used to call `fire_latch(nil)`, which
|
||
// retires nothing — `_attach_buffer` then returns the still-live
|
||
// primary and the retry calls it success. Status and config would
|
||
// say "fell back" while the buffer stayed put.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let lake = fx.slow_version_lake("bin/lake", &fake_lsp_path(), "Lake version 3.0.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &lake);
|
||
|
||
open(&state, &file);
|
||
// Let the primary initialize first — the ordering that matters.
|
||
tick_for(&mut state, 300);
|
||
assert_eq!(
|
||
attached_state(&state),
|
||
"initialized",
|
||
"precondition: the primary really did come up before the verdict"
|
||
);
|
||
assert_eq!(
|
||
attached_command(&state),
|
||
lake.display().to_string(),
|
||
"precondition: and the buffer is on it"
|
||
);
|
||
|
||
// Now let the slow `--version` land and the fallback complete.
|
||
tick_for(&mut state, 1200);
|
||
|
||
assert_eq!(
|
||
attached_command(&state),
|
||
fake_lsp_path(),
|
||
"a late version verdict must actually move the buffer to the \
|
||
fallback, not just rewrite the config and claim it did"
|
||
);
|
||
// And the retired primary is not left running or respawning.
|
||
let stale: i64 = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
local live = rec and tostring(rec.server) or ""
|
||
local n = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) ~= live then
|
||
local k = s.state and s.state.kind
|
||
if k ~= "stopped" and k ~= "crashed" then n = n + 1 end
|
||
end
|
||
end
|
||
return n
|
||
"#,
|
||
);
|
||
assert_eq!(stale, 0, "the initialized primary was retired, not left up");
|
||
}
|
||
|
||
#[test]
|
||
fn r3_a_second_lean_buffer_does_not_steal_the_rebuild_target() {
|
||
// `buf_key` was written on every Lean `buffer.after-load`, so a
|
||
// second Lean file opened before the verdict became the rebuild
|
||
// target while the latch still watched the FIRST buffer's server.
|
||
//
|
||
// Both files live in the SAME Lake package, so they share one server
|
||
// and one root — which is what makes the mis-targeting observable as
|
||
// a stranded buffer rather than as two independent servers.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let first = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let second = fx.write("pkg/B.lean", "def b := 2\n");
|
||
let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &lake);
|
||
|
||
open(&state, &first);
|
||
exec(&state, "_G.first_buf = pmacs.window.buffer()");
|
||
// A second Lean buffer, opened before the probe's verdict lands.
|
||
open(&state, &second);
|
||
tick_for(&mut state, 500);
|
||
|
||
// The armed target must still be the FIRST buffer.
|
||
let target_is_first: bool = eval(
|
||
&state,
|
||
"return pmacs.lean._probe.buf_key == tostring(_G.first_buf)",
|
||
);
|
||
assert!(
|
||
target_is_first,
|
||
"the rebuild target is captured once, when the latch arms — a \
|
||
later Lean buffer must not silently become the target"
|
||
);
|
||
|
||
// And the first buffer really does end up on the fallback.
|
||
exec(&state, "pmacs.window.switch_buffer(_G.first_buf)");
|
||
tick_for(&mut state, 600);
|
||
assert_eq!(
|
||
attached_command(&state),
|
||
fake_lsp_path(),
|
||
"the originating buffer is the one repaired"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r3_a_failing_wrapper_is_named_truthfully_not_as_lake_serve() {
|
||
// The failure latch is command-agnostic, so its message must be too.
|
||
// Telling a user that `lake serve` failed when they configured
|
||
// `my-lean-wrapper` sends them to debug the wrong thing.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/my-lean-wrapper");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.contains("my-lean-wrapper"),
|
||
"the status names the command the user actually configured; saw \
|
||
{status:?}"
|
||
);
|
||
assert!(
|
||
!status.contains("lake serve"),
|
||
"and does not attribute the failure to `lake serve`; saw {status:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round-4 review — the config swap is GLOBAL, so one repaired buffer is
|
||
// not a fallback. Both fail against 73587b0.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn r4_every_open_lean_buffer_is_repaired_not_just_the_armed_one() {
|
||
// `pmacs.lsp.config.lean4` is a single entry; swapping its command
|
||
// invalidates every buffer attached to the old one. Round 3 repaired
|
||
// exactly `probe.buf_key` and cleared the retry, leaving every other
|
||
// open Lean buffer on the retired server while status and config
|
||
// both said "fell back".
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let first = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let second = fx.write("pkg/B.lean", "def b := 2\n");
|
||
let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &lake);
|
||
|
||
open(&state, &first);
|
||
exec(&state, "_G.first_buf = pmacs.window.buffer()");
|
||
open(&state, &second);
|
||
exec(&state, "_G.second_buf = pmacs.window.buffer()");
|
||
tick_for(&mut state, 700);
|
||
|
||
// The armed (first) buffer.
|
||
exec(&state, "pmacs.window.switch_buffer(_G.first_buf)");
|
||
tick_for(&mut state, 500);
|
||
assert_eq!(
|
||
attached_command(&state),
|
||
fake_lsp_path(),
|
||
"the armed buffer is repaired"
|
||
);
|
||
|
||
// And the OTHER one, which round 3 stranded.
|
||
exec(&state, "pmacs.window.switch_buffer(_G.second_buf)");
|
||
tick_for(&mut state, 500);
|
||
assert_eq!(
|
||
attached_command(&state),
|
||
fake_lsp_path(),
|
||
"every open Lean buffer ends up on the fallback — repairing only \
|
||
the armed target leaves this one on the retired server"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r4_a_second_project_roots_server_is_also_retired() {
|
||
// Q#LN15 gives one server per project root, so a swap can invalidate
|
||
// several. `probe.primary` names only the first; retiring only that
|
||
// leaves the second root's server live on a command the config no
|
||
// longer names.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("one", "v4.9.0\n");
|
||
fx.toolchain("two", "v4.9.0\n");
|
||
let a = fx.write("one/A.lean", "def a := 1\n");
|
||
let b = fx.write("two/B.lean", "def b := 2\n");
|
||
let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &lake);
|
||
|
||
open(&state, &a);
|
||
open(&state, &b);
|
||
// Two roots, two servers, before any verdict lands.
|
||
let before: i64 = eval(&state, "return #pmacs.lsp.list()");
|
||
assert_eq!(before, 2, "precondition: one server per root");
|
||
|
||
tick_for(&mut state, 900);
|
||
|
||
// No server may still be running the retired command.
|
||
let stale_live: i64 = eval(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
local n = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.command) == "{}" then
|
||
local k = s.state and s.state.kind
|
||
if k ~= "stopped" and k ~= "crashed" then n = n + 1 end
|
||
end
|
||
end
|
||
return n
|
||
"#,
|
||
lua_str(&lake)
|
||
),
|
||
);
|
||
assert_eq!(
|
||
stale_live, 0,
|
||
"every Lean server spawned from the old command is retired, not \
|
||
just the one the probe happened to name"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r4_attribution_names_the_exact_command_and_its_arguments() {
|
||
// Round 3 implemented argument-inclusive attribution but pinned only
|
||
// "contains my-lean-wrapper" and "does not contain lake serve" — a
|
||
// mutation dropping every argument still passed.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/my-lean-wrapper");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
exec(
|
||
&state,
|
||
"pmacs.lsp.config.lean4.args = { \"serve\", \"--quiet\" }",
|
||
);
|
||
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
let status = state.core.borrow().status.clone();
|
||
let expected = format!("`{} serve --quiet`", absent.display());
|
||
assert!(
|
||
status.contains(&expected),
|
||
"the status names the exact configured command AND its arguments;\n \
|
||
want substring: {expected}\n saw: {status:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round-5 review. All fail against 7c37bdc.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn r5_a_fallback_that_dies_after_spawning_is_bounded_and_reported() {
|
||
// The once-per-buffer guard bounds calls to `_attach_buffer`, not
|
||
// the server it produced. `ensure_server` never forwards
|
||
// `cfg.restart`, so the fallback inherits `OnCrash` and a binary
|
||
// that exits before `initialize` is respawned forever — silently,
|
||
// because `latched` has already disabled the primary's poll. The
|
||
// prior failing-fallback test used a NONEXISTENT executable, which
|
||
// only exercises synchronous ENOENT.
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent_primary = fx.dir("bin/no-such-lake");
|
||
let dying_fallback = fx.root.join("bin/dying-lean");
|
||
std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap();
|
||
std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap();
|
||
std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{ "serve" }}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&absent_primary),
|
||
lua_str(&dying_fallback)
|
||
),
|
||
);
|
||
|
||
open(&state, &file);
|
||
tick_for(&mut state, 1600);
|
||
|
||
// Nothing may be respawning: `attempt` counts spawns per server.
|
||
let worst_attempt: i64 = eval(
|
||
&state,
|
||
r"
|
||
local worst = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
local a = s.attempt or 0
|
||
if a > worst then worst = a end
|
||
end
|
||
return worst
|
||
",
|
||
);
|
||
assert!(
|
||
worst_attempt <= 1,
|
||
"a dying fallback must not be respawned indefinitely; saw \
|
||
attempt {worst_attempt}"
|
||
);
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.contains("did not stay up") || status.contains("did not start"),
|
||
"and the second failure is reported; saw {status:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r5_a_user_spawned_lean_server_is_not_retired_by_the_fallback() {
|
||
// Language id AND label are public caller-supplied values. Even a
|
||
// user server that deliberately collides with the automatic path's
|
||
// `default-lean4` display label is not derived from
|
||
// `pmacs.lsp.config.lean4` and must not be stopped.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lake");
|
||
let mut state = editor(&fx);
|
||
with_fallback(&state, &absent);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
_G.mine = pmacs.lsp.spawn({{
|
||
label = "default-lean4",
|
||
language_id = "lean4",
|
||
command = "{}",
|
||
args = {{}},
|
||
}})
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
|
||
open(&state, &file);
|
||
tick_for(&mut state, 600);
|
||
|
||
let mine_alive: bool = eval(
|
||
&state,
|
||
r#"
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(_G.mine) then
|
||
local k = s.state and s.state.kind
|
||
return k ~= "stopped" and k ~= "crashed"
|
||
end
|
||
end
|
||
return false
|
||
"#,
|
||
);
|
||
assert!(
|
||
mine_alive,
|
||
"a user-spawned Lean server survives a config-driven fallback — \
|
||
it was never derived from that config"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r5_no_swap_means_no_repair_attempts() {
|
||
// When the config already names the fallback, `swap_to_fallback`
|
||
// returns false and `fire_latch` returns early — but `latched` is
|
||
// true, so a repair gated on `latched` retried the UNCHANGED
|
||
// configuration and reported it as a fallback failure.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent = fx.dir("bin/no-such-lean");
|
||
let mut state = editor(&fx);
|
||
// Config and fallback are the SAME missing command, so no swap is
|
||
// possible.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{}}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&absent),
|
||
lua_str(&absent)
|
||
),
|
||
);
|
||
|
||
open(&state, &file);
|
||
tick_for(&mut state, 400);
|
||
|
||
let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts");
|
||
assert_eq!(
|
||
attempts, 0,
|
||
"no swap happened, so there is nothing to apply and no repair \
|
||
should be attempted"
|
||
);
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
!status.contains("falling back"),
|
||
"and nothing claims a fallback occurred; saw {status:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r5_repair_is_attempted_at_most_once_per_buffer_by_count() {
|
||
// Counting keys in the `repaired` table cannot distinguish
|
||
// "once per buffer" from "every tick for one buffer" — the
|
||
// cardinality stays 1 either way. Count the ATTEMPTS.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let absent_primary = fx.dir("bin/no-such-lake");
|
||
let absent_fallback = fx.dir("bin/no-such-lean");
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{ "serve" }}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&absent_primary),
|
||
lua_str(&absent_fallback)
|
||
),
|
||
);
|
||
|
||
open(&state, &file);
|
||
// Many ticks; a per-tick retry would climb without bound.
|
||
tick_for(&mut state, 900);
|
||
|
||
let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts");
|
||
assert_eq!(
|
||
attempts, 1,
|
||
"exactly one repair attempt across many ticks for one buffer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r5_a_dead_attachment_is_never_handed_to_a_command() {
|
||
// Buffers live in other frontends get no `buffer.after-switch` here,
|
||
// so an eager sweep keyed on the ambient active buffer cannot reach
|
||
// them. Healing at the point of USE is frontend-agnostic:
|
||
// `attached_for_active` must not return a record whose server is
|
||
// gone.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
// A working primary, so we get a live attachment first.
|
||
exec(
|
||
&state,
|
||
&format!("pmacs.lsp.config.lean4.command = \"{}\"", fake_lsp_path()),
|
||
);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
let first: String = attached_sid(&state);
|
||
assert_ne!(first, "none", "precondition: attached");
|
||
|
||
// Retire it out from under the buffer, as the latch does globally,
|
||
// WITHOUT any switch or repair tick.
|
||
exec(
|
||
&state,
|
||
r"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
pcall(pmacs.lsp.stop, rec.server)
|
||
",
|
||
);
|
||
for _ in 0..40 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
|
||
// Now a command resolves its attachment. It must not get the dead
|
||
// one; it must rebuild.
|
||
// `attachment_for_request` is deliberately non-attaching, so a dead
|
||
// record must read as "no attachment" rather than being handed over.
|
||
let for_request: String = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.attachment_for_request()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.state and s.state.kind)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
for_request, "none",
|
||
"a non-attaching resolve must not hand back a dead server"
|
||
);
|
||
|
||
// And the attaching path rebuilds rather than returning the corpse.
|
||
let rebuilt: String = eval(
|
||
&state,
|
||
r#"
|
||
pmacs.lsp._attach_buffer()
|
||
local rec = pmacs.lsp.active_attachment()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.state and s.state.kind)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
);
|
||
assert!(
|
||
rebuilt != "stopped" && rebuilt != "crashed" && rebuilt != "gone" && rebuilt != "none",
|
||
"the attaching path rebuilds against a live server; saw \
|
||
{rebuilt:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round-6 review. Each is a direct counterexample against 19f48d4.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn r6_the_shipped_lean_command_rebuilds_a_dead_attachment() {
|
||
// The round-5 test called `attachment_for_request` and
|
||
// `_attach_buffer` directly, while the shipped Lean command read the
|
||
// raw `active_attachment` and still handed its request to a stopped
|
||
// server. Drive the production command this time.
|
||
let fx = Fixture::new();
|
||
fx.toolchain("pkg", "v4.9.0\n");
|
||
let file = fx.write("pkg/A.lean", "def a := 1\n");
|
||
let mut state = editor(&fx);
|
||
open(&state, &file);
|
||
settle(&mut state);
|
||
|
||
exec(
|
||
&state,
|
||
r"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
assert(rec)
|
||
pmacs.lsp.stop(rec.server)
|
||
",
|
||
);
|
||
tick_for(&mut state, 200);
|
||
|
||
exec(
|
||
&state,
|
||
r#"pmacs.command.invoke("lean.wait-for-diagnostics")"#,
|
||
);
|
||
let kind: String = eval(
|
||
&state,
|
||
r#"
|
||
local rec = pmacs.lsp.active_attachment()
|
||
if not rec then return "none" end
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if tostring(s.id) == tostring(rec.server) then
|
||
return tostring(s.state and s.state.kind)
|
||
end
|
||
end
|
||
return "gone"
|
||
"#,
|
||
);
|
||
assert!(
|
||
kind != "stopped" && kind != "crashed" && kind != "gone" && kind != "none",
|
||
"the shipped command must resolve through the command-safe \
|
||
attachment path; saw {kind:?}"
|
||
);
|
||
tick_for(&mut state, 500);
|
||
let status = state.core.borrow().status.clone();
|
||
assert_eq!(
|
||
status, "lean: elaboration complete",
|
||
"the rebuilt command path must deliver the request, not merely \
|
||
replace the attachment"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r6_every_spawned_fallback_server_is_bounded() {
|
||
// A scalar fallback watch covers only one Q#LN15 root. The second
|
||
// server can also be created directly by lsp.lua's after-load path,
|
||
// bypassing `repair_active_if_stale` entirely.
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
|
||
let fx = Fixture::new();
|
||
fx.toolchain("one", "v4.9.0\n");
|
||
fx.toolchain("two", "v4.9.0\n");
|
||
let first = fx.write("one/A.lean", "def a := 1\n");
|
||
let second = fx.write("two/B.lean", "def b := 2\n");
|
||
let absent_primary = fx.dir("bin/no-such-lake");
|
||
let dying_fallback = fx.root.join("bin/dying-lean");
|
||
std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap();
|
||
std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap();
|
||
std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{ "serve" }}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&absent_primary),
|
||
lua_str(&dying_fallback)
|
||
),
|
||
);
|
||
|
||
open(&state, &first);
|
||
open(&state, &second);
|
||
tick_for(&mut state, 1600);
|
||
|
||
let worst_attempt: i64 = eval(
|
||
&state,
|
||
r"
|
||
local worst = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if s.language_id == 'lean4' and (s.attempt or 0) > worst then
|
||
worst = s.attempt
|
||
end
|
||
end
|
||
return worst
|
||
",
|
||
);
|
||
assert!(
|
||
worst_attempt <= 1,
|
||
"every fallback server must be bounded; an unwatched root \
|
||
reached attempt {worst_attempt}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r6_point_of_use_healing_does_not_duplicate_a_restarting_server() {
|
||
// A crashed OnCrash server still has `next_restart_at` armed.
|
||
// Spawning a fresh id beside it produces two same-root servers when
|
||
// the old one restarts. Use Rust so this pins the general lsp.lua
|
||
// seam independently of Lean's fallback lifecycle.
|
||
let fx = Fixture::new();
|
||
let file = fx.write("A.rs", "fn main() {}\n");
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.rust = {{
|
||
command = "{}",
|
||
args = {{}},
|
||
env = {{ PMACS_FAKE_LSP_MODE = "crash" }},
|
||
}}
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
open(&state, &file);
|
||
|
||
let mut crashed = false;
|
||
for _ in 0..100 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
crashed = eval(
|
||
&state,
|
||
r#"
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if s.language_id == "rust"
|
||
and s.state and s.state.kind == "crashed" then
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
"#,
|
||
);
|
||
if crashed {
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(5));
|
||
}
|
||
assert!(crashed, "precondition: the attached server crashed");
|
||
|
||
exec(&state, "pmacs.lsp.hover_at_cursor()");
|
||
let rust_servers: i64 = eval(
|
||
&state,
|
||
r#"
|
||
local n = 0
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if s.language_id == "rust" then n = n + 1 end
|
||
end
|
||
return n
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
rust_servers, 1,
|
||
"healing must cancel the old id's armed restart before spawning \
|
||
its replacement"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn r6_no_swap_retires_only_the_failed_root() {
|
||
// When config already equals the fallback, no shared config changed.
|
||
// One root's failure must not globally retire another root's healthy
|
||
// instance of the same cwd-sensitive command.
|
||
use std::os::unix::fs::PermissionsExt as _;
|
||
|
||
let fx = Fixture::new();
|
||
fx.toolchain("bad", "v4.9.0\n");
|
||
fx.toolchain("good", "v4.9.0\n");
|
||
let bad = fx.write("bad/A.lean", "def a := 1\n");
|
||
let good = fx.write("good/B.lean", "def b := 2\n");
|
||
let wrapper = fx.root.join("bin/root-sensitive-lean");
|
||
std::fs::create_dir_all(wrapper.parent().unwrap()).unwrap();
|
||
std::fs::write(
|
||
&wrapper,
|
||
format!(
|
||
"#!/bin/sh\ncase \"$PWD\" in */bad) exit 4;; esac\nexec \"{}\"\n",
|
||
fake_lsp_path()
|
||
),
|
||
)
|
||
.unwrap();
|
||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||
|
||
let mut state = editor(&fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.lean4.command = "{}"
|
||
pmacs.lsp.config.lean4.args = {{}}
|
||
pmacs.lean._fallback = {{ command = "{}", args = {{}} }}
|
||
"#,
|
||
lua_str(&wrapper),
|
||
lua_str(&wrapper)
|
||
),
|
||
);
|
||
open(&state, &bad);
|
||
open(&state, &good);
|
||
tick_for(&mut state, 700);
|
||
|
||
let good_alive: bool = eval(
|
||
&state,
|
||
r#"
|
||
for _, s in ipairs(pmacs.lsp.list()) do
|
||
if s.cwd and s.cwd:match("/good$") then
|
||
local k = s.state and s.state.kind
|
||
return k ~= "stopped" and k ~= "crashed"
|
||
end
|
||
end
|
||
return false
|
||
"#,
|
||
);
|
||
assert!(
|
||
good_alive,
|
||
"one root's failure must not stop another root when no config \
|
||
swap occurred"
|
||
);
|
||
}
|