LSP didChange debounce + queued process stdin writer (typing perf)
Full-document didChange went out per keystroke: three O(file) copies, O(file) JSON, and a BLOCKING pipe write on the daemon main thread (Linux pipe buffers are 64KiB; a 240KB notification stalls the frame loop until the langserver drains). The dominant daemon-side typing cost on large files, and freeze-class when a server stops reading. - lsp.lua: the after-edit hook now bumps the version, marks the cached render families stale (new _mark_document_stale binding, so stale suppression stays keystroke-accurate), and records the buffer dirty. The coalesced send fires on the async tick after 75ms of quiet, or at most 400ms behind during continuous typing. Anything that consults the server flushes first (attached_for_active, repull_for_attachments, pull_inlay_hints_quiet) so requests and position-encoding conversion never see stale text. Versions may skip values; LSP only requires they increase. - Inlay hints re-pull at flush cadence: they're pull-model, nothing re-requested them after edits, so hints died on the first keystroke and never returned. - process.rs StdinWriter: a per-generation writer thread owns the child's stdin; write_stdin queues and never blocks (64MiB budget converts a wedged child into an error); close_stdin drains then EOFs, preserving the MCP flush-then-EOF contract. - pmacs.editor.monotonic_ms + pmacs.lsp._flush_did_changes bindings; acceptance test pins burst-coalescing, flush-on-demand, and the quiet-window tick flush. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d380358f2c
commit
799a45db06
|
|
@ -233,6 +233,84 @@ local function buffer_text(buf)
|
|||
return buf:slice(0, buf:len())
|
||||
end
|
||||
|
||||
-- didChange coalescing (typing perf) -----------------------------------------
|
||||
--
|
||||
-- Document sync is full-text, so each `textDocument/didChange` ships
|
||||
-- the entire buffer. Sending one per keystroke cost three O(file)
|
||||
-- copies plus an O(file) JSON write to the server pipe *per typed
|
||||
-- character* — the dominant daemon-side typing cost on large files.
|
||||
-- The after-edit hook now only bumps the version, marks the cached
|
||||
-- render families stale (cheap), and records the buffer as dirty;
|
||||
-- the actual notification ships from the async tick once the buffer
|
||||
-- has been quiet for DID_CHANGE_QUIET_MS, or unconditionally once
|
||||
-- the oldest unsent edit is DID_CHANGE_MAX_LAG_MS old (so the server
|
||||
-- keeps converging during continuous typing). Versions may skip
|
||||
-- values across a coalesced burst; LSP only requires that they
|
||||
-- increase. Anything that asks the server about a document flushes
|
||||
-- it first so no request is answered against stale text.
|
||||
local DID_CHANGE_QUIET_MS = 75
|
||||
local DID_CHANGE_MAX_LAG_MS = 400
|
||||
|
||||
-- Dirty buffers: key (tostring(buf)) -> {
|
||||
-- rec = the attachment record the edits belong to,
|
||||
-- first_ms = monotonic time of the oldest unsent edit,
|
||||
-- last_ms = monotonic time of the newest unsent edit,
|
||||
-- }
|
||||
local pending_did_change = {}
|
||||
|
||||
-- Forward declaration — defined below (needs helpers that follow);
|
||||
-- `flush_did_change` re-pulls inlay hints after each coalesced send.
|
||||
local pull_inlay_hints_quiet
|
||||
|
||||
local function flush_did_change(key)
|
||||
local pending = pending_did_change[key]
|
||||
if not pending then return end
|
||||
pending_did_change[key] = nil
|
||||
local rec = pending.rec
|
||||
-- The attachment may have been torn down or replaced (server
|
||||
-- crash -> re-attach) since the edit was recorded; only the live
|
||||
-- record's server should hear about the buffer.
|
||||
if attachments[key] ~= rec then return end
|
||||
local ok, text = pcall(buffer_text, rec.buffer)
|
||||
if not ok then return end
|
||||
pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, text)
|
||||
-- Inlay hints are pull-model: the store's stale flag (set per edit)
|
||||
-- only clears on a fresh `textDocument/inlayHint` response, and the
|
||||
-- server never volunteers one. Re-request at flush cadence so
|
||||
-- hints come back shortly after each pause instead of staying
|
||||
-- suppressed until the next attach/refresh. The request is
|
||||
-- supersede-keyed per (server, method, uri), so a burst of flushes
|
||||
-- cancels its own predecessors rather than piling up.
|
||||
pcall(pull_inlay_hints_quiet, rec)
|
||||
end
|
||||
|
||||
local function flush_did_change_for(rec)
|
||||
if rec and rec.buffer then flush_did_change(tostring(rec.buffer)) end
|
||||
end
|
||||
|
||||
local function flush_all_did_changes()
|
||||
for key in pairs(pending_did_change) do
|
||||
flush_did_change(key)
|
||||
end
|
||||
end
|
||||
|
||||
local function flush_due_did_changes()
|
||||
if next(pending_did_change) == nil then return end
|
||||
local now = pmacs.editor.monotonic_ms()
|
||||
for key, pending in pairs(pending_did_change) do
|
||||
if now - pending.last_ms >= DID_CHANGE_QUIET_MS
|
||||
or now - pending.first_ms >= DID_CHANGE_MAX_LAG_MS then
|
||||
flush_did_change(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Exposed for tests and for glue that must synchronize the server's
|
||||
-- document view before an out-of-band operation (e.g. a save hook).
|
||||
function pmacs.lsp._flush_did_changes()
|
||||
flush_all_did_changes()
|
||||
end
|
||||
|
||||
local function document_end_position(text)
|
||||
local line, col = 0, 0
|
||||
for i = 1, #text do
|
||||
|
|
@ -350,9 +428,16 @@ local function server_supports_inlay_hints(sid)
|
|||
return caps.inlayHintProvider ~= nil and caps.inlayHintProvider ~= false
|
||||
end
|
||||
|
||||
local function pull_inlay_hints_quiet(rec)
|
||||
-- Assigns the forward-declared local above (so `flush_did_change`
|
||||
-- can re-pull); a fresh `local function` here would shadow it.
|
||||
function pull_inlay_hints_quiet(rec)
|
||||
if not rec or not server_is_initialized(rec.server) then return end
|
||||
if not server_supports_inlay_hints(rec.server) then return end
|
||||
-- The server must see the current text before being asked to
|
||||
-- compute positions against it (didChange is debounced). A no-op
|
||||
-- when called from `flush_did_change` itself (the pending entry is
|
||||
-- removed before the send), so this cannot recurse.
|
||||
flush_did_change_for(rec)
|
||||
local end_line, end_col = document_end_position(buffer_text(rec.buffer))
|
||||
pmacs.async(function()
|
||||
pcall(function()
|
||||
|
|
@ -379,7 +464,12 @@ local function attach_buffer(buf)
|
|||
local key = tostring(buf)
|
||||
local existing = attachments[key]
|
||||
if existing and server_is_live(existing.server) then return existing end
|
||||
if existing then attachments[key] = nil end
|
||||
if existing then
|
||||
attachments[key] = nil
|
||||
-- Unsent edits targeted the dead attachment; the did_open below
|
||||
-- carries the full current text, superseding them.
|
||||
pending_did_change[key] = nil
|
||||
end
|
||||
local language = active_buffer_language()
|
||||
if not language then return nil end
|
||||
-- Path resolved before spawn so the server's `rootUri` can be
|
||||
|
|
@ -430,7 +520,16 @@ end
|
|||
local function attached_for_active()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return nil end
|
||||
return attachments[tostring(buf)] or attach_buffer(buf)
|
||||
local key = tostring(buf)
|
||||
local rec = attachments[key]
|
||||
if rec then
|
||||
-- Every interactive command resolves its attachment here before
|
||||
-- issuing requests; flushing now means the server answers those
|
||||
-- requests against the current text (didChange is debounced).
|
||||
flush_did_change(key)
|
||||
return rec
|
||||
end
|
||||
return attach_buffer(buf)
|
||||
end
|
||||
|
||||
-- Hooks --------------------------------------------------------------------
|
||||
|
|
@ -442,10 +541,21 @@ end)
|
|||
pmacs.hook.add("buffer.after-edit", function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
local rec = attachments[tostring(buf)]
|
||||
local key = tostring(buf)
|
||||
local rec = attachments[key]
|
||||
if not rec then return end
|
||||
rec.version = rec.version + 1
|
||||
pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text())
|
||||
-- Stale suppression must stay keystroke-accurate even though the
|
||||
-- O(file) didChange send below is coalesced: render families
|
||||
-- anchored to pre-edit positions are hidden from this edit on.
|
||||
pcall(pmacs.lsp._mark_document_stale, rec.uri)
|
||||
local now = pmacs.editor.monotonic_ms()
|
||||
local pending = pending_did_change[key]
|
||||
if pending and pending.rec == rec then
|
||||
pending.last_ms = now
|
||||
else
|
||||
pending_did_change[key] = { rec = rec, first_ms = now, last_ms = now }
|
||||
end
|
||||
end)
|
||||
|
||||
-- Async request surface (T M4.5 async bridge). The Rust manager
|
||||
|
|
@ -658,6 +768,9 @@ end
|
|||
local function repull_for_attachments(sid, request_fn)
|
||||
for _, rec in pairs(attachments) do
|
||||
if rec.server == sid and rec.uri then
|
||||
-- Server-initiated repulls (diagnostics refresh, semantic
|
||||
-- tokens refresh) must also see the latest text first.
|
||||
flush_did_change_for(rec)
|
||||
pcall(request_fn, sid, rec.uri, rec)
|
||||
end
|
||||
end
|
||||
|
|
@ -994,6 +1107,7 @@ if pmacs._async and pmacs._async.tick then
|
|||
pmacs._async.tick = function(...)
|
||||
local ret = _prior_async_tick(...)
|
||||
pcall(handle_server_requests)
|
||||
pcall(flush_due_did_changes)
|
||||
return ret
|
||||
end
|
||||
end
|
||||
|
|
|
|||
45
src/lsp.rs
45
src/lsp.rs
|
|
@ -3044,24 +3044,7 @@ impl LspManager {
|
|||
let uri = uri.into();
|
||||
let text = text.into();
|
||||
self.documents.insert((sid, uri.clone()), text.clone());
|
||||
// T M11.8 / Session 8 — mark cached LSP-derived render
|
||||
// families stale so semantic frontends suppress byte ranges
|
||||
// anchored to pre-edit text until the server refreshes them.
|
||||
// Diagnostics clear on `publishDiagnostics`, semantic tokens
|
||||
// on `textDocument/semanticTokens`, and inlay hints on
|
||||
// `textDocument/inlayHint`.
|
||||
self.diag_store
|
||||
.lock()
|
||||
.expect("diag store mutex poisoned")
|
||||
.mark_stale(uri.clone());
|
||||
self.semantic_token_store
|
||||
.lock()
|
||||
.expect("semantic token store mutex poisoned")
|
||||
.mark_stale(uri.clone());
|
||||
self.inlay_hint_store
|
||||
.lock()
|
||||
.expect("inlay hint store mutex poisoned")
|
||||
.mark_stale(uri.clone());
|
||||
self.mark_document_stale(&uri);
|
||||
let params = json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
|
|
@ -3074,6 +3057,32 @@ impl LspManager {
|
|||
self.send_notification(sid, "textDocument/didChange", params)
|
||||
}
|
||||
|
||||
/// T M11.8 / Session 8 — mark cached LSP-derived render families
|
||||
/// for `uri` stale so frontends suppress byte ranges anchored to
|
||||
/// pre-edit text until the server refreshes them. Diagnostics
|
||||
/// clear on `publishDiagnostics`, semantic tokens on
|
||||
/// `textDocument/semanticTokens`, and inlay hints on
|
||||
/// `textDocument/inlayHint`.
|
||||
///
|
||||
/// Factored out of [`Self::did_change_full`] so the Lua glue can
|
||||
/// mark staleness at *edit* time even while the (full-document,
|
||||
/// O(file)) didChange notification itself is debounced — per-edit
|
||||
/// staleness is what keeps stale-position artifacts off screen.
|
||||
pub fn mark_document_stale(&self, uri: &str) {
|
||||
self.diag_store
|
||||
.lock()
|
||||
.expect("diag store mutex poisoned")
|
||||
.mark_stale(uri.to_owned());
|
||||
self.semantic_token_store
|
||||
.lock()
|
||||
.expect("semantic token store mutex poisoned")
|
||||
.mark_stale(uri.to_owned());
|
||||
self.inlay_hint_store
|
||||
.lock()
|
||||
.expect("inlay hint store mutex poisoned")
|
||||
.mark_stale(uri.to_owned());
|
||||
}
|
||||
|
||||
/// Send `workspace/didChangeWatchedFiles` to `sid`. `changes` is
|
||||
/// the already-shaped `FileEvent[]` array (`[{ uri, type }]`,
|
||||
/// type 1=created / 2=changed / 3=deleted) the Lua file-watch
|
||||
|
|
|
|||
|
|
@ -7261,6 +7261,23 @@ pub fn install_lsp(
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Mark `uri`'s cached LSP render families (diagnostics,
|
||||
// semantic tokens, inlay hints) stale without sending
|
||||
// anything. The didChange-debounce glue in
|
||||
// `builtin/runtime/lsp.lua` calls this per edit so stale
|
||||
// suppression stays keystroke-accurate while the O(file)
|
||||
// full-document notification is coalesced.
|
||||
let m = manager.clone();
|
||||
lsp_mod.set(
|
||||
"_mark_document_stale",
|
||||
lua.create_function(move |_, uri: String| {
|
||||
m.borrow().mark_document_stale(&uri);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
lsp_mod.set(
|
||||
|
|
@ -11428,6 +11445,22 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// Milliseconds on a process-local monotonic clock. Only
|
||||
// differences are meaningful (the epoch is the first call).
|
||||
// Exists for Lua-side debounce/throttle logic — notably the
|
||||
// LSP didChange coalescing in `builtin/runtime/lsp.lua` —
|
||||
// which needs wall-clock-independent elapsed time; `os.clock`
|
||||
// is CPU time and `os.time` is second-granular.
|
||||
editor.set(
|
||||
"monotonic_ms",
|
||||
lua.create_function(|_, ()| {
|
||||
static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
let epoch = *EPOCH.get_or_init(std::time::Instant::now);
|
||||
Ok(i64::try_from(epoch.elapsed().as_millis()).unwrap_or(i64::MAX))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
|
|
|
|||
211
src/process.rs
211
src/process.rs
|
|
@ -51,7 +51,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -427,7 +427,7 @@ struct ManagedProcess {
|
|||
/// when the generation ends.
|
||||
struct RuntimeHandles {
|
||||
child: ChildHandle,
|
||||
stdin: Option<Box<dyn Write + Send>>,
|
||||
stdin: Option<StdinWriter>,
|
||||
pid: u32,
|
||||
/// Reader-thread join handles, drained by `Drop` of
|
||||
/// [`RuntimeHandles`] so a generation's worker threads don't
|
||||
|
|
@ -444,6 +444,102 @@ struct RuntimeHandles {
|
|||
cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Byte budget for stdin data queued but not yet written, per
|
||||
/// generation. A child this far behind on reading its own stdin is
|
||||
/// effectively not consuming it; erroring beats unbounded queue
|
||||
/// growth, and callers already treat `write_stdin` errors as
|
||||
/// process failure. Generous so it never triggers for a merely-busy
|
||||
/// child (LSP full-document didChange on a large file is ~MB-scale).
|
||||
const STDIN_QUEUE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Queued stdin writer: a dedicated thread owns the child's stdin
|
||||
/// handle and drains a channel of byte chunks. This decouples
|
||||
/// callers — the editor main thread, notably the LSP manager's
|
||||
/// full-document `didChange` notifications — from pipe
|
||||
/// backpressure: a child that stops reading (kernel pipe buffers
|
||||
/// are ~64 KiB) stalls this queue, not the editor frame loop.
|
||||
///
|
||||
/// Closing: dropping the sender (`close_stdin` / generation end)
|
||||
/// lets the thread drain whatever is queued, then drop the handle —
|
||||
/// the child sees EOF *after* the queued bytes, preserving the
|
||||
/// flush-then-EOF shutdown contract MCP relies on. The thread is
|
||||
/// detached rather than joined: joining at drop could block forever
|
||||
/// on a wedged pipe, and generation teardown (SIGTERM/SIGKILL)
|
||||
/// breaks the pipe and ends the thread shortly after anyway.
|
||||
struct StdinWriter {
|
||||
tx: Sender<Vec<u8>>,
|
||||
/// Bytes accepted by [`Self::write`] but not yet written by the
|
||||
/// thread. Backpressure signal for the queue budget.
|
||||
queued_bytes: Arc<AtomicUsize>,
|
||||
/// First write error observed by the writer thread. Writes are
|
||||
/// asynchronous, so the failure surfaces on the *next* `write`
|
||||
/// call instead of the one that hit it.
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl StdinWriter {
|
||||
fn spawn(mut sink: Box<dyn Write + Send>) -> Self {
|
||||
let (tx, rx) = channel::unbounded::<Vec<u8>>();
|
||||
let queued_bytes = Arc::new(AtomicUsize::new(0));
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
let thread_queued = Arc::clone(&queued_bytes);
|
||||
let thread_error = Arc::clone(&error);
|
||||
std::thread::Builder::new()
|
||||
.name("pmacs stdin writer".into())
|
||||
.spawn(move || {
|
||||
while let Ok(bytes) = rx.recv() {
|
||||
let result = sink.write_all(&bytes).and_then(|()| sink.flush());
|
||||
thread_queued.fetch_sub(bytes.len(), Ordering::Relaxed);
|
||||
if let Err(e) = result {
|
||||
*thread_error.lock().expect("stdin writer error mutex poisoned") =
|
||||
Some(e.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Channel closed: all queued chunks written. `sink`
|
||||
// drops here, closing the pipe — the child sees EOF.
|
||||
})
|
||||
.expect("spawn stdin writer thread");
|
||||
Self {
|
||||
tx,
|
||||
queued_bytes,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(e) = self
|
||||
.error
|
||||
.lock()
|
||||
.expect("stdin writer error mutex poisoned")
|
||||
.as_ref()
|
||||
{
|
||||
return Err(format!("write_stdin: {e}"));
|
||||
}
|
||||
let queued = self.queued_bytes.load(Ordering::Relaxed);
|
||||
if queued.saturating_add(bytes.len()) > STDIN_QUEUE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"write_stdin: child is not draining stdin ({queued} bytes already queued)"
|
||||
));
|
||||
}
|
||||
self.queued_bytes.fetch_add(bytes.len(), Ordering::Relaxed);
|
||||
self.tx.send(bytes.to_vec()).map_err(|_| {
|
||||
// Thread exited after a write error; report the stored
|
||||
// cause when we have it.
|
||||
self.queued_bytes.fetch_sub(bytes.len(), Ordering::Relaxed);
|
||||
let stored = self
|
||||
.error
|
||||
.lock()
|
||||
.expect("stdin writer error mutex poisoned")
|
||||
.clone();
|
||||
stored.map_or_else(
|
||||
|| "write_stdin: writer thread stopped".to_owned(),
|
||||
|e| format!("write_stdin: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RuntimeHandles {
|
||||
fn drop(&mut self) {
|
||||
// Wake any reader thread blocked in a bounded `send` ---
|
||||
|
|
@ -723,12 +819,13 @@ impl ProcessSupervisor {
|
|||
self.signal(id, Signal::SIGTERM)
|
||||
}
|
||||
|
||||
/// Close `id`'s stdin pipe by dropping the writer. The child
|
||||
/// observes EOF on its next read, which is the canonical
|
||||
/// stdio-graceful-shutdown signal for protocols (notably MCP)
|
||||
/// that have no protocol-level shutdown message. Idempotent: a
|
||||
/// second call after the writer is gone is a no-op. Errors only
|
||||
/// if the process id is unknown.
|
||||
/// Close `id`'s stdin pipe by dropping the writer. The writer
|
||||
/// thread drains any queued bytes first, then drops the handle,
|
||||
/// so the child observes EOF *after* everything already written
|
||||
/// — the canonical stdio-graceful-shutdown signal for protocols
|
||||
/// (notably MCP) that have no protocol-level shutdown message.
|
||||
/// Idempotent: a second call after the writer is gone is a
|
||||
/// no-op. Errors only if the process id is unknown.
|
||||
///
|
||||
/// Note: this does NOT kill the process. Callers that want a
|
||||
/// guaranteed exit follow up with [`Self::terminate`] (SIGTERM)
|
||||
|
|
@ -749,10 +846,14 @@ impl ProcessSupervisor {
|
|||
}
|
||||
|
||||
/// Write `bytes` to `id`'s stdin. Errors if the id is unknown,
|
||||
/// the process is not running, or stdin is closed (the child
|
||||
/// the process is not running, stdin is closed (the child
|
||||
/// closed stdin on its end, or stdin was never piped in the
|
||||
/// first place). Synchronous write --- callers that worry about
|
||||
/// pipe-full blocking should chunk their writes.
|
||||
/// first place), or the per-generation queue budget is
|
||||
/// exhausted. The write itself is queued to a dedicated writer
|
||||
/// thread, so this never blocks on pipe backpressure — a write
|
||||
/// *failure* (broken pipe) therefore surfaces on a subsequent
|
||||
/// call rather than the one that queued the bytes; callers that
|
||||
/// need liveness should watch the supervisor's exit events.
|
||||
pub fn write_stdin(&mut self, id: ProcessId, bytes: &[u8]) -> Result<(), String> {
|
||||
let proc = self
|
||||
.processes
|
||||
|
|
@ -764,13 +865,9 @@ impl ProcessSupervisor {
|
|||
.ok_or_else(|| format!("process {id} has no live generation"))?;
|
||||
let stdin = runtime
|
||||
.stdin
|
||||
.as_mut()
|
||||
.as_ref()
|
||||
.ok_or_else(|| format!("process {id} stdin is not piped"))?;
|
||||
stdin
|
||||
.write_all(bytes)
|
||||
.map_err(|e| format!("write_stdin: {e}"))?;
|
||||
stdin.flush().map_err(|e| format!("flush_stdin: {e}"))?;
|
||||
Ok(())
|
||||
stdin.write(bytes)
|
||||
}
|
||||
|
||||
/// Resize the PTY for `id`. Errors if the id is unknown, the
|
||||
|
|
@ -1128,7 +1225,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result<RuntimeHand
|
|||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.map(|s| Box::new(s) as Box<dyn Write + Send>);
|
||||
.map(|s| StdinWriter::spawn(Box::new(s) as Box<dyn Write + Send>));
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(BYTE_CHUNK_CHANNEL_CAP);
|
||||
|
|
@ -1238,7 +1335,7 @@ fn build_pty_runtime(
|
|||
child: Arc::new(Mutex::new(into_send_sync_child(child))),
|
||||
_master: pair.master,
|
||||
},
|
||||
stdin: Some(writer),
|
||||
stdin: Some(StdinWriter::spawn(writer)),
|
||||
pid,
|
||||
readers,
|
||||
output_rx,
|
||||
|
|
@ -1606,6 +1703,82 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_stdin_queues_without_blocking_when_child_never_reads() {
|
||||
let mut sup = ProcessSupervisor::new();
|
||||
// The child never reads its stdin, so the kernel pipe buffer
|
||||
// (~64 KiB) fills almost immediately. The pre-writer-thread
|
||||
// implementation blocked the caller in `write_all` here —
|
||||
// which in the editor was the main thread, wedging the frame
|
||||
// loop whenever an LSP server fell behind on its stdin.
|
||||
let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh");
|
||||
spec.args = vec!["-c".into(), "sleep 30".into()];
|
||||
let id = sup.spawn(spec).expect("spawn");
|
||||
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, ProcessEventKind::Started { .. }))
|
||||
});
|
||||
let payload = vec![b'x'; 1024 * 1024]; // 16x the pipe buffer
|
||||
let start = Instant::now();
|
||||
sup.write_stdin(id, &payload).expect("queued write");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(2),
|
||||
"write_stdin must queue, not block on pipe backpressure (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
sup.terminate(id).expect("terminate");
|
||||
let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_stdin_flushes_queued_bytes_before_eof() {
|
||||
let mut sup = ProcessSupervisor::new();
|
||||
// `cat` echoes stdin and exits on EOF. Receiving the full
|
||||
// payload back followed by a clean exit proves the writer
|
||||
// thread drains its queue before dropping the pipe (the
|
||||
// flush-then-EOF contract `close_stdin` documents).
|
||||
let mut spec = ProcessSpec::new("cat-echo", "/bin/sh");
|
||||
spec.args = vec!["-c".into(), "cat".into()];
|
||||
let id = sup.spawn(spec).expect("spawn");
|
||||
let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, ProcessEventKind::Started { .. }))
|
||||
});
|
||||
let payload = vec![b'y'; 256 * 1024];
|
||||
sup.write_stdin(id, &payload).expect("queued write");
|
||||
sup.close_stdin(id).expect("close stdin");
|
||||
let evs = drain_until(&mut sup, id, Duration::from_secs(10), |evs| {
|
||||
let echoed: usize = evs
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
ProcessEventKind::Stdout(b) => Some(b.len()),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
echoed >= 256 * 1024
|
||||
&& evs
|
||||
.iter()
|
||||
.any(|e| matches!(e.kind, ProcessEventKind::Exited { .. }))
|
||||
});
|
||||
let echoed: usize = evs
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
ProcessEventKind::Stdout(b) => Some(b.len()),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(
|
||||
echoed,
|
||||
payload.len(),
|
||||
"child must receive every queued byte before EOF"
|
||||
);
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, ProcessEventKind::Exited { code: 0 })),
|
||||
"EOF after drain must let the child exit cleanly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_on_crash_respawns_after_nonzero_exit() {
|
||||
let mut sup = ProcessSupervisor::new();
|
||||
|
|
|
|||
|
|
@ -5158,6 +5158,106 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() {
|
|||
assert!(probe.get::<bool>("cmd_sig").unwrap());
|
||||
}
|
||||
|
||||
/// Typing-perf: the default bundle coalesces full-document
|
||||
/// `didChange` notifications instead of sending one per keystroke
|
||||
/// (each send copies the whole buffer several times and writes
|
||||
/// O(file) JSON to the server pipe). The after-edit hook only bumps
|
||||
/// the version and records the buffer dirty; the notification ships
|
||||
/// on the async tick after the quiet window, or synchronously when a
|
||||
/// request path flushes via `pmacs.lsp._flush_did_changes`. Observed
|
||||
/// by monkeypatching `pmacs.lsp.did_change` (the bundle resolves it
|
||||
/// dynamically at flush time) and firing `buffer.after-edit` through
|
||||
/// the public hook runner.
|
||||
#[test]
|
||||
fn m4_lua_bundle_debounces_did_change_per_keystroke() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut s = EditorState::new();
|
||||
let fake = fake_lsp_path();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let file = dir.path().join("debounce.rs");
|
||||
std::fs::write(&file, "fn main() {}\n").unwrap();
|
||||
let file_disp = file.display();
|
||||
|
||||
// Point the rust config at the fake server, open the file (the
|
||||
// after-load hook auto-attaches and sends didOpen v1), then
|
||||
// instrument did_change.
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
"
|
||||
pmacs.lsp.config.rust = {{ command = '{fake}' }}
|
||||
pmacs.buffer.find_or_open('{file_disp}')
|
||||
_G.__sent_did_changes = {{}}
|
||||
local real = pmacs.lsp.did_change
|
||||
pmacs.lsp.did_change = function(sid, uri, version, text)
|
||||
table.insert(_G.__sent_did_changes, {{ version = version, len = #text }})
|
||||
return real(sid, uri, version, text)
|
||||
end
|
||||
"
|
||||
))
|
||||
.exec()
|
||||
.expect("configure + open + instrument");
|
||||
|
||||
// Three "keystrokes" in a burst: nothing may ship inline.
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load("for _ = 1, 3 do pmacs.hook.run('buffer.after-edit') end")
|
||||
.exec()
|
||||
.expect("fire after-edit burst");
|
||||
let sent: i64 = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return #_G.__sent_did_changes")
|
||||
.eval()
|
||||
.expect("count sends");
|
||||
assert_eq!(sent, 0, "didChange must not ship per keystroke");
|
||||
|
||||
// Request-path flush: exactly one coalesced notification carrying
|
||||
// the latest version (didOpen was v1, three edits bump to v4 —
|
||||
// skipped intermediate versions are legal, LSP only requires
|
||||
// strictly increasing).
|
||||
let (sent, version): (i64, i64) = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
pmacs.lsp._flush_did_changes()
|
||||
local n = #_G.__sent_did_changes
|
||||
local v = n > 0 and _G.__sent_did_changes[n].version or -1
|
||||
return n, v
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("flush + count");
|
||||
assert_eq!(sent, 1, "explicit flush ships exactly one coalesced didChange");
|
||||
assert_eq!(version, 4, "flush carries the latest version (v1 open + 3 edits)");
|
||||
|
||||
// Time-based flush: one more edit, then tick after the quiet
|
||||
// window (75ms in the bundle) has elapsed.
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load("pmacs.hook.run('buffer.after-edit')")
|
||||
.exec()
|
||||
.expect("fire single after-edit");
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
s.tick_async();
|
||||
let (sent, version): (i64, i64) = s
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local n = #_G.__sent_did_changes
|
||||
local v = n > 0 and _G.__sent_did_changes[n].version or -1
|
||||
return n, v
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("count after tick");
|
||||
assert_eq!(sent, 2, "quiet-window tick flushes the pending didChange");
|
||||
assert_eq!(version, 5, "tick flush carries the post-edit version");
|
||||
}
|
||||
|
||||
/// Defensive: the auto-attach hook ignores buffers that don't have a
|
||||
/// language config, doesn't crash on `*scratch*`, and pcall-wraps the
|
||||
/// spawn so a missing server binary in the user's PATH doesn't poison
|
||||
|
|
|
|||
Loading…
Reference in New Issue