diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 8cae97c..9699367 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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 diff --git a/src/lsp.rs b/src/lsp.rs index a8de7e2..e777c98 100644 --- a/src/lsp.rs +++ b/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 diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 334ef3f..33afe52 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -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::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( diff --git a/src/process.rs b/src/process.rs index f990711..0b88352 100644 --- a/src/process.rs +++ b/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>, + stdin: Option, 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, } +/// 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>, + /// Bytes accepted by [`Self::write`] but not yet written by the + /// thread. Backpressure signal for the queue budget. + queued_bytes: Arc, + /// 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>>, +} + +impl StdinWriter { + fn spawn(mut sink: Box) -> Self { + let (tx, rx) = channel::unbounded::>(); + 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); + .map(|s| StdinWriter::spawn(Box::new(s) as Box)); let stdout = child.stdout.take(); let stderr = child.stderr.take(); let (byte_tx, byte_rx) = channel::bounded::(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(); diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 675695e..b7cc9d9 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -5158,6 +5158,106 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() { assert!(probe.get::("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