M10.10 ship gate

Land the optimistic local-edit-application layer on top of the M10 CRDT
foundation: frontend-side rope replica with local edit application,
daemon-authoritative broadcast, and bidirectional cursor reconciliation.
Keystrokes feel instantaneous because the local replica answers next-render
queries before the daemon round-trip completes, while the daemon remains
the single source of truth for conflict resolution and broadcast to remote
replicas.

Architecture beats:
- BufferMirror (src/buffer_mirror.rs) holds a per-frontend rope replica
  with explicit cursor-staleness tracking. Every event that may move the
  active cursor or swap the active buffer marks the mirror stale; the
  next CursorByte from the daemon clears it.
- CrdtOpOrigin {OptimisticReplica(FrontendId), DaemonKey} routes broadcast.
  OptimisticReplica skips re-application on the originating frontend
  (already applied locally); DaemonKey broadcasts to all replicas including
  source -- covers Lua-driven and generated-buffer edits that bypass the
  optimistic path.
- Generated buffers (*help*, *workers*, *pmacs-instance*, *errors*) funnel
  apply_edit output through queue_daemon_origin_crdt_op so post-attach
  CRDT upgrades don't drop their edits.
- forbid(unsafe_code) preserved throughout; loro 1.12 added as the CRDT
  engine.

Audit posture: M10.10 shipped through six post-audit review rounds with
twenty-eight cumulative findings, most categorized as "incomplete
application of a prior round's mechanism." The audit doc records
grep-driven exhaustiveness as the standing countermeasure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-13 16:28:46 -04:00
parent f1e9e209c0
commit 45be65b026
32 changed files with 14258 additions and 531 deletions

979
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,12 @@ multiple_crate_versions = "allow"
default = ["luajit"]
luajit = ["mlua/luajit", "mlua/vendored"]
lua54 = ["mlua/lua54", "mlua/vendored"]
# T M10.2: gate the CRDT-backed buffer mode behind an opt-in feature
# so v0.1 builds carry zero CRDT overhead (no loro dependency, no
# field on the Buffer struct layout, no branch on apply_edit). v1.0
# builds enable `crdt`; the rope-projection redirect from M10.1 means
# the feature flip is invisible to v0.1 frontends and to workers.
crdt = ["dep:loro"]
[dependencies]
crossterm = "0.28"
@ -107,7 +113,7 @@ tree-sitter-md = "0.5"
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
# the feature surface tight to keep build time low (no syscalls
# beyond `kill(2)` for v0.1).
nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term"] }
nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket"] }
# T M4.4 PTY mode: portable abstraction over openpty / fork+exec
# with controlling-tty wiring. The crate uses internal `unsafe`
# but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"`
@ -131,10 +137,26 @@ semver = { version = "1", features = ["serde"] }
# bytes detects upstream tampering even when the host serves a SHA-1
# collision. Pure-Rust implementation; no system dep.
sha2 = "0.10"
# T M10.2 sequence CRDT for the v1.0 multi-frontend promotion. Selected
# in M10.1 (Decision section under spec §sec:m10-crdt-choice) on the
# basis of: per-op throughput dominance under realistic mixed-workload
# (190× faster than yrs at 30s window), ~100× more compact wire
# representation, stable v1.x API, and explicit spec mention. The pin
# is exact (`=1.12.0`) per the M10.1 commitment language: library
# updates are deliberate work (re-run M10.1 benchmarks, run convergence
# proptest, run acceptance suite), not Cargo background activity.
# Optional + feature-gated: zero footprint in v0.1 builds.
loro = { version = "=1.12.0", optional = true }
[dev-dependencies]
proptest = "1"
tempfile = "3"
# T M10.2 Day 7 perf bench (tests/m10_2_perf.rs) — seeded RNG plus
# log-normal op-size distribution matching the M10.1 methodology.
# Dev-only; not in release builds. rand 0.8 + edition 2024 requires
# `r#gen` for the (now-reserved) `gen` method name.
rand = "0.8"
rand_distr = "0.4"
[profile.release]
opt-level = 3

View File

@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 00a6ea2f06ee46f9058c802c4eccd65e938e069dcc93212c87257e61a4cac55f # shrinks to peer_count = 2, op_seqs = [[Insert(0, "a")], [Insert(0, "a")]], sync_pattern = Sequential

View File

@ -124,11 +124,51 @@ impl std::fmt::Display for AttachError {
match self {
Self::Io(e) => write!(f, "attach I/O error: {e}"),
Self::Transport(e) => write!(f, "{e}"),
Self::VersionMismatch { server, client } => write!(
f,
"protocol version mismatch (instance v{server}, client v{client})"
),
Self::Rejected(reason) => write!(f, "instance rejected attach: {reason:?}"),
Self::VersionMismatch { server, client } => {
// T M10.7 criterion 5: the message must tell the user
// which side is at the older version. Comparing
// `server` against `client` produces an unambiguous
// identification without the user needing to decode
// version-number semantics.
let which_older = match server.cmp(client) {
std::cmp::Ordering::Less => {
" The pmacs daemon is at the older version — upgrade the daemon \
(or restart it after upgrading the pmacs binary)."
}
std::cmp::Ordering::Greater => {
" Your pmacs binary is at the older version — upgrade the binary."
}
std::cmp::Ordering::Equal => "",
};
write!(
f,
"protocol version mismatch (instance v{server}, client v{client}).{which_older}"
)
}
Self::Rejected(reason) => match reason {
// T M10.7: capability negotiation mismatch — name the
// capabilities the frontend asked for that the
// instance can't provide. The strings on the wire are
// exactly the `FrontendCapabilities` field names
// (e.g., `multi_frontend`); user-facing translation
// happens here.
GoodbyeReason::CapabilityMismatch { missing } => {
let translated: Vec<&str> = missing
.iter()
.map(|name| match name.as_str() {
"multi_frontend" => "multi-frontend collaboration",
"crdt_replica" => "CRDT replica participation",
other => other,
})
.collect();
write!(
f,
"instance does not support the requested capabilities: {}",
translated.join(", ")
)
}
_ => write!(f, "instance rejected attach: {reason:?}"),
},
Self::Terminal(e) => write!(f, "terminal error: {e}"),
Self::SshSpawnFailed { command, source } => write!(
f,
@ -259,6 +299,35 @@ pub(crate) trait AttachPumpFrontend {
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()>;
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
fn size(&self) -> CellSize;
/// T M10.10 Day 3 step 5 Path β — paint an optimistic insert
/// at the terminal's current cursor position. Used when the
/// optimistic-apply orchestrator landed a `CrdtOp` AND the
/// mirror reports `cursor_at_end_of_line == true` for the active
/// buffer.
///
/// Default impl no-ops; the production `Frontend` overrides with
/// the actual terminal-write path. Tests using stub frontends
/// inherit the no-op (visual paint isn't being asserted at the
/// unit-test level).
///
/// Feature-gated: the orchestrator's call site is `#[cfg(feature =
/// "crdt")]`; the trait method exists only in CRDT builds to keep
/// the non-CRDT trait surface minimal.
#[cfg(feature = "crdt")]
fn paint_optimistic_insert(&mut self, _c: char) -> std::io::Result<()> {
Ok(())
}
/// T M10.10 Day 3 step 5 Path β — paint an optimistic
/// delete-back: erase the cell to the left of the cursor and
/// retreat the cursor one column. Cells match what the daemon's
/// `CellDelta` will eventually carry (last char of line becomes a
/// space at the cursor position before the cursor returns).
#[cfg(feature = "crdt")]
fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl AttachPumpFrontend for Frontend {
@ -271,6 +340,14 @@ impl AttachPumpFrontend for Frontend {
fn size(&self) -> CellSize {
Frontend::size(self)
}
#[cfg(feature = "crdt")]
fn paint_optimistic_insert(&mut self, c: char) -> std::io::Result<()> {
Frontend::paint_optimistic_insert(self, c)
}
#[cfg(feature = "crdt")]
fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> {
Frontend::paint_optimistic_delete_back(self)
}
}
/// Connect to the daemon at `socket_path` and run the attach client.
@ -289,7 +366,13 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
// mismatch, malformed Hello, EOF), the error message reaches a
// normal terminal — `Frontend::new` hasn't taken over yet.
let hello: Hello = read_message(&mut stream)?;
if hello.protocol_version != PROTOCOL_VERSION {
// T M10.5: relaxed from strict equality to range membership per
// `§sec:m10-backward-compat`. A v1.0 frontend accepts a Hello
// from a v0.1 daemon (protocol_version=1) and downgrades its own
// request to match the server's version; symmetric to the daemon-
// side relaxation. Versions outside `SUPPORTED_PROTOCOL_VERSIONS`
// are still rejected.
if !crate::protocol::is_supported_protocol_version(hello.protocol_version) {
return Err(AttachError::VersionMismatch {
server: hello.protocol_version,
client: PROTOCOL_VERSION,
@ -300,8 +383,13 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
let (cols, rows) = crossterm::terminal::size().map_err(AttachError::Terminal)?;
let initial_size = CellSize::new(u32::from(rows), u32::from(cols));
// T M10.5: match the server's protocol version so a v1.0 frontend
// connecting to a v0.1 daemon advertises protocol_version=1 in
// its AttachRequest (the v0.1 daemon's strict-equality check will
// accept). The frontend's runtime behavior on the wire is the
// intersection of features both sides support.
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_capabilities(),
initial_size,
};
@ -399,9 +487,26 @@ impl Read for KickAwareUnixReader {
}
}
fn build_capabilities() -> FrontendCapabilities {
// `pub` so the post-audit Finding 6 production-path test in
// `tests/m5_5_acceptance.rs` can verify the production caps directly
// rather than reconstructing them in the test (which is exactly the
// gap that allowed Finding 1 to survive M10.10's first audit —
// `attach_multi()`'s custom caps bypassed the production function).
//
// Not part of the stable public API; reserved for internal test use.
#[doc(hidden)]
pub fn build_capabilities() -> FrontendCapabilities {
// The v0.1 TUI implements all of these; we report them honestly
// so the daemon doesn't strip features that work fine.
//
// T M10.10 — `multi_frontend` and `crdt_replica` advertise the
// M10.10 BufferMirror + optimistic-apply infrastructure. Gated
// on the `crdt` Cargo feature because the relevant modules
// (`buffer_mirror`, `optimistic`) are conditionally compiled.
// A non-CRDT build's frontend can't bootstrap a mirror and
// shouldn't claim it can. CRDT-feature builds advertise true;
// the daemon's per-tick CursorByte + BufferSnapshot bootstrap +
// CrdtOp routing are then negotiated correctly.
FrontendCapabilities {
synchronized_output: true,
unicode_smp: true,
@ -409,6 +514,8 @@ fn build_capabilities() -> FrontendCapabilities {
mouse: true,
bracketed_paste: true,
terminal_kind: std::env::var("TERM").ok(),
multi_frontend: cfg!(feature = "crdt"),
crdt_replica: cfg!(feature = "crdt"),
}
}
@ -465,6 +572,16 @@ fn format_uptime(secs: u64) -> String {
/// is joined before this function returns. The closure-and-call
/// wind-down pattern guarantees this regardless of which `return`
/// the loop takes.
// M10.10 grew this function with optimistic-apply orchestration +
// BufferSnapshot/CursorByte/CrdtOp routing in the message-drain
// loop. The 146-line size is intentionally cohesive: the closure
// captures the AttachIo writer and BufferMirror together, and
// splitting would require parameterizing both across helper
// functions or restructuring the wind-down pattern (drop(writer)
// → kick → join) which is the function's primary correctness
// invariant. The lint flags growth without naming a structural
// problem; defer to v0.2+ refactor if growth continues.
#[allow(clippy::too_many_lines)]
pub(crate) fn run_attach_pair(
io: AttachIo,
frontend: &mut dyn AttachPumpFrontend,
@ -479,6 +596,13 @@ pub(crate) fn run_attach_pair(
let (tx, rx) = mpsc::channel::<InstanceMessage>();
let reader_handle = thread::spawn(move || run_reader(reader, tx));
// T M10.10: per-session CRDT replica state. Bootstrapped by
// `InstanceMessage::BufferSnapshot` messages routed in the
// drain loop below; consumed by the optimistic-apply predicate
// wired in Day 3.
#[cfg(feature = "crdt")]
let mut buffer_mirror = crate::buffer_mirror::BufferMirror::new(assigned_id);
// Closure-and-call: any `return` from this closure still falls
// through to `kick()` and `reader_handle.join()` below. Without
// this wrapping a writer-side IO error inside the loop would skip
@ -486,8 +610,8 @@ pub(crate) fn run_attach_pair(
let result: Result<(), AttachError> = (|| {
loop {
// Drain instance messages. Goodbye exits immediately;
// other messages are batched into a single
// present_messages call.
// BufferSnapshot routes to the mirror; other messages are
// batched into a single present_messages call.
let mut batch: Vec<InstanceMessage> = Vec::new();
let mut goodbye: Option<GoodbyeReason> = None;
let mut reader_eof = false;
@ -497,6 +621,81 @@ pub(crate) fn run_attach_pair(
goodbye = Some(reason);
break;
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
}) => {
// T M10.10: bootstrap the mirror for
// `buffer_id`. AlreadyInitialized errors
// surface a daemon-side bug (double-send) but
// shouldn't abort the session — log and
// continue with prior state. Loro decode
// errors are similarly logged.
if let Err(e) = buffer_mirror.init_from_snapshot(buffer_id, &crdt_snapshot)
{
eprintln!("pmacs: BufferMirror init for {buffer_id:?} failed: {e}");
}
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::CursorByte {
buffer_id,
byte_pos,
}) => {
// T M10.10 Finding 2: authoritative cursor
// byte-position update from the daemon. The
// optimistic-apply path consults
// `buffer_mirror.cursor_byte_pos(buffer_id)`
// before generating a local CrdtOp; this
// keeps that lookup current. Convert wire
// u64 → usize for the loro API.
buffer_mirror.set_cursor_byte_pos(buffer_id, byte_pos as usize);
}
#[cfg(feature = "crdt")]
Ok(InstanceMessage::CrdtOp { buffer_id, op }) => {
// T M10.10 step 4 — remote CrdtOp routing.
//
// The filter site is the message loop, NOT
// inside BufferMirror. Echoes of locally-
// applied edits arrive via CrdtOp broadcasts
// (the daemon fans out every op including the
// originator's own); the mirror has already
// applied these via apply_local_insert /
// apply_local_delete at keystroke time;
// re-applying would double-insert. The
// BufferMirror layer stays identity-ignorant
// by design — `apply_incoming_crdt_op` does
// the FrontendId comparison before invoking
// the mirror.
//
// Source FrontendId is derived from
// `op.peer_id` via the identity mapping
// documented in `crdt::peer_id_from_frontend`
// (FrontendId(n).0 == n).
let source = FrontendId(op.peer_id);
match crate::optimistic::apply_incoming_crdt_op(
&mut buffer_mirror,
assigned_id,
source,
buffer_id,
&op.bytes,
) {
Ok(_outcome) => {
// Applied or SkippedEcho — both are
// success. Paint reconciliation
// (step 5) handles the visible diff.
}
Err(e) => {
// NotReady is the common case for a
// buffer this frontend hasn't been
// snapshotted for (mid-session
// buffer creation; v0.2's broadcast
// will close this gap). Log and
// continue.
eprintln!("pmacs: CrdtOp routing for {buffer_id:?} failed: {e}");
}
}
}
Ok(msg) => batch.push(msg),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
@ -534,10 +733,139 @@ pub(crate) fn run_attach_pair(
return Ok(());
}
if let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size()) {
// Likely a broken pipe — instance went away.
eprintln!("pmacs: {e}");
return Err(e);
// T M10.10 Day 3 step 3b — text-input optimistic-apply
// orchestration. For Press/Repeat key events, the
// orchestrator either:
// - returns FrontendEvent::CrdtOp (after applying the
// edit to the local mirror) when the mirror is ready
// for the active buffer, or
// - returns FrontendEvent::Key (graceful Refinement 4
// fallback) when the optimistic path isn't viable.
// The caller writes whatever event was produced. Other
// event kinds (mouse, resize, paste, focus, Release-kind
// keys) fall through to the existing forward_event path.
#[cfg(feature = "crdt")]
let optimistic_handled = if let Event::Key(k) = &ev {
if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
let timestamp_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| u64::try_from(d.as_nanos()).unwrap_or(0))
.unwrap_or(0);
let pmacs_key = key_from_crossterm(k, assigned_id, timestamp_ns);
// T M10.10 Day 3 step 5 Path β — determine
// visual-paint eligibility BEFORE the orchestrator
// mutates the mirror's cursor. End-of-line typing
// is the only case where single-Print optimistic
// paint matches the daemon's eventual CellDelta
// exactly (no cells right of cursor to shift). The
// action enum is captured so we can dispatch to
// the right paint primitive after the orchestrator
// produces a CrdtOp.
let action = crate::optimistic::classify_key(pmacs_key.key, pmacs_key.mods);
// Insert paint requires cursor at end-of-line (the
// single-Print sequence matches the daemon's
// eventual CellDelta exactly).
// Delete-back paint requires the stricter
// `cursor_at_end_of_line_safe_for_delete_back`
// predicate per post-audit Finding 5: also requires
// prev char != '\n'. Backspace that joins lines
// (prev char = newline) can't be represented by the
// single-column-erase paint sequence; falls
// through to v0.1 round-trip.
let active_buf = buffer_mirror.active_buffer();
let insert_paint_eligible = active_buf
.and_then(|b| buffer_mirror.cursor_at_end_of_line(b))
== Some(true);
let delete_back_paint_eligible = active_buf
.and_then(|b| buffer_mirror.cursor_at_end_of_line_safe_for_delete_back(b))
== Some(true);
let frontend_event = crate::optimistic::frontend_event_for_keystroke(
&mut buffer_mirror,
assigned_id,
pmacs_key,
);
if let Err(e) = write_message(&mut writer, &frontend_event) {
eprintln!("pmacs: write keystroke failed: {e}");
return Err(AttachError::from(e));
}
// Post-audit-round-4 F22 — if we round-tripped via
// `FrontendEvent::Key`, the daemon's command
// pipeline may move the cursor in ways the mirror
// can't predict locally (motion, Enter/Tab,
// mid-line edits, delete-forward, etc.). Mark the
// active buffer's cursor stale so subsequent
// keystrokes round-trip too until the daemon's
// next `CursorByte` re-grounds the mirror cursor.
if matches!(frontend_event, FrontendEvent::Key(_)) {
if let Some(active_buf) = buffer_mirror.active_buffer() {
buffer_mirror.mark_cursor_stale(active_buf);
}
}
// Visual optimistic paint (Path β). Fires only when
// the orchestrator landed a CrdtOp (mirror was
// ready, action was optimistic-eligible) AND the
// pre-edit cursor was at an action-specific safe
// position. Mid-line operations, line-joining
// backspace, and round-trip cases skip — the
// daemon's CellDelta drives paint for those.
//
// Daemon-side CellDelta suppression is NOT needed:
// under Path β, optimistic paint either matches
// the eventual CellDelta exactly (end-of-line)
// or doesn't exist (mid-line / line-join). Either
// way, no flicker.
if matches!(frontend_event, FrontendEvent::CrdtOp { .. }) {
let paint_result = match action {
crate::optimistic::OptimisticAction::Insert(c)
if insert_paint_eligible =>
{
frontend.paint_optimistic_insert(c)
}
crate::optimistic::OptimisticAction::DeleteBack
if delete_back_paint_eligible =>
{
frontend.paint_optimistic_delete_back()
}
_ => Ok(()),
};
if let Err(e) = paint_result {
eprintln!("pmacs: optimistic paint failed: {e}");
}
}
true
} else {
false
}
} else {
false
};
#[cfg(not(feature = "crdt"))]
let optimistic_handled = false;
if !optimistic_handled {
if let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size()) {
// Likely a broken pipe — instance went away.
eprintln!("pmacs: {e}");
return Err(e);
}
// Post-audit-round-6 F30 — `forward_event` (success
// path) may write a Mouse / Paste / Resize /
// FocusGained / FocusLost event (or no-op for a Key
// Release). Mouse down/drag in particular can move
// the daemon's active window cursor, change the
// active buffer, or both. Anything except an
// optimistic CrdtOp can desync the mirror's cursor
// from the daemon's view; conservatively mark the
// active buffer's cursor stale so subsequent
// keystrokes round-trip until the daemon's next
// `CursorByte` re-grounds the mirror.
#[cfg(feature = "crdt")]
if let Some(active_buf) = buffer_mirror.active_buffer() {
buffer_mirror.mark_cursor_stale(active_buf);
}
}
}
})();
@ -1136,7 +1464,10 @@ fn run_one_session(
));
}
};
if hello.protocol_version != PROTOCOL_VERSION {
// T M10.5: relaxed to range membership per
// `§sec:m10-backward-compat`. Symmetric with the local-socket
// attach path above.
if !crate::protocol::is_supported_protocol_version(hello.protocol_version) {
return Err(handshake_error_with_child(
child,
stderr_handle,
@ -1166,8 +1497,11 @@ fn run_one_session(
},
};
// T M10.5: match the server's protocol version so v1.0 frontends
// attaching to v0.1 daemons advertise protocol_version=1. Same
// pattern as the local-socket path above.
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_capabilities(),
initial_size,
};
@ -1939,4 +2273,89 @@ mod tests {
"[pmacs disconnected — reconnecting in 30s — Ctrl-C to exit]"
);
}
// T M10.7 — AttachError message formatting.
//
// Criterion 5 of the spec: the version-mismatch message must
// tell the user which side is at the older version. These tests
// pin the substring assertions explicitly so a future regression
// (the message no longer naming the older side) fails visibly.
#[test]
fn version_mismatch_daemon_older_message_names_daemon() {
let err = AttachError::VersionMismatch {
server: 1,
client: 2,
};
let msg = err.to_string();
assert!(
msg.contains("daemon is at the older version"),
"criterion 5: message must name daemon as older when server < client; got: {msg}"
);
}
#[test]
fn version_mismatch_binary_older_message_names_binary() {
let err = AttachError::VersionMismatch {
server: 2,
client: 1,
};
let msg = err.to_string();
assert!(
msg.contains("binary is at the older version"),
"criterion 5: message must name client binary as older when server > client; got: {msg}"
);
}
#[test]
fn version_mismatch_equal_no_older_clause() {
// Pathological case (the daemon shouldn't emit
// VersionMismatch when versions match) — but the formatter
// shouldn't claim an older side when there isn't one.
let err = AttachError::VersionMismatch {
server: 2,
client: 2,
};
let msg = err.to_string();
assert!(
!msg.contains("older version"),
"no older clause when equal; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_names_multi_frontend() {
// T M10.7 criterion 4 — the error names the specific
// capability the frontend asked for that wasn't available.
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["multi_frontend".to_string()],
});
let msg = err.to_string();
assert!(
msg.contains("multi-frontend collaboration"),
"criterion 4: message must name the capability in user-readable form; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_names_crdt_replica() {
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["crdt_replica".to_string()],
});
let msg = err.to_string();
assert!(
msg.contains("CRDT replica participation"),
"message must translate crdt_replica to user-readable form; got: {msg}"
);
}
#[test]
fn capability_mismatch_message_lists_multiple() {
let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch {
missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()],
});
let msg = err.to_string();
assert!(msg.contains("multi-frontend collaboration"));
assert!(msg.contains("CRDT replica participation"));
}
}

File diff suppressed because it is too large Load Diff

1228
src/buffer_mirror.rs Normal file

File diff suppressed because it is too large Load Diff

1327
src/crdt.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -706,7 +706,7 @@ impl EditorState {
/// origin (0 row = first text row of this window's content).
fn activate_and_position(&mut self, win_id: WindowId, local_row: u32, local_col: u32) {
let mut core = self.core.borrow_mut();
core.active = win_id;
core.set_active_window_id(win_id);
let view_top = core.windows[&win_id].view_top;
let buffer_id = core.windows[&win_id].buffer_id;
let display_row = view_top.saturating_add(local_row as usize);
@ -814,7 +814,7 @@ fn window_at_cell(
return None;
}
let area = Rect::new(0, 0, text_rows, term_size.cols);
let placements = core.layout.compute(area);
let placements = core.active_layout().compute(area);
placements.iter().find_map(|(id, rect)| {
if cell_row >= rect.origin.row
&& cell_row < rect.origin.row + rect.size.rows
@ -902,7 +902,8 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
let mut render_state = crate::instance_render::RenderState::new(frontend.size());
loop {
let messages = render_state.render_frame(&state);
// In-process TUI never has remote frontends; no overlays.
let messages = render_state.render_frame(&state, &[]);
frontend.present_messages(&messages)?;
if state.core.borrow().quit {
break;
@ -1005,8 +1006,8 @@ pub fn paint_frame(
// Compute per-window rectangles. The text area is the term size
// minus the bottom row (status / minibuffer).
let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols);
let placements = core.layout.compute(text_area);
let active = core.active;
let placements = core.active_layout().compute(text_area);
let active = core.active_window_id();
// Clear the whole grid first so windows that shrink on resize
// don't leak the old contents.
@ -1024,10 +1025,9 @@ pub fn paint_frame(
let reg = registry.borrow();
let buf_id = core.active_buffer_id();
if let Ok(buf) = reg.get(buf_id) {
let aw = core
.windows
.get_mut(&active)
.expect("invariant: core.active is always a live window in core.windows");
let aw = core.windows.get_mut(&active).expect(
"invariant: active_window_id always references a live window in core.windows",
);
let cursor_row = aw
.text_view
.pos_to_display(buf, aw.cursor)
@ -3077,7 +3077,7 @@ mod tests {
let core = s.core.borrow();
assert_eq!(core.windows.len(), 8);
let area = crate::window::Rect::new(0, 0, 40, 120);
let placements = core.layout.compute(area);
let placements = core.active_layout().compute(area);
assert_eq!(placements.len(), 8);
for r in placements.values() {
assert!(!r.is_empty(), "rect was empty: {r:?}");
@ -3099,13 +3099,13 @@ mod tests {
)
.exec()
.unwrap();
let start = s.core.borrow().active;
let start = s.core.borrow().active_window_id();
let total = s.core.borrow().windows.len();
assert_eq!(total, 3);
for _ in 0..total {
s.core.borrow_mut().focus_next();
}
assert_eq!(s.core.borrow().active, start);
assert_eq!(s.core.borrow().active_window_id(), start);
}
/// Bullet 3: the buffer-list buffer is a regular Buffer in the
@ -3439,7 +3439,7 @@ mod tests {
.unwrap();
// Set a 2:1 weight on the root split.
if let crate::window::LayoutNode::Split { weights, .. } =
&mut s.core.borrow_mut().layout.root
&mut s.core.borrow_mut().active_layout_mut().root
{
*weights = vec![2, 1];
} else {
@ -3448,12 +3448,12 @@ mod tests {
let p1 = s
.core
.borrow()
.layout
.active_layout()
.compute(crate::window::Rect::new(0, 0, 24, 90));
let p2 = s
.core
.borrow()
.layout
.active_layout()
.compute(crate::window::Rect::new(0, 0, 24, 60));
// Both should preserve the 2:1 ratio. Find the two windows
// and verify the larger:smaller ratio is 2:1 in both.
@ -3488,10 +3488,11 @@ mod tests {
// apply_active_edit.
let core = s.core.borrow();
assert_eq!(core.active_buffer_len(), 6);
let active = core.active_window_id();
let other_id = core
.windows
.keys()
.find(|id| **id != core.active)
.find(|id| **id != active)
.copied()
.unwrap();
assert_eq!(core.windows[&other_id].buffer_id, buf_id);
@ -3508,7 +3509,7 @@ mod tests {
) -> Vec<crate::cell::Cell> {
use crate::cell::{Cell, CellGrid, CellSize};
use crate::view::Viewport;
let active = core.active;
let active = core.active_window_id();
let win = core.windows.get_mut(&active).unwrap();
let rect = crate::window::Rect::new(0, 0, 24, 80);
let cell_count = (rect.size.rows * rect.size.cols) as usize;
@ -3644,7 +3645,7 @@ mod tests {
let (single_avg_ns, dispatch_avg_ns, realistic_avg_ns) = {
let mut core = s.core.borrow_mut();
let active = core.active;
let active = core.active_window_id();
let buf_id = core.windows[&active].buffer_id;
let registry = core.registry.clone();
let reg = registry.borrow();
@ -4017,7 +4018,7 @@ mod tests {
.load("pmacs.window.split_vertical()")
.exec()
.unwrap();
let original_active = s.core.borrow().active;
let original_active = s.core.borrow().active_window_id();
// Click on the right side (col 60 — guaranteed in the second window
// for any standard 80-col terminal split in half).
s.dispatch_mouse(
@ -4025,7 +4026,7 @@ mod tests {
mouse(MouseEventKind::Down(MouseButton::Left), 0, 60),
term_size_24x80(),
);
let new_active = s.core.borrow().active;
let new_active = s.core.borrow().active_window_id();
assert_ne!(
new_active, original_active,
"click in other window did not activate it"

View File

@ -21,7 +21,7 @@
//! window's --- two windows on the same buffer keep their layout
//! caches synchronized.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use crate::buffer::{Buffer, BufferId, EditOp};
@ -33,19 +33,52 @@ use crate::rope::Edit;
use crate::rope::{Position, Range};
use crate::text_view::TextView;
use crate::view::{DisplayCoord, View};
use crate::window::{Layout, Orientation, Window, WindowId};
use crate::window::{FrontendView, Layout, Orientation, Window, WindowId};
/// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op.
///
/// Records **whether the originating frontend already applied the
/// op to its local mirror**, which determines whether the broadcast
/// sweep should exclude that frontend.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum CrdtOpOrigin {
/// A replica frontend's `FrontendEvent::CrdtOp` path applied the
/// op to its local mirror before sending. Broadcast must exclude
/// that frontend (it would double-apply otherwise — see
/// `BufferMirror::apply_local_insert` /
/// `apply_local_delete` and `optimistic::apply_incoming_crdt_op`'s
/// echo-skip rule).
OptimisticReplica(FrontendId),
/// Daemon-side mutation (a `FrontendEvent::Key` round-trip, a
/// Lua-driven edit, a fallback path) generated the op. No
/// frontend has applied it locally; broadcast to every replica
/// frontend, including the one whose `Key` event drove the
/// daemon path (its mirror is otherwise stale).
DaemonKey,
}
/// The world state mutated by editor commands.
pub struct EditorCore {
/// Shared buffer registry. The registry is the canonical owner
/// of every buffer; windows reference buffers by [`BufferId`].
pub registry: SharedRegistry,
/// Open windows, keyed by id for stable iteration.
/// All windows, keyed by id for stable iteration. `WindowId`s
/// are globally unique across all frontends; each
/// [`FrontendView`] in `views` references a subset via its
/// `Layout`.
pub windows: BTreeMap<WindowId, Window>,
/// Window tree mapping the cell grid to windows.
pub layout: Layout,
/// The focused window. `pmacs.editor.*` primitives target it.
pub active: WindowId,
/// T M10.8 — per-frontend views. Each attached frontend has its
/// own `Layout` (split tree) + `active: WindowId`. Buffers are
/// shared via `registry`; cursors / `view_top`s live in the
/// per-frontend `Window` instances.
///
/// Invariant: `FrontendId::LOCAL` always has an entry. The
/// in-process editor uses this view; daemon-attached frontends
/// register additional entries on attach (M10.8 Day 3 wires
/// the per-attach registration via the dispatcher; Day 2 ships
/// a fallback-to-LOCAL accessor so single-frontend tests pass
/// before per-attach registration lands).
pub views: HashMap<FrontendId, FrontendView>,
/// One-line message shown in the status line.
pub status: String,
/// True iff the editor should exit at the next iteration.
@ -68,6 +101,31 @@ pub struct EditorCore {
/// (multi-window, multi-user) where each input event must be
/// attributable to its source frontend.
pub active_frontend: FrontendId,
/// T M10.8 Day 4 — pending CRDT ops queue.
///
/// Each [`CrdtOpOrigin`] entry records both **what** to broadcast
/// and **who already applied it locally** (the sender-exclusion
/// signal). The dispatcher drains the queue per-tick and
/// broadcasts each op to multi-frontend sessions with
/// `crdt_replica` negotiated.
///
/// # M10.10 post-audit-round-3 F16: origin tagging
///
/// Sender exclusion depends on **whether the originating
/// frontend already applied the op to its local mirror**:
///
/// - [`CrdtOpOrigin::OptimisticReplica`] — a replica frontend's
/// `FrontendEvent::CrdtOp` path applied the op to its mirror
/// before sending. Broadcast must exclude that frontend so it
/// doesn't double-apply.
/// - [`CrdtOpOrigin::DaemonKey`] — daemon-side mutation (a
/// `FrontendEvent::Key` round-trip, a Lua-driven edit, etc.)
/// generated the op. No frontend's mirror has applied it
/// locally; broadcast must include every replica frontend
/// *including* the active one. Without this, the
/// active frontend's mirror would silently drift from daemon
/// state after every fallback / Key-path edit.
pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>,
}
impl EditorCore {
@ -84,17 +142,25 @@ impl EditorCore {
let window = Window::new(id, buffer_id, text_view);
let mut windows = BTreeMap::new();
windows.insert(id, window);
let mut views = HashMap::new();
views.insert(
FrontendId::LOCAL,
FrontendView {
layout: Layout::single(id),
active: id,
},
);
Self {
registry,
windows,
layout: Layout::single(id),
active: id,
views,
status: String::new(),
quit: false,
file_path: None,
file_meta: None,
minibuffer: Minibuffer::new(),
active_frontend: FrontendId::LOCAL,
pending_crdt_ops: Vec::new(),
}
}
@ -127,19 +193,111 @@ impl EditorCore {
// ---- accessors ---------------------------------------------------------
/// Reference the active [`Window`].
/// T M10.8 — the active frontend's view (layout + active window).
///
/// **Day 2 transitional behavior**: if `active_frontend` has no
/// registered view (the daemon-attached frontend case before Day
/// 3's dispatcher refactor wires `register_frontend_view`), fall
/// back to `FrontendId::LOCAL`'s view. The invariant "LOCAL
/// always has a view" is enforced by the constructor.
#[must_use]
pub fn active_view(&self) -> &FrontendView {
self.views.get(&self.active_frontend).unwrap_or_else(|| {
self.views.get(&FrontendId::LOCAL).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
})
}
/// Mutable view of the active frontend's [`FrontendView`].
///
/// Same fallback semantics as [`active_view`].
pub fn active_view_mut(&mut self) -> &mut FrontendView {
// Choose the key first to avoid borrowing `self.views`
// twice with overlapping lifetimes (the fallback path).
let key = if self.views.contains_key(&self.active_frontend) {
self.active_frontend
} else {
FrontendId::LOCAL
};
self.views.get_mut(&key).expect(
"invariant: FrontendId::LOCAL always has a registered FrontendView; \
populated by EditorCore::new and never removed",
)
}
/// The active frontend's window-split tree.
#[must_use]
pub fn active_layout(&self) -> &Layout {
&self.active_view().layout
}
/// Mutable access to the active frontend's window-split tree.
pub fn active_layout_mut(&mut self) -> &mut Layout {
&mut self.active_view_mut().layout
}
/// `WindowId` of the active frontend's focused window.
#[must_use]
pub fn active_window_id(&self) -> WindowId {
self.active_view().active
}
/// Set the active frontend's focused window.
pub fn set_active_window_id(&mut self, id: WindowId) {
self.active_view_mut().active = id;
}
/// Reference the active [`Window`] — the window currently
/// focused in the active frontend's view.
#[must_use]
pub fn active_window(&self) -> &Window {
let id = self.active_window_id();
self.windows
.get(&self.active)
.expect("active window present")
.get(&id)
.expect("active window present in core.windows")
}
/// Mutably reference the active [`Window`].
pub fn active_window_mut(&mut self) -> &mut Window {
let id = self.active_window_id();
self.windows
.get_mut(&self.active)
.expect("active window present")
.get_mut(&id)
.expect("active window present in core.windows")
}
/// Reference a specific frontend's active [`Window`].
///
/// Returns `None` if `fid` has no registered view (no fallback —
/// callers explicitly asking about a specific frontend get a
/// truthful answer about whether that frontend has state).
#[must_use]
pub fn active_window_for(&self, fid: FrontendId) -> Option<&Window> {
let view = self.views.get(&fid)?;
self.windows.get(&view.active)
}
/// Mutably reference a specific frontend's active [`Window`].
pub fn active_window_mut_for(&mut self, fid: FrontendId) -> Option<&mut Window> {
let win_id = self.views.get(&fid)?.active;
self.windows.get_mut(&win_id)
}
/// T M10.8 — register a `FrontendView` for `fid`. Called by the
/// daemon on attach (Day 3 dispatcher work). Day 2's fallback
/// path makes this optional; Day 3 makes it required.
pub fn register_frontend_view(&mut self, fid: FrontendId, view: FrontendView) {
self.views.insert(fid, view);
}
/// T M10.8 — drop a frontend's view on detach. The frontend's
/// windows remain in `self.windows` until explicit cleanup (M10.x
/// may add per-detach window pruning); for M10.8 they're
/// orphaned but accessible by id (matches v0.1 behavior where
/// closing a window left others intact).
pub fn unregister_frontend_view(&mut self, fid: FrontendId) {
self.views.remove(&fid);
}
/// [`BufferId`] of the active window's buffer.
@ -218,6 +376,20 @@ impl EditorCore {
}
}
}
// T M10.8 Day 4 — capture CRDT op (if the buffer was in
// CRDT mode and produced one) for the dispatcher to
// broadcast on the next tick.
//
// M10.10 post-audit-round-3 F16: this is the **daemon-side**
// mutation path (e.g. `FrontendEvent::Key` round-trip,
// Lua-driven edit, fallback). The source frontend's mirror
// has NOT applied this op locally; the queued origin is
// [`CrdtOpOrigin::DaemonKey`] so the broadcast sweep includes
// every replica (no sender exclusion).
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
Ok(edit.new_rope.len())
}
@ -743,6 +915,13 @@ impl EditorCore {
}
}
}
drop(reg);
// Post-audit-round-5 F27: undo on a CRDT-backed
// buffer produces a crdt_op that must broadcast to
// every replica frontend (including the one whose
// command triggered the undo — its BufferMirror has
// no other way to converge with the post-undo state).
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to undo".into(),
}
@ -774,11 +953,38 @@ impl EditorCore {
}
}
}
drop(reg);
// Post-audit-round-5 F27 — same as undo above.
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
Err(_) => self.status = "nothing to redo".into(),
}
}
/// T M10.10 post-audit-round-5 F27 + F28 — queue a CRDT op
/// produced by a daemon-origin edit (undo/redo via core, Lua
/// bindings, command pipeline) for broadcast.
///
/// Pushes into `pending_crdt_ops` with
/// [`CrdtOpOrigin::DaemonKey`] semantics: the broadcast sweep
/// includes every replica frontend (no sender exclusion). The
/// originating frontend's `BufferMirror` has not applied the op
/// locally — only the daemon's authoritative buffer has — so
/// the source's mirror needs the broadcast just like every
/// other replica.
///
/// No-op when the edit doesn't carry a `crdt_op` (the buffer
/// wasn't CRDT-backed at the time of the edit). Callers can
/// invoke this unconditionally after any daemon-origin
/// `apply_*` that returns an `Edit`; non-CRDT buffers pay no
/// cost beyond the early return.
pub fn queue_daemon_origin_crdt_op(&mut self, buffer_id: BufferId, edit: &Edit) {
if let Some(crdt_op) = edit.crdt_op.as_ref() {
self.pending_crdt_ops
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
}
// ---- window operations -------------------------------------------------
/// Split the active window. Returns the new window's id.
@ -799,18 +1005,24 @@ impl EditorCore {
let new_id = WindowId::next();
let new_window = Window::new(new_id, buffer_id, text_view);
self.windows.insert(new_id, new_window);
self.layout.split_window(self.active, orientation, new_id);
let active = self.active_window_id();
self.active_layout_mut()
.split_window(active, orientation, new_id);
new_id
}
/// Move focus to the next window in iteration order.
pub fn focus_next(&mut self) {
self.active = self.layout.focus_next(self.active);
let active = self.active_window_id();
let next = self.active_layout().focus_next(active);
self.set_active_window_id(next);
}
/// Move focus to the previous window in iteration order.
pub fn focus_prev(&mut self) {
self.active = self.layout.focus_prev(self.active);
let active = self.active_window_id();
let prev = self.active_layout().focus_prev(active);
self.set_active_window_id(prev);
}
/// Close the active window (unless it's the only one). Returns
@ -819,22 +1031,23 @@ impl EditorCore {
if self.windows.len() <= 1 {
return false;
}
let target = self.active;
self.layout.close_window(target);
let target = self.active_window_id();
self.active_layout_mut().close_window(target);
self.windows.remove(&target);
// Pick an adjacent window as the new focus.
self.active = *self
.layout
let next = *self
.active_layout()
.iter_ids()
.first()
.expect("at least one window remains");
self.set_active_window_id(next);
true
}
/// Close every window except the active one.
pub fn close_others(&mut self) {
let keep = self.active;
self.layout.keep_only(keep);
let keep = self.active_window_id();
self.active_layout_mut().keep_only(keep);
self.windows.retain(|id, _| *id == keep);
}
@ -1326,10 +1539,84 @@ mod tests {
assert!(s.status.contains("no file"));
}
/// T M10.8 — pins the Day 2 transitional fallback behavior in
/// [`EditorCore::active_view`].
///
/// **Day 2 → Day 3 transition contract**: while the dispatcher
/// thread is being wired (Day 3 work), the daemon may set
/// `active_frontend` to a daemon-attached `FrontendId` whose
/// `FrontendView` hasn't been registered yet. The fallback to
/// `FrontendId::LOCAL`'s view keeps single-frontend behavior
/// observable.
///
/// **Day 3 cleanup**: once
/// [`EditorCore::register_frontend_view`] is invariantly called
/// before any event dispatch, this test flips to assert "every
/// `active_frontend` has its own registered view, no fallback
/// ever activates." Until then, the fallback is the bridge.
#[test]
fn active_view_falls_back_to_local_when_active_frontend_unregistered() {
let mut s = fresh();
// Default active_frontend is LOCAL → no fallback yet.
assert_eq!(s.active_frontend, FrontendId::LOCAL);
let local_active_window = s.active_view().active;
// Simulate the Day 2 transitional state: a daemon-attached
// frontend's id is set as active, but no FrontendView is
// registered for it (Day 3 work).
s.active_frontend = FrontendId(42);
assert!(!s.views.contains_key(&FrontendId(42)));
// Fallback activates: active_view() returns LOCAL's view.
let fallback_view = s.active_view();
assert_eq!(
fallback_view.active, local_active_window,
"Day 2 fallback: active_view() returns LOCAL's view when active_frontend has no entry"
);
// Same for active_window().
let win = s.active_window();
assert_eq!(win.id, local_active_window);
}
#[test]
fn active_view_for_explicit_fid_returns_none_when_unregistered() {
// T M10.8 — explicit-fid lookups don't fall back. Callers
// explicitly asking about a specific frontend get a truthful
// None when that frontend has no state, distinguishing
// "active by default" from "actually has its own view."
let s = fresh();
assert!(s.active_window_for(FrontendId(42)).is_none());
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
}
#[test]
fn register_and_unregister_frontend_view() {
// T M10.8 — the lifecycle API the dispatcher uses on attach
// and detach. Wiring lives in `daemon.rs`; this test pins
// the EditorCore-side semantics.
let mut s = fresh();
let fid = FrontendId(7);
assert!(s.active_window_for(fid).is_none());
// Build a view referencing the existing scratch window so
// we don't need a fresh window allocation in this test.
let local_view = s.views[&FrontendId::LOCAL].clone();
s.register_frontend_view(fid, local_view);
assert!(s.active_window_for(fid).is_some());
// Unregister drops the entry; explicit lookup returns None.
s.unregister_frontend_view(fid);
assert!(s.active_window_for(fid).is_none());
// LOCAL invariant survives unrelated register/unregister.
assert!(s.views.contains_key(&FrontendId::LOCAL));
}
#[test]
fn split_active_creates_a_second_window_on_same_buffer() {
let mut s = fresh();
let original = s.active;
let original = s.active_window_id();
let new_id = s.split_active(Orientation::Vertical, true);
assert_ne!(new_id, original);
assert_eq!(s.windows.len(), 2);
@ -1349,12 +1636,8 @@ mod tests {
assert_eq!(s.active_buffer_len(), 4);
// The other window's text_view has the same line count,
// confirming on_edit fired.
let other = s
.windows
.keys()
.find(|id| **id != s.active)
.copied()
.unwrap();
let active = s.active_window_id();
let other = s.windows.keys().find(|id| **id != active).copied().unwrap();
assert_eq!(s.windows[&other].text_view.line_count(), 1);
}
@ -1377,17 +1660,112 @@ mod tests {
#[test]
fn focus_next_round_robins() {
let mut s = fresh();
let a = s.active;
let a = s.active_window_id();
let _b = s.split_active(Orientation::Vertical, true);
let _c = s.split_active(Orientation::Horizontal, true);
// Splits don't move focus; `a` is still active.
assert_eq!(s.active, a);
let order = s.layout.iter_ids();
assert_eq!(s.active_window_id(), a);
let order = s.active_layout().iter_ids();
assert_eq!(order.len(), 3);
// Walking N times wraps back to the original.
for _ in 0..3 {
s.focus_next();
}
assert_eq!(s.active, a);
assert_eq!(s.active_window_id(), a);
}
// ------------------------------------------------------------------
// F27 / F28 (post-audit-round-5) — daemon-origin CRDT ops are
// queued on `pending_crdt_ops` so they reach all replicas.
// ------------------------------------------------------------------
/// Helper: upgrade the active buffer to CRDT-backed under the
/// LOCAL peer id (mirrors what the daemon does at attach time
/// for replica sessions).
#[cfg(feature = "crdt")]
fn upgrade_active_to_crdt(s: &mut EditorCore) {
let buffer_id = s.active_buffer_id();
let mut reg = s.registry.borrow_mut();
let buf = reg.get_mut(buffer_id).expect("active buffer present");
buf.upgrade_to_crdt(crate::crdt::peer_id_from_frontend(
crate::protocol::FrontendId::LOCAL,
))
.expect("upgrade");
}
/// F27 — undo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn undo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
// Apply an edit so there's something to undo. apply_active_edit
// also pushes a DaemonKey-origin op.
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
let queued_after_edit = s.pending_crdt_ops.len();
assert!(queued_after_edit >= 1, "edit must queue a CRDT op");
// Drain to isolate the undo's queueing.
s.pending_crdt_ops.clear();
s.undo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: undo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
// Origin must be DaemonKey (broadcast-to-all-replicas).
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(
matches!(origin, CrdtOpOrigin::DaemonKey),
"F27: undo's CRDT op must be queued with DaemonKey origin (broadcast to all replicas including active frontend)"
);
}
/// F27 — redo on a CRDT-backed buffer queues the resulting
/// CRDT op for broadcast.
#[cfg(feature = "crdt")]
#[test]
fn redo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() {
let mut s = from_bytes(b"abc");
upgrade_active_to_crdt(&mut s);
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
s.undo();
s.pending_crdt_ops.clear();
s.redo();
assert!(
!s.pending_crdt_ops.is_empty(),
"F27: redo on a CRDT-backed buffer must queue a CRDT op for broadcast"
);
let (origin, _, _) = &s.pending_crdt_ops[0];
assert!(matches!(origin, CrdtOpOrigin::DaemonKey));
}
/// F27 — undo on a non-CRDT buffer is a no-op for the broadcast
/// queue (the buffer produced no `crdt_op` on the Edit).
#[test]
fn undo_on_non_crdt_buffer_does_not_queue_crdt_op_f27() {
let mut s = from_bytes(b"abc");
s.apply_active_edit(crate::buffer::EditOp::Insert {
pos: 3,
bytes: b"X",
})
.expect("edit");
// Non-CRDT — apply_active_edit's pending push is a no-op
// (Edit::crdt_op is None). Confirm precondition then undo.
assert!(s.pending_crdt_ops.is_empty());
s.undo();
assert!(
s.pending_crdt_ops.is_empty(),
"F27: undo on a non-CRDT buffer must not produce a phantom queue entry"
);
}
}

View File

@ -46,6 +46,8 @@
use std::io::{self, BufWriter, Stdout, Write};
use std::time::Duration;
#[cfg(feature = "crdt")]
use crossterm::{cursor::MoveLeft, style::Print};
use crossterm::{
cursor::{self, MoveTo},
event::{
@ -226,6 +228,93 @@ impl Frontend {
self.out.flush()
}
/// T M10.10 Day 3 step 5 Path β — paint an optimistic insert.
///
/// The character is written at the terminal's current cursor
/// position; the terminal advances the cursor by one column.
/// This is the visual half of the optimistic-apply path:
/// `BufferMirror::apply_local_insert` updated the CRDT mirror;
/// this method updates the user-visible display in the same
/// keystroke.
///
/// Called only when the cursor is at end-of-line for the active
/// buffer (per `BufferMirror::cursor_at_end_of_line`). End-of-
/// line is the dominant typing case and the only case where the
/// daemon's eventual `CellDelta` matches a single-Print
/// optimistic paint exactly (no cells right of cursor to shift).
///
/// # Post-audit round 2 (F15): style-blindness
///
/// This paint is **default-style only**. We explicitly reset
/// terminal attributes before the `Print` so the painted glyph
/// is deterministic and doesn't inherit leftover SGR state from
/// a prior `emit_span`. The `emit_span` epilogue already issues
/// `ResetColor + SetAttribute(Attribute::Reset)`, but the
/// invariant is fragile across crossterm versions and we'd
/// rather pay one extra reset than re-flash whatever style the
/// previous span set.
///
/// **Honest scope**: if the cell the daemon will eventually
/// paint into has a non-default style (e.g., a diagnostic
/// region, a syntax-highlighted token in a future milestone),
/// the optimistic glyph briefly renders default-styled until the
/// authoritative `CellDelta` arrives (within one frame target).
/// For v0.1 there is no syntax-highlighting pipeline; styled
/// regions are restricted to diagnostics squiggles, completion
/// popups, and overlays — none of which typically sit on the
/// end-of-line cell that Path β paints into. A future milestone
/// that introduces in-buffer styled content should track the
/// cursor-cell's pending style from the previous `CellDelta` and
/// apply it here, or suppress the optimistic paint on styled
/// cells altogether. The right fix needs per-cell style memory
/// the attach loop doesn't carry today.
#[cfg(feature = "crdt")]
pub fn paint_optimistic_insert(&mut self, c: char) -> io::Result<()> {
queue!(
self.out,
ResetColor,
SetAttribute(Attribute::Reset),
Print(c)
)?;
self.out.flush()
}
/// T M10.10 Day 3 step 5 Path β — paint an optimistic
/// delete-back.
///
/// Sequence: move cursor one column left, overwrite the cell
/// with a space, retreat cursor one column to its final
/// position. Matches what the daemon's eventual `CellDelta`
/// will carry: the last char of the line becomes a space at
/// the cursor's pre-edit column.
///
/// Called only when the cursor is at end-of-line and there's a
/// previous character to erase. Mid-line backspace falls
/// through to v0.1 round-trip per Path β scope.
///
/// # Post-audit round 2 (F15): style-blindness
///
/// The space is painted with default style (explicit reset
/// before `Print`). For end-of-line backspace this is correct
/// in nearly all v0.1 cases: the cell becomes empty / cleared,
/// and the daemon's eventual `CellDelta` for an empty cell is
/// itself default-styled. Same scope caveat as
/// [`Self::paint_optimistic_insert`] for any future milestone
/// where the post-erase cell might re-render with a non-default
/// background or syntax style.
#[cfg(feature = "crdt")]
pub fn paint_optimistic_delete_back(&mut self) -> io::Result<()> {
queue!(
self.out,
MoveLeft(1),
ResetColor,
SetAttribute(Attribute::Reset),
Print(' '),
MoveLeft(1)
)?;
self.out.flush()
}
/// Apply a single [`InstanceMessage`] to the terminal.
///
/// `CellDelta` emits one cursor-move + run-of-glyphs sequence per
@ -253,7 +342,33 @@ impl Frontend {
},
InstanceMessage::ModeLine(_)
| InstanceMessage::Signal(_)
| InstanceMessage::Goodbye(_) => {
| InstanceMessage::Goodbye(_)
// T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't
// maintain a local CRDT state yet (M10.8 wires that). A v2
// daemon shouldn't send CrdtOp to this frontend because our
// FrontendCapabilities advertise crdt_replica: false. If one
// arrives anyway, drop it silently — same v0.1-ignored
// category as ModeLine / Signal / Goodbye for now.
| InstanceMessage::CrdtOp { .. }
// T M10.6: PresenceUpdate joins the v0.1-ignored category.
// The peer-cursor overlay renderer is M10.8 work; until
// then any incoming PresenceUpdate is dropped silently.
| InstanceMessage::PresenceUpdate { .. }
// T M10.10: BufferSnapshot is consumed by the BufferMirror
// layer on M10.10-aware frontends (gated by negotiated
// `crdt_replica`). The legacy TUI render path here doesn't
// maintain a BufferMirror, so the variant drops silently
// in this path. The M10.10 frontend wiring intercepts
// BufferSnapshot in the attach.rs message loop BEFORE it
// reaches apply_message.
| InstanceMessage::BufferSnapshot { .. }
// T M10.10: CursorByte is paired with Cursor for replica
// frontends. The cursor's grid position (consumed by the
// legacy render path above via Cursor) drives paint; the
// byte position (consumed by BufferMirror's cursor tracker
// in attach.rs) drives optimistic-apply. The legacy path
// here only needs grid; the byte variant drops silently.
| InstanceMessage::CursorByte { .. } => {
// v0.1 TUI ignores these; v0.3 GUI consumes them.
}
}

View File

@ -36,9 +36,21 @@ use crate::keymap_tree::{Binding, Keymap};
/// [`BufferRegistry::find_by_name`].
pub const HELP_BUFFER_NAME: &str = "*help*";
/// Result of a render: the buffer id of `*help*`, or [`None`] if the
/// Result of a render: the buffer id of `*help*` paired with the
/// Edits produced by the content replacement, or [`None`] if the
/// described target doesn't exist (e.g. unknown command name).
pub type RenderResult = Option<BufferId>;
///
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// Returning the Edits (zero, one, or two — Delete for old
/// non-empty content + Insert for new non-empty content) lets the
/// caller queue any `crdt_op` they carry via
/// `EditorCore::queue_daemon_origin_crdt_op`. Without this, replica
/// frontends see the `*help*` repaint as `CellDelta` but never
/// update their `BufferMirror`s for the CRDT-backed `*help*`
/// buffer; subsequent optimistic edits on the replica would run
/// against stale mirror content.
pub type RenderResult = Option<(BufferId, Vec<crate::rope::Edit>)>;
// ---------------------------------------------------------------------------
// Render entry points
@ -278,27 +290,35 @@ fn write_mode_bindings(out: &mut String, map: &Keymap) {
}
}
fn replace_help_buffer(registry: &mut BufferRegistry, text: &str) -> BufferId {
fn replace_help_buffer(
registry: &mut BufferRegistry,
text: &str,
) -> (BufferId, Vec<crate::rope::Edit>) {
let id = registry
.find_by_name(HELP_BUFFER_NAME)
.unwrap_or_else(|| registry.create(HELP_BUFFER_NAME));
let buf = registry.get_mut(id).expect("just resolved");
let mut edits = Vec::new();
if !buf.is_empty() {
let len = buf.len();
let _ = buf.apply_edit(EditOp::Delete {
if let Ok(edit) = buf.apply_edit(EditOp::Delete {
range: crate::rope::Range::new(0, len),
});
}) {
edits.push(edit);
}
}
if !text.is_empty() {
let _ = buf.apply_edit(EditOp::Insert {
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
});
}) {
edits.push(edit);
}
}
// The help buffer is regenerated content; mark it clean so the
// modeline doesn't claim it has unsaved changes.
buf.mark_clean();
id
(id, edits)
}
// ---------------------------------------------------------------------------
@ -386,10 +406,11 @@ fn read_buffer_text(buf: &Buffer) -> String {
String::from_utf8(out).unwrap_or_default()
}
/// Result of [`follow_link_at`]: the help buffer id (re-rendered if a
/// link was found), or [`None`] if the cursor wasn't on a recognized
/// link.
pub type FollowResult = Option<BufferId>;
/// Result of [`follow_link_at`]: the help buffer id paired with the
/// Edits produced by the re-render (zero, one, or two), or [`None`]
/// if the cursor wasn't on a recognized link. Same broadcast-queueing
/// contract as [`RenderResult`].
pub type FollowResult = Option<(BufferId, Vec<crate::rope::Edit>)>;
/// Parse the link under the cursor in the `*help*` buffer and
/// re-render. Returns the help buffer id on success.
@ -489,7 +510,7 @@ mod tests {
},
)
.unwrap();
let id = render_command(&mut reg, &cmds, &kms, "cursor.left").unwrap();
let (id, _) = render_command(&mut reg, &cmds, &kms, "cursor.left").unwrap();
let body = read_buffer_text(reg.get(id).unwrap());
assert!(body.contains("Command: cursor.left"));
assert!(body.contains("Move cursor left."));
@ -521,7 +542,7 @@ mod tests {
},
)
.unwrap();
let id = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap();
let (id, _) = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap();
let body = read_buffer_text(reg.get(id).unwrap());
assert!(body.contains("Key: C-x C-s"));
assert!(body.contains("[command: save]"));
@ -690,7 +711,7 @@ mod tests {
// Find cursor on the cross-ref.
let body = read_help(&reg);
let cursor = body.find("beta").unwrap() as u64;
let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
assert_eq!(returned, id);
let body = read_help(&reg);
assert!(body.contains("Command: beta"), "{body}");
@ -724,7 +745,7 @@ mod tests {
"buffer-local key link must carry its buffer scope: {body}"
);
let cursor = body.find("s @buffer").unwrap() as u64;
let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap());
let body = read_help(&reg);
assert!(body.contains("Key: s"), "{body}");

View File

@ -25,7 +25,7 @@
use std::fmt::Write;
use crate::buffer::{BufferId, EditOp};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
use crate::protocol::{AttachmentHandle, InstanceIdentity};
@ -69,8 +69,14 @@ pub fn format_echo_line(
}
/// Render the full description into the `*pmacs-instance*` buffer
/// (creating it if absent), replacing its full contents. Returns the
/// buffer id.
/// (creating it if absent), replacing its full contents. Returns
/// the buffer id and the Edits produced by the replacement.
///
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// Returning the Edits lets the caller queue any `crdt_op` they
/// carry via `EditorCore::queue_daemon_origin_crdt_op`. See the
/// equivalent doc on `workers_buffer::render`.
///
/// The buffer is marked clean — the modeline shouldn't claim unsaved
/// changes for a generated buffer.
@ -78,26 +84,46 @@ pub fn render(
registry: &mut BufferRegistry,
identity: &InstanceIdentity,
attachment: Option<&AttachmentHandle>,
) -> BufferId {
) -> (BufferId, Vec<crate::rope::Edit>) {
let text = format_full_text(identity, attachment);
let id = registry
.find_by_name(INSTANCE_BUFFER_NAME)
.unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME));
let buf = registry.get_mut(id).expect("just resolved");
let mut edits = Vec::new();
if buffer_contents_equal(buf, &text) {
buf.mark_clean();
return (id, edits);
}
if !buf.is_empty() {
let len = buf.len();
let _ = buf.apply_edit(EditOp::Delete {
if let Ok(edit) = buf.apply_edit(EditOp::Delete {
range: crate::rope::Range::new(0, len),
});
}) {
edits.push(edit);
}
}
if !text.is_empty() {
let _ = buf.apply_edit(EditOp::Insert {
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
});
}) {
edits.push(edit);
}
}
buf.mark_clean();
id
(id, edits)
}
fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool {
if buf.len() != text.len() as u64 {
return false;
}
let mut bytes = vec![0u8; text.len()];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
bytes == text.as_bytes()
}
/// Multi-section text payload for the buffer view. Sections:
@ -360,7 +386,7 @@ mod tests {
#[test]
fn render_creates_named_buffer_and_writes_text() {
let mut reg = BufferRegistry::new();
let id = render(&mut reg, &local_identity(), None);
let (id, _edits) = render(&mut reg, &local_identity(), None);
let buf = reg.get(id).expect("just rendered");
assert_eq!(buf.name(), INSTANCE_BUFFER_NAME);
let len = buf.len();
@ -378,8 +404,8 @@ mod tests {
#[test]
fn render_replaces_existing_contents_on_second_call() {
let mut reg = BufferRegistry::new();
let id1 = render(&mut reg, &local_identity(), None);
let id2 = render(&mut reg, &named_identity(), None);
let (id1, _) = render(&mut reg, &local_identity(), None);
let (id2, _) = render(&mut reg, &named_identity(), None);
assert_eq!(id1, id2, "render must reuse the named buffer");
let buf = reg.get(id2).expect("rendered");
let len = buf.len();
@ -395,11 +421,26 @@ mod tests {
);
}
#[test]
fn render_same_identity_is_no_op() {
let mut reg = BufferRegistry::new();
let (_id, first_edits) = render(&mut reg, &local_identity(), None);
assert!(
!first_edits.is_empty(),
"initial render should create buffer contents"
);
let (_id, second_edits) = render(&mut reg, &local_identity(), None);
assert!(
second_edits.is_empty(),
"unchanged instance render must not emit delete/insert edits"
);
}
#[test]
fn render_with_attachment_includes_remote_section() {
let mut reg = BufferRegistry::new();
let h = sample_attachment();
let id = render(&mut reg, &local_identity(), Some(&h));
let (id, _) = render(&mut reg, &local_identity(), Some(&h));
let buf = reg.get(id).expect("rendered");
let len = buf.len();
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];

View File

@ -86,7 +86,17 @@ impl RenderState {
/// Returns a `CellDelta` (with the changed spans) followed by a
/// `Cursor` message. Returns an empty vec if the grid is too small
/// to render meaningfully (`rows < 2` or `cols == 0`).
pub fn render_frame(&mut self, state: &EditorState) -> Vec<InstanceMessage> {
///
/// `other_presences` (T M10.9): other attached frontends' cursor
/// and selection snapshots, with their assigned color slots.
/// The overlay paint pass modifies cells in `next` AFTER the
/// main paint and BEFORE the diff. Empty slice → no overlays
/// (in-process TUI use; M10.6/7 daemon use).
pub fn render_frame(
&mut self,
state: &EditorState,
other_presences: &[crate::overlay_paint::OtherPresence],
) -> Vec<InstanceMessage> {
if self.size.rows < 2 || self.size.cols == 0 {
return Vec::new();
}
@ -97,7 +107,17 @@ impl RenderState {
stride: self.size.cols,
size: self.size,
};
paint_frame(state, &mut grid, self.size)
let coord = paint_frame(state, &mut grid, self.size);
// T M10.9 — overlay paint after main paint, before diff.
// Modifies cells in `next`; diff captures the changes
// as ordinary style updates.
crate::overlay_paint::paint_other_frontend_overlays(
state,
&mut grid,
self.size,
other_presences,
);
coord
};
// Full-grid sync semantics (T M5.3): when `needs_full_grid` is
@ -166,7 +186,7 @@ mod tests {
#[test]
fn render_returns_cell_delta_and_cursor() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state(), &[]);
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. }));
assert!(matches!(msgs[1], InstanceMessage::Cursor(_)));
@ -175,7 +195,7 @@ mod tests {
#[test]
fn first_frame_is_full_grid_sync() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => panic!("expected CellDelta first"),
@ -185,8 +205,8 @@ mod tests {
#[test]
fn second_frame_is_differential() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state());
let _ = r.render_frame(&empty_state(), &[]);
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid),
_ => panic!("expected CellDelta first"),
@ -197,8 +217,8 @@ mod tests {
fn unchanged_state_produces_empty_spans_after_first_frame() {
let state = empty_state();
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&state);
let msgs = r.render_frame(&state);
let _ = r.render_frame(&state, &[]);
let msgs = r.render_frame(&state, &[]);
match &msgs[0] {
InstanceMessage::CellDelta { spans, .. } => assert!(
spans.is_empty(),
@ -211,7 +231,7 @@ mod tests {
#[test]
fn resize_reallocates_and_flags_full_grid() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state());
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(40, 120));
@ -220,7 +240,7 @@ mod tests {
assert_eq!(r.next.len(), 40 * 120);
assert!(r.needs_full_grid);
let msgs = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
_ => unreachable!(),
@ -230,7 +250,7 @@ mod tests {
#[test]
fn resize_to_same_size_is_noop() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state());
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.resize(CellSize::new(24, 80));
// No reallocation, no full-grid flip.
@ -240,7 +260,7 @@ mod tests {
#[test]
fn force_full_grid_resync_flips_flag() {
let mut r = RenderState::new(CellSize::new(24, 80));
let _ = r.render_frame(&empty_state());
let _ = r.render_frame(&empty_state(), &[]);
assert!(!r.needs_full_grid);
r.force_full_grid_resync();
assert!(r.needs_full_grid);
@ -250,16 +270,16 @@ mod tests {
fn too_small_grid_returns_empty_messages() {
// rows < 2 means we can't paint a text-area + status row.
let mut r = RenderState::new(CellSize::new(1, 80));
assert!(r.render_frame(&empty_state()).is_empty());
assert!(r.render_frame(&empty_state(), &[]).is_empty());
let mut r = RenderState::new(CellSize::new(24, 0));
assert!(r.render_frame(&empty_state()).is_empty());
assert!(r.render_frame(&empty_state(), &[]).is_empty());
}
#[test]
fn cursor_message_carries_coord_when_paint_returns_one() {
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[1] {
InstanceMessage::Cursor(Some(cs)) => {
assert!(cs.visible);
@ -289,7 +309,7 @@ mod tests {
// Criterion 1: the first frame after construction is a full-grid
// CellDelta carrying every non-default cell.
let mut r = RenderState::new(CellSize::new(24, 80));
let msgs = r.render_frame(&empty_state());
let msgs = r.render_frame(&empty_state(), &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "first frame must be flagged full_grid=true");
@ -315,7 +335,7 @@ mod tests {
let mut state = EditorState::new();
let mut r = RenderState::new(size);
// Seat the prev buffer.
let _ = r.render_frame(&state);
let _ = r.render_frame(&state, &[]);
// Single character insert.
state.dispatch_key(
@ -327,7 +347,7 @@ mod tests {
state: KeyEventState::empty(),
},
);
let msgs = r.render_frame(&state);
let msgs = r.render_frame(&state, &[]);
match &msgs[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid, "differential frame must not flag full_grid");
@ -354,7 +374,7 @@ mod tests {
let mut r = RenderState::new(size);
// First render: seats prev with the painted frame.
let first = r.render_frame(&empty_state());
let first = r.render_frame(&empty_state(), &[]);
let baseline_changed: usize = match &first[0] {
InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(),
_ => unreachable!(),
@ -363,7 +383,7 @@ mod tests {
// A second render with no state change normally produces zero
// spans (the state matches prev exactly).
let unchanged = r.render_frame(&empty_state());
let unchanged = r.render_frame(&empty_state(), &[]);
match &unchanged[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(!*full_grid);
@ -376,7 +396,7 @@ mod tests {
// what's on screen. force_full_grid_resync flags the next frame
// for full sync.
r.force_full_grid_resync();
let resync = r.render_frame(&empty_state());
let resync = r.render_frame(&empty_state(), &[]);
match &resync[0] {
InstanceMessage::CellDelta { full_grid, spans } => {
assert!(*full_grid, "post-resync frame must be full_grid=true");

View File

@ -38,6 +38,15 @@ pub mod command;
pub mod completion;
pub mod completion_framework;
pub mod config;
// T M10.2: CRDT-backed buffer state. Feature-gated so v0.1 builds
// carry zero overhead — the `loro` dependency isn't pulled in, no
// field on the Buffer struct layout, no branch on `apply_edit`.
#[cfg(feature = "crdt")]
pub mod crdt;
// T M10.10: frontend-side CRDT replica for optimistic local edits.
// Gated on `crdt` because BufferMirror wraps `CrdtState`.
#[cfg(feature = "crdt")]
pub mod buffer_mirror;
pub mod daemon;
pub mod daemon_attach;
pub mod definition;
@ -66,8 +75,16 @@ pub mod lua_isolation;
pub mod mcp;
pub mod message_bus;
pub mod minibuffer;
// T M10.10: frontend-side optimistic-apply infrastructure (predicate
// + echo-dedup filter). Gated on `crdt` because it consumes
// BufferMirror.
#[cfg(feature = "crdt")]
pub mod optimistic;
pub mod overlay;
pub mod overlay_color;
pub mod overlay_paint;
pub mod packages;
pub mod presence;
pub mod process;
pub mod project;
pub mod project_index;

View File

@ -409,8 +409,18 @@ impl LuaHost {
// switched to it via C-x b), its line cache would otherwise go
// stale on every appended error and cursor motion would stop
// updating the screen.
//
// Post-audit-round-6 F32 — also queue the resulting CRDT op
// for broadcast if the buffer is CRDT-backed. `*errors*`
// gets upgraded to CRDT at every replica's attach via
// `send_buffer_snapshots`, so each Lua-runtime-driven append
// produces an `Edit::crdt_op` that must reach replica
// `BufferMirror`s — otherwise their mirrors permanently
// desync from daemon state for `*errors*`.
if let Some(core) = self.core.as_ref() {
core.borrow_mut().notify_buffer_edit(id, &edit);
let mut core = core.borrow_mut();
core.notify_buffer_edit(id, &edit);
core.queue_daemon_origin_crdt_op(id, &edit);
}
}

View File

@ -1198,11 +1198,32 @@ fn add_history_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
}
/// Notify every window currently displaying `buffer_id` that the
/// buffer was just edited via the Lua surface. Without this, a window
/// already displaying the edited buffer would keep a stale
/// buffer was just edited via the Lua surface, AND queue the edit's
/// CRDT op (if any) for broadcast to replica frontends.
///
/// Without the window notification, a window already displaying the
/// edited buffer would keep a stale
/// [`crate::text_view::TextView`] line cache — cursor motions stop
/// updating the screen until the window switches buffers.
///
/// # Post-audit-round-5 F28: daemon-origin CRDT op broadcast
///
/// Lua-driven edits (`buf:insert`, `buf:delete`, `buf:replace`,
/// `buf:undo`, `buf:redo`) on CRDT-backed buffers produce Edits with
/// `crdt_op` populated. Without explicit broadcast queueing, those
/// ops never reach replica frontends — their `BufferMirror`s see the
/// resulting `CellDelta` repaint but never import the CRDT op, so
/// subsequent optimistic edits on the replica are generated against
/// stale mirror content.
///
/// We push the op as
/// [`crate::editor_core::CrdtOpOrigin::DaemonKey`] (via
/// `EditorCore::queue_daemon_origin_crdt_op`) so the broadcast sweep
/// includes every replica with no sender exclusion: no frontend
/// applied the op locally; every replica's mirror needs the bytes.
///
/// # No-op cases
///
/// No-op when no [`SharedCore`] has been registered as Lua app data
/// (the shape used by the early-stage tests that exercise the
/// registry without an editor core).
@ -1210,7 +1231,12 @@ fn notify_buffer_edit_to_windows(lua: &Lua, buffer_id: BufferId, edit: &crate::r
let Some(core) = lua.app_data_ref::<SharedCore>() else {
return;
};
core.borrow_mut().notify_buffer_edit(buffer_id, edit);
let mut core = core.borrow_mut();
core.notify_buffer_edit(buffer_id, edit);
// F28 — queue for broadcast. `queue_daemon_origin_crdt_op` is a
// no-op when the edit doesn't carry a `crdt_op` (the buffer
// wasn't CRDT-backed at edit time).
core.queue_daemon_origin_crdt_op(buffer_id, edit);
}
/// Force any window showing `buffer_id` to rebuild its `TextView`.
@ -2045,12 +2071,55 @@ fn install_instance_show_binding(lua: &Lua, registry: &SharedRegistry) -> mlua::
let attachment = lua
.app_data_ref::<CurrentAttachmentSlot>()
.and_then(|s| s.get());
let id =
let (id, edits) =
crate::instance_buffer::render(&mut reg.borrow_mut(), &identity, attachment.as_ref());
queue_generated_buffer_edits(lua, id, &edits);
if !edits.is_empty() {
rebuild_generated_buffer_views(lua, id);
}
Ok(BufferIdLua(id))
})
}
/// T M10.10 post-audit-round-6 F31 — queue every CRDT op produced
/// by a generated-buffer render to the daemon's broadcast queue.
///
/// The three generated buffers (`*help*`, `*workers*`,
/// `*pmacs-instance*`) get upgraded to CRDT-backed at every
/// replica's attach via `send_buffer_snapshots`. Each subsequent
/// regenerate (delete-all + insert-new) produces zero, one, or two
/// `Edit`s carrying `crdt_op`. Replicas need every `CrdtOp` so their
/// `BufferMirror`s converge with the daemon's new content for
/// these buffers; without queueing, the replicas see the
/// `CellDelta` repaint but their mirrors permanently desync.
///
/// Caller: every site in `lua_bindings.rs` that drives one of the
/// render functions. The render functions return their Edits
/// alongside the `BufferId` so this helper can queue them via
/// `EditorCore::queue_daemon_origin_crdt_op`.
///
/// No-op when:
/// - No `SharedCore` is registered as Lua app data (early-stage
/// tests use the registry without an editor core).
/// - The edits' buffer wasn't CRDT-backed (`queue_daemon_origin_crdt_op`
/// itself early-returns when the edit has no `crdt_op`).
fn queue_generated_buffer_edits(lua: &Lua, buffer_id: BufferId, edits: &[crate::rope::Edit]) {
let Some(core) = lua.app_data_ref::<SharedCore>() else {
return;
};
let mut core = core.borrow_mut();
for edit in edits {
core.queue_daemon_origin_crdt_op(buffer_id, edit);
}
}
fn rebuild_generated_buffer_views(lua: &Lua, buffer_id: BufferId) {
let Some(core) = lua.app_data_ref::<SharedCore>() else {
return;
};
core.borrow_mut().rebuild_views_for(buffer_id);
}
#[allow(clippy::too_many_lines)]
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
let buffer = lua.create_table()?;
@ -4024,10 +4093,11 @@ fn install_help_module(
let k = kms.borrow();
help::render_command(&mut r, &c, &k, &name)
};
if let Some(id) = result {
rebuild_help_buffer_views(lua, id);
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
rebuild_help_buffer_views(lua, *id);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(id, _)| BufferIdLua(id)))
})?,
)?;
}
@ -4052,10 +4122,11 @@ fn install_help_module(
let k = kms.borrow();
help::render_key(&mut r, &c, &k, active_buffer, &sequence)
};
if let Some(id) = result {
rebuild_help_buffer_views(lua, id);
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
rebuild_help_buffer_views(lua, *id);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(id, _)| BufferIdLua(id)))
})?,
)?;
}
@ -4069,10 +4140,11 @@ fn install_help_module(
let mut r = reg.borrow_mut();
help::render_buffer(&mut r, id.0)
};
if let Some(rid) = result {
rebuild_help_buffer_views(lua, rid);
if let Some((rid, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *rid, edits);
rebuild_help_buffer_views(lua, *rid);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(rid, _)| BufferIdLua(rid)))
})?,
)?;
}
@ -4088,10 +4160,11 @@ fn install_help_module(
let k = kms.borrow();
help::render_mode(&mut r, &k, &name)
};
if let Some(id) = result {
rebuild_help_buffer_views(lua, id);
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
rebuild_help_buffer_views(lua, *id);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(id, _)| BufferIdLua(id)))
})?,
)?;
}
@ -4107,10 +4180,11 @@ fn install_help_module(
let h = hks.borrow();
help::render_hook(&mut r, &h, &name)
};
if let Some(id) = result {
rebuild_help_buffer_views(lua, id);
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
rebuild_help_buffer_views(lua, *id);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(id, _)| BufferIdLua(id)))
})?,
)?;
}
@ -4124,10 +4198,11 @@ fn install_help_module(
let mut r = reg.borrow_mut();
help::render_view(&mut r, id.0)
};
if let Some(rid) = result {
rebuild_help_buffer_views(lua, rid);
if let Some((rid, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *rid, edits);
rebuild_help_buffer_views(lua, *rid);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(rid, _)| BufferIdLua(rid)))
})?,
)?;
}
@ -4148,10 +4223,11 @@ fn install_help_module(
let h = hks.borrow();
help::follow_link_at(&mut r, &c, &k, &h, cursor)
};
if let Some(id) = result {
rebuild_help_buffer_views(lua, id);
if let Some((id, edits)) = result.as_ref() {
queue_generated_buffer_edits(lua, *id, edits);
rebuild_help_buffer_views(lua, *id);
}
Ok(result.map(BufferIdLua))
Ok(result.map(|(id, _)| BufferIdLua(id)))
})?,
)?;
}
@ -5150,9 +5226,13 @@ pub fn install_async(
let reg = registry.clone();
async_mod.set(
"_show_workers_buffer",
lua.create_function(move |_, ()| {
lua.create_function(move |lua, ()| {
let snap = rt.workers_snapshot();
let id = workers_buffer::render(&mut reg.borrow_mut(), &snap);
let (id, edits) = workers_buffer::render(&mut reg.borrow_mut(), &snap);
queue_generated_buffer_edits(lua, id, &edits);
if !edits.is_empty() {
rebuild_generated_buffer_views(lua, id);
}
Ok(BufferIdLua(id))
})?,
)?;
@ -9666,7 +9746,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
lua.create_function(move |lua, ()| {
let c = cc.borrow();
let t = lua.create_table()?;
for (i, id) in c.layout.iter_ids().iter().enumerate() {
for (i, id) in c.active_layout().iter_ids().iter().enumerate() {
t.set(i + 1, id.raw())?;
}
Ok(t)
@ -9678,7 +9758,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
let cc = core.clone();
win.set(
"current",
lua.create_function(move |_, ()| Ok(cc.borrow().active.raw()))?,
lua.create_function(move |_, ()| Ok(cc.borrow().active_window_id().raw()))?,
)?;
}

1077
src/optimistic.rs Normal file

File diff suppressed because it is too large Load Diff

132
src/overlay_color.rs Normal file
View File

@ -0,0 +1,132 @@
//! T M10.9 — color + label palette for other-frontend cursor overlays.
//!
//! # Contract
//!
//! Each attached frontend gets one slot from a fixed palette. The
//! palette has [`PALETTE_LEN`] entries, mapping slot index → distinct
//! terminal color. Slots are assigned per Unix uid in the daemon
//! ([`crate::daemon::DaemonState::color_slot_for_uid`]); same uid
//! across reconnect → same slot (the M10.9 spec's "stable across
//! reconnect within a session" criterion).
//!
//! # Wire-format note
//!
//! The slot index → color mapping is not on the wire. The daemon
//! paints overlay cells directly into the recipient's grid using
//! the resolved color; frontends just render the resulting
//! `CellDelta`. Changing the palette is a daemon-only visual update,
//! not a protocol change.
use crate::cell::Color;
use crate::protocol::FrontendId;
/// Number of distinct slots in the palette.
///
/// 8 colors covers the typical multi-frontend deployment (24
/// attached frontends) with headroom. Beyond [`PALETTE_LEN`]
/// distinct uids, slot assignment wraps; two uids may share a
/// color (the same shape as a hash collision).
pub const PALETTE_LEN: usize = 8;
/// 8-color palette. Indexed by slot (0..[`PALETTE_LEN`]).
///
/// Chosen for visibility against typical terminal backgrounds
/// (both light and dark themes). All entries are explicit Rgb
/// tuples so the rendering is theme-independent — the palette
/// doesn't rely on terminal palette overrides.
pub const PALETTE: [Color; PALETTE_LEN] = [
Color::Rgb(0x00, 0xB7, 0xC3), // cyan
Color::Rgb(0xC2, 0x4F, 0xC2), // magenta
Color::Rgb(0x3A, 0xA0, 0x4F), // green
Color::Rgb(0xD8, 0xA0, 0x10), // gold
Color::Rgb(0x4A, 0x90, 0xE2), // blue
Color::Rgb(0xE0, 0x50, 0x50), // red
Color::Rgb(0xB0, 0xB0, 0xB0), // gray
Color::Rgb(0xA8, 0x70, 0x30), // brown
];
/// Resolve a slot index to a `Color`. Wraps via modulo for
/// safety, though the daemon should never produce out-of-range
/// slots.
#[must_use]
pub fn color_for_slot(slot: u8) -> Color {
PALETTE[(slot as usize) % PALETTE_LEN]
}
/// Label character for a `FrontendId`.
///
/// Returns `Some('A'..'Z')` for FrontendId(2)..FrontendId(27)
/// (daemon-attached frontends start at 2 because FrontendId(1)
/// is reserved for the in-process TUI). Returns `None` for
/// FrontendId(28) and beyond — labels are an aid, not a
/// requirement, and the colored cursor cell still distinguishes
/// the frontend.
///
/// v0.2+ may add username-based labels when user-identity
/// infrastructure lands. The v1.0 contract caps labels at 26 and
/// degrades gracefully past that.
#[must_use]
pub fn label_for_frontend_id(fid: FrontendId) -> Option<char> {
let raw = fid.0;
if (2..=27).contains(&raw) {
// FrontendId(2) → 'A', FrontendId(3) → 'B', etc.
let idx = u8::try_from(raw - 2).ok()?;
Some((b'A' + idx) as char)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn palette_has_documented_length() {
assert_eq!(PALETTE.len(), PALETTE_LEN);
}
#[test]
fn color_for_slot_returns_palette_entry() {
for slot in 0..PALETTE_LEN as u8 {
let color = color_for_slot(slot);
assert_eq!(color, PALETTE[slot as usize]);
}
}
#[test]
fn color_for_slot_wraps_modulo() {
assert_eq!(color_for_slot(0), color_for_slot(PALETTE_LEN as u8));
assert_eq!(color_for_slot(1), color_for_slot(PALETTE_LEN as u8 + 1));
}
#[test]
fn label_for_first_daemon_attached_is_a() {
assert_eq!(label_for_frontend_id(FrontendId(2)), Some('A'));
}
#[test]
fn label_for_z_boundary() {
assert_eq!(label_for_frontend_id(FrontendId(27)), Some('Z'));
}
#[test]
fn label_beyond_z_is_none() {
assert_eq!(label_for_frontend_id(FrontendId(28)), None);
assert_eq!(label_for_frontend_id(FrontendId(100)), None);
}
#[test]
fn label_for_local_is_none() {
// FrontendId(1) is LOCAL; never gets a label (it's the
// in-process TUI, not a peer that needs to be distinguished).
assert_eq!(label_for_frontend_id(FrontendId::LOCAL), None);
}
#[test]
fn label_for_zero_is_none() {
// Defensive — FrontendId(0) shouldn't exist but handle
// gracefully.
assert_eq!(label_for_frontend_id(FrontendId(0)), None);
}
}

440
src/overlay_paint.rs Normal file
View File

@ -0,0 +1,440 @@
//! T M10.9 — paint other-frontend cursor and selection overlays
//! into a recipient's grid.
//!
//! # Contract
//!
//! Called by [`crate::instance_render::RenderState::render_frame`]
//! AFTER the main paint pass writes the buffer content into the
//! recipient's `next` grid, and BEFORE the grid is diffed against
//! the previous frame. The overlay pass MODIFIES cells in place
//! — it doesn't insert / shift / re-layout. The grid size is
//! unchanged. The diff captures overlay changes as ordinary cell
//! diffs (style changes are visible to `Cell::PartialEq`).
//!
//! # Coordinate resolution
//!
//! **Source's byte position resolves to recipient's grid coordinates
//! via recipient's window layout.** Different recipients with
//! different viewports paint the source cursor at different grid
//! coordinates — that's correct: each recipient sees the overlay
//! where their own view places it.
//!
//! This forecloses the bug shape of "painted at source's coords"
//! (which would assume both ends share a viewport — they don't).
//!
//! # Cursor + label
//!
//! - Cursor cell: foreground color set to the source's assigned
//! palette color; `reverse: true` makes it stand out against
//! underlying text. The original glyph is preserved.
//! - Label: a single character (`'A'`..`'Z'` for `FrontendId`s 227;
//! `None` beyond) painted ONE row above the cursor cell. If
//! `row == 0`, painted ONE row below instead. Label uses the
//! source's color, no reverse.
//!
//! # Selection
//!
//! For each cell within the source's selection range that's
//! visible in the recipient's window: `underline = Single` plus
//! the source's color. Distinct from local selection's `reverse`
//! styling — a recipient can visually distinguish their own
//! selection from a remote one.
//!
//! # Filtering
//!
//! Overlays paint only when:
//! - Source != recipient (sender exclusion is the caller's
//! responsibility; we don't recheck here)
//! - The source's buffer matches at least one of the recipient's
//! windows' buffers
//! - The source's cursor maps to a coord visible in that window's
//! viewport (within `view_top` + `inner_rows`, within rect cols)
//!
//! Otherwise the overlay is silently skipped — no off-screen
//! indicator; M10.x may add one.
use crate::cell::{CellCoord, CellGrid, CellSize, Color, UnderlineStyle};
use crate::editor::EditorState;
use crate::overlay_color::{color_for_slot, label_for_frontend_id};
use crate::presence::PresenceSnapshot;
use crate::protocol::FrontendId;
use crate::view::View;
use crate::window::Rect;
/// One other-frontend presence, with the daemon's resolved color
/// slot. The dispatcher builds these from
/// [`crate::presence::SessionRegistry::other_presences_for`] +
/// the per-session color slot.
#[derive(Copy, Clone, Debug)]
pub struct OtherPresence {
/// The source frontend.
pub frontend_id: FrontendId,
/// The source's last-broadcast presence snapshot.
pub snapshot: PresenceSnapshot,
/// Palette slot index (0..[`crate::overlay_color::PALETTE_LEN`])
/// for the source's color. Resolved to `Color` via
/// [`color_for_slot`].
pub color_slot: u8,
}
/// Paint other-frontend cursor + selection overlays into the
/// recipient's grid.
///
/// `state.core.active_frontend` is the recipient. `grid` is the
/// recipient's `next` grid post-`paint_frame`. `term_size` is the
/// recipient's terminal dimensions.
///
/// See module docs for the painting semantics. This function is
/// idempotent within a single tick — calling it twice produces
/// the same final grid; the per-tick coalescing happens via
/// `SessionRegistry::sweep`.
pub fn paint_other_frontend_overlays(
state: &EditorState,
grid: &mut CellGrid,
term_size: CellSize,
other_presences: &[OtherPresence],
) {
if other_presences.is_empty() {
return;
}
if term_size.rows < 2 || term_size.cols == 0 {
return;
}
let core = state.core.borrow();
// Reserve the last row for status / minibuffer (same convention
// as `paint_frame`); overlays only paint into the text area.
let text_rows = term_size.rows.saturating_sub(1);
if text_rows == 0 {
return;
}
let text_area = Rect::new(0, 0, text_rows, term_size.cols);
let placements = core.active_layout().compute(text_area);
let registry = core.registry.clone();
let reg = registry.borrow();
for presence in other_presences {
let color = color_for_slot(presence.color_slot);
let label = label_for_frontend_id(presence.frontend_id);
// For each of the recipient's windows whose buffer matches
// the source's snapshot.buffer_id, paint the cursor in
// that window's viewport.
for (win_id, window) in &core.windows {
if window.buffer_id != presence.snapshot.buffer_id {
continue;
}
let Some(rect) = placements.get(win_id).copied() else {
continue;
};
let inner_rows = inner_rows_of(&rect);
if inner_rows == 0 || rect.size.cols == 0 {
continue;
}
let Ok(buf) = reg.get(window.buffer_id) else {
continue;
};
// Source's byte position → display coords via THIS
// recipient window's text_view (the recipient's view
// of the buffer).
let Some(disp) = window
.text_view
.pos_to_display(buf, presence.snapshot.cursor)
else {
continue;
};
// Filter to viewport visible range. `view_top` is the
// top visible buffer-line; cells below it are in-frame
// until `view_top + inner_rows`.
let row_in_window = match (disp.row as usize).checked_sub(window.view_top) {
Some(r) if r < inner_rows as usize => r,
_ => continue,
};
// Column bounds: disp.col is the buffer column; window
// doesn't horizontally scroll in v1.0, so cells past
// rect.size.cols are simply off-grid for this window.
if disp.col >= rect.size.cols {
continue;
}
let cursor_grid_row = rect.origin.row + row_in_window as u32;
let cursor_grid_col = rect.origin.col + disp.col;
paint_cursor_cell(grid, cursor_grid_row, cursor_grid_col, color);
if let Some(label_ch) = label {
paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color);
}
// Selection overlay. Iterate cells in the selection
// range visible in this window.
if let Some(sel) = presence.snapshot.selection {
let (lo, hi) = if sel.anchor <= sel.active {
(sel.anchor, sel.active)
} else {
(sel.active, sel.anchor)
};
paint_selection_in_window(grid, buf, window, rect, inner_rows, lo, hi, color);
}
}
}
}
/// Compute the inner rows of a window's rect — the text area,
/// excluding the bottom mode-line row.
fn inner_rows_of(rect: &Rect) -> u32 {
rect.size.rows.saturating_sub(1)
}
/// Paint the cursor cell for the source: set fg to the source's
/// color and toggle reverse. Preserves the underlying glyph.
fn paint_cursor_cell(grid: &mut CellGrid, row: u32, col: u32, color: Color) {
if row >= grid.size.rows || col >= grid.size.cols {
return;
}
let cell = grid.at(CellCoord::new(row, col));
cell.style.fg = color;
cell.style.reverse = !cell.style.reverse;
}
/// Paint the label character one row above the cursor (or below
/// if `row == 0`). The label cell uses the source's color
/// without reverse, so it's distinct from the cursor cell.
fn paint_label_cell(grid: &mut CellGrid, cursor_row: u32, col: u32, label: char, color: Color) {
let label_row = if cursor_row == 0 {
// Top edge: paint below.
cursor_row + 1
} else {
cursor_row - 1
};
if label_row >= grid.size.rows || col >= grid.size.cols {
return;
}
let cell = grid.at(CellCoord::new(label_row, col));
cell.glyph = crate::cell::Glyph::Char(label);
cell.style.fg = color;
cell.style.bold = true;
}
/// Paint the source's selection cells visible in this window.
/// Each cell within `[lo, hi)` that maps to a visible coord gets
/// `underline = Single` + the source's color.
#[allow(clippy::too_many_arguments)]
fn paint_selection_in_window(
grid: &mut CellGrid,
buf: &crate::buffer::Buffer,
window: &crate::window::Window,
rect: Rect,
inner_rows: u32,
lo: crate::rope::Position,
hi: crate::rope::Position,
color: Color,
) {
if lo >= hi {
return;
}
// Walk byte positions from lo to hi, mapping each to a
// display coord. Step in single-byte increments; pos_to_display
// tolerates byte-boundary positions and returns None for
// positions outside the buffer.
//
// This is O(N) in the selection's byte length. For typical
// selections (5100 cells), microseconds. For large selections
// (multi-megabyte), this would be too expensive — but the
// viewport bound makes this naturally cheap: cells outside
// the viewport are skipped via the row-bounds check, and the
// selection only PAINTS for cells in the viewport. We could
// restrict iteration to viewport-byte-range; for v1.0 the
// straightforward walk is fine.
let mut pos = lo;
while pos < hi {
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
break;
};
match (disp.row as usize).checked_sub(window.view_top) {
Some(r) if r < inner_rows as usize => {
if disp.col < rect.size.cols {
let grid_row = rect.origin.row + r as u32;
let grid_col = rect.origin.col + disp.col;
if grid_row < grid.size.rows && grid_col < grid.size.cols {
let cell = grid.at(CellCoord::new(grid_row, grid_col));
cell.style.underline = UnderlineStyle::Single;
// Use the source's color for the underline; if
// the cell already has a foreground style, the
// underline color comes from the fg. We don't
// override fg to preserve the cell's existing
// glyph appearance.
if cell.style.fg == Color::Default {
cell.style.fg = color;
}
}
}
}
_ => {
// Below viewport — no further cells in this window
// are visible if we're past `view_top + inner_rows`.
// But the selection might have skipped some bytes
// (multibyte char boundaries); we keep walking.
}
}
pos += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::BufferId;
use crate::cell::{Cell, CellSize, Glyph};
use crate::editor::EditorState;
use crate::protocol::SelectionSnapshot;
fn empty_grid(size: CellSize) -> Vec<Cell> {
vec![Cell::default(); (size.rows * size.cols) as usize]
}
fn make_grid(cells: &mut [Cell], size: CellSize) -> CellGrid<'_> {
CellGrid {
cells,
stride: size.cols,
size,
}
}
fn dummy_presence(
fid: FrontendId,
buffer_id: BufferId,
cursor: u64,
color_slot: u8,
) -> OtherPresence {
OtherPresence {
frontend_id: fid,
snapshot: PresenceSnapshot {
buffer_id,
cursor,
selection: None,
},
color_slot,
}
}
#[test]
fn empty_presences_no_paint() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
paint_other_frontend_overlays(&state, &mut grid, size, &[]);
assert_eq!(cells, original, "empty presences leaves grid unchanged");
}
#[test]
fn presence_in_different_buffer_no_paint() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
// BufferId::next() produces a unique id that doesn't match
// the in-process scratch buffer.
let presence = dummy_presence(FrontendId(2), BufferId::next(), 0, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
assert_eq!(
cells, original,
"presence in different buffer leaves grid unchanged"
);
}
#[test]
fn cursor_at_origin_paints_cell_and_label_below() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
// Get the scratch buffer's id so the presence matches.
let active_buf = state.core.borrow().active_buffer_id();
let presence = dummy_presence(FrontendId(2), active_buf, 0, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cursor at row 0, col 0: cell modified (reverse toggled,
// fg = palette[0]).
let cursor_cell = &cells[0];
assert_eq!(cursor_cell.style.fg, color_for_slot(0));
assert!(cursor_cell.style.reverse);
// Label painted BELOW the cursor since row 0 is the top.
let label_cell = &cells[(size.cols) as usize];
assert!(matches!(label_cell.glyph, Glyph::Char('A')));
assert_eq!(label_cell.style.fg, color_for_slot(0));
}
#[test]
fn frontend_beyond_26_paints_cursor_no_label() {
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
let presence = dummy_presence(FrontendId(28), active_buf, 0, 3);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cursor cell painted with slot 3's color.
let cursor_cell = &cells[0];
assert_eq!(cursor_cell.style.fg, color_for_slot(3));
assert!(cursor_cell.style.reverse);
// Label cell NOT painted (FrontendId 28 has no label).
let label_cell = &cells[(size.cols) as usize];
assert_eq!(*label_cell, Cell::default(), "no label for FrontendId(28)");
}
#[test]
fn cursor_off_grid_no_paint() {
// The scratch buffer is empty; pos_to_display for cursor
// beyond the buffer returns None → no paint.
let state = EditorState::new();
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let original = cells.clone();
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
// Cursor at byte 999 — far past the empty scratch buffer.
let presence = dummy_presence(FrontendId(2), active_buf, 999, 0);
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
assert_eq!(
cells, original,
"cursor at out-of-buffer position leaves grid unchanged"
);
}
#[test]
fn selection_paints_underline_on_visible_cells() {
// Construct a state with some buffer content so selection
// has cells to paint.
let state = EditorState::new();
// Insert a few chars so cursor positions 0..5 are valid.
state.core.borrow_mut().insert_char('h');
state.core.borrow_mut().insert_char('i');
state.core.borrow_mut().insert_char('!');
let size = CellSize::new(24, 80);
let mut cells = empty_grid(size);
let mut grid = make_grid(&mut cells, size);
let active_buf = state.core.borrow().active_buffer_id();
let mut presence = dummy_presence(FrontendId(2), active_buf, 0, 0);
presence.snapshot.selection = Some(SelectionSnapshot {
anchor: 0,
active: 3,
});
paint_other_frontend_overlays(&state, &mut grid, size, &[presence]);
// Cells 0, 1, 2 should have UnderlineStyle::Single.
for (col, cell) in cells.iter().enumerate().take(3) {
assert_eq!(
cell.style.underline,
UnderlineStyle::Single,
"cell {col} should be underlined"
);
}
// Cell 3 should NOT be underlined (selection is [0, 3) exclusive).
assert_eq!(cells[3].style.underline, UnderlineStyle::None);
}
}

810
src/presence.rs Normal file
View File

@ -0,0 +1,810 @@
//! T M10.6 — Per-tick presence broadcast.
//!
//! # Contract
//!
//! `SessionRegistry` is the daemon-side bookkeeping for the
//! `InstanceMessage::PresenceUpdate` flow:
//!
//! - Per-session **negotiated protocol version** so the per-tick
//! sweep can filter v0.1 recipients (presence didn't exist on the
//! v0.1 wire — the variant lives in the v1.0+ enum and v1 sessions
//! must never receive it).
//! - Per-source **last-broadcast snapshot** so the sweep can
//! short-circuit when nothing changed since the last tick. This is
//! the coalescing implementation: rapid cursor movement between
//! sweeps produces one broadcast carrying the *final* state, not
//! N broadcasts carrying intermediate values.
//!
//! # Equality discipline
//!
//! `PresenceSnapshot`'s `PartialEq` is exactly the wire-representation
//! equality: two snapshots compare equal iff the
//! `InstanceMessage::PresenceUpdate`s built from them would serialize
//! to identical bytes. The flat shape ([`Position`] = u64;
//! `Option<SelectionSnapshot>` is a flat pair of u64s) makes this
//! property structural — no internal state can affect equality
//! without also changing the wire bytes.
//!
//! If a future field is added to `PresenceSnapshot` or
//! `SelectionSnapshot` that does NOT affect the wire (e.g., a
//! daemon-internal annotation), the derive(PartialEq) needs
//! revisiting — otherwise the sweep emits spurious broadcasts on
//! changes the wire would not encode.
//!
//! # M10.6 single-frontend behavior
//!
//! The daemon is single-frontend in M10.6 — only one session is
//! registered at a time, and the sweep's sender-exclusion + v2-
//! recipient filter produces an empty broadcast list. The call site
//! exists; the data flow is wired; the recipient list is structurally
//! empty until M10.8 enables multi-attach.
//!
//! # M10.7 / M10.8 forward-pointers
//!
//! - M10.7 tightens recipient filtering to also require
//! `crdt_replica`/`multi_frontend` capability bits, not just v2
//! protocol. The current sweep takes only the negotiated version;
//! M10.7 will extend the session-state type to carry capabilities
//! and the sweep will consult both.
//! - M10.8 wires the multi-frontend session dispatcher. The tracker's
//! `register_session` / `unregister_session` API will be called per
//! attach/detach; the sweep's broadcast list becomes non-empty.
use std::collections::HashMap;
use crate::buffer::BufferId;
use crate::protocol::{FrontendId, InstanceMessage, NegotiatedCapabilities, SelectionSnapshot};
use crate::rope::Position;
/// T M10.7 — per-session daemon-internal state.
///
/// One entry per attached session, keyed by `FrontendId` in the
/// tracker's `sessions` map. M10.7 ships with two fields; future
/// milestones append fields with sensible defaults — e.g., M10.8 may
/// add per-session view state, M11 may add per-session keymap
/// overlays. Append-only growth keeps existing call sites valid.
///
/// **Module-location note**: `SessionState` lives in `presence.rs`
/// today because that's where M10.6 introduced the per-session
/// tracking. M10.8 will likely want this type accessible from a
/// session-routing module as well; relocating to a neutral location
/// (e.g., `src/session.rs`) is M10.8's call. M10.7 leaves it here
/// with this note.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct SessionState {
/// The protocol version negotiated during the handshake. Always
/// a member of `SUPPORTED_PROTOCOL_VERSIONS` (the daemon checks
/// before constructing this). v0.1 frontends produce `1`; v1.0+
/// frontends produce `2`.
pub negotiated_protocol_version: u32,
/// The capability bits negotiated during the handshake. v0.1
/// frontends always end up with all bits `false` (their wire
/// format does not carry capability fields; `#[serde(default)]`
/// produces `false` on the daemon side).
pub negotiated_capabilities: NegotiatedCapabilities,
/// T M10.9 — color palette slot for this session's overlay
/// rendering. Daemon assigns at attach time based on the
/// connecting peer's Unix uid (`SO_PEERCRED`); same uid across
/// reconnect → same slot. Slot resolves to a `Color` via
/// [`crate::overlay_color::color_for_slot`].
pub color_slot: u8,
}
impl SessionState {
/// Convenience constructor used by tests and the daemon's
/// handshake path. Takes the version, capabilities, and color
/// slot.
#[must_use]
pub fn new(
negotiated_protocol_version: u32,
negotiated_capabilities: NegotiatedCapabilities,
color_slot: u8,
) -> Self {
Self {
negotiated_protocol_version,
negotiated_capabilities,
color_slot,
}
}
}
/// One source frontend's presence at a tick boundary.
///
/// Equality is wire-equality — two snapshots compare equal iff they
/// would serialize to identical [`InstanceMessage::PresenceUpdate`]
/// bytes. See module docs for the discipline this implies.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct PresenceSnapshot {
/// Buffer the source frontend's cursor is in.
pub buffer_id: BufferId,
/// Byte offset of the source frontend's cursor within `buffer_id`.
pub cursor: Position,
/// Active selection range, if any.
pub selection: Option<SelectionSnapshot>,
}
/// One outbound presence broadcast: which session receives which message.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BroadcastEntry {
/// The session receiving the message. Sender exclusion is the
/// tracker's responsibility — the recipient is never the source.
pub recipient: FrontendId,
/// The wire message to send. Always
/// [`InstanceMessage::PresenceUpdate`] in M10.6.
pub message: InstanceMessage,
}
/// Per-tick presence diff + broadcast routing.
///
/// One instance lives on the daemon's per-attach path. M10.6's
/// single-frontend deployment means at most one session is registered
/// at a time; M10.8 generalizes to multiple sessions.
#[derive(Debug, Default)]
pub struct SessionRegistry {
/// Per-source last-broadcast snapshot. A source is present here
/// iff at least one sweep has emitted (or considered emitting) a
/// broadcast for it. `None` (absent) means "no prior state" —
/// the first sweep observes a change.
last_broadcast: HashMap<FrontendId, PresenceSnapshot>,
/// Per-session daemon-internal state — negotiated protocol
/// version + negotiated capability bits. M10.7 widened this from
/// a bare `u32` version to the richer `SessionState` once
/// capability negotiation became load-bearing. Recipients are
/// filtered on `negotiated_capabilities.multi_frontend` (M10.7);
/// v0.1 sessions naturally fail the filter because their
/// declared bit defaults to `false` and the AND with any
/// instance bit is `false`.
sessions: HashMap<FrontendId, SessionState>,
}
impl SessionRegistry {
/// Fresh tracker with no sessions and no prior broadcasts.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Register a session with its negotiated state (T M10.7).
///
/// Called on attach after both the version-check predicate
/// (`is_supported_protocol_version`) and the capability
/// negotiation (`negotiate_capabilities`) have accepted the
/// request. The `SessionState` carries the negotiated version
/// AND the negotiated capability bits — M10.7 widened this from
/// the M10.6 bare-`u32` shape.
pub fn register_session(&mut self, frontend_id: FrontendId, state: SessionState) {
self.sessions.insert(frontend_id, state);
}
/// Unregister a session on detach. Drops both the session entry
/// and any last-broadcast state for that frontend so a future
/// re-attach starts with no prior state.
pub fn unregister_session(&mut self, frontend_id: FrontendId) {
self.sessions.remove(&frontend_id);
self.last_broadcast.remove(&frontend_id);
}
/// The negotiated state for `frontend_id`, or `None` if no
/// session is registered for it.
#[must_use]
pub fn session_state(&self, frontend_id: FrontendId) -> Option<SessionState> {
self.sessions.get(&frontend_id).copied()
}
/// Number of registered sessions. Useful for tests + diagnostics.
#[must_use]
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// T M10.9 — gather `OtherPresence` entries for the overlay
/// renderer to paint into `recipient`'s grid.
///
/// Returns `(source, snapshot, color_slot)` triples for every
/// registered session whose:
/// - `source != recipient` (sender exclusion)
/// - `negotiated_capabilities.multi_frontend` is true (same
/// filter as `sweep`; v0.1 sessions don't broadcast presence)
/// - has a `last_broadcast` entry (i.e., has produced a snapshot
/// the sweep observed)
///
/// The recipient itself doesn't need a `multi_frontend`
/// capability check — the caller only invokes this for
/// recipients that will RECEIVE overlays, which by definition
/// means they're multi-capable.
#[must_use]
pub fn other_presences_for(
&self,
recipient: FrontendId,
) -> Vec<crate::overlay_paint::OtherPresence> {
let mut out = Vec::new();
for (&source, state) in &self.sessions {
if source == recipient {
continue;
}
if !state.negotiated_capabilities.multi_frontend {
continue;
}
if let Some(&snapshot) = self.last_broadcast.get(&source) {
out.push(crate::overlay_paint::OtherPresence {
frontend_id: source,
snapshot,
color_slot: state.color_slot,
});
}
}
out
}
/// Sweep: given per-source current snapshots, return the
/// broadcasts to send this tick.
///
/// For each `(source, snapshot)`:
/// 1. If `snapshot == last_broadcast[source]`, no change → no
/// broadcast for this source.
/// 2. Otherwise update `last_broadcast[source]` and emit one
/// [`BroadcastEntry`] per recipient where recipient is
/// registered, recipient != source (sender exclusion), and
/// the recipient's negotiated version is `>= 2` (v0.1 filter).
///
/// Coalescing is structural: the sweep is called once per tick;
/// multiple cursor movements between sweeps are observed as one
/// snapshot (the final state). The N-moves-coalesce-to-1
/// property follows from the sweep cadence, not from any
/// timestamp / counter inside the snapshot.
///
/// In M10.6 single-frontend deployments: the recipient list is
/// structurally empty (sender exclusion with no other sessions),
/// so the returned vec is always empty even if the snapshot
/// changed. The construct-then-fan-out shape is preserved so
/// M10.8 doesn't restructure the code; only the recipient set
/// grows.
pub fn sweep(&mut self, current: &[(FrontendId, PresenceSnapshot)]) -> Vec<BroadcastEntry> {
let mut out = Vec::new();
for (source, snapshot) in current {
let changed = self
.last_broadcast
.get(source)
.is_none_or(|prev| prev != snapshot);
if !changed {
continue;
}
self.last_broadcast.insert(*source, *snapshot);
// Build the wire message once per source; the recipient
// list may be empty (M10.6 single-frontend), in which
// case the message is constructed but never serialized.
// M10.8 enables non-empty recipient lists.
let message = InstanceMessage::PresenceUpdate {
frontend_id: *source,
buffer_id: snapshot.buffer_id,
cursor: snapshot.cursor,
selection: snapshot.selection,
};
for (&recipient, state) in &self.sessions {
if recipient == *source {
continue;
}
// T M10.7: filter on the negotiated capability bit.
// M10.6 filtered on `version >= 2`; M10.7 tightens to
// `multi_frontend = true`. v0.1 sessions naturally
// fail the filter (their declared bit defaults to
// false → AND with instance is false). v1.0 sessions
// that declined `multi_frontend` during negotiation
// also fail — they opted into single-frontend mode.
if !state.negotiated_capabilities.multi_frontend {
continue;
}
out.push(BroadcastEntry {
recipient,
message: message.clone(),
});
}
}
out
}
/// T M10.8 Day 4 — broadcast one `InstanceMessage::CrdtOp` to
/// every registered session that negotiated `crdt_replica: true`,
/// excluding the source.
///
/// Cadence differs from [`Self::sweep`]: presence sweeps run
/// once per tick; CRDT-op broadcasts run once per edit event
/// that produced a `Edit::crdt_op` payload. Keeping them as
/// separate methods reflects the cadence difference and avoids
/// awkward "one of these arguments is the presence, the other
/// is the CRDT op" signature overloads.
///
/// Sender exclusion: when `exclude` is `Some(fid)`, that frontend
/// is filtered out (it already applied the op via its local
/// mirror — see M10.10 post-audit-round-3 F16 / `CrdtOpOrigin`).
/// When `exclude` is `None`, every `crdt_replica`-capable
/// recipient receives the op including any frontend that may
/// have driven the daemon's mutation via `FrontendEvent::Key`
/// (whose mirror is otherwise stale). Recipient filter:
/// `negotiated_capabilities.crdt_replica == true`.
pub fn broadcast_crdt_op(
&self,
exclude: Option<FrontendId>,
buffer_id: BufferId,
op: crate::rope::CrdtOp,
) -> Vec<BroadcastEntry> {
let mut out = Vec::new();
let message = InstanceMessage::CrdtOp { buffer_id, op };
for (&recipient, state) in &self.sessions {
if Some(recipient) == exclude {
continue;
}
if !state.negotiated_capabilities.crdt_replica {
continue;
}
out.push(BroadcastEntry {
recipient,
message: message.clone(),
});
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(buffer_id: BufferId, cursor: Position) -> PresenceSnapshot {
PresenceSnapshot {
buffer_id,
cursor,
selection: None,
}
}
/// Session state for a v2 frontend that negotiated multi-frontend
/// capability — the "presence-eligible recipient" case.
/// Color slot 0 is the test default (palette[0]).
fn multi_session() -> SessionState {
SessionState::new(
2,
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
},
0,
)
}
/// Session state for a v0.1 frontend — `multi_frontend` defaults
/// to false (the v0.1 wire format doesn't carry the field;
/// `#[serde(default)]` produces false on the daemon side). This
/// is the "presence filtered out" recipient case.
fn legacy_session() -> SessionState {
SessionState::new(
1,
NegotiatedCapabilities {
multi_frontend: false,
crdt_replica: false,
},
0,
)
}
#[test]
fn new_is_empty() {
let t = SessionRegistry::new();
assert_eq!(t.session_count(), 0);
assert_eq!(t.session_state(FrontendId(2)), None);
}
#[test]
fn register_and_unregister_session() {
let mut t = SessionRegistry::new();
t.register_session(FrontendId(2), multi_session());
assert_eq!(t.session_count(), 1);
assert_eq!(
t.session_state(FrontendId(2))
.map(|s| s.negotiated_protocol_version),
Some(2)
);
t.unregister_session(FrontendId(2));
assert_eq!(t.session_count(), 0);
assert_eq!(t.session_state(FrontendId(2)), None);
}
#[test]
fn sweep_excludes_sender_in_single_frontend() {
// T M10.6 acceptance — sender exclusion. A multi-frontend
// session sees its own cursor move; sweep produces no
// broadcast because the only recipient candidate is the
// sender itself.
let mut t = SessionRegistry::new();
let fid = FrontendId(2);
t.register_session(fid, multi_session());
let buf = BufferId::next();
let out = t.sweep(&[(fid, snap(buf, 10))]);
assert!(
out.is_empty(),
"sender excluded — single-frontend sweep should produce no broadcast, got {out:?}"
);
}
#[test]
fn sweep_excludes_recipient_without_multi_frontend_capability() {
// T M10.7 (was M10.6 v1-filter test) — recipients without
// negotiated multi_frontend are filtered out. v0.1 sessions
// naturally fail because their declared bit defaults to
// false; v1.0 sessions that declined multi_frontend during
// negotiation also fail (they opted into single-frontend
// mode).
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let legacy_recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(legacy_recipient, legacy_session());
let buf = BufferId::next();
let out = t.sweep(&[(src, snap(buf, 10))]);
assert!(
out.is_empty(),
"recipient without multi_frontend filtered out — got {out:?}"
);
}
#[test]
fn sweep_broadcasts_to_multi_frontend_recipient() {
// T M10.6/7 acceptance — multi-frontend source + multi-
// frontend recipient: one message. This is the M10.8 case;
// the tracker handles it correctly even though M10.6/7's
// daemon doesn't admit multiple sessions.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
let out = t.sweep(&[(src, snap(buf, 10))]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].recipient, recipient);
match &out[0].message {
InstanceMessage::PresenceUpdate {
frontend_id,
cursor,
..
} => {
assert_eq!(*frontend_id, src);
assert_eq!(*cursor, 10);
}
other => panic!("expected PresenceUpdate, got {other:?}"),
}
}
#[test]
fn sweep_suppresses_when_snapshot_unchanged() {
// T M10.6 acceptance — diff suppression. Second sweep with
// identical snapshot produces no broadcast even though
// recipients exist.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
let s = snap(buf, 10);
let out1 = t.sweep(&[(src, s)]);
assert_eq!(out1.len(), 1, "first sweep broadcasts");
let out2 = t.sweep(&[(src, s)]);
assert!(
out2.is_empty(),
"second sweep with unchanged snapshot is suppressed, got {out2:?}"
);
}
#[test]
fn sweep_coalesces_intermediate_moves_to_final_state() {
// T M10.6 acceptance — coalescing. The sweep is called once
// per tick, observing the snapshot at the moment of the
// sweep. Multiple cursor moves between sweeps appear to the
// tracker as one snapshot change (from prev to final). The
// coalescing-to-1 property is structural: the daemon calls
// sweep once per tick, regardless of how many cursor moves
// happened during the tick.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
// Tick 1: snapshot at cursor=10. First sweep broadcasts.
let out1 = t.sweep(&[(src, snap(buf, 10))]);
assert_eq!(out1.len(), 1);
// Between ticks 1 and 2: cursor moves 10 → 20 → 30 → 99
// (intermediate moves happen, but no sweep). Tick 2 observes
// the final snapshot (cursor=99) only.
let out2 = t.sweep(&[(src, snap(buf, 99))]);
assert_eq!(out2.len(), 1, "tick 2 broadcasts once");
match &out2[0].message {
InstanceMessage::PresenceUpdate { cursor, .. } => {
assert_eq!(
*cursor, 99,
"broadcast carries final snapshot value (99), not any intermediate (20, 30, …)"
);
}
other => panic!("expected PresenceUpdate, got {other:?}"),
}
}
#[test]
fn sweep_first_tick_is_change() {
// T M10.6 — the first tick after a session registers has no
// prior state in last_broadcast. The diff treats absent-prior
// as "changed" so the initial cursor position is broadcast
// to any registered multi-frontend recipients.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
let out = t.sweep(&[(src, snap(buf, 0))]);
assert_eq!(out.len(), 1, "first sweep broadcasts initial state");
}
#[test]
fn unregister_clears_last_broadcast() {
// T M10.6 — re-attaching after unregister starts fresh. The
// last_broadcast entry is dropped on unregister so the next
// sweep after re-register sees absent-prior and broadcasts.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
// First attach: broadcast initial state.
let out1 = t.sweep(&[(src, snap(buf, 10))]);
assert_eq!(out1.len(), 1);
// Detach + re-attach: state cleared.
t.unregister_session(src);
t.register_session(src, multi_session());
// First sweep after re-attach: still cursor=10, but the
// last_broadcast was cleared on unregister, so the diff says
// "changed" and we broadcast.
let out2 = t.sweep(&[(src, snap(buf, 10))]);
assert_eq!(
out2.len(),
1,
"re-attach with same cursor still broadcasts (last_broadcast was cleared)"
);
}
#[test]
fn sweep_handles_selection_diff() {
// T M10.6 — selection change alone (cursor unchanged) is a
// diff and triggers broadcast. Equality is wire-equality, so
// selection: None vs Some(anchor=cursor=10) compare unequal.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, multi_session());
t.register_session(recipient, multi_session());
let buf = BufferId::next();
let without_sel = PresenceSnapshot {
buffer_id: buf,
cursor: 10,
selection: None,
};
let with_sel = PresenceSnapshot {
buffer_id: buf,
cursor: 10,
selection: Some(SelectionSnapshot {
anchor: 5,
active: 10,
}),
};
let _ = t.sweep(&[(src, without_sel)]);
let out = t.sweep(&[(src, with_sel)]);
assert_eq!(out.len(), 1, "selection-only change broadcasts");
}
#[test]
fn sweep_multiple_sources_produce_independent_broadcasts() {
// T M10.6 — when M10.8 enables multiple multi-frontend
// sources, each source's snapshot diff is independent.
// Per-tick sweep emits one broadcast per (source, recipient)
// pair where the source's snapshot changed.
let mut t = SessionRegistry::new();
let a = FrontendId(2);
let b = FrontendId(3);
let c = FrontendId(4);
t.register_session(a, multi_session());
t.register_session(b, multi_session());
t.register_session(c, multi_session());
let buf = BufferId::next();
// Tick 1: A at 10, B at 20. Both change (no prior). C is a
// recipient of both A and B (and a sender to A and B, but
// C didn't move so no broadcast originates from C).
let out = t.sweep(&[(a, snap(buf, 10)), (b, snap(buf, 20))]);
// From A: broadcasts to B and C (2 entries).
// From B: broadcasts to A and C (2 entries).
// Total: 4 broadcasts.
assert_eq!(
out.len(),
4,
"two sources × two recipients each = 4 entries"
);
// Tick 2: A unchanged, B moved to 25.
let out2 = t.sweep(&[(a, snap(buf, 10)), (b, snap(buf, 25))]);
// From A: unchanged, no broadcast.
// From B: changed, broadcasts to A and C.
assert_eq!(out2.len(), 2, "only B's change produces broadcasts");
}
#[test]
fn sweep_with_no_sources_is_noop() {
// Defensive — empty current list is the trivial case (no
// attached frontends moved this tick). The sweep returns
// empty without touching last_broadcast.
let mut t = SessionRegistry::new();
t.register_session(FrontendId(2), multi_session());
let out = t.sweep(&[]);
assert!(out.is_empty());
}
// T M10.8 Day 4 — broadcast_crdt_op filter matrix.
/// Session state for a v2 frontend that negotiated `crdt_replica` capability.
fn crdt_session() -> SessionState {
SessionState::new(
2,
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
},
0,
)
}
/// Session state for a v2 frontend that opted out of `crdt_replica`.
fn no_crdt_session() -> SessionState {
SessionState::new(
2,
NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
},
0,
)
}
fn dummy_crdt_op() -> crate::rope::CrdtOp {
crate::rope::CrdtOp {
peer_id: 7,
bytes: vec![0xDE, 0xAD, 0xBE, 0xEF],
}
}
#[test]
fn broadcast_crdt_op_excludes_sender() {
// M10.8 acceptance criterion 1 (sender exclusion): a source
// doesn't receive its own CRDT op back.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
t.register_session(src, crdt_session());
let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op());
assert!(
out.is_empty(),
"sender excluded; single-session broadcast empty: {out:?}"
);
}
#[test]
fn broadcast_crdt_op_routes_to_crdt_capable_recipient() {
// M10.8 acceptance criterion 3 (capability filter): a
// recipient that negotiated crdt_replica receives.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient = FrontendId(3);
t.register_session(src, crdt_session());
t.register_session(recipient, crdt_session());
let buf = BufferId::next();
let out = t.broadcast_crdt_op(Some(src), buf, dummy_crdt_op());
assert_eq!(out.len(), 1);
assert_eq!(out[0].recipient, recipient);
match &out[0].message {
InstanceMessage::CrdtOp {
buffer_id,
op: crate::rope::CrdtOp { peer_id, bytes },
} => {
assert_eq!(*buffer_id, buf);
assert_eq!(*peer_id, 7);
assert_eq!(bytes, &vec![0xDE, 0xAD, 0xBE, 0xEF]);
}
other => panic!("expected CrdtOp, got {other:?}"),
}
}
#[test]
fn broadcast_crdt_op_filters_recipient_without_crdt_replica() {
// M10.8 acceptance criterion 3: recipient with
// `crdt_replica: false` doesn't receive.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let recipient_no_crdt = FrontendId(3);
t.register_session(src, crdt_session());
t.register_session(recipient_no_crdt, no_crdt_session());
let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op());
assert!(
out.is_empty(),
"recipient with crdt_replica=false filtered out: {out:?}"
);
}
#[test]
fn broadcast_crdt_op_filters_legacy_recipient() {
// v0.1 sessions have `crdt_replica: false` by default — the
// M10.5 wire format doesn't carry the field and
// `#[serde(default)]` produces false. They're naturally
// filtered out.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let legacy_recipient = FrontendId(3);
t.register_session(src, crdt_session());
t.register_session(legacy_recipient, legacy_session());
let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op());
assert!(out.is_empty(), "legacy recipient filtered out: {out:?}");
}
#[test]
fn broadcast_crdt_op_routes_to_multiple_recipients() {
// M10.8 multi-attach case: one source, two crdt-capable
// recipients → 2 broadcasts.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
let r1 = FrontendId(3);
let r2 = FrontendId(4);
t.register_session(src, crdt_session());
t.register_session(r1, crdt_session());
t.register_session(r2, crdt_session());
let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op());
assert_eq!(out.len(), 2);
let recipients: std::collections::HashSet<_> = out.iter().map(|e| e.recipient).collect();
assert!(recipients.contains(&r1));
assert!(recipients.contains(&r2));
}
#[test]
fn broadcast_crdt_op_no_recipients_is_noop() {
// Source is the only session; broadcast empty.
let mut t = SessionRegistry::new();
let src = FrontendId(2);
t.register_session(src, crdt_session());
let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op());
assert!(out.is_empty());
}
/// M10.10 post-audit-round-3 F16: `exclude = None` broadcasts
/// to **all** crdt-capable replicas including the frontend
/// whose `Key` event drove the edit (its mirror is stale
/// otherwise).
#[test]
fn broadcast_crdt_op_none_exclude_reaches_all_replicas() {
let mut t = SessionRegistry::new();
let a = FrontendId(2);
let b = FrontendId(3);
t.register_session(a, crdt_session());
t.register_session(b, crdt_session());
let out = t.broadcast_crdt_op(None, BufferId::next(), dummy_crdt_op());
let recipients: std::collections::HashSet<_> = out.iter().map(|e| e.recipient).collect();
assert!(
recipients.contains(&a) && recipients.contains(&b),
"F16: None-exclude must include the active frontend whose mirror is stale"
);
assert_eq!(out.len(), 2);
}
}

View File

@ -295,6 +295,31 @@ pub enum FrontendEvent {
/// Frontend is going away. Instance treats this as immediate
/// detach; no acknowledgement required.
Detach(FrontendId),
/// T M10.5: CRDT operation produced by this frontend's local
/// edit, sent to the instance for broadcast to the other
/// attached frontends. The actual flow that produces these
/// (frontend maintaining a local CRDT state, applying edits
/// optimistically, sending the resulting op) is wired in M10.8
/// + M10.10; M10.5 declares the wire shape so the protocol
/// version bump (1 → 2) covers it.
///
/// Only sent by v1.0 frontends (`protocol_version = 2`); v0.1
/// frontends never emit this variant. Sessions negotiated at
/// protocol version 1 must NOT receive this on the
/// instance-side dispatcher (the daemon filters per-session;
/// the editor-core treats it as an unknown frontend event if
/// it ever arrives from a v1 session, which it shouldn't).
CrdtOp {
/// Which attached frontend produced this op. The instance
/// uses this to avoid echoing the op back to its sender.
frontend_id: FrontendId,
/// Which buffer this op affects. The instance routes the
/// op to that buffer's CRDT state.
buffer_id: crate::buffer::BufferId,
/// The CRDT operation payload — `peer_id` + opaque wire bytes
/// loro's `import_updates` decodes.
op: crate::rope::CrdtOp,
},
}
impl FrontendEvent {
@ -308,7 +333,8 @@ impl FrontendEvent {
| Self::Paste { frontend_id, .. }
| Self::FocusGained(frontend_id)
| Self::FocusLost(frontend_id)
| Self::Detach(frontend_id) => *frontend_id,
| Self::Detach(frontend_id)
| Self::CrdtOp { frontend_id, .. } => *frontend_id,
}
}
}
@ -361,6 +387,27 @@ pub enum GoodbyeReason {
/// Frontend sent a malformed message or otherwise violated the
/// protocol. The connection is closed without further dialogue.
ProtocolError,
/// T M10.7: frontend declared one or more negotiated capability
/// bits that the instance cannot honor. The handshake fails after
/// the version check but before any further messages.
///
/// `missing` lists the capability *field names* (e.g.,
/// `"multi_frontend"`, `"crdt_replica"`) the frontend requested
/// (`true`) that the instance reports as `false`. These strings
/// are stable wire-format identifiers: they are exactly the
/// `FrontendCapabilities` / `InstanceCapabilities` field names,
/// not human-readable descriptions. The frontend translates them
/// for display via [`AttachError`]'s formatting. Renaming a
/// capability bit requires changing both the field name AND the
/// missing-string emission in `negotiate_capabilities` in
/// lockstep — see the M10.7 audit's wire-format-stability
/// section.
CapabilityMismatch {
/// The capability bit names the frontend asked for that the
/// instance does not support. Each entry is a verbatim
/// `FrontendCapabilities` field name.
missing: Vec<String>,
},
}
/// Rendering and signals from instance to frontend.
@ -388,6 +435,173 @@ pub enum InstanceMessage {
Signal(InstanceSignal),
/// Instance is terminating the attachment.
Goodbye(GoodbyeReason),
/// T M10.5: CRDT operation broadcast from the instance to all
/// attached frontends. The originating frontend produced this op
/// (via `FrontendEvent::CrdtOp` or via a local editor-core edit
/// that synthesizes one); the instance fans it out so every
/// attached frontend can apply the op to its local CRDT state.
///
/// Only sent to v1.0 frontends — sessions negotiated at
/// `protocol_version = 1` never receive this variant, per
/// `§sec:m10-backward-compat`. The daemon filters at the
/// outgoing-message path; this variant simply existing in the
/// enum is not a wire-compat issue for v1 sessions because the
/// daemon never emits it to them.
///
/// M10.5 declares the wire shape. M10.8 wires the editor-core →
/// daemon → frontend flow that actually emits these.
CrdtOp {
/// Which buffer this op affects. v1.0 frontends maintain
/// a per-buffer local CRDT state; this routes to the right
/// one.
buffer_id: crate::buffer::BufferId,
/// The CRDT operation payload — `peer_id` + opaque wire bytes
/// loro's `import_updates` decodes.
op: crate::rope::CrdtOp,
},
/// T M10.6: cursor + selection state of one attached frontend,
/// broadcast to the other v1.0 frontends so they can render
/// peer-presence overlays. Coalesced at the daemon: rapid cursor
/// movement produces one `PresenceUpdate` per tick per source
/// frontend, carrying the *final* state, not intermediate values.
///
/// Sender exclusion: the source frontend never receives its own
/// `PresenceUpdate`. v0.1 sessions (negotiated `protocol_version =
/// 1`) are filtered out at the daemon's outgoing-message path.
///
/// M10.6 declares the wire shape AND wires the daemon-side
/// sweep with per-session filter. In single-frontend deployments
/// the recipient list is structurally empty (sender exclusion
/// with no other v2 sessions); M10.8 enables the multi-frontend
/// case where this message actually crosses the wire. The
/// frontend's renderer for peer-cursor overlays is also M10.8.
PresenceUpdate {
/// Which attached frontend this presence belongs to. v1.0
/// frontends use this to label the peer-cursor overlay
/// ("user 4 is editing here").
frontend_id: FrontendId,
/// Which buffer the source frontend's cursor is in.
buffer_id: crate::buffer::BufferId,
/// Byte offset of the source frontend's cursor within
/// `buffer_id`. Frontends convert to line/column at render
/// time via the rope's coord-mapping; the wire carries the
/// canonical byte offset to avoid encoding-vs-rendering
/// drift across frontends.
cursor: crate::rope::Position,
/// Active selection range, if any.
selection: Option<SelectionSnapshot>,
},
/// T M10.10: bootstrap a frontend's local CRDT replica with the
/// instance's current authoritative state. Sent once per active
/// buffer at `SessionEstablished` time (and on subsequent
/// buffer-creation events) to frontends that negotiated
/// `crdt_replica: true`. Frontends that didn't negotiate the
/// capability never receive this variant — the daemon's
/// outgoing-message filter gates the send on
/// `NegotiatedCapabilities::crdt_replica`.
///
/// `crdt_snapshot` carries loro's run-encoded snapshot
/// (`CrdtState::export_snapshot()`) — the CRDT-internal state
/// including peer IDs, version vectors, and op-history structure.
/// Raw byte contents are insufficient because a fresh CRDT replica
/// initialized from bytes alone diverges on the first concurrent
/// edit.
///
/// Cursor position is intentionally absent: cursor is per-frontend
/// window state (M10.8 `FrontendView`), not per-buffer CRDT
/// state. The same buffer can appear in multiple windows on one
/// frontend with different cursors; coupling cursor to
/// `BufferSnapshot` would break this model.
BufferSnapshot {
/// Which buffer's CRDT state this snapshot represents.
buffer_id: crate::buffer::BufferId,
/// `loro::LoroDoc::export(ExportMode::Snapshot)` output. Applied
/// to a fresh `CrdtState::new(peer_id_from_frontend(my_id))`
/// via `import_snapshot(bytes)` on the receiving frontend.
crdt_snapshot: Vec<u8>,
},
/// T M10.10: the active buffer for a replica frontend, with the
/// cursor position within it.
///
/// # Semantics (Day 3 step 3b composition-check broadened
/// contract)
///
/// `CursorByte` represents "the active buffer for this frontend
/// is `buffer_id`; the cursor in that buffer is at `byte_pos`."
/// Not just "the cursor moved." This contract matters: a narrow
/// "cursor moved" emission would miss active-buffer-changed-
/// without-cursor-motion events (Lua-driven buffer switch
/// landing at the same byte position), and the frontend's
/// active-buffer tracking would go stale.
///
/// Daemon emits `CursorByte` on every per-tick render frame for
/// replica frontends, derived fresh from `active_window_for(fid)`.
/// Cursor move, active-buffer change, and active-window change
/// all produce a new emission carrying the current `(buffer_id,
/// byte_pos)`. The per-tick rate (16ms at 60Hz) is the same as
/// `Cursor`'s grid-coord variant.
///
/// # Why a separate variant from `Cursor`
///
/// `Cursor` carries grid coordinates (row/col cells) — the
/// frontend uses them to paint the cursor. The optimistic-apply
/// path needs byte position (CRDT insert/delete is byte-indexed),
/// which the grid coordinate can't recover without duplicating
/// the daemon's view-layout logic (tab expansion, line wrap,
/// double-width chars, viewport offset). `CursorByte` is the
/// authoritative byte position for the active buffer.
///
/// # Atomicity with `Cursor`
///
/// The daemon emits `Cursor` and `CursorByte` together for
/// replica frontends — both derived from the same render-frame
/// iteration so they describe the cursor in the same instant in
/// two reference frames. Non-replica frontends receive only
/// `Cursor` (existing behavior). The replica frontend that sees
/// `Cursor` without a paired `CursorByte` would interpret stale
/// byte position; the daemon guarantees both emit together by
/// derivation, not by message-protocol atomicity.
///
/// # Wire-format compatibility
///
/// New variant in v2; receivers without M10.10 hard-error on
/// decode (postcard does not gracefully degrade unknown variants,
/// per M10.10-FRAMING.md Refinement 3). Capability-gated: daemon
/// sends only to frontends that negotiated `crdt_replica: true`.
/// `PROTOCOL_VERSION` stays at 2.
CursorByte {
/// The buffer the cursor is in. A replica frontend tracks
/// per-buffer cursors; this routes the update to the right
/// entry.
buffer_id: crate::buffer::BufferId,
/// Byte offset of the cursor within `buffer_id`. Source of
/// truth for the optimistic-apply path's insert / delete
/// position arguments. Wire type matches
/// `PresenceUpdate::cursor` (`u64`) for consistency; frontend
/// converts to `usize` for the loro API.
byte_pos: crate::rope::Position,
},
}
/// Flat selection state for the wire.
///
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
/// of byte offsets — `anchor` is where the selection began,
/// `active` is the current selection cursor. Either may be the
/// numerically larger value; callers wanting `(lo, hi)` order
/// compute it locally.
///
/// Kept flat (no nested types) so [`PartialEq`] equality is exactly
/// wire-representation equality: two `SelectionSnapshot`s compare
/// equal iff they serialize to identical bytes. The presence-diff
/// sweep relies on this — see [`crate::presence::SessionRegistry`].
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SelectionSnapshot {
/// Where the selection began.
pub anchor: crate::rope::Position,
/// The active end (typically the cursor at the moment of the
/// snapshot).
pub active: crate::rope::Position,
}
// ---------------------------------------------------------------------------
@ -891,9 +1105,39 @@ impl AttachmentHandle {
/// Wire-protocol version. Bumped on any breaking change to the
/// `Hello` / `AttachRequest` / event-message shapes.
///
/// The handshake compares the two sides' values; mismatches close the
/// connection with [`GoodbyeReason::VersionMismatch`].
pub const PROTOCOL_VERSION: u32 = 1;
/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`];
/// mismatches close the connection with
/// [`GoodbyeReason::VersionMismatch`]. v1.0 servers and clients accept
/// either the v0.1 wire (version 1) or the v1.0 wire (version 2) per
/// `§sec:m10-backward-compat` — both directions of the version
/// asymmetry need symmetric relaxation so v0.1-era binaries connect
/// to v1.0-era binaries (and vice versa) once both have shipped.
///
/// T M10.5: bumped from 1 to 2. The v0.1 wire (version 1) remains
/// accepted by v1.0 binaries; CRDT-only message variants
/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered
/// per-session for v1 negotiated sessions.
pub const PROTOCOL_VERSION: u32 = 2;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat
/// spec section describes is handled symmetrically on both sides.
///
/// The handshake check is "is the peer's `protocol_version` present in
/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The
/// session's negotiated version (the peer's) is recorded for
/// downstream filtering: v1 sessions don't receive
/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from
/// a v2 daemon.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
#[must_use]
pub fn is_supported_protocol_version(peer_version: u32) -> bool {
SUPPORTED_PROTOCOL_VERSIONS.contains(&peer_version)
}
/// Identifies an instance for client-side display.
///
@ -954,9 +1198,73 @@ impl InstanceIdentity {
///
/// Empty for v0.1; the type exists so that adding capabilities in v0.2+
/// is not a breaking-change. Symmetric with [`FrontendCapabilities`].
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
///
/// T M10.5: added `multi_frontend` and `crdt_replica` bits with
/// `#[serde(default)]` so v1 wire bytes still deserialize. The
/// negotiation logic (which side advertises what, and what the
/// instance does with mismatches) is M10.7 scope; M10.5 just makes
/// the bit positions stable in the wire format.
///
/// T M10.5/8: bit defaults evolve with the substrate.
///
/// - M10.5 declared the bits with `#[serde(default)]` so v1 wire
/// bytes deserialize forward-compatibly. M10.5M10.7 set both bits
/// to `false` so a frontend declaring `multi_frontend: true` got
/// `Goodbye(CapabilityMismatch)` — the multi-frontend path
/// wasn't actually wired yet.
/// - **T M10.8 Day 4 flip**: the instance's `multi_frontend` and
/// `crdt_replica` defaults flip to `true`. This is the "M10.8 enables
/// multi-frontend" moment — the underlying dispatcher (Day 3) and
/// broadcast routing (Day 4) support both capabilities, so the
/// instance advertises them.
///
/// The frontend-side defaults remain `false` (a frontend that omits
/// the field is conservatively treated as not supporting the
/// capability; matches v0.1 wire-format semantics).
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct InstanceCapabilities {
// No fields in v0.1. Reserved for future expansion.
/// T M10.5: instance can host multi-frontend sessions on the
/// same buffer (per `§sec:m10-collab`). T M10.8 Day 4: default
/// flipped to `true` — the dispatcher supports multiple
/// attached frontends.
#[serde(default = "default_true")]
pub multi_frontend: bool,
/// T M10.5: instance can broadcast `InstanceMessage::CrdtOp`
/// messages. T M10.8 Day 4: default flipped to `true` — the
/// broadcast routing for CRDT ops wires up in this milestone.
#[serde(default = "default_true")]
pub crdt_replica: bool,
}
// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")`
// evaluates to `false`, making this impl derivable. In CRDT builds
// the values are `true`, so the impl is genuinely manual. Allow.
#[allow(clippy::derivable_impls)]
impl Default for InstanceCapabilities {
fn default() -> Self {
// T M10.10 — the `crdt_replica` default tracks the `crdt`
// Cargo feature. A daemon built without the `crdt` feature
// can't honor a `crdt_replica: true` negotiation (the
// CRDT-handling code paths are conditionally compiled out
// — `send_buffer_snapshots`, `apply_remote_crdt_op`, the
// dispatcher's CursorByte emit). Advertising `true`
// unconditionally would be wire-protocol false advertising.
//
// `multi_frontend` is conceptually independent of CRDT but
// in M10.10's architecture every multi-frontend participant
// is also a CRDT replica; gating both on the same feature
// keeps the daemon's advertised capabilities consistent
// with what it can actually do.
Self {
multi_frontend: cfg!(feature = "crdt"),
crdt_replica: cfg!(feature = "crdt"),
}
}
}
#[allow(clippy::missing_const_for_fn)]
fn default_true() -> bool {
true
}
/// Capabilities the frontend advertises to the instance.
@ -996,6 +1304,106 @@ pub struct FrontendCapabilities {
/// branching is done on the explicit capability bits above.
#[serde(default)]
pub terminal_kind: Option<String>,
/// T M10.5: frontend can participate in multi-frontend sessions
/// (per `§sec:m10-collab`). false for v0.1 frontends — they
/// attach as single-frontend and never receive `CrdtOp` /
/// `PresenceUpdate` broadcasts. v1.0 frontends opt in via M10.7's
/// negotiation handshake. M10.5 declares the bit position; M10.7
/// wires the negotiation.
///
/// Default is `false` — v1 frontends are treated as not
/// supporting this feature, which matches reality (v1 frontends
/// have no local CRDT state). A `true` default would have v1
/// frontends claimed to support features they don't.
#[serde(default)]
pub multi_frontend: bool,
/// T M10.5: frontend can apply incoming `CrdtOp` messages to a
/// local CRDT state. false for v0.1; v1.0 opts in. M10.7 wires
/// negotiation; M10.5 declares the bit position.
#[serde(default)]
pub crdt_replica: bool,
}
/// T M10.7 — the negotiated capability bits for one attached session.
///
/// Computed by [`negotiate_capabilities`] from the frontend's
/// [`FrontendCapabilities`] and the instance's [`InstanceCapabilities`].
/// Each negotiated bit is the AND of the two declared bits. Fields
/// added here in future milestones append at the end with sensible
/// defaults so existing call sites stay valid.
///
/// This is a daemon-internal struct (not on the wire); the
/// negotiation result is communicated to the frontend via the
/// success of the handshake (no capability-mismatch `Goodbye`) and
/// the instance's behavior thereafter.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct NegotiatedCapabilities {
/// Session is eligible for multi-frontend operation. True iff
/// both the frontend and the instance declared `multi_frontend =
/// true`. v0.1 frontends always end up here as `false` (the v0.1
/// wire format does not carry the field; `#[serde(default)]`
/// makes the deserialized value `false`).
pub multi_frontend: bool,
/// Session can produce/consume `InstanceMessage::CrdtOp` /
/// `FrontendEvent::CrdtOp`. True iff both sides declared
/// `crdt_replica = true`. The daemon's outgoing-message filter for
/// `CrdtOp` consults this in M10.8.
pub crdt_replica: bool,
}
/// T M10.7 — pure-function capability negotiation.
///
/// For each negotiated bit (`multi_frontend`, `crdt_replica`):
///
/// | Frontend wants | Instance has | Result |
/// |----------------|--------------|--------|
/// | `false` | `false` | bit `false`, no error |
/// | `false` | `true` | bit `false`, no error |
/// | `true` | `true` | bit `true`, no error |
/// | `true` | `false` | bit appears in `missing` |
///
/// If any bit ends up in `missing`, the negotiation fails as a whole
/// (returns `Err`). Otherwise the negotiated bits are returned as
/// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing
/// bits into one `CapabilityMismatch` — one round-trip carries the
/// complete picture rather than serial rejections.
///
/// # Wire-format stability
///
/// The strings emitted into `missing` are exactly the
/// `FrontendCapabilities` field names (`"multi_frontend"`,
/// `"crdt_replica"`). These are stable wire-format identifiers, not
/// human-readable descriptions. User-facing translation is the
/// frontend's responsibility (see [`AttachError`]'s `Display` impl).
/// Renaming a capability bit requires updating both the field name
/// and the missing-string emission here in lockstep.
pub fn negotiate_capabilities(
frontend: &FrontendCapabilities,
instance: &InstanceCapabilities,
) -> Result<NegotiatedCapabilities, GoodbyeReason> {
let mut missing = Vec::new();
let multi_frontend = match (frontend.multi_frontend, instance.multi_frontend) {
(true, false) => {
missing.push("multi_frontend".to_string());
false
}
(a, b) => a && b,
};
let crdt_replica = match (frontend.crdt_replica, instance.crdt_replica) {
(true, false) => {
missing.push("crdt_replica".to_string());
false
}
(a, b) => a && b,
};
if missing.is_empty() {
Ok(NegotiatedCapabilities {
multi_frontend,
crdt_replica,
})
} else {
Err(GoodbyeReason::CapabilityMismatch { missing })
}
}
/// First message sent by the instance to a freshly-attached frontend.
@ -2169,10 +2577,24 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_one_for_v01() {
// Pin the value: every wire-shape change in v0.1 patch releases
// must keep this constant or break the handshake.
assert_eq!(PROTOCOL_VERSION, 1);
fn protocol_version_is_two_for_v10() {
// Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire
// adds CrdtOp / PresenceUpdate variants; the v1.0 binary
// serves both v1 and v2 sessions per §sec:m10-backward-compat.
assert_eq!(PROTOCOL_VERSION, 2);
}
#[test]
fn supported_protocol_versions_includes_one_and_two() {
// T M10.5: v1.0 binaries accept both wire versions during the
// handshake. v0.1 binaries (with their strict-equality check)
// accepted only v1; this is the symmetric relaxation that
// makes §sec:m10-backward-compat hold once both binaries ship.
assert!(is_supported_protocol_version(1));
assert!(is_supported_protocol_version(2));
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(3));
assert!(!is_supported_protocol_version(u32::MAX));
}
#[test]
@ -2205,6 +2627,8 @@ mod tests {
mouse: true,
bracketed_paste: true,
terminal_kind: Some("xterm-256color".into()),
multi_frontend: false,
crdt_replica: false,
},
initial_size: CellSize::new(50, 200),
};
@ -2377,4 +2801,490 @@ mod tests {
other => panic!("expected Key, got {other:?}"),
}
}
// -----------------------------------------------------------------
// T M10.5 round-trip tests for the new wire variants.
// -----------------------------------------------------------------
#[test]
fn instance_message_crdt_op_round_trips_through_postcard() {
// Synthetic CrdtOp with known peer_id + arbitrary bytes.
// Verifies the protocol-level serialization shape. The
// real-loro-bytes variant is in the test below.
let msg = InstanceMessage::CrdtOp {
buffer_id: crate::buffer::BufferId::next(),
op: crate::rope::CrdtOp {
peer_id: 0x1234_5678_9abc_def0,
bytes: vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD],
},
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::CrdtOp {
op: crate::rope::CrdtOp { peer_id, bytes: ob },
..
} => {
assert_eq!(peer_id, 0x1234_5678_9abc_def0);
assert_eq!(ob, vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD]);
}
other => panic!("expected CrdtOp, got {other:?}"),
}
}
#[test]
fn frontend_event_crdt_op_round_trips_through_postcard() {
let ev = FrontendEvent::CrdtOp {
frontend_id: FrontendId(42),
buffer_id: crate::buffer::BufferId::next(),
op: crate::rope::CrdtOp {
peer_id: 99,
bytes: vec![0xAA, 0xBB, 0xCC],
},
};
let bytes = postcard::to_allocvec(&ev).expect("encode");
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
match decoded {
FrontendEvent::CrdtOp {
frontend_id, op, ..
} => {
assert_eq!(frontend_id, FrontendId(42));
assert_eq!(op.peer_id, 99);
assert_eq!(op.bytes, vec![0xAA, 0xBB, 0xCC]);
}
other => panic!("expected FrontendEvent::CrdtOp, got {other:?}"),
}
}
#[cfg(feature = "crdt")]
#[test]
fn instance_message_crdt_op_round_trips_with_real_loro_bytes() {
// T M10.5 framing-pass addition: use actual loro-exported
// bytes (not synthetic) so the test catches surprising
// interactions between loro's wire format and postcard's
// encoding. Also logs the per-CrdtOp wire byte size — a
// reference number M10.8's broadcast-cost reasoning relies on.
use crate::crdt::CrdtState;
let state = CrdtState::new(7).expect("CRDT state");
let pre_version = state.version();
state.insert(0, "hello world").expect("insert");
let real_bytes = state.export_updates_since(&pre_version).expect("export");
let real_bytes_len = real_bytes.len();
let msg = InstanceMessage::CrdtOp {
buffer_id: crate::buffer::BufferId::next(),
op: crate::rope::CrdtOp {
peer_id: 7,
bytes: real_bytes.clone(),
},
};
let postcard_bytes = postcard::to_allocvec(&msg).expect("encode");
let postcard_len = postcard_bytes.len();
eprintln!(
"[T M10.5 wire-size] real-loro CrdtOp for `hello world` insert:\n \
loro export bytes: {} B\n \
postcard-encoded InstanceMessage::CrdtOp: {} B\n \
protocol overhead: {} B (BufferId + peer_id + framing)",
real_bytes_len,
postcard_len,
postcard_len.saturating_sub(real_bytes_len)
);
let decoded: InstanceMessage = postcard::from_bytes(&postcard_bytes).expect("decode");
match decoded {
InstanceMessage::CrdtOp { op, .. } => {
assert_eq!(op.peer_id, 7);
assert_eq!(
op.bytes, real_bytes,
"loro bytes must round-trip identically"
);
// Verify the round-tripped bytes apply on a remote
// CrdtState and produce the originating state's
// projection — the property M10.5's wire codec must
// preserve for M10.8's broadcast path to work.
let receiver = CrdtState::new(99).expect("receiver");
receiver.import_updates(&op.bytes).expect("import");
assert_eq!(receiver.materialize_string(), "hello world");
}
other => panic!("expected CrdtOp, got {other:?}"),
}
}
// -----------------------------------------------------------------
// T M10.5 — backward-compat handshake matrix tests.
//
// Four cases per the framing-pass handshake matrix:
// 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested.
// 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch.
// 3. v2 daemon ↔ v1 frontend: success; v1 session.
// 4. v2 daemon ↔ v2 frontend: success; v2 session.
//
// These tests exercise `is_supported_protocol_version` directly
// since the full daemon-attach path requires socket setup that's
// in m5_5_acceptance.rs. The version-check predicate is the
// load-bearing piece; daemon-level integration tests are in the
// separate integration test file.
// -----------------------------------------------------------------
#[test]
fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() {
// The relaxation that makes §sec:m10-backward-compat hold.
assert!(
is_supported_protocol_version(1),
"v2 daemon must accept v1 frontend per §sec:m10-backward-compat"
);
}
#[test]
fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() {
// The new case M10.5 enables.
assert!(
is_supported_protocol_version(2),
"v2 daemon must accept v2 frontend (the v1.0 happy path)"
);
}
#[test]
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
// v1 daemon's strict-equality behavior is documented at the
// v0.1 code level (different binary); v2 daemon's range check
// rejects v3+ until v0.2 ships.
assert!(!is_supported_protocol_version(0));
assert!(!is_supported_protocol_version(3));
assert!(!is_supported_protocol_version(u32::MAX));
}
#[test]
fn m10_5_strict_equality_v1_frontend_simulation() {
// T M10.5 framing-pass risk #5 verification: existing v1
// frontends (the v0.1.0 release codebase, pre-M10.5) do
// strict equality on Hello.protocol_version. Simulate that
// check explicitly so the audit doc has empirical evidence
// of the actual backward-compat surface.
//
// Before M10.5: `if hello.protocol_version != 1 { reject }`.
// After M10.5: `if !is_supported_protocol_version(...) { reject }`.
//
// For a v1-strict-frontend connecting to a v2 daemon: the
// daemon's Hello carries protocol_version=2; the v1-strict
// frontend rejects with VersionMismatch.
fn v1_strict_check(hello_version: u32) -> bool {
hello_version == 1
}
// v1-strict frontend hitting v2 daemon's Hello: rejected.
assert!(
!v1_strict_check(2),
"v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \
can NOT connect to v2 daemons even though v2 daemons accept their requests"
);
// v1-strict frontend hitting v1 daemon's Hello: accepted.
assert!(v1_strict_check(1));
// For comparison, M10.5's relaxed check (v2 frontend after this milestone):
assert!(is_supported_protocol_version(1));
assert!(is_supported_protocol_version(2));
}
// T M10.6 — PresenceUpdate wire shape tests.
#[test]
fn instance_message_presence_update_round_trips_no_selection() {
let msg = InstanceMessage::PresenceUpdate {
frontend_id: FrontendId(42),
buffer_id: crate::buffer::BufferId::next(),
cursor: 100,
selection: None,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
#[test]
fn instance_message_presence_update_round_trips_with_selection() {
let msg = InstanceMessage::PresenceUpdate {
frontend_id: FrontendId(7),
buffer_id: crate::buffer::BufferId::next(),
cursor: 500,
selection: Some(SelectionSnapshot {
anchor: 480,
active: 500,
}),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
#[test]
fn presence_update_typical_size_under_64_bytes() {
// T M10.6 size acceptance — typical case: cursor at offset
// 100 in a small buffer, no selection. Should be well under
// 64B (varint encoding of small u64s is 1-2 bytes each).
let msg = InstanceMessage::PresenceUpdate {
frontend_id: FrontendId(2),
buffer_id: crate::buffer::BufferId::next(),
cursor: 100,
selection: None,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let size = bytes.len();
eprintln!(
"[T M10.6 wire-size] PresenceUpdate typical (cursor=100, no selection): {size} B"
);
assert!(
size < 64,
"typical PresenceUpdate is {size} B; spec target is <64 B"
);
}
#[test]
fn presence_update_worst_case_size_recorded() {
// T M10.6 size acceptance — worst case: max u64 values for
// every position field, selection present spanning a large
// range. Varint encoding of u64::MAX is 10 bytes; this is
// the upper bound on a single PresenceUpdate's wire size.
// Recording the actual number for the audit doc.
let msg = InstanceMessage::PresenceUpdate {
frontend_id: FrontendId(u64::MAX),
buffer_id: crate::buffer::BufferId::next(),
cursor: u64::MAX,
selection: Some(SelectionSnapshot {
anchor: 0,
active: u64::MAX,
}),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let size = bytes.len();
eprintln!(
"[T M10.6 wire-size] PresenceUpdate worst-case (all-max u64s + selection): {size} B"
);
// Worst-case bound: 1 (variant tag) + 10 (frontend_id) + ~2
// (BufferId varint — small) + 10 (cursor) + 1 (Some tag) +
// 10 (anchor zero = 1B) + 10 (active = u64::MAX = 10B) = ~44
// upper bound. Buffer-id is freshly minted so its varint
// encoding is small. We assert <64 to cover the spec target,
// and log the actual number for the audit.
assert!(
size < 64,
"worst-case PresenceUpdate is {size} B; spec target is <64 B"
);
}
// -----------------------------------------------------------------
// T M10.10 round-trip + size tests for BufferSnapshot.
// -----------------------------------------------------------------
#[test]
fn instance_message_buffer_snapshot_round_trips_through_postcard() {
// Synthetic loro-snapshot bytes — the wire-level test is
// independent of the actual loro encoding.
let msg = InstanceMessage::BufferSnapshot {
buffer_id: crate::buffer::BufferId::next(),
crdt_snapshot: vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF],
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => {
assert_eq!(
crdt_snapshot,
vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF]
);
}
other => panic!("expected BufferSnapshot, got {other:?}"),
}
}
#[test]
fn instance_message_cursor_byte_round_trips_through_postcard() {
let msg = InstanceMessage::CursorByte {
buffer_id: crate::buffer::BufferId::next(),
byte_pos: 12345,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::CursorByte { byte_pos, .. } => assert_eq!(byte_pos, 12345),
other => panic!("expected CursorByte, got {other:?}"),
}
}
#[test]
fn instance_message_cursor_byte_zero_position_round_trips() {
let msg = InstanceMessage::CursorByte {
buffer_id: crate::buffer::BufferId::next(),
byte_pos: 0,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert!(matches!(
decoded,
InstanceMessage::CursorByte { byte_pos: 0, .. }
));
}
#[test]
fn instance_message_buffer_snapshot_empty_snapshot_round_trips() {
// An empty CRDT (no edits yet) — loro's export produces a
// small but non-zero byte string. The wire layer must round-trip
// a zero-length crdt_snapshot regardless of whether loro ever
// emits one.
let msg = InstanceMessage::BufferSnapshot {
buffer_id: crate::buffer::BufferId::next(),
crdt_snapshot: vec![],
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
match decoded {
InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => {
assert!(crdt_snapshot.is_empty());
}
other => panic!("expected BufferSnapshot, got {other:?}"),
}
}
// T M10.7 — capability negotiation matrix + error round-trip.
/// Build a `FrontendCapabilities` with the M10-era negotiated
/// bits set as specified and all other fields at their default.
fn front_caps(multi_frontend: bool, crdt_replica: bool) -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend,
crdt_replica,
..FrontendCapabilities::default()
}
}
fn inst_caps(multi_frontend: bool, crdt_replica: bool) -> InstanceCapabilities {
InstanceCapabilities {
multi_frontend,
crdt_replica,
}
}
#[test]
fn negotiate_neither_side_declares_anything() {
let res = negotiate_capabilities(&front_caps(false, false), &inst_caps(false, false))
.expect("ok");
assert!(!res.multi_frontend);
assert!(!res.crdt_replica);
}
#[test]
fn negotiate_frontend_silent_instance_offers() {
// Frontend didn't request, instance has — frontend's silence
// is accepted as "single-frontend subset is fine."
let res =
negotiate_capabilities(&front_caps(false, false), &inst_caps(true, true)).expect("ok");
assert!(!res.multi_frontend, "frontend didn't ask → doesn't get");
assert!(!res.crdt_replica, "frontend didn't ask → doesn't get");
}
#[test]
fn negotiate_both_sides_declare_multi_frontend() {
let res =
negotiate_capabilities(&front_caps(true, false), &inst_caps(true, false)).expect("ok");
assert!(res.multi_frontend);
assert!(!res.crdt_replica);
}
#[test]
fn negotiate_both_sides_declare_both_bits() {
let res =
negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok");
assert!(res.multi_frontend);
assert!(res.crdt_replica);
}
#[test]
fn negotiate_frontend_wants_multi_instance_lacks() {
// T M10.7 criterion 4 — mismatch produces clear error
// naming what was requested vs available.
let err = negotiate_capabilities(&front_caps(true, false), &inst_caps(false, false))
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(missing, vec!["multi_frontend".to_string()]);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_frontend_wants_crdt_replica_instance_lacks() {
let err = negotiate_capabilities(&front_caps(false, true), &inst_caps(false, false))
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(missing, vec!["crdt_replica".to_string()]);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_frontend_wants_both_instance_lacks_both() {
// Multiple missing bits land in a single CapabilityMismatch
// — one round-trip carries the complete picture.
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false))
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(
missing,
vec!["multi_frontend".to_string(), "crdt_replica".to_string()]
);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn negotiate_partial_mismatch_only_lists_missing() {
// Frontend wants both, instance has multi but not crdt:
// only crdt_replica lands in `missing`.
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(true, false))
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
assert_eq!(missing, vec!["crdt_replica".to_string()]);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
#[test]
fn goodbye_capability_mismatch_round_trips() {
let msg = InstanceMessage::Goodbye(GoodbyeReason::CapabilityMismatch {
missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()],
});
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
#[test]
fn missing_strings_are_field_names_not_descriptions() {
// T M10.7 wire-format-stability commitment: the strings
// emitted into `missing` are exactly the
// `FrontendCapabilities`/`InstanceCapabilities` field names.
// Human-readable translation happens in
// `AttachError::Display`, not on the wire. Renaming a bit
// requires updating both this emission and the field name
// in lockstep — this test pins the current names so a
// future rename forces an audit-visible diff here too.
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false))
.expect_err("should mismatch");
match err {
GoodbyeReason::CapabilityMismatch { missing } => {
// The exact strings the wire carries — no
// pluralization, no hyphenation, no human polish.
assert!(
missing
.iter()
.all(|s| s.chars().all(|c| c.is_ascii_lowercase() || c == '_')),
"missing strings must be field-name identifiers (ascii lowercase + underscore), got {missing:?}"
);
}
other => panic!("expected CapabilityMismatch, got {other:?}"),
}
}
}

View File

@ -185,6 +185,7 @@ impl Rope {
new_rope: self.clone(),
range: Range::new(pos, pos),
inserted_len: 0,
crdt_op: None,
});
}
@ -202,6 +203,7 @@ impl Rope {
new_rope: Self { root },
range: Range::new(pos, pos),
inserted_len: bytes.len() as u64,
crdt_op: None,
})
}
@ -228,6 +230,7 @@ impl Rope {
new_rope: self.clone(),
range: Range::new(start, end),
inserted_len: 0,
crdt_op: None,
});
}
@ -241,6 +244,7 @@ impl Rope {
new_rope: Self { root: new_root },
range: Range::new(start, end),
inserted_len: 0,
crdt_op: None,
})
}
@ -256,6 +260,7 @@ impl Rope {
new_rope: after_insert.new_rope,
range: Range::new(start, end),
inserted_len: bytes.len() as u64,
crdt_op: None,
})
}
}
@ -304,6 +309,55 @@ pub struct Edit {
pub range: Range,
/// Number of bytes inserted at `range.start` in the *new* rope.
pub inserted_len: u64,
/// T M10.2 Day 3: optional CRDT-op metadata.
///
/// `Some` when this Edit was produced by a CRDT-backed Buffer's
/// edit path (`apply_edit` / `undo` / `redo`); `None` otherwise — both
/// in v0.1 mode (no CRDT) and for no-op edits in CRDT mode (an
/// empty insert at an empty range produces no CRDT op).
///
/// `Box` indirection: keeps Edit's None-case cost to 8 bytes
/// (Box has a niche-optimized None) rather than the ~32 bytes
/// inline `Option<CrdtOp>` would take. Edit is constructed in
/// hot paths (every rope edit), so the size matters; CRDT mode
/// pays one allocation per edit, v0.1 mode pays nothing extra.
///
/// Always present (not `#[cfg]`-gated) to avoid feature-flag
/// proliferation through every Edit consumer (views, hooks,
/// intercepts, undo stack — dozens of touch points). Consumers
/// that don't care ignore the field; M10.5 (wire protocol) and
/// M10.4 (per-frontend undo) consume it.
pub crdt_op: Option<Box<CrdtOp>>,
}
/// T M10.2 Day 3: CRDT-op metadata carried by [`Edit`] in CRDT mode.
///
/// Two fields:
///
/// * `peer_id` — the producing-frontend identity. M10.4's per-frontend
/// undo reads this as the "is this op mine?" filter; saves the
/// consumer from parsing the op bytes to extract identity.
/// * `bytes` — wire-format serialization of the CRDT ops produced by
/// the originating edit, as returned by loro's
/// `ExportMode::updates_owned(pre_version)`. M10.5+ sends these
/// over the wire; receiving frontends import them via loro's
/// `import` to apply on their local CRDT.
///
/// Constructed by `Buffer::apply_edit` (and `undo` / `redo`) in CRDT
/// mode; rope's edit constructors set `Edit::crdt_op` to `None` and
/// the Buffer wraps after the rope returns.
///
/// T M10.5: serde derives added so this type can be the payload of
/// `InstanceMessage::CrdtOp` and `FrontendEvent::CrdtOp` on the wire.
/// `bytes` is opaque to the protocol layer — it's loro's incremental-
/// update format; the receiving end's `CrdtState::import_updates`
/// decodes it.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CrdtOp {
/// Producing frontend's identity (loro `PeerID`).
pub peer_id: u64,
/// Wire-format op bytes (loro `ExportMode::updates_owned` output).
pub bytes: Vec<u8>,
}
/// A half-open byte range `[start, end)` into a rope.

View File

@ -197,6 +197,8 @@ mod tests {
mouse: true,
bracketed_paste: true,
terminal_kind: Some("xterm-256color".into()),
multi_frontend: false,
crdt_replica: false,
},
initial_size: crate::cell::CellSize::new(24, 80),
};

View File

@ -238,6 +238,27 @@ pub struct Layout {
pub root: LayoutNode,
}
/// T M10.8 — one attached frontend's view of the editor.
///
/// Per-frontend state for multi-frontend operation: the split tree
/// the frontend sees and which window within it is focused.
/// `WindowId`s are globally unique across all frontends — the
/// `EditorCore::windows` flat map holds every window, and each
/// frontend's `FrontendView` references a subset via its `Layout`.
///
/// The buffers themselves remain shared in `EditorCore::registry` —
/// two frontends with windows onto the same `BufferId` see the same
/// content but each window owns its own cursor / `view_top` / `goal_col`.
#[derive(Clone, Debug)]
pub struct FrontendView {
/// Window tree visible to this frontend.
pub layout: Layout,
/// Focused window within `layout`. Always a `WindowId` that
/// `layout` references (invariant: `layout.iter_ids()` contains
/// `active`).
pub active: WindowId,
}
impl Layout {
/// A trivial single-window layout.
#[must_use]

View File

@ -37,37 +37,74 @@ use std::fmt::Write;
use crate::async_runtime::{
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot,
};
use crate::buffer::{BufferId, EditOp};
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
/// Canonical name for the workers observability buffer.
pub const WORKERS_BUFFER_NAME: &str = "*workers*";
/// Render `snapshot` into the `*workers*` buffer (creating it if
/// absent), replacing its full contents. Returns the buffer id.
/// absent), replacing its full contents. Returns the buffer id
/// and the Edits produced by the replacement (zero, one, or two —
/// one Delete for non-empty old content, one Insert for non-empty
/// new content).
///
/// The buffer is marked clean after rendering --- the modeline
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// When the buffer has been upgraded to CRDT-backed (which happens
/// at every replica's attach via `send_buffer_snapshots`), each
/// `apply_edit` produces an `Edit::crdt_op` that must broadcast to
/// every replica frontend so their `BufferMirror`s converge with
/// the daemon's new content. Returning the Edits lets the caller
/// queue them via `EditorCore::queue_daemon_origin_crdt_op` — the
/// render function itself doesn't have an `EditorCore` reference,
/// only the `BufferRegistry`.
///
/// The buffer is marked clean after rendering — the modeline
/// shouldn't claim unsaved changes for a generated buffer.
pub fn render(registry: &mut BufferRegistry, snapshot: &WorkersSnapshot) -> BufferId {
pub fn render(
registry: &mut BufferRegistry,
snapshot: &WorkersSnapshot,
) -> (BufferId, Vec<crate::rope::Edit>) {
let text = format_snapshot(snapshot);
let id = registry
.find_by_name(WORKERS_BUFFER_NAME)
.unwrap_or_else(|| registry.create(WORKERS_BUFFER_NAME));
let buf = registry.get_mut(id).expect("just resolved");
let mut edits = Vec::new();
if buffer_contents_equal(buf, &text) {
buf.mark_clean();
return (id, edits);
}
if !buf.is_empty() {
let len = buf.len();
let _ = buf.apply_edit(EditOp::Delete {
if let Ok(edit) = buf.apply_edit(EditOp::Delete {
range: crate::rope::Range::new(0, len),
});
}) {
edits.push(edit);
}
}
if !text.is_empty() {
let _ = buf.apply_edit(EditOp::Insert {
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
});
}) {
edits.push(edit);
}
}
buf.mark_clean();
id
(id, edits)
}
fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool {
if buf.len() != text.len() as u64 {
return false;
}
let mut bytes = vec![0u8; text.len()];
if !bytes.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
}
bytes == text.as_bytes()
}
/// Format a snapshot as the buffer's text payload.
@ -288,6 +325,22 @@ mod tests {
assert!(text.contains("200ms ago"));
}
#[test]
fn render_same_snapshot_is_no_op() {
let mut reg = BufferRegistry::new();
let s = snapshot_with(vec![], vec![]);
let (_id, first_edits) = render(&mut reg, &s);
assert!(
!first_edits.is_empty(),
"initial render should create buffer contents"
);
let (_id, second_edits) = render(&mut reg, &s);
assert!(
second_edits.is_empty(),
"unchanged workers render must not emit delete/insert edits"
);
}
#[test]
fn duration_formatter_handles_three_scales() {
assert_eq!(format_duration_ms(42), "42ms");

139
tests/m10_10_perf.rs Normal file
View File

@ -0,0 +1,139 @@
//! T M10.10 Day 3 step 7 — perf measurement baseline.
//!
//! Measures `Buffer::apply_remote_crdt_op` cost across buffer sizes
//! (1KB / 100KB / 1MB). Under Path β's narrow optimistic-paint
//! scope, M10.10 doesn't have a tight perf requirement — the
//! measurements are baseline numbers for v0.2+ optimization work:
//!
//! - **v0.2+ Path γ** (layout-aware optimistic paint) needs
//! before-state to compare against.
//! - **v0.x A1 mitigation** (unicode-method path from M10.2's 391×
//! finding) needs before-state to verify improvement.
//!
//! The numbers are recorded to stdout via eprintln (visible under
//! `cargo test -- --nocapture`) and asserted against generous bounds
//! that exist to catch catastrophic regressions, not to verify a
//! tight perf claim.
#![cfg(feature = "crdt")]
use std::time::Instant;
use pmacs::buffer::{Buffer, BufferId};
use pmacs::crdt::CrdtState;
/// Build a buffer of approximately `size` bytes seeded with ASCII
/// content, CRDT-upgraded under `peer_id` 1 (the LOCAL daemon peer).
/// Returns the buffer plus a remote-peer state synced with it for
/// generating ops.
fn build_buffer_with_size(size: usize) -> (Buffer, CrdtState) {
// Seed text: 'a' repeated. Same content via both paths so the
// buffer and donor CrdtState are byte-equivalent.
let content: Vec<u8> = std::iter::repeat_n(b'a', size).collect();
let buf = Buffer::from_bytes_with_crdt(BufferId::next(), "*perf*", &content, 1)
.expect("buf from bytes with crdt");
// Synchronize a donor (simulating a remote peer) to the buffer's
// CRDT state via snapshot. Donor uses a distinct peer_id so the
// ops it produces are attributable to a different peer.
let donor_snap = buf
.crdt_state()
.expect("buf crdt")
.export_snapshot()
.expect("snap");
let donor = CrdtState::new(2).expect("donor");
donor.import_snapshot(&donor_snap).expect("donor import");
(buf, donor)
}
/// Measure `apply_remote_crdt_op` for a buffer of `size` bytes
/// receiving a single 1-byte insertion at position 0 from a remote
/// peer. Returns elapsed time.
fn measure_apply_remote(size: usize) -> std::time::Duration {
let (mut buf, donor) = build_buffer_with_size(size);
// Donor produces a small op (insert one char at position 0).
let v_before = donor.version();
donor.insert(0, "X").expect("donor edit");
let op_bytes = donor.export_updates_since(&v_before).expect("export");
let start = Instant::now();
let _edit = buf.apply_remote_crdt_op(&op_bytes).expect("apply remote");
start.elapsed()
}
#[test]
fn m10_10_apply_remote_crdt_op_at_1kb() {
let elapsed = measure_apply_remote(1024);
let us = elapsed.as_micros();
eprintln!("[M10.10 perf] apply_remote_crdt_op at 1 KB: {us}µs");
// Generous bound: 1KB should never exceed 10ms even on slow CI.
// Tight bound is recorded in audit, not asserted.
assert!(
elapsed < std::time::Duration::from_millis(10),
"apply_remote_crdt_op at 1KB took {us}µs; expected sub-10ms"
);
}
#[test]
fn m10_10_apply_remote_crdt_op_at_100kb() {
let elapsed = measure_apply_remote(100 * 1024);
let us = elapsed.as_micros();
eprintln!("[M10.10 perf] apply_remote_crdt_op at 100 KB: {us}µs");
// Generous bound: 100KB on CI should complete in under 200ms.
// The audit records the actual measurement; this assertion is a
// catastrophic-regression guard.
assert!(
elapsed < std::time::Duration::from_millis(200),
"apply_remote_crdt_op at 100KB took {us}µs; expected sub-200ms"
);
}
#[test]
fn m10_10_apply_remote_crdt_op_at_1mb() {
// The 1MB case stresses the path. Under M10.2's 391× finding,
// byte-native loro operations on multi-MB buffers can take tens
// of ms per op (the unicode-method mitigation closes this gap).
// M10.10's perf measurement records the before-state.
let elapsed = measure_apply_remote(1024 * 1024);
let ms = elapsed.as_millis();
eprintln!("[M10.10 perf] apply_remote_crdt_op at 1 MB: {ms}ms");
// Very generous bound: 1MB on CI should complete in under 5s.
// If we exceed this, something is structurally wrong (not just
// the M10.2 391× pattern).
assert!(
elapsed < std::time::Duration::from_secs(5),
"apply_remote_crdt_op at 1MB took {ms}ms; expected sub-5s"
);
}
/// Records buffer-size scaling in one test for the audit's perf
/// table. Runs three measurements and prints them together so
/// `cargo test -- --nocapture` shows the scaling pattern at a
/// glance.
#[test]
fn m10_10_apply_remote_crdt_op_scaling_report() {
let sizes = [1024usize, 100 * 1024, 1024 * 1024];
eprintln!("\n[M10.10 perf scaling]");
eprintln!(" size | apply_remote_crdt_op");
eprintln!(" --------|---------------------");
for size in sizes {
// Run three iterations and report the median; smooths out
// outliers from cold-start and noisy CI runners.
let mut samples: Vec<_> = (0..3).map(|_| measure_apply_remote(size)).collect();
samples.sort();
let median = samples[1];
let label = match size {
n if n < 10 * 1024 => format!("{n} B"),
n if n < 10 * 1024 * 1024 => format!("{} KB", n / 1024),
n => format!("{} MB", n / (1024 * 1024)),
};
eprintln!(
" {label:7} | {} µs ({} ms)",
median.as_micros(),
median.as_millis()
);
}
eprintln!();
}

View File

@ -0,0 +1,92 @@
//! M10.10 Day 2 first verification test.
//!
//! Question: when postcard deserializes bytes for a wire-format
//! `enum` variant that does not exist in the receiver's enum
//! definition, does it error, drop silently, or something else?
//!
//! Decision rule (per M10.10-FRAMING.md Refinement 3):
//! - Hard error → `PROTOCOL_VERSION` must bump to 3 when M10.10 adds
//! `InstanceMessage::BufferSnapshot`.
//! - Graceful (error reaches connection-tear-down only) → stays at 2.
//!
//! This test does not depend on pmacs's protocol types. It uses two
//! locally-defined enums to isolate the postcard behavior question.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
enum SenderEnum {
KnownA(u32),
KnownB(String),
NewVariant { id: u64, payload: Vec<u8> },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
enum ReceiverEnum {
KnownA(u32),
KnownB(String),
}
#[test]
fn known_variants_round_trip() {
let msg = SenderEnum::KnownA(42);
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: ReceiverEnum = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, ReceiverEnum::KnownA(42));
}
#[test]
fn unknown_variant_behavior_observed() {
let msg = SenderEnum::NewVariant {
id: 12345,
payload: vec![1, 2, 3, 4, 5],
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let result: Result<ReceiverEnum, _> = postcard::from_bytes(&bytes);
match result {
Ok(value) => {
panic!("postcard silently decoded an unknown variant — unexpected: {value:?}");
}
Err(e) => {
// This is the expected case based on postcard's enum-as-varint
// discriminant model. Document the error category for the
// M10.10 audit.
eprintln!("postcard unknown-variant behavior: hard error");
eprintln!(" error: {e}");
eprintln!(" category: {e:?}");
}
}
}
#[test]
fn unknown_variant_does_not_corrupt_subsequent_stream() {
// Concat bytes: [NewVariant payload][KnownA(7) payload].
// If postcard's error on the first frame is recoverable at the
// length-prefix-framing layer (as pmacs's transport uses), the
// second frame should still decode. This isolates whether the
// unknown-variant error is per-frame or stream-corrupting.
let bad = postcard::to_allocvec(&SenderEnum::NewVariant {
id: 99,
payload: vec![0xff; 4],
})
.expect("encode bad");
let good = postcard::to_allocvec(&SenderEnum::KnownA(7)).expect("encode good");
let first: Result<ReceiverEnum, _> = postcard::from_bytes(&bad);
let second: Result<ReceiverEnum, _> = postcard::from_bytes(&good);
eprintln!("first frame: {first:?}");
eprintln!("second frame: {second:?}");
assert!(
first.is_err(),
"expected first frame to fail at unknown variant"
);
assert_eq!(
second.expect("second frame should decode independently"),
ReceiverEnum::KnownA(7),
"subsequent independent frame must decode regardless of prior failure"
);
}

602
tests/m10_2_perf.rs Normal file
View File

@ -0,0 +1,602 @@
//! T M10.2 Day 7 — performance regression check.
//!
//! Measures CRDT-mode buffer performance against v0.1-mode buffer
//! performance across four axes, with methodology matching M10.1's
//! library-survey benchmarks so numbers are directly comparable.
//!
//! All tests are `#[ignore]` — they don't run in CI. Invocation:
//!
//! ```sh
//! cargo test --release --features "luajit crdt" \
//! --test m10_2_perf -- --ignored --nocapture
//! ```
//!
//! Methodology (pinned for reproducibility against M10.1 + future
//! re-runs):
//! - Document size points: 1KB / 100KB / 1MB / 10MB
//! - Mixed-workload mix: 50% inserts / 30% deletes / 15% replaces / 5% large
//! - Op-size distribution: log-normal mu=1.1 sigma=1.5 for small,
//! mu=6 sigma=1.5 for large (matches M10.1)
//! - Deterministic seed: 0xc0ffee (matches M10.1)
//! - Window: 30s for the audit numbers; 5s for development iteration
//! - Initial document for mixed workload: 100KB
//! - Release profile, single-thread
//!
//! Reports printed to stderr via `eprintln!` (visible with --nocapture).
#![cfg(feature = "crdt")]
#![allow(
clippy::uninlined_format_args,
clippy::unreadable_literal,
reason = "perf bench: table-formatted numeric output reads better column-aligned than inline"
)]
use pmacs::buffer::{Buffer, BufferId, EditOp};
use pmacs::rope::Range;
use rand::{Rng, SeedableRng};
use rand_distr::{Distribution, LogNormal};
use std::time::{Duration, Instant};
const SEED: u64 = 0xc0ffee;
const WINDOW_SECS: u64 = 30;
const ASCII_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ\n";
fn fmt_size(n: usize) -> String {
if n >= 1_000_000 {
format!("{} MB", n / 1_000_000)
} else if n >= 1_000 {
format!("{} KB", n / 1_000)
} else {
format!("{} B", n)
}
}
fn random_ascii(n: usize, rng: &mut impl Rng) -> Vec<u8> {
(0..n)
.map(|_| ASCII_ALPHABET[rng.r#gen_range(0..ASCII_ALPHABET.len())])
.collect()
}
// ---------------------------------------------------------------------------
// 1. Microbenchmark — document-size sweep.
//
// Bulk insert + snapshot (Arc-clone of rope) at 4 size points,
// measured for both modes. The bulk insert exercises the rope's
// build path; the snapshot exercises the worker-facing handoff.
//
// Expected: v0.1 mode unchanged (no CRDT field touched). CRDT mode
// pays loro's bulk-insert + version-capture/export overhead per call.
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_document_size_sweep() {
let sizes = [1_000usize, 100_000, 1_000_000, 10_000_000];
eprintln!("\n=== M10.2 Day 7 — document size sweep ===\n");
eprintln!(
"{:>8} | {:>14} | {:>14} | {:>10}",
"size", "v0.1 bulk", "CRDT bulk", "ratio"
);
eprintln!("{}", "-".repeat(60));
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
for &size in &sizes {
let payload = random_ascii(size, &mut rng);
// v0.1 mode: build via Buffer::from_bytes.
let t = Instant::now();
let _b_v01 = Buffer::from_bytes(BufferId::next(), "v01", &payload);
let v01_us = t.elapsed().as_micros();
// CRDT mode: build via Buffer::from_bytes_with_crdt.
let t = Instant::now();
let _b_crdt = Buffer::from_bytes_with_crdt(BufferId::next(), "crdt", &payload, 1)
.expect("crdt construct");
let crdt_us = t.elapsed().as_micros();
let ratio = if v01_us > 0 {
crdt_us as f64 / v01_us as f64
} else {
f64::NAN
};
eprintln!(
"{:>8} | {:>11} us | {:>11} us | {:>9.2}x",
fmt_size(size),
v01_us,
crdt_us,
ratio
);
}
eprintln!();
}
// ---------------------------------------------------------------------------
// 2. Per-op throughput — mixed workload (M10.1 methodology).
//
// 50/30/15/5 mix, log-normal op sizes, 30s window. Comparable to the
// M10.1 library benchmarks (loro: 279k ops/sec at 30s; yrs: 1.5k).
// pmacs's Buffer adds intercept dispatch, mark adjustment, undo
// bookkeeping, and on_edit broadcast on top of the underlying CRDT
// or rope operations — those overheads are part of the measurement.
//
// Expected: v0.1 mode in the hundreds of thousands of ops/sec range
// (rope edits are cheap). CRDT mode pays additional cost per op
// (CRDT apply + Day 3 crdt_op extraction).
// ---------------------------------------------------------------------------
#[derive(Clone, Copy)]
enum OpKind {
Insert,
Delete,
Replace,
LargeOp,
}
fn pick_op<R: Rng>(rng: &mut R) -> OpKind {
let r: f64 = rng.r#gen();
if r < 0.50 {
OpKind::Insert
} else if r < 0.80 {
OpKind::Delete
} else if r < 0.95 {
OpKind::Replace
} else {
OpKind::LargeOp
}
}
fn op_size_lognormal<R: Rng>(rng: &mut R, large: bool) -> usize {
let mu = if large { 6.0 } else { 1.1 };
let dist = LogNormal::new(mu, 1.5).unwrap();
let v: f64 = dist.sample(rng);
let n = v.round() as usize;
n.clamp(1, if large { 5_000 } else { 50 })
}
fn run_workload(buf: &mut Buffer, window: Duration) -> u64 {
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + window;
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = buf.len() as usize;
if len == 0 {
let _ = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
});
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len) as u64;
let bytes = random_ascii(size, &mut rng);
let _ = buf.apply_edit(EditOp::Insert { pos, bytes: &bytes });
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l)) as u64;
let _ = buf.apply_edit(EditOp::Delete {
range: Range::new(pos, pos + l as u64),
});
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l)) as u64;
let bytes = random_ascii(new_size, &mut rng);
let _ = buf.apply_edit(EditOp::Replace {
range: Range::new(pos, pos + l as u64),
bytes: &bytes,
});
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len) as u64;
let bytes = random_ascii(size, &mut rng);
let _ = buf.apply_edit(EditOp::Insert { pos, bytes: &bytes });
}
}
ops += 1;
}
ops
}
/// Run M10.1-style mixed workload against a bare `CrdtState` (no
/// Buffer wrapper). Returns the op count over the window. Mirrors
/// `run_workload` but for `CrdtState`'s byte-native methods.
fn run_workload_bare_crdt(state: &pmacs::crdt::CrdtState, window: Duration) -> u64 {
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + window;
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = state.len_utf8();
if len == 0 {
let _ = state.insert(0, "x");
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.insert(pos, s);
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let _ = state.delete(pos, l);
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let bytes = random_ascii(new_size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.delete(pos, l);
let _ = state.insert(pos, s);
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let s = std::str::from_utf8(&bytes).expect("ASCII");
let _ = state.insert(pos, s);
}
}
ops += 1;
}
ops
}
/// Methodology-reconciliation bench: M10.1 measured 314,691 ops/sec
/// for bare loro at the same mixed-workload methodology. Day 7's
/// initial export-overhead-isolation test measured 41 µs/op for
/// bare `CrdtState` — but on a *different* workload (sequential
/// append from empty, no deletes/replaces). This test re-runs
/// M10.1's exact methodology against bare `CrdtState` to determine
/// whether the gap is workload-shape (expected) or regression
/// (alarming).
/// Bare-loro mixed workload using `insert` (unicode positions) —
/// matches M10.1's methodology exactly. If this produces ~3 µs/op
/// it confirms that the gap between M10.1 (unicode path) and Day 7
/// (byte-native path) is the byte-vs-unicode method choice, not a
/// regression.
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_bare_loro_unicode_path_matches_m10_1() {
use loro::LoroDoc;
eprintln!("\n=== M10.2 Day 7 — direct loro unicode-path bench (M10.1 replica) ===\n");
eprintln!("Uses text.insert/delete (unicode positions), matching M10.1's methodology.\n");
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
let doc = LoroDoc::new();
doc.set_peer_id(1).expect("peer");
let text = doc.get_text("body");
text.insert(0, std::str::from_utf8(&seed_bytes).expect("ASCII seed"))
.expect("seed");
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let deadline = Instant::now() + Duration::from_secs(WINDOW_SECS);
let mut ops = 0u64;
while Instant::now() < deadline {
let kind = pick_op(&mut rng);
let len = text.len_unicode();
if len == 0 {
let _ = text.insert(0, "x");
ops += 1;
continue;
}
match kind {
OpKind::Insert => {
let size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
OpKind::Delete => {
let l = op_size_lognormal(&mut rng, false).min(len);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let _ = text.delete(pos, l);
}
OpKind::Replace => {
let l = op_size_lognormal(&mut rng, false).min(len);
let new_size = op_size_lognormal(&mut rng, false);
let pos = rng.r#gen_range(0..=len.saturating_sub(l));
let bytes = random_ascii(new_size, &mut rng);
let _ = text.delete(pos, l);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
OpKind::LargeOp => {
let size = op_size_lognormal(&mut rng, true);
let pos = rng.r#gen_range(0..=len);
let bytes = random_ascii(size, &mut rng);
let _ = text.insert(pos, std::str::from_utf8(&bytes).expect("ASCII"));
}
}
ops += 1;
}
let per_sec = ops as f64 / WINDOW_SECS as f64;
let us = 1_000_000.0 / per_sec;
eprintln!(
"bare loro (unicode path, M10.1 replica): {:>9} ops | {:>9.0} ops/sec | {:>7.2} us/op",
ops, per_sec, us
);
eprintln!();
eprintln!("If close to 314,691 ops/sec (M10.1's number): confirms the byte-native");
eprintln!("methods (insert_utf8/delete_utf8) are dramatically more expensive than");
eprintln!("the unicode methods (insert/delete) at non-trivial doc sizes.");
}
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_bare_crdt_mixed_workload_reconcile_with_m10_1() {
use pmacs::crdt::CrdtState;
eprintln!("\n=== M10.2 Day 7 reconciliation — bare CrdtState mixed workload ===\n");
eprintln!("Methodology: M10.1's exact pattern (50/30/15/5, log-normal sizes, 30s, 100KB seed)");
eprintln!("Comparison target: M10.1's bare-loro number was 314,691 ops/sec (3.18 µs/op)\n");
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
let state = CrdtState::from_bytes(1, &seed_bytes).expect("seed");
let bare_ops = run_workload_bare_crdt(&state, Duration::from_secs(WINDOW_SECS));
let bare_per_sec = bare_ops as f64 / WINDOW_SECS as f64;
let bare_us = 1_000_000.0 / bare_per_sec;
let final_len = state.len_utf8();
eprintln!(
"bare CrdtState (M10.1 methodology): {:>9} ops | {:>9.0} ops/sec | {:>7.2} us/op | final doc {} B",
bare_ops, bare_per_sec, bare_us, final_len
);
eprintln!();
eprintln!("Reconciliation:");
eprintln!(" M10.1 bare loro (mixed workload): 314,691 ops/sec");
eprintln!(
" Day 7 bare CrdtState (mixed): {:>9.0} ops/sec",
bare_per_sec
);
let m101_ratio = 314_691.0 / bare_per_sec;
eprintln!(
" ratio: {:>8.2}x slower than M10.1",
m101_ratio
);
eprintln!();
eprintln!("If close to 1x: workload-shape was the gap (Day 7's export-overhead-");
eprintln!(" isolation used sequential append-from-empty, not M10.1's mixed).");
eprintln!("If much greater than 1x: real regression vs M10.1 worth investigating.");
eprintln!();
}
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_mixed_workload_throughput() {
eprintln!("\n=== M10.2 Day 7 — per-op throughput (mixed workload, 30s) ===\n");
eprintln!(
"Initial doc: 100KB; mix: 50/30/15/5; window: {}s\n",
WINDOW_SECS
);
let mut seed_rng = rand::rngs::StdRng::seed_from_u64(SEED);
let seed_bytes = random_ascii(100_000, &mut seed_rng);
// v0.1 mode
let mut b_v01 = Buffer::from_bytes(BufferId::next(), "v01", &seed_bytes);
let v01_ops = run_workload(&mut b_v01, Duration::from_secs(WINDOW_SECS));
let v01_per_sec = v01_ops as f64 / WINDOW_SECS as f64;
let v01_us = 1_000_000.0 / v01_per_sec;
eprintln!(
"v0.1 mode: {:>9} ops total | {:>9.0} ops/sec | {:>7.2} us/op",
v01_ops, v01_per_sec, v01_us
);
// CRDT mode
let mut b_crdt =
Buffer::from_bytes_with_crdt(BufferId::next(), "crdt", &seed_bytes, 1).expect("crdt");
let crdt_ops = run_workload(&mut b_crdt, Duration::from_secs(WINDOW_SECS));
let crdt_per_sec = crdt_ops as f64 / WINDOW_SECS as f64;
let crdt_us = 1_000_000.0 / crdt_per_sec;
eprintln!(
"CRDT mode: {:>9} ops total | {:>9.0} ops/sec | {:>7.2} us/op",
crdt_ops, crdt_per_sec, crdt_us
);
let ratio = v01_per_sec / crdt_per_sec;
eprintln!("\nCRDT mode is {:.2}x slower per op than v0.1 mode", ratio);
eprintln!("(M10.2 target: within 2x of v0.1 for typical edit patterns)\n");
}
// ---------------------------------------------------------------------------
// 3. Export overhead — CRDT mode without crdt_op extraction vs with.
//
// Day 3's framing called for this specific measurement: separate the
// cost of "apply CRDT op" from the cost of "export the delta bytes."
// The wrapper always extracts; to measure without, we time the loro
// underlying ops directly (via the CrdtState wrapper) against the full
// Buffer::apply_edit path.
//
// Specifically:
// - bare_crdt: time N inserts into CrdtState directly (no wrapper)
// - with_extraction: time N inserts via Buffer::apply_edit (full path,
// includes version_capture / op application / export)
// - difference = wrapper overhead (extraction + rope-sync + bookkeeping)
//
// This isn't a perfectly-isolated "extraction only" measurement
// because the wrapper also does rope-sync and undo bookkeeping. But
// it scopes the wrapper cost so the audit can record both numbers.
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_export_overhead_isolation() {
use pmacs::crdt::CrdtState;
const N_OPS: usize = 10_000;
eprintln!("\n=== M10.2 Day 7 — export overhead isolation ===\n");
eprintln!(
"Workload: {} sequential inserts, each ~5 bytes of ASCII\n",
N_OPS
);
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
let payload: Vec<Vec<u8>> = (0..N_OPS).map(|_| random_ascii(5, &mut rng)).collect();
// 3a. Bare CRDT (no wrapper, no extraction).
let state = CrdtState::new(1).expect("crdt");
let t = Instant::now();
for bytes in &payload {
let s = std::str::from_utf8(bytes).expect("ASCII");
state.insert(state.len_utf8(), s).expect("insert");
}
let bare_us = t.elapsed().as_micros() as f64;
eprintln!(
"bare CrdtState (no wrapper): {:>10.0} us total | {:>6.2} us/op",
bare_us,
bare_us / N_OPS as f64
);
// 3b. Bare CRDT + per-op export (the extraction step in isolation).
let state = CrdtState::new(2).expect("crdt");
let t = Instant::now();
for bytes in &payload {
let pre = state.version();
let s = std::str::from_utf8(bytes).expect("ASCII");
state.insert(state.len_utf8(), s).expect("insert");
let _ = state.export_updates_since(&pre).expect("export");
}
let with_export_us = t.elapsed().as_micros() as f64;
eprintln!(
"bare CrdtState + per-op export: {:>10.0} us total | {:>6.2} us/op",
with_export_us,
with_export_us / N_OPS as f64
);
let export_only_us_per_op = (with_export_us - bare_us) / N_OPS as f64;
eprintln!(
" → export-only overhead: {:>6.2} us/op",
export_only_us_per_op
);
// 3c. Full Buffer::apply_edit (CRDT mode). Includes:
// - intercept dispatch (no intercepts attached, so cheap)
// - lossy UTF-8 normalization (no-op for ASCII)
// - version capture + CRDT apply + export
// - rope mutation
// - mark adjustment (no marks attached, so cheap)
// - undo stack push
// - on_edit broadcast (no views, so cheap)
let mut buf = Buffer::new_with_crdt(BufferId::next(), "full", 3).expect("crdt buf");
let t = Instant::now();
for bytes in &payload {
let pos = buf.len();
let _ = buf.apply_edit(EditOp::Insert { pos, bytes }).expect("ins");
}
let full_us = t.elapsed().as_micros() as f64;
eprintln!(
"Buffer::apply_edit (CRDT mode): {:>10.0} us total | {:>6.2} us/op",
full_us,
full_us / N_OPS as f64
);
let wrapper_us_per_op = (full_us - with_export_us) / N_OPS as f64;
eprintln!(
" → wrapper overhead (rope + bookkeeping): {:>6.2} us/op",
wrapper_us_per_op
);
// 3d. Full Buffer::apply_edit (v0.1 mode), for comparison.
let mut buf_v01 = Buffer::new(BufferId::next(), "v01");
let t = Instant::now();
for bytes in &payload {
let pos = buf_v01.len();
let _ = buf_v01
.apply_edit(EditOp::Insert { pos, bytes })
.expect("ins");
}
let v01_us = t.elapsed().as_micros() as f64;
eprintln!(
"Buffer::apply_edit (v0.1 mode): {:>10.0} us total | {:>6.2} us/op",
v01_us,
v01_us / N_OPS as f64
);
eprintln!();
eprintln!("Decomposition (per-op):");
eprintln!(" raw CRDT op: {:>6.2} us", bare_us / N_OPS as f64);
eprintln!(
" + export extraction: {:>6.2} us (Day 3 cost)",
export_only_us_per_op
);
eprintln!(
" + wrapper overhead: {:>6.2} us (rope sync + bookkeeping)",
wrapper_us_per_op
);
eprintln!(" = full CRDT mode: {:>6.2} us", full_us / N_OPS as f64);
eprintln!(" v0.1 mode baseline: {:>6.2} us", v01_us / N_OPS as f64);
eprintln!();
}
// ---------------------------------------------------------------------------
// 4. Undo cost scaling.
//
// Day 2 framing noted undo cost scales with the size of the undone
// edit in CRDT mode (the synthetic-Replace op carries the full pre-
// edit content). Confirm linear scaling; surface superlinear if
// present.
//
// Methodology: build a buffer, apply an N-byte edit, time the undo.
// N in {10, 100, 1000, 10000}. Run both modes. v0.1 should be ~flat
// (pre-edit rope is held via Arc); CRDT should scale with N (synthetic
// Replace reads pre-edit bytes from the saved rope).
// ---------------------------------------------------------------------------
#[test]
#[ignore = "perf bench; release-mode-only via --ignored --nocapture"]
fn perf_undo_cost_scaling() {
eprintln!("\n=== M10.2 Day 7 — undo cost scaling ===\n");
eprintln!(
"{:>9} | {:>14} | {:>14} | {:>10}",
"edit size", "v0.1 undo", "CRDT undo", "ratio"
);
eprintln!("{}", "-".repeat(60));
let sizes = [10usize, 100, 1_000, 10_000];
let mut rng = rand::rngs::StdRng::seed_from_u64(SEED);
for &n in &sizes {
let payload = random_ascii(n, &mut rng);
// v0.1 mode
let mut b_v01 = Buffer::new(BufferId::next(), "v01");
b_v01
.apply_edit(EditOp::Insert {
pos: 0,
bytes: &payload,
})
.unwrap();
let t = Instant::now();
b_v01.undo().expect("v01 undo");
let v01_us = t.elapsed().as_micros();
// CRDT mode
let mut b_crdt = Buffer::new_with_crdt(BufferId::next(), "crdt", 1).expect("crdt buf");
b_crdt
.apply_edit(EditOp::Insert {
pos: 0,
bytes: &payload,
})
.unwrap();
let t = Instant::now();
b_crdt.undo().expect("crdt undo");
let crdt_us = t.elapsed().as_micros();
let ratio = if v01_us > 0 {
crdt_us as f64 / v01_us as f64
} else {
f64::NAN
};
eprintln!(
"{:>9} B | {:>11} us | {:>11} us | {:>9.2}x",
n, v01_us, crdt_us, ratio
);
}
eprintln!();
}

View File

@ -466,7 +466,7 @@ fn render_active_window_to_grid(
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
let active = core.active;
let active = core.active_window_id();
let registry = core.registry.clone();
let win = core.windows.get_mut(&active).expect("active window");
let rect = Rect::new(0, 0, rows, cols);
@ -559,7 +559,7 @@ fn m4_3_highlight_updates_within_one_frame_after_parse() {
let core = state.core.borrow();
let win = core
.windows
.get(&core.active)
.get(&core.active_window_id())
.expect("active window present");
assert!(
!win.overlays.is_empty(),

File diff suppressed because it is too large Load Diff

View File

@ -150,6 +150,8 @@ fn build_default_caps() -> FrontendCapabilities {
mouse: true,
bracketed_paste: true,
terminal_kind: Some("perf-gate".into()),
multi_frontend: false,
crdt_replica: false,
}
}