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:
parent
f1e9e209c0
commit
45be65b026
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
|
|
@ -51,6 +51,12 @@ multiple_crate_versions = "allow"
|
||||||
default = ["luajit"]
|
default = ["luajit"]
|
||||||
luajit = ["mlua/luajit", "mlua/vendored"]
|
luajit = ["mlua/luajit", "mlua/vendored"]
|
||||||
lua54 = ["mlua/lua54", "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]
|
[dependencies]
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
|
|
@ -107,7 +113,7 @@ tree-sitter-md = "0.5"
|
||||||
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
|
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
|
||||||
# the feature surface tight to keep build time low (no syscalls
|
# the feature surface tight to keep build time low (no syscalls
|
||||||
# beyond `kill(2)` for v0.1).
|
# 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
|
# T M4.4 PTY mode: portable abstraction over openpty / fork+exec
|
||||||
# with controlling-tty wiring. The crate uses internal `unsafe`
|
# with controlling-tty wiring. The crate uses internal `unsafe`
|
||||||
# but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"`
|
# 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
|
# bytes detects upstream tampering even when the host serves a SHA-1
|
||||||
# collision. Pure-Rust implementation; no system dep.
|
# collision. Pure-Rust implementation; no system dep.
|
||||||
sha2 = "0.10"
|
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]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
tempfile = "3"
|
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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
451
src/attach.rs
451
src/attach.rs
|
|
@ -124,11 +124,51 @@ impl std::fmt::Display for AttachError {
|
||||||
match self {
|
match self {
|
||||||
Self::Io(e) => write!(f, "attach I/O error: {e}"),
|
Self::Io(e) => write!(f, "attach I/O error: {e}"),
|
||||||
Self::Transport(e) => write!(f, "{e}"),
|
Self::Transport(e) => write!(f, "{e}"),
|
||||||
Self::VersionMismatch { server, client } => write!(
|
Self::VersionMismatch { server, client } => {
|
||||||
f,
|
// T M10.7 criterion 5: the message must tell the user
|
||||||
"protocol version mismatch (instance v{server}, client v{client})"
|
// which side is at the older version. Comparing
|
||||||
),
|
// `server` against `client` produces an unambiguous
|
||||||
Self::Rejected(reason) => write!(f, "instance rejected attach: {reason:?}"),
|
// 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::Terminal(e) => write!(f, "terminal error: {e}"),
|
||||||
Self::SshSpawnFailed { command, source } => write!(
|
Self::SshSpawnFailed { command, source } => write!(
|
||||||
f,
|
f,
|
||||||
|
|
@ -259,6 +299,35 @@ pub(crate) trait AttachPumpFrontend {
|
||||||
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()>;
|
fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()>;
|
||||||
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
|
fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
|
||||||
fn size(&self) -> CellSize;
|
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 {
|
impl AttachPumpFrontend for Frontend {
|
||||||
|
|
@ -271,6 +340,14 @@ impl AttachPumpFrontend for Frontend {
|
||||||
fn size(&self) -> CellSize {
|
fn size(&self) -> CellSize {
|
||||||
Frontend::size(self)
|
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.
|
/// 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
|
// mismatch, malformed Hello, EOF), the error message reaches a
|
||||||
// normal terminal — `Frontend::new` hasn't taken over yet.
|
// normal terminal — `Frontend::new` hasn't taken over yet.
|
||||||
let hello: Hello = read_message(&mut stream)?;
|
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 {
|
return Err(AttachError::VersionMismatch {
|
||||||
server: hello.protocol_version,
|
server: hello.protocol_version,
|
||||||
client: 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 (cols, rows) = crossterm::terminal::size().map_err(AttachError::Terminal)?;
|
||||||
let initial_size = CellSize::new(u32::from(rows), u32::from(cols));
|
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 {
|
let req = AttachRequest {
|
||||||
protocol_version: PROTOCOL_VERSION,
|
protocol_version: hello.protocol_version,
|
||||||
frontend_capabilities: build_capabilities(),
|
frontend_capabilities: build_capabilities(),
|
||||||
initial_size,
|
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
|
// The v0.1 TUI implements all of these; we report them honestly
|
||||||
// so the daemon doesn't strip features that work fine.
|
// 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 {
|
FrontendCapabilities {
|
||||||
synchronized_output: true,
|
synchronized_output: true,
|
||||||
unicode_smp: true,
|
unicode_smp: true,
|
||||||
|
|
@ -409,6 +514,8 @@ fn build_capabilities() -> FrontendCapabilities {
|
||||||
mouse: true,
|
mouse: true,
|
||||||
bracketed_paste: true,
|
bracketed_paste: true,
|
||||||
terminal_kind: std::env::var("TERM").ok(),
|
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
|
/// is joined before this function returns. The closure-and-call
|
||||||
/// wind-down pattern guarantees this regardless of which `return`
|
/// wind-down pattern guarantees this regardless of which `return`
|
||||||
/// the loop takes.
|
/// 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(
|
pub(crate) fn run_attach_pair(
|
||||||
io: AttachIo,
|
io: AttachIo,
|
||||||
frontend: &mut dyn AttachPumpFrontend,
|
frontend: &mut dyn AttachPumpFrontend,
|
||||||
|
|
@ -479,6 +596,13 @@ pub(crate) fn run_attach_pair(
|
||||||
let (tx, rx) = mpsc::channel::<InstanceMessage>();
|
let (tx, rx) = mpsc::channel::<InstanceMessage>();
|
||||||
let reader_handle = thread::spawn(move || run_reader(reader, tx));
|
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
|
// Closure-and-call: any `return` from this closure still falls
|
||||||
// through to `kick()` and `reader_handle.join()` below. Without
|
// through to `kick()` and `reader_handle.join()` below. Without
|
||||||
// this wrapping a writer-side IO error inside the loop would skip
|
// 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> = (|| {
|
let result: Result<(), AttachError> = (|| {
|
||||||
loop {
|
loop {
|
||||||
// Drain instance messages. Goodbye exits immediately;
|
// Drain instance messages. Goodbye exits immediately;
|
||||||
// other messages are batched into a single
|
// BufferSnapshot routes to the mirror; other messages are
|
||||||
// present_messages call.
|
// batched into a single present_messages call.
|
||||||
let mut batch: Vec<InstanceMessage> = Vec::new();
|
let mut batch: Vec<InstanceMessage> = Vec::new();
|
||||||
let mut goodbye: Option<GoodbyeReason> = None;
|
let mut goodbye: Option<GoodbyeReason> = None;
|
||||||
let mut reader_eof = false;
|
let mut reader_eof = false;
|
||||||
|
|
@ -497,6 +621,81 @@ pub(crate) fn run_attach_pair(
|
||||||
goodbye = Some(reason);
|
goodbye = Some(reason);
|
||||||
break;
|
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),
|
Ok(msg) => batch.push(msg),
|
||||||
Err(mpsc::TryRecvError::Empty) => break,
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
|
@ -534,10 +733,139 @@ pub(crate) fn run_attach_pair(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size()) {
|
// T M10.10 Day 3 step 3b — text-input optimistic-apply
|
||||||
// Likely a broken pipe — instance went away.
|
// orchestration. For Press/Repeat key events, the
|
||||||
eprintln!("pmacs: {e}");
|
// orchestrator either:
|
||||||
return Err(e);
|
// - 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(
|
return Err(handshake_error_with_child(
|
||||||
child,
|
child,
|
||||||
stderr_handle,
|
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 {
|
let req = AttachRequest {
|
||||||
protocol_version: PROTOCOL_VERSION,
|
protocol_version: hello.protocol_version,
|
||||||
frontend_capabilities: build_capabilities(),
|
frontend_capabilities: build_capabilities(),
|
||||||
initial_size,
|
initial_size,
|
||||||
};
|
};
|
||||||
|
|
@ -1939,4 +2273,89 @@ mod tests {
|
||||||
"[pmacs disconnected — reconnecting in 30s — Ctrl-C to exit]"
|
"[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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2075
src/buffer.rs
2075
src/buffer.rs
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1414
src/daemon.rs
1414
src/daemon.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -706,7 +706,7 @@ impl EditorState {
|
||||||
/// origin (0 row = first text row of this window's content).
|
/// 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) {
|
fn activate_and_position(&mut self, win_id: WindowId, local_row: u32, local_col: u32) {
|
||||||
let mut core = self.core.borrow_mut();
|
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 view_top = core.windows[&win_id].view_top;
|
||||||
let buffer_id = core.windows[&win_id].buffer_id;
|
let buffer_id = core.windows[&win_id].buffer_id;
|
||||||
let display_row = view_top.saturating_add(local_row as usize);
|
let display_row = view_top.saturating_add(local_row as usize);
|
||||||
|
|
@ -814,7 +814,7 @@ fn window_at_cell(
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let area = Rect::new(0, 0, text_rows, term_size.cols);
|
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)| {
|
placements.iter().find_map(|(id, rect)| {
|
||||||
if cell_row >= rect.origin.row
|
if cell_row >= rect.origin.row
|
||||||
&& cell_row < rect.origin.row + rect.size.rows
|
&& 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());
|
let mut render_state = crate::instance_render::RenderState::new(frontend.size());
|
||||||
|
|
||||||
loop {
|
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)?;
|
frontend.present_messages(&messages)?;
|
||||||
if state.core.borrow().quit {
|
if state.core.borrow().quit {
|
||||||
break;
|
break;
|
||||||
|
|
@ -1005,8 +1006,8 @@ pub fn paint_frame(
|
||||||
// Compute per-window rectangles. The text area is the term size
|
// Compute per-window rectangles. The text area is the term size
|
||||||
// minus the bottom row (status / minibuffer).
|
// minus the bottom row (status / minibuffer).
|
||||||
let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols);
|
let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols);
|
||||||
let placements = core.layout.compute(text_area);
|
let placements = core.active_layout().compute(text_area);
|
||||||
let active = core.active;
|
let active = core.active_window_id();
|
||||||
|
|
||||||
// Clear the whole grid first so windows that shrink on resize
|
// Clear the whole grid first so windows that shrink on resize
|
||||||
// don't leak the old contents.
|
// don't leak the old contents.
|
||||||
|
|
@ -1024,10 +1025,9 @@ pub fn paint_frame(
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
let buf_id = core.active_buffer_id();
|
let buf_id = core.active_buffer_id();
|
||||||
if let Ok(buf) = reg.get(buf_id) {
|
if let Ok(buf) = reg.get(buf_id) {
|
||||||
let aw = core
|
let aw = core.windows.get_mut(&active).expect(
|
||||||
.windows
|
"invariant: active_window_id always references a live window in core.windows",
|
||||||
.get_mut(&active)
|
);
|
||||||
.expect("invariant: core.active is always a live window in core.windows");
|
|
||||||
let cursor_row = aw
|
let cursor_row = aw
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buf, aw.cursor)
|
.pos_to_display(buf, aw.cursor)
|
||||||
|
|
@ -3077,7 +3077,7 @@ mod tests {
|
||||||
let core = s.core.borrow();
|
let core = s.core.borrow();
|
||||||
assert_eq!(core.windows.len(), 8);
|
assert_eq!(core.windows.len(), 8);
|
||||||
let area = crate::window::Rect::new(0, 0, 40, 120);
|
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);
|
assert_eq!(placements.len(), 8);
|
||||||
for r in placements.values() {
|
for r in placements.values() {
|
||||||
assert!(!r.is_empty(), "rect was empty: {r:?}");
|
assert!(!r.is_empty(), "rect was empty: {r:?}");
|
||||||
|
|
@ -3099,13 +3099,13 @@ mod tests {
|
||||||
)
|
)
|
||||||
.exec()
|
.exec()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let start = s.core.borrow().active;
|
let start = s.core.borrow().active_window_id();
|
||||||
let total = s.core.borrow().windows.len();
|
let total = s.core.borrow().windows.len();
|
||||||
assert_eq!(total, 3);
|
assert_eq!(total, 3);
|
||||||
for _ in 0..total {
|
for _ in 0..total {
|
||||||
s.core.borrow_mut().focus_next();
|
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
|
/// Bullet 3: the buffer-list buffer is a regular Buffer in the
|
||||||
|
|
@ -3439,7 +3439,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Set a 2:1 weight on the root split.
|
// Set a 2:1 weight on the root split.
|
||||||
if let crate::window::LayoutNode::Split { weights, .. } =
|
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];
|
*weights = vec![2, 1];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3448,12 +3448,12 @@ mod tests {
|
||||||
let p1 = s
|
let p1 = s
|
||||||
.core
|
.core
|
||||||
.borrow()
|
.borrow()
|
||||||
.layout
|
.active_layout()
|
||||||
.compute(crate::window::Rect::new(0, 0, 24, 90));
|
.compute(crate::window::Rect::new(0, 0, 24, 90));
|
||||||
let p2 = s
|
let p2 = s
|
||||||
.core
|
.core
|
||||||
.borrow()
|
.borrow()
|
||||||
.layout
|
.active_layout()
|
||||||
.compute(crate::window::Rect::new(0, 0, 24, 60));
|
.compute(crate::window::Rect::new(0, 0, 24, 60));
|
||||||
// Both should preserve the 2:1 ratio. Find the two windows
|
// Both should preserve the 2:1 ratio. Find the two windows
|
||||||
// and verify the larger:smaller ratio is 2:1 in both.
|
// and verify the larger:smaller ratio is 2:1 in both.
|
||||||
|
|
@ -3488,10 +3488,11 @@ mod tests {
|
||||||
// apply_active_edit.
|
// apply_active_edit.
|
||||||
let core = s.core.borrow();
|
let core = s.core.borrow();
|
||||||
assert_eq!(core.active_buffer_len(), 6);
|
assert_eq!(core.active_buffer_len(), 6);
|
||||||
|
let active = core.active_window_id();
|
||||||
let other_id = core
|
let other_id = core
|
||||||
.windows
|
.windows
|
||||||
.keys()
|
.keys()
|
||||||
.find(|id| **id != core.active)
|
.find(|id| **id != active)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(core.windows[&other_id].buffer_id, buf_id);
|
assert_eq!(core.windows[&other_id].buffer_id, buf_id);
|
||||||
|
|
@ -3508,7 +3509,7 @@ mod tests {
|
||||||
) -> Vec<crate::cell::Cell> {
|
) -> Vec<crate::cell::Cell> {
|
||||||
use crate::cell::{Cell, CellGrid, CellSize};
|
use crate::cell::{Cell, CellGrid, CellSize};
|
||||||
use crate::view::Viewport;
|
use crate::view::Viewport;
|
||||||
let active = core.active;
|
let active = core.active_window_id();
|
||||||
let win = core.windows.get_mut(&active).unwrap();
|
let win = core.windows.get_mut(&active).unwrap();
|
||||||
let rect = crate::window::Rect::new(0, 0, 24, 80);
|
let rect = crate::window::Rect::new(0, 0, 24, 80);
|
||||||
let cell_count = (rect.size.rows * rect.size.cols) as usize;
|
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 (single_avg_ns, dispatch_avg_ns, realistic_avg_ns) = {
|
||||||
let mut core = s.core.borrow_mut();
|
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 buf_id = core.windows[&active].buffer_id;
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -4017,7 +4018,7 @@ mod tests {
|
||||||
.load("pmacs.window.split_vertical()")
|
.load("pmacs.window.split_vertical()")
|
||||||
.exec()
|
.exec()
|
||||||
.unwrap();
|
.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
|
// Click on the right side (col 60 — guaranteed in the second window
|
||||||
// for any standard 80-col terminal split in half).
|
// for any standard 80-col terminal split in half).
|
||||||
s.dispatch_mouse(
|
s.dispatch_mouse(
|
||||||
|
|
@ -4025,7 +4026,7 @@ mod tests {
|
||||||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 60),
|
mouse(MouseEventKind::Down(MouseButton::Left), 0, 60),
|
||||||
term_size_24x80(),
|
term_size_24x80(),
|
||||||
);
|
);
|
||||||
let new_active = s.core.borrow().active;
|
let new_active = s.core.borrow().active_window_id();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
new_active, original_active,
|
new_active, original_active,
|
||||||
"click in other window did not activate it"
|
"click in other window did not activate it"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
//! window's --- two windows on the same buffer keep their layout
|
//! window's --- two windows on the same buffer keep their layout
|
||||||
//! caches synchronized.
|
//! caches synchronized.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferId, EditOp};
|
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||||
|
|
@ -33,19 +33,52 @@ use crate::rope::Edit;
|
||||||
use crate::rope::{Position, Range};
|
use crate::rope::{Position, Range};
|
||||||
use crate::text_view::TextView;
|
use crate::text_view::TextView;
|
||||||
use crate::view::{DisplayCoord, View};
|
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.
|
/// The world state mutated by editor commands.
|
||||||
pub struct EditorCore {
|
pub struct EditorCore {
|
||||||
/// Shared buffer registry. The registry is the canonical owner
|
/// Shared buffer registry. The registry is the canonical owner
|
||||||
/// of every buffer; windows reference buffers by [`BufferId`].
|
/// of every buffer; windows reference buffers by [`BufferId`].
|
||||||
pub registry: SharedRegistry,
|
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>,
|
pub windows: BTreeMap<WindowId, Window>,
|
||||||
/// Window tree mapping the cell grid to windows.
|
/// T M10.8 — per-frontend views. Each attached frontend has its
|
||||||
pub layout: Layout,
|
/// own `Layout` (split tree) + `active: WindowId`. Buffers are
|
||||||
/// The focused window. `pmacs.editor.*` primitives target it.
|
/// shared via `registry`; cursors / `view_top`s live in the
|
||||||
pub active: WindowId,
|
/// 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.
|
/// One-line message shown in the status line.
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// True iff the editor should exit at the next iteration.
|
/// 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
|
/// (multi-window, multi-user) where each input event must be
|
||||||
/// attributable to its source frontend.
|
/// attributable to its source frontend.
|
||||||
pub active_frontend: FrontendId,
|
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 {
|
impl EditorCore {
|
||||||
|
|
@ -84,17 +142,25 @@ impl EditorCore {
|
||||||
let window = Window::new(id, buffer_id, text_view);
|
let window = Window::new(id, buffer_id, text_view);
|
||||||
let mut windows = BTreeMap::new();
|
let mut windows = BTreeMap::new();
|
||||||
windows.insert(id, window);
|
windows.insert(id, window);
|
||||||
|
let mut views = HashMap::new();
|
||||||
|
views.insert(
|
||||||
|
FrontendId::LOCAL,
|
||||||
|
FrontendView {
|
||||||
|
layout: Layout::single(id),
|
||||||
|
active: id,
|
||||||
|
},
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
windows,
|
windows,
|
||||||
layout: Layout::single(id),
|
views,
|
||||||
active: id,
|
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
file_path: None,
|
file_path: None,
|
||||||
file_meta: None,
|
file_meta: None,
|
||||||
minibuffer: Minibuffer::new(),
|
minibuffer: Minibuffer::new(),
|
||||||
active_frontend: FrontendId::LOCAL,
|
active_frontend: FrontendId::LOCAL,
|
||||||
|
pending_crdt_ops: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,19 +193,111 @@ impl EditorCore {
|
||||||
|
|
||||||
// ---- accessors ---------------------------------------------------------
|
// ---- 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]
|
#[must_use]
|
||||||
pub fn active_window(&self) -> &Window {
|
pub fn active_window(&self) -> &Window {
|
||||||
|
let id = self.active_window_id();
|
||||||
self.windows
|
self.windows
|
||||||
.get(&self.active)
|
.get(&id)
|
||||||
.expect("active window present")
|
.expect("active window present in core.windows")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutably reference the active [`Window`].
|
/// Mutably reference the active [`Window`].
|
||||||
pub fn active_window_mut(&mut self) -> &mut Window {
|
pub fn active_window_mut(&mut self) -> &mut Window {
|
||||||
|
let id = self.active_window_id();
|
||||||
self.windows
|
self.windows
|
||||||
.get_mut(&self.active)
|
.get_mut(&id)
|
||||||
.expect("active window present")
|
.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.
|
/// [`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())
|
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(),
|
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(),
|
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 -------------------------------------------------
|
// ---- window operations -------------------------------------------------
|
||||||
|
|
||||||
/// Split the active window. Returns the new window's id.
|
/// Split the active window. Returns the new window's id.
|
||||||
|
|
@ -799,18 +1005,24 @@ impl EditorCore {
|
||||||
let new_id = WindowId::next();
|
let new_id = WindowId::next();
|
||||||
let new_window = Window::new(new_id, buffer_id, text_view);
|
let new_window = Window::new(new_id, buffer_id, text_view);
|
||||||
self.windows.insert(new_id, new_window);
|
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
|
new_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move focus to the next window in iteration order.
|
/// Move focus to the next window in iteration order.
|
||||||
pub fn focus_next(&mut self) {
|
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.
|
/// Move focus to the previous window in iteration order.
|
||||||
pub fn focus_prev(&mut self) {
|
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
|
/// Close the active window (unless it's the only one). Returns
|
||||||
|
|
@ -819,22 +1031,23 @@ impl EditorCore {
|
||||||
if self.windows.len() <= 1 {
|
if self.windows.len() <= 1 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let target = self.active;
|
let target = self.active_window_id();
|
||||||
self.layout.close_window(target);
|
self.active_layout_mut().close_window(target);
|
||||||
self.windows.remove(&target);
|
self.windows.remove(&target);
|
||||||
// Pick an adjacent window as the new focus.
|
// Pick an adjacent window as the new focus.
|
||||||
self.active = *self
|
let next = *self
|
||||||
.layout
|
.active_layout()
|
||||||
.iter_ids()
|
.iter_ids()
|
||||||
.first()
|
.first()
|
||||||
.expect("at least one window remains");
|
.expect("at least one window remains");
|
||||||
|
self.set_active_window_id(next);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close every window except the active one.
|
/// Close every window except the active one.
|
||||||
pub fn close_others(&mut self) {
|
pub fn close_others(&mut self) {
|
||||||
let keep = self.active;
|
let keep = self.active_window_id();
|
||||||
self.layout.keep_only(keep);
|
self.active_layout_mut().keep_only(keep);
|
||||||
self.windows.retain(|id, _| *id == keep);
|
self.windows.retain(|id, _| *id == keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1326,10 +1539,84 @@ mod tests {
|
||||||
assert!(s.status.contains("no file"));
|
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]
|
#[test]
|
||||||
fn split_active_creates_a_second_window_on_same_buffer() {
|
fn split_active_creates_a_second_window_on_same_buffer() {
|
||||||
let mut s = fresh();
|
let mut s = fresh();
|
||||||
let original = s.active;
|
let original = s.active_window_id();
|
||||||
let new_id = s.split_active(Orientation::Vertical, true);
|
let new_id = s.split_active(Orientation::Vertical, true);
|
||||||
assert_ne!(new_id, original);
|
assert_ne!(new_id, original);
|
||||||
assert_eq!(s.windows.len(), 2);
|
assert_eq!(s.windows.len(), 2);
|
||||||
|
|
@ -1349,12 +1636,8 @@ mod tests {
|
||||||
assert_eq!(s.active_buffer_len(), 4);
|
assert_eq!(s.active_buffer_len(), 4);
|
||||||
// The other window's text_view has the same line count,
|
// The other window's text_view has the same line count,
|
||||||
// confirming on_edit fired.
|
// confirming on_edit fired.
|
||||||
let other = s
|
let active = s.active_window_id();
|
||||||
.windows
|
let other = s.windows.keys().find(|id| **id != active).copied().unwrap();
|
||||||
.keys()
|
|
||||||
.find(|id| **id != s.active)
|
|
||||||
.copied()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(s.windows[&other].text_view.line_count(), 1);
|
assert_eq!(s.windows[&other].text_view.line_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1377,17 +1660,112 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn focus_next_round_robins() {
|
fn focus_next_round_robins() {
|
||||||
let mut s = fresh();
|
let mut s = fresh();
|
||||||
let a = s.active;
|
let a = s.active_window_id();
|
||||||
let _b = s.split_active(Orientation::Vertical, true);
|
let _b = s.split_active(Orientation::Vertical, true);
|
||||||
let _c = s.split_active(Orientation::Horizontal, true);
|
let _c = s.split_active(Orientation::Horizontal, true);
|
||||||
// Splits don't move focus; `a` is still active.
|
// Splits don't move focus; `a` is still active.
|
||||||
assert_eq!(s.active, a);
|
assert_eq!(s.active_window_id(), a);
|
||||||
let order = s.layout.iter_ids();
|
let order = s.active_layout().iter_ids();
|
||||||
assert_eq!(order.len(), 3);
|
assert_eq!(order.len(), 3);
|
||||||
// Walking N times wraps back to the original.
|
// Walking N times wraps back to the original.
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
s.focus_next();
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
src/frontend.rs
117
src/frontend.rs
|
|
@ -46,6 +46,8 @@
|
||||||
use std::io::{self, BufWriter, Stdout, Write};
|
use std::io::{self, BufWriter, Stdout, Write};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(feature = "crdt")]
|
||||||
|
use crossterm::{cursor::MoveLeft, style::Print};
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::{self, MoveTo},
|
cursor::{self, MoveTo},
|
||||||
event::{
|
event::{
|
||||||
|
|
@ -226,6 +228,93 @@ impl Frontend {
|
||||||
self.out.flush()
|
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.
|
/// Apply a single [`InstanceMessage`] to the terminal.
|
||||||
///
|
///
|
||||||
/// `CellDelta` emits one cursor-move + run-of-glyphs sequence per
|
/// `CellDelta` emits one cursor-move + run-of-glyphs sequence per
|
||||||
|
|
@ -253,7 +342,33 @@ impl Frontend {
|
||||||
},
|
},
|
||||||
InstanceMessage::ModeLine(_)
|
InstanceMessage::ModeLine(_)
|
||||||
| InstanceMessage::Signal(_)
|
| 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.
|
// v0.1 TUI ignores these; v0.3 GUI consumes them.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/help.rs
53
src/help.rs
|
|
@ -36,9 +36,21 @@ use crate::keymap_tree::{Binding, Keymap};
|
||||||
/// [`BufferRegistry::find_by_name`].
|
/// [`BufferRegistry::find_by_name`].
|
||||||
pub const HELP_BUFFER_NAME: &str = "*help*";
|
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).
|
/// 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
|
// 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
|
let id = registry
|
||||||
.find_by_name(HELP_BUFFER_NAME)
|
.find_by_name(HELP_BUFFER_NAME)
|
||||||
.unwrap_or_else(|| registry.create(HELP_BUFFER_NAME));
|
.unwrap_or_else(|| registry.create(HELP_BUFFER_NAME));
|
||||||
let buf = registry.get_mut(id).expect("just resolved");
|
let buf = registry.get_mut(id).expect("just resolved");
|
||||||
|
let mut edits = Vec::new();
|
||||||
if !buf.is_empty() {
|
if !buf.is_empty() {
|
||||||
let len = buf.len();
|
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),
|
range: crate::rope::Range::new(0, len),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
let _ = buf.apply_edit(EditOp::Insert {
|
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
|
||||||
pos: 0,
|
pos: 0,
|
||||||
bytes: text.as_bytes(),
|
bytes: text.as_bytes(),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The help buffer is regenerated content; mark it clean so the
|
// The help buffer is regenerated content; mark it clean so the
|
||||||
// modeline doesn't claim it has unsaved changes.
|
// modeline doesn't claim it has unsaved changes.
|
||||||
buf.mark_clean();
|
buf.mark_clean();
|
||||||
id
|
(id, edits)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -386,10 +406,11 @@ fn read_buffer_text(buf: &Buffer) -> String {
|
||||||
String::from_utf8(out).unwrap_or_default()
|
String::from_utf8(out).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of [`follow_link_at`]: the help buffer id (re-rendered if a
|
/// Result of [`follow_link_at`]: the help buffer id paired with the
|
||||||
/// link was found), or [`None`] if the cursor wasn't on a recognized
|
/// Edits produced by the re-render (zero, one, or two), or [`None`]
|
||||||
/// link.
|
/// if the cursor wasn't on a recognized link. Same broadcast-queueing
|
||||||
pub type FollowResult = Option<BufferId>;
|
/// contract as [`RenderResult`].
|
||||||
|
pub type FollowResult = Option<(BufferId, Vec<crate::rope::Edit>)>;
|
||||||
|
|
||||||
/// Parse the link under the cursor in the `*help*` buffer and
|
/// Parse the link under the cursor in the `*help*` buffer and
|
||||||
/// re-render. Returns the help buffer id on success.
|
/// re-render. Returns the help buffer id on success.
|
||||||
|
|
@ -489,7 +510,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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());
|
let body = read_buffer_text(reg.get(id).unwrap());
|
||||||
assert!(body.contains("Command: cursor.left"));
|
assert!(body.contains("Command: cursor.left"));
|
||||||
assert!(body.contains("Move cursor left."));
|
assert!(body.contains("Move cursor left."));
|
||||||
|
|
@ -521,7 +542,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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());
|
let body = read_buffer_text(reg.get(id).unwrap());
|
||||||
assert!(body.contains("Key: C-x C-s"));
|
assert!(body.contains("Key: C-x C-s"));
|
||||||
assert!(body.contains("[command: save]"));
|
assert!(body.contains("[command: save]"));
|
||||||
|
|
@ -690,7 +711,7 @@ mod tests {
|
||||||
// Find cursor on the cross-ref.
|
// Find cursor on the cross-ref.
|
||||||
let body = read_help(®);
|
let body = read_help(®);
|
||||||
let cursor = body.find("beta").unwrap() as u64;
|
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);
|
assert_eq!(returned, id);
|
||||||
let body = read_help(®);
|
let body = read_help(®);
|
||||||
assert!(body.contains("Command: beta"), "{body}");
|
assert!(body.contains("Command: beta"), "{body}");
|
||||||
|
|
@ -724,7 +745,7 @@ mod tests {
|
||||||
"buffer-local key link must carry its buffer scope: {body}"
|
"buffer-local key link must carry its buffer scope: {body}"
|
||||||
);
|
);
|
||||||
let cursor = body.find("s @buffer").unwrap() as u64;
|
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());
|
assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap());
|
||||||
let body = read_help(®);
|
let body = read_help(®);
|
||||||
assert!(body.contains("Key: s"), "{body}");
|
assert!(body.contains("Key: s"), "{body}");
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
|
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
use crate::buffer::{BufferId, EditOp};
|
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||||
use crate::buffer_registry::BufferRegistry;
|
use crate::buffer_registry::BufferRegistry;
|
||||||
use crate::protocol::{AttachmentHandle, InstanceIdentity};
|
use crate::protocol::{AttachmentHandle, InstanceIdentity};
|
||||||
|
|
||||||
|
|
@ -69,8 +69,14 @@ pub fn format_echo_line(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the full description into the `*pmacs-instance*` buffer
|
/// Render the full description into the `*pmacs-instance*` buffer
|
||||||
/// (creating it if absent), replacing its full contents. Returns the
|
/// (creating it if absent), replacing its full contents. Returns
|
||||||
/// buffer id.
|
/// 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
|
/// The buffer is marked clean — the modeline shouldn't claim unsaved
|
||||||
/// changes for a generated buffer.
|
/// changes for a generated buffer.
|
||||||
|
|
@ -78,26 +84,46 @@ pub fn render(
|
||||||
registry: &mut BufferRegistry,
|
registry: &mut BufferRegistry,
|
||||||
identity: &InstanceIdentity,
|
identity: &InstanceIdentity,
|
||||||
attachment: Option<&AttachmentHandle>,
|
attachment: Option<&AttachmentHandle>,
|
||||||
) -> BufferId {
|
) -> (BufferId, Vec<crate::rope::Edit>) {
|
||||||
let text = format_full_text(identity, attachment);
|
let text = format_full_text(identity, attachment);
|
||||||
let id = registry
|
let id = registry
|
||||||
.find_by_name(INSTANCE_BUFFER_NAME)
|
.find_by_name(INSTANCE_BUFFER_NAME)
|
||||||
.unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME));
|
.unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME));
|
||||||
let buf = registry.get_mut(id).expect("just resolved");
|
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() {
|
if !buf.is_empty() {
|
||||||
let len = buf.len();
|
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),
|
range: crate::rope::Range::new(0, len),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
let _ = buf.apply_edit(EditOp::Insert {
|
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
|
||||||
pos: 0,
|
pos: 0,
|
||||||
bytes: text.as_bytes(),
|
bytes: text.as_bytes(),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buf.mark_clean();
|
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:
|
/// Multi-section text payload for the buffer view. Sections:
|
||||||
|
|
@ -360,7 +386,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn render_creates_named_buffer_and_writes_text() {
|
fn render_creates_named_buffer_and_writes_text() {
|
||||||
let mut reg = BufferRegistry::new();
|
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");
|
let buf = reg.get(id).expect("just rendered");
|
||||||
assert_eq!(buf.name(), INSTANCE_BUFFER_NAME);
|
assert_eq!(buf.name(), INSTANCE_BUFFER_NAME);
|
||||||
let len = buf.len();
|
let len = buf.len();
|
||||||
|
|
@ -378,8 +404,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn render_replaces_existing_contents_on_second_call() {
|
fn render_replaces_existing_contents_on_second_call() {
|
||||||
let mut reg = BufferRegistry::new();
|
let mut reg = BufferRegistry::new();
|
||||||
let id1 = render(&mut reg, &local_identity(), None);
|
let (id1, _) = render(&mut reg, &local_identity(), None);
|
||||||
let id2 = render(&mut reg, &named_identity(), None);
|
let (id2, _) = render(&mut reg, &named_identity(), None);
|
||||||
assert_eq!(id1, id2, "render must reuse the named buffer");
|
assert_eq!(id1, id2, "render must reuse the named buffer");
|
||||||
let buf = reg.get(id2).expect("rendered");
|
let buf = reg.get(id2).expect("rendered");
|
||||||
let len = buf.len();
|
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]
|
#[test]
|
||||||
fn render_with_attachment_includes_remote_section() {
|
fn render_with_attachment_includes_remote_section() {
|
||||||
let mut reg = BufferRegistry::new();
|
let mut reg = BufferRegistry::new();
|
||||||
let h = sample_attachment();
|
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 buf = reg.get(id).expect("rendered");
|
||||||
let len = buf.len();
|
let len = buf.len();
|
||||||
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
|
let mut bytes = vec![0u8; usize::try_from(len).unwrap()];
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,17 @@ impl RenderState {
|
||||||
/// Returns a `CellDelta` (with the changed spans) followed by a
|
/// Returns a `CellDelta` (with the changed spans) followed by a
|
||||||
/// `Cursor` message. Returns an empty vec if the grid is too small
|
/// `Cursor` message. Returns an empty vec if the grid is too small
|
||||||
/// to render meaningfully (`rows < 2` or `cols == 0`).
|
/// 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 {
|
if self.size.rows < 2 || self.size.cols == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +107,17 @@ impl RenderState {
|
||||||
stride: self.size.cols,
|
stride: self.size.cols,
|
||||||
size: self.size,
|
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
|
// Full-grid sync semantics (T M5.3): when `needs_full_grid` is
|
||||||
|
|
@ -166,7 +186,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn render_returns_cell_delta_and_cursor() {
|
fn render_returns_cell_delta_and_cursor() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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_eq!(msgs.len(), 2);
|
||||||
assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. }));
|
assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. }));
|
||||||
assert!(matches!(msgs[1], InstanceMessage::Cursor(_)));
|
assert!(matches!(msgs[1], InstanceMessage::Cursor(_)));
|
||||||
|
|
@ -175,7 +195,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn first_frame_is_full_grid_sync() {
|
fn first_frame_is_full_grid_sync() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
|
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
|
||||||
_ => panic!("expected CellDelta first"),
|
_ => panic!("expected CellDelta first"),
|
||||||
|
|
@ -185,8 +205,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn second_frame_is_differential() {
|
fn second_frame_is_differential() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
let mut r = RenderState::new(CellSize::new(24, 80));
|
||||||
let _ = r.render_frame(&empty_state());
|
let _ = r.render_frame(&empty_state(), &[]);
|
||||||
let msgs = r.render_frame(&empty_state());
|
let msgs = r.render_frame(&empty_state(), &[]);
|
||||||
match &msgs[0] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid),
|
InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid),
|
||||||
_ => panic!("expected CellDelta first"),
|
_ => panic!("expected CellDelta first"),
|
||||||
|
|
@ -197,8 +217,8 @@ mod tests {
|
||||||
fn unchanged_state_produces_empty_spans_after_first_frame() {
|
fn unchanged_state_produces_empty_spans_after_first_frame() {
|
||||||
let state = empty_state();
|
let state = empty_state();
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
let mut r = RenderState::new(CellSize::new(24, 80));
|
||||||
let _ = r.render_frame(&state);
|
let _ = r.render_frame(&state, &[]);
|
||||||
let msgs = r.render_frame(&state);
|
let msgs = r.render_frame(&state, &[]);
|
||||||
match &msgs[0] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { spans, .. } => assert!(
|
InstanceMessage::CellDelta { spans, .. } => assert!(
|
||||||
spans.is_empty(),
|
spans.is_empty(),
|
||||||
|
|
@ -211,7 +231,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn resize_reallocates_and_flags_full_grid() {
|
fn resize_reallocates_and_flags_full_grid() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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);
|
assert!(!r.needs_full_grid);
|
||||||
|
|
||||||
r.resize(CellSize::new(40, 120));
|
r.resize(CellSize::new(40, 120));
|
||||||
|
|
@ -220,7 +240,7 @@ mod tests {
|
||||||
assert_eq!(r.next.len(), 40 * 120);
|
assert_eq!(r.next.len(), 40 * 120);
|
||||||
assert!(r.needs_full_grid);
|
assert!(r.needs_full_grid);
|
||||||
|
|
||||||
let msgs = r.render_frame(&empty_state());
|
let msgs = r.render_frame(&empty_state(), &[]);
|
||||||
match &msgs[0] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
|
InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
|
|
@ -230,7 +250,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn resize_to_same_size_is_noop() {
|
fn resize_to_same_size_is_noop() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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);
|
assert!(!r.needs_full_grid);
|
||||||
r.resize(CellSize::new(24, 80));
|
r.resize(CellSize::new(24, 80));
|
||||||
// No reallocation, no full-grid flip.
|
// No reallocation, no full-grid flip.
|
||||||
|
|
@ -240,7 +260,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn force_full_grid_resync_flips_flag() {
|
fn force_full_grid_resync_flips_flag() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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);
|
assert!(!r.needs_full_grid);
|
||||||
r.force_full_grid_resync();
|
r.force_full_grid_resync();
|
||||||
assert!(r.needs_full_grid);
|
assert!(r.needs_full_grid);
|
||||||
|
|
@ -250,16 +270,16 @@ mod tests {
|
||||||
fn too_small_grid_returns_empty_messages() {
|
fn too_small_grid_returns_empty_messages() {
|
||||||
// rows < 2 means we can't paint a text-area + status row.
|
// rows < 2 means we can't paint a text-area + status row.
|
||||||
let mut r = RenderState::new(CellSize::new(1, 80));
|
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));
|
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]
|
#[test]
|
||||||
fn cursor_message_carries_coord_when_paint_returns_one() {
|
fn cursor_message_carries_coord_when_paint_returns_one() {
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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] {
|
match &msgs[1] {
|
||||||
InstanceMessage::Cursor(Some(cs)) => {
|
InstanceMessage::Cursor(Some(cs)) => {
|
||||||
assert!(cs.visible);
|
assert!(cs.visible);
|
||||||
|
|
@ -289,7 +309,7 @@ mod tests {
|
||||||
// Criterion 1: the first frame after construction is a full-grid
|
// Criterion 1: the first frame after construction is a full-grid
|
||||||
// CellDelta carrying every non-default cell.
|
// CellDelta carrying every non-default cell.
|
||||||
let mut r = RenderState::new(CellSize::new(24, 80));
|
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] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, spans } => {
|
InstanceMessage::CellDelta { full_grid, spans } => {
|
||||||
assert!(*full_grid, "first frame must be flagged full_grid=true");
|
assert!(*full_grid, "first frame must be flagged full_grid=true");
|
||||||
|
|
@ -315,7 +335,7 @@ mod tests {
|
||||||
let mut state = EditorState::new();
|
let mut state = EditorState::new();
|
||||||
let mut r = RenderState::new(size);
|
let mut r = RenderState::new(size);
|
||||||
// Seat the prev buffer.
|
// Seat the prev buffer.
|
||||||
let _ = r.render_frame(&state);
|
let _ = r.render_frame(&state, &[]);
|
||||||
|
|
||||||
// Single character insert.
|
// Single character insert.
|
||||||
state.dispatch_key(
|
state.dispatch_key(
|
||||||
|
|
@ -327,7 +347,7 @@ mod tests {
|
||||||
state: KeyEventState::empty(),
|
state: KeyEventState::empty(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let msgs = r.render_frame(&state);
|
let msgs = r.render_frame(&state, &[]);
|
||||||
match &msgs[0] {
|
match &msgs[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, spans } => {
|
InstanceMessage::CellDelta { full_grid, spans } => {
|
||||||
assert!(!*full_grid, "differential frame must not flag full_grid");
|
assert!(!*full_grid, "differential frame must not flag full_grid");
|
||||||
|
|
@ -354,7 +374,7 @@ mod tests {
|
||||||
let mut r = RenderState::new(size);
|
let mut r = RenderState::new(size);
|
||||||
|
|
||||||
// First render: seats prev with the painted frame.
|
// 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] {
|
let baseline_changed: usize = match &first[0] {
|
||||||
InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(),
|
InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
|
|
@ -363,7 +383,7 @@ mod tests {
|
||||||
|
|
||||||
// A second render with no state change normally produces zero
|
// A second render with no state change normally produces zero
|
||||||
// spans (the state matches prev exactly).
|
// spans (the state matches prev exactly).
|
||||||
let unchanged = r.render_frame(&empty_state());
|
let unchanged = r.render_frame(&empty_state(), &[]);
|
||||||
match &unchanged[0] {
|
match &unchanged[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, spans } => {
|
InstanceMessage::CellDelta { full_grid, spans } => {
|
||||||
assert!(!*full_grid);
|
assert!(!*full_grid);
|
||||||
|
|
@ -376,7 +396,7 @@ mod tests {
|
||||||
// what's on screen. force_full_grid_resync flags the next frame
|
// what's on screen. force_full_grid_resync flags the next frame
|
||||||
// for full sync.
|
// for full sync.
|
||||||
r.force_full_grid_resync();
|
r.force_full_grid_resync();
|
||||||
let resync = r.render_frame(&empty_state());
|
let resync = r.render_frame(&empty_state(), &[]);
|
||||||
match &resync[0] {
|
match &resync[0] {
|
||||||
InstanceMessage::CellDelta { full_grid, spans } => {
|
InstanceMessage::CellDelta { full_grid, spans } => {
|
||||||
assert!(*full_grid, "post-resync frame must be full_grid=true");
|
assert!(*full_grid, "post-resync frame must be full_grid=true");
|
||||||
|
|
|
||||||
17
src/lib.rs
17
src/lib.rs
|
|
@ -38,6 +38,15 @@ pub mod command;
|
||||||
pub mod completion;
|
pub mod completion;
|
||||||
pub mod completion_framework;
|
pub mod completion_framework;
|
||||||
pub mod config;
|
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;
|
||||||
pub mod daemon_attach;
|
pub mod daemon_attach;
|
||||||
pub mod definition;
|
pub mod definition;
|
||||||
|
|
@ -66,8 +75,16 @@ pub mod lua_isolation;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod message_bus;
|
pub mod message_bus;
|
||||||
pub mod minibuffer;
|
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;
|
||||||
|
pub mod overlay_color;
|
||||||
|
pub mod overlay_paint;
|
||||||
pub mod packages;
|
pub mod packages;
|
||||||
|
pub mod presence;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod project_index;
|
pub mod project_index;
|
||||||
|
|
|
||||||
12
src/lua.rs
12
src/lua.rs
|
|
@ -409,8 +409,18 @@ impl LuaHost {
|
||||||
// switched to it via C-x b), its line cache would otherwise go
|
// switched to it via C-x b), its line cache would otherwise go
|
||||||
// stale on every appended error and cursor motion would stop
|
// stale on every appended error and cursor motion would stop
|
||||||
// updating the screen.
|
// 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() {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1198,11 +1198,32 @@ fn add_history_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notify every window currently displaying `buffer_id` that the
|
/// Notify every window currently displaying `buffer_id` that the
|
||||||
/// buffer was just edited via the Lua surface. Without this, a window
|
/// buffer was just edited via the Lua surface, AND queue the edit's
|
||||||
/// already displaying the edited buffer would keep a stale
|
/// 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
|
/// [`crate::text_view::TextView`] line cache — cursor motions stop
|
||||||
/// updating the screen until the window switches buffers.
|
/// 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
|
/// No-op when no [`SharedCore`] has been registered as Lua app data
|
||||||
/// (the shape used by the early-stage tests that exercise the
|
/// (the shape used by the early-stage tests that exercise the
|
||||||
/// registry without an editor core).
|
/// 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 {
|
let Some(core) = lua.app_data_ref::<SharedCore>() else {
|
||||||
return;
|
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`.
|
/// 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
|
let attachment = lua
|
||||||
.app_data_ref::<CurrentAttachmentSlot>()
|
.app_data_ref::<CurrentAttachmentSlot>()
|
||||||
.and_then(|s| s.get());
|
.and_then(|s| s.get());
|
||||||
let id =
|
let (id, edits) =
|
||||||
crate::instance_buffer::render(&mut reg.borrow_mut(), &identity, attachment.as_ref());
|
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))
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
|
fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<Table> {
|
||||||
let buffer = lua.create_table()?;
|
let buffer = lua.create_table()?;
|
||||||
|
|
@ -4024,10 +4093,11 @@ fn install_help_module(
|
||||||
let k = kms.borrow();
|
let k = kms.borrow();
|
||||||
help::render_command(&mut r, &c, &k, &name)
|
help::render_command(&mut r, &c, &k, &name)
|
||||||
};
|
};
|
||||||
if let Some(id) = result {
|
if let Some((id, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, id);
|
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();
|
let k = kms.borrow();
|
||||||
help::render_key(&mut r, &c, &k, active_buffer, &sequence)
|
help::render_key(&mut r, &c, &k, active_buffer, &sequence)
|
||||||
};
|
};
|
||||||
if let Some(id) = result {
|
if let Some((id, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, id);
|
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();
|
let mut r = reg.borrow_mut();
|
||||||
help::render_buffer(&mut r, id.0)
|
help::render_buffer(&mut r, id.0)
|
||||||
};
|
};
|
||||||
if let Some(rid) = result {
|
if let Some((rid, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, rid);
|
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();
|
let k = kms.borrow();
|
||||||
help::render_mode(&mut r, &k, &name)
|
help::render_mode(&mut r, &k, &name)
|
||||||
};
|
};
|
||||||
if let Some(id) = result {
|
if let Some((id, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, id);
|
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();
|
let h = hks.borrow();
|
||||||
help::render_hook(&mut r, &h, &name)
|
help::render_hook(&mut r, &h, &name)
|
||||||
};
|
};
|
||||||
if let Some(id) = result {
|
if let Some((id, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, id);
|
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();
|
let mut r = reg.borrow_mut();
|
||||||
help::render_view(&mut r, id.0)
|
help::render_view(&mut r, id.0)
|
||||||
};
|
};
|
||||||
if let Some(rid) = result {
|
if let Some((rid, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, rid);
|
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();
|
let h = hks.borrow();
|
||||||
help::follow_link_at(&mut r, &c, &k, &h, cursor)
|
help::follow_link_at(&mut r, &c, &k, &h, cursor)
|
||||||
};
|
};
|
||||||
if let Some(id) = result {
|
if let Some((id, edits)) = result.as_ref() {
|
||||||
rebuild_help_buffer_views(lua, id);
|
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();
|
let reg = registry.clone();
|
||||||
async_mod.set(
|
async_mod.set(
|
||||||
"_show_workers_buffer",
|
"_show_workers_buffer",
|
||||||
lua.create_function(move |_, ()| {
|
lua.create_function(move |lua, ()| {
|
||||||
let snap = rt.workers_snapshot();
|
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))
|
Ok(BufferIdLua(id))
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -9666,7 +9746,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
||||||
lua.create_function(move |lua, ()| {
|
lua.create_function(move |lua, ()| {
|
||||||
let c = cc.borrow();
|
let c = cc.borrow();
|
||||||
let t = lua.create_table()?;
|
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())?;
|
t.set(i + 1, id.raw())?;
|
||||||
}
|
}
|
||||||
Ok(t)
|
Ok(t)
|
||||||
|
|
@ -9678,7 +9758,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
||||||
let cc = core.clone();
|
let cc = core.clone();
|
||||||
win.set(
|
win.set(
|
||||||
"current",
|
"current",
|
||||||
lua.create_function(move |_, ()| Ok(cc.borrow().active.raw()))?,
|
lua.create_function(move |_, ()| Ok(cc.borrow().active_window_id().raw()))?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 (2–4
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 2–27;
|
||||||
|
//! `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 (5–100 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
930
src/protocol.rs
930
src/protocol.rs
|
|
@ -295,6 +295,31 @@ pub enum FrontendEvent {
|
||||||
/// Frontend is going away. Instance treats this as immediate
|
/// Frontend is going away. Instance treats this as immediate
|
||||||
/// detach; no acknowledgement required.
|
/// detach; no acknowledgement required.
|
||||||
Detach(FrontendId),
|
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 {
|
impl FrontendEvent {
|
||||||
|
|
@ -308,7 +333,8 @@ impl FrontendEvent {
|
||||||
| Self::Paste { frontend_id, .. }
|
| Self::Paste { frontend_id, .. }
|
||||||
| Self::FocusGained(frontend_id)
|
| Self::FocusGained(frontend_id)
|
||||||
| Self::FocusLost(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
|
/// Frontend sent a malformed message or otherwise violated the
|
||||||
/// protocol. The connection is closed without further dialogue.
|
/// protocol. The connection is closed without further dialogue.
|
||||||
ProtocolError,
|
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.
|
/// Rendering and signals from instance to frontend.
|
||||||
|
|
@ -388,6 +435,173 @@ pub enum InstanceMessage {
|
||||||
Signal(InstanceSignal),
|
Signal(InstanceSignal),
|
||||||
/// Instance is terminating the attachment.
|
/// Instance is terminating the attachment.
|
||||||
Goodbye(GoodbyeReason),
|
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
|
/// Wire-protocol version. Bumped on any breaking change to the
|
||||||
/// `Hello` / `AttachRequest` / event-message shapes.
|
/// `Hello` / `AttachRequest` / event-message shapes.
|
||||||
///
|
///
|
||||||
/// The handshake compares the two sides' values; mismatches close the
|
/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`];
|
||||||
/// connection with [`GoodbyeReason::VersionMismatch`].
|
/// mismatches close the connection with
|
||||||
pub const PROTOCOL_VERSION: u32 = 1;
|
/// [`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.
|
/// 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+
|
/// Empty for v0.1; the type exists so that adding capabilities in v0.2+
|
||||||
/// is not a breaking-change. Symmetric with [`FrontendCapabilities`].
|
/// 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.5–M10.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 {
|
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.
|
/// Capabilities the frontend advertises to the instance.
|
||||||
|
|
@ -996,6 +1304,106 @@ pub struct FrontendCapabilities {
|
||||||
/// branching is done on the explicit capability bits above.
|
/// branching is done on the explicit capability bits above.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub terminal_kind: Option<String>,
|
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.
|
/// First message sent by the instance to a freshly-attached frontend.
|
||||||
|
|
@ -2169,10 +2577,24 @@ mod tests {
|
||||||
// --- M5.5a handshake & postcard round-trips ---
|
// --- M5.5a handshake & postcard round-trips ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_version_is_one_for_v01() {
|
fn protocol_version_is_two_for_v10() {
|
||||||
// Pin the value: every wire-shape change in v0.1 patch releases
|
// Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire
|
||||||
// must keep this constant or break the handshake.
|
// adds CrdtOp / PresenceUpdate variants; the v1.0 binary
|
||||||
assert_eq!(PROTOCOL_VERSION, 1);
|
// 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]
|
#[test]
|
||||||
|
|
@ -2205,6 +2627,8 @@ mod tests {
|
||||||
mouse: true,
|
mouse: true,
|
||||||
bracketed_paste: true,
|
bracketed_paste: true,
|
||||||
terminal_kind: Some("xterm-256color".into()),
|
terminal_kind: Some("xterm-256color".into()),
|
||||||
|
multi_frontend: false,
|
||||||
|
crdt_replica: false,
|
||||||
},
|
},
|
||||||
initial_size: CellSize::new(50, 200),
|
initial_size: CellSize::new(50, 200),
|
||||||
};
|
};
|
||||||
|
|
@ -2377,4 +2801,490 @@ mod tests {
|
||||||
other => panic!("expected Key, got {other:?}"),
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
src/rope.rs
54
src/rope.rs
|
|
@ -185,6 +185,7 @@ impl Rope {
|
||||||
new_rope: self.clone(),
|
new_rope: self.clone(),
|
||||||
range: Range::new(pos, pos),
|
range: Range::new(pos, pos),
|
||||||
inserted_len: 0,
|
inserted_len: 0,
|
||||||
|
crdt_op: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +203,7 @@ impl Rope {
|
||||||
new_rope: Self { root },
|
new_rope: Self { root },
|
||||||
range: Range::new(pos, pos),
|
range: Range::new(pos, pos),
|
||||||
inserted_len: bytes.len() as u64,
|
inserted_len: bytes.len() as u64,
|
||||||
|
crdt_op: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +230,7 @@ impl Rope {
|
||||||
new_rope: self.clone(),
|
new_rope: self.clone(),
|
||||||
range: Range::new(start, end),
|
range: Range::new(start, end),
|
||||||
inserted_len: 0,
|
inserted_len: 0,
|
||||||
|
crdt_op: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +244,7 @@ impl Rope {
|
||||||
new_rope: Self { root: new_root },
|
new_rope: Self { root: new_root },
|
||||||
range: Range::new(start, end),
|
range: Range::new(start, end),
|
||||||
inserted_len: 0,
|
inserted_len: 0,
|
||||||
|
crdt_op: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,6 +260,7 @@ impl Rope {
|
||||||
new_rope: after_insert.new_rope,
|
new_rope: after_insert.new_rope,
|
||||||
range: Range::new(start, end),
|
range: Range::new(start, end),
|
||||||
inserted_len: bytes.len() as u64,
|
inserted_len: bytes.len() as u64,
|
||||||
|
crdt_op: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -304,6 +309,55 @@ pub struct Edit {
|
||||||
pub range: Range,
|
pub range: Range,
|
||||||
/// Number of bytes inserted at `range.start` in the *new* rope.
|
/// Number of bytes inserted at `range.start` in the *new* rope.
|
||||||
pub inserted_len: u64,
|
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.
|
/// A half-open byte range `[start, end)` into a rope.
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,8 @@ mod tests {
|
||||||
mouse: true,
|
mouse: true,
|
||||||
bracketed_paste: true,
|
bracketed_paste: true,
|
||||||
terminal_kind: Some("xterm-256color".into()),
|
terminal_kind: Some("xterm-256color".into()),
|
||||||
|
multi_frontend: false,
|
||||||
|
crdt_replica: false,
|
||||||
},
|
},
|
||||||
initial_size: crate::cell::CellSize::new(24, 80),
|
initial_size: crate::cell::CellSize::new(24, 80),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,27 @@ pub struct Layout {
|
||||||
pub root: LayoutNode,
|
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 {
|
impl Layout {
|
||||||
/// A trivial single-window layout.
|
/// A trivial single-window layout.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
|
|
@ -37,37 +37,74 @@ use std::fmt::Write;
|
||||||
use crate::async_runtime::{
|
use crate::async_runtime::{
|
||||||
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot,
|
ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot,
|
||||||
};
|
};
|
||||||
use crate::buffer::{BufferId, EditOp};
|
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||||
use crate::buffer_registry::BufferRegistry;
|
use crate::buffer_registry::BufferRegistry;
|
||||||
|
|
||||||
/// Canonical name for the workers observability buffer.
|
/// Canonical name for the workers observability buffer.
|
||||||
pub const WORKERS_BUFFER_NAME: &str = "*workers*";
|
pub const WORKERS_BUFFER_NAME: &str = "*workers*";
|
||||||
|
|
||||||
/// Render `snapshot` into the `*workers*` buffer (creating it if
|
/// 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.
|
/// 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 text = format_snapshot(snapshot);
|
||||||
let id = registry
|
let id = registry
|
||||||
.find_by_name(WORKERS_BUFFER_NAME)
|
.find_by_name(WORKERS_BUFFER_NAME)
|
||||||
.unwrap_or_else(|| registry.create(WORKERS_BUFFER_NAME));
|
.unwrap_or_else(|| registry.create(WORKERS_BUFFER_NAME));
|
||||||
let buf = registry.get_mut(id).expect("just resolved");
|
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() {
|
if !buf.is_empty() {
|
||||||
let len = buf.len();
|
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),
|
range: crate::rope::Range::new(0, len),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
let _ = buf.apply_edit(EditOp::Insert {
|
if let Ok(edit) = buf.apply_edit(EditOp::Insert {
|
||||||
pos: 0,
|
pos: 0,
|
||||||
bytes: text.as_bytes(),
|
bytes: text.as_bytes(),
|
||||||
});
|
}) {
|
||||||
|
edits.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buf.mark_clean();
|
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.
|
/// Format a snapshot as the buffer's text payload.
|
||||||
|
|
@ -288,6 +325,22 @@ mod tests {
|
||||||
assert!(text.contains("200ms ago"));
|
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]
|
#[test]
|
||||||
fn duration_formatter_handles_three_scales() {
|
fn duration_formatter_handles_three_scales() {
|
||||||
assert_eq!(format_duration_ms(42), "42ms");
|
assert_eq!(format_duration_ms(42), "42ms");
|
||||||
|
|
|
||||||
|
|
@ -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!();
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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!();
|
||||||
|
}
|
||||||
|
|
@ -466,7 +466,7 @@ fn render_active_window_to_grid(
|
||||||
use pmacs::window::Rect;
|
use pmacs::window::Rect;
|
||||||
|
|
||||||
let mut core = state.core.borrow_mut();
|
let mut core = state.core.borrow_mut();
|
||||||
let active = core.active;
|
let active = core.active_window_id();
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
let win = core.windows.get_mut(&active).expect("active window");
|
let win = core.windows.get_mut(&active).expect("active window");
|
||||||
let rect = Rect::new(0, 0, rows, cols);
|
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 core = state.core.borrow();
|
||||||
let win = core
|
let win = core
|
||||||
.windows
|
.windows
|
||||||
.get(&core.active)
|
.get(&core.active_window_id())
|
||||||
.expect("active window present");
|
.expect("active window present");
|
||||||
assert!(
|
assert!(
|
||||||
!win.overlays.is_empty(),
|
!win.overlays.is_empty(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -150,6 +150,8 @@ fn build_default_caps() -> FrontendCapabilities {
|
||||||
mouse: true,
|
mouse: true,
|
||||||
bracketed_paste: true,
|
bracketed_paste: true,
|
||||||
terminal_kind: Some("perf-gate".into()),
|
terminal_kind: Some("perf-gate".into()),
|
||||||
|
multi_frontend: false,
|
||||||
|
crdt_replica: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue