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:
parent
a516a46359
commit
12236b265d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 29–34 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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue