Merge pull request #9 from levineuwirth/v1.1-semantic
T M4.5 async bridge: LSP requests settle async-runtime jobs
This commit is contained in:
commit
dbaacad755
|
|
@ -153,21 +153,51 @@ pmacs.hook.add("buffer.after-edit", function()
|
||||||
pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text())
|
pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text())
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Synchronous poll: tick the supervisor + manager in tight loops until
|
-- Async request surface (T M4.5 async bridge). The Rust manager
|
||||||
-- `predicate()` returns a truthy value or the deadline elapses. Uses
|
-- registers each `textDocument/*` request with the async runtime and
|
||||||
-- wall-clock `pmacs.now_ms` because `os.clock` counts CPU time, and
|
-- returns a job id; the JSON-RPC response (or a server-teardown
|
||||||
-- the inner ticks block on subprocess I/O instead of burning CPU.
|
-- drain) settles it. `_request_*_raw` is the raw job-id-returning
|
||||||
-- The v0.2 LSP UX pass swaps this for async-await coroutines.
|
-- binding (mirrors `pmacs.mcp._send_request_raw`); the wrappers below
|
||||||
local function poll_until(predicate, timeout_ms)
|
-- hand back a `pmacs.workers` Handle whose `:await()` resumes the
|
||||||
timeout_ms = timeout_ms or 250
|
-- caller when the response lands. The pre-v1.0 `poll_until` tick-loop
|
||||||
local deadline = pmacs.now_ms() + timeout_ms
|
-- this replaced blocked the editor for the whole request; awaiting
|
||||||
while pmacs.now_ms() < deadline do
|
-- yields the coroutine instead.
|
||||||
pmacs.process._tick()
|
local workers_mod = pmacs.workers
|
||||||
pmacs.lsp._tick()
|
assert(workers_mod and workers_mod._new_handle,
|
||||||
local ok, value = pcall(predicate)
|
"pmacs.workers._new_handle missing; did async.lua load before lsp.lua?")
|
||||||
if ok and value then return value end
|
assert(pmacs.lsp._request_completion_raw,
|
||||||
|
"pmacs.lsp._request_completion_raw missing; lua_bindings::install_lsp not run?")
|
||||||
|
local new_handle = workers_mod._new_handle
|
||||||
|
|
||||||
|
local function wrap_request(raw)
|
||||||
|
-- Raises on dispatch failure (e.g. "server not ready"), matching
|
||||||
|
-- `mcp.lua`'s wrapper; callers pcall the `request():await()` chain.
|
||||||
|
return function(...)
|
||||||
|
return new_handle(raw(...))
|
||||||
end
|
end
|
||||||
return nil
|
end
|
||||||
|
|
||||||
|
pmacs.lsp.request_completion = wrap_request(pmacs.lsp._request_completion_raw)
|
||||||
|
pmacs.lsp.request_hover = wrap_request(pmacs.lsp._request_hover_raw)
|
||||||
|
pmacs.lsp.request_signature_help = wrap_request(pmacs.lsp._request_signature_help_raw)
|
||||||
|
pmacs.lsp.request_definition = wrap_request(pmacs.lsp._request_definition_raw)
|
||||||
|
pmacs.lsp.request_formatting = wrap_request(pmacs.lsp._request_formatting_raw)
|
||||||
|
|
||||||
|
-- Render an `:await()` failure into a modeline-friendly reason.
|
||||||
|
-- `Handle:await()` raises `{ tag = "cancelled", ... }` when the
|
||||||
|
-- server went away mid-request (drain in `lsp.rs`) and
|
||||||
|
-- `{ tag = "failed", message = ... }` for a JSON-RPC error response;
|
||||||
|
-- a raw dispatch failure surfaces as a plain string.
|
||||||
|
local function lsp_await_error(err)
|
||||||
|
if type(err) == "table" then
|
||||||
|
if err.tag == "cancelled" then
|
||||||
|
return "server unavailable (request cancelled)"
|
||||||
|
elseif err.tag == "failed" then
|
||||||
|
return err.message or "server error"
|
||||||
|
end
|
||||||
|
return tostring(err.tag or "error")
|
||||||
|
end
|
||||||
|
return tostring(err)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Cursor positioning ------------------------------------------------------
|
-- Cursor positioning ------------------------------------------------------
|
||||||
|
|
@ -235,6 +265,16 @@ end
|
||||||
|
|
||||||
-- Commands ----------------------------------------------------------------
|
-- Commands ----------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Each command captures the cursor/target at invocation time, then
|
||||||
|
-- spawns a coroutine that awaits the request and reacts. The editor
|
||||||
|
-- never blocks: the command function returns immediately and the
|
||||||
|
-- modeline updates when the response lands (or the await fails).
|
||||||
|
-- `:await()` sequences the work and surfaces server-gone / server-
|
||||||
|
-- error as structured errors; the normalized typed store (hybrid
|
||||||
|
-- model) is still the read path, so LSP result-shape variance
|
||||||
|
-- (Location | Location[] | LocationLink[], MarkupContent, …) stays
|
||||||
|
-- parsed in one place in Rust rather than re-derived here.
|
||||||
|
|
||||||
function pmacs.lsp.go_to_definition()
|
function pmacs.lsp.go_to_definition()
|
||||||
local rec = attached_for_active()
|
local rec = attached_for_active()
|
||||||
if not rec then
|
if not rec then
|
||||||
|
|
@ -244,28 +284,28 @@ function pmacs.lsp.go_to_definition()
|
||||||
local line = pmacs.editor.cursor_line()
|
local line = pmacs.editor.cursor_line()
|
||||||
local col = pmacs.editor.cursor_col()
|
local col = pmacs.editor.cursor_col()
|
||||||
pmacs.definition.clear(rec.server, rec.uri)
|
pmacs.definition.clear(rec.server, rec.uri)
|
||||||
local ok = pcall(pmacs.lsp.request_definition, rec.server, rec.uri, line, col)
|
pmacs.async(function()
|
||||||
if not ok then
|
local ok, err = pcall(function()
|
||||||
pmacs.editor.set_status("LSP: server not ready")
|
pmacs.lsp.request_definition(rec.server, rec.uri, line, col):await()
|
||||||
return
|
end)
|
||||||
end
|
if not ok then
|
||||||
local locs = poll_until(function()
|
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||||
local ls = pmacs.definition.locations(rec.server, rec.uri)
|
return
|
||||||
if #ls > 0 then return ls end
|
end
|
||||||
return nil
|
local locs = pmacs.definition.locations(rec.server, rec.uri)
|
||||||
end, 500)
|
if not locs or #locs == 0 then
|
||||||
if not locs then
|
pmacs.editor.set_status("LSP: no definition found")
|
||||||
pmacs.editor.set_status("LSP: no definition found")
|
return
|
||||||
return
|
end
|
||||||
end
|
local first = locs[1]
|
||||||
local first = locs[1]
|
if first.uri == rec.uri then
|
||||||
if first.uri == rec.uri then
|
move_active_cursor_to(first.line, first.col)
|
||||||
move_active_cursor_to(first.line, first.col)
|
pmacs.editor.set_status(string.format(
|
||||||
pmacs.editor.set_status(string.format(
|
"LSP: definition at %d:%d", first.line + 1, first.col + 1))
|
||||||
"LSP: definition at %d:%d", first.line + 1, first.col + 1))
|
else
|
||||||
else
|
pmacs.editor.set_status("LSP: definition lives in " .. first.uri)
|
||||||
pmacs.editor.set_status("LSP: definition lives in " .. first.uri)
|
end
|
||||||
end
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function pmacs.lsp.format_buffer()
|
function pmacs.lsp.format_buffer()
|
||||||
|
|
@ -275,22 +315,22 @@ function pmacs.lsp.format_buffer()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
pmacs.formatting.clear(rec.server, rec.uri)
|
pmacs.formatting.clear(rec.server, rec.uri)
|
||||||
local ok = pcall(pmacs.lsp.request_formatting, rec.server, rec.uri, 4, true)
|
pmacs.async(function()
|
||||||
if not ok then
|
local ok, err = pcall(function()
|
||||||
pmacs.editor.set_status("LSP: server not ready")
|
pmacs.lsp.request_formatting(rec.server, rec.uri, 4, true):await()
|
||||||
return
|
end)
|
||||||
end
|
if not ok then
|
||||||
local edits = poll_until(function()
|
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||||
local es = pmacs.formatting.edits(rec.server, rec.uri)
|
return
|
||||||
if #es > 0 then return es end
|
end
|
||||||
return nil
|
local edits = pmacs.formatting.edits(rec.server, rec.uri)
|
||||||
end, 1000)
|
if not edits or #edits == 0 then
|
||||||
if not edits then
|
pmacs.editor.set_status("LSP: no formatting edits")
|
||||||
pmacs.editor.set_status("LSP: no formatting edits")
|
return
|
||||||
return
|
end
|
||||||
end
|
local n = apply_text_edits(edits)
|
||||||
local n = apply_text_edits(edits)
|
pmacs.editor.set_status(string.format("LSP: applied %d edits", n))
|
||||||
pmacs.editor.set_status(string.format("LSP: applied %d edits", n))
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function pmacs.lsp.hover_at_cursor()
|
function pmacs.lsp.hover_at_cursor()
|
||||||
|
|
@ -299,25 +339,28 @@ function pmacs.lsp.hover_at_cursor()
|
||||||
pmacs.editor.set_status("LSP: no server for active buffer")
|
pmacs.editor.set_status("LSP: no server for active buffer")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local line = pmacs.editor.cursor_line()
|
||||||
|
local col = pmacs.editor.cursor_col()
|
||||||
pmacs.hover.clear(rec.server, rec.uri)
|
pmacs.hover.clear(rec.server, rec.uri)
|
||||||
local ok = pcall(pmacs.lsp.request_hover, rec.server, rec.uri,
|
pmacs.async(function()
|
||||||
pmacs.editor.cursor_line(), pmacs.editor.cursor_col())
|
local ok, err = pcall(function()
|
||||||
if not ok then
|
pmacs.lsp.request_hover(rec.server, rec.uri, line, col):await()
|
||||||
pmacs.editor.set_status("LSP: server not ready")
|
end)
|
||||||
return
|
if not ok then
|
||||||
end
|
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||||
local hover = poll_until(function()
|
return
|
||||||
return pmacs.hover.current(rec.server, rec.uri)
|
end
|
||||||
end, 500)
|
local hover = pmacs.hover.current(rec.server, rec.uri)
|
||||||
if not hover then
|
if not hover then
|
||||||
pmacs.editor.set_status("LSP: no hover info")
|
pmacs.editor.set_status("LSP: no hover info")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- v0.1 surfaces the first line of the hover body in the modeline. The
|
-- Surface the first line of the hover body in the modeline. The
|
||||||
-- popup view subscribes to the same store; future work can wire one
|
-- popup view subscribes to the same store; a panel can wire in
|
||||||
-- in here when the keybinding is meant to surface a panel.
|
-- here when the keybinding is meant to surface one.
|
||||||
local first = (hover.contents or ""):match("^[^\n]*") or ""
|
local first = (hover.contents or ""):match("^[^\n]*") or ""
|
||||||
pmacs.editor.set_status(first ~= "" and ("LSP: " .. first) or "LSP: hover empty")
|
pmacs.editor.set_status(first ~= "" and ("LSP: " .. first) or "LSP: hover empty")
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function pmacs.lsp.signature_help_at_cursor()
|
function pmacs.lsp.signature_help_at_cursor()
|
||||||
|
|
@ -326,22 +369,25 @@ function pmacs.lsp.signature_help_at_cursor()
|
||||||
pmacs.editor.set_status("LSP: no server for active buffer")
|
pmacs.editor.set_status("LSP: no server for active buffer")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local line = pmacs.editor.cursor_line()
|
||||||
|
local col = pmacs.editor.cursor_col()
|
||||||
pmacs.signature.clear(rec.server, rec.uri)
|
pmacs.signature.clear(rec.server, rec.uri)
|
||||||
local ok = pcall(pmacs.lsp.request_signature_help, rec.server, rec.uri,
|
pmacs.async(function()
|
||||||
pmacs.editor.cursor_line(), pmacs.editor.cursor_col())
|
local ok, err = pcall(function()
|
||||||
if not ok then
|
pmacs.lsp.request_signature_help(rec.server, rec.uri, line, col):await()
|
||||||
pmacs.editor.set_status("LSP: server not ready")
|
end)
|
||||||
return
|
if not ok then
|
||||||
end
|
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
|
||||||
local help = poll_until(function()
|
return
|
||||||
return pmacs.signature.current(rec.server, rec.uri)
|
end
|
||||||
end, 500)
|
local help = pmacs.signature.current(rec.server, rec.uri)
|
||||||
if not help or not help.signatures or #help.signatures == 0 then
|
if not help or not help.signatures or #help.signatures == 0 then
|
||||||
pmacs.editor.set_status("LSP: no signature help")
|
pmacs.editor.set_status("LSP: no signature help")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local active = help.signatures[(help.active_signature or 0) + 1]
|
local active = help.signatures[(help.active_signature or 0) + 1]
|
||||||
pmacs.editor.set_status(active and ("LSP: " .. active.label) or "LSP: signature unknown")
|
pmacs.editor.set_status(active and ("LSP: " .. active.label) or "LSP: signature unknown")
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Default commands + keymap entries --------------------------------------
|
-- Default commands + keymap entries --------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,14 @@ pub enum JobKind {
|
||||||
/// supervisor pipe. No worker thread is occupied for the
|
/// supervisor pipe. No worker thread is occupied for the
|
||||||
/// round-trip.
|
/// round-trip.
|
||||||
McpRequest,
|
McpRequest,
|
||||||
|
/// External request/reply for an LSP server, settled the same way
|
||||||
|
/// as [`JobKind::McpRequest`]: the [`crate::lsp::LspManager`]
|
||||||
|
/// registers a pending entry via [`AsyncRuntime::register_external`]
|
||||||
|
/// when a `textDocument/*` request goes out and settles it via
|
||||||
|
/// `complete_external_ok` / `_failed` / `_cancelled` when the
|
||||||
|
/// JSON-RPC response is routed in `LspManager::tick`. No worker
|
||||||
|
/// thread is occupied for the round-trip ([T M4.5] async bridge).
|
||||||
|
LspRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobKind {
|
impl JobKind {
|
||||||
|
|
@ -348,6 +356,7 @@ impl JobKind {
|
||||||
JobKind::FsChmod => "fs_chmod",
|
JobKind::FsChmod => "fs_chmod",
|
||||||
JobKind::FsRemove => "fs_remove",
|
JobKind::FsRemove => "fs_remove",
|
||||||
JobKind::McpRequest => "mcp_request",
|
JobKind::McpRequest => "mcp_request",
|
||||||
|
JobKind::LspRequest => "lsp_request",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,27 @@ fn main() {
|
||||||
.get("params")
|
.get("params")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(serde_json::Value::Null);
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
// T M4.5 async-bridge failure-path test modes:
|
||||||
|
// * `error` — answer every `textDocument/*` request with a
|
||||||
|
// JSON-RPC error object (drives `Handle:await()` -> failed).
|
||||||
|
// * `silent` — accept the request but never answer it while
|
||||||
|
// staying alive (drives the request-timeout sweep, and
|
||||||
|
// makes supersede deterministic: a superseded handle can
|
||||||
|
// only settle via cancellation, never racing a response).
|
||||||
|
if method.starts_with("textDocument/") {
|
||||||
|
if mode == "error" && id.is_some() {
|
||||||
|
let resp = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id.clone(),
|
||||||
|
"error": { "code": -32603, "message": "synthetic error" }
|
||||||
|
});
|
||||||
|
write_frame(&mut stdout, &resp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if mode == "silent" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
match (method.as_str(), id) {
|
match (method.as_str(), id) {
|
||||||
("initialize", Some(idv)) => {
|
("initialize", Some(idv)) => {
|
||||||
let resp = serde_json::json!({
|
let resp = serde_json::json!({
|
||||||
|
|
|
||||||
|
|
@ -183,9 +183,12 @@ impl EditorState {
|
||||||
// T M4.5 LSP manager. Wires onto the same supervisor so its
|
// T M4.5 LSP manager. Wires onto the same supervisor so its
|
||||||
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
|
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
|
||||||
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
|
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
|
||||||
let lsp_manager =
|
let lsp_manager = crate::lua_bindings::make_lsp_manager(
|
||||||
crate::lua_bindings::make_lsp_manager(lua_host.lua(), process_supervisor.clone())
|
lua_host.lua(),
|
||||||
.expect("install pmacs.lsp");
|
process_supervisor.clone(),
|
||||||
|
async_runtime.clone(),
|
||||||
|
)
|
||||||
|
.expect("install pmacs.lsp");
|
||||||
// T M9.1 MCP manager. Wires onto the same supervisor that LSP
|
// T M9.1 MCP manager. Wires onto the same supervisor that LSP
|
||||||
// and `pmacs.process.*` use; the protocol-uniformity claim is
|
// and `pmacs.process.*` use; the protocol-uniformity claim is
|
||||||
// that this share is sufficient (no parallel dispatch path).
|
// that this share is sufficient (no parallel dispatch path).
|
||||||
|
|
|
||||||
328
src/lsp.rs
328
src/lsp.rs
|
|
@ -53,7 +53,7 @@
|
||||||
//! it matters.
|
//! it matters.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
@ -61,10 +61,12 @@ use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::async_runtime::{JobId, JobKind, SharedAsyncRuntime};
|
||||||
use crate::process::{
|
use crate::process::{
|
||||||
ProcessEvent, ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState,
|
ProcessEvent, ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState,
|
||||||
RestartPolicy, Termination,
|
RestartPolicy, Termination,
|
||||||
};
|
};
|
||||||
|
use crate::worker::CancellationToken;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity
|
// Identity
|
||||||
|
|
@ -494,6 +496,13 @@ pub struct LspClient {
|
||||||
attempt: u32,
|
attempt: u32,
|
||||||
/// When the manager should attempt the next restart, if any.
|
/// When the manager should attempt the next restart, if any.
|
||||||
next_restart_at: Option<Instant>,
|
next_restart_at: Option<Instant>,
|
||||||
|
/// T M4.5: request ids we sent `$/cancelRequest` for after every
|
||||||
|
/// awaiter abandoned them. A server may still answer a cancelled
|
||||||
|
/// request (the cancel/response race); a late response whose id
|
||||||
|
/// is in this set is dropped silently instead of surfacing a
|
||||||
|
/// "response for unknown request id" `ProtocolError`. Mirrors
|
||||||
|
/// `mcp.rs`'s `cancelled_rids`.
|
||||||
|
cancelled_rids: HashSet<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LspClient {
|
impl LspClient {
|
||||||
|
|
@ -507,6 +516,7 @@ impl LspClient {
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
attempt: 0,
|
attempt: 0,
|
||||||
next_restart_at: None,
|
next_restart_at: None,
|
||||||
|
cancelled_rids: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,6 +584,22 @@ pub struct LspManager {
|
||||||
/// `request_completion` etc. record an entry here; `handle_response`
|
/// `request_completion` etc. record an entry here; `handle_response`
|
||||||
/// consumes it to absorb the response into the correct store.
|
/// consumes it to absorb the response into the correct store.
|
||||||
pending_routes: HashMap<(LspServerId, u64), ResponseRoute>,
|
pending_routes: HashMap<(LspServerId, u64), ResponseRoute>,
|
||||||
|
/// T M4.5 async bridge: `(server, request_id)` → the awaiters
|
||||||
|
/// parked on this request's async-runtime job(s). Settled in
|
||||||
|
/// [`Self::handle_response`] when the response routes, and
|
||||||
|
/// drained-cancelled wherever [`Self::pending_routes`] is purged
|
||||||
|
/// for a server (restart / exit / forget) so a coroutine never
|
||||||
|
/// parks forever on a server that went away.
|
||||||
|
pending_external: HashMap<(LspServerId, u64), PendingExternal>,
|
||||||
|
/// Async runtime handle. The bridge between the supervisor
|
||||||
|
/// reader-thread response delivery and Lua-side `Handle:await()`
|
||||||
|
/// resumption; mirrors [`crate::mcp::McpManager`]'s `runtime`.
|
||||||
|
runtime: SharedAsyncRuntime,
|
||||||
|
/// T M4.5 task #8: hard ceiling on how long an in-flight request
|
||||||
|
/// may park its awaiters before the sweep fails them with a
|
||||||
|
/// timeout. Generous by default (real servers can take seconds on
|
||||||
|
/// a cold cache); tunable from Lua via `pmacs.lsp.set_request_timeout_ms`.
|
||||||
|
request_timeout: Duration,
|
||||||
/// T M4.8 status tracker. Folds every emitted [`LspEvent`] into a
|
/// T M4.8 status tracker. Folds every emitted [`LspEvent`] into a
|
||||||
/// per-server [`crate::lsp_status::LspStatus`] for the modeline /
|
/// per-server [`crate::lsp_status::LspStatus`] for the modeline /
|
||||||
/// `*lsp*` buffer.
|
/// `*lsp*` buffer.
|
||||||
|
|
@ -607,15 +633,64 @@ enum ResponseRoute {
|
||||||
Formatting { uri: String },
|
Formatting { uri: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Lua-visible awaiter bound to an in-flight LSP request. Mirrors
|
||||||
|
/// [`crate::mcp`]'s `Awaiter`: each carries its own
|
||||||
|
/// [`CancellationToken`] (minted by [`SharedAsyncRuntime::register_external`])
|
||||||
|
/// so a single handle can be cancelled without disturbing the
|
||||||
|
/// in-flight wire request or sibling awaiters.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Awaiter {
|
||||||
|
job_id: JobId,
|
||||||
|
/// Per-awaiter cancellation token minted by `register_external`.
|
||||||
|
/// Flipped by `Handle:cancel()` or by supersede; the per-tick
|
||||||
|
/// [`LspManager::drain_cancelled_externals`] sweep observes it.
|
||||||
|
/// Mirrors `mcp.rs`'s `Awaiter::token`.
|
||||||
|
token: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight `textDocument/*` request bound to one or more
|
||||||
|
/// async-runtime jobs. When the JSON-RPC response is routed in
|
||||||
|
/// [`LspManager::handle_response`] the response is absorbed into the
|
||||||
|
/// typed store (the hybrid model — popup/gutter consumers are
|
||||||
|
/// untouched) *and* every non-cancelled awaiter is settled via
|
||||||
|
/// [`SharedAsyncRuntime::complete_external_ok`] (or `_failed` on a
|
||||||
|
/// server error). Keyed `(server, request_id)` alongside
|
||||||
|
/// [`LspManager::pending_routes`]. T M4.5 async bridge.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PendingExternal {
|
||||||
|
/// JSON-RPC method, kept for the `*workers*`/`*lsp*` observability
|
||||||
|
/// surface and protocol-error messages.
|
||||||
|
method: String,
|
||||||
|
/// Live awaiters. Never empty while this entry exists. The first
|
||||||
|
/// is the original caller; later entries are siblings.
|
||||||
|
awaiters: Vec<Awaiter>,
|
||||||
|
/// When the request went on the wire. T M4.5 task #8: the
|
||||||
|
/// per-tick sweep fails awaiters whose request has outlived
|
||||||
|
/// [`LspManager::request_timeout`], so a wedged-but-alive server
|
||||||
|
/// can't park a coroutine forever (the pre-v1.0 `poll_until` had
|
||||||
|
/// 500/1000 ms ceilings; this is the async-era equivalent, but a
|
||||||
|
/// generous hard ceiling rather than a UX poll budget).
|
||||||
|
dispatched_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
impl LspManager {
|
impl LspManager {
|
||||||
/// Construct a fresh manager wired to `supervisor`.
|
/// Construct a fresh manager wired to `supervisor` and the
|
||||||
|
/// editor's `runtime`. The runtime bridges the supervisor's
|
||||||
|
/// reader-thread response delivery to Lua-side `Handle:await()`
|
||||||
|
/// resumption (mirrors [`crate::mcp::McpManager::new`]).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(supervisor: crate::lua_bindings::SharedProcessSupervisor) -> Self {
|
pub fn new(
|
||||||
|
supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
||||||
|
runtime: SharedAsyncRuntime,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
supervisor,
|
supervisor,
|
||||||
|
runtime,
|
||||||
clients: HashMap::new(),
|
clients: HashMap::new(),
|
||||||
process_to_server: HashMap::new(),
|
process_to_server: HashMap::new(),
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
|
pending_external: HashMap::new(),
|
||||||
|
request_timeout: Duration::from_secs(10),
|
||||||
restart_backoff: Duration::from_millis(500),
|
restart_backoff: Duration::from_millis(500),
|
||||||
diag_store: crate::diag::make_shared_store(),
|
diag_store: crate::diag::make_shared_store(),
|
||||||
completion_store: crate::completion::make_shared_store(),
|
completion_store: crate::completion::make_shared_store(),
|
||||||
|
|
@ -796,10 +871,18 @@ impl LspManager {
|
||||||
client.next_restart_at = None;
|
client.next_restart_at = None;
|
||||||
client.stdout = FrameParser::new();
|
client.stdout = FrameParser::new();
|
||||||
client.pending.clear();
|
client.pending.clear();
|
||||||
|
// T M4.5 task #7: the new generation has a fresh request-id
|
||||||
|
// space; stale cancelled ids can never collide, so reset to
|
||||||
|
// keep the set from growing across restarts.
|
||||||
|
client.cancelled_rids.clear();
|
||||||
// T M4.7: drop any pending response routes for this server.
|
// T M4.7: drop any pending response routes for this server.
|
||||||
// Their request ids belong to the previous generation; the
|
// Their request ids belong to the previous generation; the
|
||||||
// new server starts request id numbering fresh.
|
// new server starts request id numbering fresh.
|
||||||
self.pending_routes.retain(|(sid, _), _| *sid != id);
|
self.pending_routes.retain(|(sid, _), _| *sid != id);
|
||||||
|
// T M4.5: the previous generation's in-flight awaiters will
|
||||||
|
// never get a response (new process, fresh id space) — wake
|
||||||
|
// them cancelled before the restart.
|
||||||
|
self.drain_external_cancelled(id);
|
||||||
client.state = LspClientState::Starting;
|
client.state = LspClientState::Starting;
|
||||||
let proc_spec = client.spec.to_process_spec();
|
let proc_spec = client.spec.to_process_spec();
|
||||||
let pid = self.supervisor.borrow_mut().spawn(proc_spec)?;
|
let pid = self.supervisor.borrow_mut().spawn(proc_spec)?;
|
||||||
|
|
@ -899,97 +982,262 @@ impl LspManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register an async-runtime awaiter for the just-sent request
|
||||||
|
/// `req_id` and return its [`JobId`] — the value the Lua surface
|
||||||
|
/// wraps in a `pmacs.workers` Handle. Called only after
|
||||||
|
/// [`Self::send_request`] succeeded, so the wire request is live
|
||||||
|
/// and a response (or a teardown drain) will settle it; no
|
||||||
|
/// rollback path is needed here (unlike `mcp.rs`, where the
|
||||||
|
/// register precedes the wire send). T M4.5 async bridge.
|
||||||
|
///
|
||||||
|
/// T M4.5 task #7: the awaiter is registered with a supersede key
|
||||||
|
/// `lsp:{method}:{sid}:{uri}` — stable across calls for the same
|
||||||
|
/// (server, method, document). A newer request for the same thing
|
||||||
|
/// flips the prior job's [`CancellationToken`] through the async
|
||||||
|
/// runtime's supersede map; the next [`Self::drain_cancelled_externals`]
|
||||||
|
/// sweep settles that awaiter cancelled and `$/cancelRequest`s the
|
||||||
|
/// in-flight wire request. This is what keeps keystroke-driven
|
||||||
|
/// completion from piling up N in-flight requests on the server.
|
||||||
|
fn register_awaiter(
|
||||||
|
&mut self,
|
||||||
|
sid: LspServerId,
|
||||||
|
req_id: u64,
|
||||||
|
method: &str,
|
||||||
|
uri: &str,
|
||||||
|
) -> JobId {
|
||||||
|
let supersede = format!("lsp:{method}:{}:{uri}", sid.raw());
|
||||||
|
let (job_id, token) = self
|
||||||
|
.runtime
|
||||||
|
.register_external(JobKind::LspRequest, Some(&supersede));
|
||||||
|
self.pending_external.insert(
|
||||||
|
(sid, req_id),
|
||||||
|
PendingExternal {
|
||||||
|
method: method.to_owned(),
|
||||||
|
awaiters: vec![Awaiter { job_id, token }],
|
||||||
|
dispatched_at: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
job_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T M4.5 task #8: override the per-request timeout (default 10s).
|
||||||
|
/// Exposed to Lua as `pmacs.lsp.set_request_timeout_ms` and used
|
||||||
|
/// by the await-path tests to force fast timeouts.
|
||||||
|
pub fn set_request_timeout(&mut self, timeout: Duration) {
|
||||||
|
self.request_timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T M4.5 async bridge: settle every awaiter for `sid` as
|
||||||
|
/// cancelled and drop its `pending_external` entries. Called
|
||||||
|
/// wherever [`Self::pending_routes`] is purged for a server
|
||||||
|
/// (restart generation flip / terminal exit / forget) so a
|
||||||
|
/// coroutine parked on a request whose server went away wakes
|
||||||
|
/// with `{ tag = "cancelled" }` instead of hanging forever.
|
||||||
|
/// Mirrors `mcp.rs`'s drain-on-exit. Idempotent: a second call
|
||||||
|
/// finds no entries (and `complete_external_cancelled` is itself
|
||||||
|
/// idempotent against double-completion).
|
||||||
|
fn drain_external_cancelled(&mut self, sid: LspServerId) {
|
||||||
|
let keys: Vec<(LspServerId, u64)> = self
|
||||||
|
.pending_external
|
||||||
|
.keys()
|
||||||
|
.filter(|(s, _)| *s == sid)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
for k in keys {
|
||||||
|
if let Some(p) = self.pending_external.remove(&k) {
|
||||||
|
for a in &p.awaiters {
|
||||||
|
self.runtime.complete_external_cancelled(a.job_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T M4.5 task #7: per-tick per-awaiter cancellation sweep for
|
||||||
|
/// `sid`. An awaiter whose [`CancellationToken`] was flipped —
|
||||||
|
/// either by `Handle:cancel()` or by being superseded by a newer
|
||||||
|
/// same-key request (see [`Self::register_awaiter`]) — is removed
|
||||||
|
/// and settled cancelled; the in-flight wire request continues if
|
||||||
|
/// sibling awaiters still want it. When the *last* awaiter of a
|
||||||
|
/// request abandons it, the entry and its route are dropped, the
|
||||||
|
/// rid is recorded in `cancelled_rids` so a late response is
|
||||||
|
/// dropped silently, and `$/cancelRequest` is sent best-effort so
|
||||||
|
/// the server can stop working. Mirrors `mcp.rs`'s
|
||||||
|
/// `drain_cancelled_externals` (LSP has no resource cache, so the
|
||||||
|
/// cache-state plumbing is omitted).
|
||||||
|
///
|
||||||
|
/// T M4.5 task #8: the same sweep also fails any awaiter whose
|
||||||
|
/// request has outlived [`Self::request_timeout`] — a server that
|
||||||
|
/// stays alive but never answers a particular id would otherwise
|
||||||
|
/// park its coroutine forever. Timed-out entries take the same
|
||||||
|
/// abandon path as fully-cancelled ones (`$/cancelRequest` +
|
||||||
|
/// `cancelled_rids`), but settle `failed` rather than `cancelled`.
|
||||||
|
fn drain_cancelled_externals(&mut self, sid: LspServerId) {
|
||||||
|
let mut cancelled_jobs: Vec<JobId> = Vec::new();
|
||||||
|
let mut timed_out_jobs: Vec<(JobId, String)> = Vec::new();
|
||||||
|
let mut abandoned_rids: Vec<u64> = Vec::new();
|
||||||
|
// Captured into locals so the `retain` closure doesn't have to
|
||||||
|
// borrow `self` (it already borrows `self.pending_external`).
|
||||||
|
let now = Instant::now();
|
||||||
|
let timeout = self.request_timeout;
|
||||||
|
let timeout_ms = timeout.as_millis();
|
||||||
|
self.pending_external.retain(|(s, rid), p| {
|
||||||
|
if *s != sid {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut still: Vec<Awaiter> = Vec::with_capacity(p.awaiters.len());
|
||||||
|
for a in p.awaiters.drain(..) {
|
||||||
|
if a.token.is_cancelled() {
|
||||||
|
cancelled_jobs.push(a.job_id);
|
||||||
|
} else {
|
||||||
|
still.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// T M4.5 task #8: any awaiter that survived the cancel
|
||||||
|
// pass but whose request has outlived the timeout fails
|
||||||
|
// now — the server is alive but not answering this id.
|
||||||
|
if !still.is_empty() && now.duration_since(p.dispatched_at) >= timeout {
|
||||||
|
for a in still.drain(..) {
|
||||||
|
timed_out_jobs.push((a.job_id, p.method.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.awaiters = still;
|
||||||
|
if p.awaiters.is_empty() {
|
||||||
|
abandoned_rids.push(*rid);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for job_id in cancelled_jobs {
|
||||||
|
self.runtime.complete_external_cancelled(job_id);
|
||||||
|
}
|
||||||
|
for (job_id, method) in timed_out_jobs {
|
||||||
|
self.runtime.complete_external_failed(
|
||||||
|
job_id,
|
||||||
|
format!("LSP {method}: request timed out after {timeout_ms}ms"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for rid in abandoned_rids {
|
||||||
|
self.pending_routes.remove(&(sid, rid));
|
||||||
|
if let Some(client) = self.clients.get_mut(&sid) {
|
||||||
|
client.pending.remove(&rid);
|
||||||
|
client.cancelled_rids.insert(rid);
|
||||||
|
}
|
||||||
|
self.send_cancel_request(sid, rid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `$/cancelRequest { id }` to `sid`, best-effort. A server
|
||||||
|
/// that is not accepting writes (stopped / crashed) is skipped by
|
||||||
|
/// [`Self::send_notification`]'s state guard; the `Err` is
|
||||||
|
/// intentionally ignored. T M4.5 task #7.
|
||||||
|
fn send_cancel_request(&mut self, sid: LspServerId, rid: u64) {
|
||||||
|
let _ = self.send_notification(sid, "$/cancelRequest", json!({ "id": rid }));
|
||||||
|
}
|
||||||
|
|
||||||
/// Send `textDocument/completion` for `uri` at `(line, col)`.
|
/// Send `textDocument/completion` for `uri` at `(line, col)`.
|
||||||
/// The response is absorbed into the completion store at
|
/// The response is absorbed into the completion store at
|
||||||
/// `(sid, uri)`; the same `LspEventKind::Response` event is also
|
/// `(sid, uri)` (popup consumers untouched) and also settles the
|
||||||
/// emitted so direct observers can see the raw payload.
|
/// returned awaiter. The same `LspEventKind::Response` event is
|
||||||
/// Returns the JSON-RPC request id.
|
/// still emitted for raw observers. Returns the async-runtime
|
||||||
|
/// [`JobId`] the response will settle (`type JobId = u64`, so the
|
||||||
|
/// signature is unchanged for existing Rust callers — only the
|
||||||
|
/// value's meaning moved from JSON-RPC id to job id, and no
|
||||||
|
/// caller consumes it). T M4.5 async bridge.
|
||||||
pub fn request_completion(
|
pub fn request_completion(
|
||||||
&mut self,
|
&mut self,
|
||||||
sid: LspServerId,
|
sid: LspServerId,
|
||||||
uri: impl Into<String>,
|
uri: impl Into<String>,
|
||||||
line: u32,
|
line: u32,
|
||||||
col: u32,
|
col: u32,
|
||||||
) -> Result<u64, String> {
|
) -> Result<JobId, String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
let params = json!({
|
let params = json!({
|
||||||
"textDocument": { "uri": uri.clone() },
|
"textDocument": { "uri": uri.clone() },
|
||||||
"position": { "line": line, "character": col }
|
"position": { "line": line, "character": col }
|
||||||
});
|
});
|
||||||
let req_id = self.send_request(sid, "textDocument/completion", params)?;
|
let req_id = self.send_request(sid, "textDocument/completion", params)?;
|
||||||
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/completion", &uri);
|
||||||
self.pending_routes
|
self.pending_routes
|
||||||
.insert((sid, req_id), ResponseRoute::Completion { uri });
|
.insert((sid, req_id), ResponseRoute::Completion { uri });
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `textDocument/hover` for `uri` at `(line, col)`.
|
/// Send `textDocument/hover` for `uri` at `(line, col)`. Returns
|
||||||
|
/// the async-runtime [`JobId`] the response will settle.
|
||||||
pub fn request_hover(
|
pub fn request_hover(
|
||||||
&mut self,
|
&mut self,
|
||||||
sid: LspServerId,
|
sid: LspServerId,
|
||||||
uri: impl Into<String>,
|
uri: impl Into<String>,
|
||||||
line: u32,
|
line: u32,
|
||||||
col: u32,
|
col: u32,
|
||||||
) -> Result<u64, String> {
|
) -> Result<JobId, String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
let params = json!({
|
let params = json!({
|
||||||
"textDocument": { "uri": uri.clone() },
|
"textDocument": { "uri": uri.clone() },
|
||||||
"position": { "line": line, "character": col }
|
"position": { "line": line, "character": col }
|
||||||
});
|
});
|
||||||
let req_id = self.send_request(sid, "textDocument/hover", params)?;
|
let req_id = self.send_request(sid, "textDocument/hover", params)?;
|
||||||
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/hover", &uri);
|
||||||
self.pending_routes
|
self.pending_routes
|
||||||
.insert((sid, req_id), ResponseRoute::Hover { uri });
|
.insert((sid, req_id), ResponseRoute::Hover { uri });
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `textDocument/signatureHelp` for `uri` at `(line, col)`.
|
/// Send `textDocument/signatureHelp` for `uri` at `(line, col)`.
|
||||||
|
/// Returns the async-runtime [`JobId`] the response will settle.
|
||||||
pub fn request_signature_help(
|
pub fn request_signature_help(
|
||||||
&mut self,
|
&mut self,
|
||||||
sid: LspServerId,
|
sid: LspServerId,
|
||||||
uri: impl Into<String>,
|
uri: impl Into<String>,
|
||||||
line: u32,
|
line: u32,
|
||||||
col: u32,
|
col: u32,
|
||||||
) -> Result<u64, String> {
|
) -> Result<JobId, String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
let params = json!({
|
let params = json!({
|
||||||
"textDocument": { "uri": uri.clone() },
|
"textDocument": { "uri": uri.clone() },
|
||||||
"position": { "line": line, "character": col }
|
"position": { "line": line, "character": col }
|
||||||
});
|
});
|
||||||
let req_id = self.send_request(sid, "textDocument/signatureHelp", params)?;
|
let req_id = self.send_request(sid, "textDocument/signatureHelp", params)?;
|
||||||
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/signatureHelp", &uri);
|
||||||
self.pending_routes
|
self.pending_routes
|
||||||
.insert((sid, req_id), ResponseRoute::Signature { uri });
|
.insert((sid, req_id), ResponseRoute::Signature { uri });
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `textDocument/definition` for `uri` at `(line, col)`. The
|
/// Send `textDocument/definition` for `uri` at `(line, col)`. The
|
||||||
/// response is absorbed into the definition store at `(sid, uri)`.
|
/// response is absorbed into the definition store at `(sid, uri)`.
|
||||||
|
/// Returns the async-runtime [`JobId`] the response will settle.
|
||||||
pub fn request_definition(
|
pub fn request_definition(
|
||||||
&mut self,
|
&mut self,
|
||||||
sid: LspServerId,
|
sid: LspServerId,
|
||||||
uri: impl Into<String>,
|
uri: impl Into<String>,
|
||||||
line: u32,
|
line: u32,
|
||||||
col: u32,
|
col: u32,
|
||||||
) -> Result<u64, String> {
|
) -> Result<JobId, String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
let params = json!({
|
let params = json!({
|
||||||
"textDocument": { "uri": uri.clone() },
|
"textDocument": { "uri": uri.clone() },
|
||||||
"position": { "line": line, "character": col }
|
"position": { "line": line, "character": col }
|
||||||
});
|
});
|
||||||
let req_id = self.send_request(sid, "textDocument/definition", params)?;
|
let req_id = self.send_request(sid, "textDocument/definition", params)?;
|
||||||
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/definition", &uri);
|
||||||
self.pending_routes
|
self.pending_routes
|
||||||
.insert((sid, req_id), ResponseRoute::Definition { uri });
|
.insert((sid, req_id), ResponseRoute::Definition { uri });
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `textDocument/formatting` for `uri` with `tab_size` /
|
/// Send `textDocument/formatting` for `uri` with `tab_size` /
|
||||||
/// `insert_spaces` formatting options. The response is absorbed
|
/// `insert_spaces` formatting options. The response is absorbed
|
||||||
/// into the formatting store at `(sid, uri)`.
|
/// into the formatting store at `(sid, uri)`. Returns the
|
||||||
|
/// async-runtime [`JobId`] the response will settle.
|
||||||
pub fn request_formatting(
|
pub fn request_formatting(
|
||||||
&mut self,
|
&mut self,
|
||||||
sid: LspServerId,
|
sid: LspServerId,
|
||||||
uri: impl Into<String>,
|
uri: impl Into<String>,
|
||||||
tab_size: u32,
|
tab_size: u32,
|
||||||
insert_spaces: bool,
|
insert_spaces: bool,
|
||||||
) -> Result<u64, String> {
|
) -> Result<JobId, String> {
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
let params = json!({
|
let params = json!({
|
||||||
"textDocument": { "uri": uri.clone() },
|
"textDocument": { "uri": uri.clone() },
|
||||||
|
|
@ -999,9 +1247,10 @@ impl LspManager {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let req_id = self.send_request(sid, "textDocument/formatting", params)?;
|
let req_id = self.send_request(sid, "textDocument/formatting", params)?;
|
||||||
|
let job_id = self.register_awaiter(sid, req_id, "textDocument/formatting", &uri);
|
||||||
self.pending_routes
|
self.pending_routes
|
||||||
.insert((sid, req_id), ResponseRoute::Formatting { uri });
|
.insert((sid, req_id), ResponseRoute::Formatting { uri });
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reply to a server-initiated request.
|
/// Reply to a server-initiated request.
|
||||||
|
|
@ -1036,6 +1285,10 @@ impl LspManager {
|
||||||
let server_ids: Vec<LspServerId> = self.clients.keys().copied().collect();
|
let server_ids: Vec<LspServerId> = self.clients.keys().copied().collect();
|
||||||
for sid in server_ids {
|
for sid in server_ids {
|
||||||
self.drain_process_events(sid);
|
self.drain_process_events(sid);
|
||||||
|
// T M4.5 task #7: after this tick's responses are absorbed
|
||||||
|
// (above), reap any awaiter cancelled or superseded since
|
||||||
|
// the last tick.
|
||||||
|
self.drain_cancelled_externals(sid);
|
||||||
self.maybe_restart(sid);
|
self.maybe_restart(sid);
|
||||||
}
|
}
|
||||||
// T M4.8: housekeeping for the status tracker (releases stale
|
// T M4.8: housekeeping for the status tracker (releases stale
|
||||||
|
|
@ -1228,6 +1481,11 @@ impl LspManager {
|
||||||
!was_shutdown && should_restart(client.spec.restart),
|
!was_shutdown && should_restart(client.spec.restart),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
// T M4.5: the server is gone (clean stop or crash) and will
|
||||||
|
// never answer its in-flight requests. Wake every awaiter
|
||||||
|
// cancelled now — not at the eventual restart/forget, which
|
||||||
|
// may never come under `LspRestartPolicy::Never`.
|
||||||
|
self.drain_external_cancelled(sid);
|
||||||
if was_shutdown {
|
if was_shutdown {
|
||||||
self.push_event(sid, at, LspEventKind::Stopped);
|
self.push_event(sid, at, LspEventKind::Stopped);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1363,6 +1621,14 @@ impl LspManager {
|
||||||
let Some(client) = self.clients.get_mut(&sid) else {
|
let Some(client) = self.clients.get_mut(&sid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// T M4.5 task #7: the cancel/response race. We sent
|
||||||
|
// `$/cancelRequest` for this id after every awaiter
|
||||||
|
// abandoned it; a server may answer anyway. By definition
|
||||||
|
// no awaiter is listening, so drop it silently rather
|
||||||
|
// than emitting a `ProtocolError` for an expected outcome.
|
||||||
|
if client.cancelled_rids.remove(&rid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
client.pending.remove(&rid).unwrap_or_default()
|
client.pending.remove(&rid).unwrap_or_default()
|
||||||
};
|
};
|
||||||
if method.is_empty() {
|
if method.is_empty() {
|
||||||
|
|
@ -1423,6 +1689,28 @@ impl LspManager {
|
||||||
{
|
{
|
||||||
self.absorb_routed_response(sid, &route, value);
|
self.absorb_routed_response(sid, &route, value);
|
||||||
}
|
}
|
||||||
|
// T M4.5 async bridge: settle every awaiter parked on this
|
||||||
|
// request. Deliberately independent of the store-absorb guard
|
||||||
|
// above — a null result (e.g. "no hover here") is still a
|
||||||
|
// successful response and must wake `:await()` with `nil`
|
||||||
|
// rather than leave the coroutine parked forever. A server
|
||||||
|
// error settles the awaiter as failed so `:await()` raises the
|
||||||
|
// structured `{ tag = "failed" }`. The typed store was already
|
||||||
|
// populated above when present (hybrid model); popup/gutter
|
||||||
|
// consumers are unaffected by this block.
|
||||||
|
if let Some(p) = self.pending_external.remove(&(sid, rid)) {
|
||||||
|
if let Some(err) = error.as_ref() {
|
||||||
|
let msg = format!("LSP {} error {}: {}", p.method, err.code, err.message);
|
||||||
|
for a in &p.awaiters {
|
||||||
|
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = result.clone().unwrap_or(Value::Null);
|
||||||
|
for a in &p.awaiters {
|
||||||
|
self.runtime.complete_external_ok(a.job_id, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Generic response.
|
// Generic response.
|
||||||
self.push_event(
|
self.push_event(
|
||||||
sid,
|
sid,
|
||||||
|
|
@ -1659,6 +1947,10 @@ impl LspManager {
|
||||||
self.clients.remove(&sid);
|
self.clients.remove(&sid);
|
||||||
self.pending.remove(&sid);
|
self.pending.remove(&sid);
|
||||||
self.pending_routes.retain(|(s, _), _| *s != sid);
|
self.pending_routes.retain(|(s, _), _| *s != sid);
|
||||||
|
// T M4.5: belt-and-braces — on_exit already drained on the
|
||||||
|
// terminal transition; this catches any awaiter registered
|
||||||
|
// between exit and forget. Idempotent.
|
||||||
|
self.drain_external_cancelled(sid);
|
||||||
self.status_tracker.forget(sid);
|
self.status_tracker.forget(sid);
|
||||||
// T M4.9: drop the project scoping so the next
|
// T M4.9: drop the project scoping so the next
|
||||||
// ensure_server_for_project call spawns a fresh server.
|
// ensure_server_for_project call spawns a fresh server.
|
||||||
|
|
|
||||||
|
|
@ -7117,16 +7117,31 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
// T M4.5 task #8: hard per-request timeout, tunable from
|
||||||
|
// init.lua (e.g. raise it for a slow language server on a
|
||||||
|
// cold cache, or lower it in tests).
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
"request_completion",
|
"set_request_timeout_ms",
|
||||||
|
lua.create_function(move |_, ms: u64| {
|
||||||
|
m.borrow_mut()
|
||||||
|
.set_request_timeout(std::time::Duration::from_millis(ms));
|
||||||
|
Ok(())
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let m = manager.clone();
|
||||||
|
lsp_mod.set(
|
||||||
|
"_request_completion_raw",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
||||||
let req_id = m
|
let job_id = m
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.request_completion(id.0, uri, line, col)
|
.request_completion(id.0, uri, line, col)
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -7135,14 +7150,14 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
"request_hover",
|
"_request_hover_raw",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
||||||
let req_id = m
|
let job_id = m
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.request_hover(id.0, uri, line, col)
|
.request_hover(id.0, uri, line, col)
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -7151,14 +7166,14 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
"request_signature_help",
|
"_request_signature_help_raw",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
||||||
let req_id = m
|
let job_id = m
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.request_signature_help(id.0, uri, line, col)
|
.request_signature_help(id.0, uri, line, col)
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -7167,14 +7182,14 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
"request_definition",
|
"_request_definition_raw",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
|
||||||
let req_id = m
|
let job_id = m
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.request_definition(id.0, uri, line, col)
|
.request_definition(id.0, uri, line, col)
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -7183,7 +7198,7 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
lsp_mod.set(
|
lsp_mod.set(
|
||||||
"request_formatting",
|
"_request_formatting_raw",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |_,
|
move |_,
|
||||||
(id, uri, tab_size, insert_spaces): (
|
(id, uri, tab_size, insert_spaces): (
|
||||||
|
|
@ -7192,11 +7207,11 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
u32,
|
u32,
|
||||||
Option<bool>,
|
Option<bool>,
|
||||||
)| {
|
)| {
|
||||||
let req_id = m
|
let job_id = m
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.request_formatting(id.0, uri, tab_size, insert_spaces.unwrap_or(true))
|
.request_formatting(id.0, uri, tab_size, insert_spaces.unwrap_or(true))
|
||||||
.map_err(mlua::Error::external)?;
|
.map_err(mlua::Error::external)?;
|
||||||
Ok(req_id)
|
Ok(job_id)
|
||||||
},
|
},
|
||||||
)?,
|
)?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -7424,8 +7439,9 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
|
||||||
pub fn make_lsp_manager(
|
pub fn make_lsp_manager(
|
||||||
lua: &Lua,
|
lua: &Lua,
|
||||||
supervisor: SharedProcessSupervisor,
|
supervisor: SharedProcessSupervisor,
|
||||||
|
runtime: crate::async_runtime::SharedAsyncRuntime,
|
||||||
) -> mlua::Result<SharedLspManager> {
|
) -> mlua::Result<SharedLspManager> {
|
||||||
let manager = Rc::new(RefCell::new(LspManager::new(supervisor)));
|
let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime)));
|
||||||
install_lsp(lua, &manager)?;
|
install_lsp(lua, &manager)?;
|
||||||
install_diag(lua, &manager)?;
|
install_diag(lua, &manager)?;
|
||||||
install_completion(lua, &manager)?;
|
install_completion(lua, &manager)?;
|
||||||
|
|
|
||||||
|
|
@ -1069,7 +1069,12 @@ fn make_lsp_test_manager() -> (
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
let sup = Rc::new(RefCell::new(pmacs::process::ProcessSupervisor::new()));
|
let sup = Rc::new(RefCell::new(pmacs::process::ProcessSupervisor::new()));
|
||||||
let mgr = Rc::new(RefCell::new(LspManager::new(sup.clone())));
|
// The manager owns the runtime Rc; these store-assertion tests
|
||||||
|
// never tick it, so the registered external entries are simply
|
||||||
|
// never drained (harmless). The await-path tests (T M4.5 task #9)
|
||||||
|
// use a separate helper that also returns the runtime.
|
||||||
|
let runtime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||||
|
let mgr = Rc::new(RefCell::new(LspManager::new(sup.clone(), runtime)));
|
||||||
(sup, mgr)
|
(sup, mgr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3368,3 +3373,276 @@ fn project_search_boundary_round_trips_via_lua() {
|
||||||
"set_search_boundary(nil) must clear back to nil"
|
"set_search_boundary(nil) must clear back to nil"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// T M4.5 async bridge — Handle:await() path (task #9).
|
||||||
|
//
|
||||||
|
// These drive the real end-to-end surface: EditorState (runtime wired
|
||||||
|
// into the LSP manager + builtin lsp.lua loaded), `pmacs.lsp.spawn`
|
||||||
|
// with a fake-server mode, a `pmacs.async` coroutine that `:await()`s,
|
||||||
|
// and Rust ticking processes/lsp/async until the coroutine settles a
|
||||||
|
// `_G` flag. Mirrors `m9_1_lua_send_request_returns_awaitable_handle`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tick processes → lsp → async until the Lua expression `flag`
|
||||||
|
/// evaluates true, or the deadline elapses. Returns whether it fired.
|
||||||
|
fn pump_lua_flag(state: &mut pmacs::editor::EditorState, flag: &str, secs: u64) -> bool {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
|
loop {
|
||||||
|
state.tick_processes();
|
||||||
|
state.tick_lsp();
|
||||||
|
state.tick_async();
|
||||||
|
let done: bool = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(format!("return ({flag}) == true"))
|
||||||
|
.eval()
|
||||||
|
.unwrap_or(false);
|
||||||
|
if done {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the fake LSP via the Lua surface (optionally in a test mode)
|
||||||
|
/// and pump until the manager reports it `initialized`.
|
||||||
|
fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str>) {
|
||||||
|
let fake = fake_lsp_path();
|
||||||
|
let env = match mode {
|
||||||
|
Some(m) => format!(", env = {{ PMACS_FAKE_LSP_MODE = '{m}' }}"),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(format!(
|
||||||
|
"_G._lsp = pmacs.lsp.spawn({{ label='await-test', language_id='rust', \
|
||||||
|
command='{fake}', restart='never'{env} }})"
|
||||||
|
))
|
||||||
|
.exec()
|
||||||
|
.expect("spawn lsp via Lua");
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
state.tick_processes();
|
||||||
|
state.tick_lsp();
|
||||||
|
state.tick_async();
|
||||||
|
let init: bool = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"for _,r in ipairs(pmacs.lsp.list()) do \
|
||||||
|
if r.state and r.state.kind=='initialized' then return true end end \
|
||||||
|
return false",
|
||||||
|
)
|
||||||
|
.eval()
|
||||||
|
.unwrap_or(false);
|
||||||
|
if init {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"fake LSP never reached initialized (mode {mode:?})"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Success: `request_completion():await()` returns the result table,
|
||||||
|
/// and the typed store is *also* populated (the hybrid model).
|
||||||
|
#[test]
|
||||||
|
fn m4_5_await_completion_returns_result_and_populates_store() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
spawn_lsp_and_init(&mut state, None);
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"_G._done=false _G._res=nil
|
||||||
|
pmacs.async(function()
|
||||||
|
_G._res = pmacs.lsp.request_completion(_G._lsp,'file:///x.rs',0,0):await()
|
||||||
|
_G._items = pmacs.completion.items(_G._lsp,'file:///x.rs')
|
||||||
|
_G._done=true
|
||||||
|
end)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("dispatch await coroutine");
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(&mut state, "_G._done", 5),
|
||||||
|
"await coroutine never completed"
|
||||||
|
);
|
||||||
|
let (res_is_table, store_count): (bool, i64) = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return type(_G._res)=='table', (_G._items and #_G._items) or 0")
|
||||||
|
.eval()
|
||||||
|
.expect("read result");
|
||||||
|
assert!(res_is_table, "await() should return the result table");
|
||||||
|
assert!(
|
||||||
|
store_count >= 3,
|
||||||
|
"hybrid: completion store must also be populated (got {store_count})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server JSON-RPC error → `:await()` raises `{ tag = 'failed' }`.
|
||||||
|
#[test]
|
||||||
|
fn m4_5_await_server_error_raises_failed() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
spawn_lsp_and_init(&mut state, Some("error"));
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"_G._done=false _G._tag=nil _G._msg=nil
|
||||||
|
pmacs.async(function()
|
||||||
|
local ok,v = pcall(function()
|
||||||
|
return pmacs.lsp.request_hover(_G._lsp,'file:///x.rs',0,0):await()
|
||||||
|
end)
|
||||||
|
_G._tag = (not ok) and type(v)=='table' and v.tag or 'unexpected-ok'
|
||||||
|
_G._msg = (type(v)=='table' and v.message) or ''
|
||||||
|
_G._done=true
|
||||||
|
end)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("dispatch await coroutine");
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(&mut state, "_G._done", 5),
|
||||||
|
"await coroutine never completed"
|
||||||
|
);
|
||||||
|
let (tag, msg): (String, String) = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return _G._tag, _G._msg")
|
||||||
|
.eval()
|
||||||
|
.expect("read tag");
|
||||||
|
assert_eq!(tag, "failed", "server error must surface as failed");
|
||||||
|
assert!(
|
||||||
|
msg.contains("synthetic error"),
|
||||||
|
"failure message should carry the server's error text; got {msg:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server stopped while a request is in flight → the teardown drain
|
||||||
|
/// wakes the awaiter with `{ tag = 'cancelled' }` (not a hang).
|
||||||
|
#[test]
|
||||||
|
fn m4_5_await_cancelled_when_server_stops_mid_request() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
spawn_lsp_and_init(&mut state, Some("silent"));
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"_G._done=false _G._tag=nil
|
||||||
|
pmacs.async(function()
|
||||||
|
local ok,v = pcall(function()
|
||||||
|
return pmacs.lsp.request_definition(_G._lsp,'file:///x.rs',0,0):await()
|
||||||
|
end)
|
||||||
|
_G._tag = (not ok) and type(v)=='table' and v.tag or 'unexpected-ok'
|
||||||
|
_G._done=true
|
||||||
|
end)
|
||||||
|
-- Request is now in flight against a silent server; stop it.
|
||||||
|
pmacs.lsp.stop(_G._lsp)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("dispatch + stop");
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(&mut state, "_G._done", 5),
|
||||||
|
"await coroutine never completed (server-stop drain didn't wake it)"
|
||||||
|
);
|
||||||
|
let tag: String = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return _G._tag")
|
||||||
|
.eval()
|
||||||
|
.expect("read tag");
|
||||||
|
assert_eq!(tag, "cancelled", "server-gone must wake await as cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alive-but-silent server → the per-request timeout sweep fails the
|
||||||
|
/// awaiter (`{ tag = 'failed', message ~ 'timed out' }`) so it can't
|
||||||
|
/// park forever.
|
||||||
|
#[test]
|
||||||
|
fn m4_5_await_times_out_against_silent_server() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
spawn_lsp_and_init(&mut state, Some("silent"));
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"pmacs.lsp.set_request_timeout_ms(150)
|
||||||
|
_G._done=false _G._tag=nil _G._msg=nil
|
||||||
|
pmacs.async(function()
|
||||||
|
local ok,v = pcall(function()
|
||||||
|
return pmacs.lsp.request_hover(_G._lsp,'file:///x.rs',0,0):await()
|
||||||
|
end)
|
||||||
|
_G._tag = (not ok) and type(v)=='table' and v.tag or 'unexpected-ok'
|
||||||
|
_G._msg = (type(v)=='table' and v.message) or ''
|
||||||
|
_G._done=true
|
||||||
|
end)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("dispatch await coroutine");
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(&mut state, "_G._done", 5),
|
||||||
|
"await coroutine never timed out"
|
||||||
|
);
|
||||||
|
let (tag, msg): (String, String) = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return _G._tag, _G._msg")
|
||||||
|
.eval()
|
||||||
|
.expect("read tag");
|
||||||
|
assert_eq!(tag, "failed", "timeout must surface as failed");
|
||||||
|
assert!(
|
||||||
|
msg.contains("timed out"),
|
||||||
|
"timeout message should say so; got {msg:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newer same-(server,method,uri) request supersedes the in-flight
|
||||||
|
/// one: the first handle's `:await()` raises `{ tag = 'cancelled' }`.
|
||||||
|
/// Silent server so the only way the first can settle is supersede.
|
||||||
|
#[test]
|
||||||
|
fn m4_5_await_superseded_request_is_cancelled() {
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
let mut state = EditorState::new();
|
||||||
|
spawn_lsp_and_init(&mut state, Some("silent"));
|
||||||
|
state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load(
|
||||||
|
"_G._done=false _G._h1=nil
|
||||||
|
pmacs.async(function()
|
||||||
|
local h1 = pmacs.lsp.request_completion(_G._lsp,'file:///x.rs',0,0)
|
||||||
|
local h2 = pmacs.lsp.request_completion(_G._lsp,'file:///x.rs',0,0)
|
||||||
|
local ok1,v1 = pcall(function() return h1:await() end)
|
||||||
|
_G._h1 = (not ok1) and type(v1)=='table' and v1.tag or 'unexpected-ok'
|
||||||
|
_G._done=true
|
||||||
|
-- h2 left in flight; the server stop below drains it.
|
||||||
|
end)",
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.expect("dispatch supersede coroutine");
|
||||||
|
assert!(
|
||||||
|
pump_lua_flag(&mut state, "_G._done", 5),
|
||||||
|
"supersede coroutine never completed"
|
||||||
|
);
|
||||||
|
let h1: String = state
|
||||||
|
.lua_host
|
||||||
|
.lua()
|
||||||
|
.load("return _G._h1")
|
||||||
|
.eval()
|
||||||
|
.expect("read h1 tag");
|
||||||
|
assert_eq!(
|
||||||
|
h1, "cancelled",
|
||||||
|
"the superseded (older) request must await-cancel"
|
||||||
|
);
|
||||||
|
let _ = state.lua_host.lua().load("pmacs.lsp.stop(_G._lsp)").exec();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@ fn m9_1_multiple_mcps_coexist_on_one_manager() {
|
||||||
fn m9_1_lsp_and_mcp_can_coexist_on_one_supervisor() {
|
fn m9_1_lsp_and_mcp_can_coexist_on_one_supervisor() {
|
||||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||||
let lsp_mgr = Rc::new(RefCell::new(LspManager::new(sup.clone())));
|
let lsp_mgr = Rc::new(RefCell::new(LspManager::new(sup.clone(), runtime.clone())));
|
||||||
let mcp_mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
let mcp_mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||||
|
|
||||||
// Spin up one LSP and one MCP through the same supervisor.
|
// Spin up one LSP and one MCP through the same supervisor.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue