From 04455c489968f6e47cf791649db736558c8757a7 Mon Sep 17 00:00:00 2001 From: fluid-chey Date: Wed, 4 Nov 2026 12:23:09 -0700 Subject: [PATCH] M9: joining a live session again merges what you did while disconnected Joining again after a session couldn't go on opened your offline changes in a copy beside the file. They now go on top as one undoable edit, through the three-way merge branches use. The rejoin asks to resume as the same client, so layers made offline are yours to send. What can't go in (the same thing changed both ways, or layers a new client can't make) leaves the server's version, and your whole version opens beside it so nothing is lost. A server that no longer knows you merges the same way instead of discarding unconfirmed work. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/collab_live_tests.rs | 104 ++++++++++++++++++----- crates/engine/src/live_tests.rs | 7 ++ crates/engine/src/sharing/live.rs | 24 +++--- crates/engine/src/sharing/live/events.rs | 59 +++++++++---- crates/engine/src/sharing/live/join.rs | 2 +- crates/engine/src/sharing/live/sync.rs | 22 ++++- docs/collab-protocol.md | 11 ++- 7 files changed, 175 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 6499c1895..f221420dc 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -319,9 +319,10 @@ fn a_dropped_connection_keeps_editing_and_catches_up() { } impl Room { - /// Person `i` has been told to join again: the server takes them as someone new. - fn welcome_anew(&mut self, i: usize) { - let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + /// Person `i` has been told to join again: the server takes them as `resume` says. + fn welcome_as(&mut self, i: usize, resume: Option) { + let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + h.resume = resume; let (id, out) = self.server.join(h).unwrap(); let w = welcome_of(&out, id); let p = &mut self.people[i]; @@ -351,7 +352,7 @@ fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() { } #[test] -fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { +fn a_session_that_cant_go_on_stays_on_its_tab_and_merges_when_it_joins_again() { let mut room = Room::new(base(), 2, 7); let (f, r) = frame_and_rect(room.server.document()); let uid = room.s(0).ds().unwrap().uid; @@ -363,7 +364,8 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { let live = room.s(0).live(); assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid)); assert!(room.s(0).live_read_only(uid).is_none()); - room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap(); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); // A try that fails leaves it just as it was. assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting"); assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable()); @@ -371,23 +373,85 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() })); assert!(room.s(0).live().rejoinable()); // Someone else's edit lands meanwhile. - room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap(); + room.s(1).execute("transform.move", &json!({"ids": [f], "dx": 5, "dy": 5})).unwrap(); + let theirs = |d: &Document| d.node(f).unwrap().transform; room.settle(); - // The next try joins in the same tab, and what was done offline opens beside it. + let moved = theirs(room.server.document()); + // The next try joins in the same tab, as the same client, and everything goes on top. room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed.expect("as who you were"); + assert_eq!((resumed.client_id, resumed.seq), (room.people[0].id, u64::MAX)); + room.welcome_as(0, Some(resumed)); + room.settle(); + room.check(); let s = room.s(0); assert_eq!(s.live().status, Status::Live); assert!(!s.live().disconnected()); - assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab"); - let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap()); - assert_eq!(s.live().doc_uid, Some(uid)); - assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version"); - assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name)); - assert_eq!(copy.doc.node(r).unwrap().name, "Offline"); - assert_eq!(copy.doc.node(f).unwrap().name, "Offline too"); - assert!(copy.is_dirty(), "closing the copy asks first"); - assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy"))); + assert_eq!(s.docs.len(), tabs, "no copy: it all went in"); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Offline"); + assert!(doc.contains(made), "layers you made too"); + assert_eq!(theirs(doc), moved, "and the other edit is still there"); + assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("is in the file"))); +} + +#[test] +fn the_same_thing_changed_both_ways_keeps_the_others_and_opens_your_version_beside_it() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap(); + room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Only mine"}})).unwrap(); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap(); + room.settle(); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Theirs", "what conflicts isn't written over"); + assert_eq!(doc.node(f).unwrap().name, "Only mine", "what doesn't goes in"); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + let copy = s.docs.last().unwrap(); + assert!(copy.doc.name.ends_with("(offline copy)") && copy.is_dirty(), "closing the copy asks first"); + assert_eq!(copy.doc.node(r).unwrap().name, "Mine"); +} + +#[test] +fn layers_the_new_client_cant_make_open_beside_the_file() { + let mut room = Room::new(base(), 1, 7); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + // The server takes you as someone new. + room.welcome_as(0, None); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + assert!(s.docs.last().unwrap().doc.contains(made), "it isn't lost"); +} + +#[test] +fn a_server_that_forgot_you_keeps_your_offline_edits() { + let mut room = Room::new(base(), 1, 7); + let (_, r) = frame_and_rect(room.server.document()); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap(); + // The same file under a new session: it knows no one. + room.server = Server::new(room.server.document().clone(), "t", Limits::default()); + room.welcome_as(0, None); + room.settle(); + assert_eq!(room.server.document().node(r).unwrap().name, "Offline"); + assert_eq!(room.s(0).live().status, Status::Live); } #[test] @@ -399,7 +463,8 @@ fn joining_again_with_nothing_changed_opens_no_copy() { assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too"); assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped")); room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).docs.len(), tabs); assert_eq!(room.s(0).live().status, Status::Live); } @@ -414,7 +479,8 @@ fn a_session_out_of_step_joins_again_by_itself() { assert_eq!(room.s(0).live().status, Status::Connecting); assert!(room.s(0).live().disconnected()); assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).live().status, Status::Live); assert_eq!(room.s(0).docs.len(), tabs); } diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 0b2b7c993..1e2f22f4f 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -18,6 +18,8 @@ pub(crate) struct Script { pub(crate) sent: Vec, pub(crate) hosted: Vec, pub(crate) joined: Vec, + /// What the last join asked to resume as. + pub(crate) resumed: Option, /// The sessions told to reconnect now, in order. pub(crate) retried: Vec, pub(crate) left: usize, @@ -48,6 +50,11 @@ impl LiveLink for Mock { i.url = format!("ws://{}/?file=f&token=t", i.address); Ok(i) } + fn join_resuming(&mut self, session: u64, link: &str, name: &str, resume: Option) -> Result<(), String> { + self.0.lock().unwrap().resumed = resume; + self.join(session, link, name) + } + fn retry(&mut self, session: u64) { self.0.lock().unwrap().retried.push(session); } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index b54331520..ef0a7f0ba 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -152,6 +152,8 @@ struct Stranded { link: String, /// The server's state when it stopped: what's different from it in the tab is your own work. confirmed: Box, + /// Who you were: the layers you made are in that client's id space. + resume: Resume, } impl Live { @@ -170,17 +172,19 @@ impl Live { *self = Live { id: self.id, tab: self.tab, ..Live::default() }; } - /// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or - /// one that already is; any other goes back to no session. - fn strand(&mut self) { + /// Remember what joining this session again takes, if it is one you joined (see [`Stranded`]). + fn hold(&mut self) { let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty()); - let held = self.stranded.take().or_else(|| { - let (client, info) = (self.client.as_ref()?, joined?); - Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) }) - }); - if held.is_none() { - return self.reset(); + if let (None, Some(client), Some(info)) = (&self.stranded, &self.client, joined) { + self.stranded = Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()), resume: client.resume() }); } + } + + /// Keep this session on its tab for joining again: a session you joined, or one that already + /// is; any other goes back to no session. + fn strand(&mut self) { + self.hold(); + let Some(held) = self.stranded.take() else { return self.reset() }; *self = Live { id: self.id, tab: self.tab, @@ -189,7 +193,7 @@ impl Live { server: std::mem::take(&mut self.server), published: self.published.take(), share: self.share.take(), - stranded: held, + stranded: Some(held), ..Live::default() }; } diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index 5bc94c0f4..86473ea52 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,8 +1,11 @@ use super::host::{clean_hosting, clean_shared}; +use figcraft_collab::Change; +use figcraft_collab::merge::{self, Side}; + use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status}; use super::{ - Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, - ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, + Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, Resume, ServerMsg, Session, + Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, }; /// What a live session's document holding components that contain each other's instances shows @@ -371,11 +374,12 @@ impl Session { /// Session `id`'s welcome: start the sync client; the host keeps editing the document it /// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a /// reconnect). - fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { - let client = match Client::join(welcome) { + pub(super) fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { + let mut client = match Client::join(welcome) { Ok(c) => c, Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")), }; + let mut merged = None; let Some(live) = self.live.sessions.get(&id) else { return }; let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u)); let (uid, synced) = match (live.role(), existing) { @@ -396,7 +400,7 @@ impl Session { } (Some(Role::Guest), Some(uid)) => { // Joining again in this tab, or the file you published arriving from the server. - self.live_keep_offline_work(id, uid); + merged = self.live_merge_offline_work(id, uid, &mut client); let doc = Arc::new(client.document().clone()); let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty); self.apply_remote(uid, doc.clone()); @@ -444,31 +448,50 @@ impl Session { live.client = Some(client); live.deferred.clear(); live.stranded = None; + self.live.send(id, merged); } /// Session `id` is back on document `uid` after it couldn't go on, and the server's version is - /// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost. - fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) { - let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return }; - let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return }; + /// about to replace the tab's: what you did since goes on top of it as one edit of `client`, + /// returned to send, the way two people's edits merge. What can't (the same thing changed both + /// ways, or layers `client` can't make) leaves the server's as it is, and your whole version opens + /// beside it, unsaved, so nothing is lost. + fn live_merge_offline_work(&mut self, id: SessionId, uid: u64, client: &mut Client) -> Option { + let held = self.live.sessions.get(&id)?.stranded.as_ref()?; + let local = self.docs.iter().find(|d| d.uid == uid)?.doc.clone(); let name = local.name.clone(); - // A document that can't be compared is kept too. - if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) { + let plan = merge::plan(held.confirmed.document(), &local, client.document()).ok(); + let mine = plan.as_ref().map(|p| p.changes(Side::Branch)); + if mine.is_some_and(<[Change]>::is_empty) { self.message = Some(format!("Reconnected to {name}")); - return; + return None; + } + let me = u64::from(client.id()); + let own = mine.is_some_and(|m| m.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != me))); + let merged = plan.as_ref().filter(|_| own).and_then(|p| p.merge(client.document(), Side::Branch, &|_| Some(Side::Main)).ok()); + let sent = merged.and_then(|(mut doc, touched)| { + doc.mark_dirty(touched); + client.edit_with(&doc, Some("Changes made while disconnected")).ok() + }); + if sent.is_some() && plan.is_some_and(|p| p.conflicts.is_empty()) { + self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is in the file")); + return sent.flatten(); } let mut copy = DocState::new((*local).clone(), None); let copy_name = format!("{name} (offline copy)"); Arc::make_mut(&mut copy.doc).name.clone_from(©_name); self.docs.push(copy); - self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”")); + self.message = Some(format!( + "Reconnected to {name}. Some of what you changed while disconnected couldn't go in: your whole version is open beside it as “{copy_name}”" + )); + sent.flatten() } - /// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs - /// nothing from the connection that was lost. + /// Join session `id` again in its tab (see [`Live::strand`]): nothing is needed from the + /// connection that was lost. pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> { let name = display_name(&self.prefs); - let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else { + let Some((link, resume)) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| (s.link.clone(), s.resume)) else { return Err(crate::bad(cmd, "this file has no live session to join again")); }; self.live.leave(id); @@ -476,7 +499,9 @@ impl Session { return Err(EngineError::Disabled(cmd.to_string(), why)); } let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) }; - net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?; + // As the client you were when the server still knows it, so the layers you made are yours to + // send; the whole document is asked for (no change is "after" this number). + net.join_resuming(id, &link, &name, Some(Resume { seq: u64::MAX, ..resume })).map_err(|e| crate::bad(cmd, e))?; if let Some(live) = self.live.sessions.get_mut(&id) { live.status = Status::Connecting; } diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 75ade0c80..c1b8d9e22 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -213,7 +213,7 @@ pub fn specs() -> Vec { spec!( "collab.reconnect", "Reconnect live session", - "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost", + "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since going on top (your whole version opening beside it when some of it can't). Refused when nothing was lost", false, reconnect ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index 0930151dc..576ee501b 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -1,8 +1,8 @@ use super::host::hosting_json; use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status}; use super::{ - Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code, - editing_of, json, + Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, ServerMsg, Session, Value, ViewState, bad, busy, + close, code, editing_of, json, }; impl Session { @@ -115,7 +115,23 @@ impl Session { let Some(live) = self.live.sessions.get_mut(&id) else { return }; let Some(client) = live.client.as_mut() else { return }; let mut out = Vec::new(); - for m in std::mem::take(&mut live.deferred) { + let mut waiting = std::mem::take(&mut live.deferred).into_iter(); + while let Some(m) = waiting.next() { + // The server no longer knows this client (it restarted without its saved state, say): + // it welcomed you as someone new. What you did goes on top like after any rejoin. + if let ServerMsg::Welcome { session, client_id, .. } = &m { + let me = client.resume(); + if (*session, *client_id) != (me.session, me.client_id) { + live.hold(); + live.client = None; + let rest: Vec<_> = waiting.collect(); + self.live_welcome(id, m); + if let Some(live) = self.live.sessions.get_mut(&id) { + live.deferred = rest; + } + return; + } + } match client.receive(m, &EngineRecompute) { Ok(more) => out.extend(more), Err(e) => { diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 9852a1686..79ea13763 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -546,10 +546,13 @@ resuming with its offline edits, and leaving the session going back to snapshot - Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…** - and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the - tab changed since it lost the server, what changed opens beside it as an unsaved - "offline copy" and the tab shows the server's version. A session that gets out of step does the - same by itself. A reconnect resumes as the same + and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab, as the same client when + the server still knows it, and what changed since it lost the server goes on top as one undoable + edit, through the three-way merge of `crates/collab/src/merge.rs`. The same thing changed both + ways keeps the other person's, and layers a new client can't make aren't sent; in those cases + your whole version also opens beside it as an unsaved "offline copy", so nothing is lost. A + session that gets out of step, or a server that no longer knows you, does the same by itself. A + reconnect resumes as the same client: the guest's connection keeps the session id, client id and last sequence number from the messages it read and sends them in its next `Hello` (see Offline and reconnect). - The browser build joins and publishes to a server (see *Built (the browser client)*); hosting