Merge pull request #60 from levineuwirth/session-b1-keyboard-cursor

pmacs-gpu editor: optimistic typing arc — input, perf, and CUA selection
This commit is contained in:
Levi Neuwirth 2026-06-10 12:18:33 -04:00 committed by GitHub
commit 7fdae766e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4457 additions and 403 deletions

View File

@ -40,10 +40,20 @@ cmd { name = "cursor.paragraph-down",
-- Buffer editing -------------------------------------------------------------
cmd { name = "buffer.delete-backward", description = "Delete the codepoint before the cursor.",
fn = function() ed.backspace() end }
cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.",
fn = function() ed.delete_forward() end }
-- CUA region semantics: with an active selection, Backspace / Delete
-- consume the region (cursor lands at its start, selection clears).
-- `delete_region` returns false when no region is active, so the
-- single-codepoint behavior is untouched outside selections.
cmd { name = "buffer.delete-backward",
description = "Delete the active region, or the codepoint before the cursor.",
fn = function()
if not ed.delete_region() then ed.backspace() end
end }
cmd { name = "buffer.delete-forward",
description = "Delete the active region, or the codepoint at the cursor.",
fn = function()
if not ed.delete_region() then ed.delete_forward() end
end }
cmd { name = "buffer.delete-word-backward",
description = "Delete from the cursor back to the start of the previous word.",
fn = function() ed.delete_word_backward() end }
@ -79,18 +89,31 @@ cmd { name = "cursor.select-word-left",
cmd { name = "cursor.select-word-right",
description = "Extend selection by one word right.",
fn = function() ensure_anchor(); ed.move_word_right() end }
cmd { name = "cursor.select-paragraph-up",
description = "Extend selection to the previous paragraph break.",
fn = function() ensure_anchor(); ed.move_paragraph_up() end }
cmd { name = "cursor.select-paragraph-down",
description = "Extend selection to the next paragraph break.",
fn = function() ensure_anchor(); ed.move_paragraph_down() end }
cmd { name = "cursor.select-line-start",
description = "Extend selection to start of line.",
fn = function() ensure_anchor(); ed.move_line_start() end }
cmd { name = "cursor.select-line-end",
description = "Extend selection to end of line.",
fn = function() ensure_anchor(); ed.move_line_end() end }
cmd { name = "buffer.newline", description = "Insert a newline at the cursor.",
fn = function() ed.insert_char(10) end }
cmd { name = "buffer.tab", description = "Insert a tab at the cursor.",
fn = function() ed.insert_char(9) end }
cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor.",
fn = function(codepoint) ed.insert_char(codepoint) end }
-- CUA type-over: inserting with an active selection replaces it
-- (`delete_region` is a no-op without one). The pmacs-gpu frontend
-- relies on this: its optimistic-insert path detects an own-window
-- selection and round-trips the key so these commands run.
cmd { name = "buffer.newline",
description = "Insert a newline at the cursor, replacing the active region.",
fn = function() ed.delete_region(); ed.insert_char(10) end }
cmd { name = "buffer.tab",
description = "Insert a tab at the cursor, replacing the active region.",
fn = function() ed.delete_region(); ed.insert_char(9) end }
cmd { name = "buffer.self-insert",
description = "Insert the codepoint argument at the cursor, replacing the active region.",
fn = function(codepoint) ed.delete_region(); ed.insert_char(codepoint) end }
-- History --------------------------------------------------------------------

View File

@ -73,7 +73,8 @@ bind("M-d", "buffer.delete-word-forward")
-- CUA-style Shift+motion selection. Each Shift+arrow extends a
-- selection from the cursor (anchoring at the current position if no
-- region is yet active). Ctrl+Shift+Left/Right extend by whole words;
-- Shift+Home/End extend to line edges. Plain motion (without Shift)
-- Ctrl+Shift+Up/Down extend by paragraphs; Shift+Home/End extend to
-- line edges. Plain motion (without Shift)
-- is unchanged --- it preserves any existing selection rather than
-- dropping it (Emacs-flavored default; users who want strict-CUA
-- "drop-on-plain-motion" can rebind their motion commands).
@ -85,6 +86,8 @@ bind("S-<home>", "cursor.select-line-start")
bind("S-<end>", "cursor.select-line-end")
bind("C-S-<left>", "cursor.select-word-left")
bind("C-S-<right>", "cursor.select-word-right")
bind("C-S-<up>", "cursor.select-paragraph-up")
bind("C-S-<down>", "cursor.select-paragraph-down")
-- Undo / redo ----------------------------------------------------------------
--

View File

@ -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

View File

@ -0,0 +1,164 @@
# pmacs-gpu — viewport-scoped rendering & scroll (framing)
**Status: framing pass; pre-implementation. Urgent.** Editing a large
file in pmacs-gpu is unusably slow because rendering is O(file) per
keystroke. This milestone makes it O(visible) and adds scrolling. Same
framing discipline as the Phase B framing: Q-decisions committed before
code, fact-checked first.
## Why this exists (the perf problem, precisely)
Per keystroke today, for a 240 KB file:
- **GPU**: `reshape()` rebuilds rich text from the *entire*
`current_text``projected_rich_chunks` walks all 240 KB, then
`set_rich_text` creates a `BufferLine` per source line (~25k). It
fires on every edit, and one keystroke produces 23 reshapes (the
`CrdtOp`, `StyleSpans`, and `Decorations` frames each trigger one).
- **GPU Viewport**: pmacs-gpu declares `visible: { 0, text_len }` — the
whole file (`main.rs` `BufferSnapshot`/`CrdtOp` arms). So the producer
is asked to style the whole file.
- **Daemon**: on each edit the generation bumps, the `StyleGate`
recomputes, and `scoped_style_spans` runs the tree-sitter highlight
query over the whole bundle (it clips the *result* to the viewport
but runs the query whole-file).
The GPU's O(file) `set_rich_text` + `projected_rich_chunks` dominate and
run per edit. Editing is hundreds of ms to seconds per character.
## Goal
Make the GPU per-edit cost **O(visible lines)**, not O(file), and add
line-based scrolling so the whole file is reachable. Correctness (text,
styling, caret, edits, CRDT convergence) is preserved.
## Approach: render only the visible slice (stance committed)
cosmic-text offers a native `Buffer::set_scroll` + `shape_until_scroll`
path, but that only makes *shaping* lazy — `set_rich_text` and
`projected_rich_chunks` would still process the whole rope. So that path
does **not** fix the dominant cost.
**Stance: feed cosmic-text only the visible byte slice.** The GPU keeps
the whole rope in `current_text` (the CRDT replica is authoritative and
small to hold), but builds chunks and calls `set_rich_text` over
`current_text[vstart..vend]` — the byte range of the visible source
lines. Everything cosmic-text touches is then O(visible): chunk build,
`BufferLine` creation, shaping, layout. Scrolling re-slices and
re-shapes; the buffer always renders from its own top (no cosmic-text
scroll offset needed).
The cost is **coordinate rebasing**: spans / decorations / caret /
presence arrive in whole-file byte coordinates; the slice starts at
file byte `vstart`, so each offset maps to slice byte `offset - vstart`,
and only the portion intersecting `[vstart, vend)` is rendered. This is
the part to get exactly right — and the QB3 lesson applies (glyph
offsets are line-relative; `line_byte_offsets` is computed on the
slice).
## Contract inheritance
Pixel-pure instance preserved: the GPU still sends only a byte-range
`Viewport` and byte-anchored input. The producer already clips
`StyleSpans` / `Decorations` to `vp.visible` (verified), so a scoped
viewport immediately cuts wire volume and GPU span processing with no
producer change required.
## Forced decisions
### Q#S1 — scroll unit: line-based
**Scroll position is a source-line index (`scroll_top`), not a pixel
offset.** The visible slice is `[line_start(scroll_top),
line_start(scroll_top + visible_lines + overscan))`. Line-based scroll
makes the slice always start on a line boundary (cosmic-text splits
`BufferLine`s on `\n`, so a mid-line slice would corrupt the first
line) and matches how `estimated_visible_lines` already works.
Pixel-smooth scroll is a later refinement.
### Q#S2 — what drives scroll: keep the caret visible
**The cursor stays on screen.** `scroll_top` is adjusted whenever a
`CursorByte` (own cursor) would fall outside the visible line range:
scroll just enough to bring the cursor's line to the nearest visible
edge (+ a small margin). This is the only scroll trigger session-1
needs — it makes arrow/PageUp/PageDown navigation work without a
separate scroll command:
- Arrow up/down past the edge → cursor moves (daemon) → `CursorByte`
→ auto-scroll follows.
- `PageUp`/`PageDown` → already forwarded; the daemon moves the cursor
by a page → `CursorByte` → auto-scroll follows. (No GPU-local page
math; the daemon owns cursor motion, Q#B3.)
Mouse-wheel / scrollbar scroll-without-cursor-move is a later add.
### Q#S3 — overscan: a small margin
Slice a few lines beyond the visible region (e.g. visible + 2) so a
1-line scroll doesn't always re-slice, and the bottom partial line
renders. Keep it small — overscan is wasted shaping.
### Q#S4 — rebasing rule: subtract `vstart`, computed once
A single `vstart` (file byte of the first visible line) rebases
everything: `slice = current_text[vstart..vend]`; a span/decoration/
caret at file byte `b` is at slice byte `b - vstart` and is rendered
only if `vstart <= b < vend`; `line_byte_offsets` is computed on
`slice`. One helper clips+rebases a `[start,end)` range to the slice
(returns `None` when disjoint). The caret and the background-wash
builders all route through it.
### Q#S5 — Viewport declaration: the visible range
The GPU declares `Viewport { visible: { vstart, vend } }` whenever the
slice changes (scroll or buffer switch), and **re-declares on scroll**
so the producer ships spans for what's on screen. Coalesce: only send
when `[vstart, vend)` actually changed. (Today only `BufferSnapshot`
declares a Viewport; the `CrdtOp` edit arm sends none — so the producer
keeps styling the last-declared range across edits, which is exactly
why the whole-file range is currently sticky.)
### Q#S6 — daemon whole-file highlight query: out of scope (noted)
`scoped_style_spans` runs the tree-sitter query over the whole bundle
even for a scoped viewport. After this milestone the GPU is O(visible)
and the wire is scoped, so the daemon query becomes the next bottleneck
on very large files — but it is a *separate* producer optimization
(query-by-range / incremental), deferred to its own task. The GPU
session does not depend on it.
## Predicted findings — categorical bets
| # | Bet | Category |
|---|---|---|
| S1 | Off-by-one / mid-line slicing at the bottom edge (partial last line, trailing newline) corrupts the visible text | Boundary-decomposition |
| S2 | Rebasing misses a coordinate space — a span or the caret rendered at `b` instead of `b - vstart`, or vice versa | Coordinate-space (recurrence of QB3) |
| S3 | Scroll re-declares Viewport every frame (not just on change), re-introducing per-frame churn | Cadence |
| S4 | Caret-follow scroll oscillates or lags by a frame when the cursor moves and the slice + caret update on different frames | Temporal-interaction |
Bet S2 is pre-flagged: the rebasing must be verified against the
cosmic-text source as QB3 taught, not assumed.
## Session plan
| Session | Work | Probe |
|---|---|---|
| **S1** | Visible-slice `reshape` (slice + clip/rebase spans, decorations, caret, washes) + `scroll_top` state + caret-follow auto-scroll + scoped Viewport (Q#S1S5). | Open a 240 KB file: editing is snappy; arrow/PageUp/PageDown navigate the whole file with the caret staying visible; styling/caret/edits correct at any scroll position; TUI stays converged. |
| **S2+** | Mouse-wheel / scrollbar scroll; pixel-smooth scroll; daemon query-by-range (Q#S6). | Per-session. |
## Process rule (carried forward)
Touches the core render path — the area that has regressed before. Not
merged until visually confirmed on a large file: editing is fast, the
caret tracks, and styling is correct **after scrolling** (the rebasing
is only exercised once `vstart > 0`). CI-green is necessary, not
sufficient.
## Deliberately not committed
- Pixel-smooth (sub-line) scroll — line-based first.
- Mouse-wheel / scrollbar — S2.
- Daemon query-by-range — Q#S6, its own task.
- Horizontal scroll / no-wrap long lines — separate; pmacs-gpu has no
soft wrap yet.

View File

@ -18,13 +18,14 @@
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
use pmacs_protocol::{
AttachRequest, BufferId, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
InstanceMessage, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, TransportError,
is_supported_protocol_version, read_message, write_message,
AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message,
write_message,
};
use winit::event_loop::EventLoopProxy;
@ -76,10 +77,10 @@ pub enum AttachEvent {
/// Connect, handshake, and spawn the reader thread.
///
/// Returns once the handshake has completed and the reader thread is
/// running. The reader thread owns the read half of the stream; the
/// returned [`AttachClient`] retains the write half so the main loop
/// can eventually emit `FrontendEvent`s back to the daemon (session 4
/// will need this — selection / viewport / edits travel that way).
/// running. The reader thread owns the read half of the stream; a
/// writer thread owns the write half. The returned [`AttachClient`]
/// queues outbound `FrontendEvent`s so the winit UI thread never blocks
/// on daemon socket backpressure.
///
/// **Initial window size note** — `AttachRequest::initial_size` is
/// nominally a `CellSize` (rows × cols) anchored to the TUI. The
@ -146,12 +147,13 @@ pub fn connect(
};
write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?;
// Split read/write halves for the reader thread + main-thread
// write path. UnixStream clones share the underlying FD with
// independent buffer state — safe to read on one clone while the
// other writes (the FD is full-duplex).
// Split read/write halves for the reader thread + writer thread.
// UnixStream clones share the underlying FD with independent
// buffer state — safe to read on one clone while the other writes
// (the FD is full-duplex).
let mut read_stream = stream.try_clone().map_err(AttachClientError::Connect)?;
let write_stream = stream;
let (writer_tx, writer_rx) = mpsc::channel::<FrontendEvent>();
// Reader thread. Each iteration: block on read_message, decode,
// forward via the event-loop proxy. Exits cleanly on EOF / any
@ -181,22 +183,32 @@ pub fn connect(
})
.expect("spawn attach reader thread");
// Writer thread. Socket writes can block when the daemon falls
// behind; doing them here keeps keyboard input, redraws, and
// message application off that backpressure path.
thread::Builder::new()
.name("pmacs-gpu attach writer".into())
.spawn(move || {
let mut write_stream = write_stream;
while let Ok(event) = writer_rx.recv() {
if let Err(e) = write_message(&mut write_stream, &event) {
eprintln!("pmacs-gpu: attach writer stopped: {e}");
return;
}
}
})
.expect("spawn attach writer thread");
Ok(AttachClient {
write_stream: Arc::new(Mutex::new(write_stream)),
writer_tx,
frontend_id: hello.assigned_frontend_id,
})
}
/// Handle the main loop keeps after `connect` returns. Session 4
/// wires the write side for `FrontendEvent::Viewport` emission;
/// future sessions will add cursor / edit / focus / detach.
///
/// The write half is wrapped in `Arc<Mutex<...>>` because, while
/// pmacs-gpu's event loop is single-threaded, a future multi-window
/// shape might emit events from several places concurrently. The
/// lock cost is one mutex per emitted frame — negligible.
/// Handle the main loop keeps after `connect` returns. It queues
/// `FrontendEvent`s for the attach writer thread.
pub struct AttachClient {
write_stream: Arc<Mutex<UnixStream>>,
writer_tx: mpsc::Sender<FrontendEvent>,
/// Assigned by the daemon in the `Hello` response. Every
/// `FrontendEvent` carries this so the daemon can route input back
/// to the per-session `SemanticRenderState`.
@ -204,6 +216,11 @@ pub struct AttachClient {
}
impl AttachClient {
/// Frontend id assigned by the daemon in the initial `Hello`.
pub fn frontend_id(&self) -> FrontendId {
self.frontend_id
}
/// Send a `FrontendEvent::Viewport` to the daemon. The daemon's
/// `SemanticRenderState::set_viewport` feeds the spans producer;
/// without this call the daemon ships no `StyleSpans` for the
@ -214,18 +231,48 @@ impl AttachClient {
visible: ByteRange,
generation: u64,
) -> Result<(), TransportError> {
let mut stream = self
.write_stream
.lock()
.expect("attach write-stream mutex poisoned");
write_message(
&mut *stream,
&FrontendEvent::Viewport {
self.send_event(FrontendEvent::Viewport {
frontend_id: self.frontend_id,
buffer_id,
visible,
generation,
},
)
})
}
/// Send a `FrontendEvent::Key` to the daemon (session B1). The
/// daemon routes it through `dispatch_key` — the same keymap +
/// command + Lua stack the TUI drives — so cursor motion and (in
/// later sessions) edits are produced entirely instance-side; the
/// resulting `CursorByte` / `CrdtOp` come back over the attach
/// stream. `timestamp_ns` is 0 (no capture clock plumbed yet; the
/// daemon does not depend on it).
pub fn send_key(&self, key: Key, mods: Modifiers) -> Result<(), TransportError> {
self.send_event(FrontendEvent::Key(KeyEvent {
frontend_id: self.frontend_id,
key,
mods,
timestamp_ns: 0,
}))
}
/// Send a locally-authored CRDT operation to the daemon. The GPU
/// uses this for idle plain-text insertion after applying the same
/// op to its local Loro replica, avoiding a Key round trip on the
/// hot typing path.
pub fn send_crdt_op(&self, buffer_id: BufferId, op: CrdtOp) -> Result<(), TransportError> {
self.send_event(FrontendEvent::CrdtOp {
frontend_id: self.frontend_id,
buffer_id,
op,
})
}
fn send_event(&self, event: FrontendEvent) -> Result<(), TransportError> {
self.writer_tx.send(event).map_err(|_| {
TransportError::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"attach writer thread stopped",
))
})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -684,16 +684,12 @@ impl Buffer {
/// Used by the daemon's `FrontendEvent::CrdtOp` handler when a
/// replica frontend forwards a CRDT op. The flow:
///
/// 1. Capture the rope's current bytes (rope ≡ CRDT projection
/// invariant — both have the same content pre-import).
/// 2. `crdt.import_updates(op_bytes)` — integrates the remote op
/// into the local CRDT state. CRDT convergence handles
/// concurrent edits.
/// 3. Materialize the post-import CRDT content.
/// 4. Compute the diff between pre- and post-content as a single
/// `Replace` `EditOp` (single insert/delete falls out as
/// Replace with empty inserted or empty range).
/// 5. Apply the rope stages (rope mutation + mark adjustment +
/// 1. `crdt.import_updates_with_text_deltas(op_bytes)` integrates
/// the remote op and captures Loro's text projection delta.
/// 2. Apply the common single-insert shape directly to the rope.
/// Deletes and compound updates conservatively fall back to a
/// post-import materialization + contiguous diff.
/// 3. Apply the rope stages (rope mutation + mark adjustment +
/// revision bump + modified flag + `on_edit` broadcast).
/// Skips the CRDT-application stage (already done in step 2)
/// AND the undo push (remote ops aren't locally undoable per
@ -750,25 +746,55 @@ impl Buffer {
});
};
// Step 1: capture pre-import bytes (rope ≡ CRDT projection
// invariant means rope.slice == crdt.materialize_string here).
// Integrate the remote op and capture Loro's projection diff.
// Optimistic GUI typing produces one Insert delta, so handle
// that shape without copying or materializing the document.
let text_deltas = crdt
.import_updates_with_text_deltas(op_bytes)
.map_err(|e| BufferError::CrdtRejected {
reason: format!("import_updates: {e:?}"),
})?;
if let Some((unicode_pos, inserted)) = single_remote_text_insert(&text_deltas)
&& let Some(byte_pos) = crdt.unicode_to_utf8_pos(unicode_pos)
{
let byte_pos = byte_pos as Position;
let mut views = std::mem::take(&mut self.views);
let result =
self.run_remote_rope_stages(&mut views, byte_pos, byte_pos, inserted.as_bytes());
self.views = views;
return result.map(Some);
}
// Single-delete hot path (optimistic Backspace/Delete). The
// deletion's *start* converts through the post-import doc —
// the prefix is untouched, so the byte offset is identical
// pre- and post-import. The *end* byte cannot (those chars
// are gone from the doc); it comes from walking the still
// pre-import rope over the deleted codepoint count.
if let Some((unicode_pos, deleted_chars)) = single_remote_text_delete(&text_deltas)
&& let Some(byte_start) = crdt.unicode_to_utf8_pos(unicode_pos)
&& let Some(byte_end) =
rope_byte_end_after_chars(&self.rope, byte_start as Position, deleted_chars)
{
let byte_start = byte_start as Position;
let mut views = std::mem::take(&mut self.views);
let result = self.run_remote_rope_stages(&mut views, byte_start, byte_end, b"");
self.views = views;
return result.map(Some);
}
// Conservative fallback for compound updates and
// already-integrated ops. The rope is still the pre-import
// projection, so it remains the source for the old bytes.
let old_len = self.rope.len();
let mut old_bytes = vec![0u8; old_len as usize];
if old_len > 0 {
self.rope.slice(0, old_len, &mut old_bytes);
}
// Step 2: integrate the remote op into the CRDT state.
crdt.import_updates(op_bytes)
.map_err(|e| BufferError::CrdtRejected {
reason: format!("import_updates: {e:?}"),
})?;
// Step 3: materialize the post-import content.
let new_content = crdt.materialize_string();
let new_bytes = new_content.as_bytes();
// Step 4: compute common prefix/suffix at byte level, then
// Compute common prefix/suffix at byte level, then
// **back off to UTF-8 char boundaries** in both strings.
//
// # Post-audit-round-4 F25: char-boundary alignment
@ -829,7 +855,7 @@ impl Buffer {
let range_end = (old_bytes.len() - suffix) as Position;
let inserted = &new_bytes[prefix..new_bytes.len() - suffix];
// Step 5: apply rope stages without re-applying to CRDT
// Apply rope stages without re-applying to CRDT
// (CRDT was applied above in step 2) and without undo push
// (remote ops aren't locally undoable per M10.4).
let mut views = std::mem::take(&mut self.views);
@ -1451,6 +1477,103 @@ impl Buffer {
#[cfg(feature = "crdt")]
type CrdtRoutingResult = (Option<Vec<u8>>, Option<Box<crate::rope::CrdtOp>>);
/// Recognize the hot-path projection delta produced by one remote
/// insertion. Loro's retain/delete lengths use Unicode scalar offsets;
/// the caller converts the insertion point through the post-import
/// text container before applying the UTF-8 bytes to the rope.
#[cfg(feature = "crdt")]
fn single_remote_text_insert(deltas: &[Vec<loro::TextDelta>]) -> Option<(usize, &str)> {
let [delta] = deltas else {
return None;
};
let mut cursor = 0usize;
let mut found = None;
for op in delta {
match op {
loro::TextDelta::Retain { retain, .. } => {
cursor = cursor.checked_add(*retain)?;
}
loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {}
loro::TextDelta::Insert { insert, .. } => {
if found.is_some() {
return None;
}
found = Some((cursor, insert.as_str()));
cursor = cursor.checked_add(insert.chars().count())?;
}
loro::TextDelta::Delete { delete } if *delete == 0 => {}
loro::TextDelta::Delete { .. } => return None,
}
}
found
}
/// Recognize the hot-path projection delta produced by one remote
/// deletion: an optional leading `Retain` followed by exactly one
/// `Delete`, nothing else. Returns `(unicode_start, deleted_chars)`
/// in Unicode scalar units.
#[cfg(feature = "crdt")]
fn single_remote_text_delete(deltas: &[Vec<loro::TextDelta>]) -> Option<(usize, usize)> {
let [delta] = deltas else {
return None;
};
let mut cursor = 0usize;
let mut found = None;
for op in delta {
match op {
loro::TextDelta::Retain { retain, .. } => {
cursor = cursor.checked_add(*retain)?;
}
loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {}
loro::TextDelta::Insert { .. } => return None,
loro::TextDelta::Delete { delete } if *delete == 0 => {}
loro::TextDelta::Delete { delete } => {
if found.is_some() {
return None;
}
found = Some((cursor, *delete));
}
}
}
found
}
/// Byte offset just past `chars` codepoints starting at `byte_start`
/// in `rope`. Reads at most `chars * 4` bytes (one UTF-8 max-width
/// each), so a Backspace-sized walk touches a handful of bytes, not
/// the file. `None` when the rope runs out (or a boundary is off) —
/// callers fall back to the materialize-and-diff path.
#[cfg(feature = "crdt")]
fn rope_byte_end_after_chars(
rope: &crate::rope::Rope,
byte_start: Position,
chars: usize,
) -> Option<Position> {
let len = rope.len();
if byte_start > len || chars == 0 {
return None;
}
let take = (chars as Position).saturating_mul(4).min(len - byte_start);
let mut buf = vec![0u8; take as usize];
rope.slice(byte_start, byte_start + take, &mut buf);
let s = match std::str::from_utf8(&buf) {
Ok(s) => s,
// The 4*chars window can cut a trailing codepoint that we
// don't need anyway; keep the valid prefix.
Err(e) => std::str::from_utf8(&buf[..e.valid_up_to()]).ok()?,
};
let mut remaining = chars;
let mut offset = 0usize;
for ch in s.chars() {
if remaining == 0 {
break;
}
offset += ch.len_utf8();
remaining -= 1;
}
(remaining == 0).then(|| byte_start + offset as Position)
}
/// T M10.4: derive a fine-grained `(range, inserted_len)` Edit
/// description for the change from `old_rope` to `new_rope` via
/// longest-common-prefix + longest-common-suffix trim.
@ -2884,6 +3007,70 @@ mod tests {
assert_eq!(count.get(), 1, "on_edit must fire for remote op");
}
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_insert_after_multibyte_char_uses_utf8_byte_position() {
let donor = crate::crdt::CrdtState::new(2).expect("donor");
donor.insert(0, "éx").expect("seed");
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8-insert*", 1).expect("buf");
let donor_snap = donor.export_snapshot().expect("snap");
buf.crdt
.as_ref()
.expect("crdt")
.import_snapshot(&donor_snap)
.expect("init from snap");
buf.rope = crate::rope::Rope::from_bytes("éx".as_bytes());
let v_before = donor.version();
donor.insert("é".len(), "!").expect("insert");
let op_bytes = donor.export_updates_since(&v_before).expect("export");
let edit = buf
.apply_remote_crdt_op(&op_bytes)
.expect("apply")
.expect("non-empty edit");
assert_eq!(rope_string(&buf), "é!x");
assert_eq!(edit.range, Range::new("é".len() as u64, "é".len() as u64));
assert_eq!(edit.inserted_len, 1);
assert_invariant(&buf);
}
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_single_delete_uses_pre_import_rope_for_end_byte() {
let donor = crate::crdt::CrdtState::new(2).expect("donor");
donor.insert(0, "aéx").expect("seed");
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8-delete*", 1).expect("buf");
let donor_snap = donor.export_snapshot().expect("snap");
buf.crdt
.as_ref()
.expect("crdt")
.import_snapshot(&donor_snap)
.expect("init from snap");
buf.rope = crate::rope::Rope::from_bytes("aéx".as_bytes());
// Delete the 2-byte 'é' (CrdtState::delete takes UTF-8 byte
// offsets; the wire delta reports it as 1 Unicode scalar).
let v_before = donor.version();
donor.delete(1, "é".len()).expect("delete");
let op_bytes = donor.export_updates_since(&v_before).expect("export");
let edit = buf
.apply_remote_crdt_op(&op_bytes)
.expect("apply")
.expect("non-empty edit");
assert_eq!(rope_string(&buf), "ax");
assert_eq!(
edit.range,
Range::new(1, 1 + "é".len() as u64),
"byte range covers the multibyte codepoint exactly"
);
assert_eq!(edit.inserted_len, 0);
assert_invariant(&buf);
}
/// F25 (post-audit-round-4): a CRDT update that changes one
/// codepoint into another with a shared leading UTF-8 byte
/// must produce a char-boundary-aligned diff. Pre-fix, the

View File

@ -40,7 +40,18 @@
//! propagation, the optional `crdt_op` field on [`crate::rope::Edit`],
//! and the convergence proptest.
use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, VersionVector};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use loro::{
ContainerTrait, ExportMode, LoroDoc, LoroEncodeError, LoroResult, TextDelta, UndoManager,
VersionVector,
};
type TextDeltaBatches = Arc<Mutex<Vec<Vec<TextDelta>>>>;
type TextDeltaSubscription = (TextDeltaBatches, Arc<AtomicBool>, loro::Subscription);
/// The CRDT-backed buffer state.
///
@ -54,6 +65,12 @@ use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, Versio
/// (foreground worker materializes the initial projection).
pub struct CrdtState {
doc: LoroDoc,
/// Text projection deltas captured only while a remote import is
/// active. Keeping the subscription alive avoids registering and
/// dropping one callback for every typed character.
text_delta_batches: TextDeltaBatches,
text_delta_capture_enabled: Arc<AtomicBool>,
_text_delta_subscription: loro::Subscription,
/// T M10.4: per-peer undo machinery. Bound to `doc`'s `peer_id`
/// at construction; produces inverse ops attributed to that peer.
///
@ -90,13 +107,45 @@ impl CrdtState {
// explicit get here ensures the container is registered before
// any read or write.
let _ = doc.get_text("body");
let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) =
Self::subscribe_text_deltas(&doc);
let undo = Self::create_undo_manager(&doc);
Ok(Self {
doc,
text_delta_batches,
text_delta_capture_enabled,
_text_delta_subscription: text_delta_subscription,
undo: std::cell::RefCell::new(undo),
})
}
fn subscribe_text_deltas(doc: &LoroDoc) -> TextDeltaSubscription {
let text = doc.get_text("body");
let batches = Arc::new(Mutex::new(Vec::<Vec<TextDelta>>::new()));
let capture_enabled = Arc::new(AtomicBool::new(false));
let captured_batches = Arc::clone(&batches);
let captured_enabled = Arc::clone(&capture_enabled);
let subscription = doc.subscribe(
&text.id(),
Arc::new(move |event| {
if !captured_enabled.load(Ordering::Relaxed) {
return;
}
let mut guard = captured_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for event in event.events {
if let Some(delta) = event.diff.as_text()
&& !delta.is_empty()
{
guard.push(delta.clone());
}
}
}),
);
(batches, capture_enabled, subscription)
}
/// T M10.4: construct a fresh `UndoManager` bound to the given doc.
/// Extracted as a helper because `from_bytes` constructs it AFTER
/// the initial seed insert (so the seed isn't observable as an
@ -136,9 +185,14 @@ impl CrdtState {
// The buffer's starting contents (from file load, scratch
// initial text, etc.) shouldn't be undoable from the user's
// perspective; only post-construction edits are.
let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) =
Self::subscribe_text_deltas(&doc);
let undo = Self::create_undo_manager(&doc);
Ok(Self {
doc,
text_delta_batches,
text_delta_capture_enabled,
_text_delta_subscription: text_delta_subscription,
undo: std::cell::RefCell::new(undo),
})
}
@ -302,6 +356,41 @@ impl CrdtState {
self.doc.import(bytes).map(|_| ())
}
/// Import remote updates and capture Loro's text projection deltas.
///
/// The import callback runs synchronously before `doc.import`
/// returns. Buffer's hot path uses the captured single-insert shape
/// to update its rope projection without materializing the whole
/// document.
pub fn import_updates_with_text_deltas(&self, bytes: &[u8]) -> LoroResult<Vec<Vec<TextDelta>>> {
self.text_delta_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
self.text_delta_capture_enabled
.store(true, Ordering::Relaxed);
let import_result = self.doc.import(bytes).map(|_| ());
self.text_delta_capture_enabled
.store(false, Ordering::Relaxed);
import_result?;
let mut guard = self
.text_delta_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(std::mem::take(&mut *guard))
}
/// Convert a Unicode scalar offset in the current text projection
/// to its UTF-8 byte offset.
#[must_use]
pub fn unicode_to_utf8_pos(&self, pos: usize) -> Option<usize> {
self.doc.get_text("body").convert_pos(
pos,
loro::cursor::PosType::Unicode,
loro::cursor::PosType::Bytes,
)
}
/// T M10.10 post-audit-round-4 F26 — validate that importing
/// the wire bytes `bytes` would attribute every new op to
/// `expected_peer_id`.

View File

@ -1246,6 +1246,7 @@ fn handle_session_established(
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)] // per-variant dispatcher match.
fn handle_dispatcher_event(
event: DispatcherEvent,
editor: &mut EditorState,
@ -1342,10 +1343,22 @@ fn handle_dispatcher_event(
// session never sends this; if one does, there is
// no `SemanticRenderState` to update and it is a
// benign no-op.
if semantic_states.contains_key(&source) {
// Phase B (B1) — the Viewport declares *which
// buffer this frontend is displaying*. Align its
// editor window to that buffer so keyboard input
// (`dispatch_key`) and the `CursorByte` it emits
// target the displayed buffer. Without this, a
// semantic frontend's window stays bound to
// LOCAL's attach-time buffer (often a scratch the
// user isn't viewing), so arrow keys moved an
// off-screen cursor and the caret never tracked.
align_semantic_window_to_buffer(editor, source, buffer_id);
if let Some(sem) = semantic_states.get_mut(&source) {
sem.set_viewport(buffer_id, visible, generation);
}
}
}
_ => {
let term_size = *term_sizes
.get(&source)
@ -1354,15 +1367,22 @@ fn handle_dispatcher_event(
if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, event, &mut term_size, render_state);
term_sizes.insert(source, term_size);
} else if semantic_states.contains_key(&source) {
// Phase B (session B1) — a semantic (grid-less)
// session has no `RenderState`, but its keyboard
// input still drives the shared editor core. The
// input events that don't need grid state
// (`Key`, `Mouse`) dispatch through the same
// `dispatch_key` / `dispatch_mouse` path the TUI
// uses; the resulting cursor move / edit flows
// back as `CursorByte` / `CrdtOp`. (Earlier this
// arm dropped these events — the "M11.5 scope"
// posture — which is why typing in pmacs-gpu did
// nothing before B1.)
apply_semantic_input_event(editor, event, term_size);
} else {
// T M11.2 — a semantic (grid-less) session has
// no `RenderState`. Key/Mouse/Paste/Focus
// command handling for semantic frontends is
// M11.5 scope; until then these events are
// dropped rather than panicking the
// dispatcher on the absent grid state.
debug_assert!(
semantic_states.contains_key(&source),
false,
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
);
@ -1836,6 +1856,53 @@ fn handle_remote_crdt_op(
/// `pmacs.editor.open(path)` to switch their window to a different
/// buffer; the per-frontend window-tree refactor (M10.8 Q1) makes
/// this independent.
/// Re-point a semantic frontend's active window at `buffer_id` — the
/// buffer it just declared (via `FrontendEvent::Viewport`) that it is
/// displaying. No-op when the window is already on that buffer or the
/// buffer is gone.
///
/// A semantic frontend renders from the wire (`StyleSpans` + its local
/// CRDT replica), so its daemon-side window holds only the cursor and
/// the buffer identity — no grid overlays to migrate. Rebuilding the
/// `TextView` (a cheap line index) and resetting the cursor is the
/// whole switch. This is the input/display alignment fix for B1: the
/// frontend's *declared* buffer becomes the buffer its keys edit and
/// its `CursorByte` reports.
fn align_semantic_window_to_buffer(
editor: &mut EditorState,
fid: FrontendId,
buffer_id: crate::buffer::BufferId,
) {
use crate::text_view::TextView;
let text_view = {
let core = editor.core.borrow();
let Some(win_id) = core.views.get(&fid).map(|v| v.active) else {
return;
};
if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) {
return; // Already displaying this buffer.
}
let reg = core.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return; // Unknown buffer — leave the window as-is.
};
TextView::new(buf)
};
let mut core = editor.core.borrow_mut();
let Some(win_id) = core.views.get(&fid).map(|v| v.active) else {
return;
};
if let Some(win) = core.windows.get_mut(&win_id) {
win.buffer_id = buffer_id;
win.text_view = text_view;
win.cursor = 0;
win.selection = None;
win.overlays.clear();
}
}
fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView {
use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, Window, WindowId};
@ -1897,6 +1964,30 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
}
}
/// Dispatch a semantic (grid-less) frontend's input event into the
/// shared editor core (Phase B, session B1). Mirrors the `Key` / `Mouse`
/// arms of [`apply_event`] but takes no `RenderState` — a semantic
/// frontend lays out locally, so the only state these events touch is
/// the editor core (cursor, buffer, commands), which `dispatch_key` /
/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus`
/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp`
/// are handled in their own dispatcher arms and never reach here.
#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`.
fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) {
match ev {
FrontendEvent::Key(pmacs_key) => {
if let Some(ct_key) = key_to_crossterm(&pmacs_key) {
editor.dispatch_key(pmacs_key.frontend_id, ct_key);
}
}
FrontendEvent::Mouse(pmacs_mouse) => {
let ct_mouse = mouse_to_crossterm(&pmacs_mouse);
editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size);
}
_ => {}
}
}
// Takes `ev` by value because it semantically consumes the event;
// the caller pulls events out of the channel one at a time and never
// needs to look at them again.
@ -2100,4 +2191,123 @@ mod tests {
"buffer.after-edit must fire when handle_remote_crdt_op produces a text Edit"
);
}
/// Session B1 regression: a `Key` event from a *semantic*
/// (grid-less) frontend must reach the editor core. Before B1 the
/// dispatcher's catch-all only called `apply_event` when the
/// frontend had a `RenderState`, so a semantic frontend's keys were
/// silently dropped — typing in pmacs-gpu did nothing. The routing
/// now goes through `apply_semantic_input_event`; a printable char
/// must self-insert at the frontend's window cursor.
#[cfg(feature = "crdt")]
#[test]
fn semantic_frontend_key_event_reaches_the_core() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use pmacs_protocol::{Key, KeyEvent, Modifiers};
let mut editor = EditorState::new();
let fid = FrontendId(99);
let view = build_fresh_frontend_view(&mut editor);
editor.core.borrow_mut().register_frontend_view(fid, view);
let before = editor
.core
.borrow()
.active_window_for(fid)
.expect("fid window")
.cursor;
apply_semantic_input_event(
&mut editor,
FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key: Key::Char('X'),
mods: Modifiers::NONE,
timestamp_ns: 0,
}),
CellSize::new(24, 80),
);
let after = editor
.core
.borrow()
.active_window_for(fid)
.expect("fid window")
.cursor;
assert_eq!(
after,
before + 1,
"a semantic frontend's printable Key must self-insert and advance its window cursor \
(pre-B1 the dispatcher dropped it)"
);
}
/// B1 input/display alignment: a semantic frontend's window is bound
/// to LOCAL's attach-time buffer, but the buffer it *displays* is
/// the one it declares via `Viewport`. `align_semantic_window_to_buffer`
/// re-points the window so keys edit the displayed buffer — without
/// it, arrow keys moved an off-screen cursor in the wrong buffer and
/// the caret never tracked.
#[cfg(feature = "crdt")]
#[test]
fn viewport_aligns_semantic_window_to_displayed_buffer() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use pmacs_protocol::{Key, KeyEvent, Modifiers};
let mut editor = EditorState::new();
let scratch = editor.core.borrow().active_window().buffer_id;
let file = {
let core = editor.core.borrow();
core.registry
.borrow_mut()
.create_from_bytes("file".to_owned(), b"hello\nworld\n")
};
assert_ne!(scratch, file);
// Attach: window shares LOCAL's active (scratch).
let fid = FrontendId(99);
let view = build_fresh_frontend_view(&mut editor);
editor.core.borrow_mut().register_frontend_view(fid, view);
assert_eq!(
editor
.core
.borrow()
.active_window_for(fid)
.unwrap()
.buffer_id,
scratch
);
// The frontend declares it is displaying the file buffer.
align_semantic_window_to_buffer(&mut editor, fid, file);
assert_eq!(
editor
.core
.borrow()
.active_window_for(fid)
.unwrap()
.buffer_id,
file,
"Viewport must re-point the window at the displayed buffer"
);
// A key now edits the *displayed* buffer, advancing its cursor.
apply_semantic_input_event(
&mut editor,
FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key: Key::Char('Z'),
mods: Modifiers::NONE,
timestamp_ns: 0,
}),
CellSize::new(24, 80),
);
assert_eq!(
editor.core.borrow().active_window_for(fid).unwrap().cursor,
1,
"key must self-insert into the displayed buffer, not the attach-time scratch"
);
}
}

View File

@ -16,7 +16,7 @@ use std::cell::RefCell;
use std::io;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
@ -97,8 +97,21 @@ pub struct EditorState {
/// Snippet store (T M4.11). Co-owned with the snippet
/// provider closure inside [`Self::completion_registry`].
pub snippets: crate::completion_framework::SharedSnippetRegistry,
/// Last left-button down event, used to synthesize terminal double
/// clicks from crossterm's plain Down/Up mouse event stream.
mouse_click: Option<MouseClickState>,
}
#[derive(Copy, Clone)]
struct MouseClickState {
frontend_id: FrontendId,
window_id: WindowId,
cell: CellCoord,
at: Instant,
}
const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
impl EditorState {
/// Construct a fresh editor for an unnamed scratch buffer.
///
@ -322,6 +335,7 @@ impl EditorState {
project_indexer,
completion_registry,
snippets,
mouse_click: None,
}
}
@ -662,6 +676,8 @@ impl EditorState {
/// positions the buffer cursor at the corresponding rope
/// position. Starts an empty selection at that position so
/// a drag continues the region from there.
/// * A second `Down(Left)` in the same cell within the double-click
/// threshold selects the word at the click position.
/// * `Drag(Left)` updates the cursor as the mouse moves; the
/// anchor stays put, so the region grows.
/// * `Up(Left)` ends a drag. If anchor and cursor coincide
@ -699,14 +715,28 @@ impl EditorState {
match ev.kind {
MouseEventKind::Down(MouseButton::Left) => {
if local_row >= inner_rows {
self.mouse_click = None;
return; // Mode-line click: reserved.
}
let click_cell = CellCoord::new(cell_row, cell_col);
let is_double_click = self.is_double_click(frontend_id, win_id, click_cell);
self.activate_and_position(win_id, local_row, local_col);
if is_double_click && self.core.borrow_mut().select_word_at_cursor() {
self.mouse_click = None;
} else {
let mut core = self.core.borrow_mut();
let pos = core.cursor();
core.begin_selection(pos);
self.mouse_click = Some(MouseClickState {
frontend_id,
window_id: win_id,
cell: click_cell,
at: Instant::now(),
});
}
}
MouseEventKind::Drag(MouseButton::Left) => {
self.mouse_click = None;
if local_row >= inner_rows {
return;
}
@ -721,14 +751,33 @@ impl EditorState {
}
}
MouseEventKind::ScrollUp => {
self.mouse_click = None;
self.scroll_window(win_id, -SCROLL_LINES);
}
MouseEventKind::ScrollDown => {
self.mouse_click = None;
self.scroll_window(win_id, SCROLL_LINES);
}
_ => {}
_ => {
self.mouse_click = None;
}
}
}
fn is_double_click(
&self,
frontend_id: FrontendId,
window_id: WindowId,
cell: CellCoord,
) -> bool {
let Some(prev) = self.mouse_click else {
return false;
};
prev.frontend_id == frontend_id
&& prev.window_id == window_id
&& prev.cell == cell
&& prev.at.elapsed() <= DOUBLE_CLICK_MAX_DELAY
}
/// Make `win_id` the active window and place its cursor at the
/// buffer position corresponding to `(local_row, local_col)`,
@ -1110,6 +1159,7 @@ pub fn paint_frame(
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(grid, buf, window, &rect, inner_rows);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
@ -1199,6 +1249,62 @@ fn inner_rows(rect: &crate::window::Rect) -> u32 {
rect.size.rows.saturating_sub(1)
}
fn paint_local_selection(
grid: &mut crate::cell::CellGrid<'_>,
buf: &crate::buffer::Buffer,
window: &crate::window::Window,
rect: &crate::window::Rect,
inner_rows: u32,
) {
let Some((sel_start, sel_end)) = window.region() else {
return;
};
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
return;
}
let first_row = window.view_top;
let last_row = first_row.saturating_add(inner_rows as usize);
for display_row in first_row..last_row {
let Some(line_start) = window.text_view.line_offset(display_row) else {
continue;
};
let Some(line_len) = window.text_view.line_len(buf, display_row) else {
continue;
};
let line_end = line_start.saturating_add(line_len);
let paint_start = sel_start.max(line_start);
let paint_end = sel_end.min(line_end);
if paint_start >= paint_end {
continue;
}
let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else {
continue;
};
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else {
continue;
};
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
continue;
}
let row_offset = display_row.saturating_sub(first_row) as u32;
let start_col = start_coord.col.min(rect.size.cols);
let end_col = end_coord.col.min(rect.size.cols);
if start_col >= end_col {
continue;
}
for col in start_col..end_col {
let cell = grid.at(CellCoord::new(
rect.origin.row + row_offset,
rect.origin.col + col,
));
cell.style.reverse = true;
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "the mode line packs eight unrelated facts; bundling them into a struct just adds ceremony"
@ -4055,6 +4161,94 @@ mod tests {
assert!(core.active_region().is_none());
}
#[test]
fn mouse_drag_selection_paints_in_tui_grid() {
use crossterm::event::{MouseButton, MouseEventKind};
let mut s = fresh_with(b"hello world\n");
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
term_size_24x80(),
);
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Drag(MouseButton::Left), 0, 5),
term_size_24x80(),
);
let (cells, _, _) = render_to_grid(&s, 24, 80);
for col in 0..5 {
let style = cells[col as usize].style;
assert!(style.reverse, "selected col {col} was not reverse video");
}
assert!(
!cells[5].style.reverse,
"unselected cell after mouse selection was reverse video"
);
}
#[test]
fn mouse_double_click_selects_word_and_paints_in_tui_grid() {
use crossterm::event::{MouseButton, MouseEventKind};
let mut s = fresh_with(b"hello world\n");
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Down(MouseButton::Left), 0, 7),
term_size_24x80(),
);
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Up(MouseButton::Left), 0, 7),
term_size_24x80(),
);
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Down(MouseButton::Left), 0, 7),
term_size_24x80(),
);
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Up(MouseButton::Left), 0, 7),
term_size_24x80(),
);
assert_eq!(s.core.borrow().cursor(), 11);
assert_eq!(s.core.borrow().active_region(), Some((6, 11)));
let (cells, _, _) = render_to_grid(&s, 24, 80);
assert!(!cells[5].style.reverse, "selection leaked into separator");
for col in 6..11 {
assert!(
cells[col as usize].style.reverse,
"double-click selected word missing col {col}"
);
}
assert!(!cells[11].style.reverse, "selection leaked past word");
}
#[test]
fn mouse_double_click_on_separator_leaves_no_region() {
use crossterm::event::{MouseButton, MouseEventKind};
let mut s = fresh_with(b"hello world\n");
for _ in 0..2 {
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Down(MouseButton::Left), 0, 5),
term_size_24x80(),
);
s.dispatch_mouse(
FrontendId::LOCAL,
mouse(MouseEventKind::Up(MouseButton::Left), 0, 5),
term_size_24x80(),
);
}
assert_eq!(s.core.borrow().cursor(), 5);
assert!(s.core.borrow().active_region().is_none());
}
/// Acceptance bullet 3: mouse events are coalesced at frame
/// boundaries — many drag events between renders all apply, and
/// the cursor ends up at the last position.
@ -4317,6 +4511,43 @@ mod tests {
assert_eq!(s.core.borrow().cursor(), 14);
}
#[test]
fn shift_arrow_extends_selection_and_paints_in_tui_grid() {
let mut s = fresh_with(b"abcdef\n");
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
assert_eq!(s.core.borrow().cursor(), 2);
assert_eq!(s.core.borrow().active_region(), Some((0, 2)));
let (cells, stride, _) = render_to_grid(&s, 24, 80);
assert!(cells[0].style.reverse, "selection did not paint col 0");
assert!(cells[1].style.reverse, "selection did not paint col 1");
assert!(!cells[2].style.reverse, "selection leaked into col 2");
assert_eq!(glyph_at(&cells, stride, 0, 0), 'a');
assert_eq!(glyph_at(&cells, stride, 0, 1), 'b');
}
#[test]
fn ctrl_shift_arrow_extends_selection_by_words_and_paragraphs() {
let mut s = fresh_with(b"alpha beta\n\nsecond\n");
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
);
assert_eq!(s.core.borrow().cursor(), 5);
assert_eq!(s.core.borrow().active_region(), Some((0, 5)));
s.core.borrow_mut().active_window_mut().cursor = 0;
s.core.borrow_mut().clear_selection();
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
);
assert_eq!(s.core.borrow().cursor(), 11);
assert_eq!(s.core.borrow().active_region(), Some((0, 11)));
}
#[test]
fn page_down_advances_cursor_and_view_top() {
let mut content = Vec::new();

View File

@ -757,6 +757,28 @@ impl EditorCore {
aw.goal_col = None;
}
/// Select the word at the active cursor. Returns `false` when the
/// cursor is not on a word character.
pub fn select_word_at_cursor(&mut self) -> bool {
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let range = {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(id) else {
return false;
};
word_range_at(buffer, cursor)
};
let Some((start, end)) = range else {
return false;
};
let aw = self.active_window_mut();
aw.selection = Some(crate::window::Selection { anchor: start });
aw.cursor = end;
aw.goal_col = None;
true
}
/// Move the cursor forward to the next paragraph break.
///
/// A paragraph break is a blank line (empty or whitespace-only).
@ -1383,6 +1405,16 @@ fn forward_word(buf: &Buffer, mut pos: Position) -> Position {
pos
}
fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> {
let (ch, _) = char_at(buf, pos)?;
if !is_word_char(ch) {
return None;
}
let start = backward_word(buf, pos);
let end = forward_word(buf, pos);
(start < end).then_some((start, end))
}
/// True iff `line` is empty or contains only ASCII whitespace.
/// Used by paragraph motion: a blank line is a paragraph break.
fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool {

View File

@ -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

View File

@ -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(

View File

@ -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,103 @@ 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 +820,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 +847,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 +866,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 +1226,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 +1336,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 +1704,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();

View File

@ -124,6 +124,21 @@ pub struct SemanticRenderState {
/// Only the grammar (tree-sitter) path is gated; the LSP-token path
/// has no comparably cheap handle and recomputes as before.
last_style_gate: HashMap<BufferId, StyleGate>,
/// Cached byte↔line table for the diagnostics projection, keyed
/// by buffer revision. Building it costs an O(buffer) rope copy
/// plus a full scan; before this cache, that ran on *every tick*
/// while diagnostics were on screen (the table is only consulted
/// when the store is non-stale and non-empty) — a steady-state
/// CPU burn for a value that changes only when the buffer does.
diag_line_cache: HashMap<BufferId, DiagLineCache>,
}
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
/// offsets and source length of a buffer at `revision`.
struct DiagLineCache {
revision: u64,
line_starts: Vec<u64>,
source_len: u64,
}
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
@ -169,6 +184,7 @@ impl SemanticRenderState {
last_adornments: HashMap::new(),
last_summary: HashMap::new(),
last_style_gate: HashMap::new(),
diag_line_cache: HashMap::new(),
}
}
@ -220,14 +236,29 @@ impl SemanticRenderState {
// already-sent baseline — can skip the whole block. The LSP-
// token path returns `None` (no cheap revision) and recomputes
// every tick as before.
let style_gate = grammar_style_key(state, &vp, generation);
let style_parse_not_ready = grammar_style_parse_not_ready(state, vp.buffer_id);
// The LSP-token styling authority (grammar-less buffers, e.g.
// C++) gets the same hold: while the semantic-token store is
// stale (document edited since the last token response),
// `lsp_scoped_style_spans` would compute an empty set, and
// shipping that clears the frontend's colors for the whole
// stale window — the styling twin of the diagnostics blink.
let style_tokens_stale = lsp_style_tokens_stale(state, vp.buffer_id);
let style_hold = style_parse_not_ready || style_tokens_stale;
let style_gate = (!style_hold).then(|| grammar_style_key(state, &vp, generation));
let style_gate = style_gate.flatten();
let style_unchanged = match (&style_gate, self.last_style_gate.get(&vp.buffer_id)) {
(Some(g), Some(prev)) => g.matches(prev) && self.last_sent.contains_key(&vp.buffer_id),
_ => false,
};
if style_unchanged {
// Styling cannot have changed since the last computation;
// emit nothing and skip the query.
if style_hold || style_unchanged {
// If the style key is unchanged, styling cannot have
// changed since the last computation. If a grammar parse is
// still pending (or the LSP token store is stale), keep the
// previous spans briefly rather than querying and reshaping
// stale syntax on every typed byte; the parse-bundle
// revision (or the next token response) will force a fresh
// frame as soon as it settles.
} else {
match style_gate {
Some(g) => {
@ -243,8 +274,36 @@ impl SemanticRenderState {
// --- Decorations (T M11.3 producer, T M11.4 diff) ---
let decorations = self.scoped_decorations(state, &vp);
let prev = self.last_decorations.get(&vp.buffer_id);
// Hold-while-stale: while the diag store is stale (document
// edited since the last `publishDiagnostics`), this frame has
// no authoritative diagnostic positions. The frontend's
// last-received set — which it translates through its own
// local edits — is strictly better than anything we can ship:
// an empty frame wipes it (diagnostics blink out on the first
// keystroke of every burst and back in after the next publish,
// one full frontend reshape each way), and re-shipping the
// store's items would anchor pre-edit positions over post-edit
// text (the M11.8 artifact). So as long as the
// *non-diagnostic* part is unchanged, say nothing and leave
// the baseline untouched — staleness clears on the next
// publishDiagnostics absorption, and the generation transition
// since the held baseline forces that frame full.
let held = diagnostics_store_stale(state, vp.buffer_id)
&& prev.is_some_and(|p| {
decorations
.iter()
.eq(p.items.iter().filter(|d| !is_diagnostic_kind(d.kind)))
});
let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation);
if full {
if held {
// No new information for the frontend this frame. (A
// selection change during the stale window falls through
// to the branches below and ships without diagnostics —
// rare, and better than pinning a dead selection.)
} else if full {
let suppress_empty_generation_bump = prev.is_some_and(|p| {
p.visible == vp.visible && p.items.is_empty() && decorations.is_empty()
});
self.last_decorations.insert(
vp.buffer_id,
LastFrame {
@ -253,6 +312,7 @@ impl SemanticRenderState {
generation,
},
);
if !suppress_empty_generation_bump {
out.push(InstanceMessage::Decorations {
buffer_id: vp.buffer_id,
generation,
@ -262,6 +322,7 @@ impl SemanticRenderState {
decorations,
}],
});
}
} else {
let prev = prev.expect("checked is_none_or above");
let intervals = changed_intervals(&prev.items, &decorations, |d| d.range);
@ -308,6 +369,17 @@ impl SemanticRenderState {
state: &EditorState,
vp: &DeclaredViewport,
) -> Option<InstanceMessage> {
// Hold-while-stale — mirrors the Decorations hold in
// `render_frame`. An empty frame here wipes the frontend's
// cached virtual text mid-typing-burst, and inline adornments
// occupy layout space: the wipe visibly shifts real glyphs
// (and forces a reshape), then the post-refresh re-emit
// shifts them back. The frontend's locally-translated cache
// is the better picture until a fresh `inlayHint` response
// clears the stale flag and re-emits through the diff below.
if inlay_store_stale(state, vp.buffer_id) {
return None;
}
let adornments = scoped_inline_adornments(state, vp);
let should_emit = match self.last_adornments.get(&vp.buffer_id) {
// First sight of this buffer: speak only if there is
@ -356,6 +428,18 @@ impl SemanticRenderState {
buffer_id: BufferId,
generation: u64,
) -> Option<InstanceMessage> {
// The summary is a *whole-file* tree-sitter pass (the minimap
// needs every line). Recomputing it on every edit's generation
// bump was a per-keystroke O(file) cost — a major part of the
// typing slowness. For grammar-backed buffers, debounce it to
// reparse-completion. `pending_edit_count()` alone is not
// enough: dispatch drains that list immediately, leaving the
// expensive summary path free to run while a parse job is still
// in flight. Wait until there is an installed parse, no pending
// edits, and no recorded parse job for this buffer.
if grammar_style_parse_not_ready(state, buffer_id) {
return None;
}
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
return None;
}
@ -444,35 +528,25 @@ impl SemanticRenderState {
}
}
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
fn scoped_decorations(
&mut self,
state: &EditorState,
vp: &DeclaredViewport,
) -> Vec<Decoration> {
let core = state.core.borrow();
let registry = core.registry.clone();
let reg = registry.borrow();
let mut out = Vec::new();
// Byte<->line mapping is needed by both the CurrentLine
// derivation and the diagnostics projection, and
// `buffer_source_bytes` is an O(n) rope copy. This runs every
// tick in the daemon's hot loop, so materialize at most once
// per call and reuse — never twice (the pre-9.2 shape copied
// separately in each branch).
let mut line_info: Option<(Vec<u8>, Vec<u64>)> = None;
// Selection + CurrentLine — per-window (per-frontend) state.
// Only this session's active window for the declared buffer
// contributes either kind.
//
// Q#3 (per-line CurrentLine cadence, stance β) falls out of the
// existing M11.4 diff: `render_frame` compares the new
// decoration Vec against the last sent one and emits only on
// change. Horizontal cursor motion within a single line
// produces an identical `CurrentLine` range and an identical
// overall Vec, so `changed_intervals` returns empty and nothing
// ships. No `last_cursor_line` cache is needed at this layer.
// Selection is per-window (per-frontend) state. CurrentLine is
// deliberately not emitted for semantic frontends: the GPU has
// CursorByte and paints its own caret/current-line affordances.
// Emitting CurrentLine here forced a whole-buffer line table on
// every frame even though pmacs-gpu ignores its own current-line
// wash.
if let Some(win) = core.active_window_for(self.frontend_id)
&& win.buffer_id == vp.buffer_id
{
if let Some((lo, hi)) = win.region()
&& let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
{
out.push(Decoration {
@ -480,21 +554,6 @@ impl SemanticRenderState {
kind: DecorationKind::Selection,
});
}
if let Ok(buf) = reg.get(vp.buffer_id) {
let (source, line_starts) = line_info.get_or_insert_with(|| {
let s = buffer_source_bytes(buf);
let ls = line_start_offsets(&s);
(s, ls)
});
let (lo, hi) = current_line_range(line_starts, source.len() as u64, win.cursor);
if let Some(range) = clip_to_viewport(lo, hi, vp) {
out.push(Decoration {
range,
kind: DecorationKind::CurrentLine,
});
}
}
}
// Diagnostics — keyed in the shared store by the file URI the
// Lua LSP glue opened the document under. The URI is derived
@ -518,12 +577,30 @@ impl SemanticRenderState {
&& !diags.is_empty()
&& let Ok(buf) = reg.get(vp.buffer_id)
{
let (source, line_starts) = line_info.get_or_insert_with(|| {
// Byte<->line mapping, cached per buffer revision —
// rebuilding it is an O(buffer) rope copy + scan, far
// too expensive to repeat on every tick a diagnostic
// is on screen.
let cache = self
.diag_line_cache
.entry(vp.buffer_id)
.and_modify(|c| {
if c.revision != buf.revision() {
let s = buffer_source_bytes(buf);
let ls = line_start_offsets(&s);
(s, ls)
c.revision = buf.revision();
c.line_starts = line_start_offsets(&s);
c.source_len = s.len() as u64;
}
})
.or_insert_with(|| {
let s = buffer_source_bytes(buf);
DiagLineCache {
revision: buf.revision(),
line_starts: line_start_offsets(&s),
source_len: s.len() as u64,
}
});
let source_len = source.len() as u64;
let (line_starts, source_len) = (&cache.line_starts, cache.source_len);
for d in &diags {
let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col);
let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col);
@ -711,6 +788,61 @@ fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec<Decoration> {
.collect()
}
/// True for the four diagnostic-underline decoration kinds — the
/// family whose emission is gated on diag-store staleness by the
/// hold-while-stale logic in `render_frame`.
fn is_diagnostic_kind(kind: DecorationKind) -> bool {
matches!(
kind,
DecorationKind::DiagnosticError
| DecorationKind::DiagnosticWarning
| DecorationKind::DiagnosticInfo
| DecorationKind::DiagnosticHint
)
}
/// True when `buffer_id`'s entry in the diagnostics store is stale
/// (the document changed since the last `publishDiagnostics`
/// absorption). Buffers with no file URI are never stale.
fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool {
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
return false;
};
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
guard.is_stale(&uri)
}
/// Style-family staleness for the LSP-token authority. True only for
/// a buffer with **no** tree-sitter view (policy A routes those
/// through `lsp_scoped_style_spans`) whose semantic-token store entry
/// is stale. Grammar-backed buffers always return `false` — their
/// styling freshness is `grammar_style_parse_not_ready`'s job.
fn lsp_style_tokens_stale(state: &EditorState, buffer_id: BufferId) -> bool {
if state.syntax_registry.view(buffer_id).is_some() {
return false;
}
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
return false;
};
let store = state.lsp_manager.borrow().semantic_token_store();
let guard = store.lock().expect("semantic token store mutex poisoned");
guard.is_stale(&uri)
}
/// Inlay-hint twin of [`diagnostics_store_stale`].
fn inlay_store_stale(state: &EditorState, buffer_id: BufferId) -> bool {
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
return false;
};
let store = state.lsp_manager.borrow().inlay_hint_store();
let guard = store.lock().expect("inlay-hint store mutex poisoned");
guard.is_stale(&uri)
}
/// Map an LSP diagnostic severity onto the wire decoration kind.
fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind {
use crate::diag::DiagnosticSeverity as S;
@ -758,31 +890,6 @@ fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec<u8> {
bytes
}
/// Byte range `(start, end)` of the line containing `cursor`, where
/// `start` is the position right after the previous `\n` (or 0 for the
/// first line) and `end` is the position of the next `\n` (or
/// `source_len` for the last line). Used by `scoped_decorations` to
/// emit `DecorationKind::CurrentLine`; clamps so a cursor at or past
/// `source_len` returns the last line's range rather than indexing
/// out.
fn current_line_range(line_starts: &[u64], source_len: u64, cursor: u64) -> (u64, u64) {
// `partition_point` returns the count of leading elements satisfying
// the predicate, i.e. the index of the first `line_start > cursor`.
// Subtracting 1 yields the index of the largest `line_start <=
// cursor`. `line_starts` always starts with 0, so the saturating
// sub is defensive against an empty `line_starts`.
let idx = line_starts
.partition_point(|&start| start <= cursor)
.saturating_sub(1);
let lo = line_starts.get(idx).copied().unwrap_or(0);
let hi = line_starts
.get(idx + 1)
.copied()
.unwrap_or(source_len)
.min(source_len);
(lo, hi)
}
/// Byte offset of the start of each line (index 0 = byte 0; one entry
/// per line, where a line is a maximal run ended by `\n`).
fn line_start_offsets(source: &[u8]) -> Vec<u64> {
@ -830,6 +937,15 @@ fn grammar_style_key(
})
}
fn grammar_style_parse_not_ready(state: &EditorState, buffer_id: BufferId) -> bool {
let Some(handle) = state.syntax_registry.view(buffer_id) else {
return false;
};
handle.current().is_none()
|| handle.pending_edit_count() > 0
|| state.syntax_registry.has_pending_parse_job_for(buffer_id)
}
/// Compute the styled byte runs intersecting the declared viewport,
/// mapped through the active theme. Spans are clipped to the viewport
/// and to the parsed source length; runs that resolve to the default
@ -871,7 +987,15 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<StyleSp
}
let capture_names = query.capture_names();
let highlights = crate::syntax::compute_highlight_spans(&query, &bundle);
// Scope the tree-sitter capture walk to the visible byte range so
// re-styling on each edit is O(visible), not O(file) — the typing
// bottleneck on large files (framing Q#S6). Captures whose nodes
// intersect the range are returned, then clipped exactly below.
let highlights = crate::syntax::compute_highlight_spans_in_range(
&query,
&bundle,
Some(vis_start as usize..vis_end as usize),
);
let mut out = Vec::new();
for hs in highlights {
let s = u64::from(hs.start_byte).max(vis_start);
@ -1272,30 +1396,9 @@ mod tests {
}
#[test]
fn current_line_range_finds_enclosing_line() {
// "abc\nde\nfgh": line_starts = [0, 4, 7]; source_len = 10.
let line_starts = vec![0u64, 4, 7];
let len = 10u64;
// Cursor at byte 0 → line 0 = [0, 4).
assert_eq!(current_line_range(&line_starts, len, 0), (0, 4));
// Cursor anywhere within line 0 → still line 0.
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
// Cursor on the newline byte still belongs to the line it
// terminates.
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
// Cursor at line 1 start → line 1 = [4, 7).
assert_eq!(current_line_range(&line_starts, len, 4), (4, 7));
// Cursor in last line → [7, len).
assert_eq!(current_line_range(&line_starts, len, 8), (7, 10));
// Cursor at exactly source_len (past last byte) → still last
// line; clamps cleanly without indexing out.
assert_eq!(current_line_range(&line_starts, len, len), (7, 10));
}
#[test]
fn current_line_projects_as_a_decoration_for_cursor_on_seed() {
// "abc\nde": cursor at byte 0 → CurrentLine = [0, 4).
fn semantic_projection_does_not_emit_current_line_decoration() {
// CurrentLine is a frontend-local visual for semantic sessions;
// the daemon should not copy the whole buffer to derive it.
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
@ -1304,23 +1407,14 @@ mod tests {
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
let current = decos
.iter()
.find(|d| d.kind == DecorationKind::CurrentLine)
.expect("CurrentLine present (cursor on line 0)");
assert_eq!(
current.range,
ByteRange { start: 0, end: 4 },
"line 0 of \"abc\\nde\" spans bytes [0, 4)"
assert!(
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
"semantic projection must not emit CurrentLine; got {decos:?}"
);
}
#[test]
fn current_line_skipped_when_active_window_is_a_different_buffer() {
// Producer must only emit per-window state for windows whose
// active buffer matches the projected viewport. The vp.buffer_id
// regression test (decorations_use_vp_buffer_not_active_buffer)
// exercises this for Selection; assert it for CurrentLine too.
fn semantic_current_line_absence_does_not_depend_on_active_buffer() {
let state = empty_state();
let scratch_id = active_buffer(&state);
let file_id = {
@ -1338,17 +1432,15 @@ mod tests {
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
assert!(
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
"CurrentLine must not project against a viewport whose buffer is not the active window's buffer; got {decos:?}"
"semantic projection must not emit CurrentLine for any viewport; got {decos:?}"
);
}
#[test]
fn same_line_cursor_motion_does_not_re_emit_decorations() {
// Q#3 stance β: horizontal cursor motion within the same line
// must not re-ship a Decorations frame. The existing M11.4
// changed_intervals diff gives this for free — same line means
// identical decoration ranges means an empty interval list
// means no emission.
fn cursor_motion_does_not_re_emit_decorations() {
// Cursor-only movement should not ship Decorations. Semantic
// frontends receive CursorByte separately and derive local
// cursor visuals without daemon decoration churn.
let state = empty_state();
let buffer_id = active_buffer(&state);
{
@ -1381,20 +1473,15 @@ mod tests {
"same-line cursor motion must not re-emit Decorations"
);
// Cross a `\n` (byte 10) → line changes → re-emission.
// Cross a `\n` (byte 10). Still no Decorations frame.
{
let mut core = state.core.borrow_mut();
core.active_window_mut().cursor = 12;
}
let msgs = s.render_frame(&state);
let (_full, decos) =
decorations_of(&msgs).expect("line-change must ship a Decorations frame");
let current = decos
.iter()
.find(|d| d.kind == DecorationKind::CurrentLine)
.expect("CurrentLine present");
// Line 1 of "abcdefghij\nklmno" starts at byte 11.
assert_eq!(current.range, ByteRange { start: 11, end: 16 });
assert!(
s.render_frame(&state).is_empty(),
"line-crossing cursor motion must not re-emit Decorations"
);
}
#[test]
@ -1408,11 +1495,8 @@ mod tests {
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
// Session 9.2 added `CurrentLine` to the projection: line 0
// (cursor at byte 0) emits as a `CurrentLine` decoration in
// addition to the seeded warning. This test pins the
// diagnostic projection's byte math; assert that decoration's
// shape rather than the total count.
// This test pins the diagnostic projection's byte math without
// relying on any cursor-line decoration.
let warning = decos
.iter()
.find(|d| d.kind == DecorationKind::DiagnosticWarning)
@ -1491,6 +1575,94 @@ mod tests {
);
}
/// Hold-while-stale (diagnostics churn fix): once diagnostics
/// have shipped, marking the store stale (which happens per edit)
/// must NOT ship a clearing frame — the frontend keeps its
/// last-received set, translated through its own local edits,
/// until the next `publishDiagnostics`. The pre-fix behavior
/// shipped a full empty frame on the first keystroke of every
/// burst (diagnostics blinked out, one full frontend reshape) and
/// re-added them after the next publish (blink in, another
/// reshape).
#[test]
fn diagnostics_hold_emission_while_store_stale_after_shipping() {
let state = empty_state();
let buffer_id = active_buffer(&state);
seed_diagnostic(&state, buffer_id);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let (_full, decos) =
decorations_of(&s.render_frame(&state)).expect("baseline ships the diagnostic");
assert!(
decos.iter().any(|d| is_diagnostic_kind(d.kind)),
"baseline contains the seeded diagnostic; got {decos:?}"
);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.mark_stale(uri.clone());
assert!(
decorations_of(&s.render_frame(&state)).is_none(),
"stale store holds Decorations emission instead of shipping a clearing frame"
);
assert!(
decorations_of(&s.render_frame(&state)).is_none(),
"the hold is stable across frames"
);
// A selection change during the stale window still ships
// (without diagnostic kinds) — the hold must not pin a dead
// selection just to protect the diagnostics.
set_selection(&state, 0, 2);
let (_full, decos) = decorations_of(&s.render_frame(&state))
.expect("selection change ships during the stale window");
assert!(
decos.iter().any(|d| d.kind == DecorationKind::Selection),
"fresh selection present; got {decos:?}"
);
assert!(
decos.iter().all(|d| !is_diagnostic_kind(d.kind)),
"no stale-positioned diagnostics ride along; got {decos:?}"
);
// The next publishDiagnostics clears the flag; diagnostics
// re-emit on the following frame.
state
.lsp_manager
.borrow()
.diag_store()
.lock()
.expect("diag store")
.set(
&uri,
vec![crate::diag::Diagnostic {
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 2,
severity: crate::diag::DiagnosticSeverity::Warning,
message: "x".into(),
source: None,
code: None,
}],
);
let (_full, decos) = decorations_of(&s.render_frame(&state))
.expect("post-publish frame re-ships diagnostics");
assert!(
decos
.iter()
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
"diagnostics return once the store is fresh; got {decos:?}"
);
}
/// Regression: in a multi-frontend setup the editor's *active*
/// buffer (set by `core.active_buffer_id()`, derived from the
/// active frontend's view) can differ from the buffer a given
@ -1628,6 +1800,7 @@ mod tests {
let buffer_id = active_buffer(&state);
let mut s = local();
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
set_selection(&state, 0, 1);
let _ = s.render_frame(&state); // initial full
assert!(
s.render_frame(&state).is_empty(),
@ -1651,7 +1824,7 @@ mod tests {
}
// Generation transitioned → next frame must be full for both
// diff-shaped families.
// diff-shaped families when there is state to re-anchor.
let msgs = s.render_frame(&state);
let (style_full, _) = style_segments(&msgs).expect("StyleSpans re-emitted");
let (deco_full, _) = decorations_of(&msgs).expect("Decorations re-emitted");
@ -1892,6 +2065,39 @@ mod tests {
sid
}
fn seed_rust_parse_view(
state: &EditorState,
buffer_id: BufferId,
text: &[u8],
) -> crate::syntax::ParseViewHandle {
let language = state
.syntax_registry
.language("rust")
.expect("rust language");
let mut core = state.core.borrow_mut();
let registry_handle = core.registry.clone();
let mut registry = registry_handle.borrow_mut();
let buf = registry.get_mut(buffer_id).expect("active buffer");
if !text.is_empty() {
buf.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: text,
})
.expect("seed rust text");
}
let parse_view = crate::syntax::ParseView::new(buf, language, "rust".to_owned());
let handle = parse_view.handle();
let req = handle.make_request();
let bundle = crate::syntax::run_parse(req).expect("initial rust parse");
handle.install(std::sync::Arc::new(bundle));
buf.attach_view(Box::new(parse_view));
drop(registry);
core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.rs")));
drop(core);
state.syntax_registry.attach_view(buffer_id, handle.clone());
handle
}
#[test]
fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() {
let state = empty_state();
@ -1933,6 +2139,123 @@ mod tests {
);
}
#[test]
fn grammar_style_spans_wait_for_pending_parse() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
let handle = seed_rust_parse_view(&state, bid, b"fn main() {}\n");
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
let first = s.render_frame(&state);
assert!(
style_segments(&first).is_some(),
"installed parse emits the baseline style frame"
);
{
let core = state.core.borrow();
core.registry
.borrow_mut()
.get_mut(bid)
.expect("active buffer")
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"// editing\n",
})
.expect("typing edit");
}
assert!(
handle.pending_edit_count() > 0,
"attached parse view recorded the edit"
);
let pending = s.render_frame(&state);
assert!(
style_segments(&pending).is_none(),
"style query is skipped while edits are waiting for parse dispatch"
);
let req = handle.make_request();
state.syntax_registry.record_parse_job(9001, bid);
let in_flight = s.render_frame(&state);
assert!(
style_segments(&in_flight).is_none(),
"style query is skipped while the parse job is in flight"
);
let bundle = crate::syntax::run_parse(req).expect("settled rust parse");
handle.install(std::sync::Arc::new(bundle));
assert_eq!(state.syntax_registry.take_parse_job(9001), Some(bid));
let settled = s.render_frame(&state);
assert!(
style_segments(&settled).is_some(),
"new parse bundle emits refreshed style spans"
);
}
/// Hold-while-stale for the LSP-token styling authority: once a
/// grammar-less buffer's colors have shipped, marking the token
/// store stale (which happens per edit) must NOT ship a clearing
/// frame — the styling twin of the diagnostics hold. The next
/// token response clears the flag and re-emits.
#[test]
fn lsp_style_holds_while_token_store_stale() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
let sid = seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]);
s.set_viewport(
bid,
ByteRange {
start: 0,
end: 4096,
},
0,
);
assert!(
style_segments(&s.render_frame(&state)).is_some(),
"baseline ships the LSP-token styling"
);
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/x.cpp"));
{
let store = state.lsp_manager.borrow().semantic_token_store();
let mut guard = store.lock().expect("semantic token store");
guard.mark_stale(uri.clone());
}
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"stale token store holds StyleSpans instead of clearing the colors"
);
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"the hold is stable across frames"
);
// A fresh token response (absorbed via `set`) clears the flag.
// Identical tokens produce no frame — the frontend's cache was
// never cleared, so there is nothing to say. Changed tokens
// diff against the held baseline and ship.
set_tokens(&state, sid, vec![tok(0, 0, 3)]);
assert!(
style_segments(&s.render_frame(&state)).is_none(),
"fresh-but-identical tokens stay silent (cache was never wiped)"
);
set_tokens(&state, sid, vec![tok(0, 0, 5)]);
assert!(
style_segments(&s.render_frame(&state)).is_some(),
"fresh changed tokens re-emit the styling"
);
}
#[test]
fn lsp_style_suppressed_when_unchanged() {
let state = empty_state();
@ -2139,7 +2462,7 @@ mod tests {
}
#[test]
fn inline_adornments_emit_empty_clear_while_inlay_store_stale() {
fn inline_adornments_hold_while_inlay_store_stale() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
@ -2155,26 +2478,30 @@ mod tests {
let store = state.lsp_manager.borrow().inlay_hint_store();
store.lock().expect("inlay store").mark_stale(uri.clone());
let clear =
adornments_of(&s.render_frame(&state)).expect("stale transition clears adornments");
// Hold-while-stale: no frame at all. The frontend keeps its
// last-received hints, translated through its own local
// edits — an empty frame here would wipe them and visibly
// shift the line layout on the first keystroke of a burst.
assert!(
clear.is_empty(),
"stale hints must clear the frontend's cached virtual text"
adornments_of(&s.render_frame(&state)).is_none(),
"stale store holds emission (frontend keeps its translated cache)"
);
assert!(
adornments_of(&s.render_frame(&state)).is_none(),
"unchanged stale-empty state is suppressed after the clear"
"the hold is stable across frames"
);
set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]);
// A fresh inlayHint response clears the flag and re-emits at
// the server's (possibly shifted) positions.
set_inlay_store(&state, &uri, vec![hint(0, 7, ": i32")]);
let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit");
assert_eq!(refreshed.len(), 1);
assert_eq!(refreshed[0].at, 5);
assert_eq!(refreshed[0].at, 7);
}
#[cfg(feature = "crdt")]
#[test]
fn session8_temporal_probe_sustained_edits_clear_stale_inlays_until_refresh() {
fn session8_temporal_probe_sustained_edits_hold_stale_inlays_until_refresh() {
let state = empty_state();
let mut s = local();
let bid = active_buffer(&state);
@ -2247,16 +2574,17 @@ mod tests {
}
assert_eq!(
clear_frames, 1,
"first stale frame clears cached hints; later stale frames stay silent"
clear_frames, 0,
"stale frames hold emission entirely — the frontend keeps \
its locally-translated hints instead of blinking them out"
);
assert_eq!(
full_style_frames, 1000,
"each CRDT generation transition forces a StyleSpans full resync"
);
assert_eq!(
full_deco_frames, 1000,
"each CRDT generation transition forces a Decorations full resync"
full_deco_frames, 0,
"empty Decorations state stays silent across generation transitions"
);
set_inlay_store(&state, &uri, vec![hint(0, 1005, ": i32")]);

View File

@ -579,6 +579,16 @@ impl SyntaxRegistry {
self.parse_jobs.borrow().len()
}
/// True when a dispatched parse job for `buffer` has not yet been
/// installed or drained. The syntax Lua glue records jobs here at
/// dispatch time and removes them in `_install_settled`, so this
/// is the main-thread "parse in flight" bit for render producers
/// that need to avoid stale whole-file work while typing.
#[must_use]
pub fn has_pending_parse_job_for(&self, buffer: BufferId) -> bool {
self.parse_jobs.borrow().values().any(|&bid| bid == buffer)
}
/// Lazy-compile and cache the bundled `highlights.scm` query for
/// `lang_name`. Returns `None` if the language is unknown, the
/// language entry has an empty query (no highlights shipped),
@ -691,9 +701,27 @@ pub struct HighlightSpan {
pub fn compute_highlight_spans(
query: &tree_sitter::Query,
bundle: &ParseTreeBundle,
) -> Vec<HighlightSpan> {
compute_highlight_spans_in_range(query, bundle, None)
}
/// Like [`compute_highlight_spans`], but restricts the query to nodes
/// intersecting `byte_range` when `Some`. tree-sitter's
/// `QueryCursor::set_byte_range` makes the capture walk proportional to
/// the range, not the whole tree — the semantic producer passes the
/// declared viewport so styling a screenful of a huge file is
/// O(visible), not O(file) (the per-edit typing cost; framing Q#S6).
#[must_use]
pub fn compute_highlight_spans_in_range(
query: &tree_sitter::Query,
bundle: &ParseTreeBundle,
byte_range: Option<std::ops::Range<usize>>,
) -> Vec<HighlightSpan> {
let mut spans = Vec::new();
let mut cursor = tree_sitter::QueryCursor::new();
if let Some(range) = byte_range {
cursor.set_byte_range(range);
}
let source: &[u8] = bundle.source.as_ref();
let root = bundle.tree.root_node();
let mut iter = cursor.captures(query, root, source);
@ -851,4 +879,21 @@ mod tests {
// Pending list cleared on make_request.
assert_eq!(handle.pending_edit_count(), 0);
}
#[test]
fn registry_tracks_inflight_parse_jobs_by_buffer() {
let registry = SyntaxRegistry::new();
let a = BufferId::next();
let b = BufferId::next();
registry.record_parse_job(11, a);
registry.record_parse_job(12, b);
assert!(registry.has_pending_parse_job_for(a));
assert!(registry.has_pending_parse_job_for(b));
assert_eq!(registry.take_parse_job(11), Some(a));
assert!(!registry.has_pending_parse_job_for(a));
assert!(registry.has_pending_parse_job_for(b));
}
}

View File

@ -34,6 +34,10 @@ use crate::view::{DisplayCoord, View, Viewport};
/// that is a multiple of this value.
const TAB_WIDTH: u32 = 8;
/// Line-prefix lengths up to this many bytes are decoded on the stack in
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
const STACK_CAP: usize = 256;
/// Display width of `ch` when drawn starting at column `current_col`.
///
/// Tabs expand to the next [`TAB_WIDTH`]-aligned column, so they need the
@ -180,13 +184,27 @@ impl View for TextView {
if take == 0 {
return Some(DisplayCoord::new(row_idx as u32, 0));
}
let mut bytes = vec![0u8; take];
buf.snapshot_rope().slice(line_start, pos, &mut bytes);
// Drop trailing bytes that don't form a complete codepoint.
while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() {
bytes.pop();
}
let s = std::str::from_utf8(&bytes).unwrap_or("");
// Copy [line_start, pos) into a stack buffer for the common short-line
// case, hitting the heap only for unusually long prefixes. This removes
// the per-call allocation that previously ran on every cursor move.
let mut stack_buf = [0u8; STACK_CAP];
let mut heap_buf: Vec<u8>;
let bytes: &mut [u8] = if take <= STACK_CAP {
&mut stack_buf[..take]
} else {
heap_buf = vec![0u8; take];
&mut heap_buf
};
buf.snapshot_rope().slice(line_start, pos, bytes);
// If `pos` fell inside a multi-byte codepoint, keep only the bytes up to
// the last complete codepoint. `valid_up_to()` gives that boundary in
// one step, replacing the old pop-one-byte-and-revalidate loop. (Only
// trailing bytes can be invalid here, since the slice is a prefix of
// valid UTF-8 cut at `pos`.)
let s = match std::str::from_utf8(bytes) {
Ok(valid) => valid,
Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap(),
};
let mut col: u32 = 0;
for ch in s.chars() {
col += char_display_width(ch, col);

View File

@ -0,0 +1,174 @@
//! CUA region semantics — Backspace / Delete consume the active
//! selection (set by Shift+motion) before falling back to their
//! single-codepoint behavior.
//!
//! Regression for the pmacs-gpu report "select a region with
//! shift+arrows then backspace doesn't delete as expected": the
//! `buffer.delete-backward` / `buffer.delete-forward` commands called
//! straight into the single-codepoint core primitives and never
//! consulted `active_region()`. The behavior is frontend-agnostic
//! (the GPU round-trips BS through the same dispatch), so the TUI
//! dispatch path exercised here covers both.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
}
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
/// `(buffer text, region active?, cursor)` probed through the Lua
/// surface — the same introspection a user-facing script would use.
fn probe(s: &EditorState) -> (String, bool, i64) {
s.lua_host
.lua()
.load(
"
local b = pmacs.window.buffer()
local text = b:slice(0, b:len())
return text, pmacs.editor.region() ~= nil, pmacs.editor.cursor()
",
)
.eval()
.expect("probe buffer state")
}
#[test]
fn backspace_deletes_the_shift_selected_region() {
let mut s = EditorState::new();
type_str(&mut s, "hello");
// Shift+Left three times: region [2, 5), cursor at 2.
for _ in 0..3 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
}
let (_, region_active, _) = probe(&s);
assert!(region_active, "shift+arrows must leave an active region");
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Backspace, KeyModifiers::NONE),
);
let (text, region_active, cursor) = probe(&s);
assert_eq!(text, "he", "backspace must delete the whole region");
assert!(!region_active, "the region clears with its deletion");
assert_eq!(cursor, 2, "cursor lands at the deleted region's start");
// Without a region, backspace keeps single-codepoint semantics.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Backspace, KeyModifiers::NONE),
);
let (text, _, cursor) = probe(&s);
assert_eq!(text, "h", "no region ⇒ plain single-codepoint backspace");
assert_eq!(cursor, 1);
}
/// C-Backspace deletes the previous word (and C-Delete the next),
/// mirroring C-arrow word motion. The pmacs-gpu frontend forwards
/// chorded deletion keys to this same dispatch path.
#[test]
fn ctrl_backspace_deletes_the_previous_word() {
let mut s = EditorState::new();
type_str(&mut s, "alpha beta");
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Backspace, KeyModifiers::CONTROL),
);
let (text, _, cursor) = probe(&s);
assert_eq!(
text, "alpha ",
"C-BS deletes back through the previous word"
);
assert_eq!(cursor, 6);
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL));
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Delete, KeyModifiers::CONTROL),
);
let (text, _, cursor) = probe(&s);
assert_eq!(text, " ", "C-DEL deletes forward through the next word");
assert_eq!(cursor, 0);
}
#[test]
fn typing_replaces_the_shift_selected_region() {
let mut s = EditorState::new();
type_str(&mut s, "hello");
// Select "llo" (region [2, 5), cursor at 2), then type 'X':
// CUA type-over replaces the region with the typed char.
for _ in 0..3 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
}
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('X'), KeyModifiers::SHIFT),
);
let (text, region_active, cursor) = probe(&s);
assert_eq!(text, "heX", "typing must replace the selected region");
assert!(!region_active, "the region is consumed by the replacement");
assert_eq!(cursor, 3, "cursor sits after the typed char");
// Enter over a selection replaces it with a newline.
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Enter, KeyModifiers::NONE));
let (text, region_active, cursor) = probe(&s);
assert_eq!(text, "he\n", "Enter must replace the selected region");
assert!(!region_active);
assert_eq!(cursor, 3);
// Without a selection, typing keeps plain insert semantics.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('z'), KeyModifiers::NONE),
);
let (text, _, cursor) = probe(&s);
assert_eq!(text, "he\nz", "no region ⇒ plain insert at the cursor");
assert_eq!(cursor, 4);
}
#[test]
fn delete_forward_deletes_the_shift_selected_region() {
let mut s = EditorState::new();
type_str(&mut s, "world");
// Shift+Home-equivalent: extend left over the whole word.
for _ in 0..5 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
}
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Delete, KeyModifiers::NONE));
let (text, region_active, cursor) = probe(&s);
assert_eq!(text, "", "Delete must consume the whole region");
assert!(!region_active);
assert_eq!(cursor, 0);
// Without a region, Delete keeps forward single-codepoint semantics.
type_str(&mut s, "ab");
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::NONE));
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::NONE));
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Delete, KeyModifiers::NONE));
let (text, _, cursor) = probe(&s);
assert_eq!(text, "b", "no region ⇒ plain forward delete at cursor");
assert_eq!(cursor, 0);
}

View File

@ -5158,6 +5158,112 @@ 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

View File

@ -536,10 +536,15 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() {
.read_resource(sid, "file:///race")
.expect("read c");
// Let the fake server finish its delayed response, then harvest
// the supervisor event queue without giving McpManager a chance
// to process it yet.
std::thread::sleep(Duration::from_millis(350));
// Let the fake server finish its delayed response (it sleeps
// 250ms), then harvest the supervisor event queue without giving
// McpManager a chance to process it yet. The margin over the
// fake's delay must absorb two pipe transits plus the request's
// queued-stdin-writer hop under CI load (a 100ms margin flaked on
// macOS runners); a generous wait does not weaken the contract —
// the race under test is cancel-AFTER-queue-BEFORE-manager-tick,
// which holds for any wait long enough for the response to land.
std::thread::sleep(Duration::from_secs(1));
sup.borrow_mut().tick();
// Cancel only b after the response is queued but before