fix(completion): address TUI-validation findings (LSP query gaps, scoping, prefix keys, window scope)
Five findings from the manual validation pass, all in-branch: 1. LSP-only words never queried the server: the auto-open path fired request_completion only when the sync providers already produced rows. An empty sweep now leaves a pending session and the request always fires; isIncomplete responses re-request on further typing. Corollary: attachment_for_request now flushes-if-attached but NEVER attaches -- the first cut wrapped attached_for_active, which spawns servers on demand, i.e. per-keystroke spawn attempts in unattached buffers (wedged the parallel m4 suite; serial ran 3x slower). Attachment stays buffer-open policy. 2. Cross-buffer LSP leak: the built-in provider's no-uri fallback was the legacy global store drain, so scratch/unattached buffers could show another file's cached completions. Strict now: no uri, no rows. 3. Pending prefixes own the keyboard: Action::Pending (C-x ...) dismisses the popup and the popup shadow is guarded on an empty dispatcher prefix, so the sequence's continuation and its C-g abort reach the dispatcher instead of the popup. 4. Window-scoped sessions: CompletionPopupState.window_id (stamped by completion_popup_open; Lua never sees it). Only the owning window's overlay paints -- same-buffer splits each carry a persistent overlay -- and a focus change invalidates the session. 5. Flaky worker test: the /proc thread-count probe and the idempotence check both build EditorStates and could run concurrently, polluting the baseline; merged into one test (non-Linux keeps a portable idempotence variant). Regression tests for 1-4; framing doc gains the as-built notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a1f5b1ffd7
commit
61a31b3ad4
|
|
@ -176,7 +176,15 @@ pmacs.hook.add("buffer.after-edit", function()
|
||||||
close_popup()
|
close_popup()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not publish(buf, session.anchor, prefix, "incomplete", nil) then
|
if publish(buf, session.anchor, prefix, "incomplete", nil) then
|
||||||
|
-- isIncomplete contract: a partial server response must be
|
||||||
|
-- re-queried as the user keeps typing, not merely re-filtered.
|
||||||
|
local rec = pmacs.lsp.active_attachment()
|
||||||
|
if rec then
|
||||||
|
local ok, incomplete = pcall(pmacs.completion.is_incomplete, rec.server, rec.uri)
|
||||||
|
if ok and incomplete then request_lsp_then_refresh() end
|
||||||
|
end
|
||||||
|
else
|
||||||
session = nil -- narrowed to nothing: the session is over
|
session = nil -- narrowed to nothing: the session is over
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
|
|
@ -187,9 +195,14 @@ pmacs.hook.add("buffer.after-edit", function()
|
||||||
if key ~= prev_key or not prev_cursor or cursor - prev_cursor ~= 1 then return end
|
if key ~= prev_key or not prev_cursor or cursor - prev_cursor ~= 1 then return end
|
||||||
local prefix = word_prefix_before(buf, cursor)
|
local prefix = word_prefix_before(buf, cursor)
|
||||||
if #prefix >= MIN_PREFIX then
|
if #prefix >= MIN_PREFIX then
|
||||||
if publish(buf, cursor - #prefix, prefix, "invoked", nil) then
|
-- Fire the LSP request even when the synchronous providers came
|
||||||
request_lsp_then_refresh()
|
-- up empty: for an LSP-only word (no dabbrev/snippet/index hit,
|
||||||
|
-- cold store) the popup materializes when the response lands ---
|
||||||
|
-- the same pending-session shape as the trigger-char path.
|
||||||
|
if not publish(buf, cursor - #prefix, prefix, "invoked", nil) then
|
||||||
|
session = { key = key, anchor = cursor - #prefix, pending = true }
|
||||||
end
|
end
|
||||||
|
request_lsp_then_refresh()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if #prefix == 0 then
|
if #prefix == 0 then
|
||||||
|
|
|
||||||
|
|
@ -545,16 +545,22 @@ function pmacs.lsp.active_attachment()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Flushing variant for request-issuing callers outside this file
|
-- Flushing variant for request-issuing callers outside this file
|
||||||
-- (Q#C8): resolves (or attaches) the active buffer's server AND
|
-- (Q#C8): when the active buffer already has a server attached,
|
||||||
-- flushes any debounced didChange first, so the server answers the
|
-- flush any debounced didChange first and return the record, so the
|
||||||
-- caller's request against the current text --- exactly what every
|
-- caller's request is answered against the current text. Unlike the
|
||||||
-- interactive command in this file gets from the local
|
-- local `attached_for_active`, this NEVER triggers an attach: the
|
||||||
-- `attached_for_active`. The in-buffer completion driver
|
-- in-buffer completion driver calls it on ordinary typing, and
|
||||||
-- (builtin/runtime/completion.lua) calls this before
|
-- spawning language servers as a typing side effect is wrong (and,
|
||||||
-- textDocument/completion; a non-flushing peek would hand the server
|
-- concretely, wedged the m4 suite with per-keystroke spawn attempts
|
||||||
-- stale text after a typing burst.
|
-- across parallel tests). Attachment remains buffer-open policy.
|
||||||
function pmacs.lsp.attachment_for_request()
|
function pmacs.lsp.attachment_for_request()
|
||||||
return attached_for_active()
|
local buf = pmacs.window.buffer()
|
||||||
|
if not buf then return nil end
|
||||||
|
local key = tostring(buf)
|
||||||
|
local rec = attachments[key]
|
||||||
|
if not rec then return nil end
|
||||||
|
flush_did_change(key)
|
||||||
|
return rec
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Hooks --------------------------------------------------------------------
|
-- Hooks --------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,42 @@ and kill-ring are the named next table-stakes items).
|
||||||
shows misfires, the fix is the named hook-payload PR, not
|
shows misfires, the fix is the named hook-payload PR, not
|
||||||
heuristic patching.
|
heuristic patching.
|
||||||
|
|
||||||
|
## As-built notes — phase 1 (PR #92)
|
||||||
|
|
||||||
|
Landed close to the framing; the user's TUI validation pass surfaced
|
||||||
|
five findings, all addressed in-branch:
|
||||||
|
|
||||||
|
1. **LSP-only words never queried the server** — the auto-open path
|
||||||
|
fired `request_completion` only when the synchronous providers had
|
||||||
|
already produced rows. Now an empty sweep leaves a *pending*
|
||||||
|
session (the trigger-char shape) and the request always fires;
|
||||||
|
`isIncomplete` responses re-request on further typing via
|
||||||
|
`pmacs.completion.is_incomplete`. Corollary found while fixing:
|
||||||
|
`attachment_for_request` must flush-if-attached but **never
|
||||||
|
attach** — the first cut wrapped `attached_for_active`, which
|
||||||
|
spawns a server on demand, i.e. per-keystroke spawn attempts in
|
||||||
|
every unattached buffer (wedged the parallel m4 suite).
|
||||||
|
Attachment stays buffer-open policy.
|
||||||
|
2. **Strict URI scoping** — the built-in LSP provider now returns
|
||||||
|
*nothing* without `ctx.uri` (the framing's "legacy global drain
|
||||||
|
when absent" allowed unattached/scratch buffers to show another
|
||||||
|
file's cached completions).
|
||||||
|
3. **Pending prefixes own the keyboard** — `Action::Pending` (`C-x
|
||||||
|
...`) dismisses the popup, and the popup shadow is additionally
|
||||||
|
guarded on `dispatcher.pending().is_empty()`, so a sequence's
|
||||||
|
continuation and its `C-g` abort reach the dispatcher.
|
||||||
|
4. **Window-scoped sessions** — `CompletionPopupState.window_id`
|
||||||
|
(stamped by `completion_popup_open`; Lua never sees it): only the
|
||||||
|
owning window's overlay paints (same-buffer splits each carry a
|
||||||
|
persistent overlay), and a focus change invalidates the session.
|
||||||
|
5. The worker-pool teardown fix (signal-only `EditorState::drop`)
|
||||||
|
rode along in the PR — unrelated to completion, surfaced by
|
||||||
|
running the m4 gate.
|
||||||
|
|
||||||
|
Also caught by the new acceptance suite pre-validation:
|
||||||
|
`install_completion` rebuilt `pmacs.completion` and clobbered the
|
||||||
|
popup bindings — all `pmacs.completion` installers now merge.
|
||||||
|
|
||||||
## Deferred (named, not silently dropped)
|
## Deferred (named, not silently dropped)
|
||||||
|
|
||||||
- Snippet tabstops/placeholders (v1 inserts bodies literally).
|
- Snippet tabstops/placeholders (v1 inserts bodies literally).
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,14 @@ pub struct CompletionPopupState {
|
||||||
/// Buffer the popup targets. The session closes the moment the
|
/// Buffer the popup targets. The session closes the moment the
|
||||||
/// active buffer differs (Q#C3 validation).
|
/// active buffer differs (Q#C3 validation).
|
||||||
pub buffer_id: BufferId,
|
pub buffer_id: BufferId,
|
||||||
|
/// Window the popup belongs to. Stamped by
|
||||||
|
/// [`crate::editor_core::EditorCore::completion_popup_open`]
|
||||||
|
/// (Lua publishers don't know window identity), `None` only
|
||||||
|
/// before that stamp. Two splits showing the same buffer each
|
||||||
|
/// carry a persistent overlay --- without this, both would paint
|
||||||
|
/// the popup; with it, only the owning window's overlay renders,
|
||||||
|
/// and a focus change closes the session (Q#C3).
|
||||||
|
pub window_id: Option<crate::window::WindowId>,
|
||||||
/// Byte offset where the typed prefix starts. For a
|
/// Byte offset where the typed prefix starts. For a
|
||||||
/// trigger-character session (e.g. right after `.`) the prefix is
|
/// trigger-character session (e.g. right after `.`) the prefix is
|
||||||
/// empty and `anchor` equals the cursor.
|
/// empty and `anchor` equals the cursor.
|
||||||
|
|
@ -519,6 +527,7 @@ impl CompletionPopupState {
|
||||||
}
|
}
|
||||||
Some(Self {
|
Some(Self {
|
||||||
buffer_id,
|
buffer_id,
|
||||||
|
window_id: None,
|
||||||
anchor,
|
anchor,
|
||||||
prefix,
|
prefix,
|
||||||
candidates,
|
candidates,
|
||||||
|
|
@ -637,13 +646,19 @@ pub(crate) fn popup_window(n: usize, selected: usize, max: usize) -> (usize, usi
|
||||||
/// the popup would overflow the window's right edge.
|
/// the popup would overflow the window's right edge.
|
||||||
pub struct CompletionView {
|
pub struct CompletionView {
|
||||||
popup: SharedCompletionPopup,
|
popup: SharedCompletionPopup,
|
||||||
|
/// The window this overlay instance is attached to. Overlays
|
||||||
|
/// persist per window; only the one matching the session's
|
||||||
|
/// `window_id` paints, so same-buffer splits don't each show
|
||||||
|
/// the popup.
|
||||||
|
window_id: crate::window::WindowId,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompletionView {
|
impl CompletionView {
|
||||||
/// Build a view reading `popup`.
|
/// Build a view reading `popup`, rendering only when the open
|
||||||
|
/// session belongs to `window_id`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(popup: SharedCompletionPopup) -> Self {
|
pub fn new(popup: SharedCompletionPopup, window_id: crate::window::WindowId) -> Self {
|
||||||
Self { popup }
|
Self { popup, window_id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -826,6 +841,9 @@ impl View for CompletionView {
|
||||||
let Some(popup) = guard.as_ref() else {
|
let Some(popup) = guard.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if popup.window_id != Some(self.window_id) {
|
||||||
|
return; // the session belongs to another window
|
||||||
|
}
|
||||||
if popup.buffer_id != buf.id() {
|
if popup.buffer_id != buf.id() {
|
||||||
return; // this window shows a different buffer
|
return; // this window shows a different buffer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -624,15 +624,18 @@ fn project_kind_to_completion_kind(k: &crate::project_index::SymbolKind) -> Comp
|
||||||
/// the LSP completion store. The framework does **not** drive a
|
/// the LSP completion store. The framework does **not** drive a
|
||||||
/// fresh `textDocument/completion` request --- that's the editor's
|
/// fresh `textDocument/completion` request --- that's the editor's
|
||||||
/// job; we just read whatever the async pipeline has produced so
|
/// job; we just read whatever the async pipeline has produced so
|
||||||
/// far. With `ctx.uri` set (Q#C8 scoping, the popup driver's path)
|
/// far. Strictly scoped to `ctx.uri` (Q#C8): only that document's
|
||||||
/// only that document's entries surface --- across all servers keyed
|
/// entries surface, across all servers keyed to it. **No URI → no
|
||||||
/// to it --- so a popup never shows another buffer's candidates.
|
/// LSP candidates** --- an unattached/scratch buffer must never show
|
||||||
/// Without a URI the legacy global drain across every cached
|
/// another file's cached completions (the original global drain did
|
||||||
/// `(server_id, uri)` key applies. The registry's dedup collapses
|
/// exactly that). The registry's dedup collapses identical entries;
|
||||||
/// identical entries; the prefix score ranks them.
|
/// the prefix score ranks them.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn {
|
pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn {
|
||||||
Box::new(move |ctx: &CompletionContext| -> Vec<CompletionItem> {
|
Box::new(move |ctx: &CompletionContext| -> Vec<CompletionItem> {
|
||||||
|
let Some(uri) = ctx.uri.clone() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let store_handle = {
|
let store_handle = {
|
||||||
let mgr = lsp.borrow();
|
let mgr = lsp.borrow();
|
||||||
mgr.completion_store()
|
mgr.completion_store()
|
||||||
|
|
@ -641,11 +644,7 @@ pub fn lsp_completion_provider(lsp: crate::lsp::SharedLspManager) -> ProviderFn
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut out: Vec<CompletionItem> = Vec::new();
|
let mut out: Vec<CompletionItem> = Vec::new();
|
||||||
let keys: Vec<_> = store
|
let keys: Vec<_> = store.keys().filter(|k| k.uri == uri).cloned().collect();
|
||||||
.keys()
|
|
||||||
.filter(|k| ctx.uri.as_ref().is_none_or(|uri| k.uri == *uri))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
for item in store.items(&key) {
|
for item in store.items(&key) {
|
||||||
out.push(item.clone());
|
out.push(item.clone());
|
||||||
|
|
|
||||||
|
|
@ -578,8 +578,13 @@ impl EditorState {
|
||||||
// below, so typing keeps self-inserting and motion keys keep
|
// below, so typing keeps self-inserting and motion keys keep
|
||||||
// moving. The post-dispatch validation at the bottom of this
|
// moving. The post-dispatch validation at the bottom of this
|
||||||
// function closes the session when a fallen-through key breaks
|
// function closes the session when a fallen-through key breaks
|
||||||
// the anchor/prefix invariant.
|
// the anchor/prefix invariant. A pending multi-key prefix owns
|
||||||
|
// the keyboard: while one is in flight (`C-x ...`) the popup
|
||||||
|
// must not steal its continuation or its `C-g` abort --- and
|
||||||
|
// the Pending arm below closes the popup anyway, so this guard
|
||||||
|
// only covers the same-dispatch race.
|
||||||
if self.core.borrow().completion_popup_is_open()
|
if self.core.borrow().completion_popup_is_open()
|
||||||
|
&& self.dispatcher.pending().is_empty()
|
||||||
&& let Some(key) = CompletionPopupKey::from_chord(chord)
|
&& let Some(key) = CompletionPopupKey::from_chord(chord)
|
||||||
{
|
{
|
||||||
self.dispatch_completion_key(key);
|
self.dispatch_completion_key(key);
|
||||||
|
|
@ -616,7 +621,12 @@ impl EditorState {
|
||||||
}
|
}
|
||||||
Action::Pending { .. } => {
|
Action::Pending { .. } => {
|
||||||
// The pending prefix is rendered from
|
// The pending prefix is rendered from
|
||||||
// `dispatcher.pending()`; no command runs yet.
|
// `dispatcher.pending()`; no command runs yet. Starting
|
||||||
|
// a command sequence dismisses the completion popup:
|
||||||
|
// leaving it open would route the sequence's `C-g`
|
||||||
|
// abort (and its continuation chords) into the popup's
|
||||||
|
// shadow instead of the dispatcher.
|
||||||
|
self.core.borrow_mut().completion_popup_close();
|
||||||
}
|
}
|
||||||
Action::Unbound { sequence } => match printable_char(&sequence) {
|
Action::Unbound { sequence } => match printable_char(&sequence) {
|
||||||
Some(ch) => {
|
Some(ch) => {
|
||||||
|
|
|
||||||
|
|
@ -1842,7 +1842,11 @@ impl EditorCore {
|
||||||
/// Emptiness is enforced upstream:
|
/// Emptiness is enforced upstream:
|
||||||
/// [`crate::completion::CompletionPopupState::new`] refuses to build
|
/// [`crate::completion::CompletionPopupState::new`] refuses to build
|
||||||
/// a candidate-less session.
|
/// a candidate-less session.
|
||||||
pub fn completion_popup_open(&mut self, state: crate::completion::CompletionPopupState) {
|
pub fn completion_popup_open(&mut self, mut state: crate::completion::CompletionPopupState) {
|
||||||
|
// Stamp the owning window (Lua publishers don't know window
|
||||||
|
// identity): only that window's overlay paints the popup, and
|
||||||
|
// a focus change invalidates the session.
|
||||||
|
state.window_id = Some(self.active_window_id());
|
||||||
*self
|
*self
|
||||||
.completion_popup
|
.completion_popup
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -1883,14 +1887,17 @@ impl EditorCore {
|
||||||
/// this the session is stale, not a prefix.
|
/// this the session is stale, not a prefix.
|
||||||
const MAX_PREFIX_BYTES: u64 = 512;
|
const MAX_PREFIX_BYTES: u64 = 512;
|
||||||
|
|
||||||
let (buffer_id, anchor) = {
|
let (buffer_id, window_id, anchor) = {
|
||||||
let guard = self
|
let guard = self
|
||||||
.completion_popup
|
.completion_popup
|
||||||
.lock()
|
.lock()
|
||||||
.expect("completion popup poisoned");
|
.expect("completion popup poisoned");
|
||||||
let p = guard.as_ref()?;
|
let p = guard.as_ref()?;
|
||||||
(p.buffer_id, p.anchor)
|
(p.buffer_id, p.window_id, p.anchor)
|
||||||
};
|
};
|
||||||
|
if window_id != Some(self.active_window_id()) {
|
||||||
|
return None; // focus moved to another window/split
|
||||||
|
}
|
||||||
if self.active_buffer_id() != buffer_id {
|
if self.active_buffer_id() != buffer_id {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -1979,9 +1986,10 @@ impl EditorCore {
|
||||||
/// renders nothing while the popup is closed.
|
/// renders nothing while the popup is closed.
|
||||||
fn ensure_completion_overlay(&mut self) {
|
fn ensure_completion_overlay(&mut self) {
|
||||||
let popup = self.completion_popup.clone();
|
let popup = self.completion_popup.clone();
|
||||||
|
let wid = self.active_window_id();
|
||||||
let win = self.active_window_mut();
|
let win = self.active_window_mut();
|
||||||
if !win.overlay_kinds().contains(&"completion-popup") {
|
if !win.overlay_kinds().contains(&"completion-popup") {
|
||||||
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup)));
|
win.push_overlay(Box::new(crate::completion::CompletionView::new(popup, wid)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3382,4 +3390,24 @@ mod tests {
|
||||||
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
|
assert_eq!(text_of(&s), "he world\n", "buffer untouched");
|
||||||
assert!(!s.completion_popup_is_open(), "stale accept still closes");
|
assert!(!s.completion_popup_is_open(), "stale accept still closes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_popup_validate_closes_on_window_focus_change() {
|
||||||
|
// Two splits on the SAME buffer: the session is window-scoped,
|
||||||
|
// so moving focus (buffer unchanged!) must invalidate it ---
|
||||||
|
// this is also what keeps the persistent overlay in the other
|
||||||
|
// split from painting a popup it doesn't own.
|
||||||
|
let mut s = from_bytes(b"he world\n");
|
||||||
|
s.split_active(Orientation::Horizontal, true);
|
||||||
|
s.active_window_mut().cursor = 2;
|
||||||
|
open_popup(&mut s, 0, "he", "hello");
|
||||||
|
s.completion_popup_validate();
|
||||||
|
assert!(s.completion_popup_is_open(), "session holds in its window");
|
||||||
|
s.focus_next();
|
||||||
|
s.completion_popup_validate();
|
||||||
|
assert!(
|
||||||
|
!s.completion_popup_is_open(),
|
||||||
|
"focus change closes the session even with the same buffer"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,68 @@ fn at_point_command_opens_below_threshold() {
|
||||||
assert_eq!(text, "hello_world hello_world");
|
assert_eq!(text, "hello_world hello_world");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Q#C8 URI scoping: with `ctx.uri` set, the LSP provider only
|
/// A multi-key prefix owns the keyboard: starting `C-x` while the
|
||||||
/// surfaces the matching document's cached items; without it, the
|
/// popup is open dismisses it, so the sequence's continuation (and a
|
||||||
/// legacy global drain applies. Driven through the Lua collect
|
/// `C-g` abort) reaches the dispatcher instead of the popup shadow.
|
||||||
/// surface against a hand-seeded store.
|
#[test]
|
||||||
|
fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() {
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
type_str(&mut s, "hello_world he");
|
||||||
|
let (_, visible, _) = probe(&s);
|
||||||
|
assert!(visible);
|
||||||
|
|
||||||
|
// C-x starts a prefix: the popup must close immediately...
|
||||||
|
s.dispatch_key(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
key(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
let (text, visible, _) = probe(&s);
|
||||||
|
assert_eq!(text, "hello_world he", "the prefix key edits nothing");
|
||||||
|
assert!(!visible, "a pending prefix dismisses the popup");
|
||||||
|
|
||||||
|
// ...so this C-g aborts the prefix (not a popup), and typing
|
||||||
|
// afterwards self-inserts normally.
|
||||||
|
s.dispatch_key(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
type_str(&mut s, "x");
|
||||||
|
let (text, _, _) = probe(&s);
|
||||||
|
assert_eq!(
|
||||||
|
text, "hello_world hex",
|
||||||
|
"after the aborted prefix, keys dispatch normally"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LSP-only words must still query the server: when the synchronous
|
||||||
|
/// providers return nothing at auto-open, a pending session is left
|
||||||
|
/// behind and the request fires anyway (previously the request was
|
||||||
|
/// gated on the popup having opened, so an empty dabbrev/snippet/
|
||||||
|
/// index sweep meant the server was never asked).
|
||||||
|
#[test]
|
||||||
|
fn empty_sync_sweep_still_leaves_a_pending_session() {
|
||||||
|
let mut s = EditorState::new();
|
||||||
|
// A buffer whose only word is the one being typed: dabbrev is
|
||||||
|
// structurally empty, no LSP attached. The popup cannot open...
|
||||||
|
type_str(&mut s, "qz");
|
||||||
|
let (_, visible, _) = probe(&s);
|
||||||
|
assert!(!visible, "nothing to show without providers");
|
||||||
|
// ...but the driver's session mirror must be pending rather than
|
||||||
|
// absent, so a (mocked) late LSP arrival could materialize it.
|
||||||
|
// With no attachment at all the request path no-ops; what we can
|
||||||
|
// assert end-to-end is that the state machine stays consistent:
|
||||||
|
// further typing neither crashes nor opens a bogus popup.
|
||||||
|
type_str(&mut s, "q");
|
||||||
|
let (text, visible, _) = probe(&s);
|
||||||
|
assert_eq!(text, "qzq");
|
||||||
|
assert!(!visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Q#C8 URI plumbing: `ctx.uri` reaches Lua providers as the
|
||||||
|
/// ninth positional arg, so a URI-aware provider can scope itself
|
||||||
|
/// (the BUILT-IN LSP provider is stricter still: no uri → no rows).
|
||||||
|
/// Driven through the Lua collect surface with an emulating provider
|
||||||
|
/// because the real LSP store is Rust-side.
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_scopes_lsp_candidates_to_ctx_uri() {
|
fn collect_scopes_lsp_candidates_to_ctx_uri() {
|
||||||
let s = EditorState::new();
|
let s = EditorState::new();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
//! Worker-pool teardown regression: dropping an `EditorState` must
|
//! Worker-pool teardown regression: dropping an `EditorState` must
|
||||||
//! release its worker threads even though the `Rc<AsyncRuntime>` is
|
//! release its worker threads even though the `Rc<AsyncRuntime>` is
|
||||||
//! trapped in Lua-VM reference cycles and never reaches refcount
|
//! trapped in Lua-VM reference cycles and never reaches refcount
|
||||||
//! zero (`EditorState::drop` → `AsyncRuntime::shutdown_workers`).
|
//! zero (`EditorState::drop` → `AsyncRuntime::shutdown_workers`,
|
||||||
|
//! signal-only --- a join here deadlocks against workers blocked
|
||||||
|
//! handing replies to the main thread).
|
||||||
//!
|
//!
|
||||||
//! Before the fix, every `EditorState` ever constructed leaked a
|
//! Before the fix, every `EditorState` ever constructed leaked a
|
||||||
//! full `cores - 1` worker pool: the m4 acceptance suite (54 editor-
|
//! full `cores - 1` worker pool: the m4 acceptance suite (54 editor-
|
||||||
//! building tests) accumulated 1000+ live threads, each waking every
|
//! building tests) accumulated 1000+ live threads, each waking every
|
||||||
//! 100ms.
|
//! 100ms.
|
||||||
|
//!
|
||||||
|
//! The thread-count probe and the idempotence check share ONE test
|
||||||
|
//! function: they both build `EditorState`s, and as separate tests
|
||||||
|
//! libtest may run them concurrently, polluting the /proc-based
|
||||||
|
//! baseline on high-core machines.
|
||||||
|
|
||||||
use pmacs::editor::EditorState;
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
/// Thread-count probe via /proc; Linux-only (macOS CI skips --- the
|
/// Thread-count probe via /proc; Linux-only (macOS CI runs the
|
||||||
/// leak and the fix are platform-independent, the *probe* isn't).
|
/// non-Linux variant below --- the leak and the fix are platform-
|
||||||
|
/// independent, the *probe* isn't).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn live_threads() -> usize {
|
fn live_threads() -> usize {
|
||||||
std::fs::read_dir("/proc/self/task").map_or(0, std::iter::Iterator::count)
|
std::fs::read_dir("/proc/self/task").map_or(0, std::iter::Iterator::count)
|
||||||
|
|
@ -19,14 +27,14 @@ fn live_threads() -> usize {
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn editor_state_drop_releases_worker_threads() {
|
fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() {
|
||||||
let baseline = live_threads();
|
let baseline = live_threads();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let s = EditorState::new();
|
let s = EditorState::new();
|
||||||
drop(s);
|
drop(s);
|
||||||
}
|
}
|
||||||
// Joins are synchronous in drop; the small sleep only covers
|
// Signal-only shutdown: parked workers exit within their 100ms
|
||||||
// detached per-process reaper threads finishing up.
|
// park timeout; give them a beat.
|
||||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
let after = live_threads();
|
let after = live_threads();
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -34,10 +42,17 @@ fn editor_state_drop_releases_worker_threads() {
|
||||||
"worker threads leak across EditorState drop: \
|
"worker threads leak across EditorState drop: \
|
||||||
baseline {baseline}, after 3 create/drop cycles {after}"
|
baseline {baseline}, after 3 create/drop cycles {after}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Idempotence: explicit shutdown twice, then drop runs it a
|
||||||
|
// third time --- none may hang or panic.
|
||||||
|
let s = EditorState::new();
|
||||||
|
s.async_runtime.shutdown_workers();
|
||||||
|
s.async_runtime.shutdown_workers();
|
||||||
|
drop(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-independent variant: the pool reports itself dead after
|
/// Platform-independent idempotence check for hosts without /proc.
|
||||||
/// an explicit shutdown, and shutdown is idempotent.
|
#[cfg(not(target_os = "linux"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_shutdown_is_idempotent() {
|
fn explicit_shutdown_is_idempotent() {
|
||||||
let s = EditorState::new();
|
let s = EditorState::new();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue