mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
Live sessions are per document instead of one for the whole app.
- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
installed link, the invite-link registration) plus one `Live` per session. One place resolves
"the session of this document": `Session::live()` (the active document's; status off when it
has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
spotlight and presence all go through the session of the document they are about, so edits and
presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
`(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
link keep them in a shared table with one mailbox per session
(`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
`collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
touching other sessions; until then (or if it fails) it shows on the tab it was started from.
Starting a session for a document replaces only that document's own session. Closing a document
ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
banner, follow frame and read-only reason read the session of the tab in front, per window. What
belongs to one session in the window (cursors shown, your chat box, a report being written) is
dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
`sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
`docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
366 lines
15 KiB
Rust
366 lines
15 KiB
Rust
//! Documents in a live session through the engine's live state, with a scripted link and the core
|
|
//! server in the test: commands, gestures and groups sync as one step each, `edit.undo` /
|
|
//! `edit.redo` take back only the user's own steps, a refused edit comes back with a toast, a
|
|
//! dropped connection resumes and sends what was made offline, and outside a session undo is
|
|
//! unchanged.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use figcraft_collab::sim::Rng;
|
|
use figcraft_collab::{Client, ClientMsg, Limits, Outgoing, Server, ServerMsg};
|
|
use figcraft_doc::{Document, NodeId};
|
|
use figcraft_geom::Affine;
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
use crate::collab_tests::{base, random_command};
|
|
use crate::live::{Event, Role, Status};
|
|
use crate::live_tests::{Mock, Script, info};
|
|
|
|
const LINK: &str = "figcraft://join?host=127.0.0.1:47900&file=f&token=t";
|
|
|
|
/// One person: an engine session with a scripted link, what it sent that the server hasn't had
|
|
/// yet, and what the server sent that it hasn't taken in yet.
|
|
struct Person {
|
|
s: Session,
|
|
script: Arc<Mutex<Script>>,
|
|
id: u32,
|
|
up: VecDeque<ClientMsg>,
|
|
down: VecDeque<Event>,
|
|
online: bool,
|
|
}
|
|
|
|
/// A session server (the host's, minus the sockets) and several people joined as guests.
|
|
pub(crate) struct Room {
|
|
pub(crate) server: Server,
|
|
people: Vec<Person>,
|
|
rng: Rng,
|
|
}
|
|
|
|
fn welcome_of(out: &[Outgoing], id: u32) -> ServerMsg {
|
|
out.iter().find_map(|o| if let Outgoing::To(to, w @ ServerMsg::Welcome { .. }) = o { (*to == id).then(|| w.clone()) } else { None }).unwrap()
|
|
}
|
|
|
|
impl Room {
|
|
pub(crate) fn new(doc: Document, n: usize, seed: u64) -> Room {
|
|
let mut room = Room { server: Server::new(doc, "t", Limits::default()), people: Vec::new(), rng: Rng::new(seed) };
|
|
for _ in 0..n {
|
|
let mut s = Session::new();
|
|
let script = Arc::new(Mutex::new(Script::default()));
|
|
s.live.install(Box::new(Mock(script.clone())));
|
|
s.execute("collab.join", &json!({"link": LINK})).unwrap();
|
|
let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
|
let (id, out) = room.server.join(h).unwrap();
|
|
let w = welcome_of(&out, id);
|
|
script.lock().unwrap().events.extend([Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, Event::Sync(w)]);
|
|
s.live_poll();
|
|
assert!(s.live().syncing() && s.live_doc(s.ds().unwrap().uid), "joined: the shared file is the active tab");
|
|
room.people.push(Person { s, script, id, up: VecDeque::new(), down: VecDeque::new(), online: true });
|
|
room.route(out.into_iter().filter(|o| !matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))).collect());
|
|
}
|
|
room
|
|
}
|
|
|
|
pub(crate) fn s(&mut self, i: usize) -> &mut Session {
|
|
&mut self.people[i].s
|
|
}
|
|
|
|
fn route(&mut self, out: Vec<Outgoing>) {
|
|
for o in out {
|
|
let (msg, to): (ServerMsg, Vec<usize>) = match o {
|
|
Outgoing::To(id, m) => (m, (0..self.people.len()).filter(|i| self.people[*i].id == id).collect()),
|
|
Outgoing::All(m) => (m, (0..self.people.len()).collect()),
|
|
Outgoing::Others(id, m) => (m, (0..self.people.len()).filter(|i| self.people[*i].id != id).collect()),
|
|
};
|
|
if !matches!(msg, ServerMsg::Changes(_) | ServerMsg::Reject { .. } | ServerMsg::Welcome { .. }) {
|
|
continue;
|
|
}
|
|
let to: Vec<usize> = to.into_iter().filter(|i| self.people[*i].online).collect();
|
|
for i in to {
|
|
// Through the binary encoding, like the network.
|
|
let m = figcraft_collab::message::decode(&figcraft_collab::message::encode(&msg).unwrap(), usize::MAX).unwrap();
|
|
self.people[i].down.push_back(Event::Sync(m));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What everyone sent (the UI calls `live_sync` every frame).
|
|
fn pump(&mut self) {
|
|
for p in &mut self.people {
|
|
p.s.live_sync();
|
|
let msgs = std::mem::take(&mut p.script.lock().unwrap().msgs);
|
|
if p.online {
|
|
p.up.extend(msgs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deliver one message on a random busy link. False when nothing is in flight.
|
|
fn step(&mut self) -> bool {
|
|
self.pump();
|
|
let busy: Vec<(bool, usize)> = (0..self.people.len())
|
|
.flat_map(|i| [(true, i), (false, i)])
|
|
.filter(|(up, i)| if *up { !self.people[*i].up.is_empty() } else { !self.people[*i].down.is_empty() })
|
|
.collect();
|
|
let Some(&(up, i)) = self.rng.pick(&busy) else { return false };
|
|
if up {
|
|
let msg = self.people[i].up.pop_front().unwrap();
|
|
let out = self.server.receive(self.people[i].id, msg);
|
|
self.route(out);
|
|
} else {
|
|
let p = &mut self.people[i];
|
|
let e = p.down.pop_front().unwrap();
|
|
p.script.lock().unwrap().events.push(e);
|
|
p.s.live_poll();
|
|
}
|
|
self.pump();
|
|
true
|
|
}
|
|
|
|
pub(crate) fn settle(&mut self) {
|
|
let mut n = 0;
|
|
while self.step() {
|
|
n += 1;
|
|
assert!(n < 1_000_000, "still busy");
|
|
}
|
|
}
|
|
|
|
/// Person `i`'s connection drops: what is in flight is lost; the link says it's reconnecting.
|
|
fn disconnect(&mut self, i: usize) {
|
|
let p = &mut self.people[i];
|
|
p.up.clear();
|
|
p.down.clear();
|
|
p.online = false;
|
|
p.script.lock().unwrap().events.push(Event::Status(Status::Reconnecting { attempt: 1 }));
|
|
p.s.live_poll();
|
|
let out = self.server.leave(self.people[i].id);
|
|
self.route(out);
|
|
}
|
|
|
|
/// Person `i` is back: the link says hello with the client's id and last sequence number.
|
|
fn reconnect(&mut self, i: usize) {
|
|
let p = &mut self.people[i];
|
|
let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
|
h.resume = p.s.live().client().map(Client::resume);
|
|
let (id, out) = self.server.join(h).unwrap();
|
|
assert_eq!(id, p.id, "the same client");
|
|
p.online = true;
|
|
p.down.push_back(Event::Welcome { info: info(Role::Guest, id), peers: vec![] });
|
|
self.route(out);
|
|
}
|
|
|
|
/// Everyone holds the server's document, in the session and on the canvas.
|
|
pub(crate) fn check(&self) {
|
|
for (i, p) in self.people.iter().enumerate() {
|
|
let c = p.s.live().client().unwrap();
|
|
assert_eq!(c.pending(), 0, "person {i}");
|
|
assert!(c.view() == self.server.replica(), "person {i}: {}", figcraft_collab::sim::describe(self.server.replica(), c.view()));
|
|
assert!(*p.s.doc().unwrap() == *self.server.document(), "person {i}: the canvas shows the session's document");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The shared file's first frame and one of its rectangles.
|
|
pub(crate) fn frame_and_rect(doc: &Document) -> (NodeId, NodeId) {
|
|
let page = doc.first_page().unwrap();
|
|
let f = doc.node(page).unwrap().children[0];
|
|
(f, doc.node(f).unwrap().children[0])
|
|
}
|
|
|
|
#[test]
|
|
fn edits_sync_and_undo_takes_back_only_your_own() {
|
|
let mut room = Room::new(base(), 2, 1);
|
|
let (f, r) = frame_and_rect(room.server.document());
|
|
let old = room.server.document().node(r).unwrap().name.clone();
|
|
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap();
|
|
room.settle();
|
|
room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 25, "dy": 0})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
let moved = room.server.document().node(r).unwrap().transform;
|
|
// Person 0 undoes: their rename goes, person 1's later move stays.
|
|
assert_eq!(room.s(0).execute("edit.undo", &json!({})).unwrap()["undone"], true);
|
|
room.settle();
|
|
room.check();
|
|
let n = room.server.document().node(r).unwrap();
|
|
assert_eq!((&n.name, n.transform), (&old, moved));
|
|
assert_eq!(room.s(0).status()["redo"], 1);
|
|
assert_eq!(room.s(0).execute("edit.undo", &json!({})).unwrap()["undone"], false, "nothing else of theirs");
|
|
assert_eq!(room.s(1).status()["undo"], 1, "remote edits never enter someone else's history");
|
|
room.s(0).execute("edit.redo", &json!({})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
assert_eq!(room.server.document().node(r).unwrap().name, "Mine");
|
|
// Deleting a frame with layers and undoing it brings it back whole.
|
|
let before = room.server.replica().clone();
|
|
room.s(1).execute("edit.delete", &json!({"ids": [f]})).unwrap();
|
|
room.settle();
|
|
assert!(!room.server.document().contains(f));
|
|
room.s(1).execute("edit.undo", &json!({})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
assert!(room.server.replica() == &before);
|
|
// In a live session the snapshot comparison of `collab.diff` doesn't apply.
|
|
assert!(room.s(0).execute("collab.diff", &json!({})).is_err());
|
|
assert!(room.s(0).execute("collab.diff", &json!({"against": "saved"})).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_gesture_is_one_step() {
|
|
let mut room = Room::new(base(), 2, 2);
|
|
let (_, r) = frame_and_rect(room.server.document());
|
|
let start = room.server.document().node(r).unwrap().transform;
|
|
let s = room.s(0);
|
|
s.begin_interaction("Move").unwrap();
|
|
for dx in [5.0, 10.0, 15.0] {
|
|
s.preview(|doc, _| {
|
|
if let Some(n) = doc.node_mut(r) {
|
|
n.transform = Affine::translate((dx, 0.0)) * start;
|
|
}
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
}
|
|
// Nothing goes out during the drag; someone else renames the layer meanwhile.
|
|
room.pump();
|
|
assert!(room.people[0].up.is_empty());
|
|
room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap();
|
|
room.settle();
|
|
room.s(0).commit_interaction().unwrap();
|
|
room.settle();
|
|
room.check();
|
|
let n = room.server.document().node(r).unwrap();
|
|
assert_eq!((n.name.as_str(), n.transform), ("Theirs", Affine::translate((15.0, 0.0)) * start));
|
|
assert_eq!(room.s(0).status()["undo"], 1, "one step for the whole drag");
|
|
room.s(0).execute("edit.undo", &json!({})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
let n = room.server.document().node(r).unwrap();
|
|
assert_eq!((n.name.as_str(), n.transform), ("Theirs", start));
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_of_commands_syncs_as_one_step() {
|
|
let mut room = Room::new(base(), 2, 3);
|
|
let (f, r) = frame_and_rect(room.server.document());
|
|
let start = room.server.replica().clone();
|
|
let cmds = vec![
|
|
("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
|
|
("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
|
|
("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
|
|
];
|
|
room.s(0).batch("Three", &cmds).unwrap();
|
|
room.pump();
|
|
assert_eq!(room.people[0].up.len(), 1, "one batch");
|
|
room.settle();
|
|
room.check();
|
|
assert_eq!(room.s(0).status()["undo"], 1);
|
|
room.s(0).execute("edit.undo", &json!({})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
assert!(room.server.replica() == &start, "all three are taken back");
|
|
}
|
|
|
|
#[test]
|
|
fn an_undo_waits_for_its_edit_and_shows_at_once() {
|
|
let mut room = Room::new(base(), 2, 6);
|
|
let (_, r) = frame_and_rect(room.server.document());
|
|
let old = room.server.document().node(r).unwrap().name.clone();
|
|
let start = room.server.replica().clone();
|
|
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Quick"}})).unwrap();
|
|
room.s(0).execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(room.s(0).doc().unwrap().node(r).unwrap().name, old, "shown at once");
|
|
room.pump();
|
|
assert_eq!(room.people[0].up.len(), 1, "only the edit goes out until it is confirmed");
|
|
room.settle();
|
|
room.check();
|
|
assert!(room.server.replica() == &start);
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_edit_is_rolled_back_with_a_toast() {
|
|
let mut room = Room::new(base(), 2, 4);
|
|
let (f, r) = frame_and_rect(room.server.document());
|
|
// Person 1 deletes the frame; person 0 renames a layer inside it before hearing of it.
|
|
room.s(1).execute("edit.delete", &json!({"ids": [f]})).unwrap();
|
|
room.pump();
|
|
let msg = room.people[1].up.pop_front().unwrap();
|
|
let out = room.server.receive(room.people[1].id, msg);
|
|
room.route(out);
|
|
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Too late"}})).unwrap();
|
|
room.settle();
|
|
room.check();
|
|
let s = room.s(0);
|
|
assert!(s.message.as_deref().unwrap_or_default().contains("deleted by someone else"), "{:?}", s.message);
|
|
assert_eq!(s.status()["undo"], 0, "no undo step for refused work");
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_connection_keeps_editing_and_catches_up() {
|
|
let mut room = Room::new(base(), 2, 7);
|
|
let (f, r) = frame_and_rect(room.server.document());
|
|
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Sent"}})).unwrap();
|
|
room.pump();
|
|
room.disconnect(0);
|
|
// Offline: edits and undo keep working; nothing goes out.
|
|
room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
|
room.s(0).execute("edit.undo", &json!({})).unwrap();
|
|
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline"}})).unwrap();
|
|
room.pump();
|
|
assert!(room.people[0].up.is_empty());
|
|
room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap();
|
|
room.settle();
|
|
room.reconnect(0);
|
|
room.settle();
|
|
room.check();
|
|
let doc = room.server.document();
|
|
assert_eq!((doc.node(r).unwrap().name.as_str(), doc.node(f).unwrap().name.as_str()), ("Sent", "Offline"));
|
|
}
|
|
|
|
#[test]
|
|
fn random_commands_with_undo_and_drops_converge_through_the_engine() {
|
|
for seed in 0..6 {
|
|
let mut room = Room::new(base(), 3, seed);
|
|
let mut rng = Rng::new(seed + 100);
|
|
for _ in 0..80 {
|
|
let i = rng.below(3);
|
|
if rng.chance(0.06) {
|
|
if room.people[i].online {
|
|
room.disconnect(i);
|
|
} else {
|
|
room.reconnect(i);
|
|
}
|
|
continue;
|
|
}
|
|
let (cmd, params) = random_command(room.people[i].s.doc().unwrap(), &mut rng, true);
|
|
let _ = room.s(i).execute(cmd, ¶ms);
|
|
for _ in 0..rng.below(4) {
|
|
room.step();
|
|
}
|
|
}
|
|
for i in 0..3 {
|
|
if !room.people[i].online {
|
|
room.reconnect(i);
|
|
}
|
|
}
|
|
room.settle();
|
|
room.check();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn leaving_the_session_goes_back_to_snapshot_undo() {
|
|
let mut room = Room::new(base(), 1, 5);
|
|
let (_, r) = frame_and_rect(room.server.document());
|
|
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Live"}})).unwrap();
|
|
room.settle();
|
|
let s = room.s(0);
|
|
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
|
assert_eq!(s.status()["undo"], 0, "the live history doesn't carry over");
|
|
s.execute("node.set", &json!({"id": r, "props": {"name": "Solo"}})).unwrap();
|
|
assert_eq!(s.status()["undo"], 1);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(s.doc().unwrap().node(r).unwrap().name, "Live");
|
|
}
|