//! 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(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 { 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::(&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::(&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" ); }