fix(lean): make the fallback actually produce a working server

Round 1 review, four P1s. All real; the first two mean the fallback did
not work at all.

**1. The latch swapped the config but never spawned or re-attached.**
Nothing re-fires an attach on a config change and `attach_buffer`
early-returns for a live attachment, so the buffer stayed bound to the
server that had just been stopped. The user got a config edit and no
language server. `fire_latch` now rebuilds through a new
`pmacs.lsp._attach_buffer` export.

Two mechanics had to be right for that rebuild to happen at all:

  * It is **retried on the tick**, because `pmacs.lsp.stop` leaves the
    state `shutting-down`, which `server_is_live` counts as LIVE — an
    inline re-attach early-returns the stale record and the swap is a
    silent no-op.
  * The latch **does not stop an already-terminal server**, and this is
    a substrate bug worked around rather than a style choice.
    `LspManager::stop` on a `Crashed` client takes its not-initialized
    branch, terminates the dead process, and sets `ShuttingDown { ..
    None }` on the premise that "the next exit observation cleans up" —
    but the exit already happened, which is what made it `Crashed`. No
    further event arrives, so the client is stuck in `ShuttingDown`
    forever: `server_is_live` reads it as live so `attach_buffer` never
    rebuilds, and `forget` refuses it for not being terminal. Stopping a
    dead server is what makes it un-replaceable. Named in framing §6; the
    fix belongs in `stop` and changes behavior for every language.

**2. A missing `lake` bypassed probe and latch entirely** — the single
most likely real failure. `ensure_server` swallows a synchronous ENOENT
and returns nil, so there was no attachment, and the hook keyed on
`active_attachment()` returned before arming anything. The hook now keys
on the buffer's LANGUAGE and treats a Lean buffer with no attachment as
the failure itself.

**3. `waitForDiagnostics` omitted `version`.** Lean's
`WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
`src/Lean/Data/Lsp/Extra.lean`); the request is how a client says which
revision it wants. It looked correct only because the fake server echoes
any payload — so the fake server now validates and returns InvalidParams
without it.

**4. The ledger stated the dangerous stacking order** in one sentence
and the correct rule in the next. Fixed to say BEFORE. A safety rule
written twice with opposite senses is worse than not written.

Also (P2): the probe/latch suite now drives the production path —
`buffer.after-load` -> ticks -> probe drain -> latch -> re-attach — with
real executable stubs, and asserts the originally opened buffer ends up
on a LIVE server. Round 1's acceptance 36 asserted every server was
terminal, i.e. pinned the ABSENCE of the fallback it claimed to test.
`M.fallback` is a table so the suite can point it at a working stand-in;
the probe now spawns `cfg.command --version` rather than a hardcoded
`lake`, which is also more correct for a user who configured a wrapper.

`swap_to_fallback`'s `command ~= "lake"` guard is gone: the latch fires
only when the configured server actually failed, one visible fallback
beats no server, and `probe.latched` is what keeps it to exactly one.

Three new bites, all against the committed tree: no re-attach after the
swap -> three latch tests fail; hook keyed on the attachment -> the
missing-`lake` case fails; `waitForDiagnostics` without `version` ->
acc37 fails with the server's InvalidParams.
This commit is contained in:
Levi Neuwirth 2026-07-25 18:03:29 -04:00
parent 914bf3f02f
commit cdaea66203
6 changed files with 510 additions and 118 deletions

View File

@ -142,6 +142,19 @@ end
-- `lake serve` below 3.1.0 starts a server that cannot answer, which is -- `lake serve` below 3.1.0 starts a server that cannot answer, which is
-- worse than failing: `lean4-mode` probes for exactly this and falls -- worse than failing: `lean4-mode` probes for exactly this and falls
-- back to `lean --server`. Parses the leading `x.y` of a version line. -- back to `lean --server`. Parses the leading `x.y` of a version line.
-- State kind for `sid`, or nil if the manager has forgotten it.
local function server_state_kind(sid)
local skey = tostring(sid)
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return nil end
for _, info in ipairs(rows) do
if tostring(info.id) == skey then
return info.state and info.state.kind
end
end
return nil
end
local function version_below_3_1(text) local function version_below_3_1(text)
local major, minor = text:match("(%d+)%.(%d+)") local major, minor = text:match("(%d+)%.(%d+)")
if not major then return false end if not major then return false end
@ -150,15 +163,27 @@ local function version_below_3_1(text)
return major == 3 and minor < 1 return major == 3 and minor < 1
end end
-- What the latch falls back TO. A table rather than a literal so the
-- acceptance suite can point it at a stand-in server and drive the real
-- latch path end to end, instead of asserting on a config mutation that
-- proves nothing about whether a server ever starts.
M.fallback = { command = "lean", args = { "--server" } }
-- Swap `command`/`args` ONLY. A wholesale table replacement would -- Swap `command`/`args` ONLY. A wholesale table replacement would
-- silently discard a user's `env` / `settings` / `init_options` / `root` -- silently discard a user's `env` / `settings` / `init_options` / `root`
-- from `init.lua` at exactly the moment they are least likely to notice. -- from `init.lua` at exactly the moment they are least likely to notice.
local function swap_to_lean_server() --
-- The only guard is idempotence — already-the-fallback means nothing to
-- do. It deliberately does NOT refuse when the command is user-supplied:
-- the latch fires only when the configured Lean server actually failed
-- to start, and one visible fallback attempt beats leaving the user with
-- no server at all. `probe.latched` is what keeps it to exactly one.
local function swap_to_fallback()
local cfg = pmacs.lsp.config.lean4 local cfg = pmacs.lsp.config.lean4
if not cfg then return false end if not cfg then return false end
if cfg.command ~= "lake" then return false end if cfg.command == M.fallback.command then return false end
cfg.command = "lean" cfg.command = M.fallback.command
cfg.args = { "--server" } cfg.args = M.fallback.args
return true return true
end end
@ -172,16 +197,66 @@ end
-- producing a loop the latch cannot see the end of. `pmacs.lsp.stop` -- producing a loop the latch cannot see the end of. `pmacs.lsp.stop`
-- sets `restart = Never` on the way out, which is what disarms it. The -- sets `restart = Never` on the way out, which is what disarms it. The
-- fallback is therefore a FRESH server, not a restart of the old one. -- fallback is therefore a FRESH server, not a restart of the old one.
local try_reattach
local function fire_latch(sid, why) local function fire_latch(sid, why)
if probe.latched then return end if probe.latched then return end
probe.latched = true probe.latched = true
if sid then pcall(pmacs.lsp.stop, sid) end
if swap_to_lean_server() then
report("LSP: lean4 " .. why .. "; falling back to `lean --server`")
else
report("LSP: lean4 " .. why)
end
probe.watching = nil probe.watching = nil
-- **Only stop a server that is not ALREADY terminal**, and this is
-- load-bearing rather than tidy. `LspManager::stop` on a crashed
-- client takes its not-initialized branch: it terminates the
-- (already-dead) process and sets `ShuttingDown { .. None }`, with the
-- comment "the next exit observation cleans up" — but the exit was
-- already observed, which is what made it `Crashed`. No further event
-- arrives, so the client stays in `ShuttingDown` forever:
-- `server_is_live` counts it as LIVE (neither crashed nor stopped) so
-- `attach_buffer` never rebuilds, and `LspManager::forget` refuses it
-- for not being terminal. Stopping a dead server is what makes it
-- un-replaceable. Recorded as a substrate deferral in the framing §6.
if sid then
local kind = server_state_kind(sid)
if kind and kind ~= "crashed" and kind ~= "stopped" then
pcall(pmacs.lsp.stop, sid)
end
end
if not swap_to_fallback() then
report("LSP: lean4 " .. why)
return
end
report("LSP: lean4 " .. why .. "; falling back to `"
.. tostring(M.fallback.command) .. "`")
-- **Spawn the replacement and re-point the buffer at it.** Stopping
-- and rewriting the config is not a fallback on its own: nothing
-- re-fires an attach on a config change, and `attach_buffer`
-- early-returns for a live attachment, so without this the buffer
-- stays bound to the server we just stopped and the user is left with
-- a config edit and no language server. Round 1 shipped exactly that,
-- with an acceptance test that asserted every server was terminal —
-- i.e. that pinned the absence of the fallback it claimed to check.
--
-- **Retried on the tick, not done inline**, and that is not caution:
-- `pmacs.lsp.stop` sends shutdown+exit and the state becomes
-- `shutting-down`, which `server_is_live` counts as LIVE. So an
-- immediate `_attach_buffer` early-returns the stale record and the
-- swap has no effect — the exact silent no-op this whole path exists
-- to avoid. Retrying until the old server actually reaches a terminal
-- state is what makes the rebuild happen.
probe.reattach_from = sid and tostring(sid) or false
try_reattach()
end
-- Returns true once the active Lean buffer is attached to a server that
-- is not the one the latch stopped.
function try_reattach()
if probe.reattach_from == nil then return true end
local ok, rec = pcall(pmacs.lsp._attach_buffer)
if not ok or not rec then return false end
if probe.reattach_from and tostring(rec.server) == probe.reattach_from then
return false
end
probe.reattach_from = nil
return true
end end
local function drain_probe() local function drain_probe()
@ -220,13 +295,17 @@ local function start_probe(root)
if probe.started then return end if probe.started then return end
probe.started = true probe.started = true
local cfg = pmacs.lsp.config.lean4 local cfg = pmacs.lsp.config.lean4
if not cfg or cfg.command ~= "lake" then return end if not cfg or not cfg.command then return end
-- Probe the binary we would actually run, not the literal string
-- "lake": a user pointing `command` at a wrapper or an absolute path
-- should have THAT probed, and a hardcoded name would silently probe
-- something else (or nothing).
local spec = { local spec = {
-- COHERENCE §9: `ProcessSpec.label` is the only identity a process -- COHERENCE §9: `ProcessSpec.label` is the only identity a process
-- carries, and it is what `pmacs.process.list` renders. A user -- carries, and it is what `pmacs.process.list` renders. A user
-- wondering why their editor touched `lake` finds an owner here. -- wondering why their editor touched `lake` finds an owner here.
label = "lean:lake-version-probe", label = "lean:lake-version-probe",
command = "lake", command = cfg.command,
args = { "--version" }, args = { "--version" },
stdin = "null", stdin = "null",
} }
@ -275,12 +354,20 @@ end
-- does not apply. Resolves when the server has finished elaborating. -- does not apply. Resolves when the server has finished elaborating.
-- Awaited through Stage 3a's response seam. -- Awaited through Stage 3a's response seam.
-- --
-- **`version` is required, not optional.** Lean's
-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client
-- says *which* revision of the document it wants elaboration for.
-- Sending only `uri` is a malformed request against a real server; it
-- happened to look fine here because the fake server echoes any
-- payload. Callers pass the attachment's current `version`.
--
-- `fn(err)` is called with nil on success. Registering the one-shot -- `fn(err)` is called with nil on success. Registering the one-shot
-- requires the server to have an attached buffer — see the note on -- requires the server to have an attached buffer — see the note on
-- `pmacs.lsp.on_response`; every caller here comes from an attachment. -- `pmacs.lsp.on_response`; every caller here comes from an attachment.
function M.wait_for_diagnostics(sid, uri, fn) function M.wait_for_diagnostics(sid, uri, version, fn)
local ok, rid = pcall(pmacs.lsp.send_request, sid, local ok, rid = pcall(pmacs.lsp.send_request, sid,
"textDocument/waitForDiagnostics", { uri = uri }) "textDocument/waitForDiagnostics", { uri = uri, version = version })
if not ok then if not ok then
if fn then pcall(fn, tostring(rid)) end if fn then pcall(fn, tostring(rid)) end
return nil return nil
@ -303,7 +390,7 @@ pmacs.command.define {
return return
end end
pmacs.editor.set_status("lean: elaborating…") pmacs.editor.set_status("lean: elaborating…")
M.wait_for_diagnostics(rec.server, rec.uri, function(err) M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
if err then if err then
pmacs.editor.set_status("lean: " .. tostring(err)) pmacs.editor.set_status("lean: " .. tostring(err))
else else
@ -327,27 +414,53 @@ end)
-- Wiring -------------------------------------------------------------- -- Wiring --------------------------------------------------------------
-- Runs after `lsp.lua`'s own `buffer.after-load` subscription, so the -- Runs after `lsp.lua`'s own `buffer.after-load` subscription.
-- attachment already exists. The attachment's `language` IS the Lean --
-- test — no separate major-mode lookup, which would be a second source -- **Keyed on the buffer's LANGUAGE, not on an attachment existing.**
-- of truth for the same question. -- Round 1 keyed on `active_attachment()` and returned early when it was
-- nil — which silently excluded the single most likely real-world
-- failure: `lake` not installed. `ensure_server` pcalls the spawn and
-- returns nil on ENOENT, so `attach_buffer` produces no record at all,
-- so the probe never started and the latch never armed. The case the
-- fallback exists for was the one case it could not see.
pmacs.hook.add("buffer.after-load", function() pmacs.hook.add("buffer.after-load", function()
local rec = pmacs.lsp.active_attachment() local buf = pmacs.window.buffer()
if not rec or rec.language ~= "lean4" then return end if not buf then return end
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
if not ok_lang or lang ~= "lean4" then return end
if not probe.started then if not probe.started then
local path = pmacs.editor.file_path() local path = pmacs.editor.file_path()
start_probe(path and M.root_for(path) or nil) start_probe(path and M.root_for(path) or nil)
end end
-- Watch only the FIRST Lean server: the latch is per session.
if not probe.latched and not probe.saw_initialized local rec = pmacs.lsp.active_attachment()
and probe.watching == nil then if rec and rec.language == "lean4" then
probe.watching = rec.server -- Watch only the FIRST Lean server: the latch is per session.
if not probe.latched and not probe.saw_initialized
and probe.watching == nil then
probe.watching = rec.server
end
return
end
-- No attachment for a Lean buffer means `ensure_server` could not
-- spawn at all — a synchronous ENOENT, already swallowed upstream.
-- That is not something to wait for; it is the failure itself, and
-- the only place it is still observable.
if not probe.latched then
fire_latch(nil, "`" .. tostring(
pmacs.lsp.config.lean4 and pmacs.lsp.config.lean4.command)
.. "` could not be started")
end end
end) end)
pmacs.hook.add("process.after-tick", function() pmacs.hook.add("process.after-tick", function()
drain_probe() drain_probe()
poll_latch() poll_latch()
-- Keep trying until the stopped server is really gone; see the note in
-- `fire_latch`.
if probe.reattach_from ~= nil then try_reattach() end
end) end)
-- Test seam: acceptance drives the latch deterministically rather than -- Test seam: acceptance drives the latch deterministically rather than

View File

@ -893,6 +893,24 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)] return attachments[tostring(buf)]
end end
-- Re-run the attach for the ACTIVE buffer, rebuilding it against the
-- current `pmacs.lsp.config`.
--
-- Exists for the Arc 8 Stage 3b fallback latch (Q#LN7): after that latch
-- stops a server that failed to start and rewrites `config.lean4`,
-- something has to actually spawn the replacement and re-point the
-- buffer at it. Nothing else does — `attach_buffer` early-returns for a
-- live attachment, and no hook re-fires on a config change, so without
-- this the buffer stays bound to the stopped server and the "fallback"
-- is a config edit with no effect.
--
-- Deliberately keyed on the active buffer, matching `attach_buffer`'s
-- own use of `active_buffer_path()`; it is not a general re-attach for
-- arbitrary buffers and must not be used as one.
function pmacs.lsp._attach_buffer()
return attach_buffer(pmacs.window.buffer())
end
-- Arc 4 stage 3: pure modeline projection. This reads the private -- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their -- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes -- own buffer instead of the focused window. It never attaches, flushes

View File

@ -256,12 +256,17 @@ If it does not, stop and repair the remote/fetch configuration.
- Same worktree `../pmacs-lean-stage3`, **branched off - Same worktree `../pmacs-lean-stage3`, **branched off
`lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response `lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response
seam and `pmacs.fs.canonicalize`, so it is strictly sequential and its seam and `pmacs.fs.canonicalize`, so it is strictly sequential.
PR must be retargeted to `main` only after #167 merges. (Kill-ring **Retarget PR #170 to `main` BEFORE merging #167, not after** — the
lesson: retarget stacked child PRs BEFORE merging the parent.) kill-ring lesson exactly. (Round 1 of this ledger entry stated the
reverse in its first sentence and the correct rule in the next; the
review caught it. A safety rule written twice with opposite senses is
worse than not written.)
- Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in
`src/editor.rs`, a `leanprogress` mode on `pmacs_fake_lsp`, and `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`,
`tests/lean4_server_acceptance.rs` (17 tests). No protocol change. a `leanprogress` mode plus `waitForDiagnostics` validation on
`pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (20 tests).
No protocol change.
- **Stage 1's acceptance 12 is half superseded and was rewritten, not - **Stage 1's acceptance 12 is half superseded and was rewritten, not
deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a
Stage-3 front-run; 3b is that stage. What survives is the restraint Stage-3 front-run; 3b is that stage. What survives is the restraint
@ -274,10 +279,29 @@ If it does not, stop and repair the remote/fetch configuration.
an EMPTY `lean-toolchain` (a legitimate marker — existence semantics, an EMPTY `lean-toolchain` (a legitimate marker — existence semantics,
not content). Discriminator is `read`'s SECOND return; decline only on not content). Discriminator is `read`'s SECOND return; decline only on
a non-nil err. Probed on LuaJIT 2.1. a non-nil err. Probed on LuaJIT 2.1.
- Four bites recorded, each against the committed tree: bare `io.open` - Seven bites recorded, each against the committed tree: bare `io.open`
→ 24a fails / 24b passes; require-non-nil → 24b fails / 24a passes; → 24a fails / 24b passes; require-non-nil → 24b fails / 24a passes;
no canonicalization → symlinked open spawns two servers; no stop no canonicalization → symlinked open spawns two servers; no re-attach
before fallback → acc36 fails. after the swap → three latch tests fail; hook keyed on the attachment
→ the missing-`lake` case fails; `waitForDiagnostics` without
`version` → acc37 fails with the server's InvalidParams.
- **SUBSTRATE BUG FOUND, not fixed here (framing §6).**
`LspManager::stop` on an ALREADY-terminal server takes its
not-initialized branch, terminates the dead process and sets
`ShuttingDown { .. None }` on the premise that "the next exit
observation cleans up" — but the exit already happened, which is what
made it `Crashed`. No further event arrives, so the client is stuck in
`ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so
`attach_buffer` never rebuilds, and `forget` refuses it for not being
terminal. **Stopping a dead server is what makes it un-replaceable.**
Lean works around it by checking the state before stopping.
- Round-1 review found four P1s, all real: the latch swapped the config
but never spawned or re-attached (and acc36 *asserted every server was
terminal*, pinning the absence of the fallback); a missing `lake`
bypassed probe and latch entirely because the hook keyed on an
attachment that ENOENT prevents; `waitForDiagnostics` omitted the
`version` Lean requires; and the ledger stated the dangerous stacking
order.
- The probe's non-zero exit is deliberately NOT a fallback trigger — - The probe's non-zero exit is deliberately NOT a fallback trigger —
§2.9's elan shim makes `lake --version` fail where `lake serve` still §2.9's elan shim makes `lake --version` fail where `lake serve` still
works. Only a parseable version below 3.1.0 triggers it; the works. Only a parseable version below 3.1.0 triggers it; the

View File

@ -1543,6 +1543,20 @@ What remains deferred:
which events may be dropped, which is a policy question with which events may be dropped, which is a policy question with
user-visible consequences for diagnostics and progress; Stage 3a states user-visible consequences for diagnostics and progress; Stage 3a states
the seam's contract around the behavior rather than changing it. the seam's contract around the behavior rather than changing it.
- **`LspManager::stop` on an already-terminal server strands it.** The
not-initialized branch terminates the (already-dead) process and sets
`ShuttingDown { shutdown_request_id: None }` on the premise that "the
next exit observation cleans up" — but for a `Crashed` client the exit
has already been observed, which is what produced that state. No
further event arrives, so the client sits in `ShuttingDown`
permanently: `server_is_live` counts it as live (neither crashed nor
stopped), so `attach_buffer` never rebuilds against it, and
`LspManager::forget` refuses it for not being terminal. **Stopping a
dead server is what makes it un-replaceable.** Found implementing
Stage 3b's latch, which works around it by checking the state before
stopping. The fix belongs in `stop` (treat an already-terminal client
as a no-op, or drive it straight to `Stopped`) and changes behavior
for every language, so it does not ride a Lean PR.
- **Forwarding `cfg.restart` through `ensure_server`** — read by - **Forwarding `cfg.restart` through `ensure_server`** — read by
`lua_to_lsp_spec`, never set by the spawn table, so silently dropped on `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on
every auto-attach (found landing #161). Fixing it changes behavior for every auto-attach (found landing #161). Fixing it changes behavior for
@ -1722,9 +1736,13 @@ the blast radius.
channel a user can actually observe; a report added through channel a user can actually observe; a report added through
`pmacs.error` alone must fail this. `pmacs.error` alone must fail this.
- **37.** `textDocument/waitForDiagnostics` resolves through the response seam - **37.** `textDocument/waitForDiagnostics` resolves through the response seam
(Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` (Q#LN16), **carrying both `uri` and `version`** — Lean's
starts successfully a real elaboration completes and diagnostics `WaitForDiagnosticsParams` requires the document version, and a fake
arrive; skipped otherwise, never failed. server that echoes any payload will hide its absence, so the fixture
must reject a request that omits it.
**PATH-and-success-gated live smoke:** if `lake serve` starts
successfully a real elaboration completes and diagnostics arrive;
skipped otherwise, never failed.
These two sections are bulleted with explicit labels rather than These two sections are bulleted with explicit labels rather than
numbered, because the split leaves each stage's criteria non-contiguous numbered, because the split leaves each stage's criteria non-contiguous

View File

@ -1083,6 +1083,39 @@ fn main() {
}); });
write_frame(&mut stdout, &resp); write_frame(&mut stdout, &resp);
} }
("textDocument/waitForDiagnostics", Some(idv)) => {
// Arc 8 Stage 3b: Lean's `WaitForDiagnosticsParams` is
// `{ uri, version }` (v4.9.0
// `src/Lean/Data/Lsp/Extra.lean`). Validated here rather
// than echoed, because the generic echo arm below
// accepts anything — which is exactly how a client
// sending only `uri` shipped looking correct. A client
// that omits `version`, or sends a non-integer, gets an
// InvalidParams error the way a real server would.
let uri_ok = params
.get("uri")
.and_then(serde_json::Value::as_str)
.is_some();
let version_ok = params
.get("version")
.and_then(serde_json::Value::as_i64)
.is_some();
let resp = if uri_ok && version_ok {
serde_json::json!({
"jsonrpc": "2.0", "id": idv, "result": serde_json::Value::Null
})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"error": {
"code": -32602,
"message": "waitForDiagnostics requires { uri, version }"
}
})
};
write_frame(&mut stdout, &resp);
}
(_, Some(idv)) => { (_, Some(idv)) => {
// Generic echo response. // Generic echo response.
let resp = serde_json::json!({ let resp = serde_json::json!({

View File

@ -347,12 +347,85 @@ fn acc26_did_open_carries_the_lean4_language_id() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Acceptance 28 (probe) — the version predicate. // Acceptance 27 / 28 / 35 / 36 — the probe and the fallback latch.
// //
// The parse is unit-tested directly because the spawn path is timing- // **Driven through the production path**, not by calling internals.
// bound; the latch's *effect* is pinned separately below. // 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] #[test]
fn acc28_version_predicate_triggers_only_below_3_1() { fn acc28_version_predicate_triggers_only_below_3_1() {
let fx = Fixture::new(); let fx = Fixture::new();
@ -374,36 +447,156 @@ fn acc28_version_predicate_triggers_only_below_3_1() {
); );
} }
// --------------------------------------------------------------------------- #[test]
// Acceptance 27 / 35 / 36 — the fallback latch. 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] #[test]
fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() { fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() {
let fx = Fixture::new(); let fx = Fixture::new();
let state = editor(&fx); fx.toolchain("pkg", "v4.9.0\n");
// A user's init.lua settings, on the shipped shape. 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( exec(
&state, &state,
r#" r"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lsp.config.lean4.env = { MYVAR = "1" }
pmacs.lsp.config.lean4.settings = { lean = { verbose = true } } pmacs.lsp.config.lean4.settings = { lean = { verbose = true } }
pmacs.lsp.config.lean4.init_options = { hasWidgets = false } pmacs.lsp.config.lean4.init_options = { hasWidgets = false }
_G.root_before = pmacs.lsp.config.lean4.root _G.root_before = pmacs.lsp.config.lean4.root
pmacs.lean._fire_latch(nil, "test") ",
"#,
); );
open(&state, &file);
settle(&mut state);
let after: String = eval( let after: String = eval(
&state, &state,
r#" r#"
local c = pmacs.lsp.config.lean4 local c = pmacs.lsp.config.lean4
return table.concat({ return table.concat({
tostring(c.command),
tostring(c.args and c.args[1]),
tostring(c.env and c.env.MYVAR),
tostring(c.settings and c.settings.lean and c.settings.lean.verbose), tostring(c.settings and c.settings.lean and c.settings.lean.verbose),
tostring(c.init_options and c.init_options.hasWidgets), tostring(c.init_options and c.init_options.hasWidgets),
tostring(c.root == _G.root_before), tostring(c.root == _G.root_before),
@ -411,78 +604,70 @@ fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() {
"#, "#,
); );
assert_eq!( assert_eq!(
after, "lean|--server|1|true|false|true", after, "true|false|true",
"only command/args change; env, settings, init_options and root \ "settings, init_options and root survive the swap; only \
survive the swap" command/args change"
);
}
#[test]
fn acc27_the_latch_is_one_shot_and_does_not_re_arm() {
let fx = Fixture::new();
let state = editor(&fx);
exec(
&state,
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(nil, "first failure")
_G.after_first = pmacs.lsp.config.lean4.command
-- A second failure must not rewrite the command again; if it did,
-- a user who deliberately set something else after the fallback
-- would have it silently replaced.
pmacs.lsp.config.lean4.command = "user-choice"
pmacs.lean._fire_latch(nil, "second failure")
_G.after_second = pmacs.lsp.config.lean4.command
"#,
);
assert_eq!(eval::<String>(&state, "return _G.after_first"), "lean");
assert_eq!(
eval::<String>(&state, "return _G.after_second"),
"user-choice",
"the latch never re-arms within a session"
); );
} }
#[test] #[test]
fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() { 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(); let fx = Fixture::new();
fx.toolchain("pkg", "v4.9.0\n"); fx.toolchain("pkg", "v4.9.0\n");
let file = fx.write("pkg/A.lean", "def a := 1\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); let mut state = editor(&fx);
with_fallback(&state, &dying);
open(&state, &file); open(&state, &file);
settle(&mut state); for _ in 0..60 {
assert_eq!(rows(&state).len(), 1, "precondition: one server is up"); state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(5));
if attached_state(&state) == "initialized" {
break;
}
}
// Fire the latch against the live server, exactly as `poll_latch` // The load-bearing assertion: the buffer ends up on a LIVE server.
// would. `pmacs.lsp.stop` sets `restart = Never` on the way out — assert_eq!(
// which is what prevents `RestartPolicy::OnCrash` from respawning the attached_state(&state),
// broken command underneath the latch, forever, with no attempt cap. "initialized",
exec( "the failing server is stopped and the buffer re-attached to the \
&state, fallback not left terminal"
r#"
pmacs.lsp.config.lean4.command = "lake"
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(pmacs.lsp.list()[1].id, "failed to start")
"#,
); );
settle(&mut state); // And the dead one really is stopped, so nothing is respawning it.
let dying_still_running: bool = eval(
let terminal: bool = eval(
&state, &state,
r#" r#"
local live = tostring(pmacs.lsp.active_attachment().server)
for _, s in ipairs(pmacs.lsp.list()) do for _, s in ipairs(pmacs.lsp.list()) do
local k = s.state and s.state.kind if tostring(s.id) ~= live then
if k ~= "stopped" and k ~= "crashed" then return false end local k = s.state and s.state.kind
if k ~= "stopped" and k ~= "crashed" then return true end
end
end end
return true return false
"#, "#,
); );
assert!( assert!(
terminal, !dying_still_running,
"the failing server is stopped, not left to be respawned under \ "the failing server is not respawning underneath the latch"
the latch"
); );
assert_ne!(attached_sid(&state), "none");
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -492,23 +677,24 @@ fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() {
#[test] #[test]
fn acc36a_latch_leaves_a_status_line_trace() { fn acc36a_latch_leaves_a_status_line_trace() {
let fx = Fixture::new(); let fx = Fixture::new();
let state = editor(&fx); fx.toolchain("pkg", "v4.9.0\n");
exec( let file = fx.write("pkg/A.lean", "def a := 1\n");
&state, let absent = fx.dir("bin/no-such-lake");
r#" let mut state = editor(&fx);
pmacs.lsp.config.lean4.command = "lake" with_fallback(&state, &absent);
pmacs.lsp.config.lean4.args = { "serve" }
pmacs.lean._fire_latch(nil, "`lake serve` failed to start") open(&state, &file);
"#, settle(&mut state);
);
let status = state.core.borrow().status.clone(); let status = state.core.borrow().status.clone();
assert!( assert!(
status.contains("lean4") && status.contains("lean --server"), status.contains("lean4") && status.contains("falling back"),
"the fallback names itself and what it fell back to; saw {status:?}" "the fallback names the language and says it fell back; saw {status:?}"
); );
// The channel assertion is the point (COHERENCE §1.2): a report made // The channel assertion is the point (COHERENCE §1.2): a report made
// only through `pmacs.error` — undefined in production — would leave // only through `pmacs.error` — undefined in production — would leave
// this empty while every other assertion here still passed. // 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()); assert!(!status.is_empty());
} }
@ -550,7 +736,7 @@ fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() {
r#" r#"
_G.settled = "never" _G.settled = "never"
local rec = pmacs.lsp.active_attachment() local rec = pmacs.lsp.active_attachment()
pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, function(err) pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
_G.settled = tostring(err) _G.settled = tostring(err)
end) end)
"#, "#,