pmacs/src/daemon.rs

2902 lines
124 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// daemon.rs --- Daemon mode for local-attach transport.
//! Daemon mode for the M5.5 local-attach transport (T M5.5e).
//!
//! # Lifecycle
//!
//! [`run_daemon`] is invoked from `main` when the user passes
//! `--daemon`. It:
//!
//! 1. Prepares the runtime subdir (`<runtime>/pmacs/`) under mode 0700
//! via [`crate::socket_path::ensure_runtime_subdir`].
//! 2. Acquires the sibling lockfile via
//! [`crate::lockfile::acquire_lock`].
//! 3. Unlinks any stale socket file (a previous crashed daemon may
//! have left one).
//! 4. Binds the `UnixListener` under `umask(0077)` so the socket file
//! gets mode 0600.
//! 5. Installs signal handlers: SIGTERM/SIGINT set a shutdown flag;
//! SIGPIPE/SIGHUP get no-op handlers so writes return EPIPE rather
//! than killing the process and SIGHUP is reserved for v0.2+ config
//! reload.
//! 6. Runs an accept loop with non-blocking `accept(2)` and a 50 ms
//! poll interval. Each accepted connection runs through the
//! handshake and per-attach scaffolding inline.
//! 7. On shutdown: drops the listener, unlinks the socket, releases
//! the lock.
//!
//! # Per-attach handler (M5.5f)
//!
//! After [`Hello`] / [`AttachRequest`] / version-check / already-attached
//! checks succeed, the connection enters [`run_per_attach`]:
//!
//! - A reader thread blocks on [`crate::transport::read_message`] and
//! forwards each [`FrontendEvent`] into an `mpsc` channel.
//! - The main thread renders one frame, writes the resulting
//! [`InstanceMessage`]s, then waits up to `frame_target_ms` for an
//! event. Bursts of events are coalesced into a single render pass
//! (matching the in-process TUI's behavior).
//! - On each iteration the loop ticks the async runtime, process
//! supervisor, and LSP host so background work progresses.
//!
//! Exit paths:
//! - `FrontendEvent::Detach` → return without sending Goodbye (the
//! frontend closed the conversation).
//! - Reader thread closes the channel (EOF / I/O / decode) → return.
//! - Shutdown flag set → send `Goodbye(ShuttingDown)`, return.
//! - Editor `quit` flag set → send `Goodbye(ShuttingDown)`, propagate
//! shutdown to the outer accept loop.
//! - Write fails (broken pipe) → return; ungraceful disconnect.
use std::collections::HashMap;
use std::io::ErrorKind;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use crate::cell::CellSize;
use crate::editor::EditorState;
use crate::instance_render::RenderState;
use crate::lockfile::{self, LockError, LockHandle};
use crate::presence::{PresenceSnapshot, SessionRegistry};
use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm};
use crate::protocol::{
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities,
InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind,
SelectionSnapshot,
};
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
use crate::transport::{read_message, write_message};
/// Shared debug switch with the SSH attach path. When set before
/// daemon startup, emits stderr breadcrumbs for accept/handshake
/// progress without changing the wire protocol.
const PMACS_ATTACH_DEBUG: &str = "PMACS_ATTACH_DEBUG";
fn daemon_debug_enabled() -> bool {
std::env::var_os(PMACS_ATTACH_DEBUG).is_some_and(|v| !v.is_empty() && v != "0")
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn peer_uid(stream: &UnixStream) -> Option<u32> {
nix::sys::socket::getsockopt(stream, nix::sys::socket::sockopt::PeerCredentials)
.ok()
.map(|cred| cred.uid())
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
fn peer_uid(_stream: &UnixStream) -> Option<u32> {
None
}
fn daemon_debug(msg: impl AsRef<str>) {
if daemon_debug_enabled() {
eprintln!("pmacs daemon debug: {}", msg.as_ref());
}
}
/// T M10.8 — events the dispatcher thread processes.
///
/// The dispatcher is the single thread that owns the editor; all
/// inputs from attached frontends arrive via this channel. Accept
/// thread + per-attach threads push variants here; dispatcher
/// consumes them in FIFO order.
enum DispatcherEvent {
/// A new connection finished handshake successfully and is now
/// ready to participate. The dispatcher registers the session,
/// allocates a per-frontend `RenderState`, and stores the
/// write-half of the stream so subsequent frames + broadcasts
/// can be sent to this frontend.
SessionEstablished {
frontend_id: FrontendId,
session_state: crate::presence::SessionState,
initial_size: CellSize,
/// Write-half of the per-attach stream. The dispatcher owns
/// this end; the per-attach reader thread keeps the
/// read-half via `try_clone`.
write_stream: UnixStream,
},
/// An attached frontend dispatched an event (key, mouse, resize,
/// etc.). The dispatcher applies it to the editor and renders
/// the resulting frame(s).
FrontendEvent {
source: FrontendId,
event: FrontendEvent,
},
/// An attached frontend's connection closed (EOF, decode error,
/// or explicit `FrontendEvent::Detach`). The dispatcher
/// unregisters the session and drops the write stream.
SessionDetached { frontend_id: FrontendId },
}
/// Errors that abort the daemon's startup or main loop.
#[derive(Debug)]
pub enum DaemonError {
/// Could not acquire or release the daemon lockfile.
Lock(LockError),
/// Could not prepare the runtime directory.
SocketPath(SocketPathError),
/// I/O error during bind, accept, or socket-file unlink.
Io(std::io::Error),
/// Could not install a signal handler.
Signal(std::io::Error),
}
impl std::fmt::Display for DaemonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lock(e) => write!(f, "{e}"),
Self::SocketPath(e) => write!(f, "{e}"),
Self::Io(e) => write!(f, "daemon I/O error: {e}"),
Self::Signal(e) => write!(f, "signal handler installation failed: {e}"),
}
}
}
impl std::error::Error for DaemonError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Lock(e) => Some(e),
Self::SocketPath(e) => Some(e),
Self::Io(e) | Self::Signal(e) => Some(e),
}
}
}
impl From<std::io::Error> for DaemonError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<LockError> for DaemonError {
fn from(e: LockError) -> Self {
Self::Lock(e)
}
}
impl From<SocketPathError> for DaemonError {
fn from(e: SocketPathError) -> Self {
Self::SocketPath(e)
}
}
/// Interval between non-blocking `accept` polls in the main loop.
///
/// Short enough that SIGTERM-to-exit latency is bounded by ~50 ms.
/// Long enough that an idle daemon doesn't burn CPU.
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Per-daemon state shared between the accept loop and per-attach
/// handlers.
struct DaemonState {
instance_name: Option<String>,
started: Instant,
next_frontend_id: AtomicU64,
/// T M10.8 Day 4 — count of currently-attached **non-multi**
/// sessions per Q5. A non-multi attach is rejected with
/// `Goodbye(AlreadyAttached)` iff this count is `> 0` at attach
/// time. Multi sessions don't touch this counter and aren't
/// gated by it. CAS-incremented during handshake; decremented
/// via [`NonMultiSlotGuard`] on per-attach exit (normal or
/// panic).
///
/// Semantically degenerate (0 or 1 in v1.0) but kept as
/// `AtomicU64` for future-proofing against v0.2+ scenarios that
/// might allow multiple non-multi sessions.
non_multi_session_count: AtomicU64,
/// T M10.9 — per-uid color slot registry. Maps Unix uid to
/// color palette index (0..[`crate::overlay_color::PALETTE_LEN`]).
/// First attach from a new uid gets the next available slot;
/// subsequent attaches from the same uid reuse that slot.
/// Stable across reconnect within a daemon-process lifetime —
/// satisfies the spec's "stable across reconnect (within a
/// session)" criterion for the same-uid case.
///
/// Cross-uid color collisions (two users sharing a uid → same
/// color) are v0.2+ user-identity refinement. Two distinct uids
/// with hash collision on the palette also share a color, which
/// is the same shape as cross-uid collapse.
color_registry: std::sync::Mutex<HashMap<u32, u8>>,
/// T M10.10 Day 4 — test-only latency injection for
/// `CellDelta` emission. Read once at daemon startup from
/// `PMACS_INSTANCE_LATENCY_MS`. When `> 0`, the dispatcher
/// sleeps this many milliseconds before each `CellDelta` write
/// to a stream, simulating slow daemon→frontend transport.
///
/// Used exclusively by the criterion 1 ("less than one frame
/// regardless of instance latency") acceptance tests and the
/// V0.2-PREREQUISITES.md baseline measurements. Production
/// daemons leave this at 0; tests set the env var via
/// `TestDaemon::spawn_with_env`.
///
/// **Scope: dispatcher-wide, not per-frontend.** The sleep
/// fires in the dispatcher loop's per-tick render write path.
/// Multi-frontend tests at injected latency conflate frontends
/// (all see the same delay). Criterion 1's test uses a single
/// replica frontend so this conflation doesn't affect the
/// signal. v0.2+ work on per-frontend latency injection would
/// move the sleep into a per-frontend writer thread.
injected_render_latency_ms: u64,
/// T M10.11 Q6/Q8 — test-only jitter on top of the fixed latency.
/// Read once at startup from `PMACS_INSTANCE_LATENCY_JITTER_MS`.
/// When `> 0`, each `CellDelta` write is delayed by
/// `injected_render_latency_ms + rand(0..jitter)` instead of the
/// fixed value, simulating variable network latency. No actual
/// drops — TCP/UDS never drops application bytes and loro has no
/// dropped-op recovery (Tension B / Q6: "packet loss" is
/// interpreted as latency variation only). Production leaves
/// this 0.
injected_render_latency_jitter_ms: u64,
/// T M10.11 Q8 — seed for the jitter PRNG. Read from
/// `PMACS_INSTANCE_LATENCY_JITTER_SEED` (default `0xC0FFEE`,
/// matching M10.1's microbench-seed convention) so
/// convergence-under-jitter scenarios are deterministically
/// reproducible. A flake's seed is the one to re-run.
jitter_seed: u64,
}
/// T M10.11 Q8 — `SplitMix64` PRNG for deterministic jitter.
///
/// Chosen because it is six lines of pure wrapping arithmetic: no
/// `unsafe`, no new dependency (the project is `forbid(unsafe_code)`
/// and the `rand` crate would be a production dep pulled in for a
/// test-only seam). Statistically adequate for "uniform-ish delay in
/// `[0, jitter)`"; the jitter scenario asserts CRDT convergence
/// regardless of delay ordering, not a distribution property, so PRNG
/// quality is not load-bearing — only reproducibility (seed) is.
struct SplitMix64(u64);
impl SplitMix64 {
const fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
}
/// T M10.11 F2 — apply jitter delay before a wire write.
///
/// One *mechanism* (this fn), called at two *sites*: the `CrdtOp`
/// broadcast write (criterion 3 — the CRDT-convergence path) and the
/// render-message `CellDelta` write (criterion 1 — the render-latency
/// path). **Q6's original "no new injection seams; one place"
/// commitment was wrong** and Finding 5's first resolution compounded
/// the error: criterion 1 and criterion 3 ride *different message
/// paths* (render output vs `broadcast_crdt_op`), so they
/// structurally require two call sites. Honest framing: one jitter
/// mechanism, two call sites because there are two paths — not "one
/// seam" (there isn't) and not "widen one loop's match" (Finding 5's
/// flawed fix, which matched `CrdtOp` in the render loop that never
/// carries broadcast `CrdtOp`s).
///
/// No-op when `jitter_ms == 0`. Tension-B holds: the write is
/// *delayed*, never dropped.
fn maybe_jitter_sleep(jitter_ms: u64, base_ms: u64, rng: &mut SplitMix64) {
if jitter_ms == 0 {
return;
}
let delay_ms = base_ms + (rng.next_u64() % jitter_ms);
if delay_ms > 0 {
thread::sleep(Duration::from_millis(delay_ms));
}
}
/// T M10.8 Day 4 — RAII guard for the non-multi-session slot.
///
/// Acquired via [`NonMultiSlotGuard::try_acquire`] at per-attach
/// handshake time; releases the slot on drop (whether the
/// per-attach thread exits normally or panics). Holds an `Arc` so
/// the guard doesn't borrow from a reference whose lifetime might
/// not outlive the slot.
struct NonMultiSlotGuard {
daemon_state: Arc<DaemonState>,
}
impl NonMultiSlotGuard {
/// Try to acquire the single non-multi session slot. Returns
/// `None` if another non-multi session is already attached.
fn try_acquire(daemon_state: Arc<DaemonState>) -> Option<Self> {
daemon_state
.non_multi_session_count
.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
.ok()
.map(|_| Self { daemon_state })
}
}
impl Drop for NonMultiSlotGuard {
fn drop(&mut self) {
self.daemon_state
.non_multi_session_count
.fetch_sub(1, Ordering::SeqCst);
}
}
impl DaemonState {
fn new(instance_name: Option<String>) -> Self {
// T M10.10 Day 4 — read the latency-injection env once at
// startup. Production deployments don't set this; tests
// (`TestDaemon::spawn_with_env`) set it for criterion 1
// verification.
let injected_render_latency_ms: u64 = std::env::var("PMACS_INSTANCE_LATENCY_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// T M10.11 Q6/Q8 — jitter magnitude + PRNG seed, same
// read-once-at-startup discipline. Production leaves both
// unset (jitter 0; seed defaults but unused when jitter 0).
let injected_render_latency_jitter_ms: u64 =
std::env::var("PMACS_INSTANCE_LATENCY_JITTER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let jitter_seed: u64 = std::env::var("PMACS_INSTANCE_LATENCY_JITTER_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0x00C0_FFEE);
Self {
instance_name,
started: Instant::now(),
// FrontendId(1) is reserved for FrontendId::LOCAL (the
// in-process TUI). Daemon-attached frontends start at 2.
next_frontend_id: AtomicU64::new(2),
non_multi_session_count: AtomicU64::new(0),
color_registry: std::sync::Mutex::new(HashMap::new()),
injected_render_latency_ms,
injected_render_latency_jitter_ms,
jitter_seed,
}
}
/// T M10.10 Day 4 — current injected-latency value for `CellDelta`
/// emission. Returns 0 in production.
fn injected_render_latency_ms(&self) -> u64 {
self.injected_render_latency_ms
}
/// T M10.11 Q6/Q8 — jitter magnitude (0 in production).
fn injected_render_latency_jitter_ms(&self) -> u64 {
self.injected_render_latency_jitter_ms
}
/// T M10.11 Q8 — jitter PRNG seed (default `0xC0FFEE`).
fn jitter_seed(&self) -> u64 {
self.jitter_seed
}
/// T M10.9 — look up or assign a color slot for the given uid.
///
/// Same uid across reconnect → same slot (the spec's
/// "stable across reconnect within a session" criterion).
/// New uid → next free slot, wrapping around the palette length.
fn color_slot_for_uid(&self, uid: u32) -> u8 {
use crate::overlay_color::PALETTE_LEN;
let mut registry = self
.color_registry
.lock()
.expect("color_registry mutex poisoned");
if let Some(&slot) = registry.get(&uid) {
slot
} else {
// Next slot = number of entries so far, modulo palette length.
let slot = u8::try_from(registry.len() % PALETTE_LEN).unwrap_or(0);
registry.insert(uid, slot);
slot
}
}
fn build_identity(&self) -> InstanceIdentity {
InstanceIdentity::for_running_process(self.instance_name.clone(), self.started)
}
/// `--socket NAME` value the daemon was launched with, or `None`
/// for the unnamed default daemon. The editor mirrors this into
/// `pmacs.instance.identity()` (T M5.6f).
fn instance_name(&self) -> Option<String> {
self.instance_name.clone()
}
/// Start anchor used to compute uptimes. The editor mirrors this
/// into `pmacs.instance.identity()` (T M5.6f) so the uptime
/// reported on Lua matches what the daemon sends in its Hello.
fn started(&self) -> Instant {
self.started
}
}
/// Run a daemon on `socket_path`. Returns when SIGTERM / SIGINT have
/// been received and the daemon has cleaned up.
///
/// `instance_name` is the value the user passed via `--socket NAME`
/// (resolved already; this is the human-readable name surfaced in
/// [`InstanceIdentity::instance_name`]). `None` means the default
/// daemon.
// `socket_path: PathBuf` is taken by value so the caller can hand
// the result of `resolve_socket_path` straight in without keeping a
// local; clippy's pedantic pass-by-value is wrong for this call site.
#[allow(clippy::needless_pass_by_value)]
pub fn run_daemon(socket_path: PathBuf, instance_name: Option<String>) -> Result<(), DaemonError> {
ensure_runtime_subdir(&socket_path)?;
let lock = lockfile::acquire_lock(&socket_path)?;
if socket_path.exists() {
// Stale socket from a previously crashed daemon. The lock we
// just acquired guarantees no live daemon owns it — safe to
// unlink and replace.
std::fs::remove_file(&socket_path)?;
}
let listener = bind_with_strict_umask(&socket_path)?;
listener.set_nonblocking(true)?;
let shutdown = Arc::new(AtomicBool::new(false));
install_signal_handlers(&shutdown)?;
let daemon_state = Arc::new(DaemonState::new(instance_name));
// The editor outlives any single attachment; constructed once on
// the dispatcher thread and used until daemon shutdown.
let mut editor = EditorState::new();
// Real session: wire up on-disk persistence (history + pmacs.state).
editor.install_state_dirs();
// Mark this process a daemon so `pmacs.session.desktop_mode` keeps
// desktop save/restore local-only in v1 (Q#DS9): the daemon has a
// layout per attached frontend and none at construction.
editor
.lua_host
.lua()
.set_app_data(crate::lua_bindings::DaemonMode);
// Mirror the daemon's `--socket NAME` and start time into the
// editor's `LocalInstanceInfo` so `pmacs.instance.identity()`
// (T M5.6f) reports the same identity the daemon hands back over
// its Hello.
editor
.lua_host
.set_instance_name(daemon_state.instance_name());
editor.lua_host.set_instance_started(daemon_state.started());
eprintln!(
"pmacs: daemon listening on {} (pid {})",
socket_path.display(),
std::process::id(),
);
// T M10.8 — dispatcher thread topology.
//
// The current thread becomes the dispatcher (owns the editor;
// single-threaded access to all editor state). A spawned accept
// thread runs `accept_loop`, calling `listener.accept()` and
// spawning a per-attach thread for each accepted connection.
//
// The per-attach thread does the handshake (Hello, AttachRequest,
// version + capability checks), then sends `SessionEstablished`
// to the dispatcher and becomes a reader thread for that
// connection's stream.
//
// Dispatcher channel: all attach threads push `DispatcherEvent`
// variants; dispatcher consumes in FIFO order.
let (dispatcher_tx, dispatcher_rx) = mpsc::channel::<DispatcherEvent>();
let accept_handle = {
let daemon_state = Arc::clone(&daemon_state);
let shutdown = Arc::clone(&shutdown);
let tx = dispatcher_tx.clone();
thread::spawn(move || accept_loop(listener, &daemon_state, tx, &shutdown))
};
dispatcher_loop(
dispatcher_rx,
&mut editor,
&shutdown,
daemon_state.injected_render_latency_ms(),
daemon_state.injected_render_latency_jitter_ms(),
daemon_state.jitter_seed(),
)?;
// Dispatcher exited (shutdown or quit). Wake the accept thread
// by closing the channel from our side; the accept thread checks
// the shutdown flag between accepts and exits accordingly.
drop(dispatcher_tx);
let _ = accept_handle.join();
cleanup(&socket_path, lock);
eprintln!("pmacs: daemon stopped");
Ok(())
}
/// T M10.8 — accept thread. Spawns a per-attach thread for each new
/// connection. Runs on its own OS thread parallel to the dispatcher.
///
/// `listener` is taken by value because the accept thread owns it
/// until the daemon shuts down; the file descriptor closes when the
/// thread exits.
#[allow(clippy::needless_pass_by_value)]
fn accept_loop(
listener: UnixListener,
daemon_state: &Arc<DaemonState>,
dispatcher_tx: mpsc::Sender<DispatcherEvent>,
shutdown: &Arc<AtomicBool>,
) -> Result<(), DaemonError> {
daemon_debug("accept loop started");
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
// macOS can inherit O_NONBLOCK from the listener onto
// accepted Unix streams. The per-attach reader loop
// expects blocking reads; a nonblocking stream would
// turn "no frontend event yet" into WouldBlock, which
// looks like an immediate detach before the first frame.
stream.set_nonblocking(false)?;
daemon_debug("accepted frontend socket; spawning per-attach thread");
let daemon_state = Arc::clone(daemon_state);
let tx = dispatcher_tx.clone();
thread::spawn(move || per_attach_thread(stream, daemon_state, tx));
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
thread::sleep(ACCEPT_POLL_INTERVAL);
}
Err(e) => return Err(DaemonError::Io(e)),
}
}
Ok(())
}
fn cleanup(socket_path: &Path, lock: LockHandle) {
let _ = std::fs::remove_file(socket_path);
let _ = lock.release();
}
/// T M10.8 Day 4 — read `PMACS_INSTANCE_*` env vars (if set) to
/// override the instance's advertised capabilities. Used by test
/// infrastructure that needs the daemon to advertise non-default
/// capabilities (e.g., M10.7's mismatch test rewrite, which needs
/// the daemon to advertise `multi_frontend: false` so a
/// frontend declaring `true` hits the mismatch path).
///
/// Recognized env vars (each accepts `0`/`false` to disable;
/// anything else / absent → default `true`):
/// - `PMACS_INSTANCE_MULTI_FRONTEND`
/// - `PMACS_INSTANCE_CRDT_REPLICA`
/// - `PMACS_INSTANCE_SEMANTIC_RENDER` (T M11.1; default `false`
/// until the M11.2 projection seam lands, so this env var is the
/// only way to advertise the bit for negotiation tests)
///
/// Production daemons don't set these; tests do.
fn instance_capabilities_with_env_override() -> InstanceCapabilities {
fn env_bool(key: &str, default: bool) -> bool {
match std::env::var(key).ok().as_deref() {
Some("0" | "false" | "FALSE" | "False") => false,
Some(_) | None => default,
}
}
let defaults = InstanceCapabilities::default();
InstanceCapabilities {
multi_frontend: env_bool("PMACS_INSTANCE_MULTI_FRONTEND", defaults.multi_frontend),
crdt_replica: env_bool("PMACS_INSTANCE_CRDT_REPLICA", defaults.crdt_replica),
semantic_render: env_bool("PMACS_INSTANCE_SEMANTIC_RENDER", defaults.semantic_render),
}
}
/// Bind a Unix-domain listener under a strict umask so the socket
/// file gets mode 0600.
fn bind_with_strict_umask(socket_path: &Path) -> std::io::Result<UnixListener> {
let strict = nix::sys::stat::Mode::from_bits_truncate(0o077);
let prev = nix::sys::stat::umask(strict);
let result = UnixListener::bind(socket_path);
nix::sys::stat::umask(prev);
result
}
fn install_signal_handlers(shutdown: &Arc<AtomicBool>) -> Result<(), DaemonError> {
use signal_hook::consts::{SIGHUP, SIGINT, SIGPIPE, SIGTERM};
signal_hook::flag::register(SIGTERM, Arc::clone(shutdown)).map_err(DaemonError::Signal)?;
signal_hook::flag::register(SIGINT, Arc::clone(shutdown)).map_err(DaemonError::Signal)?;
// SIGPIPE / SIGHUP: install no-op handlers so the kernel doesn't
// apply the default action (terminate). With a handler installed
// and `SA_RESTART` set (signal_hook's default), interrupted
// syscalls are restarted automatically; the practical effect is
// that `write(2)` returns `EPIPE` on a broken pipe rather than
// killing the process.
let dummy = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(SIGPIPE, Arc::clone(&dummy)).map_err(DaemonError::Signal)?;
signal_hook::flag::register(SIGHUP, Arc::clone(&dummy)).map_err(DaemonError::Signal)?;
Ok(())
}
/// T M10.8 — per-attach thread. Runs handshake on a fresh thread for
/// each accepted connection; on success, sends `SessionEstablished`
/// to the dispatcher and transitions to reader behavior on the same
/// thread (no other initialization between `SessionEstablished` and
/// the reader loop — any added work would delay first-event
/// processing).
///
/// On handshake failure (version mismatch, capability mismatch, I/O
/// error) the thread writes a `Goodbye` variant and exits without
/// notifying the dispatcher. The dispatcher never learns about
/// failed handshakes.
#[allow(clippy::needless_pass_by_value)]
fn per_attach_thread(
mut stream: UnixStream,
daemon_state: Arc<DaemonState>,
dispatcher_tx: mpsc::Sender<DispatcherEvent>,
) {
daemon_debug("per-attach thread started");
let frontend_id = FrontendId(daemon_state.next_frontend_id.fetch_add(1, Ordering::SeqCst));
daemon_debug(format!("assigned {frontend_id:?}; preparing Hello"));
// Send Hello immediately on accept. The instance capabilities
// advertised here (and used for negotiation below) come from the
// env-var override helper so test infrastructure can drive the
// mismatch path without changing the default.
let instance_caps_for_hello = instance_capabilities_with_env_override();
let hello = Hello {
protocol_version: PROTOCOL_VERSION,
assigned_frontend_id: frontend_id,
instance_identity: daemon_state.build_identity(),
instance_capabilities: instance_caps_for_hello.clone(),
};
if let Err(e) = write_message(&mut stream, &hello) {
eprintln!("pmacs: send Hello failed: {e}");
return;
}
daemon_debug(format!("sent Hello to {frontend_id:?}"));
// Read AttachRequest.
daemon_debug(format!("waiting for AttachRequest from {frontend_id:?}"));
let req: AttachRequest = match read_message(&mut stream) {
Ok(r) => r,
Err(e) => {
eprintln!("pmacs: read AttachRequest failed: {e}");
return;
}
};
daemon_debug(format!("received AttachRequest from {frontend_id:?}"));
// T M10.5 version check.
if !crate::protocol::is_supported_protocol_version(req.protocol_version) {
let _ = write_message(
&mut stream,
&InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch {
server: PROTOCOL_VERSION,
client: req.protocol_version,
}),
);
return;
}
// T M10.7 capability negotiation. T M10.8 Day 4: the instance
// defaults to advertising `multi_frontend: true` and
// `crdt_replica: true` (M10.10 renamed from `crdt_ops`).
// Env vars override at daemon startup for test
// infrastructure that needs to exercise the mismatch path
// (the M10.7 mismatch test's daemon-end-to-end rewrite).
// We re-use the caps already computed for Hello so the
// negotiation sees the same advertised values.
let instance_caps = instance_caps_for_hello;
let negotiated_caps =
match crate::protocol::negotiate_capabilities(&req.frontend_capabilities, &instance_caps) {
Ok(caps) => caps,
Err(reason) => {
let _ = write_message(&mut stream, &InstanceMessage::Goodbye(reason));
return;
}
};
// T M10.8 Day 4 — Q5 non-multi-session admission control.
let _non_multi_guard = if negotiated_caps.multi_frontend {
None
} else if let Some(guard) = NonMultiSlotGuard::try_acquire(Arc::clone(&daemon_state)) {
Some(guard)
} else {
let _ = write_message(
&mut stream,
&InstanceMessage::Goodbye(GoodbyeReason::AlreadyAttached),
);
return;
};
// T M10.9 — color slot assignment via SO_PEERCRED. The connecting
// peer's Unix uid is the stable identifier; same uid across
// reconnect → same color slot. If SO_PEERCRED fails (e.g.,
// non-Unix peer, kernel API unavailable), fall back to a
// per-FrontendId slot (degrades to per-connection stability).
let color_slot = if let Some(uid) = peer_uid(&stream) {
daemon_state.color_slot_for_uid(uid)
} else {
// Fallback: use frontend_id-based slot; per-connection
// stability only (no cross-reconnect within session).
u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0)
};
let session_state =
crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot);
// Hand the write-half to the dispatcher; keep a read-half for
// this thread's reader loop. **Reader loop starts immediately
// after the SessionEstablished send below; any initialization
// needed must happen before that send. A future contributor
// adding "let me also do X before reading" would delay
// first-event processing.**
let write_stream = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
eprintln!("pmacs: try_clone stream for {frontend_id:?} failed: {e}");
return;
}
};
if dispatcher_tx
.send(DispatcherEvent::SessionEstablished {
frontend_id,
session_state,
initial_size: req.initial_size,
write_stream,
})
.is_err()
{
// Dispatcher channel closed (daemon shutting down).
return;
}
// Reader loop: read FrontendEvents from the stream, push to
// dispatcher tagged with this frontend_id. Any error (EOF,
// decode error, broken pipe) ends the loop; final
// `SessionDetached` lets the dispatcher clean up.
let mut read_stream = stream;
while let Ok(event) = read_message::<FrontendEvent>(&mut read_stream) {
if dispatcher_tx
.send(DispatcherEvent::FrontendEvent {
source: frontend_id,
event,
})
.is_err()
{
break;
}
}
// Notify dispatcher of detach. Best-effort: if the channel is
// closed (daemon shut down before our reader exited), the send
// returns Err and we just exit.
let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id });
}
/// T M10.8 — dispatcher loop. The single thread that owns the editor.
///
/// All attached frontends' inputs arrive via the `dispatcher_rx`
/// channel as `DispatcherEvent` variants. Per-attach reader threads
/// (spawned by the accept thread on each new connection) push events
/// here. The dispatcher consumes them in FIFO order, mutates the
/// editor, and per-tick:
///
/// 1. Renders a frame for each attached frontend (per-frontend
/// `RenderState`, each rendered against its own view).
/// 2. Sweeps the `SessionRegistry` for presence broadcasts;
/// routes them to per-recipient streams.
/// 3. Writes outgoing messages to each frontend's write stream
/// (synchronous — M10.8 Day 3 doesn't have per-frontend writer
/// threads; v0.3 may add them if N attachments grow).
/// 4. Ticks async / processes / LSP.
///
/// Exits when the editor's `quit` flag is set, the `shutdown` flag
/// is set, or all per-attach senders have disconnected.
///
/// Return value is `Result` for symmetry with other daemon entry
/// points; the function does not propagate errors today, but a
/// future failure mode (e.g., catastrophic editor state corruption)
/// would surface here.
// M10.10 grew this function with per-tick CursorByte emit + lazy
// CRDT upgrade + latency injection on top of M10.8/M10.9's
// dispatcher loop. The 121-line size is cohesive — the loop body
// coordinates render + presence sweep + CRDT broadcast + shutdown
// against one stack frame's borrow scope. Splitting would require
// either passing many `&mut` parameters between helpers or moving
// state behind RefCells. Defer to v0.2+ refactor if growth continues.
#[allow(
clippy::unnecessary_wraps,
clippy::needless_pass_by_value,
clippy::too_many_lines
)]
fn dispatcher_loop(
dispatcher_rx: mpsc::Receiver<DispatcherEvent>,
editor: &mut EditorState,
shutdown: &Arc<AtomicBool>,
injected_render_latency_ms: u64,
injected_render_latency_jitter_ms: u64,
jitter_seed: u64,
) -> Result<(), DaemonError> {
// Per-frontend dispatcher state.
let mut render_states: HashMap<FrontendId, RenderState> = HashMap::new();
// T M11.2 — parallel to `render_states`, but for `semantic_render`
// sessions: the dispatcher selects the projection *per session*,
// so a frontend has exactly one of a `RenderState` (grid) or a
// `SemanticRenderState` (layout-local), never both. A grid and a
// semantic frontend can attach to the same buffer simultaneously.
let mut semantic_states: HashMap<FrontendId, crate::semantic_render::SemanticRenderState> =
HashMap::new();
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
let mut term_sizes: HashMap<FrontendId, CellSize> = HashMap::new();
// T M11.6 — last `DispatchIdle` value broadcast per `crdt_replica`
// frontend. Absence means "never sent" — the first tick after
// attach emits an initial `DispatchIdle` so the frontend starts
// from a known idle state (its default is pessimistic-`false`).
let mut last_dispatch_idle_sent: HashMap<FrontendId, bool> = HashMap::new();
// Arc 1b — the buffer each replica frontend last received a
// `BufferSnapshot` for via the active-buffer-follow path. Absence
// means "never sent": the first tick after attach ships the
// frontend its own active buffer, which also repairs the
// attach-time last-snapshot-wins ambiguity (the initial
// `send_buffer_snapshots` sweep sends every buffer; the display
// follows whichever arrived last, not necessarily the active one).
// Declared for both flavors (the follow path is crdt-gated; the
// detach cleanup isn't).
let mut last_active_buffer_sent: HashMap<FrontendId, crate::buffer::BufferId> = HashMap::new();
let mut session_registry = SessionRegistry::new();
// T M10.11 Q8 — jitter PRNG, seeded once so the
// convergence-under-jitter scenario is deterministically
// reproducible. Mutated across the loop; one stream of delays
// for the whole dispatcher (jitter is dispatcher-wide, matching
// the fixed-latency seam's scope per the field docs).
let mut jitter_rng = SplitMix64::new(jitter_seed);
loop {
// Per-tick render + presence sweep for each attached
// frontend. T M10.8 — temporarily flip `active_frontend` to
// the frontend being rendered so its FrontendView is the one
// `active_window()` returns. Restored after the render-pass
// loop to the last-dispatched value (Q11: tick-driven render
// doesn't update active_frontend in the user-driving sense).
let last_dispatched = editor.core.borrow().active_frontend;
// Union of grid + semantic sessions — each fid is in exactly
// one of the two maps (projection selected per session).
let attached_fids: Vec<FrontendId> = render_states
.keys()
.chain(semantic_states.keys())
.copied()
.collect();
// T M10.10 post-audit-round-3 F18 — drain + broadcast pending
// CRDT ops **before** the render pass. Otherwise frontends
// receive `CellDelta` + `CursorByte` (showing the edit
// visually + the new cursor position) before the `CrdtOp`
// that updates their `BufferMirror`'s rope state — a fast
// next keystroke would run optimistic logic against stale
// mirror content with the new cursor position.
//
// F16 — `CrdtOpOrigin` controls sender exclusion:
// `OptimisticReplica(fid)` excludes `fid` (already
// locally-applied); `DaemonKey` excludes nobody (no
// frontend has applied locally; the active frontend's
// mirror must receive too).
#[cfg(feature = "crdt")]
{
let pending_ops = std::mem::take(&mut editor.core.borrow_mut().pending_crdt_ops);
for (origin, buffer_id, op) in pending_ops {
let exclude = match origin {
crate::editor_core::CrdtOpOrigin::OptimisticReplica(fid) => Some(fid),
crate::editor_core::CrdtOpOrigin::DaemonKey => None,
};
let entries = session_registry.broadcast_crdt_op(exclude, buffer_id, op);
for entry in entries {
if let Some(stream) = streams.get_mut(&entry.recipient) {
// T M10.11 F2 — THE criterion-3 jitter site.
// CRDT convergence is driven by these
// `broadcast_crdt_op` writes, NOT by render
// CellDeltas. Finding 5's first fix jittered
// the render loop (which never carries
// broadcast CrdtOps) and falsely claimed
// criterion 3 was exercised. This is the
// write that actually delivers ops to
// replicas; jittering here is what makes
// `m10_11_q8_convergence_under_jitter`
// genuinely test CRDT-under-jitter.
maybe_jitter_sleep(
injected_render_latency_jitter_ms,
injected_render_latency_ms,
&mut jitter_rng,
);
let _ = write_message(stream, &entry.message);
}
}
}
}
// Non-CRDT build: `pending_crdt_ops` is empty (only the
// CRDT-feature code paths push to it). Drop the take/iter
// to keep the non-CRDT build free of unused imports.
#[cfg(not(feature = "crdt"))]
{
// Defensive: empty the queue in case shared state was
// populated through some path we haven't traced.
let _ = std::mem::take(&mut editor.core.borrow_mut().pending_crdt_ops);
}
// Q#CM6 — outbound clipboard publish. A copy/cut queued the
// region bytes for the originating frontend; deliver them as an
// `InstanceSignal::Clipboard` (a v6-floor variant every peer
// understands, so no version gate) and let the frontend write
// the OS clipboard (OSC 52 / arboard). One-shot, like the CRDT
// drain above.
if let Some((fid, bytes)) = editor.core.borrow_mut().take_pending_clipboard()
&& let Some(stream) = streams.get_mut(&fid)
{
let _ = write_message(
stream,
&InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)),
);
}
for fid in &attached_fids {
editor.core.borrow_mut().active_frontend = *fid;
// T M10.10 Day 3 — lazy CRDT upgrade on active-buffer
// access for replica frontends. Keeps the daemon
// invariant "active buffer for a replica is CRDT-backed"
// even when buffers are created mid-session
// (post-SessionEstablished). The upgrade fires at most
// once per buffer (idempotent via `is_crdt_backed()`
// check). Documented in M10.10-FRAMING.md's composition-
// consistency-check application section.
#[cfg(feature = "crdt")]
if session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
{
// F29 — when a mid-session upgrade occurs, push a
// `BufferSnapshot` for the newly-CRDT-backed buffer
// to every currently-attached replica so their
// `BufferMirror`s gain an entry for it. Without
// this, replicas attached before the upgrade
// permanently fall back to v0.1 round-trip on that
// buffer.
if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) {
broadcast_buffer_snapshot_to_replicas(
editor,
upgraded,
&session_registry,
&mut streams,
);
// The broadcast just delivered this buffer to this
// frontend too; record it so the follow check below
// doesn't send a duplicate on the same tick.
last_active_buffer_sent.insert(*fid, upgraded);
}
// Arc 1b — follow this frontend's active buffer. The
// F29 push above only fires on the *upgrade* tick;
// switching to an already-CRDT-backed buffer (a
// panel's `q`, `find_or_open` of an open file, plain
// `C-x b`) previously sent nothing, so a semantic
// frontend kept rendering the old buffer while
// daemon-side input targeted the new one — a
// typing-into-a-buffer-you-can't-see hazard. Ship the
// now-active buffer's snapshot to THIS frontend only
// (its own view changed; nobody else's did).
//
// SEMANTIC sessions only: display-follows-snapshot is
// a grid-less-frontend concept, and the GPU rebuilds
// its replica wholesale on every snapshot. The grid
// TUI renders via CellDelta and its `BufferMirror` is
// init-once — a follow send there is a guaranteed
// duplicate that errors ("already has a CRDT snapshot
// applied") on every attach and every buffer switch
// (the PR #94 round-2 startup regression).
if session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.semantic_render)
{
let active_now = {
let core = editor.core.borrow();
core.active_window_for(*fid).map(|w| w.buffer_id)
};
if let Some(active_now) = active_now
&& last_active_buffer_sent.get(fid) != Some(&active_now)
{
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
last_active_buffer_sent.insert(*fid, active_now);
}
}
}
#[cfg(not(feature = "crdt"))]
{
let _ = session_registry.session_state(*fid);
let _ = ensure_active_buffer_crdt_backed(editor, *fid);
}
// Projection selected per session (T M11.2). A semantic
// session produces `StyleSpans` scoped to its declared
// viewport and NEVER `CellDelta` / grid `Cursor` (it lays
// out locally); it still receives `CursorByte` below
// (semantic implies `crdt_replica`) and participates in
// presence. A grid session takes the M5.2 cell path.
let messages = if let Some(sem) = semantic_states.get_mut(fid) {
sem.render_frame(editor)
} else {
// T M10.9 — gather other-frontend presences for the
// overlay paint. Reads `last_broadcast` (updated by
// the sweep below); other-frontend snapshots lag by
// at most one tick. Imperceptible at frame cadence.
let other_presences = session_registry.other_presences_for(*fid);
let render_state = render_states
.get_mut(fid)
.expect("render_state present for attached grid fid");
render_state.render_frame(editor, &other_presences)
};
// T M10.6 per-frontend presence sweep. The snapshot is
// computed from this frontend's view; the sweep then
// produces broadcasts to OTHER multi-frontend recipients.
let snapshot = build_presence_snapshot(editor, *fid);
let broadcasts = session_registry.sweep(&[(*fid, snapshot)]);
// T M11.6 — DispatchIdle signal. `crdt_replica` frontends
// gate their optimistic-apply path on this; we ship it
// before the frame's other messages so a frontend that
// wakes mid-tick sees the gate flip first. Diff-suppressed
// — initial-after-attach (`last_dispatch_idle_sent` absent)
// and value-change emissions only.
let mut write_failed = false;
if session_registry.session_state(*fid).is_some_and(|s| {
// Filter on both the `crdt_replica` capability (only
// optimistic-apply frontends care) and the negotiated
// wire version (>= 4 means peer knows the variant).
s.negotiated_capabilities.crdt_replica && s.negotiated_protocol_version >= 4
}) && let Some(stream) = streams.get_mut(fid)
{
let idle_now = editor.dispatch_idle();
if last_dispatch_idle_sent.get(fid) != Some(&idle_now) {
if let Err(e) =
write_message(stream, &InstanceMessage::DispatchIdle { idle: idle_now })
{
eprintln!("pmacs: write DispatchIdle for {fid:?} failed: {e}");
write_failed = true;
} else {
last_dispatch_idle_sent.insert(*fid, idle_now);
}
}
}
// Write frame messages to this frontend's stream.
if let Some(stream) = streams.get_mut(fid)
&& !write_failed
{
// Q#S1 — `StatusFacts` is a v8 variant; an older peer
// would hard-error decoding it. Same per-session gate
// shape as `DispatchIdle` (v4).
// `StatusFacts` gained the transient status `message`
// in v15 (encoding change to the variant), so the gate
// moved 8 → 15: an older peer's band goes dark rather
// than mis-decoding the wider shape (the v10
// SearchPrompt / v14 LineNumbers precedent).
let peer_knows_status_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 15);
// Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid
// fields in v10 (encoding change); gate at >= 10 so a v9
// peer is sent no SearchPrompt rather than the wider
// shape it would mis-decode.
let peer_knows_search_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 10);
let peer_knows_menu_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 11);
let peer_knows_minibuffer_prompt = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 12);
// UX gutter — `LineNumbers` carries a `LineNumberMode` since
// v14 (was `enabled: bool` in v13); a peer below 14 keeps
// its gutter off rather than mis-decoding the wider shape.
let peer_knows_line_numbers = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 14);
// Arc 1a Q#C5 — CompletionPopup gated at v15; a v14 peer
// still completes via the daemon-side session + key
// round-trip, it just gets no GPU dropdown.
let peer_knows_completion_popup = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 15);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
{
continue;
}
if !peer_knows_search_prompt
&& matches!(msg, InstanceMessage::SearchPrompt { .. })
{
continue;
}
// Q#CM1 — MenuPrompt gated at v11; a v10 peer keeps
// its decoration-only highlights and never opens a
// GPU menu, rather than mis-decoding the new variant.
if !peer_knows_menu_prompt && matches!(msg, InstanceMessage::MenuPrompt { .. })
{
continue;
}
// Q#MB1 — MinibufferPrompt gated at v12; a v11 peer
// simply can't render the GUI minibuffer.
if !peer_knows_minibuffer_prompt
&& matches!(msg, InstanceMessage::MinibufferPrompt { .. })
{
continue;
}
if !peer_knows_line_numbers
&& matches!(msg, InstanceMessage::LineNumbers { .. })
{
continue;
}
if !peer_knows_completion_popup
&& matches!(msg, InstanceMessage::CompletionPopup { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
// `messages` is render output only (CellDelta /
// Cursor / CursorByte) — it NEVER carries broadcast
// CrdtOps (those go out via `broadcast_crdt_op` at
// the top of the loop, the criterion-3 site). So
// jitter here is CellDelta-only *by the nature of
// this loop*, not by a match choice. Finding 5's
// first fix added `| CrdtOp` to the match below
// believing it widened jitter to the CRDT path;
// that arm was dead — no broadcast CrdtOp ever
// reaches this loop. Reverted to honest
// CellDelta-only; criterion-3 jitter lives at the
// broadcast site via the same `maybe_jitter_sleep`
// mechanism. Criterion 1 ("local edit visible in
// <1 frame regardless of instance latency") is a
// render-write-latency property; CellDelta is its
// correct and only target. Fixed-latency mode
// (no jitter) is unchanged from M10.10 Day 4.
if matches!(msg, InstanceMessage::CellDelta { .. }) {
if injected_render_latency_jitter_ms > 0 {
maybe_jitter_sleep(
injected_render_latency_jitter_ms,
injected_render_latency_ms,
&mut jitter_rng,
);
} else if injected_render_latency_ms > 0 {
thread::sleep(Duration::from_millis(injected_render_latency_ms));
}
}
if let Err(e) = write_message(stream, msg) {
eprintln!("pmacs: write failed for {fid:?} in dispatcher: {e}");
write_failed = true;
break;
}
}
// T M10.10 Finding 2: emit authoritative byte-position
// cursor for replica frontends, paired with the grid
// Cursor above. Both are derived from the same
// render-frame iteration (no editor mutation between
// the two derivations), so they describe the cursor
// in the same instant in two reference frames. The
// optimistic-apply path consumes byte_pos; the legacy
// paint path consumes the grid coord.
if !write_failed
&& session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
{
let core = editor.core.borrow();
if let Some(window) = core.active_window_for(*fid) {
let cursor_byte_msg = InstanceMessage::CursorByte {
buffer_id: window.buffer_id,
byte_pos: window.cursor,
};
if let Err(e) = write_message(stream, &cursor_byte_msg) {
eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}");
write_failed = true;
}
}
}
}
// Route presence broadcasts to their recipient streams
// (recipients != fid per sender-exclusion).
for entry in &broadcasts {
if let Some(stream) = streams.get_mut(&entry.recipient) {
let _ = write_message(stream, &entry.message);
}
}
if write_failed {
// Drop the broken connection.
streams.remove(fid);
render_states.remove(fid);
semantic_states.remove(fid);
term_sizes.remove(fid);
last_dispatch_idle_sent.remove(fid);
last_active_buffer_sent.remove(fid);
session_registry.unregister_session(*fid);
editor.core.borrow_mut().unregister_frontend_view(*fid);
}
}
editor.core.borrow_mut().active_frontend = last_dispatched;
// T M10.8 Day 4 drain + broadcast block lives at the **top**
// of the loop now (post-audit-round-3 F18 reorder); CrdtOp
// broadcasts arrive at replicas before the CellDelta /
// CursorByte for the same edit.
// Shutdown / quit checks. Send Goodbye to all attached
// frontends before exiting.
let core_wants_quit = editor.core.borrow().quit;
let shutting_down = shutdown.load(Ordering::SeqCst) || core_wants_quit;
if shutting_down {
for stream in streams.values_mut() {
let _ = write_message(
stream,
&InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown),
);
}
if core_wants_quit {
shutdown.store(true, Ordering::SeqCst);
}
break;
}
// Wait up to one frame for the next dispatcher event.
let frame_target = editor.async_runtime.frame_target_ms();
match dispatcher_rx.recv_timeout(Duration::from_millis(frame_target)) {
Ok(event) => {
handle_dispatcher_event(
event,
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut session_registry,
);
// Drain a burst of immediately-available events to
// coalesce typing-flurries / multi-frontend traffic
// into a single render pass (matches the v0.1
// run_per_attach drain behavior).
while let Ok(event) = dispatcher_rx.try_recv() {
handle_dispatcher_event(
event,
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut session_registry,
);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
// `tick_async` last: the M4.5 async bridge settles awaiters
// inside `tick_lsp` (via the message bus); draining + resuming
// in the same frame keeps LSP `:await()` latency at one frame
// instead of two. Mirrors the in-process loop in `editor::run`.
editor.tick_processes();
editor.tick_lsp();
editor.tick_async();
}
Ok(())
}
/// Handle one `DispatcherEvent`. Extracted so the dispatcher loop
/// can both timeout-recv and burst-drain via the same code path.
/// T M11.2 — extracted from `handle_dispatcher_event`'s
/// `SessionEstablished` arm (kept the parent under the 100-line
/// clippy ceiling). Registers the frontend's view, bootstraps the
/// `BufferMirror` via `BufferSnapshot` when `crdt_replica`, and
/// selects the per-session projection: a `semantic_render` session
/// gets a `SemanticRenderState` (no grid `RenderState`, no
/// initial-full-grid analogue — it emits nothing until the frontend
/// declares a viewport); every other session keeps the M5.3
/// force-full-grid grid path.
#[allow(clippy::too_many_arguments)]
fn handle_session_established(
editor: &mut EditorState,
render_states: &mut HashMap<FrontendId, RenderState>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
session_registry: &mut SessionRegistry,
frontend_id: FrontendId,
session_state: crate::presence::SessionState,
initial_size: CellSize,
mut write_stream: UnixStream,
) {
// Register the frontend's view (M10.8 Day 3: fresh scratch
// buffer view; future milestones may clone LOCAL's view or
// take an explicit initial-buffer argument).
let scratch_view = build_fresh_frontend_view(editor);
editor
.core
.borrow_mut()
.register_frontend_view(frontend_id, scratch_view);
// T M10.10: bootstrap the new frontend's `BufferMirror` by
// sending one `BufferSnapshot` per CRDT-backed buffer. Gated on
// the negotiated `crdt_replica` capability — v0.1 / non-replica
// frontends never receive the variant (postcard would hard-error
// on the unknown variant; see M10.10-FRAMING.md Refinement 3).
// Ordering: snapshots are sent BEFORE any CellDelta flows (the
// next per-tick render is the first CellDelta source), so the
// mirror is initialized before any local-edit path can reference
// it.
let crdt_replica = session_state.negotiated_capabilities.crdt_replica;
// T M11.2 — a semantic session is always a text replica (the
// negotiation dependency rule guarantees `semantic_render ⇒
// crdt_replica`), so the `BufferSnapshot` bootstrap below still
// fires: the semantic frontend holds the rope locally and the
// semantic frame ships no text.
let semantic_render = session_state.negotiated_capabilities.semantic_render;
if crdt_replica {
send_buffer_snapshots(editor, &mut write_stream);
}
// Register the session in the registry (presence + capability
// filters).
session_registry.register_session(frontend_id, session_state);
if semantic_render {
semantic_states.insert(
frontend_id,
crate::semantic_render::SemanticRenderState::new(frontend_id),
);
} else {
let mut render_state = RenderState::new(initial_size);
render_state.force_full_grid_resync();
render_states.insert(frontend_id, render_state);
}
streams.insert(frontend_id, write_stream);
term_sizes.insert(frontend_id, initial_size);
// Stamp active_frontend so the initial render's Lua statusline
// code sees the right fid.
editor.core.borrow_mut().active_frontend = frontend_id;
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)] // per-variant dispatcher match.
fn handle_dispatcher_event(
event: DispatcherEvent,
editor: &mut EditorState,
render_states: &mut HashMap<FrontendId, RenderState>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
streams: &mut HashMap<FrontendId, UnixStream>,
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
session_registry: &mut SessionRegistry,
) {
match event {
DispatcherEvent::SessionEstablished {
frontend_id,
session_state,
initial_size,
write_stream,
} => {
handle_session_established(
editor,
render_states,
semantic_states,
streams,
term_sizes,
session_registry,
frontend_id,
session_state,
initial_size,
write_stream,
);
}
DispatcherEvent::FrontendEvent { source, event } => {
match event {
FrontendEvent::Detach(_) => {
// The per-attach thread will follow up with a
// `SessionDetached` event after its reader
// loop exits; cleanup happens there. Just stop
// processing here.
}
FrontendEvent::Resize { size, .. } => {
if let Some(rs) = render_states.get_mut(&source) {
rs.resize(size);
}
if let Some(ts) = term_sizes.get_mut(&source) {
*ts = size;
}
}
#[cfg(feature = "crdt")]
FrontendEvent::CrdtOp {
frontend_id: claimed_fid,
buffer_id,
op,
} => {
// T M10.10 — handled here (not in apply_event) so
// the authenticated `source` is in scope. The
// event's `claimed_fid` is client-supplied and not
// trusted; we use `source` for sender-exclusion
// routing. (Original Finding 4 fix.)
//
// Second-round audit added three pre-apply checks
// (F11, F12, F13). All identity-and-scope fields
// (negotiated cap, claimed_fid, op.peer_id,
// buffer_id) must agree with the authenticated
// `source` and the source's active-window buffer
// before the op is applied.
if let Err(reason) = validate_remote_crdt_op(
editor,
session_registry,
source,
claimed_fid,
buffer_id,
&op,
) {
eprintln!(
"pmacs daemon: dropping CrdtOp from {source:?} \
(claimed_fid={claimed_fid:?}, buffer_id={buffer_id:?}, \
op.peer_id={pid}): {reason}",
pid = op.peer_id
);
} else {
handle_remote_crdt_op(editor, source, buffer_id, op);
}
}
FrontendEvent::Viewport {
buffer_id,
visible,
generation,
..
} => {
// T M11.2 — feed the semantic projection the byte
// range the frontend has on screen. Routed by the
// authenticated `source` (the client-supplied
// `frontend_id` field is not trusted, consistent
// with the CrdtOp source-trust rule). A grid
// 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);
}
}
}
FrontendEvent::Pointer {
buffer_id,
byte,
kind,
mods,
..
} => {
// Mouse framing Q#M1 — a semantic frontend's
// locally hit-tested gesture, in source bytes.
// Routed by the authenticated `source` (the
// client-supplied frontend_id is untrusted — the
// CrdtOp / Viewport source-trust rule). The window
// aligns to the buffer the frontend says it was
// displaying: a click can race a buffer switch.
if semantic_states.contains_key(&source) {
align_semantic_window_to_buffer(editor, source, buffer_id);
if kind == PointerKind::Context {
// Q#CM1 — right-click opens the context menu
// at the hit byte (needs the Lua builder, so
// it routes here rather than dispatch_pointer).
editor.open_menu_at_byte(source, buffer_id, byte);
} else {
editor.dispatch_pointer(source, buffer_id, byte, kind, mods);
}
}
}
FrontendEvent::MenuPointer { index, invoke, .. } => {
// Q#CM1 — semantic frontend menu navigation (hover /
// click), hit-tested against the popup it drew locally.
if semantic_states.contains_key(&source) {
editor.dispatch_menu_pointer(source, index, invoke);
}
}
FrontendEvent::Paste {
frontend_id: claimed_fid,
data,
} => {
// Kill ring Q#KR10a — the unified paste route, for
// BOTH attachment kinds. Handled here (not in
// `apply_event`) for two reasons:
//
// 1. The semantic input dispatcher used to drop
// `Paste` entirely, so GPU Ctrl-V was a no-op
// (pmacs-gpu always negotiates semantic render).
// 2. The authenticated `source` is in scope. The
// event's `claimed_fid` is client-supplied and
// not trusted (the CrdtOp / Viewport / Pointer
// source-trust rule); the old grid arm set
// `active_frontend` from it, letting a forged id
// paste into another frontend's active window.
//
// The paste is a non-command edit, so it breaks the
// source's command chain (Q#KR2), and it fires
// `buffer.after-edit` like any other edit (Q#KR10b)
// — previously it never did, so LSP missed pastes.
handle_inbound_paste(editor, source, claimed_fid, &data);
}
_ => {
let term_size = *term_sizes
.get(&source)
.expect("term_size present for source");
let mut term_size = term_size;
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 {
debug_assert!(
false,
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
);
}
}
}
}
DispatcherEvent::SessionDetached { frontend_id } => {
render_states.remove(&frontend_id);
semantic_states.remove(&frontend_id);
streams.remove(&frontend_id);
term_sizes.remove(&frontend_id);
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
{
let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id);
// Kill ring Q#KR11: frontend ids are monotonic, so
// per-frontend state must not outlive the session.
core.command_history.remove(&frontend_id);
}
// Q#KR11: let Lua modules holding per-frontend tables
// (killring sessions / kill flags) drop this id's entries.
// The first frontend-lifecycle hook; carries the raw id.
let mut args = mlua::MultiValue::new();
args.push_back(mlua::Value::Integer(
i64::try_from(frontend_id.0).unwrap_or(i64::MAX),
));
editor.lua_host.run_hook("frontend.detached", args);
}
}
}
/// T M10.10: send one `InstanceMessage::BufferSnapshot` per buffer
/// in the editor's registry to the newly-attaching frontend's write
/// stream.
///
/// Called only when the session negotiated `crdt_replica: true`. The
/// receiving frontend's `BufferMirror` consumes these to bootstrap
/// its CRDT replicas before any local-edit path can reference them.
///
/// # M10.10 finding: M10.8's deferred upgrade-on-attach wiring
///
/// M10.2 shipped `Buffer::upgrade_to_crdt`; the doc comment notes
/// "Used by M10.8 (multi-frontend instance state) when a v0.1
/// frontend's buffer is promoted to CRDT-backed at attach time" — but
/// M10.8 shipped without wiring the upgrade call. M10.10 surfaces
/// the gap (no CRDT state to snapshot → no `BufferSnapshot` fires).
///
/// Resolution here: upgrade each non-CRDT buffer to CRDT-backed
/// in-place before exporting its snapshot. Uses
/// `peer_id_from_frontend(FrontendId::LOCAL)` (peer id 1) as the
/// instance's CRDT identity — the daemon-owned edit-source ID. Once
/// upgraded, subsequent attaches see the buffer as already CRDT-
/// backed and skip the upgrade.
///
/// Errors on individual buffers (upgrade failure, snapshot export
/// failure, write failure) are logged and skipped; one failed buffer
/// doesn't abort the others.
#[cfg(feature = "crdt")]
fn send_buffer_snapshots(editor: &EditorState, write_stream: &mut UnixStream) {
let core = editor.core.borrow();
let mut registry = core.registry.borrow_mut();
let buffer_ids: Vec<_> = registry.ids().to_vec();
let instance_peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
for buffer_id in buffer_ids {
let Ok(buf) = registry.get_mut(buffer_id) else {
continue;
};
// Upgrade non-CRDT buffers to CRDT-backed in place. The
// upgrade preserves the buffer's id, name, and content; only
// the CRDT machinery is added.
if !buf.is_crdt_backed()
&& let Err(e) = buf.upgrade_to_crdt(instance_peer_id)
{
eprintln!("pmacs: upgrade_to_crdt for {buffer_id:?} failed: {e:?}");
continue;
}
let Some(crdt) = buf.crdt_state() else {
// Upgrade succeeded but somehow crdt is still None —
// shouldn't happen; defensive skip.
continue;
};
let snapshot = match crdt.export_snapshot() {
Ok(bytes) => bytes,
Err(e) => {
eprintln!("pmacs: export_snapshot for {buffer_id:?} failed: {e:?}");
continue;
}
};
let msg = InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot: snapshot,
};
if let Err(e) = write_message(write_stream, &msg) {
eprintln!("pmacs: send BufferSnapshot for {buffer_id:?} failed: {e:?}");
// Continue trying other buffers — the stream may
// recover, or the next per-tick error handling will
// detach the session.
}
}
}
/// No-op stub for non-CRDT builds. v0.1 frontends never advertise
/// `crdt_replica`, so the caller is gated on a capability that's
/// always false in non-CRDT builds; this stub keeps the call site
/// compiling without conditional logic.
#[cfg(not(feature = "crdt"))]
fn send_buffer_snapshots(_editor: &EditorState, _write_stream: &mut UnixStream) {}
/// T M10.10 Day 3 — ensure the active buffer for `fid`'s window is
/// CRDT-backed, upgrading in place if needed.
///
/// Called per-tick for replica frontends from `dispatcher_loop`.
/// Idempotent: after the first upgrade, `is_crdt_backed()` returns
/// true and subsequent calls are no-ops.
///
/// Documented in M10.10-FRAMING.md's composition-consistency-check
/// application — keeps the invariant "active buffer for replica is
/// CRDT-backed" holding even for mid-session-created buffers
/// (post-`send_buffer_snapshots`). Sets up v0.2's mid-session
/// `BufferSnapshot` broadcast work without retrofit.
///
/// Errors on upgrade are logged but don't abort the session — the
/// per-tick loop will retry on the next iteration; persistent failure
/// signals a deeper buffer-state issue worth surfacing to the user
/// elsewhere.
///
/// # F29 (post-audit-round-5) — mid-session `BufferSnapshot` push
///
/// When this function performs an upgrade (returns `Some(buffer_id)`),
/// the caller broadcasts a `BufferSnapshot` to every currently-
/// attached replica frontend so their `BufferMirror`s gain an
/// entry for the newly-CRDT-backed buffer. Without this push, the
/// replicas' `init_from_snapshot` is never called for the buffer
/// and the optimistic-apply path falls through to v0.1 round-trip
/// (`is_ready` returns false) until the replica detaches and
/// reattaches.
///
/// Idempotency: the function returns `None` if the buffer was
/// already CRDT-backed, so the broadcast only fires on the actual
/// upgrade tick. A receiving frontend whose mirror already has the
/// buffer (e.g. it attached after the upgrade and received the
/// snapshot in `send_buffer_snapshots`) sees `AlreadyInitialized`
/// from `init_from_snapshot` and logs but keeps existing state —
/// non-fatal.
///
/// Returns `Some(buffer_id)` when an upgrade just happened (caller
/// must broadcast); `None` when the buffer was already CRDT-backed
/// or the upgrade failed (failure already logged inside).
#[cfg(feature = "crdt")]
fn ensure_active_buffer_crdt_backed(
editor: &EditorState,
fid: FrontendId,
) -> Option<crate::buffer::BufferId> {
let buffer_id_opt = {
let core = editor.core.borrow();
core.active_window_for(fid).map(|w| w.buffer_id)
};
let buffer_id = buffer_id_opt?;
let core = editor.core.borrow();
let mut registry = core.registry.borrow_mut();
let Ok(buf) = registry.get_mut(buffer_id) else {
return None;
};
if buf.is_crdt_backed() {
return None;
}
let instance_peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
match buf.upgrade_to_crdt(instance_peer_id) {
Ok(()) => Some(buffer_id),
Err(e) => {
eprintln!("pmacs: lazy upgrade_to_crdt for {buffer_id:?} (fid {fid:?}) failed: {e:?}");
None
}
}
}
#[cfg(not(feature = "crdt"))]
fn ensure_active_buffer_crdt_backed(
_editor: &EditorState,
_fid: FrontendId,
) -> Option<crate::buffer::BufferId> {
None
}
/// T M10.10 post-audit-round-5 F29 — broadcast a single buffer's
/// `BufferSnapshot` to every currently-attached replica frontend
/// (with `crdt_replica` negotiated).
///
/// Called when [`ensure_active_buffer_crdt_backed`] performs a
/// mid-session upgrade (or when any future code path creates /
/// upgrades a buffer that existing replicas haven't seen yet).
/// Per-replica state tracking isn't kept: replicas whose mirror
/// already has the buffer surface `AlreadyInitialized` from
/// `init_from_snapshot` and log but don't fail. The duplicate
/// send is small (snapshot bytes for the upgrade-instant state,
/// which is the empty / freshly-loaded buffer content the replica
/// already has) and only fires on the actual upgrade tick.
/// Export `buffer_id`'s CRDT snapshot bytes, or `None` (logged) when
/// the buffer is missing, not CRDT-backed, or the export fails.
#[cfg(feature = "crdt")]
fn export_buffer_snapshot(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
) -> Option<Vec<u8>> {
let core = editor.core.borrow();
let registry = core.registry.borrow();
let buf = registry.get(buffer_id).ok()?;
let crdt = buf.crdt_state()?;
match crdt.export_snapshot() {
Ok(bytes) => Some(bytes),
Err(e) => {
eprintln!("pmacs: export_snapshot for {buffer_id:?} failed: {e:?}");
None
}
}
}
/// Arc 1b — send `buffer_id`'s snapshot to ONE frontend. The
/// active-buffer-follow path (see the per-tick loop) uses this when a
/// semantic frontend's own active buffer changes to an
/// already-CRDT-backed buffer: the F29 broadcast only fires on the
/// upgrade tick, so without this a frontend that switched *back* to a
/// known buffer (a panel's `q`, `find_or_open` of an open file) kept
/// displaying the old buffer while daemon-side input targeted the new
/// one.
#[cfg(feature = "crdt")]
fn send_buffer_snapshot_to_frontend(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
fid: FrontendId,
streams: &mut HashMap<FrontendId, UnixStream>,
) {
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
return;
};
let msg = InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot: snapshot_bytes,
};
if let Some(stream) = streams.get_mut(&fid)
&& let Err(e) = write_message(stream, &msg)
{
eprintln!("pmacs: send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
}
}
#[cfg(feature = "crdt")]
fn broadcast_buffer_snapshot_to_replicas(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
session_registry: &SessionRegistry,
streams: &mut HashMap<FrontendId, UnixStream>,
) {
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
return;
};
let msg = InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot: snapshot_bytes,
};
for (fid, stream) in streams.iter_mut() {
let is_replica = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica);
if !is_replica {
continue;
}
if let Err(e) = write_message(stream, &msg) {
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
}
}
}
/// T M10.10 (post-audit round 2) — validate an incoming
/// `FrontendEvent::CrdtOp` against four invariants. Returns the
/// rejection reason on failure; `Ok(())` means the op may be applied.
///
/// The four invariants:
///
/// - **F11 — negotiated cap.** The source session must have
/// negotiated `crdt_replica: true`. A legacy / non-replica session
/// has no contract to send `CrdtOp` events; accepting one would let
/// such a session mutate daemon state under a capability it never
/// advertised.
/// - **Original Finding 4 — `claimed_fid` matches `source`.** The
/// event's `frontend_id` field is client-supplied. A buggy or
/// malicious frontend can put another frontend's id there. The
/// original Finding 4 fix used `source` for routing and merely
/// logged a warning on mismatch; F12 tightens this to a hard
/// reject — there is no legitimate reason for the values to
/// differ.
/// - **F12 — `op.peer_id` matches `source`.** The receiving
/// frontend's attach loop derives the broadcast's source via
/// `FrontendId(op.peer_id)` (see `src/attach.rs` in the `CrdtOp`
/// message branch). A frontend A that puts B's peer id in the op
/// payload can cause B's mirror to dedup-skip its way into
/// divergence: B sees the broadcast, thinks it's its own echo,
/// skips it. The daemon must reject before the op is applied or
/// re-broadcast.
/// - **F13 — `buffer_id` matches source's active window buffer.**
/// M10.10's local-edit path (`optimistic::frontend_event_for_keystroke`)
/// only emits `CrdtOp`s for the active mirror buffer. A frontend has
/// no v1.0-scope reason to target a different buffer; rejecting
/// non-active-buffer ops keeps the surface to what the test matrix
/// actually exercises.
#[cfg(feature = "crdt")]
fn validate_remote_crdt_op(
editor: &EditorState,
session_registry: &SessionRegistry,
source: FrontendId,
claimed_fid: FrontendId,
buffer_id: crate::buffer::BufferId,
op: &crate::rope::CrdtOp,
) -> Result<(), &'static str> {
let crdt_replica = session_registry
.session_state(source)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica);
if !crdt_replica {
return Err("session did not negotiate crdt_replica");
}
if claimed_fid != source {
return Err("event frontend_id does not match authenticated source");
}
if op.peer_id != crate::crdt::peer_id_from_frontend(source) {
return Err("op.peer_id does not match authenticated source");
}
let active_buffer_id = editor
.core
.borrow()
.active_window_for(source)
.map(|w| w.buffer_id);
if active_buffer_id != Some(buffer_id) {
return Err("buffer_id does not match source's active window buffer");
}
// F26 (post-audit-round-4) — the wire wrapper's `op.peer_id`
// matches `source`, but the loro update bytes carry their own
// internal peer attribution. A hostile or buggy client can
// wrap update bytes generated under a different peer with the
// wrapper peer_id set correctly. Fork the buffer's CRDT state
// and inspect which peers' counters advance on import. Any
// peer that isn't the authenticated source's peer_id is a
// protocol violation.
let expected_peer_id = crate::crdt::peer_id_from_frontend(source);
let registry_handle = editor.core.borrow().registry.clone();
let registry = registry_handle.borrow();
if let Ok(buf) = registry.get(buffer_id)
&& buf
.validate_remote_op_peer_ids(expected_peer_id, &op.bytes)
.is_err()
{
return Err(
"op.bytes carry CRDT ops attributed to a peer other than the authenticated source",
);
}
Ok(())
}
/// The unified inbound-paste route (kill ring Q#KR10a) — one handler
/// for grid *and* semantic sessions, keyed by the dispatcher's
/// authenticated `source`. `claimed` is the event payload's
/// client-supplied id: never trusted (a forged id must not paste into
/// another frontend's active window), only logged on mismatch. The
/// paste breaks the source's command chain (a non-command edit, Q#KR2)
/// and fires `buffer.after-edit` when the buffer changed (Q#KR10b).
fn handle_inbound_paste(
editor: &mut EditorState,
source: FrontendId,
claimed: FrontendId,
data: &[u8],
) {
if claimed != source {
eprintln!(
"pmacs daemon: Paste claimed {claimed:?} but came \
from {source:?}; using the authenticated source"
);
}
editor.core.borrow_mut().active_frontend = source;
editor.with_after_edit_check(|state| {
let mut core = state.core.borrow_mut();
core.break_command_chain(source);
if let Err(e) = core.paste_inbound(data) {
eprintln!("pmacs: inbound paste failed: {e}");
}
});
}
/// T M10.10 (post-audit) — apply a *pre-validated*
/// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks
/// happen upstream in `validate_remote_crdt_op`; this function trusts
/// `source` and `buffer_id` and performs four effects:
///
/// 1. **Apply op to the buffer's CRDT state + rope projection**
/// via `Buffer::apply_remote_crdt_op`. Returns an `Edit` so
/// downstream effects can use the `range/inserted_len`.
/// 2. **Update source window's cursor** to the optimistic post-edit
/// position (`edit.range.start + edit.inserted_len` — matches
/// the source frontend's mirror cursor after `advance_cursor` or
/// `retreat_cursor`). Without this, the next per-tick `CursorByte`
/// carries the daemon's stale window cursor and snaps the source
/// frontend's mirror cursor back to the wrong byte.
/// 3. **Notify other windows displaying this buffer** of the edit
/// via `notify_buffer_edit`. Updates `TextView` line caches and
/// overlays so future cursor motions / paints derive from
/// current rope state. Adjusts other-window cursors using
/// right-gravity semantics (mirrors `Buffer::adjust_marks_for_edit`
/// behavior).
/// 4. **Queue for broadcast** to other replica frontends via
/// `pending_crdt_ops`. Sender-exclusion uses the authenticated
/// `source`.
#[cfg(feature = "crdt")]
fn handle_remote_crdt_op(
editor: &mut EditorState,
source: FrontendId,
buffer_id: crate::buffer::BufferId,
op: crate::rope::CrdtOp,
) {
// Kill ring Q#KR2: an optimistic edit arrives here without ever
// touching dispatch_key, so the source's command boundary must be
// updated — or `C-k x C-k` on the GPU would append across the typed
// character. Break first (covers every early-return path); a
// successful apply refines this below: a single-codepoint insert is
// re-classified as `buffer.self-insert`, giving typed characters the
// same boundary on both frontends. That keeps kill-chain semantics
// identical (self-insert is not a kill) while making `this_command`
// a usable input-origin signal for typed-char consumers (signature
// help; the completion popup can migrate later).
editor.core.borrow_mut().break_command_chain(source);
// Effect 1: apply to buffer's CRDT + rope. Capture the Edit
// (or `None` for an op that imported cleanly but produced no
// text delta — F17).
let edit_opt = {
let core = editor.core.borrow();
let registry_handle = core.registry.clone();
drop(core);
let mut registry = registry_handle.borrow_mut();
if let Ok(buf) = registry.get_mut(buffer_id) {
match buf.apply_remote_crdt_op(&op.bytes) {
Ok(opt) => opt,
Err(e) => {
eprintln!(
"pmacs daemon: apply_remote_crdt_op for \
{buffer_id:?} failed: {e:?}; dropping op"
);
return;
}
}
} else {
eprintln!("pmacs daemon: CrdtOp for unknown {buffer_id:?}; dropping op");
return;
}
};
// Effects 2 + 3: update window cursors + notify views. ONLY
// when an Edit was produced — a CRDT import with no text delta
// (e.g. concurrent same-character delete) has nothing to
// notify but the op still needs broadcasting (F17).
if let Some(edit) = edit_opt.as_ref() {
let mut core = editor.core.borrow_mut();
// The input-origin refinement promised above. The optimistic
// layer emits exactly one op per keystroke, so an empty-range
// insert of one codepoint (14 UTF-8 bytes) IS a typed
// character — Backspace/Delete/Undo produce deletes or larger
// shapes and stay chain-breaks.
if edit.range.start == edit.range.end && (1..=4).contains(&edit.inserted_len) {
core.rotate_command(source, "buffer.self-insert");
}
// Transient status messages clear on user input. The Key path
// gets this from `dispatch_key`'s entry clear; the optimistic
// path routes plain typing here instead, and since v15 ships
// `core.status` over `StatusFacts`, a stale "12 references"
// would otherwise stay wedged in a semantic frontend's band
// through ordinary typing.
core.status.clear();
let post_edit_cursor = edit.range.start + edit.inserted_len;
// Identify source's active window id (so we can skip it
// when adjusting other windows' cursors below; the source
// window's cursor is set to the optimistic post-edit
// position directly).
let source_active_window_id = core.views.get(&source).map(|v| v.active);
// Right-gravity cursor adjustment shape (same as
// Buffer::adjust_marks_for_edit for MarkGravity::Right).
let old_len = edit.range.end - edit.range.start;
let new_end = edit.range.start + edit.inserted_len;
let inserted_len = edit.inserted_len;
for (wid, win) in &mut core.windows {
if win.buffer_id != buffer_id {
continue;
}
if Some(*wid) == source_active_window_id {
// Source window: set directly to optimistic post-edit
// position (matches the source frontend's mirror
// cursor after advance/retreat).
win.cursor = post_edit_cursor;
continue;
}
// Other window displaying this buffer: shift cursor with
// right-gravity semantics.
let pos = win.cursor;
win.cursor = if pos < edit.range.start {
pos
} else if pos > edit.range.end {
pos - old_len + inserted_len
} else {
// Within edit range — clamp to new_end (right-gravity).
new_end
};
}
core.notify_buffer_edit(buffer_id, edit);
// T M11.9 — temporarily switch active_frontend to source so
// the `buffer.after-edit` hook's Lua observers (notably the
// LSP `did_change` glue in `builtin/runtime/lsp.lua`) read
// the right buffer via `pmacs.window.buffer()`. Matches the
// pattern `dispatch_key` uses (it assigns `active_frontend`
// before running its hook).
core.active_frontend = source;
drop(core);
// T M11.9 — fire `buffer.after-edit` for replicated edits.
// Without this, LSP `textDocument/didChange` is never sent
// for keystrokes the M10.10 optimistic-apply layer routed as
// `FrontendEvent::CrdtOp` (the bulk of plain-char typing),
// so clangd's view of the document drifts behind reality.
// Diagnostics, semantic tokens, and inlay hints all silently
// freeze at the byte positions they last had when an edit
// happened to fall back to the Key path. Closes the actual
// root cause of the session-5 wrong-position-color
// artifact; the diag-store stale-flag from T M11.8 finally
// gets reached.
editor
.lua_host
.run_hook("buffer.after-edit", mlua::MultiValue::new());
}
// Effect 4: queue for broadcast. The source frontend's mirror
// already applied the op (this is the optimistic-replica
// path); use `OptimisticReplica(source)` so the broadcast
// sweep excludes it. F17: this push happens even when
// `edit_opt` is None — concurrent same-char deletes still
// need their CRDT causal metadata propagated to peers.
editor.core.borrow_mut().pending_crdt_ops.push((
crate::editor_core::CrdtOpOrigin::OptimisticReplica(source),
buffer_id,
op,
));
}
// Non-CRDT build: the call site in `handle_dispatcher_event` is
// itself feature-gated, so no stub is needed. A non-CRDT daemon
// never receives `FrontendEvent::CrdtOp` from a properly-negotiated
// frontend because `InstanceCapabilities::default()` advertises
// `crdt_replica: false` in non-CRDT builds (Finding 3 fix).
/// Build a `FrontendView` for an attaching frontend.
///
/// T M10.8 Day 3 → T M10.9 update: attaching frontends now share
/// `FrontendId::LOCAL`'s active buffer (typically the daemon's
/// scratch buffer). Each gets its OWN `Window` instance — same
/// buffer, fresh cursor at position 0. This makes M10.9's
/// "two frontends in the same buffer see each other's cursors"
/// acceptance criterion observable: A and B start in the same
/// buffer, their `PresenceUpdate` broadcasts carry matching
/// `buffer_id`, the overlay paint fires.
///
/// Frontends that want their own buffer can still do
/// `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};
let mut core = editor.core.borrow_mut();
// T M10.9 — share LOCAL's buffer (don't create a fresh
// scratch). M10.8's fresh-scratch behavior made overlays
// never fire because attaching frontends were in distinct
// buffers.
let local_view = core
.views
.get(&FrontendId::LOCAL)
.expect("LOCAL view present");
let local_active_win_id = local_view.active;
let buffer_id = core
.windows
.get(&local_active_win_id)
.expect("LOCAL's active window present in core.windows")
.buffer_id;
let text_view = {
let reg = core.registry.borrow();
let buf = reg.get(buffer_id).expect("shared buffer present");
TextView::new(buf)
};
let id = WindowId::next();
let window = Window::new(id, buffer_id, text_view);
core.windows.insert(id, window);
FrontendView {
layout: Layout::single(id),
active: id,
}
}
/// Snapshot one frontend's presence (cursor + selection +
/// containing buffer) for T M10.6/8's per-tick broadcast sweep.
///
/// **T M10.8 — explicit `frontend_id` parameter.** M10.6 used the
/// active-frontend default via `core.active_window()`; M10.8 takes
/// the explicit `frontend_id` so the dispatcher can sweep multiple
/// frontends in one tick by calling this for each attached session.
/// If `frontend_id` has no registered view yet (Day 2 transitional
/// state before the dispatcher registers per-attach views), falls
/// back to the active window — preserves M10.6 behavior unchanged.
///
/// The snapshot is taken at the tick boundary — the daemon's render
/// flush point — so multiple cursor moves between sweeps appear as
/// one snapshot transition.
fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> PresenceSnapshot {
let core = editor.core.borrow();
let win = core
.active_window_for(frontend_id)
.unwrap_or_else(|| core.active_window());
PresenceSnapshot {
buffer_id: win.buffer_id,
cursor: win.cursor,
selection: win.selection.map(|sel| SelectionSnapshot {
anchor: sel.anchor,
active: win.cursor,
}),
}
}
/// 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` / `Focus` have no
/// grid-less effect yet and are dropped; `Viewport` / `CrdtOp` /
/// `Paste` (Q#KR10a) 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.
#[allow(clippy::needless_pass_by_value)]
fn apply_event(
editor: &mut EditorState,
ev: FrontendEvent,
term_size: &mut CellSize,
render_state: &mut RenderState,
) {
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);
}
// `Key::Unknown` keys (media buttons etc.) have no
// crossterm equivalent and do not actuate commands; drop.
}
FrontendEvent::Mouse(pmacs_mouse) => {
let ct_mouse = mouse_to_crossterm(&pmacs_mouse);
editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, *term_size);
}
FrontendEvent::Resize { size, .. } => {
render_state.resize(size);
*term_size = size;
}
// Q#KR10a — Paste is handled in the dispatcher's own
// `FrontendEvent::Paste` arm (unified for grid and semantic
// sessions, keyed by the authenticated source), and never
// reaches here. Listed explicitly so a future reshuffle can't
// silently re-route it through this payload-trusting path.
FrontendEvent::Paste { .. }
| FrontendEvent::FocusGained(_)
| FrontendEvent::FocusLost(_)
// T M11.1: the semantic-frontend viewport declaration. Its
// consumer is the instance-side projection seam
// (`SemanticRenderState`, M11.2), which scopes the
// SemanticFrame family to this byte range. M11.1 only
// declares the wire shape; no projection seam exists yet and
// the instance advertises `semantic_render: false`, so
// negotiation rejects any session that would emit this — it
// is unreachable in practice. Dropped silently until M11.2
// wires the consumer (same "declared, not yet wired" posture
// CrdtOp had between M10.5 and M10.8).
| FrontendEvent::Viewport { .. } => {
// v0.1: silently ignored. Future work surfaces these
// through Lua hooks (paste-text-fn, focus-changed-hook).
}
FrontendEvent::Detach(_) => {
// Caller handles Detach as a control event before reaching
// here.
unreachable!("Detach is handled by run_per_attach directly");
}
FrontendEvent::CrdtOp { .. } => {
// T M10.10 — handled by `handle_remote_crdt_op` directly
// from `handle_dispatcher_event` so the authenticated
// source FrontendId is in scope (the dispatcher's
// `DispatcherEvent::FrontendEvent { source, event }` tags
// the message with the per-attach-authenticated id, not
// the client-supplied `frontend_id` field on the variant).
// This arm is unreachable in practice; left as a defensive
// log in case future routing changes deliver a CrdtOp
// through `apply_event` instead.
eprintln!(
"pmacs daemon: FrontendEvent::CrdtOp reached apply_event; \
this path is supposed to be intercepted in \
handle_dispatcher_event. Dropping op."
);
}
FrontendEvent::Pointer { .. } => {
// Mouse framing Q#M1 — only semantic sessions emit
// Pointer, and `handle_dispatcher_event` routes those via
// `apply_semantic_input_event` (with the authenticated
// source). A grid session sending one is a protocol
// violation; drop it like the CrdtOp arm above.
eprintln!(
"pmacs daemon: FrontendEvent::Pointer from a grid session; dropping \
(semantic sessions route via apply_semantic_input_event)"
);
}
FrontendEvent::MenuPointer { .. } => {
// Q#CM1 — like Pointer, only semantic sessions emit
// MenuPointer, routed by the authenticated source in
// `handle_dispatcher_event`. Drop a grid session's.
eprintln!(
"pmacs daemon: FrontendEvent::MenuPointer from a grid session; dropping"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_state_starts_frontend_id_at_two() {
let s = DaemonState::new(None);
let a = s.next_frontend_id.fetch_add(1, Ordering::SeqCst);
let b = s.next_frontend_id.fetch_add(1, Ordering::SeqCst);
assert_eq!(a, 2);
assert_eq!(b, 3);
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));
thread::sleep(Duration::from_millis(20));
let id = s.build_identity();
assert_eq!(id.pmacs_version, env!("CARGO_PKG_VERSION"));
assert_eq!(id.instance_name.as_deref(), Some("research"));
// uptime_secs is whole seconds; 20 ms might round to 0,
// which is the expected lower bound (uptime never negative).
// No upper bound assertion — sleep precision is variable.
}
#[test]
fn build_identity_default_instance_name_is_none() {
let s = DaemonState::new(None);
let id = s.build_identity();
assert!(id.instance_name.is_none());
}
#[test]
fn build_identity_working_directory_is_utf8() {
let s = DaemonState::new(None);
let id = s.build_identity();
// Just verify it's set (or empty if cwd was non-UTF-8).
// Test environments are UTF-8, so this should be non-empty.
assert!(!id.working_directory.is_empty());
}
/// T M11.9 regression: `handle_remote_crdt_op` fires the
/// `buffer.after-edit` Lua hook. Without this, the M10.10
/// optimistic-apply path's `FrontendEvent::CrdtOp` route
/// bypasses every Lua observer of buffer mutations — most
/// importantly the LSP `did_change` notification, which means
/// clangd never re-analyzes documents edited via the optimistic
/// path. The session-5 wrong-position-color artifact was the
/// downstream symptom: diagnostics frozen at pre-edit byte
/// positions because clangd had never been told about the edit.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_fires_after_edit_hook() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
// Install an after-edit hook that bumps a global counter
// we can read back from Lua.
editor
.lua_host
.eval(
Some("test"),
r#"
_G.PMACS_TEST_AFTER_EDIT_FIRED = 0
pmacs.hook.add("buffer.after-edit", function()
_G.PMACS_TEST_AFTER_EDIT_FIRED = (_G.PMACS_TEST_AFTER_EDIT_FIRED or 0) + 1
end)
"#,
)
.expect("install after-edit hook");
// Upgrade the active buffer to CRDT-backed so
// `handle_remote_crdt_op` finds a `CrdtState` to apply
// against (the non-CRDT path is not exercised here).
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
// Build a peer CRDT doc from the buffer's snapshot, perform
// an edit on the peer, export the op bytes. This is the
// shape `optimistic::apply_local_insert` produces in the
// attach loop's optimistic-apply branch.
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
let buf = reg.get(buffer_id).expect("buffer");
buf.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(99).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
// Apply the op via `handle_remote_crdt_op`.
super::handle_remote_crdt_op(
&mut editor,
FrontendId(99),
buffer_id,
crate::rope::CrdtOp {
peer_id: 99,
bytes: op_bytes,
},
);
// The hook should have fired exactly once.
let count_val = editor
.lua_host
.eval(
Some("test-readback"),
"return _G.PMACS_TEST_AFTER_EDIT_FIRED",
)
.expect("read counter");
let count = match count_val {
mlua::Value::Integer(n) => n,
other => panic!("expected counter integer, got {other:?}"),
};
assert_eq!(
count, 1,
"buffer.after-edit must fire when handle_remote_crdt_op produces a text Edit"
);
}
/// Kill ring Q#KR2 — GPU typing arrives here without touching
/// dispatch_key, so it must update the source frontend's command
/// boundary or `C-k x C-k` on the GPU would append across the typed
/// character. A single-codepoint insert classifies as
/// `buffer.self-insert` (the input-origin signal for signature
/// help); anything else breaks the chain outright.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_classifies_typed_input_and_ends_kill_chains() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
let source = FrontendId(7);
// A live kill chain for the source frontend...
editor
.core
.borrow_mut()
.rotate_command(source, "edit.kill-line");
// ...and one for a bystander that must survive.
editor
.core
.borrow_mut()
.rotate_command(FrontendId::LOCAL, "edit.kill-line");
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(7).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
handle_remote_crdt_op(
&mut editor,
source,
buffer_id,
crate::rope::CrdtOp {
peer_id: 7,
bytes: op_bytes,
},
);
let core = editor.core.borrow();
// A single-codepoint optimistic insert classifies as a typed
// character: the boundary rotates to buffer.self-insert (the
// input-origin signal), which — not being a kill command —
// still breaks the kill chain exactly like the TUI typed-char
// path.
assert_eq!(
core.command_history
.get(&source)
.and_then(|b| b.this.as_deref()),
Some("buffer.self-insert"),
"a typed optimistic insert classifies as self-insert"
);
assert_eq!(
core.command_history
.get(&source)
.and_then(|b| b.last.as_deref()),
None,
"the pre-existing kill chain is gone (break-then-classify): a \
following kill reads last = self-insert after its own rotation \
and never appends"
);
assert_eq!(
core.command_history
.get(&FrontendId::LOCAL)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"a bystander frontend's chain is untouched"
);
}
/// Kill ring Q#KR10a — the unified paste route trusts only the
/// dispatcher's authenticated source. A forged payload id must not
/// paste into another frontend's active window, and the paste
/// breaks the SOURCE's chain (not the claimed frontend's) and
/// fires `buffer.after-edit` exactly once.
#[test]
fn inbound_paste_uses_authenticated_source_not_the_claimed_id() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::text_view::TextView;
use crate::window::{FrontendView, Layout, Window, WindowId};
let mut editor = EditorState::new();
editor
.lua_host
.eval(
Some("test"),
r#"
_G.PASTE_AFTER_EDIT = 0
pmacs.hook.add("buffer.after-edit", function()
_G.PASTE_AFTER_EDIT = _G.PASTE_AFTER_EDIT + 1
end)
"#,
)
.expect("install after-edit hook");
// Give the attacker frontend its OWN view onto its own buffer,
// so "which window did the text land in" is observable.
let source = FrontendId(7);
let victim = FrontendId::LOCAL;
let attacker_buf = {
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.create("attacker-buffer")
};
{
let mut core = editor.core.borrow_mut();
let tv = {
let reg = core.registry.borrow();
TextView::new(reg.get(attacker_buf).expect("attacker buffer"))
};
let wid = WindowId::next();
core.windows.insert(wid, Window::new(wid, attacker_buf, tv));
core.register_frontend_view(
source,
FrontendView {
layout: Layout::single(wid),
active: wid,
},
);
}
let victim_buf = editor.core.borrow().active_window().buffer_id;
// Seed a live chain on the victim: the forged paste must not
// break it (only the authenticated source's chain breaks).
editor
.core
.borrow_mut()
.rotate_command(victim, "edit.kill-line");
// The payload CLAIMS to be the victim.
handle_inbound_paste(&mut editor, source, victim, b"FORGED");
let core = editor.core.borrow();
let text_of = |id| {
let reg = core.registry.borrow();
let buf = reg.get(id).expect("buffer");
let len = buf.len();
let mut out = vec![0u8; usize::try_from(len).unwrap_or(0)];
if len > 0 {
buf.snapshot_rope().slice(0, len, &mut out);
}
String::from_utf8_lossy(&out).into_owned()
};
assert!(
text_of(attacker_buf).contains("FORGED"),
"the paste lands in the AUTHENTICATED source's active window"
);
assert!(
!text_of(victim_buf).contains("FORGED"),
"a forged payload id must not paste into the claimed frontend's window"
);
assert!(
core.command_history
.get(&source)
.is_none_or(|b| b.this.is_none()),
"the paste breaks the source's chain"
);
assert_eq!(
core.command_history
.get(&victim)
.and_then(|b| b.this.as_deref()),
Some("edit.kill-line"),
"the claimed frontend's chain is untouched"
);
drop(core);
let count = editor
.lua_host
.eval(Some("test-readback"), "return _G.PASTE_AFTER_EDIT")
.expect("read counter");
assert!(
matches!(count, mlua::Value::Integer(1)),
"paste fires buffer.after-edit exactly once, got {count:?}"
);
}
/// v15 regression: an optimistic-path edit (the bulk of plain-char
/// typing from a semantic frontend) must clear the transient
/// status message, exactly as `dispatch_key`'s entry clear does
/// for round-tripped keys — otherwise "12 references" stays
/// wedged in the GPU band (which renders `StatusFacts.message`)
/// through ordinary typing.
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_clears_the_transient_status() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let mut editor = EditorState::new();
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("active buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(99).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
editor.core.borrow_mut().status = "12 references".to_owned();
super::handle_remote_crdt_op(
&mut editor,
FrontendId(99),
buffer_id,
crate::rope::CrdtOp {
peer_id: 99,
bytes: op_bytes,
},
);
assert!(
editor.core.borrow().status.is_empty(),
"an optimistic-path edit must clear the transient status"
);
}
/// 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"
);
}
}