pmacs/src/daemon.rs

5541 lines
233 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::ffi::{OsStr, OsString};
use std::io::ErrorKind;
use std::net::Shutdown;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
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::{
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello,
InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind,
SelectionSnapshot, SessionBootstrapRequest,
};
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,
/// Validated protocol-v20 semantic bootstrap target, if requested.
initial_target: Option<InitialTarget>,
/// 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(())
}
fn bounded_initial_target_error(mut message: String) -> String {
if message.len() <= MAX_INITIAL_TARGET_ERROR_BYTES {
return message;
}
let mut end = MAX_INITIAL_TARGET_ERROR_BYTES;
while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
message
}
fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into<String>) {
let result = InitialTargetResult::Failed {
message: bounded_initial_target_error(message.into()),
};
let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result));
let _ = stream.shutdown(Shutdown::Both);
}
fn validate_initial_target(target: &InitialTarget) -> Result<(), String> {
if target.cwd.is_empty() {
return Err("initial target cwd is empty".to_owned());
}
if target.path.is_empty() {
return Err("initial target path is empty".to_owned());
}
if target.cwd.len() > MAX_INITIAL_TARGET_PATH_BYTES {
return Err("initial target cwd exceeds 32 KiB".to_owned());
}
if target.path.len() > MAX_INITIAL_TARGET_PATH_BYTES {
return Err("initial target path exceeds 32 KiB".to_owned());
}
if target.cwd.contains(&0) || target.path.contains(&0) {
return Err("initial target path contains an embedded NUL".to_owned());
}
if !Path::new(OsStr::from_bytes(&target.cwd)).is_absolute() {
return Err("initial target cwd is not absolute".to_owned());
}
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,
clippy::too_many_lines,
reason = "the ordered handshake and bootstrap read stay on one per-connection thread"
)]
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: ADVERTISED_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: ADVERTISED_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;
}
};
// Q#GT4 — v20 semantic sessions send one bootstrap envelope after
// AttachRequest. Legacy and non-semantic sessions retain the exact
// two-message handshake and therefore must not be read here.
let initial_target = if req.protocol_version >= 20 && negotiated_caps.semantic_render {
let bootstrap: SessionBootstrapRequest = match read_message(&mut stream) {
Ok(bootstrap) => bootstrap,
Err(e) => {
eprintln!("pmacs: read SessionBootstrapRequest failed: {e}");
return;
}
};
if let Some(target) = bootstrap.initial_target.as_ref()
&& let Err(message) = validate_initial_target(target)
{
send_initial_target_failure(&mut stream, message);
return;
}
bootstrap.initial_target
} else {
None
};
// 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,
initial_target,
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 });
}
/// Belt-and-braces write-loop gate for the additive protocol-v18
/// statusline variant. The producer has its own callback/evaluation gate;
/// this filter independently prevents an unknown discriminant reaching an
/// older peer even if a message is injected into the frame vector.
fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
}
/// Whether a session negotiated the v19 wire, and may therefore drive
/// terminal state inbound.
///
/// The outbound `TerminalFrame` is gated twice — in the producer and in
/// the write loop — and the inbound direction is now symmetric. A
/// pre-v19 peer cannot construct these variants at all (its enum lacks
/// them), so this only ever refuses a hand-rolled client; the a32
/// forgery tests already prove such an event can reach nothing but the
/// sender's own authenticated active view. It is defense in depth, and
/// it makes "gated in both directions" true of the code rather than
/// only of the frontends we ship.
fn peer_declared_terminal_support(
session_registry: &SessionRegistry,
frontend_id: FrontendId,
) -> bool {
session_registry
.session_state(frontend_id)
.is_some_and(|state| state.negotiated_protocol_version >= 19)
}
/// Whether a session can **render** a side window (bottom-panel arc,
/// Q#BP13).
///
/// Grid sessions paint the whole cell grid the daemon composes, so a side
/// window is just another leaf for them. A semantic session needs the
/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1
/// answers `false` for every semantic peer, whatever it declares. No
/// client-asserted standalone boolean is trusted: the answer is derived
/// from the daemon's own negotiated state, and Stage 2 turns the version
/// arm on (`semantic_render && negotiated_protocol_version >=
/// PANEL_MIN_VERSION`).
fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
!session_state.negotiated_capabilities.semantic_render
}
/// The same belt-and-braces write-loop gate for the additive
/// protocol-v19 terminal frame. The semantic producer skips construction
/// for an older peer; this filter independently prevents an unknown
/// discriminant reaching one, so neither gate alone is load-bearing.
fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_))
}
/// 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();
// Active-terminal BEL delivery baseline. Switching away forgets the
// terminal so historical bells are never replayed on later activation.
let mut terminal_bell_baselines: HashMap<FrontendId, (crate::buffer::BufferId, u64)> =
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` to every grid replica so its
// `BufferMirror` gains an entry for the buffer. A
// semantic replica receives it only when that replica
// is displaying this buffer: applying a foreign-buffer
// snapshot would switch the GPU window away from its
// own active view.
if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) {
broadcast_buffer_snapshot_to_replicas(
editor,
upgraded,
&session_registry,
&mut streams,
&mut semantic_states,
);
// 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 = document_buffer_to_follow(editor, *fid);
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);
// PR #120 round 2 — the snapshot just wiped
// this frontend's buffer-scoped render state;
// the emission baselines must die with it or
// an unchanged-generation revisit (A → B → A)
// suppresses every re-send.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(active_now);
}
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_size = render_states
.get(fid)
.expect("render_state present for attached grid fid")
.size();
let terminal_snapshots = editor.prepare_terminal_views(*fid, render_size);
let render_state = render_states
.get_mut(fid)
.expect("render_state present for attached grid fid");
render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
};
// Vterm Stage 3 — a semantic frontend showing a terminal has
// no document cursor: the identity buffer is empty, so a
// `CursorByte` would describe byte 0 of a buffer with no
// text and scroll the frontend's document view against it.
let terminal_mode = semantic_states
.get(fid)
.is_some_and(crate::semantic_render::SemanticRenderState::in_terminal_mode);
// 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.
//
// Terminal mode does NOT skip this (PR #135 review finding 1).
// `sweep` is diff-keyed on `last_broadcast`, so a skip can
// only ever FREEZE a frontend's presence — it can never
// retract it. Sweeping truthfully moves the presence into
// the terminal identity buffer, which is what takes this
// frontend's caret off every peer showing the document.
//
// Honest note on why the skip was not a live bug: the
// buffer-follow above clears the terminal declaration when
// it ships the snapshot, so `render_frame` reports
// `terminal_active == false` on the tick a window first
// shows a terminal, and the declaration cannot arrive until
// a later tick (the frontend learns the buffer id FROM that
// snapshot). Every entry into terminal mode is therefore
// already preceded by one truthful sweep. The skip was
// load-bearing on that ordering and bought nothing; removing
// it makes "presence follows the frontend" structural
// instead of a property of tick sequencing.
//
// The framing's "suppress presence for the terminal identity
// buffer" is a RENDER rule — no peer overlay is painted
// inside a terminal — which the GPU honors by not preparing
// the decoration batch in terminal mode. It was never a
// reason to stop telling peers where this frontend went.
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 take_pending_terminal_bell(editor, *fid, &mut terminal_bell_baselines)
&& let Some(stream) = streams.get_mut(fid)
&& let Err(error) =
write_message(stream, &InstanceMessage::Signal(InstanceSignal::Bell))
{
eprintln!("pmacs: write terminal Bell for {fid:?} failed: {error}");
write_failed = true;
}
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_for(*fid);
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);
// Themes Q#TH7 — ThemeFacts gated at v16; a v15 peer's
// chrome simply stays on its frontend defaults.
let peer_knows_theme_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 16);
// Themes stage 2 Q#F4 — FontFacts gated at v17; a v16
// peer simply keeps its built-in font.
let peer_knows_font_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 17);
// Q#SL7 — independently gate the v18 statusline variant
// even though the semantic producer also skips callbacks
// and message construction for older peers.
let negotiated_protocol_version = session_registry
.session_state(*fid)
.map_or(0, |s| s.negotiated_protocol_version);
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;
}
if !peer_knows_theme_facts && matches!(msg, InstanceMessage::ThemeFacts { .. })
{
continue;
}
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
// Vterm Stage 3 — TerminalFrame gated at v19. A v18
// semantic peer keeps the empty identity snapshot and
// no terminal surface; a v18 grid peer is unaffected
// because it composes terminal windows into its own
// CellDelta.
if !peer_accepts_terminal_message(negotiated_protocol_version, msg) {
continue;
}
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
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
&& !terminal_mode
&& session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
&& let Some((buffer_id, byte_pos)) = document_cursor_byte(editor, *fid)
{
let cursor_byte_msg = InstanceMessage::CursorByte {
buffer_id,
byte_pos,
};
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);
terminal_bell_baselines.remove(fid);
editor.detach_frontend_input(*fid);
session_registry.unregister_session(*fid);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(*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 terminal_bell_baselines,
&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 terminal_bell_baselines,
&mut session_registry,
);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
// Accepted terminal context controls PTY size. Apply any focus,
// window, or resize changes before consuming another child-output
// batch so screen reflow and subsequent bytes share one geometry.
sync_terminal_layouts_for_tick(editor, &attached_fids, &term_sizes, &semantic_states);
// `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.
fn take_pending_terminal_bell(
editor: &EditorState,
frontend_id: FrontendId,
baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
) -> bool {
let buffer_id = editor
.core
.borrow()
.active_window_for(frontend_id)
.map(|window| window.buffer_id);
let Some((buffer_id, count)) = buffer_id.and_then(|buffer_id| {
editor
.terminal_manager
.borrow()
.bell_count(buffer_id)
.map(|count| (buffer_id, count))
}) else {
baselines.remove(&frontend_id);
return false;
};
match baselines.get_mut(&frontend_id) {
Some((baseline_buffer, delivered))
if *baseline_buffer == buffer_id && count > *delivered =>
{
*delivered += 1;
true
}
Some((baseline_buffer, delivered)) if *baseline_buffer == buffer_id => {
*delivered = count;
false
}
_ => {
baselines.insert(frontend_id, (buffer_id, count));
false
}
}
}
struct OpenedInitialTarget {
buffer_id: crate::buffer::BufferId,
publish_to_replicas: bool,
}
struct InitialTargetSnapshot {
crdt_snapshot: Vec<u8>,
upgraded_to_crdt: bool,
}
fn resolve_initial_target(target: InitialTarget) -> PathBuf {
let cwd = PathBuf::from(OsString::from_vec(target.cwd));
let path = PathBuf::from(OsString::from_vec(target.path));
let absolute = if path.is_absolute() {
path
} else {
cwd.join(path)
};
crate::editor_core::lexical_normalize(&absolute)
}
fn open_initial_target(
editor: &mut EditorState,
frontend_id: FrontendId,
target: InitialTarget,
) -> Result<OpenedInitialTarget, String> {
let path = resolve_initial_target(target);
// Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's
// ORIGINAL document window before any I/O. A startup hook may now
// create and select a side window, and bootstrap must reassert the
// requested buffer in a document window rather than overwriting a
// panel merely because it became `view.active`.
let (origin_window, resolved) = {
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
let origin_window = core
.primary_document_window(frontend_id)
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
let resolved = core.resolve_target_buffer(&path)?;
(origin_window, resolved)
};
// Journey Stage 1a (Q#JR6/Q#JR9): a DIRECTORY installs nothing.
//
// Nothing can be installed, because the listing that satisfies a
// directory open is asynchronous and this block is synchronous — the
// frontend is blocked on `InitialTargetResult` and will not create
// its window until it arrives, so there is no tick in which a
// listing could settle. The reply therefore names the buffer the
// fresh view's document window ALREADY holds, which is a valid,
// ready session; the listing replaces it a tick or more later.
//
// That buffer is NOT necessarily `*scratch*`: `build_fresh_frontend_view`
// clones LOCAL's primary document buffer. If LOCAL holds a real
// document, this session briefly displays and snapshots it. Accepted
// and documented rather than papered over with a placeholder buffer,
// which would need reaping and would be fought by the reassert below.
//
// `publish_to_replicas` is false for the same reason an `AfterSwitch`
// dedup sets it false: this buffer is pre-existing and already
// published, not freshly loaded here.
let (buffer_id, fire) = match resolved {
crate::editor_core::ResolvedTarget::Directory { path } => {
let dest = editor
.capture_directory_destination(frontend_id, origin_window)
.ok_or_else(|| format!("cannot open {}: no document window", path.display()))?;
editor.dispatch_directory_open(&path, dest);
editor.reconcile_panel_layout(frontend_id);
// The reply must name what the window ACTUALLY holds now, not
// what it held before the dispatch.
//
// The chain runs synchronously. dired's handler defers (it
// spawns a coroutine for the listing), but a user's resolver
// is under no such obligation: a handler that opens something
// synchronously -- through `commit_to`, which is exactly the
// supported way to do it -- has already replaced this
// window's buffer by the time we get here. Reporting the
// captured id would then send the snapshot of one buffer and
// the identity of another, and the frontend would render a
// document nobody asked for.
//
// Re-reading also covers the case a hook closed the window,
// which is why this rehomes through `non_side_target` exactly
// as the file arm's reassert does rather than returning early
// and skipping that check.
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
let destination = if core
.views
.get(&frontend_id)
.is_some_and(|view| view.layout.iter_ids().contains(&origin_window))
{
origin_window
} else {
core.non_side_target(frontend_id)
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?
};
core.focus_window(frontend_id, destination);
let buffer_id = core
.windows
.get(&destination)
.map(|window| window.buffer_id)
.ok_or_else(|| format!("cannot reselect {}: window died", path.display()))?;
return Ok(OpenedInitialTarget {
buffer_id,
// False whether or not the chain replaced the buffer: an
// untouched destination is pre-existing and already
// published, and a buffer a synchronous handler installed
// went through the ordinary display path, which publishes
// on its own terms.
publish_to_replicas: false,
});
}
crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire),
};
{
let mut core = editor.core.borrow_mut();
core.install_buffer_in_window(origin_window, buffer_id)
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
core.focus_window(frontend_id, origin_window);
}
match fire {
crate::editor_core::HookKind::AfterLoad => {
editor
.lua_host
.run_hook("buffer.after-load", mlua::MultiValue::new());
}
// Dedup is a logical switch even when the fresh view already shares
// this BufferId; configuration must observe it exactly once.
crate::editor_core::HookKind::AfterSwitch => {
editor
.lua_host
.run_hook("buffer.after-switch", mlua::MultiValue::new());
}
crate::editor_core::HookKind::None => {}
}
editor.reconcile_panel_layout(frontend_id);
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
if !core.registry.borrow().contains(buffer_id) {
return Err(format!(
"initial target {} was removed by a startup hook",
path.display()
));
}
// Reassert into the original document window when it is still live;
// if a hook closed it, rehome to an eligible non-side window in the
// same frontend WITHOUT firing a second hook.
let destination = if core
.views
.get(&frontend_id)
.is_some_and(|view| view.layout.iter_ids().contains(&origin_window))
{
origin_window
} else {
core.non_side_target(frontend_id)
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?
};
core.install_buffer_in_window(destination, buffer_id)
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?;
core.focus_window(frontend_id, destination);
Ok(OpenedInitialTarget {
buffer_id,
publish_to_replicas: matches!(
fire,
crate::editor_core::HookKind::AfterLoad | crate::editor_core::HookKind::None
),
})
}
#[cfg(feature = "crdt")]
fn initial_target_snapshot(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
) -> Result<InitialTargetSnapshot, String> {
let core = editor.core.borrow();
let mut registry = core.registry.borrow_mut();
let buffer = registry
.get_mut(buffer_id)
.map_err(|error| format!("initial target buffer disappeared: {error}"))?;
let upgraded_to_crdt = !buffer.is_crdt_backed();
if upgraded_to_crdt {
let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
buffer
.upgrade_to_crdt(peer_id)
.map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?;
}
let crdt_snapshot = buffer
.crdt_state()
.ok_or_else(|| "initial target CRDT state is unavailable".to_owned())?
.export_snapshot()
.map_err(|error| format!("initial target snapshot export failed: {error:?}"))?;
Ok(InitialTargetSnapshot {
crdt_snapshot,
upgraded_to_crdt,
})
}
#[cfg(not(feature = "crdt"))]
fn initial_target_snapshot(
_editor: &EditorState,
_buffer_id: crate::buffer::BufferId,
) -> Result<InitialTargetSnapshot, String> {
Err("initial target requires a CRDT-enabled daemon".to_owned())
}
#[allow(clippy::too_many_arguments)]
fn cleanup_provisional_session(
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_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
session_registry: &mut SessionRegistry,
frontend_id: FrontendId,
) {
render_states.remove(&frontend_id);
semantic_states.remove(&frontend_id);
if let Some(stream) = streams.remove(&frontend_id) {
let _ = stream.shutdown(Shutdown::Both);
}
term_sizes.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "one session bootstrap transaction"
)]
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>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
session_registry: &mut SessionRegistry,
frontend_id: FrontendId,
session_state: crate::presence::SessionState,
initial_size: CellSize,
initial_target: Option<InitialTarget>,
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).
//
// Arc 6 Stage 2 (Q#FD21): the projection is decided in this same
// attach transaction and from the same bit that selects a grid
// `RenderState` vs a `SemanticRenderState` below — a grid session
// collapses folds, a semantic one keeps raw-line reckoning until
// Stage 3.
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
// negotiated bit in this same transaction. Stage 1 ships the TUI
// side windows only, so a semantic session is not panel-capable and
// a `side` request falls back to its document target with every
// side-specific parameter discarded.
let fresh_view = build_fresh_frontend_view(
editor,
!session_state.negotiated_capabilities.semantic_render,
peer_declared_panel_support(session_state),
);
{
let mut core = editor.core.borrow_mut();
core.register_frontend_view(frontend_id, fresh_view);
core.active_frontend = frontend_id;
}
let opened_target = match initial_target {
Some(target) => match open_initial_target(editor, frontend_id, target) {
Ok(opened) => Some(opened),
Err(message) => {
send_initial_target_failure(&mut write_stream, message);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
},
None => None,
};
let crdt_replica = session_state.negotiated_capabilities.crdt_replica;
let semantic_render = session_state.negotiated_capabilities.semantic_render;
let negotiated_protocol_version = session_state.negotiated_protocol_version;
if let Some(opened) = opened_target.as_ref() {
let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) {
Ok(snapshot) => snapshot,
Err(message) => {
send_initial_target_failure(&mut write_stream, message);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
};
let snapshot_message = InstanceMessage::BufferSnapshot {
buffer_id: opened.buffer_id,
crdt_snapshot: target_snapshot.crdt_snapshot,
};
if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt {
publish_buffer_snapshot_to_replicas(
editor,
opened.buffer_id,
&snapshot_message,
session_registry,
streams,
semantic_states,
);
}
if write_message(&mut write_stream, &snapshot_message).is_err() {
let _ = write_stream.shutdown(Shutdown::Both);
editor
.core
.borrow_mut()
.unregister_frontend_view(frontend_id);
return;
}
} else if crdt_replica {
// Legacy no-target attach remains an all-buffer replica bootstrap.
send_buffer_snapshots(editor, &mut write_stream);
}
session_registry.register_session(frontend_id, session_state);
if semantic_render {
semantic_states.insert(
frontend_id,
crate::semantic_render::SemanticRenderState::for_peer(
frontend_id,
negotiated_protocol_version,
),
);
} 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);
// Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its
// authoritative geometry declaration, cached BEFORE any input can
// reach it. A semantic session deliberately stays UNKNOWN — Stage 2's
// authenticated `FrontendCellGeometry` fills it, and the permanent
// 24x80 attach placeholder is never consulted for panel layout.
if editor.core.borrow().panel_capable_for(frontend_id) {
editor.sync_frame_geometry(frontend_id, initial_size);
}
if let Some(opened) = opened_target {
last_active_buffer_sent.insert(frontend_id, opened.buffer_id);
let result = InstanceMessage::InitialTargetResult(InitialTargetResult::Opened {
buffer_id: opened.buffer_id,
});
let write_result = {
let stream = streams
.get_mut(&frontend_id)
.expect("new session stream installed");
write_message(stream, &result)
};
if write_result.is_err() {
cleanup_provisional_session(
editor,
render_states,
semantic_states,
streams,
term_sizes,
last_active_buffer_sent,
session_registry,
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>,
terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
session_registry: &mut SessionRegistry,
) {
match event {
DispatcherEvent::SessionEstablished {
frontend_id,
session_state,
initial_size,
initial_target,
write_stream,
} => {
handle_session_established(
editor,
render_states,
semantic_states,
streams,
term_sizes,
last_active_buffer_sent,
session_registry,
frontend_id,
session_state,
initial_size,
initial_target,
write_stream,
);
}
DispatcherEvent::FrontendEvent { source, event } => {
if session_registry.session_state(source).is_none() {
eprintln!("pmacs: dropping frontend event from uninstalled session {source:?}");
return;
}
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;
}
// Bottom-panel arc (Q#BP2b): a frame that can no
// longer satisfy the panel hides it, moves focus out,
// and releases its terminal controller here — before
// the next drained event dispatches.
if editor.core.borrow().panel_capable_for(source) {
editor.sync_frame_geometry(source, 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.
// Vterm Stage 3 — a v19 frontend declares BOTH a
// byte viewport and a terminal cell size after every
// snapshot, because an empty terminal identity
// snapshot does not announce itself as a terminal.
// The daemon keeps only the declaration appropriate
// to the authenticated source's ACTIVE buffer.
//
// Keying on the active buffer rather than the
// declared one is load-bearing: `Viewport` also
// ALIGNS the window to the buffer it names, so a
// stale document viewport still in flight when a
// command opens a terminal would drag the frontend
// straight back off it. The declared buffer is
// checked too — a terminal has no byte viewport to
// honor from any direction.
// Bottom-panel §1.3 #9 — Projection. The gate asks
// "is this frontend's DOCUMENT surface a terminal",
// so it tests the primary document window. A focused
// TERMINAL PANEL must not suppress the still-visible
// document's viewport.
let terminal_context = {
let manager = editor.terminal_manager.borrow();
let core = editor.core.borrow();
let active = core
.primary_document_buffer(source)
.is_some_and(|document| manager.is_terminal(document));
active || manager.is_terminal(buffer_id)
};
if semantic_states.contains_key(&source) && !terminal_context {
// 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.
// Bottom-panel §1.3 #7 — Projection, and it must
// NOT move focus. Routing this through the
// focused window would let an ordinary document
// viewport overwrite a focused panel's buffer.
align_primary_document_window(editor, source, buffer_id);
if let Some(sem) = semantic_states.get_mut(&source) {
sem.set_viewport(buffer_id, visible, generation);
}
}
}
FrontendEvent::TerminalResize {
buffer_id, size, ..
} => {
// Vterm Stage 3 — the terminal half of the dual
// declaration. Routed by the authenticated `source`;
// the payload's `frontend_id` is never read.
//
// Recording the geometry is what lets a PASSIVE view
// receive its own clipped/padded projection, so the
// record happens for any accepted declaration. Only
// the durable controller's declaration reaches the
// shared PTY — a declaration never claims control.
if semantic_states.contains_key(&source)
&& peer_declared_terminal_support(session_registry, source)
&& editor.semantic_terminal_declaration_is_active(source, buffer_id)
{
if let Some(sem) = semantic_states.get_mut(&source) {
sem.set_terminal_viewport(buffer_id, size);
}
editor.sync_semantic_terminal_layout(source, buffer_id, size);
}
}
FrontendEvent::TerminalPointer {
buffer_id,
coord,
kind,
mods,
..
} => {
// Vterm Stage 3 — a terminal-cell gesture. The
// adapter re-derives the window from the
// authenticated source and checks the coordinate
// against the geometry that source declared, so a
// forged id, a stale buffer, a missing declaration,
// or an out-of-bounds cell all drop before any view,
// controller, selection, menu, or PTY mutation.
if semantic_states.contains_key(&source)
&& peer_declared_terminal_support(session_registry, source)
{
editor.dispatch_semantic_terminal_pointer(
source, buffer_id, coord, kind, mods,
);
}
}
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) {
// Bottom-panel §1.3 #8 — Projection + focus. A
// click in the DOCUMENT area means "work here",
// so unlike `Viewport` (#7) this one also takes
// focus out of a panel.
align_and_activate_primary_document_window(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::FocusGained(_) => {
editor.dispatch_focus(source, true);
}
FrontendEvent::FocusLost(_) => {
editor.dispatch_focus(source, false);
}
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.
if !editor.dispatch_paste(source, &data) {
handle_inbound_paste(editor, source, claimed_fid, &data);
}
}
_ => {
let Some(&term_size) = term_sizes.get(&source) else {
eprintln!(
"pmacs: dropping frontend event without size state for {source:?}"
);
return;
};
let mut term_size = term_size;
if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, source, 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, source, event, term_size);
} else {
eprintln!(
"pmacs: dropping frontend event without render state for {source:?}"
);
}
}
}
}
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);
terminal_bell_baselines.remove(&frontend_id);
editor.detach_frontend_input(frontend_id);
session_registry.unregister_session(frontend_id);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(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> {
// Bottom-panel §1.3 #2 — Projection, and the sharpest case in the
// census. The upgrade BROADCASTS a `BufferSnapshot` to every
// replica, so keying it on focus would mean focusing a fresh
// generated panel buffer swaps every peer's document mirror to it.
// A panel buffer that genuinely needs CRDT backing gets it when it
// is displayed as a document, not as a side effect of focus.
let buffer_id_opt = {
let core = editor.core.borrow();
core.primary_document_buffer(fid)
};
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>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
let Some(snapshot) = export_buffer_snapshot(editor, buffer_id) else {
return;
};
let message = InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot: snapshot,
};
publish_buffer_snapshot_to_replicas(
editor,
buffer_id,
&message,
session_registry,
streams,
semantic_states,
);
}
fn publish_buffer_snapshot_to_replicas(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
message: &InstanceMessage,
session_registry: &SessionRegistry,
streams: &mut HashMap<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
for (peer_id, stream) in streams {
let Some(session) = session_registry.session_state(*peer_id) else {
continue;
};
if !session.negotiated_capabilities.crdt_replica {
continue;
}
if session.negotiated_capabilities.semantic_render {
let displays_buffer = peer_displays_buffer_as_document(editor, *peer_id, buffer_id);
if !displays_buffer {
continue;
}
}
if let Err(error) = write_message(stream, message) {
eprintln!(
"pmacs: BufferSnapshot publish for {buffer_id:?} to {peer_id:?} failed: {error}"
);
continue;
}
if let Some(semantic) = semantic_states.get_mut(peer_id) {
semantic.on_buffer_snapshot_sent(buffer_id);
}
}
}
/// 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}");
}
});
}
/// True when `edit` inserted exactly one UTF-8 codepoint: the leading
/// byte's sequence length equals `inserted_len` (kill ring review
/// round 4 — the typed-character classification for optimistic edits).
#[cfg(feature = "crdt")]
fn is_single_codepoint_insert(edit: &crate::rope::Edit) -> bool {
let len = edit.inserted_len;
if !(1..=4).contains(&len) {
return false;
}
let mut first = [0u8; 1];
edit.new_rope
.slice(edit.range.start, edit.range.start + 1, &mut first);
let expected = match first[0] {
b if b < 0x80 => 1,
b if b < 0xC0 => return false, // bare continuation byte
b if b < 0xE0 => 2,
b if b < 0xF0 => 3,
_ => 4,
};
expected == len
}
/// The exact codepoint a single-codepoint insert landed (auto-pairing
/// Q#AP9). Preconditions are [`is_single_codepoint_insert`]'s; the
/// inserted bytes live in the post-edit rope at `range.start`. `None`
/// on malformed UTF-8 (a classification the byte-length check above
/// already rejects, kept fail-closed rather than panicking).
#[cfg(feature = "crdt")]
fn decoded_single_codepoint(edit: &crate::rope::Edit) -> Option<char> {
let len = usize::try_from(edit.inserted_len)
.ok()
.filter(|l| *l <= 4)?;
let mut buf = [0u8; 4];
edit.new_rope.slice(
edit.range.start,
edit.range.start + edit.inserted_len,
&mut buf[..len],
);
std::str::from_utf8(&buf[..len]).ok()?.chars().next()
}
/// 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 EXACTLY ONE codepoint is a typed character —
// Backspace/Delete/Undo produce deletes or larger shapes and
// stay chain-breaks. Decoding the inserted bytes (they are in
// the post-edit rope) rather than trusting `inserted_len`
// alone: a 2-byte insert of "a(" is two ASCII codepoints and
// must NOT classify as typing (review round 4 — it would
// spuriously auto-trigger signature help). Exact provenance on
// the wire op is the named deferred general fix.
let typed_codepoint =
if edit.range.start == edit.range.end && is_single_codepoint_insert(edit) {
core.rotate_command(source, "buffer.self-insert");
// Auto-pairing Q#AP9: the optimistic arm is the second
// typed self-insert producer. The decoded codepoint plus
// this Edit build the same exact provenance record the
// dispatch fallback arms — remote CRDT imports run no
// intercepts, so requested == effective and clean == true.
decoded_single_codepoint(edit)
} else {
None
};
// 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 {
// Q#AI9 (PR #109 round 1): an empty anchor armed at
// the pre-edit cursor must not survive the cursor
// moving off it — otherwise the optimistic paths
// (GPU always; TUI mirror, which tracks no selection
// state) re-arm the type-over that
// `insert_char_over_region`'s no-region clear fixed
// on the dispatch path. Nonempty selections stand:
// the TUI gate's missing type-over check is a named
// deferral, and guessing here would destroy a real
// selection.
if win.selection.map(|sel| sel.anchor) == Some(win.cursor) {
win.selection = None;
}
// 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);
// Auto-pairing Q#AP9: arm the typed-edit record for the one
// after-edit fan-out below — but only when the source's
// active window actually displays the edited buffer, so
// `post_cursor` (set to the optimistic post-edit position in
// the window loop above) is that window's real cursor. A
// synthetic replica editing a background buffer gets no
// record: absence fails closed, silently.
if let Some(ch) = typed_codepoint
&& let Some(wid) = source_active_window_id
&& core
.windows
.get(&wid)
.is_some_and(|w| w.buffer_id == buffer_id)
{
// Revision postcondition anchor: this arm consumes the
// record in the same fan-out (no command body runs after
// the import), so the current revision is trivially the
// post-edit one.
let revision = core
.registry
.borrow()
.get(buffer_id)
.ok()
.map_or(0, crate::buffer::Buffer::revision);
core.typed_edit_set_armed(
source,
crate::editor_core::TypedEditRecord {
buffer: buffer_id,
window: wid,
codepoint: ch,
requested_start: edit.range.start,
requested_end: edit.range.end,
effective_start: edit.range.start,
effective_end: edit.range.end,
inserted_len: edit.inserted_len,
post_cursor: post_edit_cursor,
clean: true,
revision,
},
);
}
// 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());
// Q#AP9: drop any untaken record the moment the fan-out
// returns — the slot must never leak into a later hook run.
editor.core.borrow_mut().typed_edit_clear_armed();
}
// 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.
/// The buffer a semantic frontend DISPLAYS AS ITS DOCUMENT — the
/// buffer-follow / `BufferSnapshot` re-send target (bottom-panel §1.3
/// #1, Projection).
///
/// Not the focused buffer: focusing a panel must re-send no snapshot and
/// must never swap the replica's document mirror. Named as its own
/// function so the rule is pinnable — its only caller is
/// `dispatcher_loop`, which no test can drive.
#[cfg(feature = "crdt")]
fn document_buffer_to_follow(
editor: &EditorState,
fid: FrontendId,
) -> Option<crate::buffer::BufferId> {
editor.core.borrow().primary_document_buffer(fid)
}
/// The `(buffer, byte)` a semantic replica's authoritative `CursorByte`
/// describes (bottom-panel §1.3 #3, Projection).
///
/// Q#BP14's vocabulary split: "active buffer" in the replica is a
/// DOCUMENT-SURFACE term, not an input-focus term, so a focused panel
/// must not retarget the document caret at the panel's buffer.
fn document_cursor_byte(
editor: &EditorState,
fid: FrontendId,
) -> Option<(crate::buffer::BufferId, u64)> {
let core = editor.core.borrow();
let win_id = core.primary_document_window(fid)?;
let window = core.windows.get(&win_id)?;
Some((window.buffer_id, window.cursor))
}
/// Whether `peer_id` displays `buffer_id` on its DOCUMENT surface — the
/// `BufferSnapshot` publication recipient filter (bottom-panel §1.3 #21,
/// Projection).
///
/// Testing the focused window instead would both miss a buffer visible
/// in the document while a panel holds focus, and replace the peer's
/// document mirror for a buffer visible only in a panel.
fn peer_displays_buffer_as_document(
editor: &EditorState,
peer_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> bool {
editor.core.borrow().primary_document_buffer(peer_id) == Some(buffer_id)
}
/// Align a semantic frontend's **primary document window** to the
/// buffer it declared (bottom-panel §1.3 #7, Q#BP14).
///
/// **Never touches `view.active`.** This is why rejecting panel-named
/// events does not fix the *document* event: with a panel focused, an
/// ordinary document `Viewport` routed through the focused window would
/// overwrite the panel's buffer with the document buffer. Returns the
/// window it aligned so the `Pointer` path (#8) can activate it.
fn align_primary_document_window(
editor: &mut EditorState,
fid: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> Option<crate::window::WindowId> {
use crate::text_view::TextView;
let (win_id, text_view) = {
let core = editor.core.borrow();
let win_id = core.primary_document_window(fid)?;
if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) {
return Some(win_id); // Already displaying this buffer.
}
let reg = core.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
// Unknown buffer — leave the window as-is, and report
// FAILURE. Returning the window here would let a stale or
// forged `Pointer` naming a dead buffer take focus out of a
// panel via #8's activation, *before* `dispatch_pointer`
// rejects the mismatched buffer. Alignment did not happen,
// so no caller may treat this as a document gesture.
return None;
};
(win_id, TextView::new(buf))
};
let mut core = editor.core.borrow_mut();
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();
}
Some(win_id)
}
/// Align the primary document window **and take focus to it**
/// (bottom-panel §1.3 #8, Q#BP14).
///
/// A click in the document area means "work here", so it moves focus
/// out of a panel. This is the one place projection and focus
/// legitimately move together — every other Projection consumer must
/// use [`align_primary_document_window`] alone.
fn align_and_activate_primary_document_window(
editor: &mut EditorState,
fid: FrontendId,
buffer_id: crate::buffer::BufferId,
) {
if let Some(win_id) = align_primary_document_window(editor, fid, buffer_id) {
editor.core.borrow_mut().focus_window(fid, win_id);
}
}
fn build_fresh_frontend_view(
editor: &mut EditorState,
// Arc 6 Stage 2 (Q#FD21, Bet B8): whether this session's display
// collapses folds. Passed explicitly from the negotiated
// selected-render bit at the call site — never inferred here.
fold_projection: bool,
// Bottom-panel arc (Q#BP13): whether this session can RENDER a side
// window. Same explicit-at-the-call-site discipline as
// `fold_projection`; never inferred from a `FrontendId` here.
panel_capable: bool,
) -> 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.
//
// Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT
// buffer, not `local_view.active`. A TUI panel may own focus at
// attach time, and panel content must never become a newly attached
// frontend's full-window document.
let buffer_id = core
.primary_document_buffer(FrontendId::LOCAL)
.expect("LOCAL always retains a document window");
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,
fold_projection,
panel_capable,
// Grid sessions cache their real attach/resize size; a semantic
// session stays UNKNOWN until Stage 2's authenticated
// declaration, and must never be sized against the attach
// request's permanent 24×80 placeholder (Q#BP15a).
frame_geometry: None,
panel_hidden: false,
}
}
/// 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,
}),
}
}
/// One dispatcher tick's terminal-layout step, for every attached frontend.
///
/// Extracted from the dispatcher loop so the grid/semantic exclusivity is
/// **structural** rather than two adjacent `if`s, and so acceptance tests can
/// drive the real loop body instead of re-implementing it (Q#GT1).
///
/// The shape that matters: liveness is frontend-kind NEUTRAL and runs for
/// everyone, exactly once; the geometry arms are EXCLUSIVE alternatives keyed
/// on the same `semantic_states` membership that session establishment uses,
/// so a session can never be caught by both.
///
/// Before this existed, both arms ran for every frontend. A semantic session
/// has a `term_sizes` entry (from `AttachRequest`) *and* a terminal
/// declaration, so its PTY was resized twice per tick, forever: the grid arm
/// installed the TUI placement size, the semantic arm installed the declared
/// content rectangle, and each arm's own idempotence guard saw only the size
/// the other had just written. The child got a `SIGWINCH` storm at tick
/// cadence, which is what made typing into a GPU terminal impossible while
/// output kept flowing.
fn sync_terminal_layouts_for_tick(
editor: &mut EditorState,
attached_fids: &[FrontendId],
term_sizes: &HashMap<FrontendId, CellSize>,
semantic_states: &HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
for frontend_id in attached_fids {
// Neutral half: panel reconciliation (Q#BP2b's only per-tick
// enforcement point) and the release of a controller whose window
// moved away. A semantic frontend gets this from nowhere else —
// its own arm stops running the moment the buffer-follow snapshot
// clears the declaration (Q#GT4/Q#GT7).
editor.sync_terminal_controller_liveness(*frontend_id);
// Geometry: exactly one arm per frontend kind.
if let Some(state) = semantic_states.get(frontend_id) {
// Vterm Stage 3 — the frontend declared a CONTENT rectangle,
// so this consumes the size directly instead of running the
// TUI placement helper, which would subtract a modeline the
// GPU never drew. A semantic frontend with no declaration yet
// gets NO resize at all, which is correct: the terminal keeps
// the geometry it was opened with until one arrives.
if let Some((buffer_id, size)) = state.terminal_viewport() {
editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size);
}
} else if let Some(size) = term_sizes.get(frontend_id).copied() {
editor.sync_terminal_grid_geometry(*frontend_id, size);
}
}
}
/// 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,
source: FrontendId,
ev: FrontendEvent,
term_size: CellSize,
) {
match ev {
FrontendEvent::Key(pmacs_key) => {
if let Some(ct_key) = key_to_crossterm(&pmacs_key) {
editor.dispatch_key(source, ct_key);
}
}
FrontendEvent::Mouse(pmacs_mouse) => {
let ct_mouse = mouse_to_crossterm(&pmacs_mouse);
editor.dispatch_mouse(source, 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,
source: FrontendId,
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(source, 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(source, 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"
);
}
FrontendEvent::TerminalResize { .. } | FrontendEvent::TerminalPointer { .. } => {
// Vterm Stage 3 — the terminal declarations belong to
// semantic sessions and are routed by the authenticated
// source in `handle_dispatcher_event`. A grid session
// resizes its terminal through the Stage 2 layout path, so
// one arriving here is a protocol violation; drop it
// rather than letting a payload-trusted id reach a view.
eprintln!(
"pmacs daemon: terminal declaration from a grid session; dropping \
(grid terminals resize through the Stage 2 layout path)"
);
}
FrontendEvent::FrontendCellGeometry { .. }
| FrontendEvent::PanelResizeRows { .. }
| FrontendEvent::PanelPointer { .. } => {
// Bottom panel Stage 2 — panel declarations belong to
// negotiated panel-capable semantic sessions and are routed
// by the authenticated source in `handle_dispatcher_event`.
// A grid session has no panel band at all, so one arriving
// here is a protocol violation; drop it rather than letting
// a payload-trusted id reach a view.
eprintln!(
"pmacs daemon: panel declaration from a grid session; dropping \
(grid sessions negotiate no panel band)"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::PROTOCOL_VERSION;
#[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 statusline_segments_write_gate_rejects_v17_independently() {
let segments = InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(1),
left: Vec::new(),
right: Vec::new(),
};
assert!(!peer_accepts_statusline_message(17, &segments));
assert!(peer_accepts_statusline_message(18, &segments));
assert!(peer_accepts_statusline_message(
17,
&InstanceMessage::FontFacts {
family: None,
size_centi_px: None,
}
));
}
#[test]
fn terminal_frame_write_gate_rejects_v18_independently() {
let frame = InstanceMessage::TerminalFrame(crate::terminal::TerminalFrame {
buffer_id: crate::buffer::BufferId::from_raw(2),
size: crate::cell::CellSize::new(1, 1),
cells: vec![crate::cell::Cell::default()],
cursor: None,
title: None,
screen_generation: 0,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: crate::terminal::TerminalProcessState::Running,
});
assert!(!peer_accepts_terminal_message(18, &frame));
assert!(peer_accepts_terminal_message(19, &frame));
// The gate is variant-scoped: it must not silence anything else
// on an older wire.
assert!(peer_accepts_terminal_message(
18,
&InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(2),
left: Vec::new(),
right: Vec::new(),
}
));
}
#[test]
fn inbound_terminal_events_require_a_negotiated_v19_session() {
// Review round 2, finding 5: the outbound `TerminalFrame` was
// gated twice (producer + write loop) while the inbound
// declarations relied on the frontend's send gate alone. A
// pre-v19 peer cannot construct these variants, so this only
// refuses a hand-rolled client — but it makes "gated in both
// directions" true of the code, not just of the frontends we
// ship.
let mut registry = SessionRegistry::new();
let semantic = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let old_peer = FrontendId(2);
let new_peer = FrontendId(3);
registry.register_session(
old_peer,
crate::presence::SessionState::new(18, semantic, 0),
);
registry.register_session(
new_peer,
crate::presence::SessionState::new(19, semantic, 1),
);
assert!(!peer_declared_terminal_support(&registry, old_peer));
assert!(peer_declared_terminal_support(&registry, new_peer));
// An unknown session is refused rather than defaulted open.
assert!(!peer_declared_terminal_support(&registry, FrontendId(99)));
}
#[cfg(feature = "crdt")]
#[test]
fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() {
let mut editor = EditorState::new();
let semantic_peer = FrontendId(20);
let live_grid_peer = FrontendId(21);
let dead_grid_peer = FrontendId(22);
let semantic_view = build_fresh_frontend_view(&mut editor, false, false);
editor
.core
.borrow_mut()
.register_frontend_view(semantic_peer, semantic_view);
let semantic_caps = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let grid_caps = crate::protocol::NegotiatedCapabilities {
semantic_render: false,
..semantic_caps
};
let mut registry = SessionRegistry::new();
registry.register_session(
semantic_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0),
);
registry.register_session(
live_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1),
);
registry.register_session(
dead_grid_peer,
crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2),
);
let (semantic_server, mut semantic_client) =
UnixStream::pair().expect("semantic socketpair");
let (live_grid_server, mut live_grid_client) =
UnixStream::pair().expect("live grid socketpair");
let (dead_grid_server, dead_grid_client) =
UnixStream::pair().expect("dead grid socketpair");
drop(dead_grid_client);
let mut streams = HashMap::from([
(semantic_peer, semantic_server),
(live_grid_peer, live_grid_server),
(dead_grid_peer, dead_grid_server),
]);
let published_buffer = crate::buffer::BufferId::from_raw(900);
let message = InstanceMessage::BufferSnapshot {
buffer_id: published_buffer,
crdt_snapshot: vec![1, 2, 3],
};
publish_buffer_snapshot_to_replicas(
&editor,
published_buffer,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
let delivered: InstanceMessage =
read_message(&mut live_grid_client).expect("live grid snapshot");
assert_eq!(delivered, message);
semantic_client
.set_read_timeout(Some(Duration::from_millis(50)))
.expect("semantic timeout");
assert!(
read_message::<InstanceMessage>(&mut semantic_client).is_err(),
"a semantic peer displaying another buffer must receive no snapshot"
);
}
/// Bottom-panel §1.3 #21 through the REAL producer (round 3).
///
/// A semantic peer with a FOCUSED PANEL must still receive the
/// snapshot for the buffer on its DOCUMENT surface, and must NOT
/// receive one for a buffer visible only in its panel. Asserting the
/// helper alone was insufficient: reverting the producer's call site
/// to focused-window routing left every helper-level test green.
#[cfg(feature = "crdt")]
#[test]
fn snapshot_publication_follows_the_document_under_a_focused_panel() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
)
};
assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers");
let caps = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let mut registry = SessionRegistry::new();
registry.register_session(
fid,
crate::presence::SessionState::new(PROTOCOL_VERSION, caps, 0),
);
// The DOCUMENT buffer's snapshot must be delivered.
{
let (server, mut client) = UnixStream::pair().expect("socketpair");
// A read timeout on the DELIVERY read too. Without it a
// regression that suppresses the snapshot makes this test
// HANG rather than fail, which is strictly worse than a red
// assertion — found by biting this very test.
client
.set_read_timeout(Some(Duration::from_millis(500)))
.expect("delivery timeout");
let mut streams = HashMap::from([(fid, server)]);
let message = InstanceMessage::BufferSnapshot {
buffer_id: doc_buf,
crdt_snapshot: vec![1, 2, 3],
};
publish_buffer_snapshot_to_replicas(
&editor,
doc_buf,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
let delivered: InstanceMessage =
read_message(&mut client).expect("the document snapshot must arrive");
assert_eq!(
delivered, message,
"#21: a buffer on the DOCUMENT surface must still be published while a \
panel holds focus"
);
}
// The PANEL-only buffer's snapshot must NOT be delivered.
{
let (server, mut client) = UnixStream::pair().expect("socketpair");
let mut streams = HashMap::from([(fid, server)]);
let message = InstanceMessage::BufferSnapshot {
buffer_id: panel_buf,
crdt_snapshot: vec![4, 5, 6],
};
publish_buffer_snapshot_to_replicas(
&editor,
panel_buf,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
client
.set_read_timeout(Some(Duration::from_millis(50)))
.expect("timeout");
assert!(
read_message::<InstanceMessage>(&mut client).is_err(),
"#21: a buffer visible only in a PANEL must not replace the peer's \
document mirror"
);
}
}
// ---- GPU terminal input: the double terminal-layout sync -------------
//
// These drive `sync_terminal_layouts_for_tick` — the REAL dispatcher loop
// body, not a re-implementation of it. That distinction is the whole
// point: the Stage 3 acceptance sent input through `client.send_key`
// directly and therefore pinned transport rather than routing, which is
// how the defect these pin shipped.
//
// The observable is `TerminalScreen::generation`. It advances once per
// screen mutation, so with a child that produces no output and no
// `tick_processes` call, "generation stopped advancing" is exactly "the
// geometry settled" — a state predicate, not a readout.
/// Open a quiet terminal and give `frontend_id` a view that shows it,
/// holding its controller — the state the dispatcher loop runs against.
fn quiet_terminal_for(
editor: &EditorState,
frontend_id: FrontendId,
) -> (crate::buffer::BufferId, crate::window::WindowId) {
let mut spec = crate::terminal::TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.rows = 24;
spec.cols = 80;
let buffer_id = editor
.terminal_manager
.borrow_mut()
.open(
spec,
&mut editor.core.borrow_mut(),
&mut editor.process_supervisor.borrow_mut(),
)
.expect("open terminal");
let window_id = crate::window::WindowId::next();
{
let mut core = editor.core.borrow_mut();
let text_view = {
let registry = core.registry.clone();
let registry = registry.borrow();
let buffer = registry.get(buffer_id).expect("terminal buffer");
crate::text_view::TextView::new(buffer)
};
core.windows.insert(
window_id,
crate::window::Window::new(window_id, buffer_id, text_view),
);
core.register_frontend_view(
frontend_id,
crate::window::FrontendView {
layout: crate::window::Layout::single(window_id),
active: window_id,
fold_projection: true,
panel_capable: false,
frame_geometry: None,
panel_hidden: false,
},
);
}
let key = crate::terminal::TerminalViewKey::new(frontend_id, window_id, buffer_id);
let mut manager = editor.terminal_manager.borrow_mut();
manager.register_view(key);
manager.claim_controller(key);
(buffer_id, window_id)
}
fn screen_generation(editor: &EditorState, buffer_id: crate::buffer::BufferId) -> u64 {
editor
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("terminal snapshot")
.screen_generation
}
/// Acceptance 2 and 3: one declaration produces exactly one resize, and
/// the screen then STAYS at the declared content rectangle.
///
/// Against the pre-split tree both arms ran for the semantic frontend and
/// generation advanced by two per iteration forever, because each arm's
/// idempotence guard only ever saw the size the other had just written.
#[test]
fn semantic_terminal_geometry_settles_after_one_declaration() {
let fid = FrontendId(41);
let mut editor = EditorState::new();
let (buffer_id, _window) = quiet_terminal_for(&editor, fid);
// The GPU declares a CONTENT rectangle; the grid size it also
// reported at attach is deliberately DIFFERENT, which is the
// collision the defect fed on.
let declared = CellSize::new(25, 92);
let mut semantic = crate::semantic_render::SemanticRenderState::for_peer(fid, 20);
semantic.set_terminal_viewport(buffer_id, declared);
let semantic_states = HashMap::from([(fid, semantic)]);
let term_sizes = HashMap::from([(fid, CellSize::new(24, 80))]);
let attached = vec![fid];
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
let after_first = screen_generation(&editor, buffer_id);
assert_eq!(
editor.terminal_manager.borrow().screen_size(buffer_id),
Some(declared),
"the declared content rectangle must win"
);
// Acceptance 2: every further tick is a no-op.
for _ in 0..8 {
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
}
assert_eq!(
screen_generation(&editor, buffer_id),
after_first,
"an unchanged declaration must not mutate the screen again \
(pre-split: +2 per tick, forever)"
);
// Acceptance 3: the state predicate, not "a frame at this width
// arrived at some point".
assert_eq!(
editor.terminal_manager.borrow().screen_size(buffer_id),
Some(declared),
"the geometry must SETTLE at the declared rectangle"
);
editor.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 6: a semantic frontend whose window switches away releases
/// its terminal controller.
///
/// This bites against BOTH the pre-split tree's sibling arms and against
/// the naive "skip the grid arm for semantic frontends" guard, which is
/// why B1 is recorded as half-false. The release cannot live in
/// `sync_semantic_terminal_layout`: the buffer-follow snapshot clears the
/// viewport declaration, so that arm stops running in exactly this case —
/// modelled here by dropping the declaration alongside the switch.
#[test]
fn semantic_frontend_releases_its_terminal_controller_when_its_window_switches_away() {
let fid = FrontendId(42);
let mut editor = EditorState::new();
let (buffer_id, window_id) = quiet_terminal_for(&editor, fid);
let declared = CellSize::new(25, 92);
let mut semantic = crate::semantic_render::SemanticRenderState::for_peer(fid, 20);
semantic.set_terminal_viewport(buffer_id, declared);
let mut semantic_states = HashMap::from([(fid, semantic)]);
let term_sizes = HashMap::from([(fid, CellSize::new(24, 80))]);
let attached = vec![fid];
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
assert_eq!(
editor
.terminal_manager
.borrow()
.controller_view_for_frontend(fid),
Some(crate::terminal::TerminalViewKey::new(
fid, window_id, buffer_id
)),
"precondition: the frontend holds the controller"
);
// The window switches to a document, and the snapshot that announces
// it clears the semantic declaration — `on_buffer_snapshot_sent`.
let document = editor.core.borrow().registry.borrow_mut().create("doc");
{
let mut core = editor.core.borrow_mut();
let text_view = {
let registry = core.registry.clone();
let registry = registry.borrow();
let buffer = registry.get(document).expect("document buffer");
crate::text_view::TextView::new(buffer)
};
let window = core.windows.get_mut(&window_id).expect("window");
*window = crate::window::Window::new(window_id, document, text_view);
}
semantic_states
.get_mut(&fid)
.expect("semantic state")
.on_buffer_snapshot_sent(document);
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
assert_eq!(
editor
.terminal_manager
.borrow()
.controller_view_for_frontend(fid),
None,
"a semantic frontend that left its terminal must release the \
controller, or no peer can resize that PTY again"
);
editor.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 5 at the unit seam: a GRID frontend still gets its
/// placement-derived resize. The split must not turn the storm fix into
/// "semantic frontends win everywhere".
#[test]
fn grid_terminal_geometry_still_syncs_for_a_grid_frontend() {
let fid = FrontendId(43);
let mut editor = EditorState::new();
let (buffer_id, _window) = quiet_terminal_for(&editor, fid);
let semantic_states = HashMap::new();
let term_sizes = HashMap::from([(fid, CellSize::new(40, 100))]);
let attached = vec![fid];
let before = editor.terminal_manager.borrow().screen_size(buffer_id);
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
let after = editor.terminal_manager.borrow().screen_size(buffer_id);
assert_ne!(before, after, "a grid frontend must still resize its PTY");
assert_eq!(
after.map(|size| size.cols),
Some(100),
"the grid arm supplies the full declared width"
);
// And it too settles.
let settled = screen_generation(&editor, buffer_id);
for _ in 0..4 {
sync_terminal_layouts_for_tick(&mut editor, &attached, &term_sizes, &semantic_states);
}
assert_eq!(
screen_generation(&editor, buffer_id),
settled,
"an unchanged grid size must not mutate the screen again"
);
editor.process_supervisor.borrow_mut().shutdown();
}
#[test]
fn frontend_events_from_uninstalled_sessions_are_dropped_without_state_access() {
let source = FrontendId(77);
let mut editor = EditorState::new();
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
let mut streams = HashMap::new();
let mut term_sizes = HashMap::new();
let mut last_dispatch_idle_sent = HashMap::new();
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source,
event: FrontendEvent::Key(crate::protocol::KeyEvent {
frontend_id: source,
key: crate::protocol::Key::Char('x'),
mods: crate::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
assert_eq!(editor.core.borrow().active_frontend, FrontendId::LOCAL);
assert!(term_sizes.is_empty());
}
#[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"
);
}
/// Arc 8 Stage 4b acceptance 45f: the Lean abbreviation expander
/// works on the OPTIMISTIC producer, not only on `dispatch_key`.
///
/// This is the path most users take and the one no other Stage 4b
/// test covers. `classify_key` (`src/optimistic.rs`) returns
/// `Insert(c)` for `\` and for every ASCII letter — only the nine
/// built-in pair chars are excluded (Q#AP1) — so on a CRDT frontend
/// `\alpha` arrives here as six source-peer optimistic inserts,
/// while the expansion is a single daemon-peer replace spanning all
/// six. That asymmetry is the accepted undo degradation of Q#LN21;
/// what this pins is that the expansion happens at all.
///
/// It lives in `--lib` deliberately: the gate list runs
/// `--features crdt` only for `cargo test --lib`, so a crdt-gated
/// INTEGRATION test would be dark in CI and dark in the gates both.
///
/// The source frontend needs a REGISTERED WINDOW on the edited
/// buffer or nothing is armed at all — `handle_remote_crdt_op`
/// arms the record only when the source's active window displays
/// the buffer, so a source with no view fails closed and silently.
/// A version of this test without the view below passed six
/// fan-outs with a nil record and proved nothing.
#[cfg(feature = "crdt")]
#[test]
fn the_optimistic_producer_also_expands_a_lean_abbreviation() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::{FrontendView, Layout, Window, WindowId};
let dir = std::env::temp_dir().join(format!("pmacs-lean-opt-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("a.lean");
std::fs::write(&path, "").expect("write fixture");
let source = FrontendId(77);
let mut editor = EditorState::new();
editor
.lua_host
.eval(Some("test"), "pmacs.lsp.config = {}")
.expect("clear lsp config");
editor
.lua_host
.eval(
Some("test-open"),
&format!(
"pmacs.buffer.find_or_open({:?}); pmacs.editor.goto_byte(0)",
path.display().to_string()
),
)
.expect("open the lean fixture");
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let mut core = editor.core.borrow_mut();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
drop(reg);
// The replica's own window on the shared buffer.
let text_view = {
let registry = core.registry.clone();
let reg = registry.borrow();
crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer"))
};
let win_id = WindowId::next();
core.windows
.insert(win_id, Window::new(win_id, buffer_id, text_view));
core.register_frontend_view(
source,
FrontendView {
layout: Layout::single(win_id),
active: win_id,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
}
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(77).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
// One op per keystroke, exactly as the attach loop's
// optimistic-apply branch produces them.
for (i, ch) in "\\alpha".chars().enumerate() {
let v_before = peer.oplog_vv();
peer.get_text("body")
.insert(i, &ch.to_string())
.expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
super::handle_remote_crdt_op(
&mut editor,
source,
buffer_id,
crate::rope::CrdtOp {
peer_id: 77,
bytes: op_bytes,
},
);
}
let text = match editor
.lua_host
.eval(
Some("test-readback"),
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
)
.expect("read buffer text")
{
mlua::Value::String(s) => String::from_utf8_lossy(&s.as_bytes()).into_owned(),
other => panic!("expected buffer text, got {other:?}"),
};
assert_eq!(
text, "α",
"the abbreviation expanded on the optimistic path — the \
record the expander reads is armed by handle_remote_crdt_op, \
not only by dispatch_key"
);
}
/// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an
/// EMPTY anchor on the source window — the GPU always takes this
/// path, and the TUI attach mirror tracks no selection state, so
/// neither frontend's gate stops an armed-empty-anchor sequence
/// from re-creating the type-over that
/// `insert_char_over_region`'s no-region clear fixed on the
/// dispatch path. A NONEMPTY selection must survive untouched
/// (the TUI gate's missing type-over check is a named deferral).
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_clears_only_an_empty_source_anchor() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::Selection;
// Shared fixture: CRDT-backed active buffer + a peer doc that
// produces the optimistic op, sourced from LOCAL (which has a
// registered view, so the source-window arm runs).
fn apply_peer_insert(editor: &mut EditorState, buffer_id: crate::buffer::BufferId) {
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(u64::from(FrontendId::LOCAL.0))
.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");
super::handle_remote_crdt_op(
editor,
FrontendId::LOCAL,
buffer_id,
crate::rope::CrdtOp {
peer_id: FrontendId::LOCAL.0,
bytes: op_bytes,
},
);
}
// Case 1: empty anchor at the cursor (S-Left-at-BOF shape) —
// cleared by the optimistic apply.
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 mut core = editor.core.borrow_mut();
let at = core.active_window().cursor;
core.active_window_mut().selection = Some(Selection { anchor: at });
}
apply_peer_insert(&mut editor, buffer_id);
{
let core = editor.core.borrow();
assert!(
core.active_window().selection.is_none(),
"an empty anchor must not survive an optimistic source edit"
);
assert_eq!(
core.active_window().cursor,
1,
"cursor at post-edit position"
);
}
// Case 2: nonempty selection — the arm must not touch it.
let mut editor = EditorState::new();
let buffer_id = editor.core.borrow().active_window().buffer_id;
editor.core.borrow_mut().insert_char('a');
editor.core.borrow_mut().insert_char('b');
{
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 mut core = editor.core.borrow_mut();
core.active_window_mut().selection = Some(Selection { anchor: 0 });
// cursor is at 2 after the two inserts: nonempty region.
}
apply_peer_insert(&mut editor, buffer_id);
{
let core = editor.core.borrow();
assert_eq!(
core.active_window().selection,
Some(Selection { anchor: 0 }),
"a nonempty selection survives the optimistic source 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"
);
drop(core);
// A TWO-codepoint insert ("a(") must NOT classify as typing
// (review round 4): its 2-byte length satisfies a naive 1-4
// predicate, but decoding shows two ASCII codepoints — a typed
// key never produces that, and classifying it would let a
// multi-char op spuriously auto-trigger signature help.
editor
.core
.borrow_mut()
.rotate_command(source, "edit.kill-line");
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 peer2 = loro::LoroDoc::new();
peer2.set_peer_id(7).expect("set peer id");
peer2.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer2.oplog_vv();
peer2.get_text("body").insert(0, "a(").expect("peer insert");
let op_bytes = peer2
.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();
assert_eq!(
core.command_history
.get(&source)
.and_then(|b| b.this.as_deref()),
None,
"a multi-codepoint insert breaks the chain instead of classifying as typing"
);
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"
);
}
#[test]
fn forged_resize_mutates_only_the_authenticated_frontend() {
let source = FrontendId(41);
let forged = FrontendId(42);
let old_size = CellSize::new(24, 80);
let new_size = CellSize::new(31, 97);
let mut editor = EditorState::new();
let mut render_states = HashMap::from([
(source, RenderState::new(old_size)),
(forged, RenderState::new(old_size)),
]);
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::from([(source, old_size), (forged, old_size)]);
let mut last_dispatch_idle_sent = HashMap::new();
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
session_registry.register_session(
source,
crate::presence::SessionState::new(
PROTOCOL_VERSION,
crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
semantic_render: false,
},
0,
),
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source,
event: FrontendEvent::Resize {
frontend_id: forged,
size: new_size,
},
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
assert_eq!(render_states[&source].size(), new_size);
assert_eq!(term_sizes[&source], new_size);
assert_eq!(render_states[&forged].size(), old_size);
assert_eq!(term_sizes[&forged], old_size);
}
#[test]
fn terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once() {
let mut editor = EditorState::new();
let mut spec = crate::terminal::TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
"printf '\\a'; IFS= read -r _; printf '\\a'; sleep 30".into(),
];
let buffer_id = editor
.terminal_manager
.borrow_mut()
.open(
spec,
&mut editor.core.borrow_mut(),
&mut editor.process_supervisor.borrow_mut(),
)
.expect("open bell probe");
editor
.core
.borrow_mut()
.switch_active_buffer_for(FrontendId::LOCAL, buffer_id)
.expect("display bell probe");
let deadline = Instant::now() + Duration::from_secs(5);
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(1) {
editor.tick_processes();
assert!(Instant::now() < deadline, "initial terminal bell timed out");
thread::sleep(Duration::from_millis(10));
}
let mut baselines = HashMap::new();
assert!(!take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
editor
.terminal_manager
.borrow()
.send(
buffer_id,
b"\n",
&mut editor.process_supervisor.borrow_mut(),
)
.expect("advance bell probe");
let deadline = Instant::now() + Duration::from_secs(5);
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(2) {
editor.tick_processes();
assert!(Instant::now() < deadline, "second terminal bell timed out");
thread::sleep(Duration::from_millis(10));
}
assert!(take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
assert!(!take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
editor
.terminal_manager
.borrow_mut()
.terminate(buffer_id, &mut editor.process_supervisor.borrow_mut())
.expect("terminate bell probe");
}
/// 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,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
}
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);
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
// projection until Stage 3).
let view = build_fresh_frontend_view(&mut editor, false, false);
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,
fid,
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_primary_document_window`
/// 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);
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
// projection until Stage 3).
let view = build_fresh_frontend_view(&mut editor, false, false);
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.
// Bottom-panel §1.3 #7: `Viewport` takes the projection-only
// aligner, which never touches `view.active`.
align_primary_document_window(&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,
fid,
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"
);
}
/// Bottom-panel arc, §1.3 #22 (framing acceptance 51's Stage-1 half).
///
/// A fresh no-target attach clones `LOCAL`'s **primary document**
/// buffer, not `local_view.active`. Stage 1 makes a TUI panel a real
/// focus target, so `LOCAL` can legitimately own focus in a panel at
/// attach time — and panel content must never become a newly attached
/// frontend's full-window document.
#[test]
fn fresh_attach_inherits_locals_document_buffer_not_its_focused_panel() {
let mut editor = EditorState::new();
let document_buffer = editor.core.borrow().active_buffer_id();
let panel_buffer = editor.core.borrow().registry.borrow_mut().create("*panel*");
// Open a bottom panel on LOCAL and focus it.
let panel = {
let mut core = editor.core.borrow_mut();
let mut request = crate::editor_core::DisplayRequest::new(panel_buffer);
request.side = Some(crate::window::Side::Bottom);
request.height = Some(5);
request.select = Some(true);
let outcome = core
.display_buffer(FrontendId::LOCAL, &request)
.expect("panel placement");
core.focus_window(FrontendId::LOCAL, outcome.target);
outcome.target
};
assert_eq!(
editor.core.borrow().views[&FrontendId::LOCAL].active,
panel,
"LOCAL really is focused in the panel"
);
let fid = FrontendId(123);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
assert_eq!(
editor
.core
.borrow()
.active_window_for(fid)
.expect("fresh view window")
.buffer_id,
document_buffer,
"the new frontend inherited LOCAL's DOCUMENT buffer; inheriting \
`local_view.active` would have made the panel its document"
);
assert_ne!(document_buffer, panel_buffer);
}
/// Bottom-panel arc, Q#BP11b / R4-B4 (framing acceptance 55's
/// Stage-1 half).
///
/// Stage 1 lets a startup hook create and select a side window. The
/// initial-target bootstrap must still reassert the requested buffer
/// in — and activate — a **non-side** document window, rather than
/// overwriting the panel merely because it became `view.active`.
#[test]
fn initial_target_reasserts_a_document_window_when_a_hook_selects_a_panel() {
use std::os::unix::ffi::OsStrExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("target.txt");
std::fs::write(&target, b"target contents\n").expect("write target");
let mut editor = EditorState::new();
editor
.lua_host
.lua()
.load(
r#"
pmacs.lsp.config = {}
pmacs.hook.add("buffer.after-load", function()
if HOOK_RAN then return end
HOOK_RAN = true
HOOK_PANEL = pmacs.window.display(
pmacs.buffer.create("*hook-panel*"),
{ side = "bottom", height = 4, select = true })
end)
"#,
)
.exec()
.expect("install hook");
// A GRID session (panel-capable), which is the realistic shape
// for a hook-created panel in Stage 1 — and real geometry, so
// the panel is genuinely VISIBLE and focused when the reassert
// runs. Without the declaration, reconciliation would hide the
// panel and move focus out on its own, and the assertions below
// would pass without exercising the reassert at all.
let fid = FrontendId(124);
let view = build_fresh_frontend_view(&mut editor, true, true);
editor.core.borrow_mut().register_frontend_view(fid, view);
editor.sync_frame_geometry(fid, CellSize::new(24, 80));
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: target.as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("bootstrap succeeds despite the panel-creating hook");
let core = editor.core.borrow();
assert!(
!core.views[&fid].panel_hidden,
"the hook's panel is visible, so focus really was on it when \
the reassert ran"
);
let active = core.views[&fid].active;
let active_window = core.windows.get(&active).expect("active window live");
assert!(
!active_window.is_side(),
"bootstrap activated a DOCUMENT window, not the hook's panel"
);
assert_eq!(
active_window.buffer_id, opened.buffer_id,
"…showing the requested target"
);
let panel = core
.side_window_for(fid)
.expect("the hook's panel survived");
assert_ne!(
core.windows[&panel].buffer_id, opened.buffer_id,
"the panel was not overwritten with the target"
);
}
/// Bottom-panel §1.3 #8, review round 1 finding 1: a STALE document
/// `Pointer` must not steal focus out of a panel.
///
/// Driven through `handle_dispatcher_event` — the real dispatcher
/// seam — because the defect lived in the *pair* of alignment and
/// activation, not in either alone. `align_primary_document_window`
/// once returned the window even when the named buffer was gone, so
/// #8's activation focused the document before `dispatch_pointer`
/// ever rejected the mismatched buffer.
#[cfg(feature = "crdt")]
#[test]
fn a_stale_document_pointer_does_not_steal_focus_from_a_panel() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
use pmacs_protocol::{Modifiers, PointerKind};
let mut editor = EditorState::new();
let fid = FrontendId(88);
// One document window + one focused bottom panel.
let (document, panel, dead_buffer) = {
let mut core = editor.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
// A buffer id that names nothing: the stale-pointer payload.
let dead_buffer = crate::buffer::BufferId::from_raw(999_999);
let document = crate::window::WindowId::next();
let panel_id = crate::window::WindowId::next();
let (doc_view, panel_view) = {
let reg = core.registry.borrow();
(
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")),
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")),
)
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let mut panel = Window::new(panel_id, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(crate::window::Side::Bottom);
params.fixed_rows = Some(4);
panel.params = params;
core.windows.insert(panel_id, panel);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel_id)],
weights: vec![1, 1],
},
},
active: panel_id,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel_id, dead_buffer)
};
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid));
let mut streams = HashMap::new();
let mut term_sizes = HashMap::new();
term_sizes.insert(fid, CellSize::new(24, 80));
let mut last_idle = HashMap::new();
let mut last_active = HashMap::new();
let mut bells = HashMap::new();
// The dispatcher drops any event from an UNINSTALLED session
// (#148's defense-in-depth membership check), so the session must
// be registered or this test passes for the wrong reason — it did,
// on the first attempt.
let mut registry = SessionRegistry::new();
registry.register_session(
fid,
crate::presence::SessionState {
negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION,
negotiated_capabilities: crate::protocol::NegotiatedCapabilities {
semantic_render: true,
crdt_replica: true,
..Default::default()
},
color_slot: 0,
},
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source: fid,
event: FrontendEvent::Pointer {
frontend_id: fid,
buffer_id: dead_buffer,
byte: 0,
kind: PointerKind::Down,
mods: Modifiers::default(),
},
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_idle,
&mut last_active,
&mut bells,
&mut registry,
);
assert_eq!(
editor.core.borrow().views[&fid].active,
panel,
"a stale Pointer naming a dead buffer must NOT move focus out of the panel"
);
assert_ne!(
editor.core.borrow().views[&fid].active,
document,
"non-vacuity: the document window is a real, distinct focus target"
);
}
/// **N2** (Journey Stage 1a) — a DIRECTORY initial target reaches
/// readiness instead of failing.
///
/// This deliberately supersedes the directory half of the GPU
/// initial-target framing's Q#GT6 and its acceptance 10, which
/// required `IsADirectory` to fail before window creation.
/// Permission-denied and every other pre-readiness failure keep that
/// contract.
#[test]
fn initial_target_directory_reaches_ready() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write");
let mut editor = EditorState::new();
editor
.lua_host
.lua()
.load("pmacs.lsp.config = {}")
.exec()
.expect("wipe lsp config");
let fid = FrontendId(131);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a directory target must reach readiness, not fail");
// The reply names a live buffer in a live document window: a
// valid, ready session. The listing arrives later, asynchronously.
let core = editor.core.borrow();
assert!(
core.registry.borrow().contains(opened.buffer_id),
"the reported buffer must exist so its snapshot can be sent"
);
let active = core.views[&fid].active;
assert_eq!(
core.windows[&active].buffer_id, opened.buffer_id,
"the reported buffer is the one the document window shows"
);
}
/// **N5** — the bootstrap buffer is not necessarily `*scratch*`.
///
/// `build_fresh_frontend_view` clones LOCAL's PRIMARY DOCUMENT
/// buffer, so when LOCAL holds a real document the fresh session
/// briefly displays and snapshots it. Q#JR9 accepts that rather than
/// introducing a placeholder; this observes it instead of assuming.
#[test]
fn initial_target_directory_reports_a_non_scratch_primary() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
let doc = dir.path().join("already-open.txt");
std::fs::write(&doc, b"local document\n").expect("write");
// LOCAL holds a real document, not scratch.
let mut editor = EditorState::open(doc.clone()).expect("open");
editor
.lua_host
.lua()
.load("pmacs.lsp.config = {}")
.exec()
.expect("wipe lsp config");
let local_primary = editor
.core
.borrow()
.primary_document_buffer(FrontendId::LOCAL)
.expect("LOCAL always has a document window");
let fid = FrontendId(132);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a directory target must reach readiness");
assert_eq!(
opened.buffer_id, local_primary,
"the bootstrap reply names LOCAL's primary document buffer, \
which is a real document here rather than *scratch*"
);
}
/// **N2b (rev 6)** — a resolver that claims SYNCHRONOUSLY is reported
/// correctly.
///
/// The bug this pins: the arm captured the destination buffer id
/// *before* dispatching the chain and reported that. The chain runs
/// synchronously, so a handler that opens something immediately —
/// through `commit_to`, the supported way — had already replaced the
/// window's buffer, and the reply paired one buffer's snapshot with
/// another's identity.
///
/// Falsified by reporting the captured id instead of re-reading.
#[test]
fn initial_target_directory_reports_what_a_synchronous_handler_installed() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
let mut editor = EditorState::new();
editor
.lua_host
.lua()
.load(
"pmacs.lsp.config = {}
claimed = pmacs.buffer.create('*claimed*')
pmacs.path.set_directory_handler(function(path, dest)
pmacs.window.commit_to(dest, function()
pmacs.window.display(claimed, { select = true })
end)
end)",
)
.exec()
.expect("install a synchronous handler");
let fid = FrontendId(133);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a claimed directory target must reach readiness");
// Compare by NAME: the reported id must be the handler's buffer,
// and naming it is what makes the failure legible when it is not.
let core = editor.core.borrow();
let reported_name = core
.registry
.borrow()
.get(opened.buffer_id)
.expect("the reported buffer exists")
.name()
.to_string();
assert_eq!(
reported_name, "*claimed*",
"the reply must name what the handler installed, not the \
buffer captured before the dispatch"
);
let active = core.views[&fid].active;
assert_eq!(
core.windows[&active].buffer_id, opened.buffer_id,
"…and that buffer is what the window shows"
);
}
/// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose
/// only production caller is `dispatcher_loop`, pinned at the named
/// seams that loop calls. Round 2 finding: reverting any of them to
/// `active_window_for` previously left every test green.
#[cfg(feature = "crdt")]
#[test]
fn tick_producers_describe_the_document_while_a_panel_is_focused() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf, doc_cursor) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
core.windows[&document].cursor,
)
};
assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers");
// #1 buffer-follow / BufferSnapshot re-send target.
assert_eq!(
document_buffer_to_follow(&editor, fid),
Some(doc_buf),
"#1: the follow target must be the DOCUMENT buffer, not the focused panel's"
);
// #3 CursorByte.
assert_eq!(
document_cursor_byte(&editor, fid),
Some((doc_buf, doc_cursor)),
"#3: CursorByte must describe the DOCUMENT surface"
);
// #21 is deliberately NOT asserted here. Round 3: pinning it at
// this helper left the real producer free to regress — reverting
// the call site inside `publish_buffer_snapshot_to_replicas`
// kept both this test and the existing socket-pair test green.
// It is pinned through the producer instead, in
// `snapshot_publication_follows_the_document_under_a_focused_panel`.
let _ = panel_buf;
}
/// Bottom-panel §1.3 #2 — the sharpest census case: the lazy CRDT
/// upgrade BROADCASTS a snapshot, so keying it on focus would let
/// focusing a fresh generated panel buffer swap every peer's mirror.
#[cfg(feature = "crdt")]
#[test]
fn lazy_crdt_upgrade_never_targets_a_focused_panel_buffer() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
)
};
let upgraded = ensure_active_buffer_crdt_backed(&editor, fid);
assert_eq!(
upgraded,
Some(doc_buf),
"#2: the upgrade must target the DOCUMENT buffer"
);
assert_ne!(
upgraded,
Some(panel_buf),
"#2: focusing a panel must never trigger its buffer's upgrade+broadcast"
);
}
/// Bottom-panel §1.3 #7 vs #8 — `Viewport` aligns WITHOUT moving
/// focus; only `Pointer` activates. Driven through the real
/// dispatcher seam.
#[cfg(feature = "crdt")]
#[test]
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = {
let mut core = editor.core.borrow_mut();
core.registry.borrow_mut().create("*other*")
};
dispatch_one_semantic_event(
&mut editor,
fid,
FrontendEvent::Viewport {
frontend_id: fid,
buffer_id: other,
visible: pmacs_protocol::ByteRange { start: 0, end: 0 },
generation: 0,
},
);
assert_eq!(
editor.core.borrow().views[&fid].active,
panel,
"#7: a document Viewport must NOT move focus out of the panel"
);
assert_eq!(
editor.core.borrow().windows[&document].buffer_id,
other,
"#7: it must still have ALIGNED the document window to the declared buffer"
);
}
/// Shared fixture: a semantic frontend with a document window and a
/// FOCUSED bottom panel. `panel_capable` is set explicitly because
/// Stage 1 ships `false` for semantic sessions and 2B flips it for a
/// v21-negotiated peer.
#[cfg(feature = "crdt")]
fn panel_focused_semantic_fixture() -> (
crate::editor::EditorState,
FrontendId,
crate::window::WindowId,
crate::window::WindowId,
) {
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
let editor = crate::editor::EditorState::new();
let fid = FrontendId(91);
let (document, panel) = {
let mut core = editor.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
let document = crate::window::WindowId::next();
let panel = crate::window::WindowId::next();
let (doc_view, panel_view) = {
let reg = core.registry.borrow();
(
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")),
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")),
)
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let mut panel_window = Window::new(panel, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(crate::window::Side::Bottom);
params.fixed_rows = Some(4);
panel_window.params = params;
core.windows.insert(panel, panel_window);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)],
weights: vec![1, 1],
},
},
active: panel,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel)
};
editor.sync_frame_geometry(fid, CellSize::new(24, 80));
(editor, fid, document, panel)
}
/// Drive ONE authenticated semantic event through the real
/// dispatcher. The session must be registered or the event is
/// dropped at the uninstalled-session check before reaching any
/// handler.
#[cfg(feature = "crdt")]
fn dispatch_one_semantic_event(
editor: &mut crate::editor::EditorState,
fid: FrontendId,
event: FrontendEvent,
) {
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid));
let mut streams = HashMap::new();
let mut term_sizes = HashMap::new();
term_sizes.insert(fid, CellSize::new(24, 80));
let mut last_idle = HashMap::new();
let mut last_active = HashMap::new();
let mut bells = HashMap::new();
let mut registry = SessionRegistry::new();
registry.register_session(
fid,
crate::presence::SessionState {
negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION,
negotiated_capabilities: crate::protocol::NegotiatedCapabilities {
semantic_render: true,
crdt_replica: true,
..Default::default()
},
color_slot: 0,
},
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent { source: fid, event },
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_idle,
&mut last_active,
&mut bells,
&mut registry,
);
}
/// Bottom-panel §1.3 #9 — Projection. The `Viewport` terminal-context
/// gate asks "is this frontend's DOCUMENT surface a terminal", so a
/// focused TERMINAL PANEL must not suppress the still-visible
/// document's viewport.
#[cfg(feature = "crdt")]
#[test]
fn a_focused_terminal_panel_does_not_suppress_the_document_viewport() {
use crate::terminal::TerminalSpec;
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = editor.core.borrow().registry.borrow_mut().create("*other*");
// A REAL terminal in the focused panel.
let mut spec = TerminalSpec::new("/bin/sh");
spec.rows = 10;
spec.cols = 40;
let term_buf = editor.open_terminal(spec).expect("a real terminal");
editor
.core
.borrow_mut()
.install_buffer_in_window(panel, term_buf)
.expect("terminal into the panel");
editor.core.borrow_mut().focus_window(fid, panel);
dispatch_one_semantic_event(
&mut editor,
fid,
FrontendEvent::Viewport {
frontend_id: fid,
buffer_id: other,
visible: pmacs_protocol::ByteRange { start: 0, end: 0 },
generation: 0,
},
);
assert_eq!(
editor.core.borrow().windows[&document].buffer_id,
other,
"#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer"
);
}
}