feat(lsp): notification/response dispatch seams and fs.canonicalize

Arc 8 Stage 3a (framing Q#LN9, Q#LN20). No Lean content: this changes
the event drain every LSP language runs through, and is split from the
Lean server work for the reason Stage 2 was.

**The seams.** `handle_server_requests` handled five `request` methods
and `initialized`, dropping every `notification` and `response` on the
floor. Dropping responses made `pmacs.lsp.send_request` a write-only API
from Lua — the reply was drained and discarded, so nothing outside
Rust's typed stores could consume one. Two new arms route to
`pmacs.lsp.on_notification(method, fn)` (persistent, method-keyed) and
`pmacs.lsp.on_response(sid, request_id, fn)` (one-shot). Both extend the
existing loop rather than opening a second `events_take` caller, which
would steal events from it.

A one-shot is removed **before** invocation, so a raising handler cannot
be re-entered. Every subscriber is `pcall`ed and a raise reports through
`pmacs.editor.set_status` per COHERENCE §1.2 — not `pmacs.error`, which
is defined nowhere in production. The notification list's length is
captured before the walk so a subscriber registering another cannot
extend the list being iterated.

**The purge is driven off `pmacs.lsp.list()`, not off a death event.**
The framing said acceptance 34's second edge was a killed buffer. That
was wrong, and scouting the implementation is what caught it: pmacs
fires exactly five hooks (`buffer.after-edit`, `buffer.after-load`,
`buffer.after-switch`, `frontend.detached`, `process.after-tick`) and
there is no buffer-kill hook at all, so `lsp.lua` never tears an
attachment down and the drain keeps reaching that server. No leak there.

The real leak is a different path with the same root cause. The drain
builds its sid list from `attachments`, and `attach_buffer` drops a sid
from that table the moment `server_is_live` reports false — rebuilding
against a fresh server. So the `crashed` / `stopped` event that should
trigger the purge is precisely the one most likely to go undrained. A
purge wired to that event leaks exactly when it matters.

`pmacs.lsp.list()` enumerates the manager directly and is unaffected by
attachment bookkeeping, so the purge polls it after each drain: a sid
that is absent, terminal, or running a **new generation** settles its
pending one-shots with an error. The generation check uses the `attempt`
field, because a crash-then-restart reuses the sid — without it a
one-shot would sit waiting on a reply the dead generation owed.

**`pmacs.fs.canonicalize`** (Q#LN20) is the one synchronous function on
`pmacs.fs`, and synchronous is the point: its consumer is a
function-valued `config.root` called from `ensure_server` <-
`attach_buffer` <- `buffer.after-load`, where there is no coroutine and
`pmacs.fs.stat`'s awaitable handle is unusable. It is installed from
`install_async` rather than `install_project` purely for load order —
`make_workspace` runs after `fs.lua` is evaluated, so a canonicalizer
placed there reads nil.

Acceptance: `tests/lsp_dispatch_seams_acceptance.rs`, 14 tests, driven
against `pmacs_fake_lsp` through rust so nothing needs a toolchain.
Dispatch integrity is exercised at real co-occurrence — the fake server
writes `workspace/applyEdit` and the `executeCommand` reply back to
back, so both land in one `events_take` batch. 34b asserts affinity
survives a symlinked open and is paired with its own falsification: the
same resolver minus the canonicalize call spawns two servers, so the
positive case cannot be vacuous.
This commit is contained in:
Levi Neuwirth 2026-07-25 15:37:48 -04:00
parent a516a46359
commit 12236b265d
4 changed files with 892 additions and 0 deletions

View File

@ -284,4 +284,28 @@ function fs.watch(path, callback, opts)
return watch
end
-- pmacs.fs.canonicalize(path) -> string | nil
--
-- Arc 8 Stage 3a (framing Q#LN20). The **only synchronous** function on
-- this module, and deliberately so: its consumer is a function-valued
-- `pmacs.lsp.config[lang].root`, invoked from `ensure_server` <-
-- `attach_buffer` <- the `buffer.after-load` hook, where there is no
-- coroutine and therefore nothing to `:await()` on. Every other
-- primitive here returns a Handle; this one cannot, or it would be
-- unusable at the one call site that needs it — the same trap
-- `pmacs.fs.stat` falls into for that caller.
--
-- Resolves symlinks and `.` / `..`, returning an absolute path, or nil
-- if the path does not exist or cannot be resolved. Nil is a normal
-- answer, not an error: callers routinely ask about paths that may have
-- been deleted.
--
-- Why it exists: a configured LSP root reaches `file_uri_for` verbatim
-- and that URI is the server-affinity key (PR #161), so one project
-- opened through a symlink and through its real path would otherwise
-- spawn two servers. `pmacs.editor.file_path()` collapses `.` and `..`
-- lexically but leaves symlinks intact, so the resolver cannot get a
-- canonical path any other way.
fs.canonicalize = pmacs._fs.canonicalize
pmacs.fs = fs

View File

@ -1546,6 +1546,160 @@ end
-- itself is unaffected. Server ids are snapshotted before the loop
-- because `apply_workspace_edit` → `find_or_open` can attach a new
-- buffer mid-iteration (mutating `attachments`).
-- Server-originated notification / response seams (framing Q#LN9) -------
--
-- Before this, `handle_server_requests` handled five `request` methods
-- and `initialized`, and dropped every `notification` and `response` on
-- the floor. Dropping responses made `pmacs.lsp.send_request` a
-- write-only API from Lua: the reply was drained and discarded, so
-- nothing outside Rust's typed stores could ever consume one.
--
-- Both seams route through the *existing* drain. A second
-- `events_take` caller would steal events from this one — `take_events`
-- removes the queue — so any new consumer must extend this loop rather
-- than open its own.
--
-- method -> array of subscriber fns. Persistent; `pmacs.hook` has no
-- `remove` and neither does this, deliberately matching it.
local notification_subs = {}
-- tostring(sid) -> { [request_id] = { fn = fn, attempt = n } }. One-shot.
local pending_responses = {}
local function report_subscriber_error(what, err)
local msg = string.format("LSP: %s subscriber failed: %s", what,
tostring(err))
-- COHERENCE §1.2: a pcall around background wiring must report, not
-- discard. `pmacs.editor.set_status` is the channel that exists;
-- `pmacs.error` is referenced by fifteen call sites and defined
-- nowhere in production, so it rides along rather than standing alone.
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
end
-- Current spawn attempt for `sid`, or nil if the manager has forgotten
-- it. A restart reuses the sid but bumps the attempt, which is how a
-- pending one-shot tells "my server is still here" from "my server died
-- and a new generation took its id".
local function server_attempt(sid)
local skey = tostring(sid)
for _, info in ipairs(pmacs.lsp.list()) do
if tostring(info.id) == skey then
return info.attempt or 0, info.state and info.state.kind
end
end
return nil, nil
end
-- fn(sid, params); persistent, fires for every server.
function pmacs.lsp.on_notification(method, fn)
if type(method) ~= "string" or type(fn) ~= "function" then
error("pmacs.lsp.on_notification(method, fn): want string, function")
end
local subs = notification_subs[method]
if not subs then
subs = {}
notification_subs[method] = subs
end
subs[#subs + 1] = fn
end
-- fn(result, err); ONE-SHOT, keyed to the exact request.
-- `request_id` is what `pmacs.lsp.send_request` returned.
function pmacs.lsp.on_response(sid, request_id, fn)
if not sid or type(request_id) ~= "number" or type(fn) ~= "function" then
error("pmacs.lsp.on_response(sid, request_id, fn): want sid, number, function")
end
local skey = tostring(sid)
local pend = pending_responses[skey]
if not pend then
pend = {}
pending_responses[skey] = pend
end
-- The attempt is captured at registration so a restart under the same
-- sid purges this entry rather than leaving it waiting on a reply the
-- dead generation was going to send.
pend[request_id] = { fn = fn, attempt = server_attempt(sid) or 0 }
end
local function dispatch_notification(sid, ev)
local subs = notification_subs[ev.method]
if not subs then return end
-- Length captured up front: a subscriber that registers another one
-- must not be able to extend the list being walked.
local n = #subs
for i = 1, n do
local ok, err = pcall(subs[i], sid, ev.params)
if not ok then
report_subscriber_error("notification " .. tostring(ev.method), err)
end
end
end
local function deliver_response(sid, ev)
local skey = tostring(sid)
local pend = pending_responses[skey]
if not pend then return end
local entry = pend[ev.request_id]
if not entry then return end
-- Removed BEFORE invocation: a handler that raises must not be
-- re-entered by a later event carrying the same id.
pend[ev.request_id] = nil
if next(pend) == nil then pending_responses[skey] = nil end
local ok, err = pcall(entry.fn, ev.result, ev.error)
if not ok then
report_subscriber_error("response " .. tostring(ev.method), err)
end
end
-- Settle every one-shot whose server can no longer answer it.
--
-- Deliberately driven off `pmacs.lsp.list()` and NOT off a death event
-- observed in the drain, because the drain cannot be relied on to reach
-- the server in question: `handle_server_requests` builds its sid list
-- from `attachments`, and a sid leaves that table whenever
-- `attach_buffer` finds it dead and rebuilds the attachment against a
-- fresh server. So the very event that should trigger the purge —
-- `crashed` / `stopped` — is the one most likely to go undrained. A
-- one-shot settled only by the drain would leak exactly when it matters.
--
-- `pmacs.lsp.list()` enumerates the manager directly and is unaffected
-- by attachment bookkeeping, which is what makes it the right authority.
local function purge_dead_pending()
if next(pending_responses) == nil then return end
local ok, rows = pcall(pmacs.lsp.list)
-- A failed enumeration is not evidence that every server died; leaving
-- the registrations alone is the safe read of "we don't know".
if not ok or not rows then return end
local alive = {}
for _, info in ipairs(rows) do
local kind = info.state and info.state.kind
if kind ~= "crashed" and kind ~= "stopped" then
alive[tostring(info.id)] = info.attempt or 0
end
end
for skey, pend in pairs(pending_responses) do
local attempt = alive[skey]
local dead = {}
for rid, entry in pairs(pend) do
-- Absent or terminal, or the same sid running a NEW generation:
-- in every case the request this entry awaits is unanswerable.
if attempt == nil or attempt ~= entry.attempt then
dead[#dead + 1] = rid
end
end
for _, rid in ipairs(dead) do
local entry = pend[rid]
pend[rid] = nil
local ok_h, err = pcall(entry.fn, nil,
{ message = "server gone before response" })
if not ok_h then
report_subscriber_error("response purge", err)
end
end
if next(pend) == nil then pending_responses[skey] = nil end
end
end
local function handle_server_requests()
local sids, seen = {}, {}
for _, rec in pairs(attachments) do
@ -1598,6 +1752,10 @@ local function handle_server_requests()
-- LSP spells the field "unregisterations".
pcall(unregister_file_watchers, sid,
ev.params and ev.params.unregisterations)
elseif ev.kind == "notification" then
dispatch_notification(sid, ev)
elseif ev.kind == "response" then
deliver_response(sid, ev)
elseif ev.kind == "initialized" then
-- Buffers attach before the server finishes initializing, so
-- the pulls in `attach_buffer` are no-ops for the FIRST file
@ -1620,6 +1778,10 @@ if pmacs._async and pmacs._async.tick then
pmacs._async.tick = function(...)
local ret = _prior_async_tick(...)
pcall(handle_server_requests)
-- After the drain, so a response delivered this tick settles its
-- one-shot normally rather than being purged as "server gone" in the
-- same pass when the server died right after answering.
pcall(purge_dead_pending)
pcall(flush_due_did_changes)
return ret
end

View File

@ -6519,6 +6519,45 @@ pub fn install_async(
) -> mlua::Result<()> {
lua.set_app_data(runtime.clone());
let pmacs: Table = lua.globals().get("pmacs")?;
// Arc 8 Stage 3a (framing Q#LN20): the one *synchronous* filesystem
// primitive Lua has. `pmacs.fs` is otherwise an async, handle-
// returning surface built in `builtin/runtime/fs.lua`, so this
// arrives through a private table that file re-exports rather than
// joining the `_dispatch_fs_*` family it would not belong to.
//
// Installed here, alongside those dispatchers, purely for load
// order: `make_async_runtime` runs before `fs.lua` is evaluated,
// whereas `install_project` — the other plausible home — runs after
// it, so a canonicalizer placed there is nil when `fs.lua` reads it.
//
// Synchronous on purpose, and that is the whole point. The consumer
// is a function-valued `pmacs.lsp.config[lang].root`, which
// `project_root_for` calls from `ensure_server` <- `attach_buffer`
// <- the `buffer.after-load` hook — no coroutine, nothing to await
// on. An awaitable canonicalizer would be unusable there for exactly
// the reason `pmacs.fs.stat` already is, leaving #161's
// canonical-root obligation undischarged. The cost is one syscall on
// a path the editor is already opening; `pmacs.project.detect`
// canonicalizes synchronously on the same hook today.
{
let fs_priv = lua.create_table()?;
fs_priv.set(
"canonicalize",
lua.create_function(|_, path: String| {
// nil rather than an error for a path that cannot be
// resolved: asking about a deleted file or a broken
// symlink is ordinary, and raising would surface through
// `resolve_root_fn`'s pcall as a config bug, which it is
// not.
Ok(std::fs::canonicalize(&path)
.ok()
.map(|p| p.display().to_string()))
})?,
)?;
pmacs.set("_fs", fs_priv)?;
}
let async_mod = lua.create_table()?;
{

View File

@ -0,0 +1,667 @@
//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams
//! and `pmacs.fs.canonicalize`.
//!
//! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 2934 plus
//! 34a/34b.
//!
//! This suite deliberately contains **no Lean content**.
//! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single
//! LSP event drain for every language in pmacs, so the change is
//! exercised through an already-shipped language driven against
//! `pmacs_fake_lsp`. A suite that reached the drain only through Lean
//! would understate the blast radius — the same reasoning that shaped
//! Stage 2's suite.
//!
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
//! tempdir root, so a stray marker above the temp directory cannot make
//! a "markerless" case silently detected.
use std::path::{Path, PathBuf};
use std::time::Duration;
use pmacs::editor::EditorState;
fn exec(state: &EditorState, source: &str) {
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
}
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
/// A fresh editor with the shipped language configs cleared, so the only
/// server any test can spawn is the fake one it configures itself.
fn editor() -> EditorState {
let state = EditorState::new();
exec(&state, "pmacs.lsp.config = {}");
state
}
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 dir(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
fn bind(&self, state: &EditorState) {
exec(
state,
&format!(
"pmacs.project.set_search_boundary(\"{}\")",
lua_str(&self.root)
),
);
}
}
fn configure(state: &EditorState, language: &str) {
exec(
state,
&format!(
"pmacs.lsp.config.{language} = {{ command = \"{}\" }}",
fake_lsp_path()
),
);
}
fn open(state: &EditorState, path: &Path) {
exec(
state,
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
);
}
/// `tick_async` is what drives the drain: `handle_server_requests` is
/// wrapped onto `pmacs._async.tick`, so a settle loop without it moves
/// the LSP state machine while never delivering a single event to Lua.
fn settle(state: &mut EditorState) {
for _ in 0..8 {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
/// A rust project with one file, an attached fake server, and the
/// probes below installed. Returns the opened file's path.
fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf {
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n");
fx.bind(state);
configure(state, "rust");
open(state, &file);
settle(state);
file
}
/// The sid of the single live server, as a Lua expression fragment.
const THE_SID: &str = "pmacs.lsp.list()[1].id";
// ---------------------------------------------------------------------------
// Acceptance 29 — a notification reaches a registered subscriber.
// ---------------------------------------------------------------------------
#[test]
fn acc29_notification_reaches_a_registered_subscriber() {
let fx = Fixture::new();
let mut state = editor();
// Registered BEFORE the open, so the didOpen-triggered `pmacs/echo`
// is in the first drain.
exec(
&state,
r#"
_G.seen = {}
pmacs.lsp.on_notification("pmacs/echo", function(sid, params)
_G.seen[#_G.seen + 1] = tostring(params and params.uri)
end)
"#,
);
attached_rust(&mut state, &fx);
let n: i64 = eval(&state, "return #_G.seen");
assert!(
n >= 1,
"expected at least one pmacs/echo notification, got {n}"
);
let first: String = eval(&state, "return _G.seen[1]");
assert!(
first.starts_with("file://") && first.ends_with("main.rs"),
"subscriber got the document uri; saw {first:?}"
);
}
#[test]
fn acc29_subscriber_for_an_unsent_method_does_not_fire() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.hits = 0
pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end)
"#,
);
attached_rust(&mut state, &fx);
// Non-vacuity for acc29: the seam is method-keyed, not a firehose.
// Without this, a subscriber invoked for every notification would
// pass the test above while being wrong.
let hits: i64 = eval(&state, "return _G.hits");
assert_eq!(hits, 0, "a subscriber must only fire for its own method");
}
// ---------------------------------------------------------------------------
// Acceptance 30 + 33 — dispatch integrity: with subscribers registered,
// a `workspace/applyEdit` request in the same drain is still handled.
//
// The fake server writes the applyEdit request and the executeCommand
// response back to back, so both land in one `events_take` batch. That
// co-occurrence is the point: a seam that consumed the batch, or that
// returned early, would starve the `request` arms that share it.
// ---------------------------------------------------------------------------
fn drive_apply_edit(state: &mut EditorState, file: &Path) {
exec(
state,
&format!(
r#"
local sid = {THE_SID}
local uri = "file://{}"
_G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
command = "pmacs.fake.applyEdit",
arguments = {{ uri }},
}})
_G.response_hits = 0
pmacs.lsp.on_response(sid, _G.rid, function(result, err)
_G.response_hits = _G.response_hits + 1
end)
"#,
lua_str(file)
),
);
settle(state);
}
fn buffer_text(state: &EditorState) -> String {
eval(
state,
"local b = pmacs.window.buffer() return b:slice(0, b:len())",
)
}
#[test]
fn acc30_apply_edit_still_handled_with_a_notification_subscriber() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.notes = 0
pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end)
"#,
);
let file = attached_rust(&mut state, &fx);
assert!(
eval::<i64>(&state, "return _G.notes") >= 1,
"precondition: the notification subscriber is actually firing"
);
drive_apply_edit(&mut state, &file);
assert!(
buffer_text(&state).contains("ED2"),
"workspace/applyEdit must still be applied with a subscriber \
registered; buffer was {:?}",
buffer_text(&state)
);
}
#[test]
fn acc33_apply_edit_still_handled_with_a_response_subscriber() {
let fx = Fixture::new();
let mut state = editor();
let file = attached_rust(&mut state, &fx);
drive_apply_edit(&mut state, &file);
// Both halves in one drain: the response was delivered to its
// one-shot AND the server-originated request was serviced.
assert_eq!(
eval::<i64>(&state, "return _G.response_hits"),
1,
"the executeCommand response reaches its one-shot"
);
assert!(
buffer_text(&state).contains("ED2"),
"workspace/applyEdit must still be applied with a response \
subscriber registered; buffer was {:?}",
buffer_text(&state)
);
}
// ---------------------------------------------------------------------------
// Acceptance 31 — a raising subscriber does not stop later events in the
// same drain (and does not stop the `request` arms either).
// ---------------------------------------------------------------------------
#[test]
fn acc31_raising_notification_subscriber_does_not_stop_the_drain() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.second_hits = 0
pmacs.lsp.on_notification("pmacs/echo", function()
error("subscriber blew up")
end)
pmacs.lsp.on_notification("pmacs/echo", function()
_G.second_hits = _G.second_hits + 1
end)
"#,
);
let file = attached_rust(&mut state, &fx);
assert!(
eval::<i64>(&state, "return _G.second_hits") >= 1,
"a raising subscriber must not starve the ones after it"
);
// And the shared `request` arms still run in a later drain.
drive_apply_edit(&mut state, &file);
assert!(
buffer_text(&state).contains("ED2"),
"a raising subscriber must not stop workspace/applyEdit"
);
}
#[test]
fn acc33_raising_response_handler_does_not_stop_the_drain() {
let fx = Fixture::new();
let mut state = editor();
let file = attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.notes_after = 0
pmacs.lsp.on_notification("pmacs/echo", function()
_G.notes_after = _G.notes_after + 1
end)
local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
command = "pmacs.fake.applyEdit",
arguments = {{ "file://{}" }},
}})
pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end)
"#,
lua_str(&file)
),
);
settle(&mut state);
assert!(
buffer_text(&state).contains("ED2"),
"a raising response handler must not stop workspace/applyEdit in \
the same drain"
);
}
// ---------------------------------------------------------------------------
// Acceptance 32 — the one-shot is removed BEFORE invocation.
//
// Observed rather than asserted structurally: the handler raises, and
// the server is then stopped. If removal happened only on a clean
// return — or not at all — the purge below would invoke the same handler
// a second time with an error. The count is what pins it.
// ---------------------------------------------------------------------------
#[test]
fn acc32_response_one_shot_is_removed_before_invocation() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.calls = 0
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.calls = _G.calls + 1
error("handler raises after being removed")
end)
"#
),
);
settle(&mut state);
assert_eq!(
eval::<i64>(&state, "return _G.calls"),
1,
"the one-shot fires exactly once for its reply"
);
exec(&state, &format!("pmacs.lsp.stop({THE_SID})"));
settle(&mut state);
assert_eq!(
eval::<i64>(&state, "return _G.calls"),
1,
"a delivered one-shot must not be re-invoked by the purge — it \
was removed before the raising handler ran, not after"
);
}
#[test]
fn acc32_response_carries_the_servers_result() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.echoed = nil
_G.saw_err = "unset"
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.echoed = result and result.echo and result.echo.v
_G.saw_err = tostring(err)
end)
"#
),
);
settle(&mut state);
// Non-vacuity: without this the seam could "fire" with nil payloads
// and every count-based assertion above would still pass.
assert_eq!(
eval::<i64>(&state, "return _G.echoed or -1"),
42,
"the handler receives the server's result payload"
);
assert_eq!(
eval::<String>(&state, "return _G.saw_err"),
"nil",
"a successful reply passes nil for err"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and
// NOT off a death event seen in the drain.
//
// The second test is the load-bearing one. `handle_server_requests`
// builds its sid list from `attachments`, so a server that is in no
// attachment is never drained — and its `stopped` event is therefore
// never seen. A purge wired to that event leaks exactly there.
// ---------------------------------------------------------------------------
#[test]
fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.err_msg = "never called"
-- A method the fake server answers only after a delay would
-- be ideal; instead the server is stopped in the same breath,
-- so the reply can never arrive.
local rid = pmacs.lsp.send_request(sid, "test/slow", {{}})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.err_msg = tostring(err and err.message)
end)
pmacs.lsp.stop(sid)
"#
),
);
settle(&mut state);
let msg: String = eval(&state, "return _G.err_msg");
assert!(
msg.contains("server gone") || msg == "nil",
"a pending one-shot must be settled, not left waiting; saw {msg:?}"
);
assert_ne!(
msg, "never called",
"the one-shot was never settled — it leaked"
);
}
#[test]
fn acc34_purge_reaches_a_server_that_is_in_no_attachment() {
let fx = Fixture::new();
let mut state = editor();
fx.bind(&state);
// Spawned directly, never attached to a buffer. `attachments` is
// empty, so `handle_server_requests` never visits this sid and its
// `stopped` event is never drained.
exec(
&state,
&format!(
r#"
_G.settled = "never called"
local sid = pmacs.lsp.spawn({{
label = "orphan",
language_id = "rust",
command = "{}",
args = {{}},
}})
_G.orphan = sid
"#,
fake_lsp_path()
),
);
settle(&mut state);
let attached: i64 = eval(
&state,
"local n = 0 for _ in pairs(_G) do n = n + 1 end return n",
);
assert!(attached > 0, "lua globals are readable");
exec(
&state,
r#"
local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {})
pmacs.lsp.on_response(_G.orphan, rid, function(result, err)
_G.settled = tostring(err and err.message)
end)
pmacs.lsp.stop(_G.orphan)
"#,
);
settle(&mut state);
let settled: String = eval(&state, "return _G.settled");
assert_ne!(
settled, "never called",
"the purge must not depend on the drain reaching this server — \
it is in no attachment, so the drain never does"
);
assert!(
settled.contains("server gone"),
"settled with the purge's error; saw {settled:?}"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20).
// ---------------------------------------------------------------------------
#[test]
fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() {
let fx = Fixture::new();
fx.write("pkg/sub/a.txt", "x\n");
// Built here rather than assumed: the whole point is the symlink.
std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap();
let state = editor();
let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display());
let got: String = eval(
&state,
&format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"),
);
let want = fx.root.join("pkg/sub/a.txt").display().to_string();
assert_eq!(got, want, "symlink and dot segments both resolved");
// Falsification for 34b: the uncanonicalized spelling really is
// different, so the affinity test below is not vacuous.
assert_ne!(noncanon, want);
}
#[test]
fn acc34a_canonicalize_returns_nil_for_a_missing_path() {
let fx = Fixture::new();
let state = editor();
let missing = fx.dir("nope/not-here").display().to_string();
let got: String = eval(
&state,
&format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"),
);
assert_eq!(
got, "nil",
"a nonexistent path declines rather than raising"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34b — affinity survives a symlinked open.
//
// Asserted at the affinity layer, not just at the binding: the
// regression Q#LN20 exists to prevent is *two servers for one project*,
// and only this shape observes it.
// ---------------------------------------------------------------------------
fn server_count(state: &EditorState) -> i64 {
eval(state, "return #pmacs.lsp.list()")
}
#[test]
fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
let linked = fx.dir("linkproj").join("src/main.rs");
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
r#"
pmacs.lsp.config.rust = {{
command = "{}",
root = function(path)
local dir = path:match("^(.*)/[^/]*$")
if not dir then return nil end
-- Walk up to the directory holding Cargo.toml, then
-- canonicalize the Q#LN8 shape Stage 3b will use.
while dir and #dir > 0 do
local f = io.open(dir .. "/Cargo.toml", "r")
if f then
f:close()
return pmacs.fs.canonicalize(dir)
end
dir = dir:match("^(.*)/[^/]*$")
end
return nil
end,
}}
"#,
fake_lsp_path()
),
);
open(&state, &real);
settle(&mut state);
assert_eq!(server_count(&state), 1, "the real path spawns one server");
open(&state, &linked);
settle(&mut state);
assert_eq!(
server_count(&state),
1,
"the symlinked path must reuse the same server — two here is the \
exact regression Q#LN20 exists to prevent"
);
}
#[test]
fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
let linked = fx.dir("linkproj").join("src/main.rs");
let mut state = editor();
fx.bind(&state);
// Same resolver, minus the canonicalize call. This is the bite: if
// it also produced one server, the test above would be vacuous and
// `pmacs.fs.canonicalize` would be doing nothing.
exec(
&state,
&format!(
r#"
pmacs.lsp.config.rust = {{
command = "{}",
root = function(path)
local dir = path:match("^(.*)/[^/]*$")
while dir and #dir > 0 do
local f = io.open(dir .. "/Cargo.toml", "r")
if f then f:close() return dir end
dir = dir:match("^(.*)/[^/]*$")
end
return nil
end,
}}
"#,
fake_lsp_path()
),
);
open(&state, &real);
settle(&mut state);
open(&state, &linked);
settle(&mut state);
assert_eq!(
server_count(&state),
2,
"without canonicalization the two spellings key differently and \
spawn two servers this is what 34b's positive case rules out"
);
}