diff --git a/Cargo.lock b/Cargo.lock index bbc7f72..ae4117a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +30,33 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "append-only-bytes" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac436601d6bdde674a0d7fb593e829ffe7b3387c351b356dd20e2d40f5bf3ee5" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -59,6 +99,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -78,12 +127,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" version = "1.2.61" @@ -227,6 +288,58 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -261,6 +374,48 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "ensure-cov" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33753185802e107b8fa907192af1f0eca13b1fb33327a59266d650fef29b2b4e" + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_home" version = "0.1.0" @@ -318,6 +473,45 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -328,6 +522,33 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-btree" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c1bce85c110ab718fd139e0cc89c51b63bd647b14a767e24bdfc77c83df79b" +dependencies = [ + "arref", + "heapless 0.9.1", + "itertools 0.11.0", + "loro-thunderdome", + "proc-macro2", + "rustc-hash", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -362,6 +583,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -371,6 +601,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.0" @@ -384,13 +620,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -403,6 +665,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "serde", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -415,18 +698,54 @@ dependencies = [ "serde_core", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -439,6 +758,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -466,6 +791,159 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loro" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc16ee5fdda7bff6bbbd4ff276c31aa9747bc90ad1bdccf8f0da97cd2949c8a" +dependencies = [ + "enum-as-inner 0.6.1", + "generic-btree", + "loro-common", + "loro-delta", + "loro-internal", + "loro-kv-store", + "rustc-hash", + "tracing", +] + +[[package]] +name = "loro-common" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193e88dedf3bc07f3b25ec8bb609dd461349b26942e43933cb0f599bc09d9c5b" +dependencies = [ + "arbitrary", + "enum-as-inner 0.6.1", + "leb128", + "loro-rle", + "nonmax", + "rustc-hash", + "serde", + "serde_columnar", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "loro-delta" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eafa788a72c1cbf0b7dc08a862cd7cc31b96d99c2ef749cdc94c2330f9494d3" +dependencies = [ + "arrayvec", + "enum-as-inner 0.5.1", + "generic-btree", + "heapless 0.8.0", +] + +[[package]] +name = "loro-internal" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d42db22ea93c266d5b6ef09ba94af080b6a4d131942e9a28bfa6a218b312b5f" +dependencies = [ + "append-only-bytes", + "arref", + "bytes", + "either", + "ensure-cov", + "enum-as-inner 0.6.1", + "enum_dispatch", + "generic-btree", + "getrandom 0.2.17", + "im", + "itertools 0.12.1", + "leb128", + "loom", + "loro-common", + "loro-delta", + "loro-kv-store", + "loro-rle", + "loro_fractional_index", + "md5", + "nonmax", + "num", + "num-traits", + "once_cell", + "parking_lot", + "pest", + "pest_derive", + "postcard", + "pretty_assertions", + "rand 0.8.6", + "rustc-hash", + "serde", + "serde_columnar", + "serde_json", + "smallvec", + "thiserror 1.0.69", + "thread_local", + "tracing", + "wasm-bindgen", + "xxhash-rust", +] + +[[package]] +name = "loro-kv-store" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18853eed186c39e0b9d541a1f847161ad05bcf366c412068c9d257d5d981a9b5" +dependencies = [ + "bytes", + "ensure-cov", + "loro-common", + "lz4_flex", + "once_cell", + "quick_cache", + "rustc-hash", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "loro-rle" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76400c3eea6bb39b013406acce964a8db39311534e308286c8d8721baba8ee20" +dependencies = [ + "append-only-bytes", + "num", + "smallvec", +] + +[[package]] +name = "loro-thunderdome" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" + +[[package]] +name = "loro_fractional_index" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c8ea186958094052b971fe7e322a934b034c3bf62f0458ccea04fcd687ba1" +dependencies = [ + "once_cell", + "rand 0.8.6", + "serde", +] + [[package]] name = "lua-src" version = "547.0.0" @@ -485,12 +963,45 @@ dependencies = [ "which", ] +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mio" version = "1.2.0" @@ -553,6 +1064,86 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", ] [[package]] @@ -562,6 +1153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -593,6 +1185,55 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.33" @@ -605,11 +1246,14 @@ version = "0.1.0" dependencies = [ "crossbeam", "crossterm", + "loro", "mlua", "nix 0.29.0", "portable-pty", "postcard", "proptest", + "rand 0.8.6", + "rand_distr", "rmp-serde", "semver", "serde", @@ -656,7 +1300,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "serde", ] @@ -669,6 +1313,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -676,7 +1330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -698,8 +1352,8 @@ dependencies = [ "bit-vec", "bitflags 2.11.1", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.4", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -713,6 +1367,18 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quote" version = "1.0.45" @@ -734,14 +1400,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -751,7 +1438,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -763,13 +1459,32 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", ] [[package]] @@ -888,6 +1603,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -914,6 +1635,31 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_columnar" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a16e404f17b16d0273460350e29b02d76ba0d70f34afdc9a4fa034c97d6c6eb" +dependencies = [ + "itertools 0.11.0", + "postcard", + "serde", + "serde_columnar_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_columnar_derive" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45958fce4903f67e871fbf15ac78e289269b21ebd357d6fecacdba233629112e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -931,7 +1677,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -979,6 +1725,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -1032,11 +1787,30 @@ dependencies = [ "libc", ] +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "spin" @@ -1059,6 +1833,23 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1109,7 +1900,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1120,7 +1911,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] @@ -1164,6 +1964,67 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "tree-sitter" version = "0.26.8" @@ -1214,12 +2075,24 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" @@ -1244,6 +2117,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -1283,6 +2162,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1357,6 +2281,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1485,7 +2418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -1496,10 +2429,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1515,7 +2448,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1557,6 +2490,18 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.8.48" @@ -1574,7 +2519,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b9cc3b3..b17a4c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,12 @@ multiple_crate_versions = "allow" default = ["luajit"] luajit = ["mlua/luajit", "mlua/vendored"] lua54 = ["mlua/lua54", "mlua/vendored"] +# T M10.2: gate the CRDT-backed buffer mode behind an opt-in feature +# so v0.1 builds carry zero CRDT overhead (no loro dependency, no +# field on the Buffer struct layout, no branch on apply_edit). v1.0 +# builds enable `crdt`; the rope-projection redirect from M10.1 means +# the feature flip is invisible to v0.1 frontends and to workers. +crdt = ["dep:loro"] [dependencies] crossterm = "0.28" @@ -107,7 +113,7 @@ tree-sitter-md = "0.5" # T M4.4 process supervisor: signal sending without `unsafe`. Keep # the feature surface tight to keep build time low (no syscalls # beyond `kill(2)` for v0.1). -nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term"] } +nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket"] } # T M4.4 PTY mode: portable abstraction over openpty / fork+exec # with controlling-tty wiring. The crate uses internal `unsafe` # but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"` @@ -131,10 +137,26 @@ semver = { version = "1", features = ["serde"] } # bytes detects upstream tampering even when the host serves a SHA-1 # collision. Pure-Rust implementation; no system dep. sha2 = "0.10" +# T M10.2 sequence CRDT for the v1.0 multi-frontend promotion. Selected +# in M10.1 (Decision section under spec §sec:m10-crdt-choice) on the +# basis of: per-op throughput dominance under realistic mixed-workload +# (190× faster than yrs at 30s window), ~100× more compact wire +# representation, stable v1.x API, and explicit spec mention. The pin +# is exact (`=1.12.0`) per the M10.1 commitment language: library +# updates are deliberate work (re-run M10.1 benchmarks, run convergence +# proptest, run acceptance suite), not Cargo background activity. +# Optional + feature-gated: zero footprint in v0.1 builds. +loro = { version = "=1.12.0", optional = true } [dev-dependencies] proptest = "1" tempfile = "3" +# T M10.2 Day 7 perf bench (tests/m10_2_perf.rs) — seeded RNG plus +# log-normal op-size distribution matching the M10.1 methodology. +# Dev-only; not in release builds. rand 0.8 + edition 2024 requires +# `r#gen` for the (now-reserved) `gen` method name. +rand = "0.8" +rand_distr = "0.4" [profile.release] opt-level = 3 diff --git a/proptest-regressions/crdt.txt b/proptest-regressions/crdt.txt new file mode 100644 index 0000000..36e1295 --- /dev/null +++ b/proptest-regressions/crdt.txt @@ -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 diff --git a/src/attach.rs b/src/attach.rs index 34ecb0f..e0ff08c 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -124,11 +124,51 @@ impl std::fmt::Display for AttachError { match self { Self::Io(e) => write!(f, "attach I/O error: {e}"), Self::Transport(e) => write!(f, "{e}"), - Self::VersionMismatch { server, client } => write!( - f, - "protocol version mismatch (instance v{server}, client v{client})" - ), - Self::Rejected(reason) => write!(f, "instance rejected attach: {reason:?}"), + Self::VersionMismatch { server, client } => { + // T M10.7 criterion 5: the message must tell the user + // which side is at the older version. Comparing + // `server` against `client` produces an unambiguous + // identification without the user needing to decode + // version-number semantics. + let which_older = match server.cmp(client) { + std::cmp::Ordering::Less => { + " The pmacs daemon is at the older version — upgrade the daemon \ + (or restart it after upgrading the pmacs binary)." + } + std::cmp::Ordering::Greater => { + " Your pmacs binary is at the older version — upgrade the binary." + } + std::cmp::Ordering::Equal => "", + }; + write!( + f, + "protocol version mismatch (instance v{server}, client v{client}).{which_older}" + ) + } + Self::Rejected(reason) => match reason { + // T M10.7: capability negotiation mismatch — name the + // capabilities the frontend asked for that the + // instance can't provide. The strings on the wire are + // exactly the `FrontendCapabilities` field names + // (e.g., `multi_frontend`); user-facing translation + // happens here. + GoodbyeReason::CapabilityMismatch { missing } => { + let translated: Vec<&str> = missing + .iter() + .map(|name| match name.as_str() { + "multi_frontend" => "multi-frontend collaboration", + "crdt_replica" => "CRDT replica participation", + other => other, + }) + .collect(); + write!( + f, + "instance does not support the requested capabilities: {}", + translated.join(", ") + ) + } + _ => write!(f, "instance rejected attach: {reason:?}"), + }, Self::Terminal(e) => write!(f, "terminal error: {e}"), Self::SshSpawnFailed { command, source } => write!( f, @@ -259,6 +299,35 @@ pub(crate) trait AttachPumpFrontend { fn present_messages(&mut self, msgs: &[InstanceMessage]) -> std::io::Result<()>; fn poll_event(&mut self, timeout: Duration) -> std::io::Result>; fn size(&self) -> CellSize; + + /// T M10.10 Day 3 step 5 Path β — paint an optimistic insert + /// at the terminal's current cursor position. Used when the + /// optimistic-apply orchestrator landed a `CrdtOp` AND the + /// mirror reports `cursor_at_end_of_line == true` for the active + /// buffer. + /// + /// Default impl no-ops; the production `Frontend` overrides with + /// the actual terminal-write path. Tests using stub frontends + /// inherit the no-op (visual paint isn't being asserted at the + /// unit-test level). + /// + /// Feature-gated: the orchestrator's call site is `#[cfg(feature = + /// "crdt")]`; the trait method exists only in CRDT builds to keep + /// the non-CRDT trait surface minimal. + #[cfg(feature = "crdt")] + fn paint_optimistic_insert(&mut self, _c: char) -> std::io::Result<()> { + Ok(()) + } + + /// T M10.10 Day 3 step 5 Path β — paint an optimistic + /// delete-back: erase the cell to the left of the cursor and + /// retreat the cursor one column. Cells match what the daemon's + /// `CellDelta` will eventually carry (last char of line becomes a + /// space at the cursor position before the cursor returns). + #[cfg(feature = "crdt")] + fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> { + Ok(()) + } } impl AttachPumpFrontend for Frontend { @@ -271,6 +340,14 @@ impl AttachPumpFrontend for Frontend { fn size(&self) -> CellSize { Frontend::size(self) } + #[cfg(feature = "crdt")] + fn paint_optimistic_insert(&mut self, c: char) -> std::io::Result<()> { + Frontend::paint_optimistic_insert(self, c) + } + #[cfg(feature = "crdt")] + fn paint_optimistic_delete_back(&mut self) -> std::io::Result<()> { + Frontend::paint_optimistic_delete_back(self) + } } /// Connect to the daemon at `socket_path` and run the attach client. @@ -289,7 +366,13 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> { // mismatch, malformed Hello, EOF), the error message reaches a // normal terminal — `Frontend::new` hasn't taken over yet. let hello: Hello = read_message(&mut stream)?; - if hello.protocol_version != PROTOCOL_VERSION { + // T M10.5: relaxed from strict equality to range membership per + // `§sec:m10-backward-compat`. A v1.0 frontend accepts a Hello + // from a v0.1 daemon (protocol_version=1) and downgrades its own + // request to match the server's version; symmetric to the daemon- + // side relaxation. Versions outside `SUPPORTED_PROTOCOL_VERSIONS` + // are still rejected. + if !crate::protocol::is_supported_protocol_version(hello.protocol_version) { return Err(AttachError::VersionMismatch { server: hello.protocol_version, client: PROTOCOL_VERSION, @@ -300,8 +383,13 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> { let (cols, rows) = crossterm::terminal::size().map_err(AttachError::Terminal)?; let initial_size = CellSize::new(u32::from(rows), u32::from(cols)); + // T M10.5: match the server's protocol version so a v1.0 frontend + // connecting to a v0.1 daemon advertises protocol_version=1 in + // its AttachRequest (the v0.1 daemon's strict-equality check will + // accept). The frontend's runtime behavior on the wire is the + // intersection of features both sides support. let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_capabilities(), initial_size, }; @@ -399,9 +487,26 @@ impl Read for KickAwareUnixReader { } } -fn build_capabilities() -> FrontendCapabilities { +// `pub` so the post-audit Finding 6 production-path test in +// `tests/m5_5_acceptance.rs` can verify the production caps directly +// rather than reconstructing them in the test (which is exactly the +// gap that allowed Finding 1 to survive M10.10's first audit — +// `attach_multi()`'s custom caps bypassed the production function). +// +// Not part of the stable public API; reserved for internal test use. +#[doc(hidden)] +pub fn build_capabilities() -> FrontendCapabilities { // The v0.1 TUI implements all of these; we report them honestly // so the daemon doesn't strip features that work fine. + // + // T M10.10 — `multi_frontend` and `crdt_replica` advertise the + // M10.10 BufferMirror + optimistic-apply infrastructure. Gated + // on the `crdt` Cargo feature because the relevant modules + // (`buffer_mirror`, `optimistic`) are conditionally compiled. + // A non-CRDT build's frontend can't bootstrap a mirror and + // shouldn't claim it can. CRDT-feature builds advertise true; + // the daemon's per-tick CursorByte + BufferSnapshot bootstrap + + // CrdtOp routing are then negotiated correctly. FrontendCapabilities { synchronized_output: true, unicode_smp: true, @@ -409,6 +514,8 @@ fn build_capabilities() -> FrontendCapabilities { mouse: true, bracketed_paste: true, terminal_kind: std::env::var("TERM").ok(), + multi_frontend: cfg!(feature = "crdt"), + crdt_replica: cfg!(feature = "crdt"), } } @@ -465,6 +572,16 @@ fn format_uptime(secs: u64) -> String { /// is joined before this function returns. The closure-and-call /// wind-down pattern guarantees this regardless of which `return` /// the loop takes. +// M10.10 grew this function with optimistic-apply orchestration + +// BufferSnapshot/CursorByte/CrdtOp routing in the message-drain +// loop. The 146-line size is intentionally cohesive: the closure +// captures the AttachIo writer and BufferMirror together, and +// splitting would require parameterizing both across helper +// functions or restructuring the wind-down pattern (drop(writer) +// → kick → join) which is the function's primary correctness +// invariant. The lint flags growth without naming a structural +// problem; defer to v0.2+ refactor if growth continues. +#[allow(clippy::too_many_lines)] pub(crate) fn run_attach_pair( io: AttachIo, frontend: &mut dyn AttachPumpFrontend, @@ -479,6 +596,13 @@ pub(crate) fn run_attach_pair( let (tx, rx) = mpsc::channel::(); let reader_handle = thread::spawn(move || run_reader(reader, tx)); + // T M10.10: per-session CRDT replica state. Bootstrapped by + // `InstanceMessage::BufferSnapshot` messages routed in the + // drain loop below; consumed by the optimistic-apply predicate + // wired in Day 3. + #[cfg(feature = "crdt")] + let mut buffer_mirror = crate::buffer_mirror::BufferMirror::new(assigned_id); + // Closure-and-call: any `return` from this closure still falls // through to `kick()` and `reader_handle.join()` below. Without // this wrapping a writer-side IO error inside the loop would skip @@ -486,8 +610,8 @@ pub(crate) fn run_attach_pair( let result: Result<(), AttachError> = (|| { loop { // Drain instance messages. Goodbye exits immediately; - // other messages are batched into a single - // present_messages call. + // BufferSnapshot routes to the mirror; other messages are + // batched into a single present_messages call. let mut batch: Vec = Vec::new(); let mut goodbye: Option = None; let mut reader_eof = false; @@ -497,6 +621,81 @@ pub(crate) fn run_attach_pair( goodbye = Some(reason); break; } + #[cfg(feature = "crdt")] + Ok(InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + }) => { + // T M10.10: bootstrap the mirror for + // `buffer_id`. AlreadyInitialized errors + // surface a daemon-side bug (double-send) but + // shouldn't abort the session — log and + // continue with prior state. Loro decode + // errors are similarly logged. + if let Err(e) = buffer_mirror.init_from_snapshot(buffer_id, &crdt_snapshot) + { + eprintln!("pmacs: BufferMirror init for {buffer_id:?} failed: {e}"); + } + } + #[cfg(feature = "crdt")] + Ok(InstanceMessage::CursorByte { + buffer_id, + byte_pos, + }) => { + // T M10.10 Finding 2: authoritative cursor + // byte-position update from the daemon. The + // optimistic-apply path consults + // `buffer_mirror.cursor_byte_pos(buffer_id)` + // before generating a local CrdtOp; this + // keeps that lookup current. Convert wire + // u64 → usize for the loro API. + buffer_mirror.set_cursor_byte_pos(buffer_id, byte_pos as usize); + } + #[cfg(feature = "crdt")] + Ok(InstanceMessage::CrdtOp { buffer_id, op }) => { + // T M10.10 step 4 — remote CrdtOp routing. + // + // The filter site is the message loop, NOT + // inside BufferMirror. Echoes of locally- + // applied edits arrive via CrdtOp broadcasts + // (the daemon fans out every op including the + // originator's own); the mirror has already + // applied these via apply_local_insert / + // apply_local_delete at keystroke time; + // re-applying would double-insert. The + // BufferMirror layer stays identity-ignorant + // by design — `apply_incoming_crdt_op` does + // the FrontendId comparison before invoking + // the mirror. + // + // Source FrontendId is derived from + // `op.peer_id` via the identity mapping + // documented in `crdt::peer_id_from_frontend` + // (FrontendId(n).0 == n). + let source = FrontendId(op.peer_id); + match crate::optimistic::apply_incoming_crdt_op( + &mut buffer_mirror, + assigned_id, + source, + buffer_id, + &op.bytes, + ) { + Ok(_outcome) => { + // Applied or SkippedEcho — both are + // success. Paint reconciliation + // (step 5) handles the visible diff. + } + Err(e) => { + // NotReady is the common case for a + // buffer this frontend hasn't been + // snapshotted for (mid-session + // buffer creation; v0.2's broadcast + // will close this gap). Log and + // continue. + eprintln!("pmacs: CrdtOp routing for {buffer_id:?} failed: {e}"); + } + } + } Ok(msg) => batch.push(msg), Err(mpsc::TryRecvError::Empty) => break, Err(mpsc::TryRecvError::Disconnected) => { @@ -534,10 +733,139 @@ pub(crate) fn run_attach_pair( return Ok(()); } - if let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size()) { - // Likely a broken pipe — instance went away. - eprintln!("pmacs: {e}"); - return Err(e); + // T M10.10 Day 3 step 3b — text-input optimistic-apply + // orchestration. For Press/Repeat key events, the + // orchestrator either: + // - returns FrontendEvent::CrdtOp (after applying the + // edit to the local mirror) when the mirror is ready + // for the active buffer, or + // - returns FrontendEvent::Key (graceful Refinement 4 + // fallback) when the optimistic path isn't viable. + // The caller writes whatever event was produced. Other + // event kinds (mouse, resize, paste, focus, Release-kind + // keys) fall through to the existing forward_event path. + #[cfg(feature = "crdt")] + let optimistic_handled = if let Event::Key(k) = &ev { + if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + let timestamp_ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| u64::try_from(d.as_nanos()).unwrap_or(0)) + .unwrap_or(0); + let pmacs_key = key_from_crossterm(k, assigned_id, timestamp_ns); + + // T M10.10 Day 3 step 5 Path β — determine + // visual-paint eligibility BEFORE the orchestrator + // mutates the mirror's cursor. End-of-line typing + // is the only case where single-Print optimistic + // paint matches the daemon's eventual CellDelta + // exactly (no cells right of cursor to shift). The + // action enum is captured so we can dispatch to + // the right paint primitive after the orchestrator + // produces a CrdtOp. + let action = crate::optimistic::classify_key(pmacs_key.key, pmacs_key.mods); + // Insert paint requires cursor at end-of-line (the + // single-Print sequence matches the daemon's + // eventual CellDelta exactly). + // Delete-back paint requires the stricter + // `cursor_at_end_of_line_safe_for_delete_back` + // predicate per post-audit Finding 5: also requires + // prev char != '\n'. Backspace that joins lines + // (prev char = newline) can't be represented by the + // single-column-erase paint sequence; falls + // through to v0.1 round-trip. + let active_buf = buffer_mirror.active_buffer(); + let insert_paint_eligible = active_buf + .and_then(|b| buffer_mirror.cursor_at_end_of_line(b)) + == Some(true); + let delete_back_paint_eligible = active_buf + .and_then(|b| buffer_mirror.cursor_at_end_of_line_safe_for_delete_back(b)) + == Some(true); + + let frontend_event = crate::optimistic::frontend_event_for_keystroke( + &mut buffer_mirror, + assigned_id, + pmacs_key, + ); + if let Err(e) = write_message(&mut writer, &frontend_event) { + eprintln!("pmacs: write keystroke failed: {e}"); + return Err(AttachError::from(e)); + } + // Post-audit-round-4 F22 — if we round-tripped via + // `FrontendEvent::Key`, the daemon's command + // pipeline may move the cursor in ways the mirror + // can't predict locally (motion, Enter/Tab, + // mid-line edits, delete-forward, etc.). Mark the + // active buffer's cursor stale so subsequent + // keystrokes round-trip too until the daemon's + // next `CursorByte` re-grounds the mirror cursor. + if matches!(frontend_event, FrontendEvent::Key(_)) { + if let Some(active_buf) = buffer_mirror.active_buffer() { + buffer_mirror.mark_cursor_stale(active_buf); + } + } + + // Visual optimistic paint (Path β). Fires only when + // the orchestrator landed a CrdtOp (mirror was + // ready, action was optimistic-eligible) AND the + // pre-edit cursor was at an action-specific safe + // position. Mid-line operations, line-joining + // backspace, and round-trip cases skip — the + // daemon's CellDelta drives paint for those. + // + // Daemon-side CellDelta suppression is NOT needed: + // under Path β, optimistic paint either matches + // the eventual CellDelta exactly (end-of-line) + // or doesn't exist (mid-line / line-join). Either + // way, no flicker. + if matches!(frontend_event, FrontendEvent::CrdtOp { .. }) { + let paint_result = match action { + crate::optimistic::OptimisticAction::Insert(c) + if insert_paint_eligible => + { + frontend.paint_optimistic_insert(c) + } + crate::optimistic::OptimisticAction::DeleteBack + if delete_back_paint_eligible => + { + frontend.paint_optimistic_delete_back() + } + _ => Ok(()), + }; + if let Err(e) = paint_result { + eprintln!("pmacs: optimistic paint failed: {e}"); + } + } + true + } else { + false + } + } else { + false + }; + #[cfg(not(feature = "crdt"))] + let optimistic_handled = false; + + if !optimistic_handled { + if let Err(e) = forward_event(&mut writer, &ev, assigned_id, frontend.size()) { + // Likely a broken pipe — instance went away. + eprintln!("pmacs: {e}"); + return Err(e); + } + // Post-audit-round-6 F30 — `forward_event` (success + // path) may write a Mouse / Paste / Resize / + // FocusGained / FocusLost event (or no-op for a Key + // Release). Mouse down/drag in particular can move + // the daemon's active window cursor, change the + // active buffer, or both. Anything except an + // optimistic CrdtOp can desync the mirror's cursor + // from the daemon's view; conservatively mark the + // active buffer's cursor stale so subsequent + // keystrokes round-trip until the daemon's next + // `CursorByte` re-grounds the mirror. + #[cfg(feature = "crdt")] + if let Some(active_buf) = buffer_mirror.active_buffer() { + buffer_mirror.mark_cursor_stale(active_buf); + } } } })(); @@ -1136,7 +1464,10 @@ fn run_one_session( )); } }; - if hello.protocol_version != PROTOCOL_VERSION { + // T M10.5: relaxed to range membership per + // `§sec:m10-backward-compat`. Symmetric with the local-socket + // attach path above. + if !crate::protocol::is_supported_protocol_version(hello.protocol_version) { return Err(handshake_error_with_child( child, stderr_handle, @@ -1166,8 +1497,11 @@ fn run_one_session( }, }; + // T M10.5: match the server's protocol version so v1.0 frontends + // attaching to v0.1 daemons advertise protocol_version=1. Same + // pattern as the local-socket path above. let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_capabilities(), initial_size, }; @@ -1939,4 +2273,89 @@ mod tests { "[pmacs disconnected — reconnecting in 30s — Ctrl-C to exit]" ); } + + // T M10.7 — AttachError message formatting. + // + // Criterion 5 of the spec: the version-mismatch message must + // tell the user which side is at the older version. These tests + // pin the substring assertions explicitly so a future regression + // (the message no longer naming the older side) fails visibly. + + #[test] + fn version_mismatch_daemon_older_message_names_daemon() { + let err = AttachError::VersionMismatch { + server: 1, + client: 2, + }; + let msg = err.to_string(); + assert!( + msg.contains("daemon is at the older version"), + "criterion 5: message must name daemon as older when server < client; got: {msg}" + ); + } + + #[test] + fn version_mismatch_binary_older_message_names_binary() { + let err = AttachError::VersionMismatch { + server: 2, + client: 1, + }; + let msg = err.to_string(); + assert!( + msg.contains("binary is at the older version"), + "criterion 5: message must name client binary as older when server > client; got: {msg}" + ); + } + + #[test] + fn version_mismatch_equal_no_older_clause() { + // Pathological case (the daemon shouldn't emit + // VersionMismatch when versions match) — but the formatter + // shouldn't claim an older side when there isn't one. + let err = AttachError::VersionMismatch { + server: 2, + client: 2, + }; + let msg = err.to_string(); + assert!( + !msg.contains("older version"), + "no older clause when equal; got: {msg}" + ); + } + + #[test] + fn capability_mismatch_message_names_multi_frontend() { + // T M10.7 criterion 4 — the error names the specific + // capability the frontend asked for that wasn't available. + let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch { + missing: vec!["multi_frontend".to_string()], + }); + let msg = err.to_string(); + assert!( + msg.contains("multi-frontend collaboration"), + "criterion 4: message must name the capability in user-readable form; got: {msg}" + ); + } + + #[test] + fn capability_mismatch_message_names_crdt_replica() { + let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch { + missing: vec!["crdt_replica".to_string()], + }); + let msg = err.to_string(); + assert!( + msg.contains("CRDT replica participation"), + "message must translate crdt_replica to user-readable form; got: {msg}" + ); + } + + #[test] + fn capability_mismatch_message_lists_multiple() { + let err = AttachError::Rejected(GoodbyeReason::CapabilityMismatch { + missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()], + }); + let msg = err.to_string(); + assert!(msg.contains("multi-frontend collaboration")); + assert!(msg.contains("CRDT replica participation")); + } } diff --git a/src/buffer.rs b/src/buffer.rs index d3aa439..f047681 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -39,7 +39,11 @@ use crate::view::{InterceptContext, View}; /// /// The internal representation is private (R22): callers cannot reach for /// `.0`; construction goes through [`BufferId::next`]. -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +/// +/// T M10.5: `Serialize` / `Deserialize` derived so `BufferId` can be the +/// routing key on `InstanceMessage::CrdtOp` / `FrontendEvent::CrdtOp`. +/// The serialized form is the bare `u64` (transparent newtype). +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)] pub struct BufferId(u64); impl BufferId { @@ -212,6 +216,20 @@ pub struct Buffer { /// than mutating the rope mid-intercept; cross-buffer re-entry /// is unaffected. editing_in_progress: bool, + /// Optional CRDT-backed state (T M10.2). When `Some`, every + /// successful edit (forward, undo, or redo) is also applied to + /// the CRDT, keeping the invariant `rope contents ≡ CRDT + /// projection` at all times. Set at construction via + /// [`Buffer::new_with_crdt`] / [`Buffer::from_bytes_with_crdt`] + /// or attached to an existing buffer via + /// [`Buffer::upgrade_to_crdt`]; never cleared (per the M10.2 + /// "Option set at construction, not toggled later" rule). + /// + /// Workers consume the rope projection via + /// [`Buffer::snapshot_rope`] and never see the CRDT directly, + /// per the rope-projection redirect (M10.1, §sec:m10-crdt-choice). + #[cfg(feature = "crdt")] + crdt: Option, } impl Buffer { @@ -250,9 +268,158 @@ impl Buffer { undo: Vec::new(), redo: Vec::new(), editing_in_progress: false, + #[cfg(feature = "crdt")] + crdt: None, } } + /// Construct an empty CRDT-backed buffer. + /// + /// `peer_id` identifies this frontend's edits in the CRDT op + /// stream; M10.4's per-frontend undo and M10.5's wire-protocol + /// op messages consume it. Threading: main thread only. + /// + /// The `Option` is set here and never toggled + /// afterward (M10.2 "set at construction, not later" rule). + /// To attach a CRDT to an existing rope-only buffer, use + /// [`Buffer::upgrade_to_crdt`]. + #[cfg(feature = "crdt")] + pub fn new_with_crdt( + id: BufferId, + name: impl Into, + peer_id: u64, + ) -> Result { + let mut buf = Self::new(id, name); + buf.crdt = Some(crate::crdt::CrdtState::new(peer_id)?); + Ok(buf) + } + + /// Construct a CRDT-backed buffer seeded with the given bytes. + /// + /// The bytes are loaded into the rope (byte-faithful) and into + /// the CRDT (UTF-8-normalized via `String::from_utf8_lossy`, + /// matching [`crate::crdt::CrdtState::from_bytes`]). For valid + /// UTF-8 input the two are identical; for ill-formed input the + /// CRDT loses ill-formed-byte detail to U+FFFD replacement + /// while the rope retains the original bytes — a documented + /// divergence the v0.1 `from_bytes` already accepted. + /// + /// Threading: main thread only. + #[cfg(feature = "crdt")] + pub fn from_bytes_with_crdt( + id: BufferId, + name: impl Into, + bytes: &[u8], + peer_id: u64, + ) -> Result { + let mut buf = Self::from_bytes(id, name, bytes); + buf.crdt = Some(crate::crdt::CrdtState::from_bytes(peer_id, bytes)?); + Ok(buf) + } + + /// Attach a CRDT to an existing rope-only buffer. + /// + /// Materializes a fresh `CrdtState` seeded from the buffer's + /// current rope contents. Existing intercepts, marks, views, + /// undo stack, revision, and `is_modified` flag are preserved. + /// The buffer's `BufferId` is unchanged so existing references + /// stay valid. + /// + /// **Undo-history loss**: Pre-upgrade entries in the v0.1 undo + /// stack (and redo stack) are cleared explicitly during the + /// upgrade. Post-upgrade undo routes through loro's `UndoManager`, + /// which has no knowledge of pre-upgrade edits. Users wishing to + /// preserve undo history should attach collaboration before + /// making edits, or accept that mid-session collaboration loses + /// prior undo state. A v0.2+ refinement preserving v0.1 history + /// alongside `UndoManager` is feasible but out of scope for v1.0 + /// (the synthesis from v0.1 entries → CRDT ops is structurally + /// problematic since the pre-upgrade ops have no `peer_id` to + /// attribute to `UndoManager`). + /// + /// Used by M10.8 (multi-frontend instance state) when a v0.1 + /// frontend's buffer is promoted to CRDT-backed at attach time + /// because a v1.0 frontend has joined the session. M10.2 ships + /// the API surface; the M10.8 caller wires invocation. + /// + /// Returns an error if the CRDT was already attached (the + /// "set once" rule); callers should check + /// [`Buffer::is_crdt_backed`] if uncertain. + /// + /// Threading: main thread only. + #[cfg(feature = "crdt")] + pub fn upgrade_to_crdt(&mut self, peer_id: u64) -> Result<(), BufferError> { + if self.crdt.is_some() { + // Already CRDT-backed. The "set once" rule rejects re- + // attachment; callers should not invoke this on an + // already-upgraded buffer. + return Err(BufferError::CrdtRejected { + reason: "buffer is already CRDT-backed".to_owned(), + }); + } + // Materialize CRDT state from the current rope. The rope's + // bytes are read in chunks to avoid one large allocation + // (matters for the 10MB+ case the M10.1 audit measured at + // 92ms cold-path materialization). + let rope_len = self.rope.len(); + let mut bytes = vec![0u8; rope_len as usize]; + if rope_len > 0 { + self.rope.slice(0, rope_len, &mut bytes); + } + self.crdt = Some(crate::crdt::CrdtState::from_bytes(peer_id, &bytes)?); + // M10.4 reframe: clear v0.1 undo/redo stacks on upgrade. The + // pre-upgrade entries can't be replayed through UndoManager + // (no peer_id attribution); leaving them in self.undo would + // make them unreachable through CRDT-mode undo (which + // bypasses self.undo). Clear explicitly + log so the data + // loss is visible. v0.2+ may revisit (preserve alongside + // UndoManager, route undo to v0.1 stack first then switch). + if !self.undo.is_empty() || !self.redo.is_empty() { + // The buffer-registry / Lua-binding layer wraps this in + // a user-facing notification; the log here is for + // developer audit. eprintln intentionally for visibility + // at upgrade time without taking a dep on pmacs's error + // surface from inside the rope/buffer layer. + eprintln!( + "Buffer {} ({:?}): upgrade_to_crdt clearing {} undo + {} redo entries; \ + v0.1 history is not preserved across CRDT mode upgrade. See M10.4 audit doc \ + for the v0.2+ refinement path.", + self.name, + self.id, + self.undo.len(), + self.redo.len() + ); + } + self.undo.clear(); + self.redo.clear(); + Ok(()) + } + + /// Whether this buffer is CRDT-backed. + /// + /// Threading: main thread only. + #[cfg(feature = "crdt")] + #[must_use] + pub fn is_crdt_backed(&self) -> bool { + self.crdt.is_some() + } + + /// Read-only access to the CRDT state. + /// + /// Used by the consistency property test + /// (`rope ≡ CRDT projection`) and by T M10.10's daemon-side + /// `BufferSnapshot` export: the dispatcher calls + /// `crdt_state().export_snapshot()` on each active buffer to + /// bootstrap a newly-attaching frontend's `BufferMirror`. + /// + /// Workers continue to consume the rope projection per M10.1's + /// redirect; CRDT access is main-thread-only and limited to the + /// snapshot-export + wire-protocol paths. + #[cfg(feature = "crdt")] + pub fn crdt_state(&self) -> Option<&crate::crdt::CrdtState> { + self.crdt.as_ref() + } + /// This buffer's identifier. /// /// Threading: main thread only (entire `Buffer` API is main-only). @@ -504,6 +671,227 @@ impl Buffer { result } + /// T M10.10 Finding 3 — apply a remotely-produced CRDT op to + /// this buffer. + /// + /// Used by the daemon's `FrontendEvent::CrdtOp` handler when a + /// replica frontend forwards a CRDT op. The flow: + /// + /// 1. Capture the rope's current bytes (rope ≡ CRDT projection + /// invariant — both have the same content pre-import). + /// 2. `crdt.import_updates(op_bytes)` — integrates the remote op + /// into the local CRDT state. CRDT convergence handles + /// concurrent edits. + /// 3. Materialize the post-import CRDT content. + /// 4. Compute the diff between pre- and post-content as a single + /// `Replace` `EditOp` (single insert/delete falls out as + /// Replace with empty inserted or empty range). + /// 5. Apply the rope stages (rope mutation + mark adjustment + + /// revision bump + modified flag + `on_edit` broadcast). + /// Skips the CRDT-application stage (already done in step 2) + /// AND the undo push (remote ops aren't locally undoable per + /// M10.4's per-peer undo design — loro's `UndoManager` tracks + /// history). + /// + /// # Why the diff-then-EditOp shape + /// + /// `on_edit` subscribers (`TextView`'s line cache, syntax + /// highlighter, overlay style maps, marks) require Edit + /// descriptions to maintain their incremental state. A naive + /// "replace rope wholesale" approach (set `self.rope = + /// new_materialized`) bypasses these subscribers — caches go + /// stale, marks lose translation. The single-Replace `EditOp` + /// preserves incremental updates throughout. + /// + /// # Limitations / v0.2+ work + /// + /// - Compound CRDT ops carrying multiple inserts/deletes + /// collapse to one Replace covering the whole changed region. + /// For M10.10's per-keystroke-op flow this is the actual shape + /// (one insert OR one delete per op); compound ops appearing + /// in v0.2+ would land here as a single coarse Replace, + /// acceptable but less efficient than per-sub-op processing. + /// - Marks within the replaced range are still subject to the + /// existing mark-adjustment-for-Replace logic; if mark + /// semantics need finer-grained handling for multi-op CRDT + /// updates, v0.2+ may migrate marks to loro cursor primitives. + /// + /// # Errors + /// + /// - Returns `BufferError::CrdtRejected` if the buffer isn't + /// CRDT-backed (caller should upgrade first per + /// `send_buffer_snapshots`/`ensure_active_buffer_crdt_backed`). + /// - Returns `BufferError::CrdtRejected` if `import_updates` + /// fails (e.g., malformed op bytes from a buggy peer). + /// + /// Returns `Ok(None)` if the imported op produced no actual + /// content change (rare; could happen for already-integrated + /// ops in a CRDT-redundant edge case). + #[cfg(feature = "crdt")] + pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result, BufferError> { + if self.editing_in_progress { + return Err(BufferError::ConcurrentEdit { + id: self.id, + name: self.name.clone(), + }); + } + let Some(crdt) = self.crdt.as_ref() else { + return Err(BufferError::CrdtRejected { + reason: "remote CrdtOp requires CRDT-backed buffer; daemon \ + should call upgrade_to_crdt first" + .to_owned(), + }); + }; + + // Step 1: capture pre-import bytes (rope ≡ CRDT projection + // invariant means rope.slice == crdt.materialize_string here). + let old_len = self.rope.len(); + let mut old_bytes = vec![0u8; old_len as usize]; + if old_len > 0 { + self.rope.slice(0, old_len, &mut old_bytes); + } + + // Step 2: integrate the remote op into the CRDT state. + crdt.import_updates(op_bytes) + .map_err(|e| BufferError::CrdtRejected { + reason: format!("import_updates: {e:?}"), + })?; + + // Step 3: materialize the post-import content. + let new_content = crdt.materialize_string(); + let new_bytes = new_content.as_bytes(); + + // Step 4: compute common prefix/suffix at byte level, then + // **back off to UTF-8 char boundaries** in both strings. + // + // # Post-audit-round-4 F25: char-boundary alignment + // + // Naively splitting on byte equality can land mid-codepoint + // for compound CRDT updates that change a single character. + // Example: 'é' (`0xC3 0xA9`) → 'è' (`0xC3 0xA8`). Byte + // prefix = 1; range_start = 1 puts the rope edit's start + // inside the first codepoint, so the resulting `inserted` + // slice (`[0xA8]`) is not valid UTF-8 and downstream + // consumers (TextView line indexing, char-aware cursor + // motion) get an invalid byte stream. + // + // Fix: after computing byte-level prefix and suffix, walk + // both bounds outward (decreasing prefix, decreasing suffix) + // until they sit on char boundaries in BOTH old_str and + // new_str. `str::is_char_boundary(n)` is the standard test. + // The result: `range_start..range_end` always covers + // complete codepoints in both old and new content; the + // `inserted` slice is always a valid UTF-8 substring. + let old_str = + std::str::from_utf8(&old_bytes).expect("rope content is UTF-8 by project invariant"); + let new_str = new_content.as_str(); + + let mut prefix = old_bytes + .iter() + .zip(new_bytes.iter()) + .take_while(|(a, b)| a == b) + .count(); + while prefix > 0 && !old_str.is_char_boundary(prefix) { + prefix -= 1; + } + // Cap suffix so prefix and suffix don't overlap on either side. + let max_suffix = (old_bytes.len() - prefix).min(new_bytes.len() - prefix); + let mut suffix = old_bytes + .iter() + .rev() + .zip(new_bytes.iter().rev()) + .take_while(|(a, b)| a == b) + .count() + .min(max_suffix); + while suffix > 0 + && (!old_str.is_char_boundary(old_bytes.len() - suffix) + || !new_str.is_char_boundary(new_bytes.len() - suffix)) + { + suffix -= 1; + } + + if prefix + suffix == old_bytes.len() && prefix + suffix == new_bytes.len() { + // Pre- and post-content are identical — import was a + // no-op (already-integrated op, or content-equivalent + // concurrent edit). Skip rope mutation; return None to + // signal "nothing changed." + return Ok(None); + } + + let range_start = prefix as Position; + let range_end = (old_bytes.len() - suffix) as Position; + let inserted = &new_bytes[prefix..new_bytes.len() - suffix]; + + // Step 5: apply rope stages without re-applying to CRDT + // (CRDT was applied above in step 2) and without undo push + // (remote ops aren't locally undoable per M10.4). + let mut views = std::mem::take(&mut self.views); + let result = self.run_remote_rope_stages(&mut views, range_start, range_end, inserted); + self.views = views; + result.map(Some) + } + + /// T M10.10 post-audit-round-4 F26 — verify that importing the + /// remote update `bytes` would attribute every new op to + /// `expected_peer_id`. Forked-import; doesn't mutate the buffer. + /// + /// Returns `Ok(())` on match (or non-CRDT buffer — caller's + /// other validations gate that case). Returns `Err(actual)` for + /// the first peer mismatch found. + /// + /// The daemon's `validate_remote_crdt_op` calls this after the + /// other identity / scope checks to ensure the loro-internal + /// peer attribution agrees with the wire wrapper's + /// `op.peer_id` (and therefore with the authenticated source). + #[cfg(feature = "crdt")] + pub fn validate_remote_op_peer_ids( + &self, + expected_peer_id: u64, + bytes: &[u8], + ) -> Result<(), u64> { + if let Some(crdt) = self.crdt.as_ref() { + crdt.validate_update_peer_ids(expected_peer_id, bytes) + } else { + // Non-CRDT buffer: the apply will fail downstream with a + // clearer error. Nothing to validate here. + Ok(()) + } + } + + /// T M10.10 — rope-stages-only path for remote CRDT ops. Mirrors + /// `run_rope_edit_and_broadcast`'s stages 2–4 but skips CRDT + /// application (already done) and undo push (remote ops aren't + /// locally undoable). Always called from + /// [`apply_remote_crdt_op`](Self::apply_remote_crdt_op). + #[cfg(feature = "crdt")] + fn run_remote_rope_stages( + &mut self, + views: &mut [(ViewId, Box)], + range_start: Position, + range_end: Position, + inserted: &[u8], + ) -> Result { + // Stage 2: rope edit (single Replace covering the diff). + let edit = self.rope.replace(range_start, range_end, inserted)?; + + // Stage 3: state update (mark adjustment + revision bump + + // modified flag; no undo push for remote ops). + let pre_range = edit.range; + let inserted_len = edit.inserted_len; + self.rope = edit.new_rope.clone(); + self.adjust_marks_for_edit(pre_range, inserted_len); + self.is_modified = true; + self.revision = self.revision.wrapping_add(1); + + // Stage 4: broadcast on_edit so views update incrementally + // (TextView line cache, syntax highlighter, overlays, etc.). + for (_, view) in views.iter_mut() { + view.on_edit(self, &edit)?; + } + + Ok(edit) + } + /// Apply an edit, skipping the intercept chain. /// /// Used by the Lua bindings (T M7.4) after they have run intercepts @@ -543,18 +931,168 @@ impl Buffer { self.run_rope_edit_and_broadcast(views, ¤t) } + /// T M10.2: apply an `EditOp` to the CRDT, return the lossy- + /// normalized byte payload (if any) so the rope can mirror it, + /// AND (Day 3) the wire-format CRDT op bytes for the originating + /// edit so the resulting `Edit::crdt_op` carries them. + /// + /// Per Q2 (defense-in-depth): the CRDT op runs first; on failure + /// the rope is untouched. The bytes are converted to UTF-8 via + /// `from_utf8_lossy` (matching `CrdtState::from_bytes`); for valid + /// UTF-8 inputs this is a no-op, for ill-formed bytes the CRDT + /// sees U+FFFD substitution and the rope must too (to preserve + /// the `rope ≡ CRDT projection` invariant). + /// + /// Day 3 addition: the version-capture / export idiom captures + /// pre-version BEFORE applying ops, then exports the delta AFTER. + /// This produces the wire-format bytes for THIS edit's ops (one + /// op for Insert/Delete, two ops for Replace). The bytes are + /// what M10.5 (wire protocol) sends across the network. + /// + /// Returns `(normalized_bytes, crdt_op)`: + /// + /// * `normalized_bytes`: `Some` if the rope must mirror lossy- + /// converted bytes (UTF-8 normalization happened); `None` if + /// the original bytes round-trip cleanly OR for delete-only ops. + /// * `crdt_op`: `Some` carrying `peer_id` + wire bytes if any + /// CRDT op was applied; `None` for true no-op edits (Q5 + /// detection path: pre-checked at the `EditOp` level so true + /// no-ops skip the CRDT path entirely; this function isn't + /// invoked for them). + #[cfg(feature = "crdt")] + fn apply_to_crdt_then_normalize_bytes( + crdt: &crate::crdt::CrdtState, + op: &EditOp<'_>, + ) -> Result { + // Capture the pre-edit oplog frontier so the post-edit export + // returns exactly the ops produced by THIS edit. Loro's + // transactional model gives a consistent before/after pair. + let pre_version = crdt.version(); + + let normalized: Option> = match op { + EditOp::Insert { pos, bytes } => { + if bytes.is_empty() { + // Pre-checked at the caller (no-op detection), + // but defensive-return-None-here in case a future + // caller forgets. + return Ok((None, None)); + } + let s = String::from_utf8_lossy(bytes); + crdt.insert(*pos as usize, &s)?; + if matches!(s, std::borrow::Cow::Borrowed(_)) { + None + } else { + Some(s.into_owned().into_bytes()) + } + } + EditOp::Delete { range } => { + if range.is_empty() { + return Ok((None, None)); + } + crdt.delete(range.start as usize, range.len() as usize)?; + None + } + EditOp::Replace { range, bytes } => { + // Two CRDT ops (no splice_utf8 in loro 1.12 per the + // morning audit). Order: delete, then insert. If + // delete succeeds and insert fails, the CRDT is + // mid-transaction (range deleted but replacement + // not inserted) and the rope is unchanged. This is + // an invariant violation; loro's insert is + // expected to succeed if the position is valid + // (which it is by construction). Treat insert + // failure here as a bug worth surfacing. + if !range.is_empty() { + crdt.delete(range.start as usize, range.len() as usize)?; + } + if bytes.is_empty() { + // Replace { non-empty range, empty bytes } is + // semantically a delete; the delete above already + // ran, no further op is needed. Fall through to + // exporting the delta below. + None + } else { + let s = String::from_utf8_lossy(bytes); + crdt.insert(range.start as usize, &s)?; + if matches!(s, std::borrow::Cow::Borrowed(_)) { + None + } else { + Some(s.into_owned().into_bytes()) + } + } + } + }; + + // Export the wire bytes for the delta produced by the ops + // above. This is the `crdt_op` field on the resulting Edit. + let bytes = crdt.export_updates_since(&pre_version)?; + let crdt_op = Box::new(crate::rope::CrdtOp { + peer_id: crdt.peer_id(), + bytes, + }); + Ok((normalized, Some(crdt_op))) + } + fn run_rope_edit_and_broadcast( &mut self, views: &mut [(ViewId, Box)], current: &EditOp<'_>, ) -> Result { - // Stage 2: rope edit. - let edit = match current { + // T M10.2: CRDT routing (Q2 defense-in-depth ordering — CRDT + // first, then rope; if CRDT errors, abort before rope mutation). + // The byte → str conversion uses `from_utf8_lossy` per the + // documented divergence: ill-formed bytes become U+FFFD in the + // CRDT and (under v0.1 byte-permissive rope) would diverge. To + // keep the invariant `rope ≡ CRDT projection`, the rope ALSO + // sees the lossy bytes when CRDT mode is active. v0.1 mode + // (CRDT off) is unchanged. + #[cfg(feature = "crdt")] + let (lossy_owned, captured_crdt_op): ( + Option>, + Option>, + ) = if self.crdt.is_some() && !is_no_op_edit(current) { + Self::apply_to_crdt_then_normalize_bytes(self.crdt.as_ref().expect("checked"), current)? + } else { + (None, None) + }; + + // Stage 2: rope edit. In CRDT mode, the EditOp's byte payload + // is replaced by the lossy-normalized version so the rope + // matches the CRDT projection (the invariant the proptest + // pins). + #[cfg(feature = "crdt")] + let normalized: Option> = lossy_owned.as_deref().map(|bytes| match current { + EditOp::Insert { pos, .. } => EditOp::Insert { pos: *pos, bytes }, + EditOp::Replace { range, .. } => EditOp::Replace { + range: *range, + bytes, + }, + EditOp::Delete { range } => EditOp::Delete { range: *range }, + }); + #[cfg(feature = "crdt")] + let current = normalized.as_ref().unwrap_or(current); + + #[cfg_attr(not(feature = "crdt"), allow(unused_mut))] + let mut edit = match current { EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?, EditOp::Delete { range } => self.rope.delete(range.start, range.end)?, EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?, }; + // T M10.2 Day 3: populate the Edit's crdt_op field with the + // wire-format bytes captured by the CRDT routing above. + // Mutation pattern: rope returns Edit with crdt_op = None; + // Buffer mutates the field before the Edit is returned to + // any consumer. The "moment of partial construction" is + // internal to apply_edit; consumers always see fully- + // constructed Edits. Don't refactor this away under + // "Edits should be immutable" reasoning — the alternative + // is double-allocation per edit. + #[cfg(feature = "crdt")] + { + edit.crdt_op = captured_crdt_op; + } + // No-op: empty insert, empty-range delete, or replace-empty-with- // empty all roundtrip with no actual change. Skip undo bookkeeping // and don't mark the buffer modified --- the spec's edit flow @@ -575,14 +1113,29 @@ impl Buffer { let inserted_len = edit.inserted_len; let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone()); self.adjust_marks_for_edit(pre_range, inserted_len); - self.undo.push(UndoEntry { - rope: old_rope, - edit: EditDescription { - pre_range, - inserted_len, - }, - }); - self.redo.clear(); + // T M10.4: in CRDT mode, loro's UndoManager tracks undo + // history; the v0.1 self.undo stack is bypassed (would grow + // unboundedly otherwise since nothing pops it in CRDT mode). + // The redo stack is similarly unused in CRDT mode. + #[cfg(feature = "crdt")] + let in_crdt_mode = self.crdt.is_some(); + #[cfg(not(feature = "crdt"))] + let in_crdt_mode = false; + if in_crdt_mode { + // CRDT mode: loro's UndoManager tracks history; drop the + // old rope (in v0.1 it's owned by the pushed UndoEntry, + // in CRDT mode it's released here). + drop(old_rope); + } else { + self.undo.push(UndoEntry { + rope: old_rope, + edit: EditDescription { + pre_range, + inserted_len, + }, + }); + self.redo.clear(); + } self.is_modified = true; self.revision = self.revision.wrapping_add(1); @@ -602,6 +1155,18 @@ impl Buffer { /// /// Threading: main thread only. pub fn undo(&mut self) -> Result { + // T M10.4: in CRDT mode, route through loro's UndoManager via + // the materialize-and-replace path (Day 1 morning audit + // decision — path (a)). Inverse ops are produced as proper + // CRDT ops by UndoManager, interacting with concurrent remote + // ops via CRDT convergence rules. + #[cfg(feature = "crdt")] + if self.crdt.is_some() { + return self.undo_crdt_mode(); + } + + // v0.1 mode: pop the saved UndoEntry, swap the rope back, + // push onto redo stack. let entry = self.undo.pop().ok_or(BufferError::NothingToUndo)?; // The pre-edit rope held in `entry.rope` becomes current. The @@ -631,6 +1196,59 @@ impl Buffer { new_rope, range: inverse_pre_range, inserted_len: inverse_inserted_len, + crdt_op: None, + }; + self.broadcast_on_edit(&inverse_edit)?; + Ok(inverse_edit) + } + + /// T M10.4: CRDT-mode undo via loro's `UndoManager`. + /// + /// Materialize-and-replace path (Day 1 morning audit decision). + /// Inverse ops are produced by `UndoManager` as proper CRDT ops + /// (not synthetic Replace as M10.2's path did); they interact + /// with concurrent remote ops via CRDT convergence rules. + /// + /// The Edit description is derived via [`derive_replacement_edit`]: + /// longest-common-prefix + longest-common-suffix trim against the + /// pre-undo rope. This produces a minimal `(range, inserted_len)` + /// covering exactly the bytes that changed, so marks adjust + /// correctly and tree-sitter's incremental parse stays + /// incremental. Cost: O(min(`old_len`, `new_len`)) byte compare via + /// rope chunks; sub-ms at typical edit sizes. + #[cfg(feature = "crdt")] + fn undo_crdt_mode(&mut self) -> Result { + // Extract everything we need from `self.crdt` before mutating + // `self.rope` / `self.marks` / `self.revision` etc., to + // avoid a borrow-checker conflict between the immutable + // crdt-ref and the upcoming `&mut self` method calls. + let (new_text, bytes, peer_id, can_undo_after) = { + let crdt = self.crdt.as_ref().expect("checked"); + let pre_version = crdt.version(); + let undid = crdt.undo()?; + if !undid { + return Err(BufferError::NothingToUndo); + } + let new_text = crdt.materialize_string(); + let bytes = crdt.export_updates_since(&pre_version)?; + (new_text, bytes, crdt.peer_id(), crdt.can_undo()) + }; + let new_rope = crate::rope::Rope::from_bytes(new_text.as_bytes()); + let (range, inserted_len) = derive_replacement_edit(&self.rope, &new_rope); + self.rope = new_rope.clone(); + self.adjust_marks_for_edit(range, inserted_len); + self.revision = self.revision.wrapping_add(1); + // is_modified stays true while there's still anything in the + // CRDT's undo stack (i.e. local edits not yet at the buffer's + // saved baseline). Matches v0.1 mode's `!self.undo.is_empty()` + // semantics translated to CrdtState's bookkeeping. + self.is_modified = can_undo_after; + + let inverse_edit = Edit { + new_rope, + range, + inserted_len, + crdt_op: Some(Box::new(crate::rope::CrdtOp { peer_id, bytes })), }; self.broadcast_on_edit(&inverse_edit)?; Ok(inverse_edit) @@ -644,6 +1262,13 @@ impl Buffer { /// /// Threading: main thread only. pub fn redo(&mut self) -> Result { + // T M10.4: in CRDT mode, route through loro's UndoManager. + #[cfg(feature = "crdt")] + if self.crdt.is_some() { + return self.redo_crdt_mode(); + } + + // v0.1 mode: pop the saved redo entry, swap the rope forward. let entry = self.redo.pop().ok_or(BufferError::NothingToRedo)?; let inverse_pre_range = Range::new( @@ -669,11 +1294,87 @@ impl Buffer { new_rope, range: inverse_pre_range, inserted_len: inverse_inserted_len, + crdt_op: None, }; self.broadcast_on_edit(&inverse_edit)?; Ok(inverse_edit) } + /// T M10.4: CRDT-mode redo via loro's `UndoManager`. + /// + /// Symmetric to [`Self::undo_crdt_mode`]; same materialize-and- + /// replace path. + #[cfg(feature = "crdt")] + fn redo_crdt_mode(&mut self) -> Result { + let (new_text, bytes, peer_id) = { + let crdt = self.crdt.as_ref().expect("checked"); + let pre_version = crdt.version(); + let redid = crdt.redo()?; + if !redid { + return Err(BufferError::NothingToRedo); + } + let new_text = crdt.materialize_string(); + let bytes = crdt.export_updates_since(&pre_version)?; + (new_text, bytes, crdt.peer_id()) + }; + let new_rope = crate::rope::Rope::from_bytes(new_text.as_bytes()); + let (range, inserted_len) = derive_replacement_edit(&self.rope, &new_rope); + self.rope = new_rope.clone(); + self.adjust_marks_for_edit(range, inserted_len); + self.revision = self.revision.wrapping_add(1); + self.is_modified = true; + + let inverse_edit = Edit { + new_rope, + range, + inserted_len, + crdt_op: Some(Box::new(crate::rope::CrdtOp { peer_id, bytes })), + }; + self.broadcast_on_edit(&inverse_edit)?; + Ok(inverse_edit) + } + + /// T M10.4: per-frontend undo for a specific attached frontend. + /// + /// In M10.4 (single-frontend buffers), the buffer holds one + /// `UndoManager`; this method ignores the `frontend_id` argument + /// and routes to the same `UndoManager` that [`Self::undo`] uses. + /// In M10.8 (multi-frontend buffers), the buffer will hold a + /// `HashMap` and this method routes + /// by the explicit `frontend_id`. The API surface is shipped + /// in M10.4 so M10.8's generalization doesn't introduce a + /// breaking signature change. + /// + /// v0.1 mode: same behavior as [`Self::undo`] (`frontend_id` + /// ignored; there's only one undo path). + /// + /// Threading: main thread only. + pub fn undo_for( + &mut self, + _frontend_id: crate::protocol::FrontendId, + ) -> Result { + // M10.4 single-frontend routing: degenerate case routes to + // the single UndoManager. M10.8 generalizes. + self.undo() + } + + /// T M10.4: symmetric to [`Self::undo_for`]. + pub fn redo_for( + &mut self, + _frontend_id: crate::protocol::FrontendId, + ) -> Result { + self.redo() + } + + // T M10.4: `sync_crdt_for_history_swap` removed. M10.2 Day 2's + // synthetic-Replace path produced inverse ops attributed to the + // editing peer that looked like fresh edits to the CRDT (not + // semantic undos). M10.4 replaces this with loro's UndoManager + // (see `undo_crdt_mode` / `redo_crdt_mode` above), which produces + // proper inverse ops that interact correctly with concurrent + // remote edits per the M10.4 acceptance: "B's edit lands on + // whatever surrounding text remains." + fn broadcast_on_edit(&mut self, edit: &Edit) -> Result<(), BufferError> { let mut views = std::mem::take(&mut self.views); let result = (|| { @@ -723,6 +1424,107 @@ impl Buffer { // Errors // --------------------------------------------------------------------------- +/// T M10.2 Day 3 type alias: the routing result tuple — lossy- +/// normalized bytes (if any) plus the captured CRDT op (if any). +/// Factored out to silence clippy's `type_complexity`. +#[cfg(feature = "crdt")] +type CrdtRoutingResult = (Option>, Option>); + +/// T M10.4: derive a fine-grained `(range, inserted_len)` Edit +/// description for the change from `old_rope` to `new_rope` via +/// longest-common-prefix + longest-common-suffix trim. +/// +/// CRDT-mode undo/redo materializes the post-undo rope via the CRDT +/// projection (path (a) from Day 1 morning's audit). A coarse Edit +/// description `(0..old_len, new_len)` would break mark positions +/// (every mark would be moved through a full-replace) and force +/// tree-sitter to re-parse the whole document. This helper computes +/// the minimal Edit description by trimming matching prefix + +/// suffix from both ropes. +/// +/// Correctness: assumes the change is a single contiguous edit +/// (which `UndoManager`.undo / .redo always produces — each undo +/// reverses one logical `apply_edit` op). For multi-edit changes +/// (concurrent remote ops applied during the undo, hypothetically) +/// the derived Edit description is still correct in the sense that +/// applying it to `old_rope` produces `new_rope` — the change just +/// covers a wider range. +/// +/// Cost: O(min(`old_len`, `new_len`)) byte comparison via rope +/// chunk iteration. At 1MB doc size with one-keystroke undo, the +/// prefix walk hits the divergence point within microseconds; same +/// for the suffix walk. +#[cfg(feature = "crdt")] +fn derive_replacement_edit(old_rope: &Rope, new_rope: &Rope) -> (Range, u64) { + let old_len = old_rope.len(); + let new_len = new_rope.len(); + if old_len == 0 && new_len == 0 { + return (Range::new(0, 0), 0); + } + // Longest common prefix. + let mut prefix = 0u64; + let max_prefix = old_len.min(new_len); + let chunk = 4096u64.min(max_prefix); + while prefix < max_prefix { + let n = chunk.min(max_prefix - prefix); + let mut a = vec![0u8; n as usize]; + let mut b = vec![0u8; n as usize]; + old_rope.slice(prefix, prefix + n, &mut a); + new_rope.slice(prefix, prefix + n, &mut b); + let mismatch = a.iter().zip(b.iter()).position(|(x, y)| x != y); + if let Some(off) = mismatch { + prefix += off as u64; + break; + } + prefix += n; + } + // Longest common suffix (bounded so we don't overlap with prefix). + let mut suffix = 0u64; + let max_suffix = (old_len - prefix).min(new_len - prefix); + while suffix < max_suffix { + let n = chunk.min(max_suffix - suffix); + let mut a = vec![0u8; n as usize]; + let mut b = vec![0u8; n as usize]; + old_rope.slice(old_len - suffix - n, old_len - suffix, &mut a); + new_rope.slice(new_len - suffix - n, new_len - suffix, &mut b); + let mismatch = a.iter().rev().zip(b.iter().rev()).position(|(x, y)| x != y); + if let Some(off) = mismatch { + suffix += off as u64; + break; + } + suffix += n; + } + let range = Range::new(prefix, old_len - suffix); + let inserted_len = new_len - prefix - suffix; + (range, inserted_len) +} + +/// T M10.2 Day 3: pre-check an `EditOp` for the no-op case so the +/// CRDT path can be skipped entirely (Q5 detection: pre-check the +/// `EditOp` variants explicitly; truly empty edits skip the CRDT +/// path; partially-empty Replace variants delegate to insert or +/// delete semantics and are NOT no-ops). +/// +/// Truly no-op cases: +/// * `Insert { bytes: empty }` — inserts nothing +/// * `Delete { range: empty }` — deletes nothing +/// * `Replace { range: empty, bytes: empty }` — neither deletes nor inserts +/// +/// `Replace` with non-empty range OR non-empty bytes is NOT a no-op: +/// it has actual semantic effect (delete-only or insert-only or +/// both) that the CRDT must observe to keep the rope ≡ projection +/// invariant. The rope path's own no-op short-circuit handles the +/// truly-empty cases AFTER the rope mutation runs; this helper lets +/// the CRDT path skip the round-trip BEFORE the rope runs. +#[cfg(feature = "crdt")] +fn is_no_op_edit(op: &EditOp<'_>) -> bool { + match op { + EditOp::Insert { bytes, .. } => bytes.is_empty(), + EditOp::Delete { range } => range.is_empty(), + EditOp::Replace { range, bytes } => range.is_empty() && bytes.is_empty(), + } +} + /// Errors produced by [`Buffer`] operations. #[derive(Debug, thiserror::Error)] pub enum BufferError { @@ -766,6 +1568,37 @@ pub enum BufferError { /// The buffer's name, for diagnostics. name: String, }, + /// The CRDT-backed buffer mode rejected an op (T M10.2). Reaches + /// the caller when loro returns an error during edit routing — the + /// most common case is a mid-codepoint position that loro's + /// `insert_utf8` / `delete_utf8` rejects (per the M10.2 Day 2 + /// morning audit). Per the Q2 defense-in-depth ordering, the + /// CRDT op is attempted before the rope mutation, so this error + /// leaves the rope unchanged. + #[cfg(feature = "crdt")] + #[error("CRDT edit rejected: {reason}")] + CrdtRejected { + /// Human-readable reason from loro. Surfaced verbatim. + reason: String, + }, +} + +#[cfg(feature = "crdt")] +impl From for BufferError { + fn from(e: loro::LoroError) -> Self { + BufferError::CrdtRejected { + reason: e.to_string(), + } + } +} + +#[cfg(feature = "crdt")] +impl From for BufferError { + fn from(e: loro::LoroEncodeError) -> Self { + BufferError::CrdtRejected { + reason: format!("CRDT encode failed: {e}"), + } + } } // --------------------------------------------------------------------------- @@ -778,8 +1611,65 @@ mod tests { use crate::view::View; use std::sync::Mutex; - fn fresh() -> Buffer { - Buffer::new(BufferId::next(), "*scratch*") + // T M10.2 Day 5: `fresh()` helper removed — every test that used + // it has been converted to `dual_mode_test!` and uses the + // injected `make`/`make_bytes` factories instead. v0.1-only tests + // construct via `Buffer::new` / `Buffer::from_bytes` directly; + // CRDT-only tests use `Buffer::new_with_crdt` / `from_bytes_with_crdt`. + + // ----------------------------------------------------------------- + // T M10.2 Day 5 — dual-mode test harness. + // + // `dual_mode_test!` generates two `#[test]` entries (`::v01` + // and `::crdt`) from one test body. Each entry runs the body + // with a different buffer factory: + // + // * `make(name)` → empty buffer + // * `make_bytes(name, bytes)` → buffer seeded with bytes + // + // The body picks whichever factory it needs; tests that only want + // the empty case ignore `make_bytes` (and vice versa) via `_`- + // prefixed names. The CRDT entry is `#[cfg(feature = "crdt")]`, + // so v0.1 builds get only the v0.1 test. + // + // Why per-mode `#[test]` entries vs one test running both modes: + // failure messages identify which mode failed without ambiguity. + // `::v01` and `::crdt` show up as distinct entries in + // the test runner's output. + // + // Day 5 converts the existing buffer tests that exercise the + // apply_edit / undo / redo / intercept paths. Day 6 classifies + // any divergences this surfaces. + // ----------------------------------------------------------------- + macro_rules! dual_mode_test { + ($name:ident, |$make:ident, $make_bytes:ident| $body:block) => { + mod $name { + use super::*; + + #[test] + fn v01() { + let $make = |n: &str| Buffer::new(BufferId::next(), n.to_owned()); + let $make_bytes = |n: &str, bytes: &[u8]| { + Buffer::from_bytes(BufferId::next(), n.to_owned(), bytes) + }; + $body + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt() { + let $make = |n: &str| { + Buffer::new_with_crdt(BufferId::next(), n.to_owned(), 1) + .expect("CRDT-mode buffer construction failed") + }; + let $make_bytes = |n: &str, bytes: &[u8]| { + Buffer::from_bytes_with_crdt(BufferId::next(), n.to_owned(), bytes, 1) + .expect("CRDT-mode from_bytes failed") + }; + $body + } + } + }; } fn collect(buf: &Buffer) -> Vec { @@ -852,41 +1742,37 @@ mod tests { assert_ne!(a, b); } - #[test] - fn new_is_empty_and_clean() { - let b = fresh(); + dual_mode_test!(new_is_empty_and_clean, |make, _make_bytes| { + let b = make("*scratch*"); assert!(b.is_empty()); assert!(!b.is_modified()); assert_eq!(b.name(), "*scratch*"); assert_eq!(b.view_count(), 0); - } + }); - #[test] - fn from_bytes_preserves_content() { - let b = Buffer::from_bytes(BufferId::next(), "hello.txt", b"hello world"); + dual_mode_test!(from_bytes_preserves_content, |_make, make_bytes| { + let b = make_bytes("hello.txt", b"hello world"); assert_eq!(b.len(), 11); assert_eq!(collect(&b), b"hello world"); assert!(!b.is_modified()); - } + }); // ----- view attach / detach ----- - #[test] - fn attach_and_detach_view() { - let mut b = fresh(); + dual_mode_test!(attach_and_detach_view, |make, _make_bytes| { + let mut b = make("*scratch*"); let id = b.attach_view(Box::new(RecorderView::default())); assert_eq!(b.view_count(), 1); let detached = b.detach_view(id).expect("present"); let _ = detached; assert_eq!(b.view_count(), 0); assert!(b.detach_view(id).is_none()); - } + }); // ----- edit flow ----- - #[test] - fn apply_insert_edit_updates_state() { - let mut b = fresh(); + dual_mode_test!(apply_insert_edit_updates_state, |make, _make_bytes| { + let mut b = make("*scratch*"); let edit = b .apply_edit(EditOp::Insert { pos: 0, @@ -898,11 +1784,10 @@ mod tests { assert_eq!(b.len(), 3); assert!(b.is_modified()); assert_eq!(collect(&b), b"abc"); - } + }); - #[test] - fn marks_apply_insertion_gravity() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd"); + dual_mode_test!(marks_apply_insertion_gravity, |_make, make_bytes| { + let mut b = make_bytes("test", b"abcd"); let left = b.create_mark(2, MarkGravity::Left).unwrap(); let right = b.create_mark(2, MarkGravity::Right).unwrap(); @@ -914,11 +1799,10 @@ mod tests { assert_eq!(b.mark_pos(left), Some(2)); assert_eq!(b.mark_pos(right), Some(4)); - } + }); - #[test] - fn marks_shift_and_clamp_through_delete() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcdef"); + dual_mode_test!(marks_shift_and_clamp_through_delete, |_make, make_bytes| { + let mut b = make_bytes("test", b"abcdef"); let before = b.create_mark(1, MarkGravity::Right).unwrap(); let inside = b.create_mark(3, MarkGravity::Left).unwrap(); let after = b.create_mark(5, MarkGravity::Right).unwrap(); @@ -931,11 +1815,10 @@ mod tests { assert_eq!(b.mark_pos(before), Some(1)); assert_eq!(b.mark_pos(inside), Some(2)); assert_eq!(b.mark_pos(after), Some(3)); - } + }); - #[test] - fn marks_follow_undo_and_redo() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abcd"); + dual_mode_test!(marks_follow_undo_and_redo, |_make, make_bytes| { + let mut b = make_bytes("test", b"abcd"); let mark = b.create_mark(3, MarkGravity::Right).unwrap(); b.apply_edit(EditOp::Insert { @@ -950,40 +1833,41 @@ mod tests { b.redo().unwrap(); assert_eq!(b.mark_pos(mark), Some(5)); - } + }); - #[test] - fn intercept_runs_before_on_edit_and_before_rope_mutation() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hi"); - let view = RecorderView::default(); - let events = view.events.clone(); - b.attach_view(Box::new(view)); + dual_mode_test!( + intercept_runs_before_on_edit_and_before_rope_mutation, + |_make, make_bytes| { + let mut b = make_bytes("test", b"hi"); + let view = RecorderView::default(); + let events = view.events.clone(); + b.attach_view(Box::new(view)); - let _ = b - .apply_edit(EditOp::Insert { - pos: 2, - bytes: b"!", - }) - .unwrap(); + let _ = b + .apply_edit(EditOp::Insert { + pos: 2, + bytes: b"!", + }) + .unwrap(); - let events = events.lock().unwrap(); - assert_eq!(events.len(), 2); - // intercept runs first, observing pre-edit rope (len 2). - assert_eq!(events[0], RecorderEvent::Intercept { pre_len: 2 }); - // on_edit runs after, observing post-edit rope (len 3) with the - // edit description. - assert_eq!( - events[1], - RecorderEvent::OnEdit { - post_len: 3, - inserted: 1 - } - ); - } + let events = events.lock().unwrap(); + assert_eq!(events.len(), 2); + // intercept runs first, observing pre-edit rope (len 2). + assert_eq!(events[0], RecorderEvent::Intercept { pre_len: 2 }); + // on_edit runs after, observing post-edit rope (len 3) with the + // edit description. + assert_eq!( + events[1], + RecorderEvent::OnEdit { + post_len: 3, + inserted: 1 + } + ); + } + ); - #[test] - fn intercept_can_reject_edit() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abc"); + dual_mode_test!(intercept_can_reject_edit, |_make, make_bytes| { + let mut b = make_bytes("test", b"abc"); b.attach_view(Box::new(ReverseInsertView)); // Delete is rejected by the view. @@ -996,11 +1880,10 @@ mod tests { assert!(!b.is_modified()); // Undo stack unchanged. assert!(matches!(b.undo(), Err(BufferError::NothingToUndo))); - } + }); - #[test] - fn delete_and_replace() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"hello world"); + dual_mode_test!(delete_and_replace, |_make, make_bytes| { + let mut b = make_bytes("test", b"hello world"); b.apply_edit(EditOp::Delete { range: Range::new(5, 11), }) @@ -1012,13 +1895,12 @@ mod tests { }) .unwrap(); assert_eq!(collect(&b), b"HELLO"); - } + }); // ----- undo / redo ----- - #[test] - fn undo_round_trips_to_original() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"original"); + dual_mode_test!(undo_round_trips_to_original, |_make, make_bytes| { + let mut b = make_bytes("test", b"original"); b.apply_edit(EditOp::Insert { pos: 0, bytes: b"X", @@ -1043,11 +1925,10 @@ mod tests { // Nothing left to undo. assert!(matches!(b.undo(), Err(BufferError::NothingToUndo))); - } + }); - #[test] - fn redo_replays_undone_edit() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abc"); + dual_mode_test!(redo_replays_undone_edit, |_make, make_bytes| { + let mut b = make_bytes("test", b"abc"); b.apply_edit(EditOp::Insert { pos: 3, bytes: b"def", @@ -1059,21 +1940,19 @@ mod tests { b.redo().unwrap(); assert_eq!(collect(&b), b"abcdef"); assert!(b.is_modified()); - } + }); - #[test] - fn empty_insert_is_a_noop() { + dual_mode_test!(empty_insert_is_a_noop, |make, _make_bytes| { // Inserting zero bytes must not mark the buffer modified or push // anything onto the undo stack. - let mut b = fresh(); + let mut b = make("*scratch*"); b.apply_edit(EditOp::Insert { pos: 0, bytes: b"" }).unwrap(); assert!(!b.is_modified()); assert!(matches!(b.undo(), Err(BufferError::NothingToUndo))); - } + }); - #[test] - fn empty_range_delete_is_a_noop() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abc"); + dual_mode_test!(empty_range_delete_is_a_noop, |_make, make_bytes| { + let mut b = make_bytes("test", b"abc"); b.apply_edit(EditOp::Delete { range: Range::new(1, 1), }) @@ -1081,11 +1960,10 @@ mod tests { assert!(!b.is_modified()); assert_eq!(collect(&b), b"abc"); assert!(matches!(b.undo(), Err(BufferError::NothingToUndo))); - } + }); - #[test] - fn replace_empty_with_empty_is_a_noop() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abc"); + dual_mode_test!(replace_empty_with_empty_is_a_noop, |_make, make_bytes| { + let mut b = make_bytes("test", b"abc"); b.apply_edit(EditOp::Replace { range: Range::new(2, 2), bytes: b"", @@ -1094,11 +1972,10 @@ mod tests { assert!(!b.is_modified()); assert_eq!(collect(&b), b"abc"); assert!(matches!(b.undo(), Err(BufferError::NothingToUndo))); - } + }); - #[test] - fn forward_edit_clears_redo() { - let mut b = Buffer::from_bytes(BufferId::next(), "test", b"abc"); + dual_mode_test!(forward_edit_clears_redo, |_make, make_bytes| { + let mut b = make_bytes("test", b"abc"); b.apply_edit(EditOp::Insert { pos: 3, bytes: b"d", @@ -1115,77 +1992,959 @@ mod tests { }) .unwrap(); assert!(matches!(b.redo(), Err(BufferError::NothingToRedo))); + }); + + dual_mode_test!( + random_edit_then_full_undo_recovers_original, + |_make, make_bytes| { + // Reuses the rope's fuzz pattern but at the buffer level: any + // arbitrary sequence must undo to the starting bytes exactly. + // + // Seed bytes are ASCII-only (lower 7 bits) so the CRDT-mode + // run doesn't trip on lossy UTF-8 normalization. The + // v0.1-mode seed in the rope-level fuzz test uses 0..251 + // which includes ill-formed UTF-8; that's a rope-only + // property and stays in `src/rope.rs`'s tests. + let mut b = make_bytes( + "test", + &(0..512u32).map(|i| (i % 128) as u8).collect::>(), + ); + let original = collect(&b); + + let mut rng_state: u64 = 0x1234_5678; + let mut rng = || { + rng_state = rng_state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + (rng_state >> 33) as u32 + }; + + let mut edit_count = 0usize; + for _ in 0..200 { + let len = b.len(); + match rng() % 3 { + 0 => { + let pos = u64::from(rng()) % (len + 1); + let n = (rng() % 32 + 1) as usize; + // ASCII-only bytes so CRDT mode's lossy-utf8 + // normalization is a no-op (rope ≡ projection + // for the v0.1-byte-permissive AND CRDT-utf8- + // normalized paths). + let bytes: Vec = (0..n) + .map(|i| ((rng() & 0x7F) as u8).wrapping_add(i as u8) & 0x7F) + .collect(); + b.apply_edit(EditOp::Insert { pos, bytes: &bytes }).unwrap(); + edit_count += 1; + } + 1 if len > 0 => { + let s = u64::from(rng()) % len; + let e = s + u64::from(rng()) % (len - s + 1).max(1); + let e = e.min(len); + if s < e { + b.apply_edit(EditOp::Delete { + range: Range::new(s, e), + }) + .unwrap(); + edit_count += 1; + } + } + _ if len > 0 => { + let s = u64::from(rng()) % len; + let e = s + u64::from(rng()) % (len - s + 1).max(1); + let e = e.min(len); + if s < e { + let n = (rng() % 16) as usize; + let bytes: Vec = (0..n) + .map(|i| ((rng() & 0x7F) as u8).wrapping_add(i as u8) & 0x7F) + .collect(); + b.apply_edit(EditOp::Replace { + range: Range::new(s, e), + bytes: &bytes, + }) + .unwrap(); + edit_count += 1; + } + } + _ => {} + } + } + + for _ in 0..edit_count { + b.undo().unwrap(); + } + assert_eq!(collect(&b), original); + assert!(!b.is_modified()); + } + ); + + // ----------------------------------------------------------------- + // T M10.2 CRDT-backed buffer tests. + // + // Targeted tests for Day 2's routing work: rope ≡ CRDT projection + // invariant must hold after apply_edit / undo / redo / arbitrary + // sequences. Day 5+'s parameterized harness re-runs all of the + // existing buffer tests against CRDT mode; Day 2's tests are + // direct. + // ----------------------------------------------------------------- + + // ----------------------------------------------------------------- + // T M10.2 Day 6 — coverage-audit gap closures. + // + // Day 5's 17 dual-mode tests cover canonical edit semantics but + // miss two non-canonical paths that the Day 6 coverage audit + // surfaced as substantive M10.2 scope: + // + // * `apply_edit_skip_intercepts` — the Lua-bindings path that + // bypasses the intercept chain. Routes through the same + // `run_rope_edit_and_broadcast` as `apply_edit`, which is + // where CRDT routing happens; in principle CRDT mode applies + // equally, but worth an explicit test to lock it in. + // * `begin_edit` / `end_edit` — the re-entrancy gate that + // surfaces `BufferError::ConcurrentEdit` on nested edits. + // Independent of CRDT mode but the gate's interaction with + // CRDT routing wasn't tested. + // + // Other gaps the audit surfaced but classified as deferred: + // + // * Boundary edits (insert at 0, insert at end, delete entire + // buffer, replace covering whole) — implicitly covered by + // the random_edit fuzz and explicit tests. Adding dedicated + // boundary tests is cheap polish, not M10.2 scope. + // * Large edits (1MB+) — exercises the export-overhead cost + // model; Day 7's perf check covers these. + // * `set_name`, `mark_clean`, `editing_in_progress` getter — + // non-edit-path methods; behavior is identical across modes + // by construction. Not worth a dual-mode test each. + // ----------------------------------------------------------------- + + dual_mode_test!( + apply_edit_skip_intercepts_routes_through_crdt, + |_make, make_bytes| { + // skip_intercepts bypasses the intercept chain but routes + // through the same rope-edit-and-broadcast path. In CRDT + // mode the CRDT routing still applies; the rope projection + // and CRDT state must stay in sync as they do for `apply_edit`. + let mut b = make_bytes("test", b"hello"); + // Attach a view whose intercept would reject any edit; the + // skip_intercepts call must succeed because the intercept is + // bypassed, demonstrating the path works. + b.attach_view(Box::new(ReverseInsertView)); + let edit = b + .apply_edit_skip_intercepts(EditOp::Delete { + range: Range::new(0, 1), + }) + .expect("skip_intercepts bypasses the rejecting intercept"); + assert_eq!(edit.range, Range::new(0, 1)); + assert_eq!(edit.inserted_len, 0); + assert_eq!(collect(&b), b"ello"); + assert!(b.is_modified()); + } + ); + + dual_mode_test!( + begin_edit_blocks_reentrant_apply_edit, + |make, _make_bytes| { + // begin_edit sets the editing_in_progress flag; a subsequent + // apply_edit returns ConcurrentEdit until end_edit. The flag + // is independent of CRDT mode but the gate's interaction + // with CRDT routing is worth pinning. + let mut b = make("test"); + b.begin_edit().expect("first begin"); + assert!(b.editing_in_progress()); + + let err = b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"X", + }); + assert!( + matches!(err, Err(BufferError::ConcurrentEdit { .. })), + "expected ConcurrentEdit, got {err:?}" + ); + + // end_edit clears the flag; subsequent apply_edit succeeds. + b.end_edit(); + assert!(!b.editing_in_progress()); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"X", + }) + .expect("after end_edit"); + assert_eq!(collect(&b), b"X"); + } + ); + + dual_mode_test!( + begin_edit_is_reentrant_safe_via_returned_error, + |make, _make_bytes| { + // Two consecutive begin_edit calls without an intervening + // end_edit must return ConcurrentEdit on the second, not + // double-set the flag. Pins the gate's idempotency. + let mut b = make("*scratch*"); + b.begin_edit().expect("first begin"); + let r = b.begin_edit(); + assert!(matches!(r, Err(BufferError::ConcurrentEdit { .. }))); + // First begin's flag is still set; end_edit clears it. + assert!(b.editing_in_progress()); + b.end_edit(); + assert!(!b.editing_in_progress()); + } + ); + + #[cfg(feature = "crdt")] + fn rope_string(b: &Buffer) -> String { + let mut bytes = vec![0u8; b.len() as usize]; + if !bytes.is_empty() { + b.snapshot_rope().slice(0, b.len(), &mut bytes); + } + String::from_utf8(bytes).expect("rope contents must be UTF-8 in CRDT mode") } + #[cfg(feature = "crdt")] + fn assert_invariant(b: &Buffer) { + let rope = rope_string(b); + let crdt = b + .crdt_state() + .expect("CRDT-backed in this test") + .materialize_string(); + assert_eq!(rope, crdt, "rope ≡ CRDT projection invariant violated"); + } + + #[cfg(feature = "crdt")] #[test] - fn random_edit_then_full_undo_recovers_original() { - // Reuses the rope's fuzz pattern but at the buffer level: any - // arbitrary sequence must undo to the starting bytes exactly. - let mut b = Buffer::from_bytes( - BufferId::next(), - "test", - &(0..512u32).map(|i| (i % 251) as u8).collect::>(), - ); - let original = collect(&b); + fn crdt_apply_edit_keeps_invariant_basic() { + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction"); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + assert_eq!(rope_string(&b), "hello"); + assert_invariant(&b); - let mut rng_state: u64 = 0x1234_5678; - let mut rng = || { - rng_state = rng_state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1); - (rng_state >> 33) as u32 - }; + b.apply_edit(EditOp::Insert { + pos: 5, + bytes: b" world", + }) + .unwrap(); + assert_eq!(rope_string(&b), "hello world"); + assert_invariant(&b); - let mut edit_count = 0usize; - for _ in 0..200 { - let len = b.len(); - match rng() % 3 { - 0 => { - let pos = u64::from(rng()) % (len + 1); - let n = (rng() % 32 + 1) as usize; - let bytes: Vec = (0..n) - .map(|i| (rng() as u8).wrapping_add(i as u8)) - .collect(); - b.apply_edit(EditOp::Insert { pos, bytes: &bytes }).unwrap(); - edit_count += 1; + b.apply_edit(EditOp::Delete { + range: Range::new(5, 6), + }) + .unwrap(); + assert_eq!(rope_string(&b), "helloworld"); + assert_invariant(&b); + + b.apply_edit(EditOp::Replace { + range: Range::new(0, 5), + bytes: b"howdy", + }) + .unwrap(); + assert_eq!(rope_string(&b), "howdyworld"); + assert_invariant(&b); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_undo_keeps_invariant() { + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction"); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + b.apply_edit(EditOp::Insert { + pos: 5, + bytes: b" world", + }) + .unwrap(); + assert_eq!(rope_string(&b), "hello world"); + assert_invariant(&b); + + b.undo().expect("undo"); + assert_eq!(rope_string(&b), "hello"); + assert_invariant(&b); + + b.undo().expect("undo"); + assert_eq!(rope_string(&b), ""); + assert_invariant(&b); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_undo_redo_keeps_invariant() { + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction"); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"abcdef", + }) + .unwrap(); + b.apply_edit(EditOp::Replace { + range: Range::new(2, 4), + bytes: b"XY", + }) + .unwrap(); + assert_eq!(rope_string(&b), "abXYef"); + assert_invariant(&b); + + b.undo().expect("undo replace"); + assert_eq!(rope_string(&b), "abcdef"); + assert_invariant(&b); + + b.redo().expect("redo replace"); + assert_eq!(rope_string(&b), "abXYef"); + assert_invariant(&b); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_from_bytes_seeds_both_rope_and_crdt() { + let b = + Buffer::from_bytes_with_crdt(BufferId::next(), "*seeded*", b"the quick brown fox", 7) + .expect("crdt seeded"); + assert_eq!(rope_string(&b), "the quick brown fox"); + assert_invariant(&b); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_upgrade_to_crdt_clears_v01_history() { + // T M10.4 reframe: upgrade_to_crdt clears v0.1 undo/redo + // stacks explicitly. The pre-upgrade entries can't be + // replayed through UndoManager (no peer_id attribution); the + // M10.4 audit doc records this as documented behavior with + // a v0.2+ refinement path. This test pins the cleared-stack + // behavior so future contributors don't reintroduce silent- + // persist-but-unreachable semantics. + let mut b = Buffer::from_bytes(BufferId::next(), "*upgrade*", b"initial"); + b.apply_edit(EditOp::Insert { + pos: 7, + bytes: b" content", + }) + .unwrap(); + let pre_upgrade_rev = b.revision(); + assert!(!b.is_crdt_backed()); + assert_eq!(b.undo.len(), 1); + + b.upgrade_to_crdt(42).expect("upgrade"); + assert!(b.is_crdt_backed()); + // Content preserved. + assert_eq!(rope_string(&b), "initial content"); + assert_invariant(&b); + // Revision counter preserved (the rope contents themselves + // didn't change at upgrade time; revision tracks rope-version + // not history). + assert_eq!(b.revision(), pre_upgrade_rev); + // v0.1 undo/redo stacks cleared per M10.4 reframe. + assert!(b.undo.is_empty()); + assert!(b.redo.is_empty()); + // CRDT-mode undo has nothing yet (the seeded content from + // upgrade isn't an undoable edit — see CrdtState::from_bytes). + let undo_result = b.undo(); + assert!(matches!(undo_result, Err(BufferError::NothingToUndo))); + + // Subsequent edits on the upgraded buffer keep the invariant + // and ARE undoable via CRDT-mode undo. + b.apply_edit(EditOp::Delete { + range: Range::new(0, 8), + }) + .unwrap(); + assert_eq!(rope_string(&b), "content"); + assert_invariant(&b); + b.undo().expect("undo the delete"); + assert_eq!(rope_string(&b), "initial content"); + assert_invariant(&b); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_upgrade_rejects_double_attach() { + let mut b = Buffer::new_with_crdt(BufferId::next(), "*twice*", 1).expect("first attach"); + let r = b.upgrade_to_crdt(2); + assert!(matches!(r, Err(BufferError::CrdtRejected { .. }))); + } + + // --------------------------------------------------------------- + // Consistency property test: rope ≡ CRDT projection holds under + // arbitrary apply_edit + undo + redo sequences. + // + // Generators per the framing-pass methodology: + // - Insert / Delete / Replace at random aligned positions + // - Undo / Redo at ~5–10% probability each + // - Byte content from a small UTF-8 alphabet (avoids + // ill-formed-bytes drift; the wrapper handles those via + // from_utf8_lossy but the proptest is testing routing + // correctness, not normalization correctness) + // - Sequence length 50; 64 cases (proptest's default) + // --------------------------------------------------------------- + #[cfg(feature = "crdt")] + mod proptests { + use super::*; + use proptest::prelude::*; + + // Codepoint-aligned generators. The mid-codepoint case is its + // own test (`crdt_mid_codepoint_position_is_rejected_cleanly`); + // the property test exercises the well-formed path so failures + // surface routing bugs, not codepoint-alignment surprises. + const ALPHABET: &[&str] = &["a", "b", "c", " ", "\n"]; + + #[derive(Clone, Debug)] + enum GenOp { + Insert(usize, String), + Delete(usize, usize), + Replace(usize, usize, String), + Undo, + Redo, + } + + fn gen_payload() -> impl Strategy { + prop::collection::vec(prop::sample::select(ALPHABET.to_vec()), 0..6) + .prop_map(|parts| parts.concat()) + } + + fn gen_op() -> impl Strategy { + // Weighted: forward edits dominate, undo/redo at ~5% each. + prop_oneof![ + 30 => (any::(), gen_payload()).prop_map(|(p, s)| GenOp::Insert(p as usize, s)), + 20 => (any::(), any::()).prop_map(|(p, l)| GenOp::Delete(p as usize, l as usize)), + 10 => (any::(), any::(), gen_payload()) + .prop_map(|(p, l, s)| GenOp::Replace(p as usize, l as usize, s)), + 3 => Just(GenOp::Undo), + 3 => Just(GenOp::Redo), + ] + } + + // T M10.2 Day 3 helper: applies a `GenOp` and returns the + // resulting Edit so the proptest can assert per-op shape. + // Each op is best-effort: out-of-range positions are clamped + // before dispatch so the proptest doesn't fail on benign rope + // errors. Routing bugs (CRDT/rope drift) are what the post- + // condition catches. Returns None on no-op short-circuits and + // on history-stack-empty errors (NothingToUndo / NothingToRedo). + fn apply_capturing(b: &mut Buffer, op: GenOp) -> Option { + let len = b.len() as usize; + match op { + GenOp::Insert(pos, s) => { + let pos = pos.min(len); + b.apply_edit(EditOp::Insert { + pos: pos as u64, + bytes: s.as_bytes(), + }) + .ok() } - 1 if len > 0 => { - let s = u64::from(rng()) % len; - let e = s + u64::from(rng()) % (len - s + 1).max(1); - let e = e.min(len); - if s < e { - b.apply_edit(EditOp::Delete { - range: Range::new(s, e), - }) - .unwrap(); - edit_count += 1; - } + GenOp::Delete(pos, l) => { + let pos = pos.min(len); + let l = l.min(len.saturating_sub(pos)); + b.apply_edit(EditOp::Delete { + range: Range::new(pos as u64, (pos + l) as u64), + }) + .ok() } - _ if len > 0 => { - let s = u64::from(rng()) % len; - let e = s + u64::from(rng()) % (len - s + 1).max(1); - let e = e.min(len); - if s < e { - let n = (rng() % 16) as usize; - let bytes: Vec = (0..n) - .map(|i| (rng() as u8).wrapping_add(i as u8)) - .collect(); - b.apply_edit(EditOp::Replace { - range: Range::new(s, e), - bytes: &bytes, - }) - .unwrap(); - edit_count += 1; - } + GenOp::Replace(pos, l, s) => { + let pos = pos.min(len); + let l = l.min(len.saturating_sub(pos)); + b.apply_edit(EditOp::Replace { + range: Range::new(pos as u64, (pos + l) as u64), + bytes: s.as_bytes(), + }) + .ok() } - _ => {} + GenOp::Undo => b.undo().ok(), + GenOp::Redo => b.redo().ok(), } } - for _ in 0..edit_count { - b.undo().unwrap(); + proptest! { + // Smaller proptest case count than the default (64) to keep + // CI overhead modest; the per-op invariant check is the + // load-bearing part, not the diversity of sequences. + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn rope_matches_crdt_projection_after_arbitrary_edits( + ops in prop::collection::vec(gen_op(), 1..50), + ) { + let mut b = Buffer::new_with_crdt(BufferId::next(), "*proptest*", 1) + .expect("crdt construction"); + for op in ops { + let op_repr = format!("{op:?}"); + let edit = apply_capturing(&mut b, op); + // Per-op invariant check: catches drift the moment + // it happens, with the failing op visible in the + // shrinker output. + let rope = rope_string(&b); + let crdt = b.crdt_state().unwrap().materialize_string(); + prop_assert_eq!( + &rope, &crdt, + "invariant violated after op {}: rope={:?} crdt={:?}", + op_repr, rope, crdt + ); + // Day 3: crdt_op shape invariant. + // - real edits in CRDT mode populate crdt_op + // - no-op short-circuits leave crdt_op = None + // - history-stack-empty errors return None Edit + if let Some(edit) = edit { + let is_no_op_edit_result = + edit.range.is_empty() && edit.inserted_len == 0; + if is_no_op_edit_result { + prop_assert!( + edit.crdt_op.is_none(), + "no-op edit must have crdt_op = None ({})", + op_repr + ); + } else { + prop_assert!( + edit.crdt_op.is_some(), + "non-no-op CRDT-mode edit must have crdt_op = Some ({})", + op_repr + ); + let crdt_op = edit.crdt_op.as_ref().unwrap(); + prop_assert_eq!( + crdt_op.peer_id, 1, + "peer_id must thread from CrdtState ({})", + op_repr + ); + prop_assert!( + !crdt_op.bytes.is_empty(), + "wire bytes must be non-empty ({})", + op_repr + ); + } + } + } + } + + // T M10.3: round-trip property. Arbitrary EditOp sequences + // on Buffer A (peer_id 1) produce per-edit crdt_op bytes. + // Replaying those bytes on a fresh CrdtState B (peer_id 2) + // must produce a projection identical to A's. This is the + // stronger property than Day 3's single-instance test: + // proves that the wire-format bytes are independently + // re-applicable on a remote CRDT instance, exercising the + // path M10.5 will use for InstanceMessage::CrdtOp delivery. + // + // Excludes undo/redo from the gen — those produce synthetic + // Replace ops that, when re-applied on B from base zero, + // create a state inconsistent with A's history-swap + // semantics. M10.5's actual wire protocol delivers undo + // ops only when the originating peer's prior history is + // already known to the receiver; the proptest scope is + // forward edits only (insert / delete / replace). + #[test] + fn crdt_op_bytes_round_trip_via_remote_crdt_state( + ops in prop::collection::vec(gen_op_forward_only(), 1..30), + ) { + let mut a = Buffer::new_with_crdt(BufferId::next(), "A", 1) + .expect("A construction"); + let receiver = crate::crdt::CrdtState::new(2) + .expect("receiver construction"); + + for op in ops { + let op_repr = format!("{op:?}"); + let edit = apply_capturing(&mut a, op); + if let Some(edit) = edit { + if let Some(crdt_op) = edit.crdt_op.as_ref() { + // Apply the wire-format bytes to the + // receiver. Receiver projection must match + // A's projection after this. + receiver + .import_updates(&crdt_op.bytes) + .expect("receiver import"); + let a_proj = a + .crdt_state() + .unwrap() + .materialize_string(); + let b_proj = receiver.materialize_string(); + prop_assert_eq!( + &a_proj, &b_proj, + "A and remote receiver diverged after op {}: \ + A={:?} B={:?}", + op_repr, a_proj, b_proj + ); + } + } + } + } } - assert_eq!(collect(&b), original); - assert!(!b.is_modified()); + + /// T M10.3 generator: forward-only ops (no Undo/Redo). The + /// round-trip proptest excludes history-nav because the + /// synthetic-Replace ops undo/redo produce don't round-trip + /// cleanly when replayed on a peer without the originating + /// history. M10.5's wire protocol handles this; M10.3's + /// scope is forward edits. + fn gen_op_forward_only() -> impl Strategy { + prop_oneof![ + 30 => (any::(), gen_payload()).prop_map(|(p, s)| GenOp::Insert(p as usize, s)), + 20 => (any::(), any::()).prop_map(|(p, l)| GenOp::Delete(p as usize, l as usize)), + 10 => (any::(), any::(), gen_payload()) + .prop_map(|(p, l, s)| GenOp::Replace(p as usize, l as usize, s)), + ] + } + } + + // --------------------------------------------------------------- + // T M10.2 Day 3 — crdt_op population on Edit. + // --------------------------------------------------------------- + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_is_none_in_v01_mode() { + let mut b = Buffer::new(BufferId::next(), "*v01*"); + let edit = b + .apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + assert!( + edit.crdt_op.is_none(), + "v0.1 mode (no CRDT) must produce Edit with crdt_op = None" + ); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_is_some_in_crdt_mode_for_real_edits() { + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*crdt*", 42).expect("crdt construction"); + let edit = b + .apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + let op = edit + .crdt_op + .as_ref() + .expect("CRDT mode must populate crdt_op"); + assert_eq!(op.peer_id, 42, "peer_id must thread through"); + assert!(!op.bytes.is_empty(), "wire bytes must be non-empty"); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_is_none_for_no_op_edits_even_in_crdt_mode() { + // Q5: truly empty edits skip the CRDT path entirely. The + // returned Edit's crdt_op must be None — the rope's no-op + // short-circuit doesn't reach the CRDT routing, so no op + // bytes are captured. + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*crdt*", 1).expect("crdt construction"); + let edit = b.apply_edit(EditOp::Insert { pos: 0, bytes: b"" }).unwrap(); + assert!(edit.crdt_op.is_none(), "true no-op insert must yield None"); + let edit = b + .apply_edit(EditOp::Delete { + range: Range::new(0, 0), + }) + .unwrap(); + assert!(edit.crdt_op.is_none(), "true no-op delete must yield None"); + let edit = b + .apply_edit(EditOp::Replace { + range: Range::new(0, 0), + bytes: b"", + }) + .unwrap(); + assert!(edit.crdt_op.is_none(), "true no-op replace must yield None"); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_carries_distinct_bytes_per_edit() { + // The framing pass's verification target: apply two edits in + // succession, verify each Edit's crdt_op contains exactly its + // own delta (not the cumulative). Loro's transactional model + // gives a consistent before/after pair via version capture. + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*twin*", 7).expect("crdt construction"); + let e1 = b + .apply_edit(EditOp::Insert { + pos: 0, + bytes: b"first", + }) + .unwrap(); + let e2 = b + .apply_edit(EditOp::Insert { + pos: 5, + bytes: b"-second", + }) + .unwrap(); + let bytes1 = &e1.crdt_op.as_ref().unwrap().bytes; + let bytes2 = &e2.crdt_op.as_ref().unwrap().bytes; + assert_ne!( + bytes1, bytes2, + "each edit's crdt_op must carry its own delta, not cumulative state" + ); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_populated_for_undo_and_redo() { + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*history*", 3).expect("crdt construction"); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + let undo_edit = b.undo().expect("undo"); + assert!( + undo_edit.crdt_op.is_some(), + "undo's inverse Edit must carry the synthetic-Replace bytes" + ); + assert_eq!(undo_edit.crdt_op.as_ref().unwrap().peer_id, 3); + let redo_edit = b.redo().expect("redo"); + assert!( + redo_edit.crdt_op.is_some(), + "redo's inverse Edit must carry the synthetic-Replace bytes" + ); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_op_imports_into_a_fresh_doc_to_reproduce_state() { + // Day 3 acceptance verification (framing-pass risk #2 follow- + // up): the wire-format bytes a single edit produces, when + // imported into a fresh CRDT doc, should reproduce the + // post-edit content. This is what M10.5's wire protocol will + // rely on — receiving frontends import the bytes to apply + // ops on their local CRDT. + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*wire*", 1).expect("crdt construction"); + // First edit: empty -> "hello". + let e1 = b + .apply_edit(EditOp::Insert { + pos: 0, + bytes: b"hello", + }) + .unwrap(); + // Second edit: -> "hello world". + let e2 = b + .apply_edit(EditOp::Insert { + pos: 5, + bytes: b" world", + }) + .unwrap(); + // Replay both deltas into a fresh CRDT doc; result should + // match the originating buffer's rope. + let receiver = crate::crdt::CrdtState::new(99).unwrap(); + receiver + .import_snapshot(&e1.crdt_op.as_ref().unwrap().bytes) + .expect("import e1"); + receiver + .import_snapshot(&e2.crdt_op.as_ref().unwrap().bytes) + .expect("import e2"); + assert_eq!( + receiver.materialize_string(), + "hello world", + "replaying the wire bytes on a fresh doc must reproduce the originating state" + ); + } + + #[cfg(feature = "crdt")] + #[test] + fn crdt_mid_codepoint_position_is_rejected_cleanly() { + // Per the Q1 morning audit, loro rejects mid-codepoint + // delete_utf8 / insert_utf8. The Buffer routing must surface + // the rejection as a clean error and leave both rope and CRDT + // untouched (the rope mutation never runs because Q2 ordering + // applies CRDT first). + let mut b = + Buffer::new_with_crdt(BufferId::next(), "*midcp*", 1).expect("crdt construction"); + b.apply_edit(EditOp::Insert { + pos: 0, + bytes: "héllo".as_bytes(), + }) + .unwrap(); + assert_invariant(&b); + + // Try to delete starting mid-é (byte 2 of "héllo"). Should + // surface as CrdtRejected; rope and CRDT both unchanged. + let pre_rope = rope_string(&b); + let r = b.apply_edit(EditOp::Delete { + range: Range::new(2, 3), + }); + assert!( + matches!(r, Err(BufferError::CrdtRejected { .. })), + "got {r:?}" + ); + assert_eq!(rope_string(&b), pre_rope, "rope must be unchanged"); + assert_invariant(&b); + } + + // ----------------------------------------------------------------- + // T M10.10 Finding 3 — apply_remote_crdt_op acceptance. + // ----------------------------------------------------------------- + + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_integrates_op_and_keeps_invariant() { + // Donor peer (frontend B simulated) produces an op against + // an empty starting state. Receiver (daemon-side buffer) + // applies the op via apply_remote_crdt_op. + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + let v_before = donor.version(); + donor.insert(0, "hello").expect("donor seed"); + let op_bytes = donor + .export_updates_since(&v_before) + .expect("export updates"); + + // Receiver buffer starts empty under peer 1 (the daemon's + // LOCAL peer id). + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*remote*", 1).expect("receiver buf"); + assert_eq!(rope_string(&buf), ""); + + let edit = buf + .apply_remote_crdt_op(&op_bytes) + .expect("apply remote") + .expect("non-empty edit"); + + // Rope ≡ CRDT projection invariant after remote op. + assert_eq!(rope_string(&buf), "hello"); + assert_invariant(&buf); + // Edit's crdt_op stays None — remote op doesn't get re-broadcast. + assert!( + edit.crdt_op.is_none(), + "remote-applied Edit must not carry crdt_op" + ); + // Modified + revision bumped. + assert!(buf.is_modified()); + assert!(buf.revision() > 0); + } + + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_on_non_crdt_buffer_errors() { + let mut buf = Buffer::new(BufferId::next(), "*plain*"); + let result = buf.apply_remote_crdt_op(&[0x00, 0x01, 0x02]); + assert!( + matches!(result, Err(BufferError::CrdtRejected { .. })), + "got {result:?}" + ); + } + + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_invokes_on_edit_subscribers() { + // Verify the Edit subscriber path is honored — TextView's + // line cache + syntax highlighting + overlays all depend on + // on_edit notifications. A simple recording View confirms + // the broadcast fires. + use crate::view::View; + use std::cell::Cell; + use std::rc::Rc; + + struct RecorderView { + count: Rc>, + } + impl View for RecorderView { + fn on_edit(&mut self, _buf: &Buffer, _edit: &Edit) -> Result<(), BufferError> { + self.count.set(self.count.get() + 1); + Ok(()) + } + } + + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + let v_before = donor.version(); + donor.insert(0, "abc").expect("donor seed"); + let op_bytes = donor.export_updates_since(&v_before).expect("export"); + + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*recorder*", 1).expect("buf"); + let count = Rc::new(Cell::new(0usize)); + buf.attach_view(Box::new(RecorderView { + count: Rc::clone(&count), + })); + + let _edit = buf.apply_remote_crdt_op(&op_bytes).expect("apply").unwrap(); + + assert_eq!(count.get(), 1, "on_edit must fire for remote op"); + } + + /// F25 (post-audit-round-4): a CRDT update that changes one + /// codepoint into another with a shared leading UTF-8 byte + /// must produce a char-boundary-aligned diff. Pre-fix, the + /// byte-prefix walk landed mid-codepoint and the rope edit + /// carried an invalid byte slice. + /// + /// Setup: receiver starts empty; donor inserts 'é'; receiver + /// applies that first op (state → 'é'). Donor then deletes the + /// 'é' and inserts 'è'; receiver applies that second op. The + /// second op's diff path sees pre = 'é' (`0xC3 0xA9`) vs post + /// = 'è' (`0xC3 0xA8`) — byte prefix 1, lands mid-codepoint. + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_preserves_utf8_boundaries_for_single_char_change_f25() { + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + let v0 = donor.version(); + donor.insert(0, "é").expect("donor seed"); + let seed_bytes = donor.export_updates_since(&v0).expect("seed export"); + let v_after_seed = donor.version(); + donor.delete(0, "é".len()).expect("donor delete"); + donor.insert(0, "è").expect("donor insert"); + let replace_bytes = donor + .export_updates_since(&v_after_seed) + .expect("replace export"); + + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8*", 1).expect("buf"); + // Step 1: seed receiver with 'é'. + let seed_edit = buf + .apply_remote_crdt_op(&seed_bytes) + .expect("apply seed") + .expect("seed edit"); + assert_eq!(rope_string(&buf), "é"); + assert_eq!(seed_edit.range.start, 0); + assert_eq!(seed_edit.range.end, 0); + + // Step 2: the F25 codepath. Apply the replace op WITHOUT + // panicking on a mid-codepoint rope split. + let replace_edit = buf + .apply_remote_crdt_op(&replace_bytes) + .expect("apply replace") + .expect("replace edit"); + assert_eq!(rope_string(&buf), "è"); + // The Edit's range should cover the WHOLE codepoint (0..2), + // not the byte-naive (1..2) that pre-fix would have produced. + assert_eq!( + replace_edit.range.start, 0, + "F25: range_start must be char-boundary-aligned (0, not 1)" + ); + assert_eq!(replace_edit.range.end, "é".len() as u64); + assert_invariant(&buf); + } + + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_for_delete_produces_correct_diff() { + // Donor inserts then deletes; the second op is the "delete" + // we'll apply remotely. Replicates the multi-step convergence + // path. + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + donor.insert(0, "hello world").expect("seed"); + + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*remote-del*", 1).expect("buf"); + // Sync receiver up to donor's initial state. + let donor_snap = donor.export_snapshot().expect("snap"); + buf.crdt + .as_ref() + .expect("crdt") + .import_snapshot(&donor_snap) + .expect("init from snap"); + // Replace rope to match (test setup; production daemon side + // doesn't do this manually). + buf.rope = crate::rope::Rope::from_bytes(b"hello world"); + assert_eq!(rope_string(&buf), "hello world"); + + // Donor deletes " world" (6 bytes from position 5). + let v_before = donor.version(); + donor.delete(5, 6).expect("donor delete"); + let op_bytes = donor.export_updates_since(&v_before).expect("export"); + + let _ = buf.apply_remote_crdt_op(&op_bytes).expect("apply").unwrap(); + assert_eq!(rope_string(&buf), "hello"); + assert_invariant(&buf); } } diff --git a/src/buffer_mirror.rs b/src/buffer_mirror.rs new file mode 100644 index 0000000..0f8e408 --- /dev/null +++ b/src/buffer_mirror.rs @@ -0,0 +1,1228 @@ +//! T M10.10 — Frontend-side CRDT replica state. +//! +//! `BufferMirror` is the frontend's mirror of the instance's +//! authoritative buffer state. M10.10 makes the frontend a CRDT +//! replica (departing from the M10.5–M10.9 "transport sink" posture) +//! so that local edits can be applied optimistically before the +//! daemon's confirmation round-trips. +//! +//! # Lifecycle +//! +//! - **Bootstrap.** On `SessionEstablished` the daemon sends one +//! `InstanceMessage::BufferSnapshot` per active buffer. The +//! frontend calls [`BufferMirror::init_from_snapshot`] for each; +//! each buffer's mirror starts at the same CRDT state as the +//! instance. +//! - **Local edits.** A typed character (per the text-input +//! predicate in `attach.rs`) becomes a `CrdtState::insert` / +//! `CrdtState::delete` call on the relevant mirror. The mirror +//! produces a `CrdtOp` payload that the frontend sends upstream as +//! `FrontendEvent::CrdtOp`. The mirror has already applied the op +//! locally — the upstream send is for the daemon and other +//! frontends. +//! - **Remote ops.** Incoming `InstanceMessage::CrdtOp` is routed +//! here via [`BufferMirror::apply_remote_op`]. The op-source +//! `FrontendId` determines whether it's the receiving frontend's +//! own echo (no-op-on-echo, Q4) or a true remote op that needs +//! integration plus repaint (Q5). +//! - **Mid-session buffer creation.** A new buffer surfaces via a +//! subsequent `BufferSnapshot`. [`BufferMirror::is_ready`] gates +//! optimistic-apply per buffer; pre-bootstrap typing falls through +//! to the v0.1 round-trip path (Refinement 4). +//! +//! # Threading +//! +//! Single-thread owner (frontend's main loop). `CrdtState` is +//! `Send`-but-not-`Sync` per `crdt.rs`'s docstring; the mirror lives +//! on the main thread, the reader thread only delivers `InstanceMessage` +//! via mpsc. No `Mutex` wrapping. + +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use crate::buffer::BufferId; +use crate::crdt::{CrdtState, peer_id_from_frontend}; +use crate::protocol::FrontendId; +use loro::LoroError; +use unicode_width::UnicodeWidthChar; + +/// Errors returned by [`BufferMirror`] operations. +/// +/// Two invariant-violation variants surface caller mistakes honestly: +/// `NotReady` means a method was called on a buffer that hasn't had a +/// snapshot applied; `AlreadyInitialized` means +/// [`init_from_snapshot`](BufferMirror::init_from_snapshot) was called +/// twice for the same buffer. The third variant wraps `loro::LoroError` +/// for genuine CRDT-layer failures. +#[derive(Debug)] +pub enum BufferMirrorError { + /// `apply_local_insert`/`apply_local_delete`/`apply_remote_op` was + /// called for a buffer that hasn't received a snapshot yet. The + /// caller should consult [`is_ready`](BufferMirror::is_ready) and + /// fall through to the v0.1 round-trip path + /// (Refinement 4: pre-bootstrap graceful degradation). + NotReady(BufferId), + /// [`init_from_snapshot`](BufferMirror::init_from_snapshot) was + /// called for a buffer that already has state. Silently replacing + /// would discard any optimistically-applied local edits — data + /// loss. The caller (the dispatcher event loop) should treat this + /// as a daemon-side bug and surface the error rather than retry. + AlreadyInitialized(BufferId), + /// Underlying CRDT operation failed. Includes invalid byte + /// offsets, mid-codepoint positions, and op-bytes that don't + /// decode. + Loro(LoroError), +} + +impl From for BufferMirrorError { + fn from(e: LoroError) -> Self { + Self::Loro(e) + } +} + +impl fmt::Display for BufferMirrorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotReady(id) => { + write!(f, "buffer {id:?} has no CRDT snapshot applied yet") + } + Self::AlreadyInitialized(id) => { + write!(f, "buffer {id:?} already has a CRDT snapshot applied") + } + Self::Loro(e) => write!(f, "CRDT operation failed: {e}"), + } + } +} + +impl std::error::Error for BufferMirrorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Loro(e) => Some(e), + Self::NotReady(_) | Self::AlreadyInitialized(_) => None, + } + } +} + +/// Per-frontend collection of CRDT-replica states keyed by `BufferId`. +/// +/// A `BufferMirror` is created once per attach session, owned by the +/// frontend's main loop. It holds one [`CrdtState`] per active buffer +/// the daemon has snapshotted to this frontend, plus the byte-position +/// cursor for each buffer (authoritatively updated from +/// `InstanceMessage::CursorByte`). +pub struct BufferMirror { + /// The frontend's assigned id — used to derive the loro `peer_id` + /// for newly-initialized `CrdtState`s. Stable for the session. + peer_id: u64, + /// One CRDT replica per buffer the frontend currently mirrors. + states: HashMap, + /// Per-buffer cursor byte position. Authoritative source for the + /// optimistic-apply path's insert/delete position arguments. + /// + /// Updated by [`set_cursor_byte_pos`](BufferMirror::set_cursor_byte_pos) + /// when an `InstanceMessage::CursorByte` arrives from the daemon. + /// Absent until the first such message arrives for a given + /// `BufferId` — callers (the optimistic-apply path) treat absence + /// the same as `is_ready` false and fall through to v0.1 + /// round-trip per Refinement 4. + cursors: HashMap, + /// The buffer the most recent `InstanceMessage::CursorByte` + /// described — the daemon's authoritative "active buffer for this + /// frontend" signal. The optimistic-apply path routes keystrokes + /// to this buffer. + /// + /// `None` until the first `CursorByte` arrives (pre-bootstrap + /// state). The optimistic predicate falls through to v0.1 + /// round-trip when this is `None` per Refinement 4 graceful + /// degradation. + active_buffer: Option, + /// T M10.10 post-audit-round-4 F22 + F23 — buffers whose cursor + /// position is **non-authoritative** until the next `CursorByte` + /// arrives from the daemon. Set when: + /// + /// - [`apply_remote_op`](Self::apply_remote_op) integrates a + /// remote CRDT op into the buffer's content. The mirror's + /// cursor for the buffer doesn't auto-adjust with right- + /// gravity, so it may now point at the wrong byte relative to + /// the new content (F23). + /// - [`mark_cursor_stale`](Self::mark_cursor_stale) is called by + /// the attach loop after sending a `FrontendEvent::Key` that + /// the daemon will process — Key events can move the cursor + /// (motion keys, Enter, Tab, mid-line edits, etc.) and the + /// mirror has no way to predict the new position locally + /// (F22). + /// + /// Cleared when + /// [`set_cursor_byte_pos`](Self::set_cursor_byte_pos) is called + /// (the daemon's authoritative `CursorByte` arrived). + /// + /// The optimistic-apply orchestrator gates on + /// [`is_cursor_fresh`](Self::is_cursor_fresh) and round-trips + /// when stale, preventing "the mirror cursor advanced via + /// optimistic apply against content the daemon's cursor was + /// already past" coherence bugs. + stale_cursors: HashSet, +} + +impl BufferMirror { + /// Construct a mirror tied to `frontend_id`. The mirror starts + /// with no buffers; [`init_from_snapshot`](Self::init_from_snapshot) + /// adds them as `BufferSnapshot` messages arrive. + #[must_use] + pub fn new(frontend_id: FrontendId) -> Self { + Self { + peer_id: peer_id_from_frontend(frontend_id), + states: HashMap::new(), + cursors: HashMap::new(), + active_buffer: None, + stale_cursors: HashSet::new(), + } + } + + /// Mark the cursor for `buffer_id` as non-authoritative until + /// the next `CursorByte` arrives. The optimistic-apply + /// orchestrator round-trips while stale. + /// + /// Called by the attach loop after sending a `FrontendEvent::Key` + /// to the daemon — the daemon's command pipeline may move the + /// cursor (motion, Enter/Tab, mid-line edits) in ways the mirror + /// can't predict locally. See post-audit-round-4 F22. + pub fn mark_cursor_stale(&mut self, buffer_id: BufferId) { + self.stale_cursors.insert(buffer_id); + } + + /// Returns true when the mirror's cursor for `buffer_id` + /// reflects the daemon's authoritative position (i.e. no + /// `apply_remote_op` or `mark_cursor_stale` has run since the + /// last `CursorByte`). + /// + /// The orchestrator consults this before generating a local + /// `CrdtOp`. Stale → round-trip. + #[must_use] + pub fn is_cursor_fresh(&self, buffer_id: BufferId) -> bool { + !self.stale_cursors.contains(&buffer_id) + } + + /// The buffer the daemon most recently signaled as active for + /// this frontend (via `InstanceMessage::CursorByte`). The + /// optimistic-apply path routes keystrokes to this buffer. + /// + /// Returns `None` until the first `CursorByte` has been applied. + /// Callers should treat `None` the same as `is_ready` false and + /// fall through to v0.1 round-trip. + #[must_use] + pub fn active_buffer(&self) -> Option { + self.active_buffer + } + + /// This frontend's loro `peer_id`. Stable for the session; used + /// by the keystroke-handling path to construct `CrdtOp` wire + /// payloads. + #[must_use] + pub fn peer_id(&self) -> u64 { + self.peer_id + } + + /// Byte length of the character ending at the current cursor in + /// `buffer_id`. Used for delete-back: removing one character + /// from the optimistic mirror means removing this many bytes + /// ending at the cursor. + /// + /// Returns: + /// - `Some(n)` with the UTF-8 byte length of the previous + /// character (1 for ASCII, 2–4 for non-ASCII Unicode). + /// - `None` if the buffer isn't ready, the cursor isn't tracked, + /// or the cursor is at position 0 (nothing before it to delete). + /// + /// The char-aware byte count matters: loro rejects mid-codepoint + /// deletes (`crdt.rs::q1_sub_check_3_mid_codepoint_rejection_on_delete`). + /// A naive 1-byte-retreat would error on multi-byte characters + /// — using this helper produces a valid CRDT op. + #[must_use] + pub fn prev_char_len(&self, buffer_id: BufferId) -> Option { + let cursor = self.cursors.get(&buffer_id).copied()?; + if cursor == 0 { + return None; + } + let content = self.states.get(&buffer_id)?.materialize_string(); + content[..cursor].chars().last().map(char::len_utf8) + } + + /// T M10.10 (post-audit Finding 5) — is the cursor at end-of- + /// line **and safe for visual delete-back paint**? + /// + /// Stricter than [`cursor_at_end_of_line`](Self::cursor_at_end_of_line): + /// requires three additional invariants on top of end-of-line: + /// (a) cursor not at position 0 (something to delete); + /// (b) previous char is not `\n` (a line-join requires `MoveUp` + + /// repaint of the previous line's tail, which the single-column + /// erase can't represent — round-1 Finding 9); + /// (c) previous char's rendered display width is exactly 1 + /// column (post-audit-round-3 F20 — wide chars, tabs, combining + /// marks, and other zero-width controls all break the single- + /// column-erase paint sequence's column accounting). + /// + /// # F20: width-1 invariant + /// + /// The paint sequence `MoveLeft(1), Print(' '), MoveLeft(1)` + /// assumes the cell to erase is exactly one terminal column + /// wide. `TextView` renders wide chars (`UnicodeWidthChar::width + /// == 2`) into two cells (base + Continuation), tabs into a + /// variable run of spaces aligned to the next tab stop, and + /// zero-width combining marks as clusters attached to the + /// previous cell. Erasing one of these with our one-column + /// paint leaves a half-painted cell or a column off. + /// + /// The check uses `UnicodeWidthChar::width(prev_char) == + /// Some(1)`, which excludes: + /// - wide chars (`Some(2)`) + /// - control characters (`None` or `Some(0)`) + /// - zero-width combining marks (`Some(0)`) + /// - tabs (`None`) + /// + /// Returns: + /// - `Some(true)` if optimistic delete-back paint is safe (all + /// four invariants hold). + /// - `Some(false)` if optimistic delete-back paint is unsafe + /// (any invariant fails). + /// - `None` if the buffer isn't ready. + #[must_use] + pub fn cursor_at_end_of_line_safe_for_delete_back(&self, buffer_id: BufferId) -> Option { + let cursor = self.cursors.get(&buffer_id).copied()?; + let content = self.states.get(&buffer_id)?.materialize_string(); + let bytes = content.as_bytes(); + if cursor == 0 { + return Some(false); // nothing before cursor to delete + } + let at_end_of_line = if cursor >= bytes.len() { + true + } else { + bytes[cursor] == b'\n' + }; + if !at_end_of_line { + return Some(false); + } + // Previous char must not be a newline — otherwise this is a + // line-join operation that the single-column-erase paint + // sequence can't represent. + let prev_char = content[..cursor].chars().last()?; + if prev_char == '\n' { + return Some(false); + } + // F20: previous char's rendered width must be exactly 1 + // column. Wide chars, tabs, combining marks all fail this + // check and fall through to v0.1 round-trip. + Some(UnicodeWidthChar::width(prev_char) == Some(1)) + } + + /// T M10.10 Day 3 step 5 Path β — is the cursor at the end of + /// its current line? + /// + /// Used by the visual-optimistic-paint gate: end-of-line typing + /// can paint optimistically (`queue!(out, Print(c))` matches the + /// cell the daemon's `CellDelta` will eventually carry, so no + /// flicker). Mid-line typing would shift cells right of cursor + /// in the daemon's render, which the frontend's single-Print + /// can't match without view layout — falls through to no + /// optimistic visual paint. + /// + /// Returns: + /// - `Some(true)` if the cursor is positioned at the end of its + /// line (either at the buffer's end, or immediately before a + /// `\n`). + /// - `Some(false)` if the cursor is in the middle of its line. + /// - `None` if the buffer isn't ready or the cursor isn't + /// tracked. + /// + /// Detection: the byte at `cursor_byte_pos` is either past the + /// buffer's end OR is a `\n`. Both cases mean "nothing on this + /// line right of the cursor" — optimistic Print fits. + #[must_use] + pub fn cursor_at_end_of_line(&self, buffer_id: BufferId) -> Option { + let cursor = self.cursors.get(&buffer_id).copied()?; + let content = self.states.get(&buffer_id)?.materialize_string(); + let bytes = content.as_bytes(); + if cursor >= bytes.len() { + // Cursor at or past the buffer's end — always end-of-line. + return Some(true); + } + // `\n` at cursor position means cursor is at the end of the + // line that precedes the newline. + Some(bytes[cursor] == b'\n') + } + + /// Byte length of the character at the current cursor in + /// `buffer_id`. Used for delete-forward: removing one character + /// at the cursor means removing this many bytes. + /// + /// Returns: + /// - `Some(n)` with the UTF-8 byte length of the next character. + /// - `None` if the buffer isn't ready, the cursor isn't tracked, + /// or the cursor is at the end of the buffer. + /// + /// Same char-boundary rationale as + /// [`prev_char_len`](Self::prev_char_len). + #[must_use] + pub fn next_char_len(&self, buffer_id: BufferId) -> Option { + let cursor = self.cursors.get(&buffer_id).copied()?; + let content = self.states.get(&buffer_id)?.materialize_string(); + if cursor >= content.len() { + return None; + } + content[cursor..].chars().next().map(char::len_utf8) + } + + /// Get the cursor byte position for `buffer_id`. Returns `None` + /// until the first `InstanceMessage::CursorByte` for that buffer + /// has been applied via + /// [`set_cursor_byte_pos`](Self::set_cursor_byte_pos). + /// + /// The optimistic-apply path consults this before generating a + /// local `CrdtOp`. Absence means cursor position is unknown for + /// this buffer; the keystroke should fall through to the v0.1 + /// `FrontendEvent::Key` round-trip path (Refinement 4 graceful + /// degradation). + #[must_use] + pub fn cursor_byte_pos(&self, buffer_id: BufferId) -> Option { + self.cursors.get(&buffer_id).copied() + } + + /// Set the cursor byte position for `buffer_id` authoritatively. + /// Called when an `InstanceMessage::CursorByte { buffer_id, + /// byte_pos }` arrives from the daemon. + /// + /// Per the broadened `CursorByte` semantics (Day 3 step 3b + /// composition-check resolution), this method also marks + /// `buffer_id` as the active buffer for the optimistic-apply + /// path. `CursorByte` represents "active buffer + cursor in it," + /// not just "cursor moved," so the active-buffer update is the + /// natural pairing. + /// + /// Overwrites any prior cursor for `buffer_id` (including + /// optimistically-advanced positions). This is the + /// authoritative-update path; optimistic advances via + /// [`advance_cursor`](Self::advance_cursor) and + /// [`retreat_cursor`](Self::retreat_cursor) yield to the next + /// `CursorByte` from the daemon. + pub fn set_cursor_byte_pos(&mut self, buffer_id: BufferId, byte_pos: usize) { + self.cursors.insert(buffer_id, byte_pos); + self.active_buffer = Some(buffer_id); + // F22 + F23: the daemon's authoritative cursor byte position + // re-grounds the mirror cursor. Any prior staleness from a + // pending Key round-trip or unaccounted remote-op + // right-gravity is resolved. + self.stale_cursors.remove(&buffer_id); + } + + /// Advance the cursor for `buffer_id` by `n` bytes (used after a + /// local optimistic insert). No-op if the cursor isn't tracked + /// yet (the optimistic-apply path's contract is that + /// [`cursor_byte_pos`](Self::cursor_byte_pos) returned `Some` + /// before the apply, so the cursor is guaranteed to exist by the + /// time we're advancing — but we don't panic on the contract + /// violation). + pub fn advance_cursor(&mut self, buffer_id: BufferId, n: usize) { + if let Some(pos) = self.cursors.get_mut(&buffer_id) { + *pos = pos.saturating_add(n); + } + } + + /// Retreat the cursor for `buffer_id` by `n` bytes (used after a + /// local optimistic delete-back). Saturating: cursor clamps at 0 + /// rather than wrapping. + pub fn retreat_cursor(&mut self, buffer_id: BufferId, n: usize) { + if let Some(pos) = self.cursors.get_mut(&buffer_id) { + *pos = pos.saturating_sub(n); + } + } + + /// True if the mirror has a CRDT state for `buffer_id`. The + /// frontend's optimistic-apply predicate consults this before + /// generating a local `CrdtOp`; false → fall through to v0.1 + /// round-trip (Refinement 4 graceful degradation). + /// + /// Returns false for `BufferId`s the mirror has never received a + /// snapshot for — including buffers that exist on the instance + /// but whose `BufferSnapshot` hasn't arrived at this frontend yet + /// (the "buffer just got created elsewhere" case from + /// Refinement 6). + #[must_use] + pub fn is_ready(&self, buffer_id: BufferId) -> bool { + self.states.contains_key(&buffer_id) + } + + /// Initialize the mirror for `buffer_id` from a daemon-sent CRDT + /// snapshot. + /// + /// # Errors + /// + /// Returns [`BufferMirrorError::AlreadyInitialized`] if the buffer + /// already has state. Silently replacing would discard any + /// optimistically-applied local edits made between the two + /// snapshots — that's data loss; this method refuses the + /// double-init explicitly. A daemon bug (sending two snapshots + /// for the same buffer) is the most likely cause; the caller + /// should log and surface the error rather than retry blindly. + /// + /// Returns [`BufferMirrorError::Loro`] if `CrdtState::new` or + /// `import_snapshot` fail. A failed init leaves the mirror + /// without an entry for `buffer_id`. + pub fn init_from_snapshot( + &mut self, + buffer_id: BufferId, + snapshot: &[u8], + ) -> Result<(), BufferMirrorError> { + if self.states.contains_key(&buffer_id) { + return Err(BufferMirrorError::AlreadyInitialized(buffer_id)); + } + let state = CrdtState::new(self.peer_id)?; + state.import_snapshot(snapshot)?; + self.states.insert(buffer_id, state); + Ok(()) + } + + /// Apply a local insertion to the mirror for `buffer_id`. + /// Returns the op's wire-format bytes so the frontend can wrap + /// them in `FrontendEvent::CrdtOp` and send upstream. + /// + /// # Errors + /// + /// - [`BufferMirrorError::NotReady`] if the buffer hasn't received + /// a snapshot. Callers should check + /// [`is_ready`](Self::is_ready) first and fall through to the + /// v0.1 round-trip path on false. + /// - [`BufferMirrorError::Loro`] if the loro `insert` fails + /// (e.g., mid-codepoint position). + pub fn apply_local_insert( + &mut self, + buffer_id: BufferId, + pos: usize, + text: &str, + ) -> Result, BufferMirrorError> { + let state = self + .states + .get_mut(&buffer_id) + .ok_or(BufferMirrorError::NotReady(buffer_id))?; + let version_before = state.version(); + state.insert(pos, text)?; + state.export_updates_since(&version_before).map_err(|e| { + BufferMirrorError::Loro(LoroError::DecodeError( + format!("export_updates: {e:?}").into(), + )) + }) + } + + /// Apply a local deletion to the mirror for `buffer_id`. + /// Returns the op's wire-format bytes; same contract as + /// [`apply_local_insert`](Self::apply_local_insert). + /// + /// # Errors + /// + /// As for [`apply_local_insert`](Self::apply_local_insert). + pub fn apply_local_delete( + &mut self, + buffer_id: BufferId, + pos: usize, + len: usize, + ) -> Result, BufferMirrorError> { + let state = self + .states + .get_mut(&buffer_id) + .ok_or(BufferMirrorError::NotReady(buffer_id))?; + let version_before = state.version(); + state.delete(pos, len)?; + state.export_updates_since(&version_before).map_err(|e| { + BufferMirrorError::Loro(LoroError::DecodeError( + format!("export_updates: {e:?}").into(), + )) + }) + } + + /// Apply a remote op (received via `InstanceMessage::CrdtOp`) to + /// the mirror for `buffer_id`. + /// + /// # Echo filtering — caller's responsibility + /// + /// **Filter rule:** call this method only when the broadcast's + /// source `FrontendId` differs from this frontend's assigned + /// `FrontendId`. Ops whose source matches the local frontend are + /// echoes of locally-applied edits; passing them here would + /// double-apply (the local op was already integrated by + /// `apply_local_insert`/`apply_local_delete` at keystroke time). + /// + /// The mirror layer doesn't know about `FrontendId` or session + /// identity — it operates on op bytes only. The attach loop in + /// `attach.rs` performs the `FrontendId` comparison before invoking + /// this method. + /// + /// # Errors + /// + /// - [`BufferMirrorError::NotReady`] if no snapshot has been + /// applied for `buffer_id`. + /// - [`BufferMirrorError::Loro`] if the op bytes don't decode. + /// A per-op failure doesn't corrupt the mirror; the caller can + /// log and continue. + pub fn apply_remote_op( + &mut self, + buffer_id: BufferId, + op_bytes: &[u8], + ) -> Result<(), BufferMirrorError> { + let state = self + .states + .get_mut(&buffer_id) + .ok_or(BufferMirrorError::NotReady(buffer_id))?; + state + .import_updates(op_bytes) + .map_err(BufferMirrorError::Loro)?; + // F23 — content changed; the mirror's cursor for this buffer + // doesn't auto-adjust with right-gravity, so it may now point + // at the wrong byte relative to the new content. Mark stale + // until the daemon's next `CursorByte` re-grounds it. The + // optimistic-apply orchestrator round-trips while stale. + self.stale_cursors.insert(buffer_id); + Ok(()) + } + + /// Materialize the current text of `buffer_id` as a String. + /// Returns None if the buffer isn't ready. + #[must_use] + pub fn materialize(&self, buffer_id: BufferId) -> Option { + self.states + .get(&buffer_id) + .map(CrdtState::materialize_string) + } + + /// UTF-8 byte length of `buffer_id`'s current content. Returns + /// None if not ready. The frontend uses this for cursor-bounds + /// checks before generating delete-forward ops. + #[must_use] + pub fn len_utf8(&self, buffer_id: BufferId) -> Option { + self.states.get(&buffer_id).map(CrdtState::len_utf8) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_snapshot(seed_peer_id: u64, initial_text: &str) -> Vec { + // Build a CrdtState on a synthetic peer, seed text, export. + // Models what the daemon's CRDT-aware buffer does at attach + // time. + let state = CrdtState::new(seed_peer_id).expect("new"); + state.insert(0, initial_text).expect("seed insert"); + state.export_snapshot().expect("export") + } + + #[test] + fn fresh_mirror_has_no_buffers_and_is_not_ready_for_any() { + let m = BufferMirror::new(FrontendId(7)); + let some_id = BufferId::next(); + assert!(!m.is_ready(some_id)); + assert!(m.materialize(some_id).is_none()); + assert!(m.len_utf8(some_id).is_none()); + } + + #[test] + fn is_ready_returns_false_for_buffer_never_snapshotted() { + // Verifies the "buffer just got created on another frontend; + // this frontend hasn't received the snapshot yet" path from + // Refinement 6. A BufferId the mirror has never heard of + // returns false, not a panic. + let mut m = BufferMirror::new(FrontendId(2)); + let known = BufferId::next(); + let unknown = BufferId::next(); + m.init_from_snapshot(known, &fresh_snapshot(99, "x")) + .expect("init known"); + assert!(m.is_ready(known)); + assert!(!m.is_ready(unknown)); + } + + #[test] + fn init_from_snapshot_makes_buffer_ready() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let snap = fresh_snapshot(99, "hello"); + m.init_from_snapshot(id, &snap).expect("init"); + + assert!(m.is_ready(id)); + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.len_utf8(id), Some("hello".len())); + } + + #[test] + fn init_from_snapshot_twice_errors_already_initialized() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "first")) + .expect("init 1"); + + let err = m + .init_from_snapshot(id, &fresh_snapshot(99, "second")) + .expect_err("second init must error"); + match err { + BufferMirrorError::AlreadyInitialized(b) => assert_eq!(b, id), + other => panic!("expected AlreadyInitialized, got {other:?}"), + } + // The first snapshot's state is preserved — the second-init + // attempt did not corrupt it. + assert_eq!(m.materialize(id).as_deref(), Some("first")); + } + + #[test] + fn local_insert_modifies_mirror_and_returns_op_bytes() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let snap = fresh_snapshot(99, "abc"); + m.init_from_snapshot(id, &snap).expect("init"); + + let op = m.apply_local_insert(id, 3, "X").expect("insert"); + assert!(!op.is_empty(), "op bytes must be non-empty"); + assert_eq!(m.materialize(id).as_deref(), Some("abcX")); + } + + #[test] + fn local_delete_modifies_mirror_and_returns_op_bytes() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let snap = fresh_snapshot(99, "abcde"); + m.init_from_snapshot(id, &snap).expect("init"); + + let op = m.apply_local_delete(id, 1, 2).expect("delete"); + assert!(!op.is_empty()); + assert_eq!(m.materialize(id).as_deref(), Some("ade")); + } + + #[test] + fn remote_op_applies_to_mirror() { + // Build a snapshot from peer 99, then synthesize a remote + // op also produced by peer 99 (simulates the receiving + // frontend hasn't typed anything and another peer's edit + // arrives). + let donor = CrdtState::new(99).expect("donor new"); + donor.insert(0, "abc").expect("donor seed"); + let snap = donor.export_snapshot().expect("snap"); + let v_before = donor.version(); + donor.insert(3, "Y").expect("donor edit"); + let op_bytes = donor + .export_updates_since(&v_before) + .expect("export updates"); + + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &snap).expect("init"); + m.apply_remote_op(id, &op_bytes).expect("apply remote"); + + assert_eq!(m.materialize(id).as_deref(), Some("abcY")); + } + + #[test] + fn apply_local_insert_on_unready_buffer_errors_not_ready() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let err = m.apply_local_insert(id, 0, "x").expect_err("must error"); + match err { + BufferMirrorError::NotReady(b) => assert_eq!(b, id), + other => panic!("expected NotReady, got {other:?}"), + } + assert!(!m.is_ready(id)); + } + + #[test] + fn apply_local_delete_on_unready_buffer_errors_not_ready() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let err = m.apply_local_delete(id, 0, 1).expect_err("must error"); + assert!(matches!(err, BufferMirrorError::NotReady(b) if b == id)); + } + + #[test] + fn apply_remote_op_on_unready_buffer_errors_not_ready() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let err = m + .apply_remote_op(id, &[0xCA, 0xFE]) + .expect_err("must error"); + assert!(matches!(err, BufferMirrorError::NotReady(b) if b == id)); + } + + // ----------------------------------------------------------------- + // Cursor tracking (T M10.10 Finding 2 — CursorByte wire variant). + // ----------------------------------------------------------------- + + #[test] + fn cursor_byte_pos_returns_none_before_any_cursor_byte_received() { + let m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + assert!(m.cursor_byte_pos(id).is_none()); + } + + #[test] + fn cursor_byte_pos_returns_none_for_unknown_buffer_even_when_others_have_cursors() { + let mut m = BufferMirror::new(FrontendId(2)); + let known = BufferId::next(); + let unknown = BufferId::next(); + m.set_cursor_byte_pos(known, 42); + assert_eq!(m.cursor_byte_pos(known), Some(42)); + assert!(m.cursor_byte_pos(unknown).is_none()); + } + + #[test] + fn set_cursor_byte_pos_overwrites_prior_value() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 10); + m.set_cursor_byte_pos(id, 25); + assert_eq!(m.cursor_byte_pos(id), Some(25)); + } + + #[test] + fn advance_cursor_increments_existing_position() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 5); + m.advance_cursor(id, 3); + assert_eq!(m.cursor_byte_pos(id), Some(8)); + } + + #[test] + fn advance_cursor_on_unknown_buffer_is_noop() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.advance_cursor(id, 5); + assert!(m.cursor_byte_pos(id).is_none()); + } + + #[test] + fn retreat_cursor_decrements_existing_position() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 10); + m.retreat_cursor(id, 3); + assert_eq!(m.cursor_byte_pos(id), Some(7)); + } + + #[test] + fn retreat_cursor_saturates_at_zero() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 2); + m.retreat_cursor(id, 10); + assert_eq!(m.cursor_byte_pos(id), Some(0)); + } + + #[test] + fn authoritative_cursor_update_overwrites_optimistic_advance() { + // Models the daemon-correction path: frontend optimistically + // advanced cursor; daemon's authoritative CursorByte arrives + // and overwrites with the daemon's source-of-truth value. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 5); + m.advance_cursor(id, 1); // optimistic: 5 → 6 + m.set_cursor_byte_pos(id, 7); // daemon says actual is 7 + assert_eq!(m.cursor_byte_pos(id), Some(7)); + } + + // ----------------------------------------------------------------- + // active_buffer tracking (broadened CursorByte semantics). + // ----------------------------------------------------------------- + + #[test] + fn active_buffer_is_none_before_any_cursor_byte_received() { + let m = BufferMirror::new(FrontendId(2)); + assert!(m.active_buffer().is_none()); + } + + #[test] + fn set_cursor_byte_pos_updates_active_buffer() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 5); + assert_eq!(m.active_buffer(), Some(id)); + } + + #[test] + fn active_buffer_tracks_most_recent_cursor_byte_buffer() { + // Broadened CursorByte semantics: each CursorByte represents + // "active buffer + cursor in it". Buffer switches are + // reflected as active_buffer changes. + let mut m = BufferMirror::new(FrontendId(2)); + let a = BufferId::next(); + let b = BufferId::next(); + m.set_cursor_byte_pos(a, 10); + assert_eq!(m.active_buffer(), Some(a)); + m.set_cursor_byte_pos(b, 0); + assert_eq!(m.active_buffer(), Some(b)); + // Prior cursor for `a` is preserved (per-buffer cursors are + // independent); only the active-buffer pointer switches. + assert_eq!(m.cursor_byte_pos(a), Some(10)); + assert_eq!(m.cursor_byte_pos(b), Some(0)); + } + + // ----------------------------------------------------------------- + // prev_char_len / next_char_len — char-boundary-aware byte counts. + // ----------------------------------------------------------------- + + #[test] + fn prev_char_len_ascii_is_one_byte() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); // cursor at end + assert_eq!(m.prev_char_len(id), Some(1)); + } + + #[test] + fn prev_char_len_multibyte_unicode_is_2_to_4_bytes() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + // 'é' is 2 bytes (U+00E9: C3 A9); '中' is 3 bytes (E4 B8 AD). + m.init_from_snapshot(id, &fresh_snapshot(99, "aé中")) + .expect("init"); + // Cursor right after '中' (the 3-byte char). + // "aé中" = 1 + 2 + 3 = 6 bytes. + m.set_cursor_byte_pos(id, 6); + assert_eq!(m.prev_char_len(id), Some(3)); + // Cursor right after 'é' (the 2-byte char). + m.set_cursor_byte_pos(id, 3); + assert_eq!(m.prev_char_len(id), Some(2)); + // Cursor right after 'a' (1-byte ASCII). + m.set_cursor_byte_pos(id, 1); + assert_eq!(m.prev_char_len(id), Some(1)); + } + + #[test] + fn prev_char_len_at_position_zero_is_none() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "x")) + .expect("init"); + m.set_cursor_byte_pos(id, 0); + assert!(m.prev_char_len(id).is_none()); + } + + #[test] + fn prev_char_len_without_cursor_is_none() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "x")) + .expect("init"); + // No CursorByte received → no cursor tracked. + assert!(m.prev_char_len(id).is_none()); + } + + #[test] + fn next_char_len_ascii_is_one_byte() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 0); + assert_eq!(m.next_char_len(id), Some(1)); + } + + #[test] + fn next_char_len_multibyte_unicode_is_2_to_4_bytes() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + // "é中a" = 2 + 3 + 1 = 6 bytes. + m.init_from_snapshot(id, &fresh_snapshot(99, "é中a")) + .expect("init"); + m.set_cursor_byte_pos(id, 0); // before 'é' + assert_eq!(m.next_char_len(id), Some(2)); + m.set_cursor_byte_pos(id, 2); // before '中' + assert_eq!(m.next_char_len(id), Some(3)); + m.set_cursor_byte_pos(id, 5); // before 'a' + assert_eq!(m.next_char_len(id), Some(1)); + } + + #[test] + fn next_char_len_at_end_is_none() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "abc")) + .expect("init"); + m.set_cursor_byte_pos(id, 3); + assert!(m.next_char_len(id).is_none()); + } + + #[test] + fn peer_id_matches_frontend_derivation() { + let m = BufferMirror::new(FrontendId(42)); + assert_eq!(m.peer_id(), 42); + } + + // ----------------------------------------------------------------- + // cursor_at_end_of_line — Path β end-of-line predicate. + // ----------------------------------------------------------------- + + #[test] + fn cursor_at_end_of_line_is_true_at_buffer_end() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); + assert_eq!(m.cursor_at_end_of_line(id), Some(true)); + } + + #[test] + fn cursor_at_end_of_line_is_false_mid_line() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello world")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); // between 'hello' and ' world' + assert_eq!(m.cursor_at_end_of_line(id), Some(false)); + } + + #[test] + fn cursor_at_end_of_line_is_true_before_newline() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "foo\nbar")) + .expect("init"); + m.set_cursor_byte_pos(id, 3); // immediately before '\n' + assert_eq!(m.cursor_at_end_of_line(id), Some(true)); + } + + #[test] + fn cursor_at_end_of_line_is_false_at_line_start_with_content() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "foo\nbar")) + .expect("init"); + m.set_cursor_byte_pos(id, 4); // start of "bar" line + assert_eq!(m.cursor_at_end_of_line(id), Some(false)); + } + + #[test] + fn cursor_at_end_of_line_is_true_on_empty_line() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "foo\n\nbar")) + .expect("init"); + m.set_cursor_byte_pos(id, 4); // empty line between foo and bar + assert_eq!(m.cursor_at_end_of_line(id), Some(true)); + } + + // ----------------------------------------------------------------- + // cursor_at_end_of_line_safe_for_delete_back — Finding 5 narrower + // predicate that excludes line-joining backspaces. + // ----------------------------------------------------------------- + + #[test] + fn safe_for_delete_back_true_when_prev_char_is_not_newline() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); // cursor after 'o' + assert_eq!(m.cursor_at_end_of_line_safe_for_delete_back(id), Some(true)); + } + + #[test] + fn safe_for_delete_back_false_when_prev_char_is_newline() { + // Cursor at byte position 4 (start of empty line after "foo\n"). + // bytes[4] is past end → end-of-line=true. + // But prev char is '\n' — backspace would join lines. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "foo\n")) + .expect("init"); + m.set_cursor_byte_pos(id, 4); + // Original predicate says yes (cursor at end-of-line) + assert_eq!(m.cursor_at_end_of_line(id), Some(true)); + // Stricter predicate says no (would be line-join) + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false) + ); + } + + #[test] + fn safe_for_delete_back_false_when_cursor_at_zero() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "abc")) + .expect("init"); + m.set_cursor_byte_pos(id, 0); + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false) + ); + } + + #[test] + fn safe_for_delete_back_false_mid_line() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello world")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false) + ); + } + + #[test] + fn cursor_at_end_of_line_is_none_when_buffer_not_ready() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 5); + assert!(m.cursor_at_end_of_line(id).is_none()); + } + + // ------------------------------------------------------------------ + // F20 (post-audit-round-3) — width-aware safe-for-delete-back + // predicate. The paint sequence `MoveLeft(1), Print(' '), + // MoveLeft(1)` is column-accurate only when the previous char + // renders to exactly one column. Wide chars, tabs, and zero- + // width combining marks break that invariant. + // ------------------------------------------------------------------ + + #[test] + fn safe_for_delete_back_false_for_wide_prev_char() { + // CJK ideograph 漢 has UnicodeWidthChar::width == Some(2). + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "漢")) + .expect("init"); + // "漢" is 3 UTF-8 bytes; cursor at end-of-line. + m.set_cursor_byte_pos(id, "漢".len()); + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false), + "F20: wide-char delete-back must not be optimistically painted" + ); + } + + #[test] + fn safe_for_delete_back_false_for_tab_prev_char() { + // Tab has UnicodeWidthChar::width == None — paint sequence + // can't account for variable tab expansion width. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "x\t")) + .expect("init"); + m.set_cursor_byte_pos(id, "x\t".len()); + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false), + "F20: tab delete-back must not be optimistically painted" + ); + } + + #[test] + fn safe_for_delete_back_false_for_combining_mark_prev_char() { + // "a" + U+0301 (COMBINING ACUTE ACCENT, width 0). The + // combining mark attaches to "a"'s cell; erasing it as if + // it were a width-1 cell would clear "a"'s glyph. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + let s = "a\u{0301}"; + m.init_from_snapshot(id, &fresh_snapshot(99, s)) + .expect("init"); + m.set_cursor_byte_pos(id, s.len()); + assert_eq!( + m.cursor_at_end_of_line_safe_for_delete_back(id), + Some(false), + "F20: combining-mark delete-back must not be optimistically painted" + ); + } + + // ------------------------------------------------------------------ + // F22 + F23 (post-audit-round-4) — mirror-cursor freshness + // invariant. The mirror cursor must round-trip after any event + // that may have desynced it from the daemon's authoritative + // cursor. + // ------------------------------------------------------------------ + + #[test] + fn cursor_starts_fresh_after_init() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + // Fresh by default; no CursorByte has arrived yet but no + // staleness-inducing event has happened either. + assert!(m.is_cursor_fresh(id)); + } + + #[test] + fn mark_cursor_stale_makes_fresh_false() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); + assert!(m.is_cursor_fresh(id)); + m.mark_cursor_stale(id); + assert!( + !m.is_cursor_fresh(id), + "F22: mark_cursor_stale must make is_cursor_fresh return false" + ); + } + + #[test] + fn set_cursor_byte_pos_clears_staleness() { + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); + m.mark_cursor_stale(id); + assert!(!m.is_cursor_fresh(id)); + // Daemon's authoritative CursorByte re-grounds the mirror. + m.set_cursor_byte_pos(id, 7); + assert!( + m.is_cursor_fresh(id), + "F22: a fresh CursorByte from the daemon must clear the stale flag" + ); + } + + #[test] + fn apply_remote_op_marks_cursor_stale() { + // F23: a remote CRDT op changes content; the mirror's cursor + // doesn't right-gravity-adjust, so it may now point at the + // wrong byte. Must be marked stale until the daemon's next + // CursorByte arrives. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "hello")) + .expect("init"); + m.set_cursor_byte_pos(id, 5); + assert!(m.is_cursor_fresh(id)); + + // Build a remote op against a peer-99 replica that inserts + // "X" at position 0. The export contains an update we can + // import into our mirror. + let peer = CrdtState::new(99).expect("peer"); + peer.import_snapshot(&fresh_snapshot(99, "hello")) + .expect("peer init"); + let v0 = peer.version(); + peer.insert(0, "X").expect("peer insert"); + let op_bytes = peer.export_updates_since(&v0).expect("export"); + + m.apply_remote_op(id, &op_bytes).expect("apply remote"); + assert!( + !m.is_cursor_fresh(id), + "F23: apply_remote_op must mark the cursor stale (right-gravity not done locally)" + ); + } + + #[test] + fn safe_for_delete_back_true_for_plain_ascii_prev_char() { + // Regression: width-1 ASCII is the typical case and must + // still return true. + let mut m = BufferMirror::new(FrontendId(2)); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, "abc")) + .expect("init"); + m.set_cursor_byte_pos(id, 3); + assert_eq!(m.cursor_at_end_of_line_safe_for_delete_back(id), Some(true)); + } +} diff --git a/src/crdt.rs b/src/crdt.rs new file mode 100644 index 0000000..d93683f --- /dev/null +++ b/src/crdt.rs @@ -0,0 +1,1327 @@ +//! T M10.2: CRDT-backed buffer state. +//! +//! This module implements the wrapper layer that mediates between the +//! editor's existing rope contract and the underlying CRDT library +//! (loro 1.12, selected in M10.1). +//! +//! # Architecture (per M10.1 Decision section, §sec:m10-crdt-choice) +//! +//! The CRDT lives on the main thread. Workers consume a *rope +//! projection* materialized from the CRDT state via the existing +//! [`crate::rope::Rope`] type and the existing +//! [`crate::buffer::Buffer::snapshot_rope`] API. Workers never see +//! the CRDT directly; the rope-projection redirect preserves the +//! v0.1 worker contract. +//! +//! Two cost-model paths: +//! +//! * **Hot path**: per-edit incremental projection updates. Each +//! CRDT op produces an [`crate::rope::Edit`] description that +//! updates the rope projection at O(log n) via the existing rope +//! edit path. This is the cost shape the editor already pays. +//! * **Cold path**: attach-time / catastrophic-divergence full +//! re-materialization. O(n); paid once per attach, dispatched to a +//! foreground worker for documents above ~1MB. +//! +//! # Feature gating +//! +//! The whole module is gated behind the `crdt` Cargo feature so v0.1 +//! builds carry zero CRDT overhead — the `loro` dependency isn't +//! pulled in, no field on the [`crate::buffer::Buffer`] struct +//! layout, no branch on `apply_edit`. v1.0 builds enable `crdt`. +//! +//! # Day 1 scope +//! +//! M10.2 Day 1 builds the minimal `CrdtState` wrapper: insert/delete +//! operations against a [`loro::LoroDoc`], a `from_bytes` constructor +//! that seeds a `CrdtState` from existing rope contents, and a +//! [`CrdtState::materialize_string`] projection extractor. Subsequent days wire this +//! into [`crate::buffer::Buffer`] and add the per-edit incremental +//! propagation, the optional `crdt_op` field on [`crate::rope::Edit`], +//! and the convergence proptest. + +use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, VersionVector}; + +/// The CRDT-backed buffer state. +/// +/// Owns a [`LoroDoc`] with a single text container (named `"body"`). +/// Edit operations route through the loro APIs; the editor consumes +/// projections via [`Self::materialize_string`] for the rope materialization. +/// +/// `Send`-but-not-`Sync`: a single-threaded owner (the main thread) +/// holds it; workers consume the rope projection, not the CRDT. The +/// `Send` bound is needed for the M10.2 Day 7 cold-path attach +/// (foreground worker materializes the initial projection). +pub struct CrdtState { + doc: LoroDoc, + /// T M10.4: per-peer undo machinery. Bound to `doc`'s `peer_id` + /// at construction; produces inverse ops attributed to that peer. + /// + /// Loro's `UndoManager`: + /// - Local-only: undoes the bound peer's most recent change, not + /// the document's most recent change (the M10.4 collaborative + /// semantics that "undo my edits, not theirs" is provided by + /// loro's underlying design, not by pmacs). + /// - Inverse ops interact with concurrent remote ops via the + /// CRDT's normal convergence rules — the M10.4 acceptance + /// criterion "B's edit lands on whatever surrounding text + /// remains" is loro's intrinsic behavior. + /// - Default max undo steps: 100. Pmacs raises this to `10_000` to + /// match v0.1's effectively-unbounded undo stack semantics. + /// + /// `UndoManager` is `!Send + !Sync` internally; `CrdtState` is + /// main-thread-only, matching the M10.1 rope-projection-redirect + /// constraint (workers consume the rope, not the CRDT). + undo: std::cell::RefCell, +} + +impl CrdtState { + /// Construct an empty CRDT state. + /// + /// The `peer_id` is the producing-frontend identity for ops this + /// state generates. M10.4 (per-frontend undo) consumes this as + /// the "is this op mine?" filter; M10.5 (wire protocol) embeds it + /// into broadcast `CrdtOp` messages. + pub fn new(peer_id: u64) -> LoroResult { + let doc = LoroDoc::new(); + doc.set_peer_id(peer_id)?; + // Ensure the "body" text container exists by creating the + // handle. Loro creates containers lazily on first access; the + // explicit get here ensures the container is registered before + // any read or write. + let _ = doc.get_text("body"); + let undo = Self::create_undo_manager(&doc); + Ok(Self { + doc, + undo: std::cell::RefCell::new(undo), + }) + } + + /// T M10.4: construct a fresh `UndoManager` bound to the given doc. + /// Extracted as a helper because `from_bytes` constructs it AFTER + /// the initial seed insert (so the seed isn't observable as an + /// undoable op), while `new` constructs it before any ops happen + /// (same effect). + fn create_undo_manager(doc: &LoroDoc) -> UndoManager { + let mut undo = UndoManager::new(doc); + // Raise max undo steps from loro's default 100 to 10_000. + // v0.1's undo stack is effectively unbounded; 10k is the + // realistic ceiling for human-driven editing and gives users + // ample undo depth without unreasonable memory cost. + undo.set_max_undo_steps(10_000); + undo + } + + /// Construct a CRDT state seeded with the given bytes. + /// + /// Used by the cold-path attach: an existing rope's contents are + /// loaded into a fresh CRDT state. The result has one initial op + /// (the bulk insert at byte 0) attributed to `peer_id`. + pub fn from_bytes(peer_id: u64, bytes: &[u8]) -> LoroResult { + let doc = LoroDoc::new(); + doc.set_peer_id(peer_id)?; + let _ = doc.get_text("body"); + if !bytes.is_empty() { + // Loro's text API takes &str; we accept `&[u8]` and decode + // as UTF-8 with replacement to match the existing rope's + // permissive byte-level semantics. Files that aren't valid + // UTF-8 round-trip via the projection but lose the + // ill-formed-byte detail; v0.1 has the same limitation + // for any operation that reads bytes as text. + let text = String::from_utf8_lossy(bytes); + doc.get_text("body").insert(0, &text)?; + } + // T M10.4: construct UndoManager AFTER the initial seed + // insert so the seed is not observable as an undoable op. + // The buffer's starting contents (from file load, scratch + // initial text, etc.) shouldn't be undoable from the user's + // perspective; only post-construction edits are. + let undo = Self::create_undo_manager(&doc); + Ok(Self { + doc, + undo: std::cell::RefCell::new(undo), + }) + } + + /// Return the producing-frontend identity for this state. + pub fn peer_id(&self) -> u64 { + self.doc.peer_id() + } + + /// Insert `s` at byte position `pos` in the body text. + /// + /// Routes to loro's byte-native `insert_utf8` per the M10.2 Day 2 + /// morning audit (Q1 verification). Mid-codepoint positions are + /// rejected with a clear error from loro; callers should ensure + /// `pos` aligns to a UTF-8 codepoint boundary. + pub fn insert(&self, pos: usize, s: &str) -> LoroResult<()> { + self.doc.get_text("body").insert_utf8(pos, s) + } + + /// Delete `len` bytes starting at byte position `pos`. Same + /// codepoint-boundary requirement as [`Self::insert`]. + pub fn delete(&self, pos: usize, len: usize) -> LoroResult<()> { + self.doc.get_text("body").delete_utf8(pos, len) + } + + /// Length of the body text in bytes (UTF-8 length). + pub fn len_utf8(&self) -> usize { + self.doc.get_text("body").len_utf8() + } + + /// Length of the body text in unicode code points. Used by tests + /// that assert on codepoint-vs-byte distinctions; production + /// callers should prefer [`Self::len_utf8`] which matches the + /// rope's byte-length contract. + pub fn len_unicode(&self) -> usize { + self.doc.get_text("body").len_unicode() + } + + /// Materialize the rope projection as a `String`. + /// + /// O(n). Day 1 ships the trivial projection (full string + /// extraction); Day 2's Buffer integration replaces this with the + /// per-edit-incremental path for hot-path edits and reserves + /// this method for the cold-path attach. + /// + /// Named `materialize_string` rather than `to_string` to avoid + /// shadowing the [`std::fmt::Display`]-derived `to_string` + /// (clippy `inherent_to_string`); semantics are explicit at the + /// call site (it's a projection materialization, not a display + /// conversion). + pub fn materialize_string(&self) -> String { + self.doc.get_text("body").to_string() + } + + /// T M10.2 Day 3: capture the current oplog frontier. + /// + /// Used as the `from` argument to a subsequent + /// [`Self::export_updates_since`] call to capture the wire bytes + /// for ops produced between the two version captures. Cheap; the + /// version vector is a small data structure that loro maintains + /// alongside its op log. + pub fn version(&self) -> VersionVector { + self.doc.oplog_vv() + } + + /// T M10.2 Day 3: export wire-format bytes for ops added since + /// `from`. + /// + /// Used by [`crate::buffer::Buffer`] to capture the per-edit op + /// delta that populates [`crate::rope::Edit::crdt_op`]. The bytes + /// are loro's incremental-update format; M10.5 (wire protocol) + /// sends them across the wire to remote frontends, which import + /// via [`Self::import_snapshot`] (or its updates-shaped variant). + /// + /// The capture-apply-export idiom is: + /// 1. `let pre = state.version();` + /// 2. apply ops via [`Self::insert`] / [`Self::delete`] / etc. + /// 3. `let bytes = state.export_updates_since(&pre)?;` + /// + /// Loro's transactional model gives a consistent before/after + /// pair: the version captured before the op does not include the + /// op's effect, and the export from that version captures + /// exactly the ops that were applied after the capture (the + /// per-edit delta the M10.2 Day 3 framing requires). + /// + /// Returns empty bytes if no ops have been applied since `from` + /// (no-op case detection is the caller's responsibility — empty + /// bytes still has loro's structural overhead, so byte-length + /// alone isn't a reliable empty-check; the caller pre-checks + /// the `EditOp` variants instead). + pub fn export_updates_since(&self, from: &VersionVector) -> Result, LoroEncodeError> { + self.doc.export(ExportMode::updates(from)) + } + + /// Export a wire-format snapshot of the entire CRDT state. + /// + /// Used by M10.5 wire-protocol serialization for full-state-sync + /// to reconnecting frontends. Loro's snapshot is run-encoded + /// (~0.8% of source at 1MB+ per the M10.1 measurements), so the + /// wire bandwidth cost is small. + pub fn export_snapshot(&self) -> Result, LoroEncodeError> { + self.doc.export(ExportMode::Snapshot) + } + + /// Import a wire-format snapshot into this state. + /// + /// Inverse of [`Self::export_snapshot`]. Used by M10.5 attach + /// flow: a frontend receives a snapshot from the instance and + /// constructs its local state from it. + pub fn import_snapshot(&self, bytes: &[u8]) -> LoroResult<()> { + self.doc.import(bytes).map(|_| ()) + } + + /// T M10.2 Day 4: import incremental update bytes from a remote + /// peer. + /// + /// Inverse of [`Self::export_updates_since`]. Used by M10.5's + /// wire-protocol layer when a frontend receives a `CrdtOp` + /// message broadcast by the instance — the bytes are the delta + /// the originating frontend produced; this method merges them + /// into the local CRDT. + /// + /// Loro's underlying `doc.import` accepts both full snapshots + /// AND incremental updates (the format is universal); the + /// separate method exists to make call sites self-documenting: + /// + /// * [`Self::import_snapshot`] — full-state replacement (attach + /// path); call site signals "I'm receiving the whole state." + /// * [`Self::import_updates`] — partial delta merge (per-edit + /// path); call site signals "I'm receiving incremental ops." + /// + /// Loro's CRDT semantics handle the merge: concurrent ops from + /// different peers converge regardless of import order, which is + /// the property M10.2 Day 4's convergence proptest verifies. + pub fn import_updates(&self, bytes: &[u8]) -> LoroResult<()> { + self.doc.import(bytes).map(|_| ()) + } + + /// T M10.10 post-audit-round-4 F26 — validate that importing + /// the wire bytes `bytes` would attribute every new op to + /// `expected_peer_id`. + /// + /// Imports into a forked clone so the live state isn't mutated. + /// Compares the oplog version vector before and after import: + /// every peer whose counter advances must be `expected_peer_id`, + /// or the bytes carry ops produced under a different identity + /// than the wire wrapper's `op.peer_id`. + /// + /// Returns: + /// - `Ok(())` — every advancing peer matches `expected_peer_id`, + /// OR the loro decoder rejected the bytes (the real import + /// will surface the same decode error to the caller; we don't + /// reject on validation grounds here). + /// - `Err(actual)` — the first peer whose counter advanced and + /// doesn't match `expected_peer_id`. The caller (the daemon's + /// `validate_remote_crdt_op`) treats this as a protocol + /// violation and drops the op. + /// + /// Cost: doubles the import cost. Loro's `fork()` is shallow; + /// the real import reapplies. Acceptable for v1.0 throughput; + /// v0.2+ may add a peek API to loro that avoids the second + /// import. + pub fn validate_update_peer_ids(&self, expected_peer_id: u64, bytes: &[u8]) -> Result<(), u64> { + let fork = self.doc.fork(); + let before = fork.oplog_vv(); + if fork.import(bytes).is_err() { + // Decode error: let the real import surface it. + return Ok(()); + } + let after = fork.oplog_vv(); + for (peer, after_counter) in after.iter() { + let before_counter = before.get(peer).copied().unwrap_or(0); + if *after_counter > before_counter && *peer != expected_peer_id { + return Err(*peer); + } + } + Ok(()) + } + + /// T M10.4: undo the bound peer's most recent change. + /// + /// Returns `true` if an undo was performed, `false` if there was + /// nothing to undo (the undo stack was empty). Inverse ops are + /// applied to the doc; the projection (`materialize_string`) + /// reflects the post-undo state immediately. Callers needing the + /// wire-format bytes for the inverse should use the + /// `version()` → `undo()` → `export_updates_since()` pattern, + /// mirroring the `apply_edit` path. + /// + /// Loro's `UndoManager`: + /// - Affects only the bound peer's ops; remote ops are unchanged + /// - Inverse interacts with concurrent remote ops via CRDT + /// convergence (M10.4 acceptance: "B's edit lands on + /// whatever surrounding text remains") + /// + /// `&self` (not `&mut self`) via interior mutability: the + /// underlying `UndoManager` needs `&mut` but pmacs's call sites + /// hold `CrdtState` by reference. `RefCell` gates this safely; + /// the main-thread-only constraint means there's no contention. + pub fn undo(&self) -> LoroResult { + self.undo.borrow_mut().undo() + } + + /// T M10.4: redo the most-recently-undone change by the bound peer. + /// + /// Symmetric to [`Self::undo`]. Returns `true` if a redo was + /// performed, `false` if the redo stack was empty. + pub fn redo(&self) -> LoroResult { + self.undo.borrow_mut().redo() + } + + /// T M10.4: whether the bound peer has anything to undo. + pub fn can_undo(&self) -> bool { + self.undo.borrow().can_undo() + } + + /// T M10.4: whether the bound peer has anything to redo. + pub fn can_redo(&self) -> bool { + self.undo.borrow().can_redo() + } + + /// T M10.4: record an undo checkpoint. + /// + /// Pmacs's `apply_edit` semantics is "each successful forward edit + /// is its own undo unit." Loro's `UndoManager` groups ops into + /// undo units by merge interval; default 0 means no merging, + /// which matches pmacs's per-edit semantics naturally. The + /// explicit checkpoint method is exposed for v0.2+ batch-op + /// coalescing (Day 7 mitigation B) where multiple CRDT ops + /// should group into one undo unit. + pub fn record_checkpoint(&self) -> LoroResult<()> { + self.undo.borrow_mut().record_new_checkpoint() + } +} + +/// T M10.3: map a [`crate::protocol::FrontendId`] to the loro `PeerID` +/// (u64) used as the producing-frontend identity in CRDT ops. +/// +/// The mapping is **identity**: `FrontendId(n)` maps to `n`. Both +/// types are `u64` by design (`FrontendId(pub u64)`, loro `PeerID` +/// is `u64`-wrapped). The single invariant the mapping must +/// preserve is non-zero: +/// +/// * `FrontendId::LOCAL = FrontendId(1)` — the v0.1 default; non-zero +/// * Multi-frontend allocations from M5's counter start above `LOCAL` +/// * Loro's `PeerID` is internally `NonZeroU64` (default feature) — zero +/// would panic at `LoroDoc::set_peer_id` +/// +/// The identity mapping preserves the non-zero invariant by +/// construction: every `FrontendId` produced by pmacs's frontend +/// machinery is non-zero, and `FrontendId(0)` is reserved as the +/// "no frontend" sentinel (not allocated to any real attach). The +/// mapping is a thin wrapper rather than implicit conversion so +/// call sites are explicit about which value-space they're in: +/// the wire-protocol layer uses `FrontendId`; the CRDT layer uses +/// `PeerID`; the boundary is this function. +/// +/// M10.4's per-frontend undo reads this back from `CrdtOp.peer_id` +/// to decide whose ops to undo; M10.5's wire protocol embeds it +/// in `InstanceMessage::CrdtOp` broadcasts. +#[must_use] +pub fn peer_id_from_frontend(frontend_id: crate::protocol::FrontendId) -> u64 { + // The non-zero invariant is a precondition; callers must not + // pass FrontendId(0). Debug-assertion guards the assumption; + // in release builds we trust callers and the identity mapping + // returns 0 which loro will reject with a clear error at the + // CrdtState::new boundary. + debug_assert!( + frontend_id.0 != 0, + "FrontendId(0) is the no-frontend sentinel; loro PeerID requires non-zero \ + (NonZeroU64 internally). Caller passed a sentinel-valued FrontendId, \ + which is not a real frontend identity." + ); + frontend_id.0 +} + +#[cfg(test)] +mod m10_3_tests { + use super::*; + use crate::protocol::FrontendId; + + #[test] + fn peer_id_from_frontend_is_identity_for_local() { + assert_eq!(peer_id_from_frontend(FrontendId::LOCAL), 1); + } + + #[test] + fn peer_id_from_frontend_is_identity_for_arbitrary_ids() { + for raw in [2u64, 3, 100, 1_000_000, u64::MAX] { + assert_eq!(peer_id_from_frontend(FrontendId(raw)), raw); + } + } + + #[test] + fn peer_id_from_frontend_round_trips_through_crdt_state() { + // The identity mapping is round-trip-safe: a CrdtState + // constructed with peer_id_from_frontend(fid) returns the + // same fid back when its peer_id is read. + let fid = FrontendId(42); + let state = CrdtState::new(peer_id_from_frontend(fid)).expect("new"); + assert_eq!(state.peer_id(), 42); + assert_eq!(state.peer_id(), fid.0); + } +} + +// --------------------------------------------------------------------------- +// Smoke tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_state_is_empty() { + let s = CrdtState::new(1).expect("new"); + assert_eq!(s.len_unicode(), 0); + assert_eq!(s.materialize_string(), ""); + assert_eq!(s.peer_id(), 1); + } + + #[test] + fn insert_round_trip_ascii() { + let s = CrdtState::new(1).expect("new"); + s.insert(0, "hello").expect("insert"); + assert_eq!(s.materialize_string(), "hello"); + s.insert(5, " world").expect("insert at end"); + assert_eq!(s.materialize_string(), "hello world"); + s.insert(5, ",").expect("insert in middle"); + assert_eq!(s.materialize_string(), "hello, world"); + } + + #[test] + fn delete_removes_range() { + let s = CrdtState::new(1).expect("new"); + s.insert(0, "hello, world").expect("seed"); + s.delete(5, 2).expect("delete ', '"); + assert_eq!(s.materialize_string(), "helloworld"); + } + + #[test] + fn from_bytes_seeds_initial_content() { + let s = CrdtState::from_bytes(42, b"the quick brown fox").expect("from_bytes"); + assert_eq!(s.materialize_string(), "the quick brown fox"); + assert_eq!(s.peer_id(), 42); + // Subsequent edits compose with the seed. + s.insert(19, " jumps").expect("append"); + assert_eq!(s.materialize_string(), "the quick brown fox jumps"); + } + + #[test] + fn from_bytes_empty_yields_empty_state() { + let s = CrdtState::from_bytes(7, b"").expect("from_bytes empty"); + assert_eq!(s.len_unicode(), 0); + assert_eq!(s.materialize_string(), ""); + } + + #[test] + fn snapshot_round_trips_through_export_import() { + let source = CrdtState::new(1).expect("source"); + source.insert(0, "snapshot me").expect("seed"); + let bytes = source.export_snapshot().expect("export"); + // Independent state on a different peer imports the bytes + // and observes the same content. + let restored = CrdtState::new(2).expect("restored"); + restored.import_snapshot(&bytes).expect("import"); + assert_eq!(restored.materialize_string(), "snapshot me"); + // Restored state can continue editing under its own peer ID. + restored.insert(11, "!").expect("post-restore edit"); + assert_eq!(restored.materialize_string(), "snapshot me!"); + } + + // ----------------------------------------------------------------- + // M10.2 Day 2 morning audit — Q1 sub-checks for byte-native loro. + // + // Findings recorded here so a future loro upgrade or wrapper change + // doesn't silently shift the byte/unicode boundary semantics. Loro + // 1.12 provides parallel utf8-suffixed methods (`insert_utf8`, + // `delete_utf8`, `mark_utf8`) for byte-native operation rather + // than a global `OffsetKind` config. The default `insert` / `delete` + // are unicode-native; we use the `_utf8` variants exclusively in + // the wrapper to match pmacs's byte-position rope API. + // + // Test string: "héllo" — h=0x68 (1B), é=0xC3 0xA9 (2B), l=0x6C + // (1B), l=0x6C (1B), o=0x6F (1B). Total 6 bytes, 5 codepoints. + + #[test] + fn q1_sub_check_1_byte_native_insertion() { + // We don't (yet) expose `insert_utf8` on `CrdtState` — Day 2's + // afternoon adds it. This test pins the underlying loro + // behaviour we'll wrap. + let state = CrdtState::new(1).expect("new"); + // Using the underlying loro doc directly to verify the byte- + // native method exists and behaves as expected. The wrapper + // method (`insert_bytes` / similar) will route to this. + let text = state.doc.get_text("body"); + text.insert_utf8(0, "héllo").expect("seed"); + // Insert "X" at byte offset 3 (after "hé"). Byte-native: + // result is "héXllo". Unicode-native: would be "hélXlo". + text.insert_utf8(3, "X").expect("byte-native insert"); + let s = text.to_string(); + assert_eq!( + s, "héXllo", + "loro insert_utf8 must be byte-native (got {s:?})" + ); + } + + #[test] + fn q1_sub_check_2_byte_native_deletion() { + let state = CrdtState::new(1).expect("new"); + let text = state.doc.get_text("body"); + text.insert_utf8(0, "héllo world").expect("seed"); + // Delete bytes [1, 3) — the é (2-byte codepoint). Byte-native: + // "hllo world". Unicode-native (codepoints [1, 3) = "él"): + // "hlo world". The two interpretations differ unambiguously. + text.delete_utf8(1, 2).expect("byte-native delete"); + let s = text.to_string(); + assert_eq!(s, "hllo world", "loro delete_utf8 must be byte-native"); + } + + #[test] + fn q1_sub_check_3_mid_codepoint_rejection_on_delete() { + // Trying to delete a range that splits a multi-byte codepoint + // must fail rather than silently corrupt. The é at bytes 1..3: + // delete_utf8(2, 1) starts mid-codepoint and should error. + let state = CrdtState::new(1).expect("new"); + let text = state.doc.get_text("body"); + text.insert_utf8(0, "héllo").expect("seed"); + let r = text.delete_utf8(2, 1); + assert!( + r.is_err(), + "delete_utf8 with mid-codepoint range must reject (got {r:?})" + ); + // The buffer should be unchanged after the rejected delete. + assert_eq!(text.to_string(), "héllo"); + } + + #[test] + fn q1_sub_check_3_mid_codepoint_on_insert() { + // insert_utf8 at a mid-codepoint position. Behaviour pinned; + // either reject or split-with-replacement. The wrapper layer + // can normalize whichever it is to a uniform error shape, but + // we need to know which one loro picks. + let state = CrdtState::new(1).expect("new"); + let text = state.doc.get_text("body"); + text.insert_utf8(0, "héllo").expect("seed"); + let r = text.insert_utf8(2, "X"); + match r { + Ok(()) => { + let s = text.to_string(); + // Document the observed behaviour rather than asserting + // a specific one — the wrapper layer adapts. + eprintln!("loro insert_utf8 at mid-codepoint accepted: result = {s:?}"); + // Sanity: result must not be UB-shaped; should still + // be valid UTF-8 and contain at least the inserted X. + assert!(s.contains('X')); + } + Err(e) => { + eprintln!("loro insert_utf8 at mid-codepoint rejected: {e}"); + assert_eq!(text.to_string(), "héllo"); + } + } + } + + #[test] + fn q1_sub_check_cursor_position_default_kind() { + // get_cursor takes a unicode position by default and + // get_cursor_pos returns AbsolutePosition.pos in some kind. + // We need to know which kind so the wrapper can route + // correctly. + use loro::cursor::Side; + let state = CrdtState::new(1).expect("new"); + let text = state.doc.get_text("body"); + text.insert_utf8(0, "héllo").expect("seed"); + // Place a cursor with input pos=2 (unicode). Multi-byte case + // makes the byte/unicode answer differ: + // unicode pos 2 = after "hé" (2 codepoints in) + // byte pos 2 = mid-é (between é's two bytes — illegal but + // informative if loro uses it) + let cursor = text.get_cursor(2, Side::Middle).expect("cursor"); + let pos = state.doc.get_cursor_pos(&cursor).expect("query"); + eprintln!( + "get_cursor(2, Middle) on \"héllo\" -> current.pos = {} (unicode 2 = after 'hé', byte 2 = mid-é)", + pos.current.pos + ); + // Loro's default get_cursor / get_cursor_pos use unicode + // positions per the docs; the wrapper layer will translate + // when exposing cursor positions. Pin the expectation: + assert_eq!( + pos.current.pos, 2, + "loro cursor positions are unicode by default (input pos round-trips)" + ); + } + + #[test] + fn from_bytes_handles_non_utf8_via_replacement() { + // The byte 0xFF is not valid UTF-8. The editor's rope is + // permissive at the byte level; the CRDT-backed mode loses + // the ill-formed-byte detail (replaced with U+FFFD) per the + // documented from_bytes caveat. Pin the behaviour. + let s = CrdtState::from_bytes(1, b"ok\xFFok").expect("from_bytes"); + // The replacement-character round-trip preserves length-3 + // structure: "ok" + U+FFFD + "ok" — verify text. + assert!(s.materialize_string().contains("ok")); + assert!(s.materialize_string().contains('\u{FFFD}')); + } + + // ----------------------------------------------------------------- + // T M10.2 Day 4 — multi-peer convergence smoke tests. + // + // Targeted tests for the canonical CRDT convergence claim: + // concurrent ops from N peers, when merged in any order, produce + // identical final states on every peer. The proptest version is + // in `crdt::proptests` below; these smoke tests verify the + // smallest interesting cases first so a proptest failure is + // easier to debug. + // + // Per the Q2 design: `import_updates` is the per-edit-delta + // path (Day 4 + M10.5); `import_snapshot` is the full-state + // attach path. Both wrap loro's universal `doc.import`. + // ----------------------------------------------------------------- + + /// Helper: peer-to-peer sync. Each peer captures its updates since + /// the empty version vector (which is "all my updates") and the + /// other peer imports them. After this call, both peers should be + /// converged. + #[cfg(test)] + fn sync_pair(a: &CrdtState, b: &CrdtState) { + use loro::VersionVector; + let zero = VersionVector::default(); + let a_updates = a.export_updates_since(&zero).expect("a export"); + let b_updates = b.export_updates_since(&zero).expect("b export"); + a.import_updates(&b_updates).expect("a import b"); + b.import_updates(&a_updates).expect("b import a"); + } + + #[test] + fn two_peers_converge_with_concurrent_inserts_at_zero() { + // Both peers start empty. + // Peer A inserts "AAA" at position 0. + // Peer B inserts "BBB" at position 0. + // After bidirectional sync, both peers must hold the same + // final string. Loro's tie-breaking yields one of "AAABBB" + // or "BBBAAA" deterministically by peer ID; the test asserts + // the peers agree, not a specific outcome. + let a = CrdtState::new(1).expect("peer a"); + let b = CrdtState::new(2).expect("peer b"); + a.insert(0, "AAA").expect("a insert"); + b.insert(0, "BBB").expect("b insert"); + + let pre_a = a.materialize_string(); + let pre_b = b.materialize_string(); + assert_eq!(pre_a, "AAA", "peer A sees only its own op pre-sync"); + assert_eq!(pre_b, "BBB", "peer B sees only its own op pre-sync"); + + sync_pair(&a, &b); + + let post_a = a.materialize_string(); + let post_b = b.materialize_string(); + assert_eq!( + post_a, post_b, + "peers must converge after bidirectional sync" + ); + // Sanity: both edits are represented in the converged state. + assert!(post_a.contains("AAA")); + assert!(post_a.contains("BBB")); + assert_eq!(post_a.len(), 6, "no duplication, no loss"); + } + + #[test] + fn two_peers_converge_independent_of_import_order() { + use loro::VersionVector; + // Apply identical ops to two peer pairs but in different + // import orders; the converged states must match. This is + // the smallest "different sync ordering, same final state" + // smoke test — the canonical CRDT property. + let make_pair = || { + let p1 = CrdtState::new(1).unwrap(); + let p2 = CrdtState::new(2).unwrap(); + p1.insert(0, "hello").unwrap(); + p2.insert(0, "world").unwrap(); + (p1, p2) + }; + + // Ordering 1: peer 1 imports first, then peer 2. + let (a1, a2) = make_pair(); + let zero = VersionVector::default(); + let a2_bytes = a2.export_updates_since(&zero).unwrap(); + a1.import_updates(&a2_bytes).unwrap(); + let a1_bytes = a1.export_updates_since(&zero).unwrap(); + a2.import_updates(&a1_bytes).unwrap(); + + // Ordering 2: peer 2 imports first, then peer 1. + let (b1, b2) = make_pair(); + let b1_bytes = b1.export_updates_since(&zero).unwrap(); + b2.import_updates(&b1_bytes).unwrap(); + let b2_bytes = b2.export_updates_since(&zero).unwrap(); + b1.import_updates(&b2_bytes).unwrap(); + + // Both orderings produce the same final state across all peers. + let final_state = a1.materialize_string(); + assert_eq!(a1.materialize_string(), final_state); + assert_eq!(a2.materialize_string(), final_state); + assert_eq!(b1.materialize_string(), final_state); + assert_eq!(b2.materialize_string(), final_state); + } + + // ----------------------------------------------------------------- + // T M10.2 Day 4 — convergence proptest. + // + // Generates N peers (2-4), each with a random sequence of + // local ops, applied independently (each peer sees only its own + // ops until sync). After full sync via one of five hand-picked + // patterns, all peers must converge to the same projection + // string. + // + // Five sync orderings: + // * Sequential — peer-0 → peer-1 → peer-2 → ... (each peer + // receives every prior peer's updates) + // * Star — peer-0 receives all others' updates, then + // broadcasts the combined state + // * Pairwise — peer-0 syncs with peer-1, then both sync + // with peer-2, etc. + // * DelayedJoin — first half of peers sync each other; second + // half joins later and pulls combined state + // * Reverse — like Sequential but in reverse peer order + // + // Hand-picked patterns beat random permutations here: realistic + // multi-frontend topologies have structural meaning that uniform + // random permutation dilutes. Loro's convergence is proved + // upstream; what's being tested is the wrapper's correctness + // under realistic patterns. + // ----------------------------------------------------------------- + mod proptests { + use super::*; + use loro::VersionVector; + use proptest::prelude::*; + + const ALPHABET: &[&str] = &["a", "b", "c", " ", "\n"]; + + #[derive(Clone, Debug)] + enum PeerOp { + Insert(usize, String), + Delete(usize, usize), + Replace(usize, usize, String), + } + + #[derive(Clone, Debug)] + enum SyncPattern { + Sequential, + Star, + Pairwise, + DelayedJoin, + Reverse, + } + + fn gen_payload() -> impl Strategy { + prop::collection::vec(prop::sample::select(ALPHABET.to_vec()), 1..6) + .prop_map(|parts| parts.concat()) + } + + fn gen_op() -> impl Strategy { + prop_oneof![ + 3 => (any::(), gen_payload()).prop_map(|(p, s)| PeerOp::Insert(p as usize, s)), + 2 => (any::(), any::()).prop_map(|(p, l)| PeerOp::Delete(p as usize, l as usize)), + 1 => (any::(), any::(), gen_payload()) + .prop_map(|(p, l, s)| PeerOp::Replace(p as usize, l as usize, s)), + ] + } + + /// Apply an op to a peer, clamping positions to the peer's + /// local state at op-generation time. The op's recorded + /// position refers to that state; loro's CRDT handles + /// translation when the op is applied on a peer with + /// different local state. We're testing that this translation + /// produces convergent results, not that loro rejects + /// invalid positions — so the clamp ensures the generated + /// ops are *valid for the local peer*; convergence handles + /// the rest. + fn apply_to_peer(peer: &CrdtState, op: &PeerOp) -> Result<(), loro::LoroError> { + let len = peer.len_utf8(); + match op { + PeerOp::Insert(pos, s) => { + let pos = (*pos).min(len); + peer.insert(pos, s) + } + PeerOp::Delete(pos, l) => { + let pos = (*pos).min(len); + let l = (*l).min(len.saturating_sub(pos)); + if l == 0 { + return Ok(()); + } + peer.delete(pos, l) + } + PeerOp::Replace(pos, l, s) => { + let pos = (*pos).min(len); + let l = (*l).min(len.saturating_sub(pos)); + if l > 0 { + peer.delete(pos, l)?; + } + peer.insert(pos, s) + } + } + } + + /// Run a sync pattern against a list of peers. After this + /// call, every peer must hold the same converged state. + /// + /// Each pattern is implemented as a sequence of pairwise + /// imports: each invocation transfers one peer's complete + /// update set (since the empty version vector) to another + /// peer. Loro's import handles concurrent merges via the + /// underlying CRDT semantics; this driver just orchestrates + /// who-imports-from-whom in the chosen topology. + /// + /// The explicit `for i in 0..n / for j in 0..n` index loops + /// (vs `.iter().enumerate()`) make the topology semantics + /// readable: "every peer imports from every other peer" is + /// a 2D index pattern, not a transformation chain. + #[allow( + clippy::needless_range_loop, + reason = "index loops express topology more clearly than iterator chains for the i != j cross-product pattern" + )] + fn run_sync(peers: &[CrdtState], pattern: &SyncPattern) { + let zero = VersionVector::default(); + let n = peers.len(); + let exports: Vec> = peers + .iter() + .map(|p| p.export_updates_since(&zero).expect("export")) + .collect(); + match pattern { + SyncPattern::Sequential => { + // Each peer i imports from every prior peer 0..i, + // and forward-shares to peer i+1. + for i in 0..n { + for j in 0..n { + if i != j { + peers[i].import_updates(&exports[j]).expect("import"); + } + } + } + } + SyncPattern::Reverse => { + // Same as sequential but processed in reverse. + for i in (0..n).rev() { + for j in (0..n).rev() { + if i != j { + peers[i].import_updates(&exports[j]).expect("import"); + } + } + } + } + SyncPattern::Star => { + // peer 0 collects from all; then re-exports to all. + for j in 1..n { + peers[0].import_updates(&exports[j]).expect("import"); + } + let combined = peers[0].export_updates_since(&zero).expect("re-export"); + for i in 1..n { + peers[i].import_updates(&combined).expect("import"); + } + } + SyncPattern::Pairwise => { + // Peer 0 syncs with 1, then (0,1) with 2, etc. + // Each step pulls the cumulative state to the + // joining peer. + for i in 1..n { + let combined = peers[i - 1] + .export_updates_since(&zero) + .expect("export combined"); + peers[i].import_updates(&combined).expect("import"); + // Reverse direction so the older peers also + // see the new one's ops. + let back = peers[i].export_updates_since(&zero).expect("back-export"); + for j in 0..i { + peers[j].import_updates(&back).expect("back-import"); + } + } + } + SyncPattern::DelayedJoin => { + // First half syncs each other; second half joins + // later and pulls combined state. + let half = n / 2; + if half >= 2 { + for i in 0..half { + for j in 0..half { + if i != j { + peers[i].import_updates(&exports[j]).expect("first-half"); + } + } + } + } + let combined = peers[0] + .export_updates_since(&zero) + .expect("first-half combined"); + for i in half..n { + peers[i].import_updates(&combined).expect("delayed import"); + // Late joiners also share with the first half. + let late = peers[i].export_updates_since(&zero).expect("late export"); + for j in 0..half { + peers[j].import_updates(&late).expect("late-receive"); + } + } + // Final round: everyone imports everyone (idempotent + // for already-merged ops). + let final_exports: Vec> = peers + .iter() + .map(|p| p.export_updates_since(&zero).expect("final-export")) + .collect(); + for i in 0..n { + for j in 0..n { + if i != j { + peers[i].import_updates(&final_exports[j]).expect("final"); + } + } + } + } + } + } + + proptest! { + // 32 cases for CI; bump locally (e.g. 256+) when validating + // before declaring Day 4 done. 32 cases passing means "no + // failure surfaced in this sample," not exhaustive proof. + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn peers_converge_under_arbitrary_sync_order( + peer_count in 2usize..=4, + op_seqs in prop::collection::vec( + prop::collection::vec(gen_op(), 1..=15), + 2..=4, + ), + sync_pattern in prop::sample::select(vec![ + SyncPattern::Sequential, + SyncPattern::Reverse, + SyncPattern::Star, + SyncPattern::Pairwise, + SyncPattern::DelayedJoin, + ]), + ) { + // The strategy may generate more op-seqs than peer_count; + // truncate to peer_count so the sizes match. + let op_seqs: Vec<_> = op_seqs.into_iter().take(peer_count).collect(); + // The strategy guarantees at least 2 op-seqs (vec size + // bound), so peer_count<=op_seqs.len() may not hold if + // peer_count is 4 but the inner vec generated only 2. + // Re-derive the actual peer count from op_seqs.len(). + let actual_peer_count = op_seqs.len(); + + // Create N peers with unique peer IDs (loro requires + // NonZeroU64, so we use 1..=N). + let peers: Vec = (1..=actual_peer_count as u64) + .map(|id| CrdtState::new(id).expect("peer")) + .collect(); + + // Each peer applies its own op sequence in isolation + // (no cross-peer visibility yet). Some ops may be + // benign no-ops after clamping; that's fine — the + // convergence claim holds across any valid op set. + for (peer, ops) in peers.iter().zip(op_seqs.iter()) { + for op in ops { + let _ = apply_to_peer(peer, op); + } + } + + // Capture pre-sync states for diagnostic output on + // failure. The shrinker output without these is hard + // to interpret; with them, the wrapper bug (if any) + // is visible. + let pre_sync: Vec = peers + .iter() + .map(CrdtState::materialize_string) + .collect(); + // Capture logical version vectors (not byte encodings) + // — encoding may not canonicalize entry order, so + // byte-comparison would false-positive divergence; + // VersionVector has PartialEq that compares logical + // content. + let pre_versions: Vec<_> = peers.iter().map(CrdtState::version).collect(); + + run_sync(&peers, &sync_pattern); + + let post_sync: Vec = peers + .iter() + .map(CrdtState::materialize_string) + .collect(); + let post_versions: Vec<_> = peers.iter().map(CrdtState::version).collect(); + + // Convergence: all peers' projections match peer 0's. + for i in 1..actual_peer_count { + prop_assert_eq!( + &post_sync[0], + &post_sync[i], + "peers diverged after {:?} sync\n \ + peer 0 ops: {:?}\n \ + peer {} ops: {:?}\n \ + peer 0 pre-sync: {:?}\n \ + peer {} pre-sync: {:?}\n \ + peer 0 post-sync: {:?}\n \ + peer {} post-sync: {:?}\n \ + peer 0 pre-version: {:?}\n \ + peer {} pre-version: {:?}\n \ + peer 0 post-version: {:?}\n \ + peer {} post-version: {:?}", + sync_pattern, + op_seqs[0], + i, op_seqs[i], + pre_sync[0], + i, pre_sync[i], + post_sync[0], + i, post_sync[i], + pre_versions[0], + i, pre_versions[i], + post_versions[0], + i, post_versions[i] + ); + } + + // Also assert all peers' version vectors are identical + // post-sync. This is stronger than projection-string + // equality: if peers agree on the string but disagree + // on the version vector, future remote ops might cause + // them to diverge. Loro's CRDT contract guarantees + // both; pin both. + for i in 1..actual_peer_count { + prop_assert_eq!( + &post_versions[0], + &post_versions[i], + "peer version vectors diverged" + ); + } + + // Avoid the unused-variable warning when peer_count is + // referenced only via op_seqs.len(). + let _ = peer_count; + } + } + } + + // ----------------------------------------------------------------- + // T M10.4 — per-frontend undo acceptance tests. + // + // Five tests covering the spec's three criteria plus two extras + // surfaced during the framing pass (concurrent-without-sync, + // region-overlap): + // + // 1. (covered elsewhere) Single-frontend identical to v0.1 + // — dual-mode buffer tests already verify this + // 2. Concurrent inserts with intervening sync: A inserts / sync + // / B inserts / sync / A undoes / B's insert remains + // 3. Concurrent inserts WITHOUT intervening sync: A inserts / + // B inserts (both unaware) / sync both ways / A undoes / + // B's insert remains + // 4. B edits A's region: A inserts / sync / B edits within / + // sync / A undoes A's insert / verify loro's region- + // overlap behavior + // 5. Redo symmetry: each pattern with redo applied, state + // returns to pre-undo + // ----------------------------------------------------------------- + + #[test] + fn m10_4_concurrent_with_sync_a_undoes_b_remains() { + use loro::VersionVector; + // A inserts / sync / B inserts / sync / A undoes / verify + // B's insert remains, A's insert is gone. + let a = CrdtState::new(1).expect("A"); + let b = CrdtState::new(2).expect("B"); + + // Round 1: A inserts "AA" at 0. + a.insert(0, "AA").expect("A insert"); + // Sync A → B. + let zero = VersionVector::default(); + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "AA"); + + // Round 2: B inserts "BB" at end (position 2). + b.insert(b.len_utf8(), "BB").expect("B insert"); + // Sync B → A. + a.import_updates(&b.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(a.materialize_string(), "AABB"); + assert_eq!(b.materialize_string(), "AABB"); + + // A undoes its own insert. B's "BB" must remain. + let undid = a.undo().expect("A undo"); + assert!(undid); + let after_undo = a.materialize_string(); + assert_eq!( + after_undo, "BB", + "A's undo must remove only A's insert; B's must remain" + ); + + // Sync A's undo back to B; B's projection must match A's. + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "BB"); + } + + #[test] + fn m10_4_concurrent_without_sync_a_undoes_b_remains() { + use loro::VersionVector; + // A inserts / B inserts (both unaware of each other) / sync + // bidirectionally / A undoes / verify B's insert remains. + let a = CrdtState::new(1).expect("A"); + let b = CrdtState::new(2).expect("B"); + + // Both peers insert concurrently with no intervening sync. + a.insert(0, "AAA").expect("A insert"); + b.insert(0, "BBB").expect("B insert"); + + // Now sync bidirectionally. + let zero = VersionVector::default(); + let a_bytes = a.export_updates_since(&zero).unwrap(); + let b_bytes = b.export_updates_since(&zero).unwrap(); + a.import_updates(&b_bytes).unwrap(); + b.import_updates(&a_bytes).unwrap(); + + // Converged state contains both edits. + assert_eq!(a.materialize_string(), b.materialize_string()); + let converged = a.materialize_string(); + assert!(converged.contains("AAA")); + assert!(converged.contains("BBB")); + assert_eq!(converged.len(), 6); + + // A undoes its insert. B's "BBB" must remain. + a.undo().expect("A undo"); + let after_undo = a.materialize_string(); + assert_eq!( + after_undo, "BBB", + "A's undo removes A's insert; B's BBB remains regardless of sync order" + ); + + // Sync undo to B; convergence holds. + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "BBB"); + } + + #[test] + fn m10_4_b_edits_a_region_then_a_undoes() { + use loro::VersionVector; + // A inserts "hello" / sync / B edits within (inserts "X" at + // position 2, between "he" and "llo") / sync / A undoes / + // verify loro's behavior for region-overlap undo. + // + // Loro's UndoManager produces an inverse op that removes the + // bytes A originally inserted. B's "X" insert was at a + // position WITHIN A's "hello" block; after A's undo, what + // happens to B's "X"? + // + // CRDT semantics: B's "X" insert referenced A's "hello" block + // structurally (insert-between-codepoints). When A's hello is + // removed, B's X has no anchor — but it persists because + // loro's tombstone preserves the insertion point. + // + // This test PINS whatever behavior loro produces; the spec + // language "B's edit lands on whatever surrounding text + // remains" is loro's intrinsic behavior, not ours to design. + let a = CrdtState::new(1).expect("A"); + let b = CrdtState::new(2).expect("B"); + + a.insert(0, "hello").expect("A insert"); + let zero = VersionVector::default(); + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "hello"); + + // B inserts "X" at position 2 ("he" + "X" + "llo" -> "heXllo"). + b.insert(2, "X").expect("B insert in middle"); + a.import_updates(&b.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(a.materialize_string(), "heXllo"); + assert_eq!(b.materialize_string(), "heXllo"); + + // A undoes "hello". B's "X" remains; the surrounding "hello" + // bytes attributed to A are removed. + a.undo().expect("A undo"); + let after_undo = a.materialize_string(); + assert_eq!( + after_undo, "X", + "A's undo removes A's hello; B's X remains on surrounding text \ + (which is now empty)" + ); + + // Sync to B; convergence holds. + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "X"); + } + + #[test] + fn m10_4_redo_symmetric_after_undo() { + use loro::VersionVector; + // Verify redo correctly reverses undo across the multi-peer + // patterns. Same setup as test 2 (concurrent-with-sync); A + // undoes / verifies / A redoes / verifies state returns to + // pre-undo. + let a = CrdtState::new(1).expect("A"); + let b = CrdtState::new(2).expect("B"); + a.insert(0, "AA").expect("A insert"); + let zero = VersionVector::default(); + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + b.insert(b.len_utf8(), "BB").expect("B insert"); + a.import_updates(&b.export_updates_since(&zero).unwrap()) + .unwrap(); + let pre_undo = a.materialize_string(); + assert_eq!(pre_undo, "AABB"); + + a.undo().expect("undo"); + assert_eq!(a.materialize_string(), "BB"); + + a.redo().expect("redo"); + assert_eq!( + a.materialize_string(), + pre_undo, + "redo must restore pre-undo state across multi-peer scenarios" + ); + } + + #[test] + fn m10_4_undo_is_local_only_across_peers() { + // Verify that A.undo() doesn't affect B's local view. + // (B's view only changes when A's inverse op is synced to B + // via import_updates.) Pins the "local-only" undo semantics + // loro's UndoManager promises. + use loro::VersionVector; + let a = CrdtState::new(1).expect("A"); + let b = CrdtState::new(2).expect("B"); + a.insert(0, "hello").expect("A insert"); + let zero = VersionVector::default(); + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), "hello"); + + // A undoes. B's view is NOT updated until B explicitly imports. + a.undo().expect("A undo"); + assert_eq!(a.materialize_string(), ""); + assert_eq!( + b.materialize_string(), + "hello", + "B's view unchanged until B imports A's inverse op" + ); + + // Now B imports; convergence. + b.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + assert_eq!(b.materialize_string(), ""); + } + + #[test] + fn delayed_join_peer_converges_with_others() { + use loro::VersionVector; + // The cold-attach pattern: peers A and B exchange ops over + // time, then peer C joins much later and syncs from both. + // After C catches up, all three peers must converge. + let a = CrdtState::new(1).expect("peer a"); + let b = CrdtState::new(2).expect("peer b"); + + // Round 1: a and b exchange. + a.insert(0, "round1-a ").unwrap(); + b.insert(0, "round1-b ").unwrap(); + sync_pair(&a, &b); + + // Round 2: more ops, more exchange. + let a_len = a.len_utf8(); + let b_len = b.len_utf8(); + a.insert(a_len, "round2-a ").unwrap(); + b.insert(b_len, "round2-b ").unwrap(); + sync_pair(&a, &b); + + // Now peer C joins, importing from both a and b. + let c = CrdtState::new(3).expect("peer c"); + let zero = VersionVector::default(); + c.import_updates(&a.export_updates_since(&zero).unwrap()) + .unwrap(); + c.import_updates(&b.export_updates_since(&zero).unwrap()) + .unwrap(); + + // C's state matches a's and b's. All three converged. + assert_eq!(c.materialize_string(), a.materialize_string()); + assert_eq!(c.materialize_string(), b.materialize_string()); + } +} diff --git a/src/daemon.rs b/src/daemon.rs index 4e44c5f..e6fc7e0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -48,8 +48,8 @@ //! shutdown to the outer accept loop. //! - Write fails (broken pipe) → return; ungraceful disconnect. +use std::collections::HashMap; use std::io::ErrorKind; -use std::net::Shutdown; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -61,14 +61,49 @@ use crate::cell::CellSize; use crate::editor::EditorState; use crate::instance_render::RenderState; use crate::lockfile::{self, LockError, LockHandle}; +use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities, - InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, + InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SelectionSnapshot, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; +/// T M10.8 — events the dispatcher thread processes. +/// +/// The dispatcher is the single thread that owns the editor; all +/// inputs from attached frontends arrive via this channel. Accept +/// thread + per-attach threads push variants here; dispatcher +/// consumes them in FIFO order. +enum DispatcherEvent { + /// A new connection finished handshake successfully and is now + /// ready to participate. The dispatcher registers the session, + /// allocates a per-frontend `RenderState`, and stores the + /// write-half of the stream so subsequent frames + broadcasts + /// can be sent to this frontend. + SessionEstablished { + frontend_id: FrontendId, + session_state: crate::presence::SessionState, + initial_size: CellSize, + /// Write-half of the per-attach stream. The dispatcher owns + /// this end; the per-attach reader thread keeps the + /// read-half via `try_clone`. + write_stream: UnixStream, + }, + /// An attached frontend dispatched an event (key, mouse, resize, + /// etc.). The dispatcher applies it to the editor and renders + /// the resulting frame(s). + FrontendEvent { + source: FrontendId, + event: FrontendEvent, + }, + /// An attached frontend's connection closed (EOF, decode error, + /// or explicit `FrontendEvent::Detach`). The dispatcher + /// unregisters the session and drops the write stream. + SessionDetached { frontend_id: FrontendId }, +} + /// Errors that abort the daemon's startup or main loop. #[derive(Debug)] pub enum DaemonError { @@ -133,21 +168,130 @@ struct DaemonState { instance_name: Option, started: Instant, next_frontend_id: AtomicU64, - /// `true` while a frontend is attached. v0.1 single-frontend - /// invariant: a second attach attempt sees this set and is - /// rejected with `Goodbye(AlreadyAttached)`. - attached: AtomicBool, + /// T M10.8 Day 4 — count of currently-attached **non-multi** + /// sessions per Q5. A non-multi attach is rejected with + /// `Goodbye(AlreadyAttached)` iff this count is `> 0` at attach + /// time. Multi sessions don't touch this counter and aren't + /// gated by it. CAS-incremented during handshake; decremented + /// via [`NonMultiSlotGuard`] on per-attach exit (normal or + /// panic). + /// + /// Semantically degenerate (0 or 1 in v1.0) but kept as + /// `AtomicU64` for future-proofing against v0.2+ scenarios that + /// might allow multiple non-multi sessions. + non_multi_session_count: AtomicU64, + /// T M10.9 — per-uid color slot registry. Maps Unix uid to + /// color palette index (0..[`crate::overlay_color::PALETTE_LEN`]). + /// First attach from a new uid gets the next available slot; + /// subsequent attaches from the same uid reuse that slot. + /// Stable across reconnect within a daemon-process lifetime — + /// satisfies the spec's "stable across reconnect (within a + /// session)" criterion for the same-uid case. + /// + /// Cross-uid color collisions (two users sharing a uid → same + /// color) are v0.2+ user-identity refinement. Two distinct uids + /// with hash collision on the palette also share a color, which + /// is the same shape as cross-uid collapse. + color_registry: std::sync::Mutex>, + /// T M10.10 Day 4 — test-only latency injection for + /// `CellDelta` emission. Read once at daemon startup from + /// `PMACS_INSTANCE_LATENCY_MS`. When `> 0`, the dispatcher + /// sleeps this many milliseconds before each `CellDelta` write + /// to a stream, simulating slow daemon→frontend transport. + /// + /// Used exclusively by the criterion 1 ("less than one frame + /// regardless of instance latency") acceptance tests and the + /// V0.2-PREREQUISITES.md baseline measurements. Production + /// daemons leave this at 0; tests set the env var via + /// `TestDaemon::spawn_with_env`. + /// + /// **Scope: dispatcher-wide, not per-frontend.** The sleep + /// fires in the dispatcher loop's per-tick render write path. + /// Multi-frontend tests at injected latency conflate frontends + /// (all see the same delay). Criterion 1's test uses a single + /// replica frontend so this conflation doesn't affect the + /// signal. v0.2+ work on per-frontend latency injection would + /// move the sleep into a per-frontend writer thread. + injected_render_latency_ms: u64, +} + +/// T M10.8 Day 4 — RAII guard for the non-multi-session slot. +/// +/// Acquired via [`NonMultiSlotGuard::try_acquire`] at per-attach +/// handshake time; releases the slot on drop (whether the +/// per-attach thread exits normally or panics). Holds an `Arc` so +/// the guard doesn't borrow from a reference whose lifetime might +/// not outlive the slot. +struct NonMultiSlotGuard { + daemon_state: Arc, +} + +impl NonMultiSlotGuard { + /// Try to acquire the single non-multi session slot. Returns + /// `None` if another non-multi session is already attached. + fn try_acquire(daemon_state: Arc) -> Option { + daemon_state + .non_multi_session_count + .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst) + .ok() + .map(|_| Self { daemon_state }) + } +} + +impl Drop for NonMultiSlotGuard { + fn drop(&mut self) { + self.daemon_state + .non_multi_session_count + .fetch_sub(1, Ordering::SeqCst); + } } impl DaemonState { fn new(instance_name: Option) -> Self { + // T M10.10 Day 4 — read the latency-injection env once at + // startup. Production deployments don't set this; tests + // (`TestDaemon::spawn_with_env`) set it for criterion 1 + // verification. + let injected_render_latency_ms: u64 = std::env::var("PMACS_INSTANCE_LATENCY_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); Self { instance_name, started: Instant::now(), // FrontendId(1) is reserved for FrontendId::LOCAL (the // in-process TUI). Daemon-attached frontends start at 2. next_frontend_id: AtomicU64::new(2), - attached: AtomicBool::new(false), + non_multi_session_count: AtomicU64::new(0), + color_registry: std::sync::Mutex::new(HashMap::new()), + injected_render_latency_ms, + } + } + + /// T M10.10 Day 4 — current injected-latency value for `CellDelta` + /// emission. Returns 0 in production. + fn injected_render_latency_ms(&self) -> u64 { + self.injected_render_latency_ms + } + + /// T M10.9 — look up or assign a color slot for the given uid. + /// + /// Same uid across reconnect → same slot (the spec's + /// "stable across reconnect within a session" criterion). + /// New uid → next free slot, wrapping around the palette length. + fn color_slot_for_uid(&self, uid: u32) -> u8 { + use crate::overlay_color::PALETTE_LEN; + let mut registry = self + .color_registry + .lock() + .expect("color_registry mutex poisoned"); + if let Some(&slot) = registry.get(&uid) { + slot + } else { + // Next slot = number of entries so far, modulo palette length. + let slot = u8::try_from(registry.len() % PALETTE_LEN).unwrap_or(0); + registry.insert(uid, slot); + slot } } @@ -199,56 +343,82 @@ pub fn run_daemon(socket_path: PathBuf, instance_name: Option) -> Result install_signal_handlers(&shutdown)?; let daemon_state = Arc::new(DaemonState::new(instance_name)); - // The editor and render-state outlive any single attachment; they - // are constructed once and reused across detach / reattach cycles. - // The render-state's initial size is a placeholder — the first - // `AttachRequest` carries the real size and we resize on attach. + // The editor outlives any single attachment; constructed once on + // the dispatcher thread and used until daemon shutdown. let mut editor = EditorState::new(); // Mirror the daemon's `--socket NAME` and start time into the // editor's `LocalInstanceInfo` so `pmacs.instance.identity()` // (T M5.6f) reports the same identity the daemon hands back over - // its Hello. The DaemonState built above is the source of truth - // for both, so the editor's identity stays in lock-step with the - // wire payload. + // its Hello. editor .lua_host .set_instance_name(daemon_state.instance_name()); editor.lua_host.set_instance_started(daemon_state.started()); - let mut render_state = RenderState::new(CellSize::new(24, 80)); eprintln!( "pmacs: daemon listening on {} (pid {})", socket_path.display(), - std::process::id() + std::process::id(), ); - accept_loop( - &listener, - &daemon_state, + // T M10.8 — dispatcher thread topology. + // + // The current thread becomes the dispatcher (owns the editor; + // single-threaded access to all editor state). A spawned accept + // thread runs `accept_loop`, calling `listener.accept()` and + // spawning a per-attach thread for each accepted connection. + // + // The per-attach thread does the handshake (Hello, AttachRequest, + // version + capability checks), then sends `SessionEstablished` + // to the dispatcher and becomes a reader thread for that + // connection's stream. + // + // Dispatcher channel: all attach threads push `DispatcherEvent` + // variants; dispatcher consumes in FIFO order. + let (dispatcher_tx, dispatcher_rx) = mpsc::channel::(); + let accept_handle = { + let daemon_state = Arc::clone(&daemon_state); + let shutdown = Arc::clone(&shutdown); + let tx = dispatcher_tx.clone(); + thread::spawn(move || accept_loop(listener, &daemon_state, tx, &shutdown)) + }; + + dispatcher_loop( + dispatcher_rx, &mut editor, - &mut render_state, &shutdown, + daemon_state.injected_render_latency_ms(), )?; - cleanup(listener, &socket_path, lock); + + // Dispatcher exited (shutdown or quit). Wake the accept thread + // by closing the channel from our side; the accept thread checks + // the shutdown flag between accepts and exits accordingly. + drop(dispatcher_tx); + let _ = accept_handle.join(); + cleanup(&socket_path, lock); eprintln!("pmacs: daemon stopped"); Ok(()) } +/// T M10.8 — accept thread. Spawns a per-attach thread for each new +/// connection. Runs on its own OS thread parallel to the dispatcher. +/// +/// `listener` is taken by value because the accept thread owns it +/// until the daemon shuts down; the file descriptor closes when the +/// thread exits. +#[allow(clippy::needless_pass_by_value)] fn accept_loop( - listener: &UnixListener, + listener: UnixListener, daemon_state: &Arc, - editor: &mut EditorState, - render_state: &mut RenderState, + dispatcher_tx: mpsc::Sender, shutdown: &Arc, ) -> Result<(), DaemonError> { while !shutdown.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, _)) => { - if let Err(e) = - handle_connection(stream, daemon_state, editor, render_state, shutdown) - { - eprintln!("pmacs: connection handler: {e}"); - } + let daemon_state = Arc::clone(daemon_state); + let tx = dispatcher_tx.clone(); + thread::spawn(move || per_attach_thread(stream, daemon_state, tx)); } Err(e) if e.kind() == ErrorKind::WouldBlock => { thread::sleep(ACCEPT_POLL_INTERVAL); @@ -259,12 +429,38 @@ fn accept_loop( Ok(()) } -fn cleanup(listener: UnixListener, socket_path: &Path, lock: LockHandle) { - drop(listener); +fn cleanup(socket_path: &Path, lock: LockHandle) { let _ = std::fs::remove_file(socket_path); let _ = lock.release(); } +/// T M10.8 Day 4 — read `PMACS_INSTANCE_*` env vars (if set) to +/// override the instance's advertised capabilities. Used by test +/// infrastructure that needs the daemon to advertise non-default +/// capabilities (e.g., M10.7's mismatch test rewrite, which needs +/// the daemon to advertise `multi_frontend: false` so a +/// frontend declaring `true` hits the mismatch path). +/// +/// Recognized env vars (each accepts `0`/`false` to disable; +/// anything else / absent → default `true`): +/// - `PMACS_INSTANCE_MULTI_FRONTEND` +/// - `PMACS_INSTANCE_CRDT_REPLICA` +/// +/// Production daemons don't set these; tests do. +fn instance_capabilities_with_env_override() -> InstanceCapabilities { + fn env_bool(key: &str, default: bool) -> bool { + match std::env::var(key).ok().as_deref() { + Some("0" | "false" | "FALSE" | "False") => false, + Some(_) | None => default, + } + } + let defaults = InstanceCapabilities::default(); + InstanceCapabilities { + multi_frontend: env_bool("PMACS_INSTANCE_MULTI_FRONTEND", defaults.multi_frontend), + crdt_replica: env_bool("PMACS_INSTANCE_CRDT_REPLICA", defaults.crdt_replica), + } +} + /// Bind a Unix-domain listener under a strict umask so the socket /// file gets mode 0600. fn bind_with_strict_umask(socket_path: &Path) -> std::io::Result { @@ -294,25 +490,39 @@ fn install_signal_handlers(shutdown: &Arc) -> Result<(), DaemonError Ok(()) } -fn handle_connection( +/// T M10.8 — per-attach thread. Runs handshake on a fresh thread for +/// each accepted connection; on success, sends `SessionEstablished` +/// to the dispatcher and transitions to reader behavior on the same +/// thread (no other initialization between `SessionEstablished` and +/// the reader loop — any added work would delay first-event +/// processing). +/// +/// On handshake failure (version mismatch, capability mismatch, I/O +/// error) the thread writes a `Goodbye` variant and exits without +/// notifying the dispatcher. The dispatcher never learns about +/// failed handshakes. +#[allow(clippy::needless_pass_by_value)] +fn per_attach_thread( mut stream: UnixStream, - daemon_state: &Arc, - editor: &mut EditorState, - render_state: &mut RenderState, - shutdown: &Arc, -) -> Result<(), DaemonError> { + daemon_state: Arc, + dispatcher_tx: mpsc::Sender, +) { let frontend_id = FrontendId(daemon_state.next_frontend_id.fetch_add(1, Ordering::SeqCst)); - // Send Hello immediately on accept. + // Send Hello immediately on accept. The instance capabilities + // advertised here (and used for negotiation below) come from the + // env-var override helper so test infrastructure can drive the + // mismatch path without changing the default. + let instance_caps_for_hello = instance_capabilities_with_env_override(); let hello = Hello { protocol_version: PROTOCOL_VERSION, assigned_frontend_id: frontend_id, instance_identity: daemon_state.build_identity(), - instance_capabilities: InstanceCapabilities::default(), + instance_capabilities: instance_caps_for_hello.clone(), }; if let Err(e) = write_message(&mut stream, &hello) { eprintln!("pmacs: send Hello failed: {e}"); - return Ok(()); + return; } // Read AttachRequest. @@ -320,12 +530,12 @@ fn handle_connection( Ok(r) => r, Err(e) => { eprintln!("pmacs: read AttachRequest failed: {e}"); - return Ok(()); + return; } }; - // Version check. - if req.protocol_version != PROTOCOL_VERSION { + // T M10.5 version check. + if !crate::protocol::is_supported_protocol_version(req.protocol_version) { let _ = write_message( &mut stream, &InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { @@ -333,155 +543,1017 @@ fn handle_connection( client: req.protocol_version, }), ); - return Ok(()); + return; } - // Already-attached check (single-frontend invariant). - if daemon_state - .attached - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { + // T M10.7 capability negotiation. T M10.8 Day 4: the instance + // defaults to advertising `multi_frontend: true` and + // `crdt_replica: true` (M10.10 renamed from `crdt_ops`). + // Env vars override at daemon startup for test + // infrastructure that needs to exercise the mismatch path + // (the M10.7 mismatch test's daemon-end-to-end rewrite). + // We re-use the caps already computed for Hello so the + // negotiation sees the same advertised values. + let instance_caps = instance_caps_for_hello; + let negotiated_caps = + match crate::protocol::negotiate_capabilities(&req.frontend_capabilities, &instance_caps) { + Ok(caps) => caps, + Err(reason) => { + let _ = write_message(&mut stream, &InstanceMessage::Goodbye(reason)); + return; + } + }; + + // T M10.8 Day 4 — Q5 non-multi-session admission control. + let _non_multi_guard = if negotiated_caps.multi_frontend { + None + } else if let Some(guard) = NonMultiSlotGuard::try_acquire(Arc::clone(&daemon_state)) { + Some(guard) + } else { let _ = write_message( &mut stream, &InstanceMessage::Goodbye(GoodbyeReason::AlreadyAttached), ); - return Ok(()); + return; + }; + + // T M10.9 — color slot assignment via SO_PEERCRED. The connecting + // peer's Unix uid is the stable identifier; same uid across + // reconnect → same color slot. If SO_PEERCRED fails (e.g., + // non-Unix peer, kernel API unavailable), fall back to a + // per-FrontendId slot (degrades to per-connection stability). + let color_slot = + match nix::sys::socket::getsockopt(&stream, nix::sys::socket::sockopt::PeerCredentials) { + Ok(cred) => daemon_state.color_slot_for_uid(cred.uid()), + Err(_) => { + // Fallback: use frontend_id-based slot; per-connection + // stability only (no cross-reconnect within session). + u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)) + .unwrap_or(0) + } + }; + + let session_state = + crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot); + + // Hand the write-half to the dispatcher; keep a read-half for + // this thread's reader loop. **Reader loop starts immediately + // after the SessionEstablished send below; any initialization + // needed must happen before that send. A future contributor + // adding "let me also do X before reading" would delay + // first-event processing.** + let write_stream = match stream.try_clone() { + Ok(s) => s, + Err(e) => { + eprintln!("pmacs: try_clone stream for {frontend_id:?} failed: {e}"); + return; + } + }; + + if dispatcher_tx + .send(DispatcherEvent::SessionEstablished { + frontend_id, + session_state, + initial_size: req.initial_size, + write_stream, + }) + .is_err() + { + // Dispatcher channel closed (daemon shutting down). + return; } - // Stamp `active_frontend` so Lua's `pmacs.frontend.id()` returns - // the assigned id even before any input event arrives. dispatch_key - // and dispatch_mouse will overwrite this on every event, but the - // initial value matters for code that runs during the initial - // render (Lua statusline functions, etc.). - editor.core.borrow_mut().active_frontend = frontend_id; + // Reader loop: read FrontendEvents from the stream, push to + // dispatcher tagged with this frontend_id. Any error (EOF, + // decode error, broken pipe) ends the loop; final + // `SessionDetached` lets the dispatcher clean up. + let mut read_stream = stream; + while let Ok(event) = read_message::(&mut read_stream) { + if dispatcher_tx + .send(DispatcherEvent::FrontendEvent { + source: frontend_id, + event, + }) + .is_err() + { + break; + } + } - let result = run_per_attach(stream, editor, render_state, shutdown, req.initial_size); - daemon_state.attached.store(false, Ordering::SeqCst); - result + // Notify dispatcher of detach. Best-effort: if the channel is + // closed (daemon shut down before our reader exited), the send + // returns Err and we just exit. + let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id }); } -/// Drive the editor for one attached frontend until Detach / EOF / -/// shutdown / write-failure (broken pipe). +/// T M10.8 — dispatcher loop. The single thread that owns the editor. /// -/// Threading model: -/// - Reader thread blocks on [`read_message`] and pushes events into -/// an `mpsc::channel`. -/// - This (main) thread renders, writes [`InstanceMessage`]s back to -/// the socket, then waits up to one frame for events on the channel -/// before ticking async work and looping. +/// All attached frontends' inputs arrive via the `dispatcher_rx` +/// channel as `DispatcherEvent` variants. Per-attach reader threads +/// (spawned by the accept thread on each new connection) push events +/// here. The dispatcher consumes them in FIFO order, mutates the +/// editor, and per-tick: /// -/// Initial frame: `render_state` is resized to the frontend's -/// announced `initial_size` and forced to a full-grid resync, so the -/// first frame paints the entire screen. +/// 1. Renders a frame for each attached frontend (per-frontend +/// `RenderState`, each rendered against its own view). +/// 2. Sweeps the `SessionRegistry` for presence broadcasts; +/// routes them to per-recipient streams. +/// 3. Writes outgoing messages to each frontend's write stream +/// (synchronous — M10.8 Day 3 doesn't have per-frontend writer +/// threads; v0.3 may add them if N attachments grow). +/// 4. Ticks async / processes / LSP. /// -/// Detach semantics: the daemon does *not* send a Goodbye on graceful -/// detach — the frontend asked first, and a Goodbye for an already- -/// closed connection is noise. Goodbye is reserved for the cases -/// where the instance is doing the closing (shutdown, version -/// mismatch, already-attached, protocol error). -fn run_per_attach( - stream: UnixStream, +/// Exits when the editor's `quit` flag is set, the `shutdown` flag +/// is set, or all per-attach senders have disconnected. +/// +/// Return value is `Result` for symmetry with other daemon entry +/// points; the function does not propagate errors today, but a +/// future failure mode (e.g., catastrophic editor state corruption) +/// would surface here. +// M10.10 grew this function with per-tick CursorByte emit + lazy +// CRDT upgrade + latency injection on top of M10.8/M10.9's +// dispatcher loop. The 121-line size is cohesive — the loop body +// coordinates render + presence sweep + CRDT broadcast + shutdown +// against one stack frame's borrow scope. Splitting would require +// either passing many `&mut` parameters between helpers or moving +// state behind RefCells. Defer to v0.2+ refactor if growth continues. +#[allow( + clippy::unnecessary_wraps, + clippy::needless_pass_by_value, + clippy::too_many_lines +)] +fn dispatcher_loop( + dispatcher_rx: mpsc::Receiver, editor: &mut EditorState, - render_state: &mut RenderState, shutdown: &Arc, - initial_size: CellSize, + injected_render_latency_ms: u64, ) -> Result<(), DaemonError> { - let reader_stream = stream.try_clone()?; - let kick_stream = stream.try_clone()?; - let mut writer_stream = stream; - - let (tx, rx) = mpsc::channel::(); - let reader_handle = thread::spawn(move || run_reader(reader_stream, tx)); - - // Initial sync: resize the render-state to the frontend's announced - // size, force the next frame to be a full-grid resync. - render_state.resize(initial_size); - render_state.force_full_grid_resync(); - let mut term_size = initial_size; + // Per-frontend dispatcher state. + let mut render_states: HashMap = HashMap::new(); + let mut streams: HashMap = HashMap::new(); + let mut term_sizes: HashMap = HashMap::new(); + let mut session_registry = SessionRegistry::new(); loop { - // Render and ship. - let messages = render_state.render_frame(editor); - let mut write_failed = false; - for msg in &messages { - if let Err(e) = write_message(&mut writer_stream, msg) { - eprintln!("pmacs: write failed in per-attach loop: {e}"); - write_failed = true; - break; + // Per-tick render + presence sweep for each attached + // frontend. T M10.8 — temporarily flip `active_frontend` to + // the frontend being rendered so its FrontendView is the one + // `active_window()` returns. Restored after the render-pass + // loop to the last-dispatched value (Q11: tick-driven render + // doesn't update active_frontend in the user-driving sense). + let last_dispatched = editor.core.borrow().active_frontend; + let attached_fids: Vec = render_states.keys().copied().collect(); + + // T M10.10 post-audit-round-3 F18 — drain + broadcast pending + // CRDT ops **before** the render pass. Otherwise frontends + // receive `CellDelta` + `CursorByte` (showing the edit + // visually + the new cursor position) before the `CrdtOp` + // that updates their `BufferMirror`'s rope state — a fast + // next keystroke would run optimistic logic against stale + // mirror content with the new cursor position. + // + // F16 — `CrdtOpOrigin` controls sender exclusion: + // `OptimisticReplica(fid)` excludes `fid` (already + // locally-applied); `DaemonKey` excludes nobody (no + // frontend has applied locally; the active frontend's + // mirror must receive too). + #[cfg(feature = "crdt")] + { + let pending_ops = std::mem::take(&mut editor.core.borrow_mut().pending_crdt_ops); + for (origin, buffer_id, op) in pending_ops { + let exclude = match origin { + crate::editor_core::CrdtOpOrigin::OptimisticReplica(fid) => Some(fid), + crate::editor_core::CrdtOpOrigin::DaemonKey => None, + }; + let entries = session_registry.broadcast_crdt_op(exclude, buffer_id, op); + for entry in entries { + if let Some(stream) = streams.get_mut(&entry.recipient) { + let _ = write_message(stream, &entry.message); + } + } } } - if write_failed { + // Non-CRDT build: `pending_crdt_ops` is empty (only the + // CRDT-feature code paths push to it). Drop the take/iter + // to keep the non-CRDT build free of unused imports. + #[cfg(not(feature = "crdt"))] + { + // Defensive: empty the queue in case shared state was + // populated through some path we haven't traced. + let _ = std::mem::take(&mut editor.core.borrow_mut().pending_crdt_ops); + } + + for fid in &attached_fids { + editor.core.borrow_mut().active_frontend = *fid; + + // T M10.10 Day 3 — lazy CRDT upgrade on active-buffer + // access for replica frontends. Keeps the daemon + // invariant "active buffer for a replica is CRDT-backed" + // even when buffers are created mid-session + // (post-SessionEstablished). The upgrade fires at most + // once per buffer (idempotent via `is_crdt_backed()` + // check). Documented in M10.10-FRAMING.md's composition- + // consistency-check application section. + #[cfg(feature = "crdt")] + if session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_capabilities.crdt_replica) + { + // F29 — when a mid-session upgrade occurs, push a + // `BufferSnapshot` for the newly-CRDT-backed buffer + // to every currently-attached replica so their + // `BufferMirror`s gain an entry for it. Without + // this, replicas attached before the upgrade + // permanently fall back to v0.1 round-trip on that + // buffer. + if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) { + broadcast_buffer_snapshot_to_replicas( + editor, + upgraded, + &session_registry, + &mut streams, + ); + } + } + #[cfg(not(feature = "crdt"))] + { + let _ = session_registry.session_state(*fid); + let _ = ensure_active_buffer_crdt_backed(editor, *fid); + } + + // T M10.9 — gather other-frontend presences for the + // overlay paint. Reads `last_broadcast` (updated by the + // sweep below); other-frontend snapshots lag by at most + // one tick. Imperceptible at frame-rate cadence. + let other_presences = session_registry.other_presences_for(*fid); + let render_state = render_states + .get_mut(fid) + .expect("render_state present for attached fid"); + let messages = render_state.render_frame(editor, &other_presences); + + // T M10.6 per-frontend presence sweep. The snapshot is + // computed from this frontend's view; the sweep then + // produces broadcasts to OTHER multi-frontend recipients. + let snapshot = build_presence_snapshot(editor, *fid); + let broadcasts = session_registry.sweep(&[(*fid, snapshot)]); + + // Write frame messages to this frontend's stream. + let mut write_failed = false; + if let Some(stream) = streams.get_mut(fid) { + for msg in &messages { + // T M10.10 Day 4 — test-only latency injection. + // When `PMACS_INSTANCE_LATENCY_MS` is set (>0), + // sleep before each CellDelta write to simulate + // slow daemon→frontend transport. Verifies + // criterion 1 ("less than one frame regardless of + // instance latency") under realistic high-latency + // conditions. Dispatcher-wide scope: multi- + // frontend tests at injected latency conflate + // frontends. + if injected_render_latency_ms > 0 + && matches!(msg, InstanceMessage::CellDelta { .. }) + { + thread::sleep(Duration::from_millis(injected_render_latency_ms)); + } + if let Err(e) = write_message(stream, msg) { + eprintln!("pmacs: write failed for {fid:?} in dispatcher: {e}"); + write_failed = true; + break; + } + } + // T M10.10 Finding 2: emit authoritative byte-position + // cursor for replica frontends, paired with the grid + // Cursor above. Both are derived from the same + // render-frame iteration (no editor mutation between + // the two derivations), so they describe the cursor + // in the same instant in two reference frames. The + // optimistic-apply path consumes byte_pos; the legacy + // paint path consumes the grid coord. + if !write_failed + && session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_capabilities.crdt_replica) + { + let core = editor.core.borrow(); + if let Some(window) = core.active_window_for(*fid) { + let cursor_byte_msg = InstanceMessage::CursorByte { + buffer_id: window.buffer_id, + byte_pos: window.cursor, + }; + if let Err(e) = write_message(stream, &cursor_byte_msg) { + eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}"); + write_failed = true; + } + } + } + } + + // Route presence broadcasts to their recipient streams + // (recipients != fid per sender-exclusion). + for entry in &broadcasts { + if let Some(stream) = streams.get_mut(&entry.recipient) { + let _ = write_message(stream, &entry.message); + } + } + + if write_failed { + // Drop the broken connection. + streams.remove(fid); + render_states.remove(fid); + term_sizes.remove(fid); + session_registry.unregister_session(*fid); + editor.core.borrow_mut().unregister_frontend_view(*fid); + } + } + + editor.core.borrow_mut().active_frontend = last_dispatched; + // T M10.8 Day 4 drain + broadcast block lives at the **top** + // of the loop now (post-audit-round-3 F18 reorder); CrdtOp + // broadcasts arrive at replicas before the CellDelta / + // CursorByte for the same edit. + + // Shutdown / quit checks. Send Goodbye to all attached + // frontends before exiting. + let core_wants_quit = editor.core.borrow().quit; + let shutting_down = shutdown.load(Ordering::SeqCst) || core_wants_quit; + if shutting_down { + for stream in streams.values_mut() { + let _ = write_message( + stream, + &InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown), + ); + } + if core_wants_quit { + shutdown.store(true, Ordering::SeqCst); + } break; } - if editor.core.borrow().quit { - // The editor wants to quit (M-x kill-pmacs / `:q` / etc.). - // In daemon mode, "quit" means shutting down the whole - // instance — propagate to the outer accept loop. - let _ = write_message( - &mut writer_stream, - &InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown), - ); - shutdown.store(true, Ordering::SeqCst); - break; - } - - if shutdown.load(Ordering::SeqCst) { - let _ = write_message( - &mut writer_stream, - &InstanceMessage::Goodbye(GoodbyeReason::ShuttingDown), - ); - break; - } - - // Wait up to one frame for input. + // Wait up to one frame for the next dispatcher event. let frame_target = editor.async_runtime.frame_target_ms(); - let mut had_event = false; - let mut detached = false; - match rx.recv_timeout(Duration::from_millis(frame_target)) { - Ok(FrontendEvent::Detach(_)) => { - detached = true; - } - Ok(ev) => { - apply_event(editor, ev, &mut term_size, render_state); - had_event = true; + match dispatcher_rx.recv_timeout(Duration::from_millis(frame_target)) { + Ok(event) => { + handle_dispatcher_event( + event, + editor, + &mut render_states, + &mut streams, + &mut term_sizes, + &mut session_registry, + ); + // Drain a burst of immediately-available events to + // coalesce typing-flurries / multi-frontend traffic + // into a single render pass (matches the v0.1 + // run_per_attach drain behavior). + while let Ok(event) = dispatcher_rx.try_recv() { + handle_dispatcher_event( + event, + editor, + &mut render_states, + &mut streams, + &mut term_sizes, + &mut session_registry, + ); + } } Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } - if detached { - break; - } - - if had_event { - // Drain the burst — coalesce a typing-flurry into one - // frame, matching the TUI run loop's behavior. - loop { - match rx.try_recv() { - Ok(FrontendEvent::Detach(_)) => { - detached = true; - break; - } - Ok(ev) => apply_event(editor, ev, &mut term_size, render_state), - Err(_) => break, - } - } - if detached { - break; - } - } editor.tick_async(); editor.tick_processes(); editor.tick_lsp(); } - let _ = kick_stream.shutdown(Shutdown::Read); - let _ = reader_handle.join(); Ok(()) } +/// Handle one `DispatcherEvent`. Extracted so the dispatcher loop +/// can both timeout-recv and burst-drain via the same code path. +fn handle_dispatcher_event( + event: DispatcherEvent, + editor: &mut EditorState, + render_states: &mut HashMap, + streams: &mut HashMap, + term_sizes: &mut HashMap, + session_registry: &mut SessionRegistry, +) { + match event { + DispatcherEvent::SessionEstablished { + frontend_id, + session_state, + initial_size, + mut write_stream, + } => { + // Register the frontend's view (M10.8 Day 3: fresh + // scratch buffer view; future milestones may clone + // LOCAL's view or take an explicit initial-buffer + // argument). + let scratch_view = build_fresh_frontend_view(editor); + editor + .core + .borrow_mut() + .register_frontend_view(frontend_id, scratch_view); + + // T M10.10: bootstrap the new frontend's `BufferMirror` + // by sending one `BufferSnapshot` per CRDT-backed buffer. + // Gated on the negotiated `crdt_replica` capability — + // v0.1 / non-replica frontends never receive the variant + // (postcard would hard-error on the unknown variant; see + // M10.10-FRAMING.md Refinement 3). Ordering: snapshots + // are sent BEFORE any CellDelta flows (the next per-tick + // render is the first CellDelta source), so the mirror + // is initialized before any local-edit path can + // reference it. + let crdt_replica = session_state.negotiated_capabilities.crdt_replica; + if crdt_replica { + send_buffer_snapshots(editor, &mut write_stream); + } + + // Register the session in the registry (presence + + // capability filters). + session_registry.register_session(frontend_id, session_state); + + // Allocate per-frontend RenderState; force initial + // full-grid sync so the first frame paints everything. + let mut render_state = RenderState::new(initial_size); + render_state.force_full_grid_resync(); + render_states.insert(frontend_id, render_state); + streams.insert(frontend_id, write_stream); + term_sizes.insert(frontend_id, initial_size); + + // Stamp active_frontend so the initial render's Lua + // statusline code sees the right fid. + editor.core.borrow_mut().active_frontend = frontend_id; + } + DispatcherEvent::FrontendEvent { source, event } => { + match event { + FrontendEvent::Detach(_) => { + // The per-attach thread will follow up with a + // `SessionDetached` event after its reader + // loop exits; cleanup happens there. Just stop + // processing here. + } + FrontendEvent::Resize { size, .. } => { + if let Some(rs) = render_states.get_mut(&source) { + rs.resize(size); + } + if let Some(ts) = term_sizes.get_mut(&source) { + *ts = size; + } + } + #[cfg(feature = "crdt")] + FrontendEvent::CrdtOp { + frontend_id: claimed_fid, + buffer_id, + op, + } => { + // T M10.10 — handled here (not in apply_event) so + // the authenticated `source` is in scope. The + // event's `claimed_fid` is client-supplied and not + // trusted; we use `source` for sender-exclusion + // routing. (Original Finding 4 fix.) + // + // Second-round audit added three pre-apply checks + // (F11, F12, F13). All identity-and-scope fields + // (negotiated cap, claimed_fid, op.peer_id, + // buffer_id) must agree with the authenticated + // `source` and the source's active-window buffer + // before the op is applied. + if let Err(reason) = validate_remote_crdt_op( + editor, + session_registry, + source, + claimed_fid, + buffer_id, + &op, + ) { + eprintln!( + "pmacs daemon: dropping CrdtOp from {source:?} \ + (claimed_fid={claimed_fid:?}, buffer_id={buffer_id:?}, \ + op.peer_id={pid}): {reason}", + pid = op.peer_id + ); + } else { + handle_remote_crdt_op(editor, source, buffer_id, op); + } + } + _ => { + let term_size = *term_sizes + .get(&source) + .expect("term_size present for source"); + let render_state = render_states + .get_mut(&source) + .expect("render_state present for source"); + let mut term_size = term_size; + apply_event(editor, event, &mut term_size, render_state); + term_sizes.insert(source, term_size); + } + } + } + DispatcherEvent::SessionDetached { frontend_id } => { + render_states.remove(&frontend_id); + streams.remove(&frontend_id); + term_sizes.remove(&frontend_id); + session_registry.unregister_session(frontend_id); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + } + } +} + +/// T M10.10: send one `InstanceMessage::BufferSnapshot` per buffer +/// in the editor's registry to the newly-attaching frontend's write +/// stream. +/// +/// Called only when the session negotiated `crdt_replica: true`. The +/// receiving frontend's `BufferMirror` consumes these to bootstrap +/// its CRDT replicas before any local-edit path can reference them. +/// +/// # M10.10 finding: M10.8's deferred upgrade-on-attach wiring +/// +/// M10.2 shipped `Buffer::upgrade_to_crdt`; the doc comment notes +/// "Used by M10.8 (multi-frontend instance state) when a v0.1 +/// frontend's buffer is promoted to CRDT-backed at attach time" — but +/// M10.8 shipped without wiring the upgrade call. M10.10 surfaces +/// the gap (no CRDT state to snapshot → no `BufferSnapshot` fires). +/// +/// Resolution here: upgrade each non-CRDT buffer to CRDT-backed +/// in-place before exporting its snapshot. Uses +/// `peer_id_from_frontend(FrontendId::LOCAL)` (peer id 1) as the +/// instance's CRDT identity — the daemon-owned edit-source ID. Once +/// upgraded, subsequent attaches see the buffer as already CRDT- +/// backed and skip the upgrade. +/// +/// Errors on individual buffers (upgrade failure, snapshot export +/// failure, write failure) are logged and skipped; one failed buffer +/// doesn't abort the others. +#[cfg(feature = "crdt")] +fn send_buffer_snapshots(editor: &EditorState, write_stream: &mut UnixStream) { + let core = editor.core.borrow(); + let mut registry = core.registry.borrow_mut(); + let buffer_ids: Vec<_> = registry.ids().to_vec(); + let instance_peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL); + + for buffer_id in buffer_ids { + let Ok(buf) = registry.get_mut(buffer_id) else { + continue; + }; + // Upgrade non-CRDT buffers to CRDT-backed in place. The + // upgrade preserves the buffer's id, name, and content; only + // the CRDT machinery is added. + if !buf.is_crdt_backed() { + if let Err(e) = buf.upgrade_to_crdt(instance_peer_id) { + eprintln!("pmacs: upgrade_to_crdt for {buffer_id:?} failed: {e:?}"); + continue; + } + } + let Some(crdt) = buf.crdt_state() else { + // Upgrade succeeded but somehow crdt is still None — + // shouldn't happen; defensive skip. + continue; + }; + let snapshot = match crdt.export_snapshot() { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("pmacs: export_snapshot for {buffer_id:?} failed: {e:?}"); + continue; + } + }; + let msg = InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot: snapshot, + }; + if let Err(e) = write_message(write_stream, &msg) { + eprintln!("pmacs: send BufferSnapshot for {buffer_id:?} failed: {e:?}"); + // Continue trying other buffers — the stream may + // recover, or the next per-tick error handling will + // detach the session. + } + } +} + +/// No-op stub for non-CRDT builds. v0.1 frontends never advertise +/// `crdt_replica`, so the caller is gated on a capability that's +/// always false in non-CRDT builds; this stub keeps the call site +/// compiling without conditional logic. +#[cfg(not(feature = "crdt"))] +fn send_buffer_snapshots(_editor: &EditorState, _write_stream: &mut UnixStream) {} + +/// T M10.10 Day 3 — ensure the active buffer for `fid`'s window is +/// CRDT-backed, upgrading in place if needed. +/// +/// Called per-tick for replica frontends from `dispatcher_loop`. +/// Idempotent: after the first upgrade, `is_crdt_backed()` returns +/// true and subsequent calls are no-ops. +/// +/// Documented in M10.10-FRAMING.md's composition-consistency-check +/// application — keeps the invariant "active buffer for replica is +/// CRDT-backed" holding even for mid-session-created buffers +/// (post-`send_buffer_snapshots`). Sets up v0.2's mid-session +/// `BufferSnapshot` broadcast work without retrofit. +/// +/// Errors on upgrade are logged but don't abort the session — the +/// per-tick loop will retry on the next iteration; persistent failure +/// signals a deeper buffer-state issue worth surfacing to the user +/// elsewhere. +/// +/// # F29 (post-audit-round-5) — mid-session `BufferSnapshot` push +/// +/// When this function performs an upgrade (returns `Some(buffer_id)`), +/// the caller broadcasts a `BufferSnapshot` to every currently- +/// attached replica frontend so their `BufferMirror`s gain an +/// entry for the newly-CRDT-backed buffer. Without this push, the +/// replicas' `init_from_snapshot` is never called for the buffer +/// and the optimistic-apply path falls through to v0.1 round-trip +/// (`is_ready` returns false) until the replica detaches and +/// reattaches. +/// +/// Idempotency: the function returns `None` if the buffer was +/// already CRDT-backed, so the broadcast only fires on the actual +/// upgrade tick. A receiving frontend whose mirror already has the +/// buffer (e.g. it attached after the upgrade and received the +/// snapshot in `send_buffer_snapshots`) sees `AlreadyInitialized` +/// from `init_from_snapshot` and logs but keeps existing state — +/// non-fatal. +/// +/// Returns `Some(buffer_id)` when an upgrade just happened (caller +/// must broadcast); `None` when the buffer was already CRDT-backed +/// or the upgrade failed (failure already logged inside). +#[cfg(feature = "crdt")] +fn ensure_active_buffer_crdt_backed( + editor: &EditorState, + fid: FrontendId, +) -> Option { + let buffer_id_opt = { + let core = editor.core.borrow(); + core.active_window_for(fid).map(|w| w.buffer_id) + }; + let buffer_id = buffer_id_opt?; + let core = editor.core.borrow(); + let mut registry = core.registry.borrow_mut(); + let Ok(buf) = registry.get_mut(buffer_id) else { + return None; + }; + if buf.is_crdt_backed() { + return None; + } + let instance_peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL); + match buf.upgrade_to_crdt(instance_peer_id) { + Ok(()) => Some(buffer_id), + Err(e) => { + eprintln!("pmacs: lazy upgrade_to_crdt for {buffer_id:?} (fid {fid:?}) failed: {e:?}"); + None + } + } +} + +#[cfg(not(feature = "crdt"))] +fn ensure_active_buffer_crdt_backed( + _editor: &EditorState, + _fid: FrontendId, +) -> Option { + None +} + +/// T M10.10 post-audit-round-5 F29 — broadcast a single buffer's +/// `BufferSnapshot` to every currently-attached replica frontend +/// (with `crdt_replica` negotiated). +/// +/// Called when [`ensure_active_buffer_crdt_backed`] performs a +/// mid-session upgrade (or when any future code path creates / +/// upgrades a buffer that existing replicas haven't seen yet). +/// Per-replica state tracking isn't kept: replicas whose mirror +/// already has the buffer surface `AlreadyInitialized` from +/// `init_from_snapshot` and log but don't fail. The duplicate +/// send is small (snapshot bytes for the upgrade-instant state, +/// which is the empty / freshly-loaded buffer content the replica +/// already has) and only fires on the actual upgrade tick. +#[cfg(feature = "crdt")] +fn broadcast_buffer_snapshot_to_replicas( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, + session_registry: &SessionRegistry, + streams: &mut HashMap, +) { + let snapshot_bytes = { + let core = editor.core.borrow(); + let registry = core.registry.borrow(); + let Ok(buf) = registry.get(buffer_id) else { + return; + }; + let Some(crdt) = buf.crdt_state() else { + return; + }; + match crdt.export_snapshot() { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("pmacs: F29 export_snapshot for {buffer_id:?} failed: {e:?}"); + return; + } + } + }; + let msg = InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot: snapshot_bytes, + }; + for (fid, stream) in streams.iter_mut() { + let is_replica = session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_capabilities.crdt_replica); + if !is_replica { + continue; + } + if let Err(e) = write_message(stream, &msg) { + eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}"); + } + } +} + +/// T M10.10 (post-audit round 2) — validate an incoming +/// `FrontendEvent::CrdtOp` against four invariants. Returns the +/// rejection reason on failure; `Ok(())` means the op may be applied. +/// +/// The four invariants: +/// +/// - **F11 — negotiated cap.** The source session must have +/// negotiated `crdt_replica: true`. A legacy / non-replica session +/// has no contract to send `CrdtOp` events; accepting one would let +/// such a session mutate daemon state under a capability it never +/// advertised. +/// - **Original Finding 4 — `claimed_fid` matches `source`.** The +/// event's `frontend_id` field is client-supplied. A buggy or +/// malicious frontend can put another frontend's id there. The +/// original Finding 4 fix used `source` for routing and merely +/// logged a warning on mismatch; F12 tightens this to a hard +/// reject — there is no legitimate reason for the values to +/// differ. +/// - **F12 — `op.peer_id` matches `source`.** The receiving +/// frontend's attach loop derives the broadcast's source via +/// `FrontendId(op.peer_id)` (see `src/attach.rs` in the `CrdtOp` +/// message branch). A frontend A that puts B's peer id in the op +/// payload can cause B's mirror to dedup-skip its way into +/// divergence: B sees the broadcast, thinks it's its own echo, +/// skips it. The daemon must reject before the op is applied or +/// re-broadcast. +/// - **F13 — `buffer_id` matches source's active window buffer.** +/// M10.10's local-edit path (`optimistic::frontend_event_for_keystroke`) +/// only emits `CrdtOp`s for the active mirror buffer. A frontend has +/// no v1.0-scope reason to target a different buffer; rejecting +/// non-active-buffer ops keeps the surface to what the test matrix +/// actually exercises. +#[cfg(feature = "crdt")] +fn validate_remote_crdt_op( + editor: &EditorState, + session_registry: &SessionRegistry, + source: FrontendId, + claimed_fid: FrontendId, + buffer_id: crate::buffer::BufferId, + op: &crate::rope::CrdtOp, +) -> Result<(), &'static str> { + let crdt_replica = session_registry + .session_state(source) + .is_some_and(|s| s.negotiated_capabilities.crdt_replica); + if !crdt_replica { + return Err("session did not negotiate crdt_replica"); + } + if claimed_fid != source { + return Err("event frontend_id does not match authenticated source"); + } + if op.peer_id != crate::crdt::peer_id_from_frontend(source) { + return Err("op.peer_id does not match authenticated source"); + } + let active_buffer_id = editor + .core + .borrow() + .active_window_for(source) + .map(|w| w.buffer_id); + if active_buffer_id != Some(buffer_id) { + return Err("buffer_id does not match source's active window buffer"); + } + // F26 (post-audit-round-4) — the wire wrapper's `op.peer_id` + // matches `source`, but the loro update bytes carry their own + // internal peer attribution. A hostile or buggy client can + // wrap update bytes generated under a different peer with the + // wrapper peer_id set correctly. Fork the buffer's CRDT state + // and inspect which peers' counters advance on import. Any + // peer that isn't the authenticated source's peer_id is a + // protocol violation. + let expected_peer_id = crate::crdt::peer_id_from_frontend(source); + let registry_handle = editor.core.borrow().registry.clone(); + let registry = registry_handle.borrow(); + if let Ok(buf) = registry.get(buffer_id) { + if buf + .validate_remote_op_peer_ids(expected_peer_id, &op.bytes) + .is_err() + { + return Err( + "op.bytes carry CRDT ops attributed to a peer other than the authenticated source", + ); + } + } + Ok(()) +} + +/// T M10.10 (post-audit) — apply a *pre-validated* +/// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks +/// happen upstream in `validate_remote_crdt_op`; this function trusts +/// `source` and `buffer_id` and performs four effects: +/// +/// 1. **Apply op to the buffer's CRDT state + rope projection** +/// via `Buffer::apply_remote_crdt_op`. Returns an `Edit` so +/// downstream effects can use the `range/inserted_len`. +/// 2. **Update source window's cursor** to the optimistic post-edit +/// position (`edit.range.start + edit.inserted_len` — matches +/// the source frontend's mirror cursor after `advance_cursor` or +/// `retreat_cursor`). Without this, the next per-tick `CursorByte` +/// carries the daemon's stale window cursor and snaps the source +/// frontend's mirror cursor back to the wrong byte. +/// 3. **Notify other windows displaying this buffer** of the edit +/// via `notify_buffer_edit`. Updates `TextView` line caches and +/// overlays so future cursor motions / paints derive from +/// current rope state. Adjusts other-window cursors using +/// right-gravity semantics (mirrors `Buffer::adjust_marks_for_edit` +/// behavior). +/// 4. **Queue for broadcast** to other replica frontends via +/// `pending_crdt_ops`. Sender-exclusion uses the authenticated +/// `source`. +#[cfg(feature = "crdt")] +fn handle_remote_crdt_op( + editor: &mut EditorState, + source: FrontendId, + buffer_id: crate::buffer::BufferId, + op: crate::rope::CrdtOp, +) { + // Effect 1: apply to buffer's CRDT + rope. Capture the Edit + // (or `None` for an op that imported cleanly but produced no + // text delta — F17). + let edit_opt = { + let core = editor.core.borrow(); + let registry_handle = core.registry.clone(); + drop(core); + let mut registry = registry_handle.borrow_mut(); + if let Ok(buf) = registry.get_mut(buffer_id) { + match buf.apply_remote_crdt_op(&op.bytes) { + Ok(opt) => opt, + Err(e) => { + eprintln!( + "pmacs daemon: apply_remote_crdt_op for \ + {buffer_id:?} failed: {e:?}; dropping op" + ); + return; + } + } + } else { + eprintln!("pmacs daemon: CrdtOp for unknown {buffer_id:?}; dropping op"); + return; + } + }; + + // Effects 2 + 3: update window cursors + notify views. ONLY + // when an Edit was produced — a CRDT import with no text delta + // (e.g. concurrent same-character delete) has nothing to + // notify but the op still needs broadcasting (F17). + if let Some(edit) = edit_opt.as_ref() { + let mut core = editor.core.borrow_mut(); + let post_edit_cursor = edit.range.start + edit.inserted_len; + + // Identify source's active window id (so we can skip it + // when adjusting other windows' cursors below; the source + // window's cursor is set to the optimistic post-edit + // position directly). + let source_active_window_id = core.views.get(&source).map(|v| v.active); + + // Right-gravity cursor adjustment shape (same as + // Buffer::adjust_marks_for_edit for MarkGravity::Right). + let old_len = edit.range.end - edit.range.start; + let new_end = edit.range.start + edit.inserted_len; + let inserted_len = edit.inserted_len; + + for (wid, win) in &mut core.windows { + if win.buffer_id != buffer_id { + continue; + } + if Some(*wid) == source_active_window_id { + // Source window: set directly to optimistic post-edit + // position (matches the source frontend's mirror + // cursor after advance/retreat). + win.cursor = post_edit_cursor; + continue; + } + // Other window displaying this buffer: shift cursor with + // right-gravity semantics. + let pos = win.cursor; + win.cursor = if pos < edit.range.start { + pos + } else if pos > edit.range.end { + pos - old_len + inserted_len + } else { + // Within edit range — clamp to new_end (right-gravity). + new_end + }; + } + + core.notify_buffer_edit(buffer_id, edit); + } + + // Effect 4: queue for broadcast. The source frontend's mirror + // already applied the op (this is the optimistic-replica + // path); use `OptimisticReplica(source)` so the broadcast + // sweep excludes it. F17: this push happens even when + // `edit_opt` is None — concurrent same-char deletes still + // need their CRDT causal metadata propagated to peers. + editor.core.borrow_mut().pending_crdt_ops.push(( + crate::editor_core::CrdtOpOrigin::OptimisticReplica(source), + buffer_id, + op, + )); +} + +// Non-CRDT build: the call site in `handle_dispatcher_event` is +// itself feature-gated, so no stub is needed. A non-CRDT daemon +// never receives `FrontendEvent::CrdtOp` from a properly-negotiated +// frontend because `InstanceCapabilities::default()` advertises +// `crdt_replica: false` in non-CRDT builds (Finding 3 fix). + +/// Build a `FrontendView` for an attaching frontend. +/// +/// T M10.8 Day 3 → T M10.9 update: attaching frontends now share +/// `FrontendId::LOCAL`'s active buffer (typically the daemon's +/// scratch buffer). Each gets its OWN `Window` instance — same +/// buffer, fresh cursor at position 0. This makes M10.9's +/// "two frontends in the same buffer see each other's cursors" +/// acceptance criterion observable: A and B start in the same +/// buffer, their `PresenceUpdate` broadcasts carry matching +/// `buffer_id`, the overlay paint fires. +/// +/// Frontends that want their own buffer can still do +/// `pmacs.editor.open(path)` to switch their window to a different +/// buffer; the per-frontend window-tree refactor (M10.8 Q1) makes +/// this independent. +fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView { + use crate::text_view::TextView; + use crate::window::{FrontendView, Layout, Window, WindowId}; + let mut core = editor.core.borrow_mut(); + // T M10.9 — share LOCAL's buffer (don't create a fresh + // scratch). M10.8's fresh-scratch behavior made overlays + // never fire because attaching frontends were in distinct + // buffers. + let local_view = core + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view present"); + let local_active_win_id = local_view.active; + let buffer_id = core + .windows + .get(&local_active_win_id) + .expect("LOCAL's active window present in core.windows") + .buffer_id; + let text_view = { + let reg = core.registry.borrow(); + let buf = reg.get(buffer_id).expect("shared buffer present"); + TextView::new(buf) + }; + let id = WindowId::next(); + let window = Window::new(id, buffer_id, text_view); + core.windows.insert(id, window); + FrontendView { + layout: Layout::single(id), + active: id, + } +} + +/// Snapshot one frontend's presence (cursor + selection + +/// containing buffer) for T M10.6/8's per-tick broadcast sweep. +/// +/// **T M10.8 — explicit `frontend_id` parameter.** M10.6 used the +/// active-frontend default via `core.active_window()`; M10.8 takes +/// the explicit `frontend_id` so the dispatcher can sweep multiple +/// frontends in one tick by calling this for each attached session. +/// If `frontend_id` has no registered view yet (Day 2 transitional +/// state before the dispatcher registers per-attach views), falls +/// back to the active window — preserves M10.6 behavior unchanged. +/// +/// The snapshot is taken at the tick boundary — the daemon's render +/// flush point — so multiple cursor moves between sweeps appear as +/// one snapshot transition. +fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> PresenceSnapshot { + let core = editor.core.borrow(); + let win = core + .active_window_for(frontend_id) + .unwrap_or_else(|| core.active_window()); + PresenceSnapshot { + buffer_id: win.buffer_id, + cursor: win.cursor, + selection: win.selection.map(|sel| SelectionSnapshot { + anchor: sel.anchor, + active: win.cursor, + }), + } +} + // Takes `ev` by value because it semantically consumes the event; // the caller pulls events out of the channel one at a time and never // needs to look at them again. @@ -519,24 +1591,21 @@ fn apply_event( // here. unreachable!("Detach is handled by run_per_attach directly"); } - } -} - -// `stream: UnixStream` is owned so the fd closes when the reader -// thread returns; this is the lifecycle we want, not what -// `needless_pass_by_value` suggests. -#[allow(clippy::needless_pass_by_value)] -fn run_reader(mut stream: UnixStream, tx: mpsc::Sender) { - loop { - match read_message::(&mut stream) { - Ok(ev) => { - if tx.send(ev).is_err() { - return; - } - } - // Any transport error ends the reader. The main thread - // sees the channel close and exits the per-attach loop. - Err(_) => return, + FrontendEvent::CrdtOp { .. } => { + // T M10.10 — handled by `handle_remote_crdt_op` directly + // from `handle_dispatcher_event` so the authenticated + // source FrontendId is in scope (the dispatcher's + // `DispatcherEvent::FrontendEvent { source, event }` tags + // the message with the per-attach-authenticated id, not + // the client-supplied `frontend_id` field on the variant). + // This arm is unreachable in practice; left as a defensive + // log in case future routing changes deliver a CrdtOp + // through `apply_event` instead. + eprintln!( + "pmacs daemon: FrontendEvent::CrdtOp reached apply_event; \ + this path is supposed to be intercepted in \ + handle_dispatcher_event. Dropping op." + ); } } } @@ -552,7 +1621,6 @@ mod tests { let b = s.next_frontend_id.fetch_add(1, Ordering::SeqCst); assert_eq!(a, 2); assert_eq!(b, 3); - assert!(!s.attached.load(Ordering::SeqCst)); } #[test] diff --git a/src/editor.rs b/src/editor.rs index 9f82401..4ceeb50 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -706,7 +706,7 @@ impl EditorState { /// origin (0 row = first text row of this window's content). fn activate_and_position(&mut self, win_id: WindowId, local_row: u32, local_col: u32) { let mut core = self.core.borrow_mut(); - core.active = win_id; + core.set_active_window_id(win_id); let view_top = core.windows[&win_id].view_top; let buffer_id = core.windows[&win_id].buffer_id; let display_row = view_top.saturating_add(local_row as usize); @@ -814,7 +814,7 @@ fn window_at_cell( return None; } let area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.layout.compute(area); + let placements = core.active_layout().compute(area); placements.iter().find_map(|(id, rect)| { if cell_row >= rect.origin.row && cell_row < rect.origin.row + rect.size.rows @@ -902,7 +902,8 @@ pub fn run(file: Option) -> io::Result<()> { let mut render_state = crate::instance_render::RenderState::new(frontend.size()); loop { - let messages = render_state.render_frame(&state); + // In-process TUI never has remote frontends; no overlays. + let messages = render_state.render_frame(&state, &[]); frontend.present_messages(&messages)?; if state.core.borrow().quit { break; @@ -1005,8 +1006,8 @@ pub fn paint_frame( // Compute per-window rectangles. The text area is the term size // minus the bottom row (status / minibuffer). let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.layout.compute(text_area); - let active = core.active; + let placements = core.active_layout().compute(text_area); + let active = core.active_window_id(); // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -1024,10 +1025,9 @@ pub fn paint_frame( let reg = registry.borrow(); let buf_id = core.active_buffer_id(); if let Ok(buf) = reg.get(buf_id) { - let aw = core - .windows - .get_mut(&active) - .expect("invariant: core.active is always a live window in core.windows"); + let aw = core.windows.get_mut(&active).expect( + "invariant: active_window_id always references a live window in core.windows", + ); let cursor_row = aw .text_view .pos_to_display(buf, aw.cursor) @@ -3077,7 +3077,7 @@ mod tests { let core = s.core.borrow(); assert_eq!(core.windows.len(), 8); let area = crate::window::Rect::new(0, 0, 40, 120); - let placements = core.layout.compute(area); + let placements = core.active_layout().compute(area); assert_eq!(placements.len(), 8); for r in placements.values() { assert!(!r.is_empty(), "rect was empty: {r:?}"); @@ -3099,13 +3099,13 @@ mod tests { ) .exec() .unwrap(); - let start = s.core.borrow().active; + let start = s.core.borrow().active_window_id(); let total = s.core.borrow().windows.len(); assert_eq!(total, 3); for _ in 0..total { s.core.borrow_mut().focus_next(); } - assert_eq!(s.core.borrow().active, start); + assert_eq!(s.core.borrow().active_window_id(), start); } /// Bullet 3: the buffer-list buffer is a regular Buffer in the @@ -3439,7 +3439,7 @@ mod tests { .unwrap(); // Set a 2:1 weight on the root split. if let crate::window::LayoutNode::Split { weights, .. } = - &mut s.core.borrow_mut().layout.root + &mut s.core.borrow_mut().active_layout_mut().root { *weights = vec![2, 1]; } else { @@ -3448,12 +3448,12 @@ mod tests { let p1 = s .core .borrow() - .layout + .active_layout() .compute(crate::window::Rect::new(0, 0, 24, 90)); let p2 = s .core .borrow() - .layout + .active_layout() .compute(crate::window::Rect::new(0, 0, 24, 60)); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. @@ -3488,10 +3488,11 @@ mod tests { // apply_active_edit. let core = s.core.borrow(); assert_eq!(core.active_buffer_len(), 6); + let active = core.active_window_id(); let other_id = core .windows .keys() - .find(|id| **id != core.active) + .find(|id| **id != active) .copied() .unwrap(); assert_eq!(core.windows[&other_id].buffer_id, buf_id); @@ -3508,7 +3509,7 @@ mod tests { ) -> Vec { use crate::cell::{Cell, CellGrid, CellSize}; use crate::view::Viewport; - let active = core.active; + let active = core.active_window_id(); let win = core.windows.get_mut(&active).unwrap(); let rect = crate::window::Rect::new(0, 0, 24, 80); let cell_count = (rect.size.rows * rect.size.cols) as usize; @@ -3644,7 +3645,7 @@ mod tests { let (single_avg_ns, dispatch_avg_ns, realistic_avg_ns) = { let mut core = s.core.borrow_mut(); - let active = core.active; + let active = core.active_window_id(); let buf_id = core.windows[&active].buffer_id; let registry = core.registry.clone(); let reg = registry.borrow(); @@ -4017,7 +4018,7 @@ mod tests { .load("pmacs.window.split_vertical()") .exec() .unwrap(); - let original_active = s.core.borrow().active; + let original_active = s.core.borrow().active_window_id(); // Click on the right side (col 60 — guaranteed in the second window // for any standard 80-col terminal split in half). s.dispatch_mouse( @@ -4025,7 +4026,7 @@ mod tests { mouse(MouseEventKind::Down(MouseButton::Left), 0, 60), term_size_24x80(), ); - let new_active = s.core.borrow().active; + let new_active = s.core.borrow().active_window_id(); assert_ne!( new_active, original_active, "click in other window did not activate it" diff --git a/src/editor_core.rs b/src/editor_core.rs index 3dfed05..e9d60a5 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -21,7 +21,7 @@ //! window's --- two windows on the same buffer keep their layout //! caches synchronized. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use crate::buffer::{Buffer, BufferId, EditOp}; @@ -33,19 +33,52 @@ use crate::rope::Edit; use crate::rope::{Position, Range}; use crate::text_view::TextView; use crate::view::{DisplayCoord, View}; -use crate::window::{Layout, Orientation, Window, WindowId}; +use crate::window::{FrontendView, Layout, Orientation, Window, WindowId}; + +/// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op. +/// +/// Records **whether the originating frontend already applied the +/// op to its local mirror**, which determines whether the broadcast +/// sweep should exclude that frontend. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum CrdtOpOrigin { + /// A replica frontend's `FrontendEvent::CrdtOp` path applied the + /// op to its local mirror before sending. Broadcast must exclude + /// that frontend (it would double-apply otherwise — see + /// `BufferMirror::apply_local_insert` / + /// `apply_local_delete` and `optimistic::apply_incoming_crdt_op`'s + /// echo-skip rule). + OptimisticReplica(FrontendId), + /// Daemon-side mutation (a `FrontendEvent::Key` round-trip, a + /// Lua-driven edit, a fallback path) generated the op. No + /// frontend has applied it locally; broadcast to every replica + /// frontend, including the one whose `Key` event drove the + /// daemon path (its mirror is otherwise stale). + DaemonKey, +} /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner /// of every buffer; windows reference buffers by [`BufferId`]. pub registry: SharedRegistry, - /// Open windows, keyed by id for stable iteration. + /// All windows, keyed by id for stable iteration. `WindowId`s + /// are globally unique across all frontends; each + /// [`FrontendView`] in `views` references a subset via its + /// `Layout`. pub windows: BTreeMap, - /// Window tree mapping the cell grid to windows. - pub layout: Layout, - /// The focused window. `pmacs.editor.*` primitives target it. - pub active: WindowId, + /// T M10.8 — per-frontend views. Each attached frontend has its + /// own `Layout` (split tree) + `active: WindowId`. Buffers are + /// shared via `registry`; cursors / `view_top`s live in the + /// per-frontend `Window` instances. + /// + /// Invariant: `FrontendId::LOCAL` always has an entry. The + /// in-process editor uses this view; daemon-attached frontends + /// register additional entries on attach (M10.8 Day 3 wires + /// the per-attach registration via the dispatcher; Day 2 ships + /// a fallback-to-LOCAL accessor so single-frontend tests pass + /// before per-attach registration lands). + pub views: HashMap, /// One-line message shown in the status line. pub status: String, /// True iff the editor should exit at the next iteration. @@ -68,6 +101,31 @@ pub struct EditorCore { /// (multi-window, multi-user) where each input event must be /// attributable to its source frontend. pub active_frontend: FrontendId, + /// T M10.8 Day 4 — pending CRDT ops queue. + /// + /// Each [`CrdtOpOrigin`] entry records both **what** to broadcast + /// and **who already applied it locally** (the sender-exclusion + /// signal). The dispatcher drains the queue per-tick and + /// broadcasts each op to multi-frontend sessions with + /// `crdt_replica` negotiated. + /// + /// # M10.10 post-audit-round-3 F16: origin tagging + /// + /// Sender exclusion depends on **whether the originating + /// frontend already applied the op to its local mirror**: + /// + /// - [`CrdtOpOrigin::OptimisticReplica`] — a replica frontend's + /// `FrontendEvent::CrdtOp` path applied the op to its mirror + /// before sending. Broadcast must exclude that frontend so it + /// doesn't double-apply. + /// - [`CrdtOpOrigin::DaemonKey`] — daemon-side mutation (a + /// `FrontendEvent::Key` round-trip, a Lua-driven edit, etc.) + /// generated the op. No frontend's mirror has applied it + /// locally; broadcast must include every replica frontend + /// *including* the active one. Without this, the + /// active frontend's mirror would silently drift from daemon + /// state after every fallback / Key-path edit. + pub pending_crdt_ops: Vec<(CrdtOpOrigin, BufferId, crate::rope::CrdtOp)>, } impl EditorCore { @@ -84,17 +142,25 @@ impl EditorCore { let window = Window::new(id, buffer_id, text_view); let mut windows = BTreeMap::new(); windows.insert(id, window); + let mut views = HashMap::new(); + views.insert( + FrontendId::LOCAL, + FrontendView { + layout: Layout::single(id), + active: id, + }, + ); Self { registry, windows, - layout: Layout::single(id), - active: id, + views, status: String::new(), quit: false, file_path: None, file_meta: None, minibuffer: Minibuffer::new(), active_frontend: FrontendId::LOCAL, + pending_crdt_ops: Vec::new(), } } @@ -127,19 +193,111 @@ impl EditorCore { // ---- accessors --------------------------------------------------------- - /// Reference the active [`Window`]. + /// T M10.8 — the active frontend's view (layout + active window). + /// + /// **Day 2 transitional behavior**: if `active_frontend` has no + /// registered view (the daemon-attached frontend case before Day + /// 3's dispatcher refactor wires `register_frontend_view`), fall + /// back to `FrontendId::LOCAL`'s view. The invariant "LOCAL + /// always has a view" is enforced by the constructor. + #[must_use] + pub fn active_view(&self) -> &FrontendView { + self.views.get(&self.active_frontend).unwrap_or_else(|| { + self.views.get(&FrontendId::LOCAL).expect( + "invariant: FrontendId::LOCAL always has a registered FrontendView; \ + populated by EditorCore::new and never removed", + ) + }) + } + + /// Mutable view of the active frontend's [`FrontendView`]. + /// + /// Same fallback semantics as [`active_view`]. + pub fn active_view_mut(&mut self) -> &mut FrontendView { + // Choose the key first to avoid borrowing `self.views` + // twice with overlapping lifetimes (the fallback path). + let key = if self.views.contains_key(&self.active_frontend) { + self.active_frontend + } else { + FrontendId::LOCAL + }; + self.views.get_mut(&key).expect( + "invariant: FrontendId::LOCAL always has a registered FrontendView; \ + populated by EditorCore::new and never removed", + ) + } + + /// The active frontend's window-split tree. + #[must_use] + pub fn active_layout(&self) -> &Layout { + &self.active_view().layout + } + + /// Mutable access to the active frontend's window-split tree. + pub fn active_layout_mut(&mut self) -> &mut Layout { + &mut self.active_view_mut().layout + } + + /// `WindowId` of the active frontend's focused window. + #[must_use] + pub fn active_window_id(&self) -> WindowId { + self.active_view().active + } + + /// Set the active frontend's focused window. + pub fn set_active_window_id(&mut self, id: WindowId) { + self.active_view_mut().active = id; + } + + /// Reference the active [`Window`] — the window currently + /// focused in the active frontend's view. #[must_use] pub fn active_window(&self) -> &Window { + let id = self.active_window_id(); self.windows - .get(&self.active) - .expect("active window present") + .get(&id) + .expect("active window present in core.windows") } /// Mutably reference the active [`Window`]. pub fn active_window_mut(&mut self) -> &mut Window { + let id = self.active_window_id(); self.windows - .get_mut(&self.active) - .expect("active window present") + .get_mut(&id) + .expect("active window present in core.windows") + } + + /// Reference a specific frontend's active [`Window`]. + /// + /// Returns `None` if `fid` has no registered view (no fallback — + /// callers explicitly asking about a specific frontend get a + /// truthful answer about whether that frontend has state). + #[must_use] + pub fn active_window_for(&self, fid: FrontendId) -> Option<&Window> { + let view = self.views.get(&fid)?; + self.windows.get(&view.active) + } + + /// Mutably reference a specific frontend's active [`Window`]. + pub fn active_window_mut_for(&mut self, fid: FrontendId) -> Option<&mut Window> { + let win_id = self.views.get(&fid)?.active; + self.windows.get_mut(&win_id) + } + + /// T M10.8 — register a `FrontendView` for `fid`. Called by the + /// daemon on attach (Day 3 dispatcher work). Day 2's fallback + /// path makes this optional; Day 3 makes it required. + pub fn register_frontend_view(&mut self, fid: FrontendId, view: FrontendView) { + self.views.insert(fid, view); + } + + /// T M10.8 — drop a frontend's view on detach. The frontend's + /// windows remain in `self.windows` until explicit cleanup (M10.x + /// may add per-detach window pruning); for M10.8 they're + /// orphaned but accessible by id (matches v0.1 behavior where + /// closing a window left others intact). + pub fn unregister_frontend_view(&mut self, fid: FrontendId) { + self.views.remove(&fid); } /// [`BufferId`] of the active window's buffer. @@ -218,6 +376,20 @@ impl EditorCore { } } } + // T M10.8 Day 4 — capture CRDT op (if the buffer was in + // CRDT mode and produced one) for the dispatcher to + // broadcast on the next tick. + // + // M10.10 post-audit-round-3 F16: this is the **daemon-side** + // mutation path (e.g. `FrontendEvent::Key` round-trip, + // Lua-driven edit, fallback). The source frontend's mirror + // has NOT applied this op locally; the queued origin is + // [`CrdtOpOrigin::DaemonKey`] so the broadcast sweep includes + // every replica (no sender exclusion). + if let Some(crdt_op) = edit.crdt_op.as_ref() { + self.pending_crdt_ops + .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone())); + } Ok(edit.new_rope.len()) } @@ -743,6 +915,13 @@ impl EditorCore { } } } + drop(reg); + // Post-audit-round-5 F27: undo on a CRDT-backed + // buffer produces a crdt_op that must broadcast to + // every replica frontend (including the one whose + // command triggered the undo — its BufferMirror has + // no other way to converge with the post-undo state). + self.queue_daemon_origin_crdt_op(buffer_id, &edit); } Err(_) => self.status = "nothing to undo".into(), } @@ -774,11 +953,38 @@ impl EditorCore { } } } + drop(reg); + // Post-audit-round-5 F27 — same as undo above. + self.queue_daemon_origin_crdt_op(buffer_id, &edit); } Err(_) => self.status = "nothing to redo".into(), } } + /// T M10.10 post-audit-round-5 F27 + F28 — queue a CRDT op + /// produced by a daemon-origin edit (undo/redo via core, Lua + /// bindings, command pipeline) for broadcast. + /// + /// Pushes into `pending_crdt_ops` with + /// [`CrdtOpOrigin::DaemonKey`] semantics: the broadcast sweep + /// includes every replica frontend (no sender exclusion). The + /// originating frontend's `BufferMirror` has not applied the op + /// locally — only the daemon's authoritative buffer has — so + /// the source's mirror needs the broadcast just like every + /// other replica. + /// + /// No-op when the edit doesn't carry a `crdt_op` (the buffer + /// wasn't CRDT-backed at the time of the edit). Callers can + /// invoke this unconditionally after any daemon-origin + /// `apply_*` that returns an `Edit`; non-CRDT buffers pay no + /// cost beyond the early return. + pub fn queue_daemon_origin_crdt_op(&mut self, buffer_id: BufferId, edit: &Edit) { + if let Some(crdt_op) = edit.crdt_op.as_ref() { + self.pending_crdt_ops + .push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone())); + } + } + // ---- window operations ------------------------------------------------- /// Split the active window. Returns the new window's id. @@ -799,18 +1005,24 @@ impl EditorCore { let new_id = WindowId::next(); let new_window = Window::new(new_id, buffer_id, text_view); self.windows.insert(new_id, new_window); - self.layout.split_window(self.active, orientation, new_id); + let active = self.active_window_id(); + self.active_layout_mut() + .split_window(active, orientation, new_id); new_id } /// Move focus to the next window in iteration order. pub fn focus_next(&mut self) { - self.active = self.layout.focus_next(self.active); + let active = self.active_window_id(); + let next = self.active_layout().focus_next(active); + self.set_active_window_id(next); } /// Move focus to the previous window in iteration order. pub fn focus_prev(&mut self) { - self.active = self.layout.focus_prev(self.active); + let active = self.active_window_id(); + let prev = self.active_layout().focus_prev(active); + self.set_active_window_id(prev); } /// Close the active window (unless it's the only one). Returns @@ -819,22 +1031,23 @@ impl EditorCore { if self.windows.len() <= 1 { return false; } - let target = self.active; - self.layout.close_window(target); + let target = self.active_window_id(); + self.active_layout_mut().close_window(target); self.windows.remove(&target); // Pick an adjacent window as the new focus. - self.active = *self - .layout + let next = *self + .active_layout() .iter_ids() .first() .expect("at least one window remains"); + self.set_active_window_id(next); true } /// Close every window except the active one. pub fn close_others(&mut self) { - let keep = self.active; - self.layout.keep_only(keep); + let keep = self.active_window_id(); + self.active_layout_mut().keep_only(keep); self.windows.retain(|id, _| *id == keep); } @@ -1326,10 +1539,84 @@ mod tests { assert!(s.status.contains("no file")); } + /// T M10.8 — pins the Day 2 transitional fallback behavior in + /// [`EditorCore::active_view`]. + /// + /// **Day 2 → Day 3 transition contract**: while the dispatcher + /// thread is being wired (Day 3 work), the daemon may set + /// `active_frontend` to a daemon-attached `FrontendId` whose + /// `FrontendView` hasn't been registered yet. The fallback to + /// `FrontendId::LOCAL`'s view keeps single-frontend behavior + /// observable. + /// + /// **Day 3 cleanup**: once + /// [`EditorCore::register_frontend_view`] is invariantly called + /// before any event dispatch, this test flips to assert "every + /// `active_frontend` has its own registered view, no fallback + /// ever activates." Until then, the fallback is the bridge. + #[test] + fn active_view_falls_back_to_local_when_active_frontend_unregistered() { + let mut s = fresh(); + // Default active_frontend is LOCAL → no fallback yet. + assert_eq!(s.active_frontend, FrontendId::LOCAL); + let local_active_window = s.active_view().active; + + // Simulate the Day 2 transitional state: a daemon-attached + // frontend's id is set as active, but no FrontendView is + // registered for it (Day 3 work). + s.active_frontend = FrontendId(42); + assert!(!s.views.contains_key(&FrontendId(42))); + + // Fallback activates: active_view() returns LOCAL's view. + let fallback_view = s.active_view(); + assert_eq!( + fallback_view.active, local_active_window, + "Day 2 fallback: active_view() returns LOCAL's view when active_frontend has no entry" + ); + + // Same for active_window(). + let win = s.active_window(); + assert_eq!(win.id, local_active_window); + } + + #[test] + fn active_view_for_explicit_fid_returns_none_when_unregistered() { + // T M10.8 — explicit-fid lookups don't fall back. Callers + // explicitly asking about a specific frontend get a truthful + // None when that frontend has no state, distinguishing + // "active by default" from "actually has its own view." + let s = fresh(); + assert!(s.active_window_for(FrontendId(42)).is_none()); + assert!(s.active_window_for(FrontendId::LOCAL).is_some()); + } + + #[test] + fn register_and_unregister_frontend_view() { + // T M10.8 — the lifecycle API the dispatcher uses on attach + // and detach. Wiring lives in `daemon.rs`; this test pins + // the EditorCore-side semantics. + let mut s = fresh(); + let fid = FrontendId(7); + assert!(s.active_window_for(fid).is_none()); + + // Build a view referencing the existing scratch window so + // we don't need a fresh window allocation in this test. + let local_view = s.views[&FrontendId::LOCAL].clone(); + s.register_frontend_view(fid, local_view); + assert!(s.active_window_for(fid).is_some()); + + // Unregister drops the entry; explicit lookup returns None. + s.unregister_frontend_view(fid); + assert!(s.active_window_for(fid).is_none()); + + // LOCAL invariant survives unrelated register/unregister. + assert!(s.views.contains_key(&FrontendId::LOCAL)); + } + #[test] fn split_active_creates_a_second_window_on_same_buffer() { let mut s = fresh(); - let original = s.active; + let original = s.active_window_id(); let new_id = s.split_active(Orientation::Vertical, true); assert_ne!(new_id, original); assert_eq!(s.windows.len(), 2); @@ -1349,12 +1636,8 @@ mod tests { assert_eq!(s.active_buffer_len(), 4); // The other window's text_view has the same line count, // confirming on_edit fired. - let other = s - .windows - .keys() - .find(|id| **id != s.active) - .copied() - .unwrap(); + let active = s.active_window_id(); + let other = s.windows.keys().find(|id| **id != active).copied().unwrap(); assert_eq!(s.windows[&other].text_view.line_count(), 1); } @@ -1377,17 +1660,112 @@ mod tests { #[test] fn focus_next_round_robins() { let mut s = fresh(); - let a = s.active; + let a = s.active_window_id(); let _b = s.split_active(Orientation::Vertical, true); let _c = s.split_active(Orientation::Horizontal, true); // Splits don't move focus; `a` is still active. - assert_eq!(s.active, a); - let order = s.layout.iter_ids(); + assert_eq!(s.active_window_id(), a); + let order = s.active_layout().iter_ids(); assert_eq!(order.len(), 3); // Walking N times wraps back to the original. for _ in 0..3 { s.focus_next(); } - assert_eq!(s.active, a); + assert_eq!(s.active_window_id(), a); + } + + // ------------------------------------------------------------------ + // F27 / F28 (post-audit-round-5) — daemon-origin CRDT ops are + // queued on `pending_crdt_ops` so they reach all replicas. + // ------------------------------------------------------------------ + + /// Helper: upgrade the active buffer to CRDT-backed under the + /// LOCAL peer id (mirrors what the daemon does at attach time + /// for replica sessions). + #[cfg(feature = "crdt")] + fn upgrade_active_to_crdt(s: &mut EditorCore) { + let buffer_id = s.active_buffer_id(); + let mut reg = s.registry.borrow_mut(); + let buf = reg.get_mut(buffer_id).expect("active buffer present"); + buf.upgrade_to_crdt(crate::crdt::peer_id_from_frontend( + crate::protocol::FrontendId::LOCAL, + )) + .expect("upgrade"); + } + + /// F27 — undo on a CRDT-backed buffer queues the resulting + /// CRDT op for broadcast. + #[cfg(feature = "crdt")] + #[test] + fn undo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() { + let mut s = from_bytes(b"abc"); + upgrade_active_to_crdt(&mut s); + // Apply an edit so there's something to undo. apply_active_edit + // also pushes a DaemonKey-origin op. + s.apply_active_edit(crate::buffer::EditOp::Insert { + pos: 3, + bytes: b"X", + }) + .expect("edit"); + let queued_after_edit = s.pending_crdt_ops.len(); + assert!(queued_after_edit >= 1, "edit must queue a CRDT op"); + + // Drain to isolate the undo's queueing. + s.pending_crdt_ops.clear(); + s.undo(); + + assert!( + !s.pending_crdt_ops.is_empty(), + "F27: undo on a CRDT-backed buffer must queue a CRDT op for broadcast" + ); + // Origin must be DaemonKey (broadcast-to-all-replicas). + let (origin, _, _) = &s.pending_crdt_ops[0]; + assert!( + matches!(origin, CrdtOpOrigin::DaemonKey), + "F27: undo's CRDT op must be queued with DaemonKey origin (broadcast to all replicas including active frontend)" + ); + } + + /// F27 — redo on a CRDT-backed buffer queues the resulting + /// CRDT op for broadcast. + #[cfg(feature = "crdt")] + #[test] + fn redo_on_crdt_buffer_queues_crdt_op_for_broadcast_f27() { + let mut s = from_bytes(b"abc"); + upgrade_active_to_crdt(&mut s); + s.apply_active_edit(crate::buffer::EditOp::Insert { + pos: 3, + bytes: b"X", + }) + .expect("edit"); + s.undo(); + s.pending_crdt_ops.clear(); + s.redo(); + assert!( + !s.pending_crdt_ops.is_empty(), + "F27: redo on a CRDT-backed buffer must queue a CRDT op for broadcast" + ); + let (origin, _, _) = &s.pending_crdt_ops[0]; + assert!(matches!(origin, CrdtOpOrigin::DaemonKey)); + } + + /// F27 — undo on a non-CRDT buffer is a no-op for the broadcast + /// queue (the buffer produced no `crdt_op` on the Edit). + #[test] + fn undo_on_non_crdt_buffer_does_not_queue_crdt_op_f27() { + let mut s = from_bytes(b"abc"); + s.apply_active_edit(crate::buffer::EditOp::Insert { + pos: 3, + bytes: b"X", + }) + .expect("edit"); + // Non-CRDT — apply_active_edit's pending push is a no-op + // (Edit::crdt_op is None). Confirm precondition then undo. + assert!(s.pending_crdt_ops.is_empty()); + s.undo(); + assert!( + s.pending_crdt_ops.is_empty(), + "F27: undo on a non-CRDT buffer must not produce a phantom queue entry" + ); } } diff --git a/src/frontend.rs b/src/frontend.rs index 7f929b1..b11108a 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -46,6 +46,8 @@ use std::io::{self, BufWriter, Stdout, Write}; use std::time::Duration; +#[cfg(feature = "crdt")] +use crossterm::{cursor::MoveLeft, style::Print}; use crossterm::{ cursor::{self, MoveTo}, event::{ @@ -226,6 +228,93 @@ impl Frontend { self.out.flush() } + /// T M10.10 Day 3 step 5 Path β — paint an optimistic insert. + /// + /// The character is written at the terminal's current cursor + /// position; the terminal advances the cursor by one column. + /// This is the visual half of the optimistic-apply path: + /// `BufferMirror::apply_local_insert` updated the CRDT mirror; + /// this method updates the user-visible display in the same + /// keystroke. + /// + /// Called only when the cursor is at end-of-line for the active + /// buffer (per `BufferMirror::cursor_at_end_of_line`). End-of- + /// line is the dominant typing case and the only case where the + /// daemon's eventual `CellDelta` matches a single-Print + /// optimistic paint exactly (no cells right of cursor to shift). + /// + /// # Post-audit round 2 (F15): style-blindness + /// + /// This paint is **default-style only**. We explicitly reset + /// terminal attributes before the `Print` so the painted glyph + /// is deterministic and doesn't inherit leftover SGR state from + /// a prior `emit_span`. The `emit_span` epilogue already issues + /// `ResetColor + SetAttribute(Attribute::Reset)`, but the + /// invariant is fragile across crossterm versions and we'd + /// rather pay one extra reset than re-flash whatever style the + /// previous span set. + /// + /// **Honest scope**: if the cell the daemon will eventually + /// paint into has a non-default style (e.g., a diagnostic + /// region, a syntax-highlighted token in a future milestone), + /// the optimistic glyph briefly renders default-styled until the + /// authoritative `CellDelta` arrives (within one frame target). + /// For v0.1 there is no syntax-highlighting pipeline; styled + /// regions are restricted to diagnostics squiggles, completion + /// popups, and overlays — none of which typically sit on the + /// end-of-line cell that Path β paints into. A future milestone + /// that introduces in-buffer styled content should track the + /// cursor-cell's pending style from the previous `CellDelta` and + /// apply it here, or suppress the optimistic paint on styled + /// cells altogether. The right fix needs per-cell style memory + /// the attach loop doesn't carry today. + #[cfg(feature = "crdt")] + pub fn paint_optimistic_insert(&mut self, c: char) -> io::Result<()> { + queue!( + self.out, + ResetColor, + SetAttribute(Attribute::Reset), + Print(c) + )?; + self.out.flush() + } + + /// T M10.10 Day 3 step 5 Path β — paint an optimistic + /// delete-back. + /// + /// Sequence: move cursor one column left, overwrite the cell + /// with a space, retreat cursor one column to its final + /// position. Matches what the daemon's eventual `CellDelta` + /// will carry: the last char of the line becomes a space at + /// the cursor's pre-edit column. + /// + /// Called only when the cursor is at end-of-line and there's a + /// previous character to erase. Mid-line backspace falls + /// through to v0.1 round-trip per Path β scope. + /// + /// # Post-audit round 2 (F15): style-blindness + /// + /// The space is painted with default style (explicit reset + /// before `Print`). For end-of-line backspace this is correct + /// in nearly all v0.1 cases: the cell becomes empty / cleared, + /// and the daemon's eventual `CellDelta` for an empty cell is + /// itself default-styled. Same scope caveat as + /// [`Self::paint_optimistic_insert`] for any future milestone + /// where the post-erase cell might re-render with a non-default + /// background or syntax style. + #[cfg(feature = "crdt")] + pub fn paint_optimistic_delete_back(&mut self) -> io::Result<()> { + queue!( + self.out, + MoveLeft(1), + ResetColor, + SetAttribute(Attribute::Reset), + Print(' '), + MoveLeft(1) + )?; + self.out.flush() + } + /// Apply a single [`InstanceMessage`] to the terminal. /// /// `CellDelta` emits one cursor-move + run-of-glyphs sequence per @@ -253,7 +342,33 @@ impl Frontend { }, InstanceMessage::ModeLine(_) | InstanceMessage::Signal(_) - | InstanceMessage::Goodbye(_) => { + | InstanceMessage::Goodbye(_) + // T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't + // maintain a local CRDT state yet (M10.8 wires that). A v2 + // daemon shouldn't send CrdtOp to this frontend because our + // FrontendCapabilities advertise crdt_replica: false. If one + // arrives anyway, drop it silently — same v0.1-ignored + // category as ModeLine / Signal / Goodbye for now. + | InstanceMessage::CrdtOp { .. } + // T M10.6: PresenceUpdate joins the v0.1-ignored category. + // The peer-cursor overlay renderer is M10.8 work; until + // then any incoming PresenceUpdate is dropped silently. + | InstanceMessage::PresenceUpdate { .. } + // T M10.10: BufferSnapshot is consumed by the BufferMirror + // layer on M10.10-aware frontends (gated by negotiated + // `crdt_replica`). The legacy TUI render path here doesn't + // maintain a BufferMirror, so the variant drops silently + // in this path. The M10.10 frontend wiring intercepts + // BufferSnapshot in the attach.rs message loop BEFORE it + // reaches apply_message. + | InstanceMessage::BufferSnapshot { .. } + // T M10.10: CursorByte is paired with Cursor for replica + // frontends. The cursor's grid position (consumed by the + // legacy render path above via Cursor) drives paint; the + // byte position (consumed by BufferMirror's cursor tracker + // in attach.rs) drives optimistic-apply. The legacy path + // here only needs grid; the byte variant drops silently. + | InstanceMessage::CursorByte { .. } => { // v0.1 TUI ignores these; v0.3 GUI consumes them. } } diff --git a/src/help.rs b/src/help.rs index 9d82ab4..a71fb7d 100644 --- a/src/help.rs +++ b/src/help.rs @@ -36,9 +36,21 @@ use crate::keymap_tree::{Binding, Keymap}; /// [`BufferRegistry::find_by_name`]. pub const HELP_BUFFER_NAME: &str = "*help*"; -/// Result of a render: the buffer id of `*help*`, or [`None`] if the +/// Result of a render: the buffer id of `*help*` paired with the +/// Edits produced by the content replacement, or [`None`] if the /// described target doesn't exist (e.g. unknown command name). -pub type RenderResult = Option; +/// +/// # 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)>; // --------------------------------------------------------------------------- // 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) { let id = registry .find_by_name(HELP_BUFFER_NAME) .unwrap_or_else(|| registry.create(HELP_BUFFER_NAME)); let buf = registry.get_mut(id).expect("just resolved"); + let mut edits = Vec::new(); if !buf.is_empty() { let len = buf.len(); - let _ = buf.apply_edit(EditOp::Delete { + if let Ok(edit) = buf.apply_edit(EditOp::Delete { range: crate::rope::Range::new(0, len), - }); + }) { + edits.push(edit); + } } if !text.is_empty() { - let _ = buf.apply_edit(EditOp::Insert { + if let Ok(edit) = buf.apply_edit(EditOp::Insert { pos: 0, bytes: text.as_bytes(), - }); + }) { + edits.push(edit); + } } // The help buffer is regenerated content; mark it clean so the // modeline doesn't claim it has unsaved changes. buf.mark_clean(); - id + (id, edits) } // --------------------------------------------------------------------------- @@ -386,10 +406,11 @@ fn read_buffer_text(buf: &Buffer) -> String { String::from_utf8(out).unwrap_or_default() } -/// Result of [`follow_link_at`]: the help buffer id (re-rendered if a -/// link was found), or [`None`] if the cursor wasn't on a recognized -/// link. -pub type FollowResult = Option; +/// Result of [`follow_link_at`]: the help buffer id paired with the +/// Edits produced by the re-render (zero, one, or two), or [`None`] +/// if the cursor wasn't on a recognized link. Same broadcast-queueing +/// contract as [`RenderResult`]. +pub type FollowResult = Option<(BufferId, Vec)>; /// Parse the link under the cursor in the `*help*` buffer and /// re-render. Returns the help buffer id on success. @@ -489,7 +510,7 @@ mod tests { }, ) .unwrap(); - let id = render_command(&mut reg, &cmds, &kms, "cursor.left").unwrap(); + let (id, _) = render_command(&mut reg, &cmds, &kms, "cursor.left").unwrap(); let body = read_buffer_text(reg.get(id).unwrap()); assert!(body.contains("Command: cursor.left")); assert!(body.contains("Move cursor left.")); @@ -521,7 +542,7 @@ mod tests { }, ) .unwrap(); - let id = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap(); + let (id, _) = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap(); let body = read_buffer_text(reg.get(id).unwrap()); assert!(body.contains("Key: C-x C-s")); assert!(body.contains("[command: save]")); @@ -690,7 +711,7 @@ mod tests { // Find cursor on the cross-ref. let body = read_help(®); let cursor = body.find("beta").unwrap() as u64; - let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); assert_eq!(returned, id); let body = read_help(®); assert!(body.contains("Command: beta"), "{body}"); @@ -724,7 +745,7 @@ mod tests { "buffer-local key link must carry its buffer scope: {body}" ); let cursor = body.find("s @buffer").unwrap() as u64; - let returned = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap()); let body = read_help(®); assert!(body.contains("Key: s"), "{body}"); diff --git a/src/instance_buffer.rs b/src/instance_buffer.rs index c37b977..45fc035 100644 --- a/src/instance_buffer.rs +++ b/src/instance_buffer.rs @@ -25,7 +25,7 @@ use std::fmt::Write; -use crate::buffer::{BufferId, EditOp}; +use crate::buffer::{Buffer, BufferId, EditOp}; use crate::buffer_registry::BufferRegistry; use crate::protocol::{AttachmentHandle, InstanceIdentity}; @@ -69,8 +69,14 @@ pub fn format_echo_line( } /// Render the full description into the `*pmacs-instance*` buffer -/// (creating it if absent), replacing its full contents. Returns the -/// buffer id. +/// (creating it if absent), replacing its full contents. Returns +/// the buffer id and the Edits produced by the replacement. +/// +/// # Post-audit-round-6 F31 — broadcast queueing +/// +/// Returning the Edits lets the caller queue any `crdt_op` they +/// carry via `EditorCore::queue_daemon_origin_crdt_op`. See the +/// equivalent doc on `workers_buffer::render`. /// /// The buffer is marked clean — the modeline shouldn't claim unsaved /// changes for a generated buffer. @@ -78,26 +84,46 @@ pub fn render( registry: &mut BufferRegistry, identity: &InstanceIdentity, attachment: Option<&AttachmentHandle>, -) -> BufferId { +) -> (BufferId, Vec) { let text = format_full_text(identity, attachment); let id = registry .find_by_name(INSTANCE_BUFFER_NAME) .unwrap_or_else(|| registry.create(INSTANCE_BUFFER_NAME)); let buf = registry.get_mut(id).expect("just resolved"); + let mut edits = Vec::new(); + if buffer_contents_equal(buf, &text) { + buf.mark_clean(); + return (id, edits); + } if !buf.is_empty() { let len = buf.len(); - let _ = buf.apply_edit(EditOp::Delete { + if let Ok(edit) = buf.apply_edit(EditOp::Delete { range: crate::rope::Range::new(0, len), - }); + }) { + edits.push(edit); + } } if !text.is_empty() { - let _ = buf.apply_edit(EditOp::Insert { + if let Ok(edit) = buf.apply_edit(EditOp::Insert { pos: 0, bytes: text.as_bytes(), - }); + }) { + edits.push(edit); + } } buf.mark_clean(); - id + (id, edits) +} + +fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool { + if buf.len() != text.len() as u64 { + return false; + } + let mut bytes = vec![0u8; text.len()]; + if !bytes.is_empty() { + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + } + bytes == text.as_bytes() } /// Multi-section text payload for the buffer view. Sections: @@ -360,7 +386,7 @@ mod tests { #[test] fn render_creates_named_buffer_and_writes_text() { let mut reg = BufferRegistry::new(); - let id = render(&mut reg, &local_identity(), None); + let (id, _edits) = render(&mut reg, &local_identity(), None); let buf = reg.get(id).expect("just rendered"); assert_eq!(buf.name(), INSTANCE_BUFFER_NAME); let len = buf.len(); @@ -378,8 +404,8 @@ mod tests { #[test] fn render_replaces_existing_contents_on_second_call() { let mut reg = BufferRegistry::new(); - let id1 = render(&mut reg, &local_identity(), None); - let id2 = render(&mut reg, &named_identity(), None); + let (id1, _) = render(&mut reg, &local_identity(), None); + let (id2, _) = render(&mut reg, &named_identity(), None); assert_eq!(id1, id2, "render must reuse the named buffer"); let buf = reg.get(id2).expect("rendered"); let len = buf.len(); @@ -395,11 +421,26 @@ mod tests { ); } + #[test] + fn render_same_identity_is_no_op() { + let mut reg = BufferRegistry::new(); + let (_id, first_edits) = render(&mut reg, &local_identity(), None); + assert!( + !first_edits.is_empty(), + "initial render should create buffer contents" + ); + let (_id, second_edits) = render(&mut reg, &local_identity(), None); + assert!( + second_edits.is_empty(), + "unchanged instance render must not emit delete/insert edits" + ); + } + #[test] fn render_with_attachment_includes_remote_section() { let mut reg = BufferRegistry::new(); let h = sample_attachment(); - let id = render(&mut reg, &local_identity(), Some(&h)); + let (id, _) = render(&mut reg, &local_identity(), Some(&h)); let buf = reg.get(id).expect("rendered"); let len = buf.len(); let mut bytes = vec![0u8; usize::try_from(len).unwrap()]; diff --git a/src/instance_render.rs b/src/instance_render.rs index 477d7b3..4cbe405 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -86,7 +86,17 @@ impl RenderState { /// Returns a `CellDelta` (with the changed spans) followed by a /// `Cursor` message. Returns an empty vec if the grid is too small /// to render meaningfully (`rows < 2` or `cols == 0`). - pub fn render_frame(&mut self, state: &EditorState) -> Vec { + /// + /// `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 { if self.size.rows < 2 || self.size.cols == 0 { return Vec::new(); } @@ -97,7 +107,17 @@ impl RenderState { stride: self.size.cols, size: self.size, }; - paint_frame(state, &mut grid, self.size) + let coord = paint_frame(state, &mut grid, self.size); + // T M10.9 — overlay paint after main paint, before diff. + // Modifies cells in `next`; diff captures the changes + // as ordinary style updates. + crate::overlay_paint::paint_other_frontend_overlays( + state, + &mut grid, + self.size, + other_presences, + ); + coord }; // Full-grid sync semantics (T M5.3): when `needs_full_grid` is @@ -166,7 +186,7 @@ mod tests { #[test] fn render_returns_cell_delta_and_cursor() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state()); + let msgs = r.render_frame(&empty_state(), &[]); assert_eq!(msgs.len(), 2); assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. })); assert!(matches!(msgs[1], InstanceMessage::Cursor(_))); @@ -175,7 +195,7 @@ mod tests { #[test] fn first_frame_is_full_grid_sync() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state()); + let msgs = r.render_frame(&empty_state(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => panic!("expected CellDelta first"), @@ -185,8 +205,8 @@ mod tests { #[test] fn second_frame_is_differential() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state()); - let msgs = r.render_frame(&empty_state()); + let _ = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid), _ => panic!("expected CellDelta first"), @@ -197,8 +217,8 @@ mod tests { fn unchanged_state_produces_empty_spans_after_first_frame() { let state = empty_state(); let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&state); - let msgs = r.render_frame(&state); + let _ = r.render_frame(&state, &[]); + let msgs = r.render_frame(&state, &[]); match &msgs[0] { InstanceMessage::CellDelta { spans, .. } => assert!( spans.is_empty(), @@ -211,7 +231,7 @@ mod tests { #[test] fn resize_reallocates_and_flags_full_grid() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state()); + let _ = r.render_frame(&empty_state(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(40, 120)); @@ -220,7 +240,7 @@ mod tests { assert_eq!(r.next.len(), 40 * 120); assert!(r.needs_full_grid); - let msgs = r.render_frame(&empty_state()); + let msgs = r.render_frame(&empty_state(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => unreachable!(), @@ -230,7 +250,7 @@ mod tests { #[test] fn resize_to_same_size_is_noop() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state()); + let _ = r.render_frame(&empty_state(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(24, 80)); // No reallocation, no full-grid flip. @@ -240,7 +260,7 @@ mod tests { #[test] fn force_full_grid_resync_flips_flag() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state()); + let _ = r.render_frame(&empty_state(), &[]); assert!(!r.needs_full_grid); r.force_full_grid_resync(); assert!(r.needs_full_grid); @@ -250,16 +270,16 @@ mod tests { fn too_small_grid_returns_empty_messages() { // rows < 2 means we can't paint a text-area + status row. let mut r = RenderState::new(CellSize::new(1, 80)); - assert!(r.render_frame(&empty_state()).is_empty()); + assert!(r.render_frame(&empty_state(), &[]).is_empty()); let mut r = RenderState::new(CellSize::new(24, 0)); - assert!(r.render_frame(&empty_state()).is_empty()); + assert!(r.render_frame(&empty_state(), &[]).is_empty()); } #[test] fn cursor_message_carries_coord_when_paint_returns_one() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state()); + let msgs = r.render_frame(&empty_state(), &[]); match &msgs[1] { InstanceMessage::Cursor(Some(cs)) => { assert!(cs.visible); @@ -289,7 +309,7 @@ mod tests { // Criterion 1: the first frame after construction is a full-grid // CellDelta carrying every non-default cell. let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state()); + let msgs = r.render_frame(&empty_state(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "first frame must be flagged full_grid=true"); @@ -315,7 +335,7 @@ mod tests { let mut state = EditorState::new(); let mut r = RenderState::new(size); // Seat the prev buffer. - let _ = r.render_frame(&state); + let _ = r.render_frame(&state, &[]); // Single character insert. state.dispatch_key( @@ -327,7 +347,7 @@ mod tests { state: KeyEventState::empty(), }, ); - let msgs = r.render_frame(&state); + let msgs = r.render_frame(&state, &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid, "differential frame must not flag full_grid"); @@ -354,7 +374,7 @@ mod tests { let mut r = RenderState::new(size); // First render: seats prev with the painted frame. - let first = r.render_frame(&empty_state()); + let first = r.render_frame(&empty_state(), &[]); let baseline_changed: usize = match &first[0] { InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(), _ => unreachable!(), @@ -363,7 +383,7 @@ mod tests { // A second render with no state change normally produces zero // spans (the state matches prev exactly). - let unchanged = r.render_frame(&empty_state()); + let unchanged = r.render_frame(&empty_state(), &[]); match &unchanged[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid); @@ -376,7 +396,7 @@ mod tests { // what's on screen. force_full_grid_resync flags the next frame // for full sync. r.force_full_grid_resync(); - let resync = r.render_frame(&empty_state()); + let resync = r.render_frame(&empty_state(), &[]); match &resync[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "post-resync frame must be full_grid=true"); diff --git a/src/lib.rs b/src/lib.rs index 74896fc..9668714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,15 @@ pub mod command; pub mod completion; pub mod completion_framework; pub mod config; +// T M10.2: CRDT-backed buffer state. Feature-gated so v0.1 builds +// carry zero overhead — the `loro` dependency isn't pulled in, no +// field on the Buffer struct layout, no branch on `apply_edit`. +#[cfg(feature = "crdt")] +pub mod crdt; +// T M10.10: frontend-side CRDT replica for optimistic local edits. +// Gated on `crdt` because BufferMirror wraps `CrdtState`. +#[cfg(feature = "crdt")] +pub mod buffer_mirror; pub mod daemon; pub mod daemon_attach; pub mod definition; @@ -66,8 +75,16 @@ pub mod lua_isolation; pub mod mcp; pub mod message_bus; pub mod minibuffer; +// T M10.10: frontend-side optimistic-apply infrastructure (predicate +// + echo-dedup filter). Gated on `crdt` because it consumes +// BufferMirror. +#[cfg(feature = "crdt")] +pub mod optimistic; pub mod overlay; +pub mod overlay_color; +pub mod overlay_paint; pub mod packages; +pub mod presence; pub mod process; pub mod project; pub mod project_index; diff --git a/src/lua.rs b/src/lua.rs index bd6c20b..11026bf 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -409,8 +409,18 @@ impl LuaHost { // switched to it via C-x b), its line cache would otherwise go // stale on every appended error and cursor motion would stop // updating the screen. + // + // Post-audit-round-6 F32 — also queue the resulting CRDT op + // for broadcast if the buffer is CRDT-backed. `*errors*` + // gets upgraded to CRDT at every replica's attach via + // `send_buffer_snapshots`, so each Lua-runtime-driven append + // produces an `Edit::crdt_op` that must reach replica + // `BufferMirror`s — otherwise their mirrors permanently + // desync from daemon state for `*errors*`. if let Some(core) = self.core.as_ref() { - core.borrow_mut().notify_buffer_edit(id, &edit); + let mut core = core.borrow_mut(); + core.notify_buffer_edit(id, &edit); + core.queue_daemon_origin_crdt_op(id, &edit); } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 82b70e6..990b10a 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -1198,11 +1198,32 @@ fn add_history_methods>(methods: &mut M) { } /// Notify every window currently displaying `buffer_id` that the -/// buffer was just edited via the Lua surface. Without this, a window -/// already displaying the edited buffer would keep a stale +/// buffer was just edited via the Lua surface, AND queue the edit's +/// CRDT op (if any) for broadcast to replica frontends. +/// +/// Without the window notification, a window already displaying the +/// edited buffer would keep a stale /// [`crate::text_view::TextView`] line cache — cursor motions stop /// updating the screen until the window switches buffers. /// +/// # Post-audit-round-5 F28: daemon-origin CRDT op broadcast +/// +/// Lua-driven edits (`buf:insert`, `buf:delete`, `buf:replace`, +/// `buf:undo`, `buf:redo`) on CRDT-backed buffers produce Edits with +/// `crdt_op` populated. Without explicit broadcast queueing, those +/// ops never reach replica frontends — their `BufferMirror`s see the +/// resulting `CellDelta` repaint but never import the CRDT op, so +/// subsequent optimistic edits on the replica are generated against +/// stale mirror content. +/// +/// We push the op as +/// [`crate::editor_core::CrdtOpOrigin::DaemonKey`] (via +/// `EditorCore::queue_daemon_origin_crdt_op`) so the broadcast sweep +/// includes every replica with no sender exclusion: no frontend +/// applied the op locally; every replica's mirror needs the bytes. +/// +/// # No-op cases +/// /// No-op when no [`SharedCore`] has been registered as Lua app data /// (the shape used by the early-stage tests that exercise the /// registry without an editor core). @@ -1210,7 +1231,12 @@ fn notify_buffer_edit_to_windows(lua: &Lua, buffer_id: BufferId, edit: &crate::r let Some(core) = lua.app_data_ref::() else { return; }; - core.borrow_mut().notify_buffer_edit(buffer_id, edit); + let mut core = core.borrow_mut(); + core.notify_buffer_edit(buffer_id, edit); + // F28 — queue for broadcast. `queue_daemon_origin_crdt_op` is a + // no-op when the edit doesn't carry a `crdt_op` (the buffer + // wasn't CRDT-backed at edit time). + core.queue_daemon_origin_crdt_op(buffer_id, edit); } /// Force any window showing `buffer_id` to rebuild its `TextView`. @@ -2045,12 +2071,55 @@ fn install_instance_show_binding(lua: &Lua, registry: &SharedRegistry) -> mlua:: let attachment = lua .app_data_ref::() .and_then(|s| s.get()); - let id = + let (id, edits) = crate::instance_buffer::render(&mut reg.borrow_mut(), &identity, attachment.as_ref()); + queue_generated_buffer_edits(lua, id, &edits); + if !edits.is_empty() { + rebuild_generated_buffer_views(lua, id); + } Ok(BufferIdLua(id)) }) } +/// T M10.10 post-audit-round-6 F31 — queue every CRDT op produced +/// by a generated-buffer render to the daemon's broadcast queue. +/// +/// The three generated buffers (`*help*`, `*workers*`, +/// `*pmacs-instance*`) get upgraded to CRDT-backed at every +/// replica's attach via `send_buffer_snapshots`. Each subsequent +/// regenerate (delete-all + insert-new) produces zero, one, or two +/// `Edit`s carrying `crdt_op`. Replicas need every `CrdtOp` so their +/// `BufferMirror`s converge with the daemon's new content for +/// these buffers; without queueing, the replicas see the +/// `CellDelta` repaint but their mirrors permanently desync. +/// +/// Caller: every site in `lua_bindings.rs` that drives one of the +/// render functions. The render functions return their Edits +/// alongside the `BufferId` so this helper can queue them via +/// `EditorCore::queue_daemon_origin_crdt_op`. +/// +/// No-op when: +/// - No `SharedCore` is registered as Lua app data (early-stage +/// tests use the registry without an editor core). +/// - The edits' buffer wasn't CRDT-backed (`queue_daemon_origin_crdt_op` +/// itself early-returns when the edit has no `crdt_op`). +fn queue_generated_buffer_edits(lua: &Lua, buffer_id: BufferId, edits: &[crate::rope::Edit]) { + let Some(core) = lua.app_data_ref::() 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::() else { + return; + }; + core.borrow_mut().rebuild_views_for(buffer_id); +} + #[allow(clippy::too_many_lines)] fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result { let buffer = lua.create_table()?; @@ -4024,10 +4093,11 @@ fn install_help_module( let k = kms.borrow(); help::render_command(&mut r, &c, &k, &name) }; - if let Some(id) = result { - rebuild_help_buffer_views(lua, id); + if let Some((id, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *id, edits); + rebuild_help_buffer_views(lua, *id); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(id, _)| BufferIdLua(id))) })?, )?; } @@ -4052,10 +4122,11 @@ fn install_help_module( let k = kms.borrow(); help::render_key(&mut r, &c, &k, active_buffer, &sequence) }; - if let Some(id) = result { - rebuild_help_buffer_views(lua, id); + if let Some((id, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *id, edits); + rebuild_help_buffer_views(lua, *id); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(id, _)| BufferIdLua(id))) })?, )?; } @@ -4069,10 +4140,11 @@ fn install_help_module( let mut r = reg.borrow_mut(); help::render_buffer(&mut r, id.0) }; - if let Some(rid) = result { - rebuild_help_buffer_views(lua, rid); + if let Some((rid, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *rid, edits); + rebuild_help_buffer_views(lua, *rid); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(rid, _)| BufferIdLua(rid))) })?, )?; } @@ -4088,10 +4160,11 @@ fn install_help_module( let k = kms.borrow(); help::render_mode(&mut r, &k, &name) }; - if let Some(id) = result { - rebuild_help_buffer_views(lua, id); + if let Some((id, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *id, edits); + rebuild_help_buffer_views(lua, *id); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(id, _)| BufferIdLua(id))) })?, )?; } @@ -4107,10 +4180,11 @@ fn install_help_module( let h = hks.borrow(); help::render_hook(&mut r, &h, &name) }; - if let Some(id) = result { - rebuild_help_buffer_views(lua, id); + if let Some((id, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *id, edits); + rebuild_help_buffer_views(lua, *id); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(id, _)| BufferIdLua(id))) })?, )?; } @@ -4124,10 +4198,11 @@ fn install_help_module( let mut r = reg.borrow_mut(); help::render_view(&mut r, id.0) }; - if let Some(rid) = result { - rebuild_help_buffer_views(lua, rid); + if let Some((rid, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *rid, edits); + rebuild_help_buffer_views(lua, *rid); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(rid, _)| BufferIdLua(rid))) })?, )?; } @@ -4148,10 +4223,11 @@ fn install_help_module( let h = hks.borrow(); help::follow_link_at(&mut r, &c, &k, &h, cursor) }; - if let Some(id) = result { - rebuild_help_buffer_views(lua, id); + if let Some((id, edits)) = result.as_ref() { + queue_generated_buffer_edits(lua, *id, edits); + rebuild_help_buffer_views(lua, *id); } - Ok(result.map(BufferIdLua)) + Ok(result.map(|(id, _)| BufferIdLua(id))) })?, )?; } @@ -5150,9 +5226,13 @@ pub fn install_async( let reg = registry.clone(); async_mod.set( "_show_workers_buffer", - lua.create_function(move |_, ()| { + lua.create_function(move |lua, ()| { let snap = rt.workers_snapshot(); - let id = workers_buffer::render(&mut reg.borrow_mut(), &snap); + let (id, edits) = workers_buffer::render(&mut reg.borrow_mut(), &snap); + queue_generated_buffer_edits(lua, id, &edits); + if !edits.is_empty() { + rebuild_generated_buffer_views(lua, id); + } Ok(BufferIdLua(id)) })?, )?; @@ -9666,7 +9746,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ lua.create_function(move |lua, ()| { let c = cc.borrow(); let t = lua.create_table()?; - for (i, id) in c.layout.iter_ids().iter().enumerate() { + for (i, id) in c.active_layout().iter_ids().iter().enumerate() { t.set(i + 1, id.raw())?; } Ok(t) @@ -9678,7 +9758,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ let cc = core.clone(); win.set( "current", - lua.create_function(move |_, ()| Ok(cc.borrow().active.raw()))?, + lua.create_function(move |_, ()| Ok(cc.borrow().active_window_id().raw()))?, )?; } diff --git a/src/optimistic.rs b/src/optimistic.rs new file mode 100644 index 0000000..10a9448 --- /dev/null +++ b/src/optimistic.rs @@ -0,0 +1,1077 @@ +//! T M10.10 — Frontend-side optimistic-apply infrastructure. +//! +//! Two pieces of logic live here, deliberately small and testable in +//! isolation: +//! +//! 1. **`classify_key`** — the text-input predicate. Given a keystroke, +//! decides whether to take the optimistic path (insert / delete-back / +//! delete-forward) or fall through to the v0.1 `FrontendEvent::Key` +//! round-trip. Pure function; no state. +//! +//! 2. **`apply_incoming_crdt_op`** — the echo-dedup filter for incoming +//! `InstanceMessage::CrdtOp` broadcasts. Compares the broadcast's +//! source `FrontendId` to the local frontend's id and either applies +//! the op to the `BufferMirror` (remote) or skips (own echo). +//! +//! The attach loop (`attach.rs`) is the consumer: each keystroke runs +//! through `classify_key`; each incoming `CrdtOp` runs through +//! `apply_incoming_crdt_op`. Keeping these as standalone functions in +//! a dedicated module makes them unit-testable without spinning up an +//! attach session. +//! +//! # Semantics note: keymap and the text-input predicate +//! +//! `classify_key` assumes the default keymap's "text-input → self- +//! insert" mapping. Users with Lua keymap rebindings for text characters +//! (e.g., binding 'a' to a non-insert command) will see those bindings +//! lost on replica frontends — text-input chars take the optimistic +//! path and never reach the daemon's keymap layer. +//! +//! This is a v1.0-acceptable simplification: most users don't rebind +//! text characters; users who do can fall back to v0.1 round-trip by +//! advertising `crdt_replica: false`. v0.2+ may expand the predicate +//! to consult a frontend-side keymap mirror. + +use crate::buffer::BufferId; +use crate::buffer_mirror::{BufferMirror, BufferMirrorError}; +use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers}; +use crate::rope::CrdtOp; +use unicode_width::UnicodeWidthChar; + +/// Result of classifying a keystroke for optimistic apply. +/// +/// The frontend's keystroke handler matches on this to either take the +/// optimistic path (the three concrete actions) or fall through to the +/// v0.1 `FrontendEvent::Key` send. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum OptimisticAction { + /// Insert a single character at the cursor. + Insert(char), + /// Delete one byte/grapheme behind the cursor (Backspace). + DeleteBack, + /// Delete one byte/grapheme at the cursor (Delete-forward). + DeleteForward, + /// No optimistic path applies; fall through to round-trip via + /// `FrontendEvent::Key`. Covers control-char modifiers (Ctrl, Alt, + /// Meta, Hyper), function keys, navigation keys, and any + /// keystroke whose semantics aren't text-input. + RoundTrip, +} + +/// True if the modifier set excludes all editor-control modifiers +/// (Ctrl, Alt, Meta, Hyper). Shift is allowed because capital letters +/// arrive as `Char('A')` with `SHIFT` set — Shift is part of how the +/// character was produced, not a command modifier that changes semantics. +const fn is_text_input_modifiers(mods: Modifiers) -> bool { + !mods.contains(Modifiers::CTRL) + && !mods.contains(Modifiers::ALT) + && !mods.contains(Modifiers::META) + && !mods.contains(Modifiers::HYPER) +} + +/// Classify a key event for the optimistic-apply path. +/// +/// Returns: +/// - `Insert(c)` for a printable `Char(c)` with no editor-control modifier. +/// - `DeleteBack` for `Backspace` with no editor-control modifier. +/// - `DeleteForward` for `Delete` with no editor-control modifier. +/// - `RoundTrip` for everything else (modified text input, function +/// keys, arrows, escape, etc.). +/// +/// `char::is_control()` filters out ASCII control codes (0x00–0x1F, +/// 0x7F) and Unicode control codes. Tab and Enter qualify as control +/// chars and therefore round-trip — they often have non-insert +/// semantics in editor keymaps (indentation, newline-with-indent). +#[must_use] +pub fn classify_key(key: Key, mods: Modifiers) -> OptimisticAction { + if !is_text_input_modifiers(mods) { + return OptimisticAction::RoundTrip; + } + match key { + Key::Char(c) if !c.is_control() => OptimisticAction::Insert(c), + Key::Backspace => OptimisticAction::DeleteBack, + Key::Delete => OptimisticAction::DeleteForward, + _ => OptimisticAction::RoundTrip, + } +} + +/// Compute the `FrontendEvent` to send upstream for a keystroke, +/// applying the optimistic-apply path locally if the mirror is +/// ready **and** the action is paint-eligible. +/// +/// This is the keystroke-handler orchestrator: it classifies the +/// key, consults the mirror's readiness + paint-eligibility for the +/// active buffer, and either returns a `FrontendEvent::CrdtOp` +/// (after applying the edit to the local mirror) or a +/// `FrontendEvent::Key` (round-trip path: original keystroke is +/// forwarded as before). +/// +/// # Decision flow +/// +/// 1. `classify_key(key, mods)` → `OptimisticAction`. +/// 2. `RoundTrip` → return `FrontendEvent::Key`. +/// 3. `Insert(c)` / `DeleteBack` / `DeleteForward`: +/// - Read `mirror.active_buffer()`. None → fall through to +/// `FrontendEvent::Key` (no active buffer known; Refinement 4 +/// graceful degradation). +/// - Check `mirror.is_ready(active_buffer)`. False → fall +/// through (mirror not bootstrapped for this buffer). +/// - Check `mirror.cursor_byte_pos(active_buffer)`. None → fall +/// through (cursor position unknown). +/// - **Paint-eligibility gate (post-audit-round-3 F19):** +/// - `Insert(c)`: require `mirror.cursor_at_end_of_line` → +/// Some(true). Mid-line insert falls through to round-trip. +/// - `DeleteBack`: require +/// `mirror.cursor_at_end_of_line_safe_for_delete_back` → +/// Some(true). Mid-line, line-joining, or width-unsafe +/// delete-back falls through to round-trip. +/// - `DeleteForward`: ALWAYS round-trip (no optimistic paint +/// primitive exists for delete-forward; advancing the +/// mirror without painting de-syncs the mirror cursor from +/// the terminal cursor). +/// - Apply the action to the mirror. Errors fall through. +/// - Return `FrontendEvent::CrdtOp { source: my_fid, buffer_id, +/// op: CrdtOp { peer_id, bytes } }`. +/// +/// The caller writes whatever `FrontendEvent` is returned. Non- +/// paint-eligible actions round-trip via the daemon's Key path; the +/// daemon's `apply_active_edit` emits a `DaemonKey`-origin CRDT op +/// (F16) which broadcasts to every replica including the source. +/// The source mirror updates via the broadcast, keeping mirror state +/// and terminal cursor coherent. +/// +/// # F19 motivation +/// +/// Pre-fix, the orchestrator advanced mirror state for non-paint +/// edits (mid-line insert, mid-line backspace, all delete-forward). +/// The terminal cursor stayed at its pre-edit position (no paint +/// primitive fires) until the daemon's `CellDelta` arrived. A fast +/// next keystroke would then run optimistic logic against the +/// already-advanced mirror — `cursor_at_end_of_line` could report +/// "yes" at the mirror's new cursor while the terminal cursor was +/// at the OLD pre-edit position, causing `paint_optimistic_insert` +/// to write at the wrong column. +#[must_use] +pub fn frontend_event_for_keystroke( + mirror: &mut BufferMirror, + my_fid: FrontendId, + pmacs_key: KeyEvent, +) -> FrontendEvent { + let action = classify_key(pmacs_key.key, pmacs_key.mods); + let round_trip = || FrontendEvent::Key(pmacs_key); + if matches!(action, OptimisticAction::RoundTrip) { + return round_trip(); + } + // Need: active buffer, mirror ready for it, cursor tracked for + // it, AND the cursor is authoritative (post-audit-round-4 F22 + + // F23 freshness invariant). A stale cursor means the mirror's + // cursor for this buffer hasn't been re-grounded by the daemon's + // `CursorByte` since the last potential desync (either an + // outbound `FrontendEvent::Key` whose daemon-side cursor effect + // we can't predict, or an inbound `apply_remote_op` that didn't + // right-gravity-adjust the cursor locally). + let Some(buffer_id) = mirror.active_buffer() else { + return round_trip(); + }; + if !mirror.is_ready(buffer_id) { + return round_trip(); + } + if !mirror.is_cursor_fresh(buffer_id) { + return round_trip(); + } + let Some(cursor) = mirror.cursor_byte_pos(buffer_id) else { + return round_trip(); + }; + + let result = match action { + OptimisticAction::Insert(c) => { + // F19 — only optimistic-apply when paint will fire. + // Mid-line insert can't be painted by a single Print + // (cells to the right of cursor would need to shift), + // so we round-trip and let the daemon's CellDelta drive + // both visual and mirror update. + if mirror.cursor_at_end_of_line(buffer_id) != Some(true) { + return round_trip(); + } + // Post-audit-round-4 F24 — `paint_optimistic_insert` + // calls `queue!(Print(c))` which writes one terminal + // column. Wide chars (width 2) would only paint the + // base column without the Continuation cell; zero- + // width chars (combining marks, ZWJ) write a column + // for what `TextView` renders as a cluster attached to + // the previous cell. Either mismatch leaves the + // terminal out of sync with what the daemon's + // eventual `CellDelta` will paint. Width-unsafe + // inserts round-trip; the daemon's `CellDelta` drives + // both visual + mirror update. + if UnicodeWidthChar::width(c) != Some(1) { + return round_trip(); + } + let mut tmp = [0u8; 4]; + let s: &str = c.encode_utf8(&mut tmp); + let s_len = s.len(); + mirror + .apply_local_insert(buffer_id, cursor, s) + .inspect(|_bytes| { + mirror.advance_cursor(buffer_id, s_len); + }) + } + OptimisticAction::DeleteBack => { + // F19 — only optimistic-apply when the strict delete- + // back predicate holds (end-of-line + prev char not '\n' + // + prev char width == 1). Round-trip otherwise. + if mirror.cursor_at_end_of_line_safe_for_delete_back(buffer_id) != Some(true) { + return round_trip(); + } + match mirror.prev_char_len(buffer_id) { + Some(n) if n > 0 => { + let new_pos = cursor.saturating_sub(n); + mirror + .apply_local_delete(buffer_id, new_pos, n) + .inspect(|_bytes| { + mirror.retreat_cursor(buffer_id, n); + }) + } + _ => return round_trip(), + } + } + OptimisticAction::DeleteForward => { + // F19 — no optimistic paint primitive exists for + // delete-forward. Always round-trip. Mirror state stays + // coherent via the daemon's broadcast (F16 ensures the + // source receives it). + return round_trip(); + } + OptimisticAction::RoundTrip => unreachable!("RoundTrip handled above"), + }; + + match result { + Ok(op_bytes) => FrontendEvent::CrdtOp { + frontend_id: my_fid, + buffer_id, + op: CrdtOp { + peer_id: mirror.peer_id(), + bytes: op_bytes, + }, + }, + Err(_) => { + // Optimistic application failed (e.g., mid-codepoint or + // out-of-range position — shouldn't happen given the + // char-aware byte counts above, but defensive). Fall + // through to v0.1 round-trip. + round_trip() + } + } +} + +/// Outcome of routing an incoming `InstanceMessage::CrdtOp` through +/// the echo-dedup filter. +#[derive(Debug, Eq, PartialEq)] +pub enum IncomingCrdtOpOutcome { + /// Op was applied to the mirror (source frontend was not us). + Applied, + /// Op was filtered out as a local-edit echo (source frontend + /// was us; the mirror has already integrated this op via + /// `apply_local_insert` / `apply_local_delete`). + SkippedEcho, +} + +/// Handle an incoming `CrdtOp` broadcast: filter own-echoes; apply +/// remote ops to the mirror. +/// +/// **Filter rule (matches `BufferMirror::apply_remote_op` docstring):** +/// op is an echo iff `source == local_id`. The filter lives at this +/// call site (not in `BufferMirror`) because the mirror is +/// identity-ignorant by design — it operates on op bytes only. +/// Pushing the filter here keeps the mirror reusable in non-session +/// contexts (tests, future Lua bindings) and concentrates the +/// `FrontendId` comparison in one place. +/// +/// # Errors +/// +/// Propagates [`BufferMirrorError`] from the underlying mirror call. +/// Echo-skip never errors. +pub fn apply_incoming_crdt_op( + mirror: &mut BufferMirror, + local_id: FrontendId, + source: FrontendId, + buffer_id: BufferId, + op_bytes: &[u8], +) -> Result { + if source == local_id { + return Ok(IncomingCrdtOpOutcome::SkippedEcho); + } + mirror.apply_remote_op(buffer_id, op_bytes)?; + Ok(IncomingCrdtOpOutcome::Applied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crdt::CrdtState; + + // ----------------------------------------------------------------- + // classify_key — the text-input predicate. + // ----------------------------------------------------------------- + + #[test] + fn classify_ascii_char_no_modifiers_is_insert() { + assert_eq!( + classify_key(Key::Char('a'), Modifiers::NONE), + OptimisticAction::Insert('a') + ); + } + + #[test] + fn classify_ascii_char_with_shift_is_still_insert() { + // Capital letters arrive with SHIFT set; the char itself is + // already the shifted version. + assert_eq!( + classify_key(Key::Char('A'), Modifiers::SHIFT), + OptimisticAction::Insert('A') + ); + } + + #[test] + fn classify_ascii_char_with_ctrl_is_round_trip() { + // Ctrl+a is a command, not text input. + assert_eq!( + classify_key(Key::Char('a'), Modifiers::CTRL), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_ascii_char_with_alt_is_round_trip() { + assert_eq!( + classify_key(Key::Char('a'), Modifiers::ALT), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_ascii_char_with_meta_is_round_trip() { + assert_eq!( + classify_key(Key::Char('a'), Modifiers::META), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_ascii_char_with_hyper_is_round_trip() { + assert_eq!( + classify_key(Key::Char('a'), Modifiers::HYPER), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_space_no_modifiers_is_insert() { + assert_eq!( + classify_key(Key::Char(' '), Modifiers::NONE), + OptimisticAction::Insert(' ') + ); + } + + #[test] + fn classify_punctuation_no_modifiers_is_insert() { + for c in ['.', ',', ';', ':', '!', '?', '@', '#', '$', '%'] { + assert_eq!( + classify_key(Key::Char(c), Modifiers::NONE), + OptimisticAction::Insert(c) + ); + } + } + + #[test] + fn classify_unicode_char_no_modifiers_is_insert() { + // Non-ASCII printable — multi-byte UTF-8. + assert_eq!( + classify_key(Key::Char('é'), Modifiers::NONE), + OptimisticAction::Insert('é') + ); + assert_eq!( + classify_key(Key::Char('中'), Modifiers::NONE), + OptimisticAction::Insert('中') + ); + } + + #[test] + fn classify_backspace_no_modifiers_is_delete_back() { + assert_eq!( + classify_key(Key::Backspace, Modifiers::NONE), + OptimisticAction::DeleteBack + ); + } + + #[test] + fn classify_backspace_with_shift_is_still_delete_back() { + // Shift+Backspace behaves the same as Backspace in default + // keymap; Shift doesn't change the semantic. + assert_eq!( + classify_key(Key::Backspace, Modifiers::SHIFT), + OptimisticAction::DeleteBack + ); + } + + #[test] + fn classify_backspace_with_ctrl_is_round_trip() { + // Ctrl+Backspace is often "delete previous word" — keymap + // territory, not optimistic text-input. + assert_eq!( + classify_key(Key::Backspace, Modifiers::CTRL), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_delete_no_modifiers_is_delete_forward() { + assert_eq!( + classify_key(Key::Delete, Modifiers::NONE), + OptimisticAction::DeleteForward + ); + } + + #[test] + fn classify_delete_with_ctrl_is_round_trip() { + assert_eq!( + classify_key(Key::Delete, Modifiers::CTRL), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_function_key_is_round_trip() { + for n in 1u8..=12 { + assert_eq!( + classify_key(Key::F(n), Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + } + + #[test] + fn classify_navigation_keys_are_round_trip() { + for key in [ + Key::Left, + Key::Right, + Key::Up, + Key::Down, + Key::Home, + Key::End, + Key::PageUp, + Key::PageDown, + ] { + assert_eq!( + classify_key(key, Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + } + + #[test] + fn classify_enter_and_tab_are_round_trip() { + // Enter and Tab are arguably text input but often have + // editor-specific semantics (newline-with-indent, indent + // command). Send via round-trip so keymap can dispatch. + assert_eq!( + classify_key(Key::Enter, Modifiers::NONE), + OptimisticAction::RoundTrip + ); + assert_eq!( + classify_key(Key::Tab, Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_escape_is_round_trip() { + assert_eq!( + classify_key(Key::Escape, Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_unknown_key_is_round_trip() { + assert_eq!( + classify_key(Key::Unknown(0x1234), Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + + #[test] + fn classify_null_char_is_round_trip() { + // Char('\0') is control; should not optimistic-insert a NUL. + assert_eq!( + classify_key(Key::Char('\0'), Modifiers::NONE), + OptimisticAction::RoundTrip + ); + } + + // ----------------------------------------------------------------- + // apply_incoming_crdt_op — echo-dedup composition test. + // ----------------------------------------------------------------- + + /// Helper: build a snapshot of a small CRDT replica seeded with + /// the given text under `peer_id`. + fn fresh_snapshot(peer_id: u64, initial: &str) -> Vec { + let state = CrdtState::new(peer_id).expect("new"); + state.insert(0, initial).expect("seed"); + state.export_snapshot().expect("export") + } + + // ----------------------------------------------------------------- + // frontend_event_for_keystroke — Day 3 step 3b orchestrator. + // ----------------------------------------------------------------- + + fn key_event(key: Key, mods: Modifiers, fid: FrontendId) -> KeyEvent { + KeyEvent { + frontend_id: fid, + key, + mods, + timestamp_ns: 0, + } + } + + fn ready_mirror_with_cursor(initial: &str, cursor: usize) -> (BufferMirror, BufferId) { + let fid = FrontendId(2); + let mut m = BufferMirror::new(fid); + let id = BufferId::next(); + m.init_from_snapshot(id, &fresh_snapshot(99, initial)) + .expect("init"); + m.set_cursor_byte_pos(id, cursor); + (m, id) + } + + #[test] + fn keystroke_round_trips_when_mirror_has_no_active_buffer() { + let mut m = BufferMirror::new(FrontendId(2)); + let fid = FrontendId(2); + let ev = key_event(Key::Char('a'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} // round-trip + other => panic!("expected Key (graceful fallback), got {other:?}"), + } + } + + #[test] + fn keystroke_round_trips_when_buffer_not_ready() { + let fid = FrontendId(2); + let mut m = BufferMirror::new(fid); + // Set cursor for a buffer that has no snapshot yet — mirror + // tracks the cursor but is_ready returns false. + let id = BufferId::next(); + m.set_cursor_byte_pos(id, 5); + assert!(!m.is_ready(id)); + let ev = key_event(Key::Char('a'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} // round-trip per Refinement 4 + other => panic!("expected Key (graceful fallback), got {other:?}"), + } + } + + #[test] + fn keystroke_round_trips_for_non_text_input() { + // Ctrl+a — not text input; round-trip regardless of mirror + // state. + let (mut m, _id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + let ev = key_event(Key::Char('a'), Modifiers::CTRL, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(k) => { + assert!(k.mods.contains(Modifiers::CTRL)); + } + other => panic!("expected Key for non-text-input, got {other:?}"), + } + } + + #[test] + fn keystroke_optimistic_insert_produces_crdt_op_and_advances_cursor() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + let ev = key_event(Key::Char('!'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + + match result { + FrontendEvent::CrdtOp { + frontend_id, + buffer_id, + op, + } => { + assert_eq!(frontend_id, fid); + assert_eq!(buffer_id, id); + assert_eq!(op.peer_id, 2); + assert!(!op.bytes.is_empty()); + } + other => panic!("expected CrdtOp, got {other:?}"), + } + // Mirror state advanced. + assert_eq!(m.materialize(id).as_deref(), Some("hello!")); + assert_eq!(m.cursor_byte_pos(id), Some(6)); + } + + #[test] + fn keystroke_optimistic_insert_with_multibyte_char_advances_by_byte_length() { + let (mut m, id) = ready_mirror_with_cursor("ab", 2); + let fid = FrontendId(2); + let ev = key_event(Key::Char('é'), Modifiers::NONE, fid); // 2 bytes + let _ = frontend_event_for_keystroke(&mut m, fid, ev); + assert_eq!(m.materialize(id).as_deref(), Some("abé")); + // Cursor advanced by 2 (UTF-8 byte length). + assert_eq!(m.cursor_byte_pos(id), Some(4)); + } + + #[test] + fn keystroke_optimistic_delete_back_ascii_removes_one_byte() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + let ev = key_event(Key::Backspace, Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::CrdtOp { .. } => {} + other => panic!("expected CrdtOp, got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hell")); + assert_eq!(m.cursor_byte_pos(id), Some(4)); + } + + #[test] + fn keystroke_optimistic_delete_back_multibyte_removes_full_char() { + // Char-boundary-aware: deleting back from after 'é' removes + // both UTF-8 bytes, not just one (loro rejects mid-codepoint). + let (mut m, id) = ready_mirror_with_cursor("aé", 3); + let fid = FrontendId(2); + let ev = key_event(Key::Backspace, Modifiers::NONE, fid); + let _ = frontend_event_for_keystroke(&mut m, fid, ev); + assert_eq!(m.materialize(id).as_deref(), Some("a")); + assert_eq!(m.cursor_byte_pos(id), Some(1)); + } + + #[test] + fn keystroke_optimistic_delete_back_at_position_zero_round_trips() { + let (mut m, id) = ready_mirror_with_cursor("hello", 0); + let fid = FrontendId(2); + let ev = key_event(Key::Backspace, Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} // round-trip — nothing to delete + other => panic!("expected Key at pos 0, got {other:?}"), + } + // Mirror untouched. + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + } + + #[test] + fn keystroke_optimistic_delete_forward_at_end_round_trips() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + let ev = key_event(Key::Delete, Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} // round-trip — nothing to delete forward + other => panic!("expected Key at end, got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + } + + /// Post-audit-round-3 F19: `OptimisticAction::DeleteForward` + /// **always** round-trips. No paint primitive exists for + /// forward-delete, so optimistically applying to the mirror + /// without painting would desync the mirror cursor from the + /// terminal cursor. The daemon's `CellDelta` + the F16 broadcast + /// keep both sides in sync via round-trip. + #[test] + fn keystroke_optimistic_delete_forward_always_round_trips_f19() { + let (mut m, id) = ready_mirror_with_cursor("hello", 1); + let fid = FrontendId(2); + let ev = key_event(Key::Delete, Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F19: DeleteForward must round-trip; got {other:?}"), + } + // Mirror MUST be untouched — F19 narrowing prevents the + // mirror from advancing on round-tripped edits. + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.cursor_byte_pos(id), Some(1)); + } + + /// F19: `OptimisticAction::Insert` mid-line round-trips + /// (paint scope is end-of-line only; advancing the mirror + /// mid-line desyncs from the terminal cursor). + #[test] + fn keystroke_optimistic_insert_mid_line_round_trips_f19() { + let (mut m, id) = ready_mirror_with_cursor("hello", 2); // mid-line + let fid = FrontendId(2); + let ev = key_event(Key::Char('X'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F19: mid-line Insert must round-trip; got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.cursor_byte_pos(id), Some(2)); + } + + /// F24 (post-audit-round-4) — wide char (`UnicodeWidthChar::width + /// == Some(2)`) Insert round-trips. The optimistic paint + /// `queue!(Print(c))` writes one column; the daemon's eventual + /// `CellDelta` paints two cells (base + Continuation). + #[test] + fn keystroke_optimistic_insert_wide_char_round_trips_f24() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + // CJK ideograph (width 2). + let ev = key_event(Key::Char('漢'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F24: wide-char Insert must round-trip; got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.cursor_byte_pos(id), Some(5)); + } + + /// F24 — combining mark / zero-width Insert round-trips. The + /// paint writes a column for what `TextView` renders as a + /// cluster attached to the previous cell. + #[test] + fn keystroke_optimistic_insert_combining_mark_round_trips_f24() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + let fid = FrontendId(2); + // U+0301 COMBINING ACUTE ACCENT (width 0). + let ev = key_event(Key::Char('\u{0301}'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F24: zero-width combining-mark Insert must round-trip; got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + } + + /// F22 (post-audit-round-4) — when the mirror cursor is stale + /// (a prior `FrontendEvent::Key` may have moved the daemon's + /// cursor in ways the mirror can't predict), the orchestrator + /// must round-trip subsequent keystrokes until `CursorByte` + /// re-grounds the mirror cursor. Otherwise a fast `` then + /// `x` sequence emits an Insert `CrdtOp` for the byte the cursor + /// was at BEFORE ``. + #[test] + fn keystroke_round_trips_when_cursor_is_stale_f22() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + m.mark_cursor_stale(id); + let fid = FrontendId(2); + let ev = key_event(Key::Char('x'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F22: stale cursor must round-trip subsequent keys; got {other:?}"), + } + // Mirror untouched. + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.cursor_byte_pos(id), Some(5)); + } + + /// F23 (post-audit-round-4) — `apply_remote_op` marks the cursor + /// stale. A keystroke between the remote op and the next + /// `CursorByte` must round-trip. + #[test] + fn keystroke_round_trips_after_remote_op_until_cursor_byte_f23() { + let (mut m, id) = ready_mirror_with_cursor("hello", 5); + // Remote op: peer inserts "X" at position 0. + let peer = CrdtState::new(99).expect("peer"); + peer.import_snapshot(&fresh_snapshot(99, "hello")) + .expect("peer init"); + let v0 = peer.version(); + peer.insert(0, "X").expect("peer insert"); + let op_bytes = peer.export_updates_since(&v0).expect("export"); + m.apply_remote_op(id, &op_bytes).expect("apply remote"); + + // Mirror content now "Xhello"; mirror cursor still at 5 + // (no right-gravity adjustment). Without F23 the + // orchestrator would optimistically insert at byte 5 of + // "Xhello" — wrong byte for the user's intent. + let fid = FrontendId(2); + let ev = key_event(Key::Char('!'), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F23: post-remote-op cursor staleness must round-trip; got {other:?}"), + } + + // After daemon's CursorByte re-grounds the mirror, optimistic + // path reopens. + m.set_cursor_byte_pos(id, 6); // post-right-gravity position + let result2 = frontend_event_for_keystroke(&mut m, fid, ev); + match result2 { + FrontendEvent::CrdtOp { .. } => {} + other => panic!("F23: CursorByte must clear staleness; got {other:?}"), + } + } + + /// F19: `OptimisticAction::DeleteBack` mid-line round-trips. + #[test] + fn keystroke_optimistic_delete_back_mid_line_round_trips_f19() { + let (mut m, id) = ready_mirror_with_cursor("hello", 3); // mid-line + let fid = FrontendId(2); + let ev = key_event(Key::Backspace, Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + match result { + FrontendEvent::Key(_) => {} + other => panic!("F19: mid-line DeleteBack must round-trip; got {other:?}"), + } + assert_eq!(m.materialize(id).as_deref(), Some("hello")); + assert_eq!(m.cursor_byte_pos(id), Some(3)); + } + + /// T M10.10 Day 4 — criterion 1 unit acceptance: keystroke-to- + /// optimistic-apply completes in sub-frame time regardless of + /// any daemon state. + /// + /// Spec criterion 1: "Local edit visible in less than one frame + /// regardless of instance latency." + /// + /// The load-bearing property under Path β is that the orchestrator + /// completes synchronously — it doesn't wait for the daemon, doesn't + /// poll, doesn't block on I/O. Mirror update is in-process; produced + /// `FrontendEvent` is returned by value. Latency to the daemon + /// affects when the daemon's `CellDelta` arrives back, but doesn't + /// affect the orchestrator's completion time. + /// + /// This test demonstrates the property directly without involving + /// a daemon: 100 consecutive keystrokes through the orchestrator + /// must complete in well under 16ms (one frame at 60Hz). In + /// practice each call is microseconds; the upper bound is + /// generous to avoid CI flakiness. + /// + /// **Path β scope**: this test exercises end-of-line typing + /// (cursor at content end after each insert). Mid-line typing + /// would round-trip and incur daemon-latency for paint per Path + /// β's documented scope. + #[test] + fn criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke() { + use std::time::Instant; + let (mut m, _id) = ready_mirror_with_cursor("", 0); + let fid = FrontendId(2); + + let start = Instant::now(); + for c in "the quick brown fox jumps over the lazy dog".chars() { + let ev = key_event(Key::Char(c), Modifiers::NONE, fid); + let result = frontend_event_for_keystroke(&mut m, fid, ev); + // Verify each keystroke took the optimistic path — + // criterion 1 isn't met if the orchestrator falls through + // to FrontendEvent::Key (which would await daemon round- + // trip). + assert!( + matches!(result, FrontendEvent::CrdtOp { .. }), + "criterion 1: orchestrator must produce CrdtOp for end-of-line \ + text input (not round-trip Key)" + ); + } + let elapsed = start.elapsed(); + let per_keystroke = elapsed / 43; // length of the pangram + + // Upper bound per keystroke: 1ms (60× under frame budget). + // Loose because CI runners vary; tight enough to catch any + // synchronous-IO regression that would put criterion 1 at risk. + assert!( + per_keystroke < std::time::Duration::from_millis(1), + "criterion 1: per-keystroke orchestrator time {per_keystroke:?} \ + exceeds 1ms (well below 16ms frame budget). Total: {elapsed:?}" + ); + } + + /// T M10.10 Day 3 step 6 — bootstrap-window typing test. + /// + /// Narrates the bootstrap state machine that the M10.10 frontend + /// goes through on attach. The "bootstrap window" is the time + /// between session establishment and the frontend's mirror being + /// ready for optimistic apply — during this window, keystrokes + /// must gracefully degrade to v0.1 round-trip (Refinement 4). + /// After the bootstrap completes (`BufferSnapshot` + `CursorByte` + /// applied), subsequent keystrokes take the optimistic path. + /// + /// This test walks the explicit transitions a real attach would + /// experience (modulo the inter-thread message delivery the wire + /// transports do): + /// 1. Pre-bootstrap: empty mirror, type 'h' → `FrontendEvent::Key`. + /// 2. `BufferSnapshot` arrives (`init_from_snapshot`): mirror has + /// state but no active buffer / cursor yet. Type 'i' → still + /// Key (no `active_buffer` until `CursorByte` arrives). + /// 3. `CursorByte` arrives (`set_cursor_byte_pos`): mirror has + /// `active_buffer` + cursor; the optimistic predicate now fires + /// for in-scope keystrokes. + /// 4. Post-bootstrap: type 'j' → `FrontendEvent::CrdtOp`. + /// + /// Each transition is the boundary that step 3a's wire variants + /// (`BufferSnapshot`, `CursorByte`) and step 3b's orchestrator + /// jointly enforce. The narration matters because the bootstrap- + /// window race condition isn't deterministic at the daemon-e2e + /// level (the inter-thread message-drain ordering on the + /// receiver) — exercising the orchestrator's state machine + /// directly makes the contract observable. + #[test] + fn bootstrap_window_keystrokes_round_trip_until_mirror_ready() { + let fid = FrontendId(2); + let id = BufferId::next(); + let mut m = BufferMirror::new(fid); + + // ----------------------------------------------------------- + // State 1: pre-bootstrap. Empty mirror; no buffers; no active + // buffer; no cursor. Refinement 4 graceful-degradation kicks + // in: keystrokes that would otherwise be optimistic must + // fall through to FrontendEvent::Key. + // ----------------------------------------------------------- + assert!(m.active_buffer().is_none()); + assert!(!m.is_ready(id)); + + let ev = key_event(Key::Char('h'), Modifiers::NONE, fid); + let result_1 = frontend_event_for_keystroke(&mut m, fid, ev); + match result_1 { + FrontendEvent::Key(_) => {} // expected — graceful fallback + other => panic!("pre-bootstrap keystroke must round-trip via Key; got {other:?}"), + } + + // ----------------------------------------------------------- + // State 2: BufferSnapshot processed. Mirror has CRDT state + // for `id`, but the daemon hasn't yet emitted CursorByte — + // active_buffer is still None. The orchestrator must still + // round-trip because there's no active buffer to target. + // ----------------------------------------------------------- + m.init_from_snapshot(id, &fresh_snapshot(99, "abc")) + .expect("init from BufferSnapshot"); + assert!(m.is_ready(id)); + assert!(m.active_buffer().is_none()); // CursorByte hasn't fired yet + + let ev = key_event(Key::Char('i'), Modifiers::NONE, fid); + let result_2 = frontend_event_for_keystroke(&mut m, fid, ev); + match result_2 { + FrontendEvent::Key(_) => {} // expected — no active_buffer + other => panic!( + "post-BufferSnapshot but pre-CursorByte keystroke must round-trip; \ + got {other:?}" + ), + } + + // ----------------------------------------------------------- + // State 3: CursorByte processed. Mirror has active_buffer + + // cursor byte position. The optimistic predicate can now + // fire for in-scope keystrokes. Bootstrap is complete. + // ----------------------------------------------------------- + m.set_cursor_byte_pos(id, 3); // cursor at end of "abc" + assert_eq!(m.active_buffer(), Some(id)); + assert_eq!(m.cursor_byte_pos(id), Some(3)); + + // ----------------------------------------------------------- + // State 4: post-bootstrap typing. Optimistic path active; + // keystroke produces FrontendEvent::CrdtOp. + // ----------------------------------------------------------- + let ev = key_event(Key::Char('j'), Modifiers::NONE, fid); + let result_3 = frontend_event_for_keystroke(&mut m, fid, ev); + match result_3 { + FrontendEvent::CrdtOp { + frontend_id, + buffer_id, + op, + } => { + assert_eq!(frontend_id, fid); + assert_eq!(buffer_id, id); + assert_eq!(op.peer_id, fid.0); + assert!(!op.bytes.is_empty()); + } + other => { + panic!("post-bootstrap optimistic keystroke must produce CrdtOp; got {other:?}") + } + } + + // Mirror state reflects the optimistic apply. + assert_eq!(m.materialize(id).as_deref(), Some("abcj")); + assert_eq!(m.cursor_byte_pos(id), Some(4)); + } + + /// The canonical Day 3 echo-dedup test: a local op echoed back + /// from the daemon (tagged with our `FrontendId`) must be skipped; + /// a remote op (tagged with a different `FrontendId`) must apply. + /// + /// Symmetric coverage — single test exercises both filter + /// directions so a one-sided filter bug (filters everything OR + /// filters nothing) fails the test regardless of which way it + /// breaks. + #[test] + fn echo_dedup_skips_own_op_but_applies_remote() { + // Frontend A's FrontendId is 2; peer_id_from_frontend(2) == 2. + let local_id = FrontendId(2); + let remote_id = FrontendId(7); + let buffer_id = BufferId::next(); + + // Bootstrap: A's mirror initializes from a snapshot containing + // "abc" (the daemon-side initial state). + let snap = fresh_snapshot(0xABCD, "abc"); + let mut mirror = BufferMirror::new(local_id); + mirror.init_from_snapshot(buffer_id, &snap).expect("init"); + assert_eq!(mirror.materialize(buffer_id).as_deref(), Some("abc")); + + // A types 'X' — apply_local_insert produces wire-format op + // bytes attributable to A's peer_id. + let local_op_bytes = mirror + .apply_local_insert(buffer_id, 3, "X") + .expect("local insert"); + assert_eq!(mirror.materialize(buffer_id).as_deref(), Some("abcX")); + + // Echo arrives: daemon broadcasts A's op back to A (tagged + // with A's FrontendId). The filter must skip it. + let outcome = apply_incoming_crdt_op( + &mut mirror, + local_id, + local_id, // source == local → echo + buffer_id, + &local_op_bytes, + ) + .expect("echo filter"); + assert_eq!(outcome, IncomingCrdtOpOutcome::SkippedEcho); + + // After echo: mirror unchanged (op was NOT applied a second + // time). If the filter were broken and the op double-applied, + // we'd see "abcXX" here. + assert_eq!( + mirror.materialize(buffer_id).as_deref(), + Some("abcX"), + "echoed own op must not double-apply" + ); + + // Now a remote edit from B (a different FrontendId) arrives. + // Build a B-side replica that has integrated A's op + B's + // own edit; we'll deliver B's op-since-A to A's mirror. + let b_state = CrdtState::new(0xBEEF).expect("b new"); + b_state.import_snapshot(&snap).expect("b bootstrap"); + b_state + .import_updates(&local_op_bytes) + .expect("b sees A's op"); + let v_before_b_edit = b_state.version(); + b_state.insert(0, "Z").expect("b insert"); + let remote_op_bytes = b_state + .export_updates_since(&v_before_b_edit) + .expect("b op"); + + let outcome = apply_incoming_crdt_op( + &mut mirror, + local_id, + remote_id, // source != local → real remote op + buffer_id, + &remote_op_bytes, + ) + .expect("remote apply"); + assert_eq!(outcome, IncomingCrdtOpOutcome::Applied); + + // After remote apply: B's "Z" prepended to A's mirror content. + assert_eq!( + mirror.materialize(buffer_id).as_deref(), + Some("ZabcX"), + "remote op must apply when source != local" + ); + } +} diff --git a/src/overlay_color.rs b/src/overlay_color.rs new file mode 100644 index 0000000..890d196 --- /dev/null +++ b/src/overlay_color.rs @@ -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 { + 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); + } +} diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs new file mode 100644 index 0000000..818dbdf --- /dev/null +++ b/src/overlay_paint.rs @@ -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 { + 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); + } +} diff --git a/src/presence.rs b/src/presence.rs new file mode 100644 index 0000000..1ed40c6 --- /dev/null +++ b/src/presence.rs @@ -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` 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, +} + +/// 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, + /// 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, +} + +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 { + 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 { + 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 { + 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, + buffer_id: BufferId, + op: crate::rope::CrdtOp, + ) -> Vec { + 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); + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 200b150..64b7715 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -295,6 +295,31 @@ pub enum FrontendEvent { /// Frontend is going away. Instance treats this as immediate /// detach; no acknowledgement required. Detach(FrontendId), + /// T M10.5: CRDT operation produced by this frontend's local + /// edit, sent to the instance for broadcast to the other + /// attached frontends. The actual flow that produces these + /// (frontend maintaining a local CRDT state, applying edits + /// optimistically, sending the resulting op) is wired in M10.8 + /// + M10.10; M10.5 declares the wire shape so the protocol + /// version bump (1 → 2) covers it. + /// + /// Only sent by v1.0 frontends (`protocol_version = 2`); v0.1 + /// frontends never emit this variant. Sessions negotiated at + /// protocol version 1 must NOT receive this on the + /// instance-side dispatcher (the daemon filters per-session; + /// the editor-core treats it as an unknown frontend event if + /// it ever arrives from a v1 session, which it shouldn't). + CrdtOp { + /// Which attached frontend produced this op. The instance + /// uses this to avoid echoing the op back to its sender. + frontend_id: FrontendId, + /// Which buffer this op affects. The instance routes the + /// op to that buffer's CRDT state. + buffer_id: crate::buffer::BufferId, + /// The CRDT operation payload — `peer_id` + opaque wire bytes + /// loro's `import_updates` decodes. + op: crate::rope::CrdtOp, + }, } impl FrontendEvent { @@ -308,7 +333,8 @@ impl FrontendEvent { | Self::Paste { frontend_id, .. } | Self::FocusGained(frontend_id) | Self::FocusLost(frontend_id) - | Self::Detach(frontend_id) => *frontend_id, + | Self::Detach(frontend_id) + | Self::CrdtOp { frontend_id, .. } => *frontend_id, } } } @@ -361,6 +387,27 @@ pub enum GoodbyeReason { /// Frontend sent a malformed message or otherwise violated the /// protocol. The connection is closed without further dialogue. ProtocolError, + /// T M10.7: frontend declared one or more negotiated capability + /// bits that the instance cannot honor. The handshake fails after + /// the version check but before any further messages. + /// + /// `missing` lists the capability *field names* (e.g., + /// `"multi_frontend"`, `"crdt_replica"`) the frontend requested + /// (`true`) that the instance reports as `false`. These strings + /// are stable wire-format identifiers: they are exactly the + /// `FrontendCapabilities` / `InstanceCapabilities` field names, + /// not human-readable descriptions. The frontend translates them + /// for display via [`AttachError`]'s formatting. Renaming a + /// capability bit requires changing both the field name AND the + /// missing-string emission in `negotiate_capabilities` in + /// lockstep — see the M10.7 audit's wire-format-stability + /// section. + CapabilityMismatch { + /// The capability bit names the frontend asked for that the + /// instance does not support. Each entry is a verbatim + /// `FrontendCapabilities` field name. + missing: Vec, + }, } /// Rendering and signals from instance to frontend. @@ -388,6 +435,173 @@ pub enum InstanceMessage { Signal(InstanceSignal), /// Instance is terminating the attachment. Goodbye(GoodbyeReason), + /// T M10.5: CRDT operation broadcast from the instance to all + /// attached frontends. The originating frontend produced this op + /// (via `FrontendEvent::CrdtOp` or via a local editor-core edit + /// that synthesizes one); the instance fans it out so every + /// attached frontend can apply the op to its local CRDT state. + /// + /// Only sent to v1.0 frontends — sessions negotiated at + /// `protocol_version = 1` never receive this variant, per + /// `§sec:m10-backward-compat`. The daemon filters at the + /// outgoing-message path; this variant simply existing in the + /// enum is not a wire-compat issue for v1 sessions because the + /// daemon never emits it to them. + /// + /// M10.5 declares the wire shape. M10.8 wires the editor-core → + /// daemon → frontend flow that actually emits these. + CrdtOp { + /// Which buffer this op affects. v1.0 frontends maintain + /// a per-buffer local CRDT state; this routes to the right + /// one. + buffer_id: crate::buffer::BufferId, + /// The CRDT operation payload — `peer_id` + opaque wire bytes + /// loro's `import_updates` decodes. + op: crate::rope::CrdtOp, + }, + /// T M10.6: cursor + selection state of one attached frontend, + /// broadcast to the other v1.0 frontends so they can render + /// peer-presence overlays. Coalesced at the daemon: rapid cursor + /// movement produces one `PresenceUpdate` per tick per source + /// frontend, carrying the *final* state, not intermediate values. + /// + /// Sender exclusion: the source frontend never receives its own + /// `PresenceUpdate`. v0.1 sessions (negotiated `protocol_version = + /// 1`) are filtered out at the daemon's outgoing-message path. + /// + /// M10.6 declares the wire shape AND wires the daemon-side + /// sweep with per-session filter. In single-frontend deployments + /// the recipient list is structurally empty (sender exclusion + /// with no other v2 sessions); M10.8 enables the multi-frontend + /// case where this message actually crosses the wire. The + /// frontend's renderer for peer-cursor overlays is also M10.8. + PresenceUpdate { + /// Which attached frontend this presence belongs to. v1.0 + /// frontends use this to label the peer-cursor overlay + /// ("user 4 is editing here"). + frontend_id: FrontendId, + /// Which buffer the source frontend's cursor is in. + buffer_id: crate::buffer::BufferId, + /// Byte offset of the source frontend's cursor within + /// `buffer_id`. Frontends convert to line/column at render + /// time via the rope's coord-mapping; the wire carries the + /// canonical byte offset to avoid encoding-vs-rendering + /// drift across frontends. + cursor: crate::rope::Position, + /// Active selection range, if any. + selection: Option, + }, + /// 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, + }, + /// T M10.10: the active buffer for a replica frontend, with the + /// cursor position within it. + /// + /// # Semantics (Day 3 step 3b composition-check broadened + /// contract) + /// + /// `CursorByte` represents "the active buffer for this frontend + /// is `buffer_id`; the cursor in that buffer is at `byte_pos`." + /// Not just "the cursor moved." This contract matters: a narrow + /// "cursor moved" emission would miss active-buffer-changed- + /// without-cursor-motion events (Lua-driven buffer switch + /// landing at the same byte position), and the frontend's + /// active-buffer tracking would go stale. + /// + /// Daemon emits `CursorByte` on every per-tick render frame for + /// replica frontends, derived fresh from `active_window_for(fid)`. + /// Cursor move, active-buffer change, and active-window change + /// all produce a new emission carrying the current `(buffer_id, + /// byte_pos)`. The per-tick rate (16ms at 60Hz) is the same as + /// `Cursor`'s grid-coord variant. + /// + /// # Why a separate variant from `Cursor` + /// + /// `Cursor` carries grid coordinates (row/col cells) — the + /// frontend uses them to paint the cursor. The optimistic-apply + /// path needs byte position (CRDT insert/delete is byte-indexed), + /// which the grid coordinate can't recover without duplicating + /// the daemon's view-layout logic (tab expansion, line wrap, + /// double-width chars, viewport offset). `CursorByte` is the + /// authoritative byte position for the active buffer. + /// + /// # Atomicity with `Cursor` + /// + /// The daemon emits `Cursor` and `CursorByte` together for + /// replica frontends — both derived from the same render-frame + /// iteration so they describe the cursor in the same instant in + /// two reference frames. Non-replica frontends receive only + /// `Cursor` (existing behavior). The replica frontend that sees + /// `Cursor` without a paired `CursorByte` would interpret stale + /// byte position; the daemon guarantees both emit together by + /// derivation, not by message-protocol atomicity. + /// + /// # Wire-format compatibility + /// + /// New variant in v2; receivers without M10.10 hard-error on + /// decode (postcard does not gracefully degrade unknown variants, + /// per M10.10-FRAMING.md Refinement 3). Capability-gated: daemon + /// sends only to frontends that negotiated `crdt_replica: true`. + /// `PROTOCOL_VERSION` stays at 2. + CursorByte { + /// The buffer the cursor is in. A replica frontend tracks + /// per-buffer cursors; this routes the update to the right + /// entry. + buffer_id: crate::buffer::BufferId, + /// Byte offset of the cursor within `buffer_id`. Source of + /// truth for the optimistic-apply path's insert / delete + /// position arguments. Wire type matches + /// `PresenceUpdate::cursor` (`u64`) for consistency; frontend + /// converts to `usize` for the loro API. + byte_pos: crate::rope::Position, + }, +} + +/// Flat selection state for the wire. +/// +/// Mirrors [`crate::window::Selection`] but as a self-contained pair +/// of byte offsets — `anchor` is where the selection began, +/// `active` is the current selection cursor. Either may be the +/// numerically larger value; callers wanting `(lo, hi)` order +/// compute it locally. +/// +/// Kept flat (no nested types) so [`PartialEq`] equality is exactly +/// wire-representation equality: two `SelectionSnapshot`s compare +/// equal iff they serialize to identical bytes. The presence-diff +/// sweep relies on this — see [`crate::presence::SessionRegistry`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SelectionSnapshot { + /// Where the selection began. + pub anchor: crate::rope::Position, + /// The active end (typically the cursor at the moment of the + /// snapshot). + pub active: crate::rope::Position, } // --------------------------------------------------------------------------- @@ -891,9 +1105,39 @@ impl AttachmentHandle { /// Wire-protocol version. Bumped on any breaking change to the /// `Hello` / `AttachRequest` / event-message shapes. /// -/// The handshake compares the two sides' values; mismatches close the -/// connection with [`GoodbyeReason::VersionMismatch`]. -pub const PROTOCOL_VERSION: u32 = 1; +/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`]; +/// mismatches close the connection with +/// [`GoodbyeReason::VersionMismatch`]. v1.0 servers and clients accept +/// either the v0.1 wire (version 1) or the v1.0 wire (version 2) per +/// `§sec:m10-backward-compat` — both directions of the version +/// asymmetry need symmetric relaxation so v0.1-era binaries connect +/// to v1.0-era binaries (and vice versa) once both have shipped. +/// +/// T M10.5: bumped from 1 to 2. The v0.1 wire (version 1) remains +/// accepted by v1.0 binaries; CRDT-only message variants +/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered +/// per-session for v1 negotiated sessions. +pub const PROTOCOL_VERSION: u32 = 2; + +/// T M10.5: the set of protocol versions a v1.0 binary accepts on +/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept +/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat +/// spec section describes is handled symmetrically on both sides. +/// +/// The handshake check is "is the peer's `protocol_version` present in +/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The +/// session's negotiated version (the peer's) is recorded for +/// downstream filtering: v1 sessions don't receive +/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from +/// a v2 daemon. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2]; + +/// T M10.5: predicate for the handshake check. Returns `true` if +/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. +#[must_use] +pub fn is_supported_protocol_version(peer_version: u32) -> bool { + SUPPORTED_PROTOCOL_VERSIONS.contains(&peer_version) +} /// Identifies an instance for client-side display. /// @@ -954,9 +1198,73 @@ impl InstanceIdentity { /// /// Empty for v0.1; the type exists so that adding capabilities in v0.2+ /// is not a breaking-change. Symmetric with [`FrontendCapabilities`]. -#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +/// +/// T M10.5: added `multi_frontend` and `crdt_replica` bits with +/// `#[serde(default)]` so v1 wire bytes still deserialize. The +/// negotiation logic (which side advertises what, and what the +/// instance does with mismatches) is M10.7 scope; M10.5 just makes +/// the bit positions stable in the wire format. +/// +/// T M10.5/8: bit defaults evolve with the substrate. +/// +/// - M10.5 declared the bits with `#[serde(default)]` so v1 wire +/// bytes deserialize forward-compatibly. M10.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 { - // No fields in v0.1. Reserved for future expansion. + /// T M10.5: instance can host multi-frontend sessions on the + /// same buffer (per `§sec:m10-collab`). T M10.8 Day 4: default + /// flipped to `true` — the dispatcher supports multiple + /// attached frontends. + #[serde(default = "default_true")] + pub multi_frontend: bool, + /// T M10.5: instance can broadcast `InstanceMessage::CrdtOp` + /// messages. T M10.8 Day 4: default flipped to `true` — the + /// broadcast routing for CRDT ops wires up in this milestone. + #[serde(default = "default_true")] + pub crdt_replica: bool, +} + +// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")` +// evaluates to `false`, making this impl derivable. In CRDT builds +// the values are `true`, so the impl is genuinely manual. Allow. +#[allow(clippy::derivable_impls)] +impl Default for InstanceCapabilities { + fn default() -> Self { + // T M10.10 — the `crdt_replica` default tracks the `crdt` + // Cargo feature. A daemon built without the `crdt` feature + // can't honor a `crdt_replica: true` negotiation (the + // CRDT-handling code paths are conditionally compiled out + // — `send_buffer_snapshots`, `apply_remote_crdt_op`, the + // dispatcher's CursorByte emit). Advertising `true` + // unconditionally would be wire-protocol false advertising. + // + // `multi_frontend` is conceptually independent of CRDT but + // in M10.10's architecture every multi-frontend participant + // is also a CRDT replica; gating both on the same feature + // keeps the daemon's advertised capabilities consistent + // with what it can actually do. + Self { + multi_frontend: cfg!(feature = "crdt"), + crdt_replica: cfg!(feature = "crdt"), + } + } +} + +#[allow(clippy::missing_const_for_fn)] +fn default_true() -> bool { + true } /// Capabilities the frontend advertises to the instance. @@ -996,6 +1304,106 @@ pub struct FrontendCapabilities { /// branching is done on the explicit capability bits above. #[serde(default)] pub terminal_kind: Option, + /// 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 { + let mut missing = Vec::new(); + let multi_frontend = match (frontend.multi_frontend, instance.multi_frontend) { + (true, false) => { + missing.push("multi_frontend".to_string()); + false + } + (a, b) => a && b, + }; + let crdt_replica = match (frontend.crdt_replica, instance.crdt_replica) { + (true, false) => { + missing.push("crdt_replica".to_string()); + false + } + (a, b) => a && b, + }; + if missing.is_empty() { + Ok(NegotiatedCapabilities { + multi_frontend, + crdt_replica, + }) + } else { + Err(GoodbyeReason::CapabilityMismatch { missing }) + } } /// First message sent by the instance to a freshly-attached frontend. @@ -2169,10 +2577,24 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_one_for_v01() { - // Pin the value: every wire-shape change in v0.1 patch releases - // must keep this constant or break the handshake. - assert_eq!(PROTOCOL_VERSION, 1); + fn protocol_version_is_two_for_v10() { + // Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire + // adds CrdtOp / PresenceUpdate variants; the v1.0 binary + // serves both v1 and v2 sessions per §sec:m10-backward-compat. + assert_eq!(PROTOCOL_VERSION, 2); + } + + #[test] + fn supported_protocol_versions_includes_one_and_two() { + // T M10.5: v1.0 binaries accept both wire versions during the + // handshake. v0.1 binaries (with their strict-equality check) + // accepted only v1; this is the symmetric relaxation that + // makes §sec:m10-backward-compat hold once both binaries ship. + assert!(is_supported_protocol_version(1)); + assert!(is_supported_protocol_version(2)); + assert!(!is_supported_protocol_version(0)); + assert!(!is_supported_protocol_version(3)); + assert!(!is_supported_protocol_version(u32::MAX)); } #[test] @@ -2205,6 +2627,8 @@ mod tests { mouse: true, bracketed_paste: true, terminal_kind: Some("xterm-256color".into()), + multi_frontend: false, + crdt_replica: false, }, initial_size: CellSize::new(50, 200), }; @@ -2377,4 +2801,490 @@ mod tests { other => panic!("expected Key, got {other:?}"), } } + + // ----------------------------------------------------------------- + // T M10.5 round-trip tests for the new wire variants. + // ----------------------------------------------------------------- + + #[test] + fn instance_message_crdt_op_round_trips_through_postcard() { + // Synthetic CrdtOp with known peer_id + arbitrary bytes. + // Verifies the protocol-level serialization shape. The + // real-loro-bytes variant is in the test below. + let msg = InstanceMessage::CrdtOp { + buffer_id: crate::buffer::BufferId::next(), + op: crate::rope::CrdtOp { + peer_id: 0x1234_5678_9abc_def0, + bytes: vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD], + }, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::CrdtOp { + op: crate::rope::CrdtOp { peer_id, bytes: ob }, + .. + } => { + assert_eq!(peer_id, 0x1234_5678_9abc_def0); + assert_eq!(ob, vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD]); + } + other => panic!("expected CrdtOp, got {other:?}"), + } + } + + #[test] + fn frontend_event_crdt_op_round_trips_through_postcard() { + let ev = FrontendEvent::CrdtOp { + frontend_id: FrontendId(42), + buffer_id: crate::buffer::BufferId::next(), + op: crate::rope::CrdtOp { + peer_id: 99, + bytes: vec![0xAA, 0xBB, 0xCC], + }, + }; + let bytes = postcard::to_allocvec(&ev).expect("encode"); + let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + FrontendEvent::CrdtOp { + frontend_id, op, .. + } => { + assert_eq!(frontend_id, FrontendId(42)); + assert_eq!(op.peer_id, 99); + assert_eq!(op.bytes, vec![0xAA, 0xBB, 0xCC]); + } + other => panic!("expected FrontendEvent::CrdtOp, got {other:?}"), + } + } + + #[cfg(feature = "crdt")] + #[test] + fn instance_message_crdt_op_round_trips_with_real_loro_bytes() { + // T M10.5 framing-pass addition: use actual loro-exported + // bytes (not synthetic) so the test catches surprising + // interactions between loro's wire format and postcard's + // encoding. Also logs the per-CrdtOp wire byte size — a + // reference number M10.8's broadcast-cost reasoning relies on. + use crate::crdt::CrdtState; + let state = CrdtState::new(7).expect("CRDT state"); + let pre_version = state.version(); + state.insert(0, "hello world").expect("insert"); + let real_bytes = state.export_updates_since(&pre_version).expect("export"); + let real_bytes_len = real_bytes.len(); + let msg = InstanceMessage::CrdtOp { + buffer_id: crate::buffer::BufferId::next(), + op: crate::rope::CrdtOp { + peer_id: 7, + bytes: real_bytes.clone(), + }, + }; + let postcard_bytes = postcard::to_allocvec(&msg).expect("encode"); + let postcard_len = postcard_bytes.len(); + eprintln!( + "[T M10.5 wire-size] real-loro CrdtOp for `hello world` insert:\n \ + loro export bytes: {} B\n \ + postcard-encoded InstanceMessage::CrdtOp: {} B\n \ + protocol overhead: {} B (BufferId + peer_id + framing)", + real_bytes_len, + postcard_len, + postcard_len.saturating_sub(real_bytes_len) + ); + let decoded: InstanceMessage = postcard::from_bytes(&postcard_bytes).expect("decode"); + match decoded { + InstanceMessage::CrdtOp { op, .. } => { + assert_eq!(op.peer_id, 7); + assert_eq!( + op.bytes, real_bytes, + "loro bytes must round-trip identically" + ); + // Verify the round-tripped bytes apply on a remote + // CrdtState and produce the originating state's + // projection — the property M10.5's wire codec must + // preserve for M10.8's broadcast path to work. + let receiver = CrdtState::new(99).expect("receiver"); + receiver.import_updates(&op.bytes).expect("import"); + assert_eq!(receiver.materialize_string(), "hello world"); + } + other => panic!("expected CrdtOp, got {other:?}"), + } + } + + // ----------------------------------------------------------------- + // T M10.5 — backward-compat handshake matrix tests. + // + // Four cases per the framing-pass handshake matrix: + // 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested. + // 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch. + // 3. v2 daemon ↔ v1 frontend: success; v1 session. + // 4. v2 daemon ↔ v2 frontend: success; v2 session. + // + // These tests exercise `is_supported_protocol_version` directly + // since the full daemon-attach path requires socket setup that's + // in m5_5_acceptance.rs. The version-check predicate is the + // load-bearing piece; daemon-level integration tests are in the + // separate integration test file. + // ----------------------------------------------------------------- + + #[test] + fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() { + // The relaxation that makes §sec:m10-backward-compat hold. + assert!( + is_supported_protocol_version(1), + "v2 daemon must accept v1 frontend per §sec:m10-backward-compat" + ); + } + + #[test] + fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() { + // The new case M10.5 enables. + assert!( + is_supported_protocol_version(2), + "v2 daemon must accept v2 frontend (the v1.0 happy path)" + ); + } + + #[test] + fn m10_5_handshake_matrix_versions_outside_range_rejected() { + // v1 daemon's strict-equality behavior is documented at the + // v0.1 code level (different binary); v2 daemon's range check + // rejects v3+ until v0.2 ships. + assert!(!is_supported_protocol_version(0)); + assert!(!is_supported_protocol_version(3)); + assert!(!is_supported_protocol_version(u32::MAX)); + } + + #[test] + fn m10_5_strict_equality_v1_frontend_simulation() { + // T M10.5 framing-pass risk #5 verification: existing v1 + // frontends (the v0.1.0 release codebase, pre-M10.5) do + // strict equality on Hello.protocol_version. Simulate that + // check explicitly so the audit doc has empirical evidence + // of the actual backward-compat surface. + // + // Before M10.5: `if hello.protocol_version != 1 { reject }`. + // After M10.5: `if !is_supported_protocol_version(...) { reject }`. + // + // For a v1-strict-frontend connecting to a v2 daemon: the + // daemon's Hello carries protocol_version=2; the v1-strict + // frontend rejects with VersionMismatch. + fn v1_strict_check(hello_version: u32) -> bool { + hello_version == 1 + } + // v1-strict frontend hitting v2 daemon's Hello: rejected. + assert!( + !v1_strict_check(2), + "v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \ + can NOT connect to v2 daemons even though v2 daemons accept their requests" + ); + // v1-strict frontend hitting v1 daemon's Hello: accepted. + assert!(v1_strict_check(1)); + // For comparison, M10.5's relaxed check (v2 frontend after this milestone): + assert!(is_supported_protocol_version(1)); + assert!(is_supported_protocol_version(2)); + } + + // T M10.6 — PresenceUpdate wire shape tests. + + #[test] + fn instance_message_presence_update_round_trips_no_selection() { + let msg = InstanceMessage::PresenceUpdate { + frontend_id: FrontendId(42), + buffer_id: crate::buffer::BufferId::next(), + cursor: 100, + selection: None, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + + #[test] + fn instance_message_presence_update_round_trips_with_selection() { + let msg = InstanceMessage::PresenceUpdate { + frontend_id: FrontendId(7), + buffer_id: crate::buffer::BufferId::next(), + cursor: 500, + selection: Some(SelectionSnapshot { + anchor: 480, + active: 500, + }), + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + + #[test] + fn presence_update_typical_size_under_64_bytes() { + // T M10.6 size acceptance — typical case: cursor at offset + // 100 in a small buffer, no selection. Should be well under + // 64B (varint encoding of small u64s is 1-2 bytes each). + let msg = InstanceMessage::PresenceUpdate { + frontend_id: FrontendId(2), + buffer_id: crate::buffer::BufferId::next(), + cursor: 100, + selection: None, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let size = bytes.len(); + eprintln!( + "[T M10.6 wire-size] PresenceUpdate typical (cursor=100, no selection): {size} B" + ); + assert!( + size < 64, + "typical PresenceUpdate is {size} B; spec target is <64 B" + ); + } + + #[test] + fn presence_update_worst_case_size_recorded() { + // T M10.6 size acceptance — worst case: max u64 values for + // every position field, selection present spanning a large + // range. Varint encoding of u64::MAX is 10 bytes; this is + // the upper bound on a single PresenceUpdate's wire size. + // Recording the actual number for the audit doc. + let msg = InstanceMessage::PresenceUpdate { + frontend_id: FrontendId(u64::MAX), + buffer_id: crate::buffer::BufferId::next(), + cursor: u64::MAX, + selection: Some(SelectionSnapshot { + anchor: 0, + active: u64::MAX, + }), + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let size = bytes.len(); + eprintln!( + "[T M10.6 wire-size] PresenceUpdate worst-case (all-max u64s + selection): {size} B" + ); + // Worst-case bound: 1 (variant tag) + 10 (frontend_id) + ~2 + // (BufferId varint — small) + 10 (cursor) + 1 (Some tag) + + // 10 (anchor zero = 1B) + 10 (active = u64::MAX = 10B) = ~44 + // upper bound. Buffer-id is freshly minted so its varint + // encoding is small. We assert <64 to cover the spec target, + // and log the actual number for the audit. + assert!( + size < 64, + "worst-case PresenceUpdate is {size} B; spec target is <64 B" + ); + } + + // ----------------------------------------------------------------- + // T M10.10 round-trip + size tests for BufferSnapshot. + // ----------------------------------------------------------------- + + #[test] + fn instance_message_buffer_snapshot_round_trips_through_postcard() { + // Synthetic loro-snapshot bytes — the wire-level test is + // independent of the actual loro encoding. + let msg = InstanceMessage::BufferSnapshot { + buffer_id: crate::buffer::BufferId::next(), + crdt_snapshot: vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF], + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => { + assert_eq!( + crdt_snapshot, + vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF] + ); + } + other => panic!("expected BufferSnapshot, got {other:?}"), + } + } + + #[test] + fn instance_message_cursor_byte_round_trips_through_postcard() { + let msg = InstanceMessage::CursorByte { + buffer_id: crate::buffer::BufferId::next(), + byte_pos: 12345, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::CursorByte { byte_pos, .. } => assert_eq!(byte_pos, 12345), + other => panic!("expected CursorByte, got {other:?}"), + } + } + + #[test] + fn instance_message_cursor_byte_zero_position_round_trips() { + let msg = InstanceMessage::CursorByte { + buffer_id: crate::buffer::BufferId::next(), + byte_pos: 0, + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert!(matches!( + decoded, + InstanceMessage::CursorByte { byte_pos: 0, .. } + )); + } + + #[test] + fn instance_message_buffer_snapshot_empty_snapshot_round_trips() { + // An empty CRDT (no edits yet) — loro's export produces a + // small but non-zero byte string. The wire layer must round-trip + // a zero-length crdt_snapshot regardless of whether loro ever + // emits one. + let msg = InstanceMessage::BufferSnapshot { + buffer_id: crate::buffer::BufferId::next(), + crdt_snapshot: vec![], + }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + match decoded { + InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => { + assert!(crdt_snapshot.is_empty()); + } + other => panic!("expected BufferSnapshot, got {other:?}"), + } + } + + // T M10.7 — capability negotiation matrix + error round-trip. + + /// Build a `FrontendCapabilities` with the M10-era negotiated + /// bits set as specified and all other fields at their default. + fn front_caps(multi_frontend: bool, crdt_replica: bool) -> FrontendCapabilities { + FrontendCapabilities { + multi_frontend, + crdt_replica, + ..FrontendCapabilities::default() + } + } + + fn inst_caps(multi_frontend: bool, crdt_replica: bool) -> InstanceCapabilities { + InstanceCapabilities { + multi_frontend, + crdt_replica, + } + } + + #[test] + fn negotiate_neither_side_declares_anything() { + let res = negotiate_capabilities(&front_caps(false, false), &inst_caps(false, false)) + .expect("ok"); + assert!(!res.multi_frontend); + assert!(!res.crdt_replica); + } + + #[test] + fn negotiate_frontend_silent_instance_offers() { + // Frontend didn't request, instance has — frontend's silence + // is accepted as "single-frontend subset is fine." + let res = + negotiate_capabilities(&front_caps(false, false), &inst_caps(true, true)).expect("ok"); + assert!(!res.multi_frontend, "frontend didn't ask → doesn't get"); + assert!(!res.crdt_replica, "frontend didn't ask → doesn't get"); + } + + #[test] + fn negotiate_both_sides_declare_multi_frontend() { + let res = + negotiate_capabilities(&front_caps(true, false), &inst_caps(true, false)).expect("ok"); + assert!(res.multi_frontend); + assert!(!res.crdt_replica); + } + + #[test] + fn negotiate_both_sides_declare_both_bits() { + let res = + negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok"); + assert!(res.multi_frontend); + assert!(res.crdt_replica); + } + + #[test] + fn negotiate_frontend_wants_multi_instance_lacks() { + // T M10.7 criterion 4 — mismatch produces clear error + // naming what was requested vs available. + let err = negotiate_capabilities(&front_caps(true, false), &inst_caps(false, false)) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!(missing, vec!["multi_frontend".to_string()]); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_frontend_wants_crdt_replica_instance_lacks() { + let err = negotiate_capabilities(&front_caps(false, true), &inst_caps(false, false)) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!(missing, vec!["crdt_replica".to_string()]); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_frontend_wants_both_instance_lacks_both() { + // Multiple missing bits land in a single CapabilityMismatch + // — one round-trip carries the complete picture. + let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false)) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!( + missing, + vec!["multi_frontend".to_string(), "crdt_replica".to_string()] + ); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn negotiate_partial_mismatch_only_lists_missing() { + // Frontend wants both, instance has multi but not crdt: + // only crdt_replica lands in `missing`. + let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(true, false)) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + assert_eq!(missing, vec!["crdt_replica".to_string()]); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } + + #[test] + fn goodbye_capability_mismatch_round_trips() { + let msg = InstanceMessage::Goodbye(GoodbyeReason::CapabilityMismatch { + missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()], + }); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + + #[test] + fn missing_strings_are_field_names_not_descriptions() { + // T M10.7 wire-format-stability commitment: the strings + // emitted into `missing` are exactly the + // `FrontendCapabilities`/`InstanceCapabilities` field names. + // Human-readable translation happens in + // `AttachError::Display`, not on the wire. Renaming a bit + // requires updating both this emission and the field name + // in lockstep — this test pins the current names so a + // future rename forces an audit-visible diff here too. + let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false)) + .expect_err("should mismatch"); + match err { + GoodbyeReason::CapabilityMismatch { missing } => { + // The exact strings the wire carries — no + // pluralization, no hyphenation, no human polish. + assert!( + missing + .iter() + .all(|s| s.chars().all(|c| c.is_ascii_lowercase() || c == '_')), + "missing strings must be field-name identifiers (ascii lowercase + underscore), got {missing:?}" + ); + } + other => panic!("expected CapabilityMismatch, got {other:?}"), + } + } } diff --git a/src/rope.rs b/src/rope.rs index cfcb85d..1ea5164 100644 --- a/src/rope.rs +++ b/src/rope.rs @@ -185,6 +185,7 @@ impl Rope { new_rope: self.clone(), range: Range::new(pos, pos), inserted_len: 0, + crdt_op: None, }); } @@ -202,6 +203,7 @@ impl Rope { new_rope: Self { root }, range: Range::new(pos, pos), inserted_len: bytes.len() as u64, + crdt_op: None, }) } @@ -228,6 +230,7 @@ impl Rope { new_rope: self.clone(), range: Range::new(start, end), inserted_len: 0, + crdt_op: None, }); } @@ -241,6 +244,7 @@ impl Rope { new_rope: Self { root: new_root }, range: Range::new(start, end), inserted_len: 0, + crdt_op: None, }) } @@ -256,6 +260,7 @@ impl Rope { new_rope: after_insert.new_rope, range: Range::new(start, end), inserted_len: bytes.len() as u64, + crdt_op: None, }) } } @@ -304,6 +309,55 @@ pub struct Edit { pub range: Range, /// Number of bytes inserted at `range.start` in the *new* rope. pub inserted_len: u64, + /// T M10.2 Day 3: optional CRDT-op metadata. + /// + /// `Some` when this Edit was produced by a CRDT-backed Buffer's + /// edit path (`apply_edit` / `undo` / `redo`); `None` otherwise — both + /// in v0.1 mode (no CRDT) and for no-op edits in CRDT mode (an + /// empty insert at an empty range produces no CRDT op). + /// + /// `Box` indirection: keeps Edit's None-case cost to 8 bytes + /// (Box has a niche-optimized None) rather than the ~32 bytes + /// inline `Option` 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>, +} + +/// 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, } /// A half-open byte range `[start, end)` into a rope. diff --git a/src/transport.rs b/src/transport.rs index e5170a8..64fd9eb 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -197,6 +197,8 @@ mod tests { mouse: true, bracketed_paste: true, terminal_kind: Some("xterm-256color".into()), + multi_frontend: false, + crdt_replica: false, }, initial_size: crate::cell::CellSize::new(24, 80), }; diff --git a/src/window.rs b/src/window.rs index 4b8ce44..a5ed06a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -238,6 +238,27 @@ pub struct Layout { pub root: LayoutNode, } +/// T M10.8 — one attached frontend's view of the editor. +/// +/// Per-frontend state for multi-frontend operation: the split tree +/// the frontend sees and which window within it is focused. +/// `WindowId`s are globally unique across all frontends — the +/// `EditorCore::windows` flat map holds every window, and each +/// frontend's `FrontendView` references a subset via its `Layout`. +/// +/// The buffers themselves remain shared in `EditorCore::registry` — +/// two frontends with windows onto the same `BufferId` see the same +/// content but each window owns its own cursor / `view_top` / `goal_col`. +#[derive(Clone, Debug)] +pub struct FrontendView { + /// Window tree visible to this frontend. + pub layout: Layout, + /// Focused window within `layout`. Always a `WindowId` that + /// `layout` references (invariant: `layout.iter_ids()` contains + /// `active`). + pub active: WindowId, +} + impl Layout { /// A trivial single-window layout. #[must_use] diff --git a/src/workers_buffer.rs b/src/workers_buffer.rs index 0d2236b..c520631 100644 --- a/src/workers_buffer.rs +++ b/src/workers_buffer.rs @@ -37,37 +37,74 @@ use std::fmt::Write; use crate::async_runtime::{ ActiveJobInfo, CompletedJobInfo, JobOutcome, JobResult, WorkersSnapshot, }; -use crate::buffer::{BufferId, EditOp}; +use crate::buffer::{Buffer, BufferId, EditOp}; use crate::buffer_registry::BufferRegistry; /// Canonical name for the workers observability buffer. pub const WORKERS_BUFFER_NAME: &str = "*workers*"; /// Render `snapshot` into the `*workers*` buffer (creating it if -/// absent), replacing its full contents. Returns the buffer id. +/// absent), replacing its full contents. Returns the buffer id +/// and the Edits produced by the replacement (zero, one, or two — +/// one Delete for non-empty old content, one Insert for non-empty +/// new content). /// -/// The buffer is marked clean after rendering --- the modeline +/// # Post-audit-round-6 F31 — broadcast queueing +/// +/// When the buffer has been upgraded to CRDT-backed (which happens +/// at every replica's attach via `send_buffer_snapshots`), each +/// `apply_edit` produces an `Edit::crdt_op` that must broadcast to +/// every replica frontend so their `BufferMirror`s converge with +/// the daemon's new content. Returning the Edits lets the caller +/// queue them via `EditorCore::queue_daemon_origin_crdt_op` — the +/// render function itself doesn't have an `EditorCore` reference, +/// only the `BufferRegistry`. +/// +/// The buffer is marked clean after rendering — the modeline /// shouldn't claim unsaved changes for a generated buffer. -pub fn render(registry: &mut BufferRegistry, snapshot: &WorkersSnapshot) -> BufferId { +pub fn render( + registry: &mut BufferRegistry, + snapshot: &WorkersSnapshot, +) -> (BufferId, Vec) { let text = format_snapshot(snapshot); let id = registry .find_by_name(WORKERS_BUFFER_NAME) .unwrap_or_else(|| registry.create(WORKERS_BUFFER_NAME)); let buf = registry.get_mut(id).expect("just resolved"); + let mut edits = Vec::new(); + if buffer_contents_equal(buf, &text) { + buf.mark_clean(); + return (id, edits); + } if !buf.is_empty() { let len = buf.len(); - let _ = buf.apply_edit(EditOp::Delete { + if let Ok(edit) = buf.apply_edit(EditOp::Delete { range: crate::rope::Range::new(0, len), - }); + }) { + edits.push(edit); + } } if !text.is_empty() { - let _ = buf.apply_edit(EditOp::Insert { + if let Ok(edit) = buf.apply_edit(EditOp::Insert { pos: 0, bytes: text.as_bytes(), - }); + }) { + edits.push(edit); + } } buf.mark_clean(); - id + (id, edits) +} + +fn buffer_contents_equal(buf: &Buffer, text: &str) -> bool { + if buf.len() != text.len() as u64 { + return false; + } + let mut bytes = vec![0u8; text.len()]; + if !bytes.is_empty() { + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + } + bytes == text.as_bytes() } /// Format a snapshot as the buffer's text payload. @@ -288,6 +325,22 @@ mod tests { assert!(text.contains("200ms ago")); } + #[test] + fn render_same_snapshot_is_no_op() { + let mut reg = BufferRegistry::new(); + let s = snapshot_with(vec![], vec![]); + let (_id, first_edits) = render(&mut reg, &s); + assert!( + !first_edits.is_empty(), + "initial render should create buffer contents" + ); + let (_id, second_edits) = render(&mut reg, &s); + assert!( + second_edits.is_empty(), + "unchanged workers render must not emit delete/insert edits" + ); + } + #[test] fn duration_formatter_handles_three_scales() { assert_eq!(format_duration_ms(42), "42ms"); diff --git a/tests/m10_10_perf.rs b/tests/m10_10_perf.rs new file mode 100644 index 0000000..6fbb16c --- /dev/null +++ b/tests/m10_10_perf.rs @@ -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 = 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!(); +} diff --git a/tests/m10_10_postcard_unknown_variant.rs b/tests/m10_10_postcard_unknown_variant.rs new file mode 100644 index 0000000..9a11ba9 --- /dev/null +++ b/tests/m10_10_postcard_unknown_variant.rs @@ -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 }, +} + +#[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 = 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 = postcard::from_bytes(&bad); + let second: Result = 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" + ); +} diff --git a/tests/m10_2_perf.rs b/tests/m10_2_perf.rs new file mode 100644 index 0000000..c698129 --- /dev/null +++ b/tests/m10_2_perf.rs @@ -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 { + (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(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(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> = (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!(); +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 9f294dd..2199a62 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -466,7 +466,7 @@ fn render_active_window_to_grid( use pmacs::window::Rect; let mut core = state.core.borrow_mut(); - let active = core.active; + let active = core.active_window_id(); let registry = core.registry.clone(); let win = core.windows.get_mut(&active).expect("active window"); let rect = Rect::new(0, 0, rows, cols); @@ -559,7 +559,7 @@ fn m4_3_highlight_updates_within_one_frame_after_parse() { let core = state.core.borrow(); let win = core .windows - .get(&core.active) + .get(&core.active_window_id()) .expect("active window present"); assert!( !win.overlays.is_empty(), diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index 0c7a625..3b99c0d 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -32,6 +32,10 @@ use nix::unistd::Pid; use tempfile::TempDir; use pmacs::cell::CellSize; +#[cfg(feature = "crdt")] +use pmacs::cell::Color; +#[cfg(feature = "crdt")] +use pmacs::overlay_color::color_for_slot; use pmacs::protocol::{ AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, @@ -52,6 +56,12 @@ struct TestDaemon { impl TestDaemon { fn spawn() -> Self { + Self::spawn_with_env(&[]) + } + + /// T M10.8 Day 4 — spawn with extra env-var overrides for + /// instance-capability tests. + fn spawn_with_env(env_vars: &[(&str, &str)]) -> Self { let tempdir = TempDir::new().expect("tempdir"); // tempfile::TempDir creates 0755-mode directories; the daemon // requires a 0700-or-stricter parent for the socket. Tighten @@ -59,7 +69,7 @@ impl TestDaemon { fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700)) .expect("chmod tempdir 0700"); let socket_path = tempdir.path().join("pmacs.sock"); - let process = spawn_daemon_process(&socket_path); + let process = spawn_daemon_process_with_env(&socket_path, env_vars); wait_for_socket_or_exit(&socket_path, &process, Duration::from_secs(10)) .expect("daemon socket appeared"); Self { @@ -96,20 +106,29 @@ impl Drop for TestDaemon { } fn spawn_daemon_process(socket_path: &Path) -> Child { - // Isolate user config so a test daemon doesn't read $HOME/.pmacs/init.lua. - // Both HOME and XDG_CONFIG_HOME point at the parent of the socket - // (which is empty at this point), so config::load_user_config is a - // no-op. + spawn_daemon_process_with_env(socket_path, &[]) +} + +/// Spawn a daemon with extra environment-variable overrides. +/// +/// T M10.8 Day 4 — used by tests that need to exercise non-default +/// instance capabilities (e.g., the M10.7 mismatch test, which +/// needs `PMACS_INSTANCE_MULTI_FRONTEND=0` so a frontend declaring +/// `multi_frontend: true` hits the capability-mismatch path). +/// Production daemons don't set these vars. +fn spawn_daemon_process_with_env(socket_path: &Path, env_vars: &[(&str, &str)]) -> Child { let isolated_home = socket_path.parent().expect("socket has parent"); - Command::new(env!("CARGO_BIN_EXE_pmacs")) - .args(["--daemon", "--socket"]) + let mut cmd = Command::new(env!("CARGO_BIN_EXE_pmacs")); + cmd.args(["--daemon", "--socket"]) .arg(socket_path) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn pmacs --daemon") + .stderr(Stdio::null()); + for (key, value) in env_vars { + cmd.env(key, value); + } + cmd.spawn().expect("spawn pmacs --daemon") } fn wait_for_socket_or_exit( @@ -153,6 +172,8 @@ fn build_default_caps() -> FrontendCapabilities { mouse: true, bracketed_paste: true, terminal_kind: Some("test".into()), + multi_frontend: false, + crdt_replica: false, } } @@ -554,3 +575,1917 @@ fn version_mismatch_clean_disconnect() { .unwrap(); let _hello = do_handshake(&mut stream2); } + +// --------------------------------------------------------------------------- +// T M10.7 — capability negotiation, daemon end-to-end. +// +// Pure-function tests for `negotiate_capabilities` live in +// `src/protocol.rs` and exercise the negotiation logic exhaustively. +// This end-to-end test exercises the wire path: a v2 frontend +// declaring `multi_frontend: true` receives `Goodbye( +// CapabilityMismatch)` from a v2 daemon whose +// `InstanceCapabilities::default()` has `multi_frontend: false` +// (M10.8 flips that bit; M10.7 ships the mismatch path). +// --------------------------------------------------------------------------- + +#[test] +fn m10_7_capability_mismatch_v2_frontend_wants_multi_frontend() { + // T M10.8 Day 4 rewrite: M10.8 flipped `InstanceCapabilities:: + // default()` to advertise `multi_frontend: true` and `crdt_replica: + // true`. To preserve daemon-end-to-end coverage of the M10.7 + // mismatch path, this test spawns the daemon with + // `PMACS_INSTANCE_MULTI_FRONTEND=0` so the instance advertises + // `multi_frontend: false` and a frontend declaring `true` + // hits the mismatch. + // + // Approach (i) from the M10.8 framing-pass review: explicit + // instance-caps override preserves daemon-end-to-end coverage. + // Pure-function negotiation tests in `protocol.rs` still cover + // the negotiation logic itself; this test specifically + // exercises the daemon's wiring of negotiate_capabilities into + // handle_connection. + let mut daemon = TestDaemon::spawn_with_env(&[("PMACS_INSTANCE_MULTI_FRONTEND", "0")]); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let _hello: Hello = read_message(&mut stream).expect("read Hello"); + + let caps_multi = FrontendCapabilities { + multi_frontend: true, + ..build_default_caps() + }; + let req = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: caps_multi, + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream, &req).expect("write AttachRequest"); + + match read_message::(&mut stream) { + Ok(InstanceMessage::Goodbye(GoodbyeReason::CapabilityMismatch { missing })) => { + assert_eq!( + missing, + vec!["multi_frontend".to_string()], + "M10.7 criterion 4: error names the requested capability" + ); + } + other => panic!("expected Goodbye(CapabilityMismatch), got {other:?}"), + } + + // Daemon stays up; another connection without multi_frontend + // succeeds even with the env-var override (the env var only + // affects the instance's advertised caps, not what frontends + // request — a frontend not asking for multi_frontend doesn't + // hit the mismatch). + drop(stream); + assert!(daemon.is_alive()); + let mut stream2 = daemon.connect(); + stream2 + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello2 = do_handshake(&mut stream2); +} + +#[test] +fn m10_7_no_negotiation_for_v1_frontend() { + // T M10.7 criterion 1 — a v0.1 frontend (no `multi_frontend` + // declared in the wire format) attaches successfully. The + // negotiation function sees frontend.multi_frontend = false + // (from #[serde(default)]) and produces a clean Ok(...) result; + // no mismatch is generated. + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let _hello = do_handshake(&mut stream); + // do_handshake's default caps have multi_frontend: false; the + // attach should succeed and produce the initial CellDelta. If + // the daemon had emitted Goodbye(CapabilityMismatch) instead, + // read_message below would decode that variant — but the + // standard handshake path produces the CellDelta. + let initial: InstanceMessage = read_message(&mut stream).expect("initial frame"); + match initial { + InstanceMessage::CellDelta { + full_grid: true, .. + } => {} + other => panic!("expected initial full-grid CellDelta, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// T M10.8 Day 4 — multi-attach end-to-end acceptance + Q5 admission matrix. +// --------------------------------------------------------------------------- + +/// Caps for a v1.0 multi-frontend frontend (declares both bits). +#[cfg(feature = "crdt")] +fn multi_frontend_caps() -> FrontendCapabilities { + FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + ..build_default_caps() + } +} + +/// Connect and run the `AttachRequest` with multi-frontend caps. +/// Reads but does not assert on the initial `CellDelta` — caller +/// handles that. Returns the Hello + stream. +#[cfg(feature = "crdt")] +fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) { + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let hello: Hello = read_message(&mut stream).expect("read Hello"); + let req = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: multi_frontend_caps(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream, &req).expect("write AttachRequest"); + (hello, stream) +} + +/// M10.8 acceptance criterion 1 + 2 + 3 — happy-path multi-attach. +/// +/// Two v1.0 frontends both negotiate `multi_frontend: true`, +/// attach simultaneously, and each receives at least one +/// presence broadcast from the other when the other moves its +/// cursor. +// Multi-frontend tests below exercise the M10.8/M10.9 multi-frontend +// + presence + CRDT path. Post-audit Finding 3 fix made +// `InstanceCapabilities::default()` advertise `multi_frontend: false` +// in non-CRDT builds (because multi-frontend's payoff is the CRDT +// pipeline). These tests are inherently CRDT-feature-only. +#[cfg(feature = "crdt")] +#[test] +fn m10_8_two_frontends_attached_simultaneously_receive_presence_broadcasts() { + let daemon = TestDaemon::spawn(); + + let (hello_a, mut stream_a) = attach_multi(&daemon); + let (hello_b, mut stream_b) = attach_multi(&daemon); + assert_ne!(hello_a.assigned_frontend_id, hello_b.assigned_frontend_id); + + // Read initial frames synchronously so kernel buffers stay + // drained; drain_pending's 50ms-timeout reads back up under + // the dispatcher's per-tick writes. + let _initial_a: InstanceMessage = read_message(&mut stream_a).expect("A initial frame"); + let _initial_b: InstanceMessage = read_message(&mut stream_b).expect("B initial frame"); + + // Send a key event from A. The daemon dispatches it to A's + // active window; cursor moves; per-tick presence sweep + // produces a PresenceUpdate broadcast to B. + let key = FrontendEvent::Key(pmacs::protocol::KeyEvent { + frontend_id: hello_a.assigned_frontend_id, + key: pmacs::protocol::Key::Char('x'), + mods: pmacs::protocol::Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_a, &key).expect("send key from A"); + + // Both A and B will receive some messages. We're specifically + // checking that B receives a PresenceUpdate sourced from + // hello_a.assigned_frontend_id. + let deadline = Instant::now() + Duration::from_secs(2); + stream_b + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let mut saw_presence_from_a = false; + while Instant::now() < deadline { + if let Ok(InstanceMessage::PresenceUpdate { frontend_id, .. }) = + read_message::(&mut stream_b) + && frontend_id == hello_a.assigned_frontend_id + { + saw_presence_from_a = true; + break; + } + // Other messages (CellDelta, Cursor) and read timeouts both + // just continue the deadline loop. + } + assert!( + saw_presence_from_a, + "M10.8 criterion 1: frontend B should receive a PresenceUpdate from A's cursor move" + ); +} + +/// M10.8 acceptance criterion 2 — disconnect is local. +/// +/// Attach A and B; disconnect A; verify B continues to operate. +#[cfg(feature = "crdt")] +#[test] +fn m10_8_disconnect_of_one_frontend_does_not_affect_the_other() { + let mut daemon = TestDaemon::spawn(); + + let (_hello_a, stream_a) = attach_multi(&daemon); + let (hello_b, mut stream_b) = attach_multi(&daemon); + let _initial_b: InstanceMessage = read_message(&mut stream_b).expect("B initial frame"); + + // Disconnect A by dropping its stream. + drop(stream_a); + + // Give the dispatcher a moment to process A's detach. + thread::sleep(Duration::from_millis(100)); + + // B can still send events and the daemon responds. + let key = FrontendEvent::Key(pmacs::protocol::KeyEvent { + frontend_id: hello_b.assigned_frontend_id, + key: pmacs::protocol::Key::Char('y'), + mods: pmacs::protocol::Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_b, &key).expect("B can still send keys"); + + stream_b + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let response: InstanceMessage = + read_message(&mut stream_b).expect("B receives response after A disconnected"); + // Any response counts — CellDelta from B's edit, Cursor, or a + // presence broadcast from before the disconnect. The + // assertion is that B's connection is still live. + let _ = response; + assert!(daemon.is_alive()); +} + +/// Q5 row 1: v0.1 frontend attached; another v0.1 attaches; rejected. +#[test] +fn m10_8_q5_row1_v01_with_v01_attempt_rejected() { + let daemon = TestDaemon::spawn(); + + // First v0.1 frontend (no multi_frontend, no crdt_replica): attaches. + let mut stream_a = daemon.connect(); + stream_a + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello_a = do_handshake(&mut stream_a); + // Read initial frame so the daemon's per-tick render isn't + // backed up on stream_a (which might trigger spurious write + // failures elsewhere). + let initial: InstanceMessage = read_message(&mut stream_a).expect("initial frame on A"); + let _ = initial; + + // Second v0.1 frontend: rejected with AlreadyAttached. + let mut stream_b = daemon.connect(); + stream_b + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello_b: Hello = read_message(&mut stream_b).expect("read Hello"); + let req_b = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream_b, &req_b).expect("write AttachRequest"); + + match read_message::(&mut stream_b) { + Ok(InstanceMessage::Goodbye(GoodbyeReason::AlreadyAttached)) => {} + other => panic!("Q5 row 1: expected AlreadyAttached, got {other:?}"), + } +} + +/// Q5 row 2: v0.1 attached; v2 multi attaches; both coexist +/// (heterogeneous case; the v0.1 sees its session normally, the +/// v2 sees the v0.1 as a "ghost editor"). +#[cfg(feature = "crdt")] +#[test] +fn m10_8_q5_row2_v01_with_v2_multi_coexist() { + let daemon = TestDaemon::spawn(); + + // First: v0.1 frontend attaches normally. + let mut stream_v1 = daemon.connect(); + stream_v1 + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello_v1 = do_handshake(&mut stream_v1); + // Read initial frame synchronously so the kernel buffer + // doesn't back up; using drain_pending here causes the + // dispatcher's per-tick writes to accumulate while the test + // is mid-50ms-timeout, which can leave streams in a flaky + // state by the time the second attach runs. + let _initial_v1: InstanceMessage = read_message(&mut stream_v1).expect("v0.1 initial frame"); + + // Second: v2 multi-capable frontend attaches alongside. + let (_hello_v2, mut stream_v2) = attach_multi(&daemon); + // If the multi attach succeeded, the daemon sends initial + // CellDelta etc. — read at least one frame to confirm + // acceptance. + let initial: InstanceMessage = read_message(&mut stream_v2).expect("v2 initial frame"); + match initial { + InstanceMessage::CellDelta { .. } => {} + InstanceMessage::Goodbye(reason) => { + panic!("Q5 row 2: v2 multi should attach alongside v0.1, got Goodbye: {reason:?}") + } + _other => {} // Cursor / etc. also acceptable as "attached" + } +} + +/// Q5 row 3: v2 multi attached; v0.1 attempts to attach; accepted +/// (Q5 logic: a non-multi attach is rejected iff +/// `count_non_multi_sessions > 0`; with only multi attached the +/// count is 0, so v0.1 takes the non-multi slot). +#[cfg(feature = "crdt")] +#[test] +fn m10_8_q5_row3_v2_multi_then_v01_accepted() { + let daemon = TestDaemon::spawn(); + + let (_hello_v2, mut stream_v2) = attach_multi(&daemon); + // Read the v2 initial frame synchronously (same rationale as + // Q5 row 2 — avoid drain_pending's 50ms-timeout buffer race). + let _initial_v2: InstanceMessage = read_message(&mut stream_v2).expect("v2 initial frame"); + + // v0.1 frontend attaches: should succeed because the multi + // session doesn't occupy the non-multi slot. + let mut stream_v1 = daemon.connect(); + stream_v1 + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello_v1: Hello = read_message(&mut stream_v1).expect("read Hello"); + let req_v1 = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream_v1, &req_v1).expect("write AttachRequest"); + + let initial: InstanceMessage = read_message(&mut stream_v1).expect("v0.1 initial frame"); + match initial { + InstanceMessage::CellDelta { .. } => {} + InstanceMessage::Goodbye(reason) => { + panic!("Q5 row 3: v0.1 should attach alongside v2 multi, got Goodbye: {reason:?}") + } + _other => {} + } +} + +/// Q5 row 4: two v2 multi sessions, both attached. +#[cfg(feature = "crdt")] +#[test] +fn m10_8_q5_row4_two_v2_multi_sessions_attached() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a) = attach_multi(&daemon); + let (hello_b, mut stream_b) = attach_multi(&daemon); + assert_ne!(hello_a.assigned_frontend_id, hello_b.assigned_frontend_id); + + // Both receive initial frames; no rejection. + let _frame_a: InstanceMessage = read_message(&mut stream_a).expect("A initial"); + let _frame_b: InstanceMessage = read_message(&mut stream_b).expect("B initial"); +} + +// --------------------------------------------------------------------------- +// T M10.9 — overlay color rendering, daemon end-to-end. +// --------------------------------------------------------------------------- + +/// M10.9 acceptance criterion 1: two frontends in the same buffer +/// see each other's cursors. +/// +/// Spawn daemon, attach two multi-capable frontends. They share +/// LOCAL's buffer (M10.9 attach behavior). A inserts a few +/// characters (moving A's cursor); B's subsequent `CellDelta` +/// should contain an overlay cell with A's assigned color. +#[cfg(feature = "crdt")] +#[test] +fn m10_9_other_frontend_cursor_appears_in_recipient_cell_delta_with_color() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a) = attach_multi(&daemon); + let (_hello_b, mut stream_b) = attach_multi(&daemon); + + // Read initial CellDelta + Cursor on each. M10.9: B's initial + // frame includes A's cursor at byte 0 (where A's window starts). + let _initial_a: InstanceMessage = read_message(&mut stream_a).expect("A initial"); + let _initial_b_1: InstanceMessage = read_message(&mut stream_b).expect("B initial CellDelta"); + let _initial_b_2: InstanceMessage = read_message(&mut stream_b).expect("B initial Cursor"); + + // A presses a character. A's cursor advances; B should + // receive a CellDelta that includes an overlay cell with A's + // color. + let key = FrontendEvent::Key(KeyEvent { + frontend_id: hello_a.assigned_frontend_id, + key: Key::Char('z'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_a, &key).expect("send key from A"); + + // Compute A's expected color. With both A and B from same uid + // (test process), they share a color slot — so this test + // verifies the *presence* of an overlay-style cell, not a + // specific color. We assert that B's CellDelta contains at + // least one cell whose fg is in the M10.9 palette. + let deadline = Instant::now() + Duration::from_secs(2); + stream_b + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let mut saw_palette_cell = false; + while Instant::now() < deadline { + if let Ok(InstanceMessage::CellDelta { spans, .. }) = + read_message::(&mut stream_b) + { + for span in &spans { + for cell in &span.cells { + // The palette uses Color::Rgb(...). Any cell whose + // fg is a palette entry is an overlay cell. + if let Color::Rgb(_, _, _) = cell.style.fg { + if is_palette_color(cell.style.fg) { + saw_palette_cell = true; + break; + } + } + } + if saw_palette_cell { + break; + } + } + } + if saw_palette_cell { + break; + } + } + assert!( + saw_palette_cell, + "M10.9 criterion 1: B's CellDelta should contain an overlay cell with a palette color after A moved its cursor" + ); +} + +#[cfg(feature = "crdt")] +fn is_palette_color(c: Color) -> bool { + for slot in 0..pmacs::overlay_color::PALETTE_LEN as u8 { + if color_for_slot(slot) == c { + return true; + } + } + false +} + +/// M10.9 acceptance criterion 2 (partial): color stability for +/// same-uid reconnect. +/// +/// Attach A → detach A → reattach A'. A and A' connect from the +/// same uid (the test process). The daemon's `color_registry` maps +/// uid → slot. The slot should be the same for both attaches. +/// +/// This test verifies the color-stability property at the daemon +/// level by attaching B as observer, then attaching A (slot +/// recorded), detaching A, reattaching A' (slot reused from +/// uid lookup), and checking B sees consistent overlay color. +#[cfg(feature = "crdt")] +#[test] +fn m10_9_color_stable_across_reconnect_for_same_uid() { + let daemon = TestDaemon::spawn(); + // B is the observer. + let (_hello_b, mut stream_b) = attach_multi(&daemon); + let _initial_b_cd: InstanceMessage = read_message(&mut stream_b).expect("B initial cd"); + let _initial_b_cu: InstanceMessage = read_message(&mut stream_b).expect("B initial cursor"); + + // A attaches the first time. + let (hello_a1, mut stream_a1) = attach_multi(&daemon); + let _a1_init: InstanceMessage = read_message(&mut stream_a1).expect("A1 init"); + // A1 moves cursor so an overlay is generated for B. + let key1 = FrontendEvent::Key(KeyEvent { + frontend_id: hello_a1.assigned_frontend_id, + key: Key::Char('x'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_a1, &key1).expect("send key from A1"); + + // Capture A1's overlay color from B. + let color_a1 = wait_for_palette_color_in_b(&mut stream_b, Duration::from_secs(2)) + .expect("should observe A1's overlay color"); + + // Detach A1. + drop(stream_a1); + thread::sleep(Duration::from_millis(100)); + + // A2 reattaches (same test process, same uid). + let (hello_a2, mut stream_a2) = attach_multi(&daemon); + let _a2_init: InstanceMessage = read_message(&mut stream_a2).expect("A2 init"); + let key2 = FrontendEvent::Key(KeyEvent { + frontend_id: hello_a2.assigned_frontend_id, + key: Key::Char('y'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_a2, &key2).expect("send key from A2"); + + let color_a2 = wait_for_palette_color_in_b(&mut stream_b, Duration::from_secs(2)) + .expect("should observe A2's overlay color"); + + assert_eq!( + color_a1, color_a2, + "M10.9 criterion 2: same uid across reconnect → same color slot" + ); +} + +/// Helper for color-stability test: read `CellDelta` messages from +/// `stream` for up to `timeout` and return the first palette +/// color found in any overlay cell. +#[cfg(feature = "crdt")] +fn wait_for_palette_color_in_b(stream: &mut UnixStream, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .ok(); + while Instant::now() < deadline { + if let Ok(InstanceMessage::CellDelta { spans, .. }) = + read_message::(stream) + { + for span in &spans { + for cell in &span.cells { + if let Color::Rgb(_, _, _) = cell.style.fg { + if is_palette_color(cell.style.fg) { + return Some(cell.style.fg); + } + } + } + } + } + } + None +} + +// --------------------------------------------------------------------------- +// T M10.10 Day 2 — daemon sends BufferSnapshot on SessionEstablished +// for crdt_replica-negotiated frontends; not for non-replica frontends. +// --------------------------------------------------------------------------- + +/// M10.10 Day 2 acceptance: a frontend that negotiated +/// `crdt_replica: true` receives an `InstanceMessage::BufferSnapshot` +/// for the `*scratch*` buffer immediately after `SessionEstablished`, +/// before any `CellDelta`. The snapshot bytes round-trip into a fresh +/// `CrdtState` and yield the expected materialized content. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_replica_frontend_receives_buffer_snapshot_before_cell_delta() { + let daemon = TestDaemon::spawn(); + let (_hello, mut stream) = attach_multi(&daemon); + + // The first InstanceMessage after handshake must be a + // BufferSnapshot. The dispatcher sends snapshots before the first + // per-tick render fires. + let first: InstanceMessage = read_message(&mut stream).expect("first frame"); + let (buffer_id, snapshot_bytes) = match first { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => { + panic!("M10.10: first frame for replica frontend must be BufferSnapshot, got {other:?}") + } + }; + assert!( + !snapshot_bytes.is_empty(), + "snapshot bytes must be non-empty (loro encodes empty state as a non-empty payload)" + ); + + // The snapshot must decode into a fresh CrdtState. peer_id 0xBEEF + // here is arbitrary — bootstrap on the frontend uses + // peer_id_from_frontend(my_id), but the round-trip test doesn't + // care which peer reconstructs. + let replica = pmacs::crdt::CrdtState::new(0xBEEF).expect("fresh CrdtState"); + replica + .import_snapshot(&snapshot_bytes) + .expect("import the daemon's snapshot"); + // The daemon's *scratch* buffer starts empty. + assert_eq!(replica.materialize_string(), ""); + let _ = buffer_id; // consumed for the panic-message in the match +} + +/// M10.10 Day 3 Finding 2 acceptance: a replica frontend receives an +/// authoritative `InstanceMessage::CursorByte` paired with the regular +/// `Cursor` grid update. The byte position is the daemon's +/// active-window cursor at the moment of the render frame. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_replica_frontend_receives_cursor_byte_paired_with_cursor() { + let daemon = TestDaemon::spawn(); + let (_hello, mut stream) = attach_multi(&daemon); + + // Drain initial frames until both Cursor and CursorByte have + // been seen. The daemon emits per-tick: CellDelta, Cursor, + // CursorByte. BufferSnapshot fires before all of those at + // session establishment. We want to confirm that for the same + // render iteration, Cursor and CursorByte both arrive. + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut saw_cursor = false; + let mut saw_cursor_byte = false; + for _ in 0..16 { + match read_message::(&mut stream) { + Ok(InstanceMessage::Cursor(_)) => saw_cursor = true, + Ok(InstanceMessage::CursorByte { .. }) => saw_cursor_byte = true, + Ok(_) => continue, + Err(_) => break, + } + if saw_cursor && saw_cursor_byte { + break; + } + } + assert!(saw_cursor, "replica frontend should receive Cursor"); + assert!( + saw_cursor_byte, + "replica frontend should receive CursorByte alongside Cursor (Finding 2)" + ); +} + +/// M10.10 Day 3 Finding 3 acceptance: end-to-end `CrdtOp` broadcast. +/// Frontend A (replica) sends `FrontendEvent::CrdtOp` to the daemon; +/// daemon applies it to the buffer's CRDT + rope, then fans out +/// `InstanceMessage::CrdtOp` to other replica frontends. Frontend B +/// (also replica) receives the broadcast tagged with A's +/// `frontend_id` so B's mirror can route through the echo-dedup +/// filter (apply, not skip). +#[cfg(feature = "crdt")] +#[test] +fn m10_10_crdt_op_from_a_reaches_b_via_daemon_broadcast() { + let daemon = TestDaemon::spawn(); + + // Attach A and B as replica frontends. + let (hello_a, mut stream_a) = attach_multi(&daemon); + let (hello_b, mut stream_b) = attach_multi(&daemon); + assert_ne!(hello_a.assigned_frontend_id, hello_b.assigned_frontend_id); + + // Drain A's first frame (BufferSnapshot) and bootstrap a test- + // side CRDT replica from it. We'll generate a valid op against + // this state and send it as A. + let (buffer_id, snapshot_bytes) = + match read_message::(&mut stream_a).expect("A first frame") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + + let a_replica = pmacs::crdt::CrdtState::new(hello_a.assigned_frontend_id.0).expect("a replica"); + a_replica.import_snapshot(&snapshot_bytes).expect("import"); + + // A generates a CrdtOp (simulating optimistic-apply at keystroke). + let v_before = a_replica.version(); + a_replica.insert(0, "X").expect("a insert"); + let op_bytes = a_replica + .export_updates_since(&v_before) + .expect("export updates"); + + // Drain B's first frame (BufferSnapshot — same buffer_id; the + // daemon snapshots all CRDT buffers for each replica at attach). + let _b_first: InstanceMessage = read_message(&mut stream_b).expect("B first frame"); + + // A sends FrontendEvent::CrdtOp upstream. + let crdt_op_event = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes.clone(), + }, + }; + write_message(&mut stream_a, &crdt_op_event).expect("send CrdtOp from A"); + + // B should receive an InstanceMessage::CrdtOp tagged with A's + // frontend_id (via op.peer_id since the wire variant doesn't + // carry a separate source field). + stream_b + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let mut saw_crdt_op_from_a = false; + while Instant::now() < deadline { + match read_message::(&mut stream_b) { + Ok(InstanceMessage::CrdtOp { buffer_id: bid, op }) + if bid == buffer_id && op.peer_id == hello_a.assigned_frontend_id.0 => + { + saw_crdt_op_from_a = true; + assert_eq!( + op.bytes, op_bytes, + "broadcast bytes must match A's emitted bytes" + ); + break; + } + Ok(_) | Err(_) => {} + } + } + assert!( + saw_crdt_op_from_a, + "M10.10 Finding 3 criterion: B must receive CrdtOp from A via daemon broadcast" + ); +} + +/// M10.10 (post-audit Finding 6) — verify the **production** +/// `attach::build_capabilities()` output negotiates `crdt_replica` +/// correctly so the production TUI binary actually receives +/// `BufferSnapshot` and bootstraps its `BufferMirror`. +/// +/// Pre-fix: the M10.10 acceptance tests used `attach_multi()` with +/// custom caps that had `crdt_replica: true`; the production +/// `build_capabilities()` had `crdt_replica: false` so the optimistic- +/// apply infrastructure was structurally unreachable in the real +/// binary. Test coverage didn't catch this because no test used the +/// production caps. +/// +/// This test closes that gap: spawn a daemon, connect, send an +/// `AttachRequest` with caps from production `build_capabilities()`, +/// verify the first non-handshake frame is `BufferSnapshot`. If it's +/// `CellDelta` instead, the production capability negotiation is +/// broken. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_production_attach_negotiates_crdt_replica() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + // Production handshake — NOT the test `attach_multi()` path. + let hello: Hello = read_message(&mut stream).expect("read Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + let req = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: pmacs::attach::build_capabilities(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream, &req).expect("write AttachRequest"); + + // Production caps in CRDT-feature builds set `crdt_replica: true`. + // First frame after the handshake must be `BufferSnapshot`. + let first: InstanceMessage = read_message(&mut stream).expect("first frame"); + match first { + InstanceMessage::BufferSnapshot { .. } => {} + InstanceMessage::CellDelta { .. } => panic!( + "Finding 6: production frontend received CellDelta as first frame; \ + expected BufferSnapshot. The production caps don't negotiate \ + crdt_replica, so M10.10 optimistic apply is dead in the real TUI." + ), + other => { + panic!("expected BufferSnapshot as first production-frontend frame, got {other:?}") + } + } +} + +/// M10.10 (post-audit Finding 6 companion) — non-CRDT build path. +/// The production `build_capabilities()` advertises `crdt_replica: +/// false` when built without the `crdt` feature, and the daemon +/// (also non-CRDT) advertises `crdt_replica: false`, so neither side +/// negotiates the capability and the first frame is `CellDelta` as +/// in v0.1. Verifies Finding 3 fix is symmetric on both sides. +#[cfg(not(feature = "crdt"))] +#[test] +fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let _hello: Hello = read_message(&mut stream).expect("read Hello"); + let req = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: pmacs::attach::build_capabilities(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream, &req).expect("write AttachRequest"); + + let first: InstanceMessage = read_message(&mut stream).expect("first frame"); + match first { + InstanceMessage::CellDelta { .. } => {} + other => panic!( + "non-CRDT build expected CellDelta as first frame; got {other:?}. \ + Finding 3 regression?" + ), + } +} + +/// M10.10 Day 4 — verify `PMACS_INSTANCE_LATENCY_MS` injection. +/// +/// Sets the env var to 200ms; spawns a daemon; attaches a frontend; +/// triggers a `CellDelta` emission by sending a key event; verifies +/// the `CellDelta` arrives at least ~150ms later (allowing for +/// scheduling jitter on busy CI). +/// +/// This is the load-bearing setup test for the criterion 1 +/// verification — without confirming the injection mechanism +/// works, the latency-dependent criterion tests can't trust their +/// timing. +#[test] +fn m10_10_latency_injection_delays_cell_delta() { + let daemon = TestDaemon::spawn_with_env(&[("PMACS_INSTANCE_LATENCY_MS", "200")]); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let hello = do_handshake(&mut stream); + + // Trigger a CellDelta by sending a key event. v0.1-mode + // frontend (build_default_caps has crdt_replica=false) so the + // daemon's path is: receive key → apply edit → render → emit + // CellDelta with injected sleep. + let key = FrontendEvent::Key(KeyEvent { + frontend_id: hello.assigned_frontend_id, + key: Key::Char('x'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }); + + let start = Instant::now(); + write_message(&mut stream, &key).expect("send key"); + + // Drain incoming messages until we see a CellDelta. + loop { + if matches!( + read_message::(&mut stream).expect("frame"), + InstanceMessage::CellDelta { .. } + ) { + break; + } + } + let elapsed = start.elapsed(); + + // Lower bound: at least 150ms (200ms injection minus jitter + // tolerance). Upper bound: avoid asserting because CI variability + // can be high; failing-by-exceeding-bound would be a different + // kind of bug. + assert!( + elapsed >= Duration::from_millis(150), + "PMACS_INSTANCE_LATENCY_MS=200 should delay CellDelta by ~200ms, \ + observed {elapsed:?}" + ); +} + +/// M10.10 Day 4 — criterion 1 acceptance: keystroke send is non- +/// blocking regardless of daemon latency. +/// +/// Spec criterion 1: "Local edit visible in less than one frame +/// regardless of instance latency." +/// +/// Under Path β, the visible local edit is the optimistic Print +/// emitted synchronously by the frontend's keystroke handler. The +/// daemon's `CellDelta` arrives later (delayed by injected latency) +/// but doesn't block the frontend. +/// +/// Demonstration: send 10 `CrdtOp` events back-to-back to a daemon +/// with 200ms injected latency. If the frontend blocked on each +/// `CellDelta`, total time would be ≥2s. The actual time should be +/// under 100ms because writes are non-blocking (the dispatcher's +/// sleeps don't affect the writer's send). +/// +/// This is the wire-level demonstration of criterion 1 that +/// complements the orchestrator-level unit test +/// `criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_criterion_1_keystroke_send_non_blocking_at_200ms_latency() { + let daemon = TestDaemon::spawn_with_env(&[("PMACS_INSTANCE_LATENCY_MS", "200")]); + let (hello, mut stream) = attach_multi(&daemon); + + // Bootstrap the test replica from the BufferSnapshot. + let (buffer_id, snap) = match read_message::(&mut stream).expect("snap") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + let replica = pmacs::crdt::CrdtState::new(hello.assigned_frontend_id.0).expect("replica"); + replica.import_snapshot(&snap).expect("import"); + + // Simulate 10 back-to-back keystrokes, each producing a CrdtOp + // sent to the daemon. The frontend's orchestrator is in-process + // here (the test code is the orchestrator); each send is the + // analog of "after orchestrator returns CrdtOp + optimistic + // Print, the wire send happens." + let start = Instant::now(); + for ch in "0123456789".chars() { + let v_before = replica.version(); + replica.insert(0, &ch.to_string()).expect("local insert"); + let bytes = replica.export_updates_since(&v_before).expect("export"); + let event = FrontendEvent::CrdtOp { + frontend_id: hello.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello.assigned_frontend_id.0, + bytes, + }, + }; + write_message(&mut stream, &event).expect("send"); + } + let elapsed = start.elapsed(); + + // If the frontend's sends blocked on the daemon's 200ms-delayed + // CellDelta replies, total would be ≥ 2s (10 × 200ms). Non- + // blocking sends complete in microseconds. Upper bound of 100ms + // catches any synchronous-IO regression while tolerating CI + // jitter on the per-write socket cost. + assert!( + elapsed < Duration::from_millis(100), + "criterion 1: 10 keystroke sends at 200ms injected latency took \ + {elapsed:?}; expected non-blocking (<100ms). The frontend is \ + blocking on daemon round-trips." + ); +} + +/// M10.10 Day 4 — criterion 2 acceptance: no-flicker via byte- +/// equivalent optimistic paint. +/// +/// Spec criterion 2: "Confirmation cell delta does not produce +/// visible flicker or correction (the optimistic state matches the +/// confirmed state)." +/// +/// Under Path β's end-of-line scope, the optimistic Print emits the +/// typed character at the cursor's column with the terminal's +/// default style. The daemon's `CellDelta` for the same edit carries +/// a `Cell { glyph: Char(c), style: default }` at the same column. +/// If both encode the same character at the same column with the +/// same style, the daemon's `CellDelta` repaints the cell identically +/// → no visible change → no flicker. +/// +/// This test verifies the byte-equivalence property: after the +/// frontend sends a `CrdtOp` for inserting 'X' at end-of-line, the +/// daemon's resulting `CellDelta` carries an 'X' cell at the column +/// where the optimistic Print would have written it. The cell's +/// style is default (no overlays/highlighting active in this +/// minimal test setup). +/// +/// **Path β scope**: end-of-line typing only. Mid-line typing +/// produces a multi-cell `CellDelta` (shifted cells); under Path β, +/// no optimistic paint exists for that case (orchestrator round- +/// trips), so there's no optimistic state to flicker against. +/// Documented as v0.2+ Path γ work. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_criterion_2_no_flicker_for_end_of_line_optimistic_insert() { + use pmacs::cell::{Cell, DiffSpan, Glyph, Style}; + + let daemon = TestDaemon::spawn(); + let (hello, mut stream) = attach_multi(&daemon); + + // Bootstrap mirror from BufferSnapshot. + let (buffer_id, snap) = match read_message::(&mut stream).expect("snap") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + let replica = pmacs::crdt::CrdtState::new(hello.assigned_frontend_id.0).expect("replica"); + replica.import_snapshot(&snap).expect("import"); + + // The *scratch* buffer starts empty; cursor is at byte 0 + // (which is end-of-line for an empty buffer per the Path β + // predicate). Insert 'X' at position 0 — both daemon and + // optimistic Print would put 'X' at column 0 of row 0. + let v_before = replica.version(); + replica.insert(0, "X").expect("local insert"); + let op_bytes = replica.export_updates_since(&v_before).expect("export"); + + let event = FrontendEvent::CrdtOp { + frontend_id: hello.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello.assigned_frontend_id.0, + bytes: op_bytes, + }, + }; + write_message(&mut stream, &event).expect("send CrdtOp"); + + // Drain incoming until we find a CellDelta containing the 'X' + // cell. + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + let mut found_x_cell: Option = None; + while Instant::now() < deadline && found_x_cell.is_none() { + match read_message::(&mut stream) { + Ok(InstanceMessage::CellDelta { spans, .. }) => { + for DiffSpan { start, cells } in spans { + // Look for an 'X' cell at row 0 (the *scratch* + // buffer's only line). + for (offset, cell) in cells.iter().enumerate() { + if start.row == 0 + && (start.col as usize + offset) == 0 + && matches!(cell.glyph, Glyph::Char('X')) + { + found_x_cell = Some(cell.clone()); + break; + } + } + } + } + Ok(_) => {} + Err(_) => break, + } + } + + let cell = found_x_cell.expect( + "criterion 2: daemon's CellDelta should carry 'X' at column 0 of row 0 \ + after the CrdtOp lands", + ); + + // Byte-equivalence check: the cell's style must be Default + // (matching what an optimistic Print without explicit style + // emits to the terminal). Any non-default style here would + // create visible flicker between optimistic Print (no style) + // and daemon CellDelta paint (styled). + assert_eq!( + cell.style, + Style::default(), + "criterion 2: cell at cursor must have default style for byte-equivalent \ + no-flicker repaint. Got {:?}", + cell.style + ); + assert!( + matches!(cell.glyph, Glyph::Char('X')), + "criterion 2: cell glyph must be 'X'; got {:?}", + cell.glyph + ); +} + +/// M10.10 Day 4 — criterion 3 acceptance: two-frontend conflict +/// convergence. +/// +/// Spec criterion 3: "Concurrent edit from another frontend that +/// conflicts with the optimistic edit resolves correctly (CRDT +/// convergence handles it; local frontend's view repaints)." +/// +/// Three properties under test: +/// +/// (a) **CRDT convergence**: both frontends' mirrors agree on final +/// state after both ops have been delivered. +/// (b) **Local frontend's view repaints**: after convergence, the +/// daemon's `CellDelta` carries the converged content (verifiable +/// on the wire — both frontends receive a `CellDelta` after the +/// remote op is integrated daemon-side). +/// (c) **Optimistic `CrdtOp` preserved through convergence**: each +/// frontend's optimistic edit is in the final state; not lost +/// to the conflict resolution. Verified by inspecting the final +/// mirror content for both frontends' characters. +/// +/// The test explicitly sends `FrontendEvent::CrdtOp` (not +/// `FrontendEvent::Key`) so the test proves the optimistic-apply +/// pipeline works end-to-end, not just that some keystroke path +/// produced the right result. +#[cfg(feature = "crdt")] +#[allow(clippy::too_many_lines)] // narrative test with explicit assertions per property +#[test] +fn m10_10_criterion_3_two_frontend_conflict_converges() { + let daemon = TestDaemon::spawn(); + + // Two replica frontends. + let (hello_a, mut stream_a) = attach_multi(&daemon); + let (hello_b, mut stream_b) = attach_multi(&daemon); + + // Both receive BufferSnapshot for *scratch* (empty buffer). + let (buffer_id, snap_a) = match read_message::(&mut stream_a).expect("A snap") + { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + let snap_b = match read_message::(&mut stream_b).expect("B snap") { + InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => crdt_snapshot, + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + + // Bootstrap test-side replicas from the snapshots. These + // simulate the frontends' BufferMirror state. + let replica_a = pmacs::crdt::CrdtState::new(hello_a.assigned_frontend_id.0).expect("a state"); + replica_a.import_snapshot(&snap_a).expect("a import"); + let replica_b = pmacs::crdt::CrdtState::new(hello_b.assigned_frontend_id.0).expect("b state"); + replica_b.import_snapshot(&snap_b).expect("b import"); + + // CONCURRENT EDITS: A and B each apply an op to their own + // mirror BEFORE either sees the other's op. This is the + // canonical conflict scenario. + // + // A optimistically inserts 'A' at position 0 (its mirror state + // before any remote op has arrived). + let v_before_a = replica_a.version(); + replica_a.insert(0, "A").expect("a optimistic insert"); + let op_a = replica_a.export_updates_since(&v_before_a).expect("a op"); + assert_eq!(replica_a.materialize_string(), "A"); + + // B optimistically inserts 'B' at position 0 (its mirror state + // before any remote op has arrived — concurrent with A). + let v_before_b = replica_b.version(); + replica_b.insert(0, "B").expect("b optimistic insert"); + let op_b = replica_b.export_updates_since(&v_before_b).expect("b op"); + assert_eq!(replica_b.materialize_string(), "B"); + + // Send both ops to the daemon as FrontendEvent::CrdtOp (the + // exact wire shape the production optimistic-apply orchestrator + // emits — property (c) load-bearing assertion). + let event_a = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_a.clone(), + }, + }; + let event_b = FrontendEvent::CrdtOp { + frontend_id: hello_b.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_b.assigned_frontend_id.0, + bytes: op_b.clone(), + }, + }; + write_message(&mut stream_a, &event_a).expect("send op A"); + write_message(&mut stream_b, &event_b).expect("send op B"); + + // Both frontends should receive the OTHER frontend's op via + // daemon broadcast. Drain incoming streams until each has seen + // the other's CrdtOp. + stream_a + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + stream_b + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + let mut a_received_b_op = false; + let mut b_received_a_op = false; + while Instant::now() < deadline && !(a_received_b_op && b_received_a_op) { + if !a_received_b_op { + if let Ok(InstanceMessage::CrdtOp { op, .. }) = + read_message::(&mut stream_a) + { + if op.peer_id == hello_b.assigned_frontend_id.0 { + replica_a + .import_updates(&op.bytes) + .expect("a import B's op"); + a_received_b_op = true; + } + } + } + if !b_received_a_op { + if let Ok(InstanceMessage::CrdtOp { op, .. }) = + read_message::(&mut stream_b) + { + if op.peer_id == hello_a.assigned_frontend_id.0 { + replica_b + .import_updates(&op.bytes) + .expect("b import A's op"); + b_received_a_op = true; + } + } + } + } + assert!( + a_received_b_op, + "criterion 3: A must receive B's CrdtOp via daemon broadcast" + ); + assert!( + b_received_a_op, + "criterion 3: B must receive A's CrdtOp via daemon broadcast" + ); + + // Property (a): CRDT convergence — both mirrors agree on final + // state. CRDT order-determinism (loro's peer_id-based ordering) + // ensures the final content is the same on both replicas. + let a_final = replica_a.materialize_string(); + let b_final = replica_b.materialize_string(); + assert_eq!( + a_final, b_final, + "criterion 3 (a) CRDT convergence: A and B must reach identical final \ + state. A={a_final:?} B={b_final:?}" + ); + + // Property (c): both optimistic edits preserved through + // convergence — final state contains both 'A' and 'B'. + assert!( + a_final.contains('A') && a_final.contains('B'), + "criterion 3 (c) optimistic edits preserved: final state must contain \ + both A and B. Got {a_final:?}" + ); + assert_eq!( + a_final.len(), + 2, + "criterion 3 (c) edits preserved: final state should be exactly two \ + characters (A and B in some order). Got {a_final:?}" + ); + + // Property (b): the daemon's view of the buffer (which drives + // CellDelta to other frontends and to fresh attaches) also + // matches the converged state. Verify by attaching a third + // observer frontend C and inspecting its BufferSnapshot — + // it must contain both 'A' and 'B' in the same order. + let (_hello_c, mut stream_c) = attach_multi(&daemon); + let snap_c = match read_message::(&mut stream_c).expect("C snap") { + InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => crdt_snapshot, + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + let observer = pmacs::crdt::CrdtState::new(99).expect("observer"); + observer.import_snapshot(&snap_c).expect("observer import"); + let daemon_state = observer.materialize_string(); + assert_eq!( + daemon_state, a_final, + "criterion 3 (b) daemon view repaints to converged state: a fresh \ + observer's BufferSnapshot must match what A and B converged to. \ + daemon={daemon_state:?} converged={a_final:?}" + ); +} + +/// M10.10 Day 3 Finding 3 acceptance: own-CrdtOp-echo NOT sent back +/// to originator. Frontend A sends `CrdtOp`; daemon broadcasts to +/// other replicas (per-frontend sender-exclusion); A does NOT +/// receive its own op back. This is the daemon-side half of the +/// echo-dedup contract: the frontend-side filter is a defense-in- +/// depth but the daemon shouldn't send echoes in the first place. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_crdt_op_originator_does_not_receive_own_broadcast_echo() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a) = attach_multi(&daemon); + // Need a B attached so the daemon's broadcast loop has someone + // to broadcast to — without recipients the broadcast is a no-op + // and the test wouldn't distinguish "no echo because no + // broadcast" from "no echo because sender-exclusion." + let (_hello_b, _stream_b) = attach_multi(&daemon); + + // Drain A's BufferSnapshot, bootstrap, generate op. + let (buffer_id, snapshot_bytes) = + match read_message::(&mut stream_a).expect("A first frame") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + let a_replica = pmacs::crdt::CrdtState::new(hello_a.assigned_frontend_id.0).expect("a replica"); + a_replica.import_snapshot(&snapshot_bytes).expect("import"); + let v_before = a_replica.version(); + a_replica.insert(0, "Q").expect("a insert"); + let op_bytes = a_replica.export_updates_since(&v_before).expect("export"); + + let crdt_op_event = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes, + }, + }; + write_message(&mut stream_a, &crdt_op_event).expect("send CrdtOp from A"); + + // Read A's incoming stream for a short window; assert no + // CrdtOp arrives. Subsequent CellDelta / CursorByte / etc. + // messages are fine — we're just checking CrdtOp specifically. + stream_a + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let deadline = Instant::now() + Duration::from_millis(1500); + while Instant::now() < deadline { + if let Ok(InstanceMessage::CrdtOp { .. }) = read_message::(&mut stream_a) { + panic!( + "M10.10 Finding 3: originator received its own CrdtOp \ + back — daemon sender-exclusion is broken" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Post-audit round 2 — F11 / F12 / F13 inbound-CrdtOp validation. +// +// Three negative-path tests proving the daemon rejects malformed or +// out-of-scope inbound `FrontendEvent::CrdtOp` events: +// - F11: a session that didn't negotiate `crdt_replica` can't drive +// CRDT state by sending the variant. +// - F12: an op whose `op.peer_id` names another frontend is rejected +// before apply / broadcast. (Without this, the receiving +// frontend's attach loop derives the source via +// `FrontendId(op.peer_id)` and dedup-skips the broadcast, +// diverging its mirror from daemon state.) +// - F13: an op targeting a buffer the source isn't actively editing +// is rejected. (M10.10's local-edit path only emits ops for +// the active mirror buffer.) +// +// The reject path drops the op and logs to stderr; we observe rejection +// indirectly by sending a follow-up well-formed op from the same +// frontend and asserting only the well-formed op reaches the peer +// frontend's stream as an `InstanceMessage::CrdtOp` broadcast. (A +// stronger black-box assertion than checking stderr.) +// --------------------------------------------------------------------------- + +/// Helper for the F11/F12/F13 negative-path tests: bootstrap two +/// attached replica frontends, drain A's initial frame, return the +/// buffer id A is editing and an A-replica `CrdtState` that's +/// already imported A's snapshot. The caller generates op bytes from +/// the replica and sends crafted `FrontendEvent::CrdtOp` payloads. +#[cfg(feature = "crdt")] +fn bootstrap_two_replicas_for_negative_path( + daemon: &TestDaemon, +) -> ( + Hello, + UnixStream, + Hello, + UnixStream, + pmacs::buffer::BufferId, + pmacs::crdt::CrdtState, +) { + let (hello_a, mut stream_a) = attach_multi(daemon); + let (hello_b, mut stream_b) = attach_multi(daemon); + assert_ne!(hello_a.assigned_frontend_id, hello_b.assigned_frontend_id); + + let (buffer_id, snapshot_bytes) = + match read_message::(&mut stream_a).expect("A first frame") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot from A, got {other:?}"), + }; + let _b_first: InstanceMessage = read_message(&mut stream_b).expect("B first frame"); + + let a_replica = pmacs::crdt::CrdtState::new(hello_a.assigned_frontend_id.0).expect("a replica"); + a_replica.import_snapshot(&snapshot_bytes).expect("import"); + + (hello_a, stream_a, hello_b, stream_b, buffer_id, a_replica) +} + +/// Drain stream until a `InstanceMessage::CrdtOp` arrives or the +/// deadline elapses. Used in the negative-path tests to observe the +/// daemon's actual broadcast decisions. +#[cfg(feature = "crdt")] +fn wait_for_crdt_op_broadcast( + stream: &mut UnixStream, + deadline: Instant, +) -> Option<(pmacs::buffer::BufferId, pmacs::rope::CrdtOp)> { + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + while Instant::now() < deadline { + if let Ok(InstanceMessage::CrdtOp { buffer_id, op }) = + read_message::(stream) + { + return Some((buffer_id, op)); + } + } + None +} + +/// F11: a frontend that did NOT negotiate `crdt_replica` (legacy v0.1 +/// caps) cannot mutate daemon state by sending `FrontendEvent::CrdtOp`. +/// The daemon's pre-apply validation drops the op; the peer replica +/// frontend never receives a broadcast for it. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f11_non_replica_session_crdt_op_is_rejected() { + let daemon = TestDaemon::spawn(); + + // Legacy session (no crdt_replica) connects first. A is the + // attacker — its caps don't advertise the capability, but it tries + // to send a CrdtOp variant anyway. + let mut stream_a = daemon.connect(); + stream_a + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let hello_a = do_handshake(&mut stream_a); + let _initial_a: InstanceMessage = read_message(&mut stream_a).expect("A initial frame"); + + // B is a legitimate replica frontend that should NOT receive an + // echo of A's rejected op. + let (_hello_b, mut stream_b) = attach_multi(&daemon); + let (buffer_id, snapshot_bytes) = + match read_message::(&mut stream_b).expect("B first frame") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected BufferSnapshot, got {other:?}"), + }; + + // A bootstraps a CRDT replica out-of-band (it didn't actually + // negotiate, but it can still craft bytes since CRDT state is + // public) and generates an op. + let a_replica = pmacs::crdt::CrdtState::new(hello_a.assigned_frontend_id.0).expect("a replica"); + a_replica.import_snapshot(&snapshot_bytes).expect("import"); + let v_before = a_replica.version(); + a_replica.insert(0, "Z").expect("insert"); + let op_bytes = a_replica.export_updates_since(&v_before).expect("export"); + + let event = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes, + }, + }; + write_message(&mut stream_a, &event).expect("write"); + + // B's stream must not see the broadcast within a generous window. + let deadline = Instant::now() + Duration::from_secs(1); + assert!( + wait_for_crdt_op_broadcast(&mut stream_b, deadline).is_none(), + "F11: daemon must drop CrdtOp from a session that didn't negotiate crdt_replica" + ); +} + +/// F12: an op whose `op.peer_id` names a different frontend is +/// rejected before apply / broadcast. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f12_spoofed_op_peer_id_is_rejected() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a, hello_b, mut stream_b, buffer_id, a_replica) = + bootstrap_two_replicas_for_negative_path(&daemon); + + // Craft ONE set of op bytes (so the daemon can causally apply + // either copy). Send it twice with different identity framing: + // - spoofed event: `op.peer_id` set to B's id (should be + // rejected by F12 pre-apply). + // - well-formed event: `op.peer_id` set to A's id (should + // apply + broadcast normally). + // Loro is idempotent on remote-op import, so reusing the bytes is + // safe even if both were applied; here only the second is. + let v_before = a_replica.version(); + a_replica.insert(0, "X").expect("insert"); + let op_bytes = a_replica.export_updates_since(&v_before).expect("export"); + + let spoofed = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_b.assigned_frontend_id.0, // B's peer_id, not A's + bytes: op_bytes.clone(), + }, + }; + let well_formed = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes.clone(), + }, + }; + write_message(&mut stream_a, &spoofed).expect("write spoofed"); + write_message(&mut stream_a, &well_formed).expect("write well-formed"); + + // B should see exactly one CrdtOp broadcast — the well-formed one, + // tagged with A's peer_id. (The spoofed op was rejected pre-apply + // and would otherwise have arrived with op.peer_id == B.0.) + let deadline = Instant::now() + Duration::from_secs(2); + let first = wait_for_crdt_op_broadcast(&mut stream_b, deadline) + .expect("B must receive the well-formed op"); + assert_eq!(first.0, buffer_id); + assert_eq!( + first.1.peer_id, hello_a.assigned_frontend_id.0, + "F12: B's first CrdtOp broadcast must be the well-formed op (op.peer_id = A's id); \ + a B-tagged op slipping through means the spoofed op was not rejected" + ); + assert_eq!(first.1.bytes, op_bytes, "F12: broadcast bytes must match"); +} + +/// F13: an op targeting a `buffer_id` that the source is not +/// actively editing is rejected. We construct this by attaching the +/// session, switching its active window to a different buffer (or +/// using a buffer id that doesn't exist), and asserting the +/// well-formed op for the actually-active buffer still goes through +/// while the wrong-buffer op is dropped. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f13_wrong_buffer_id_is_rejected() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a, _hello_b, mut stream_b, buffer_id, a_replica) = + bootstrap_two_replicas_for_negative_path(&daemon); + + // A fabricates a non-existent buffer id. The daemon's active- + // window-for-A lookup returns the scratch buffer; the fabricated + // id doesn't match → reject. BufferId's constructor is crate- + // internal, so we round-trip via postcard (its wire form is the + // bare u64 newtype). + let fake_buffer_id: pmacs::buffer::BufferId = + postcard::from_bytes(&postcard::to_stdvec(&u64::MAX).unwrap()).expect("rtt"); + assert_ne!(fake_buffer_id, buffer_id); + + // One set of valid op bytes; send twice with different + // `buffer_id` framing. + let v_before = a_replica.version(); + a_replica.insert(0, "X").expect("insert"); + let op_bytes = a_replica.export_updates_since(&v_before).expect("export"); + + let wrong_buf = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id: fake_buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes.clone(), + }, + }; + let right_buf = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes.clone(), + }, + }; + write_message(&mut stream_a, &wrong_buf).expect("write wrong-buf"); + write_message(&mut stream_a, &right_buf).expect("write right-buf"); + + let deadline = Instant::now() + Duration::from_secs(2); + let first = wait_for_crdt_op_broadcast(&mut stream_b, deadline) + .expect("B must receive the right-buffer op"); + assert_eq!( + first.0, buffer_id, + "F13: B's first CrdtOp broadcast must target the active buffer; \ + a fake-buffer op slipping through means F13 didn't reject it" + ); + assert_eq!(first.1.bytes, op_bytes); +} + +/// F26: the daemon validates that the loro-internal peer +/// attribution inside `op.bytes` matches the authenticated source. +/// A hostile client can set `op.peer_id == authenticated source` +/// while the update bytes themselves were generated under a +/// DIFFERENT loro peer id; the wrapper-only check (F12) would +/// accept this. Recipients then route/dedup by wrapper identity, +/// but CRDT history attributes to the other peer, splitting +/// observable state from CRDT causal metadata. +/// +/// Test shape: A bootstraps a normal CRDT replica under peer-id = +/// `A.0`. A also builds a SECONDARY CRDT replica seeded from A's +/// bootstrap snapshot but with `peer_id = 999`. The secondary +/// replica produces an op; A wraps it with the secondary's loro +/// bytes but `op.peer_id = A.0` (so F12 passes). The daemon's F26 +/// fork-import detects the mismatch and rejects. B then submits a +/// well-formed op and only B's broadcast reaches the peer. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f26_spoofed_loro_internal_peer_id_is_rejected() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a, _hello_b, mut stream_b, buffer_id, _a_replica) = + bootstrap_two_replicas_for_negative_path(&daemon); + + // Secondary replica with a DIFFERENT loro peer id than A's + // assigned frontend id. Seeded from the same content so its + // exports are causally applicable to the daemon's buffer. + let secondary_peer_id: u64 = 999; + assert_ne!(secondary_peer_id, hello_a.assigned_frontend_id.0); + let secondary = pmacs::crdt::CrdtState::new(secondary_peer_id).expect("secondary"); + let snapshot_donor = pmacs::crdt::CrdtState::new(2).expect("snap-donor"); + let snapshot_bytes = snapshot_donor.export_snapshot().expect("export"); + secondary.import_snapshot(&snapshot_bytes).expect("import"); + let v_before = secondary.version(); + secondary.insert(0, "S").expect("secondary insert"); + let spoofed_bytes = secondary + .export_updates_since(&v_before) + .expect("export spoofed"); + + // Send the spoofed op: wrapper peer_id matches A (F12 passes) + // but the bytes carry ops attributed to peer 999. F26 fork- + // import detects this and rejects. + let spoofed = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, // wrapper = A (F12 passes) + bytes: spoofed_bytes.clone(), + }, + }; + write_message(&mut stream_a, &spoofed).expect("write spoofed"); + + // B must not receive a broadcast for the spoofed op within a + // generous window. (The peer's broadcast would carry op.peer_id + // = A but the bytes attributed to peer 999 — if accepted, the + // peer mirror would dedup-skip on echo and then re-apply via + // the broadcast, getting wrong attribution either way.) + stream_b + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + let mut saw_spoofed_broadcast = false; + while Instant::now() < deadline { + if let Ok(InstanceMessage::CrdtOp { op, .. }) = + read_message::(&mut stream_b) + { + if op.bytes == spoofed_bytes { + saw_spoofed_broadcast = true; + break; + } + } + } + assert!( + !saw_spoofed_broadcast, + "F26: daemon must reject ops whose loro-internal peer attribution \ + disagrees with the authenticated source" + ); +} + +/// F16: a daemon-side mutation (a `FrontendEvent::Key` round-trip) +/// that generates a CRDT op broadcasts to **all** replica frontends +/// including the source. Pre-fix, the source's mirror would silently +/// drift from daemon state after every fallback / Key-path edit +/// because `pending_crdt_ops` excluded the active frontend. +/// +/// Test shape: A (replica) attaches; sends a plain `FrontendEvent::Key` +/// (a printable char that the daemon's command pipeline maps to +/// `pmacs.editor.insert-text`). The daemon mutates the active buffer, +/// generates a CRDT op tagged `CrdtOpOrigin::DaemonKey`, broadcasts. +/// A asserts it received its own `InstanceMessage::CrdtOp` broadcast. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f16_daemon_key_origin_broadcasts_to_source() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a) = attach_multi(&daemon); + + // Drain A's bootstrap frames (BufferSnapshot + CursorByte). + // We're going to wait for a CrdtOp specifically, so we want + // any other messages drained first. + let _first_a: InstanceMessage = + read_message(&mut stream_a).expect("A first frame (BufferSnapshot)"); + + // Send a `FrontendEvent::Key` for a plain printable char. The + // daemon's normal key-path will produce a CRDT op tagged + // `DaemonKey`; F16 ensures it broadcasts to A as well. + let key_event = FrontendEvent::Key(KeyEvent { + frontend_id: hello_a.assigned_frontend_id, + key: Key::Char('K'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }); + write_message(&mut stream_a, &key_event).expect("write Key"); + + // A must receive its OWN edit's CRDT broadcast (F16). Without + // the fix, the broadcast would exclude A and the assertion would + // time out. + stream_a + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let mut saw_self_broadcast = false; + while Instant::now() < deadline { + if let Ok(InstanceMessage::CrdtOp { .. }) = read_message::(&mut stream_a) { + saw_self_broadcast = true; + break; + } + } + assert!( + saw_self_broadcast, + "F16: daemon-key-origin CRDT op must broadcast to the active frontend; \ + pre-fix the source frontend's mirror diverged from daemon state on every \ + Key round-trip" + ); +} + +/// F17: a CRDT op whose import produces no text delta (e.g. a +/// concurrent same-character delete that already converged) is +/// **still broadcast** to peers. Pre-fix, `handle_remote_crdt_op` +/// returned early on `Ok(None)` and dropped the op — peers never +/// imported the CRDT causal metadata, and later updates that +/// depend on it could fail to apply. +/// +/// Test shape: A and B both attached as replicas. A bootstraps a +/// CRDT replica and generates an op. A sends the op twice. The +/// first application produces an Edit and broadcasts; the second +/// `apply_remote_crdt_op` is idempotent → `Ok(None)`. F17 ensures +/// the second broadcast still happens, so B sees **two** `CrdtOp` +/// broadcasts even though the daemon's text content changed only +/// once. +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f17_no_text_delta_import_still_broadcasts() { + let daemon = TestDaemon::spawn(); + let (hello_a, mut stream_a, _hello_b, mut stream_b, buffer_id, a_replica) = + bootstrap_two_replicas_for_negative_path(&daemon); + + let v_before = a_replica.version(); + a_replica.insert(0, "Z").expect("insert"); + let op_bytes = a_replica.export_updates_since(&v_before).expect("export"); + + let event = FrontendEvent::CrdtOp { + frontend_id: hello_a.assigned_frontend_id, + buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: hello_a.assigned_frontend_id.0, + bytes: op_bytes.clone(), + }, + }; + // First send: applies (Some(edit)) and broadcasts. + write_message(&mut stream_a, &event).expect("write 1"); + // Second send: import is idempotent → Ok(None). Pre-fix the op + // would have been dropped here; F17 still pushes to the broadcast + // queue. + write_message(&mut stream_a, &event).expect("write 2"); + + // B must see TWO CrdtOp broadcasts, both carrying the same + // bytes. Without F17, B would see only one. + stream_b + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let mut crdt_op_count = 0; + while Instant::now() < deadline && crdt_op_count < 2 { + if let Ok(InstanceMessage::CrdtOp { op, .. }) = + read_message::(&mut stream_b) + { + assert_eq!(op.bytes, op_bytes); + crdt_op_count += 1; + } + } + assert_eq!( + crdt_op_count, 2, + "F17: peer must receive both broadcasts even though the second \ + apply_remote_crdt_op produced no text delta; pre-fix only one arrives" + ); +} + +/// F14: production-path end-to-end keystroke flow. Negotiates with +/// the **production** `attach::build_capabilities()` (not the test +/// `multi_frontend_caps()`), bootstraps a real `BufferMirror` from +/// the daemon's `BufferSnapshot` + `CursorByte`, then drives the +/// production `optimistic::frontend_event_for_keystroke` orchestrator +/// with a synthetic `KeyEvent`. The produced `FrontendEvent::CrdtOp` +/// is sent to the daemon and a second replica frontend (B) must +/// receive the broadcast. +/// +/// Why this matters: the other M10.10 acceptance tests inject +/// `FrontendEvent::CrdtOp` directly with hand-built fields. That +/// bypasses the production decision chain (`classify_key`, +/// `frontend_event_for_keystroke`, eligibility predicates), so a +/// regression in *any* of those layers would not be caught by the +/// existing matrix — exactly the gap that allowed the original +/// post-audit Finding 1 to ship a structurally-unreachable +/// optimistic-apply path. +/// +/// We can't drive crossterm's raw-mode terminal from a test, but we +/// can drive everything from `KeyEvent` downward in the same code +/// path the production attach loop uses (see src/attach.rs:784). +#[cfg(feature = "crdt")] +#[test] +fn m10_10_f14_production_path_keystroke_flows_to_broadcast() { + use pmacs::buffer_mirror::BufferMirror; + + let daemon = TestDaemon::spawn(); + + // A attaches with PRODUCTION caps — exercises Finding 3 fix + // (build_capabilities advertising crdt_replica in CRDT builds). + let mut stream_a = daemon.connect(); + stream_a + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let hello_a: Hello = read_message(&mut stream_a).expect("A Hello"); + let req_a = AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: pmacs::attach::build_capabilities(), + initial_size: CellSize::new(24, 80), + }; + write_message(&mut stream_a, &req_a).expect("A AttachRequest"); + + // B attaches with multi-frontend test caps so it's a replica too + // — it will receive A's broadcast. + let (_hello_b, mut stream_b) = attach_multi(&daemon); + + // A's bootstrap: BufferSnapshot first, then drain frames until + // CursorByte arrives (the byte-position pairing M10.10 added). + let (buffer_id, snapshot_bytes) = match read_message::(&mut stream_a) + .expect("A first frame") + { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!( + "F14: production caps must negotiate crdt_replica → first frame is BufferSnapshot; got {other:?}" + ), + }; + + let mut a_mirror = BufferMirror::new(hello_a.assigned_frontend_id); + a_mirror + .init_from_snapshot(buffer_id, &snapshot_bytes) + .expect("init_from_snapshot"); + a_mirror.set_cursor_byte_pos(buffer_id, 0); + + // Drain until CursorByte arrives (or timeout) so the mirror's + // cursor is grounded in the daemon's actual cursor position. The + // attach loop normally does this; we replicate it here. + let drain_deadline = Instant::now() + Duration::from_millis(500); + stream_a + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + while Instant::now() < drain_deadline { + match read_message::(&mut stream_a) { + Ok(InstanceMessage::CursorByte { + buffer_id: bid, + byte_pos, + }) if bid == buffer_id => { + a_mirror.set_cursor_byte_pos(buffer_id, byte_pos as usize); + break; + } + Ok(_) | Err(_) => {} + } + } + + // Drive the PRODUCTION orchestrator. This is the exact call site + // at src/attach.rs:784 — same function signature, same inputs. + let pmacs_key = KeyEvent { + frontend_id: hello_a.assigned_frontend_id, + key: Key::Char('Q'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }; + let frontend_event = pmacs::optimistic::frontend_event_for_keystroke( + &mut a_mirror, + hello_a.assigned_frontend_id, + pmacs_key, + ); + + // The orchestrator must produce a CrdtOp (mirror is ready, action + // is Insert, cursor is at a valid position). If it returned + // `Key` as fallback, the production path's optimistic apply is + // broken end-to-end. + let (event_buffer_id, op_peer_id, op_bytes) = match &frontend_event { + FrontendEvent::CrdtOp { buffer_id, op, .. } => (*buffer_id, op.peer_id, op.bytes.clone()), + other => panic!( + "F14: production orchestrator returned {other:?} instead of CrdtOp \ + for a plain Char('Q') insert. This means the mirror wasn't ready or \ + the eligibility predicates rejected what should be a viable optimistic \ + insert. (See src/optimistic.rs:128 — `frontend_event_for_keystroke`.)" + ), + }; + assert_eq!(event_buffer_id, buffer_id); + assert_eq!(op_peer_id, hello_a.assigned_frontend_id.0); + + // Send the produced event upstream; B must see the broadcast. + write_message(&mut stream_a, &frontend_event).expect("write CrdtOp"); + let deadline = Instant::now() + Duration::from_secs(2); + let broadcast = wait_for_crdt_op_broadcast(&mut stream_b, deadline) + .expect("F14: B must receive A's CrdtOp broadcast end-to-end"); + assert_eq!(broadcast.0, buffer_id); + assert_eq!(broadcast.1.peer_id, hello_a.assigned_frontend_id.0); + assert_eq!( + broadcast.1.bytes, op_bytes, + "F14: broadcast bytes must match what the production orchestrator produced" + ); +} + +/// M10.10 Day 3 Finding 2 acceptance: a non-replica frontend does NOT +/// receive `CursorByte` — capability gating skips the emission for +/// frontends that don't negotiate `crdt_replica`. (Without this gate, +/// the non-replica frontend's postcard decoder would hard-error on +/// the unknown variant per Refinement 3.) +#[test] +fn m10_10_non_replica_frontend_does_not_receive_cursor_byte() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let _hello = do_handshake(&mut stream); + + // Read a handful of incoming frames; assert none is CursorByte. + // 16 frames is enough to cover at least a couple of per-tick + // render cycles. + for _ in 0..16 { + match read_message::(&mut stream) { + Ok(InstanceMessage::CursorByte { .. }) => panic!( + "non-replica frontend received CursorByte — capability \ + gating must skip the emission" + ), + Ok(_) => {} + Err(_) => break, + } + } +} + +/// M10.10 Day 2 acceptance: a frontend that did NOT negotiate +/// `crdt_replica` (the v0.1-style frontend path) receives the initial +/// `CellDelta` directly without any `BufferSnapshot` first. The +/// daemon's M10.10 send is capability-gated; non-replica frontends +/// don't even see the wire variant (and can't decode it — postcard +/// hard-errors on unknown variants per Refinement 3). +#[test] +fn m10_10_non_replica_frontend_does_not_receive_buffer_snapshot() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + // build_default_caps() has crdt_replica: false — the daemon + // negotiates crdt_replica: false and skips the send. + let _hello = do_handshake(&mut stream); + + let first: InstanceMessage = read_message(&mut stream).expect("first frame"); + match first { + InstanceMessage::CellDelta { + full_grid: true, .. + } => {} + InstanceMessage::BufferSnapshot { .. } => { + panic!( + "M10.10: non-replica frontend received BufferSnapshot — \ + capability gating must skip the send" + ); + } + other => panic!( + "M10.10: first frame for non-replica frontend should be full-grid \ + CellDelta, got {other:?}" + ), + } +} diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index e0936c7..506dbe9 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -150,6 +150,8 @@ fn build_default_caps() -> FrontendCapabilities { mouse: true, bracketed_paste: true, terminal_kind: Some("perf-gate".into()), + multi_frontend: false, + crdt_replica: false, } }