mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
M9: a joined live session keeps reconnecting and says when it's disconnected
A session you joined could end for reasons that pass, or after about 85 s of retries, and its tab then looked like an unsaved local file. Work done there reached no one, with only a 3 s toast to say so. A live connection now reconnects until you leave, pausing a minute after each 12 attempts. Only refusals that are for good stop it, so "too many messages", "busy" and the like retry. Messages queued while offline go out paced so a long queue can't trip the server's rate limit. A session that ended stays on its tab: the tab shows a red mark and a strip across the canvas offers Retry now or Try again, Save a copy and Dismiss. Joining again uses the same tab, and what changed offline opens beside it as an unsaved copy. A session that gets out of step joins again by itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f780211dd
commit
fccaa43101
30 files changed
+687
-59
No files matched your search
@@ -118,7 +118,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
});
|
||||
for guest in [&b, &c] {
|
||||
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
|
||||
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
|
||||
assert!(guest.live().rejoinable() && !guest.live().status.active(), "over: nothing retries, and the tab says so");
|
||||
}
|
||||
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
|
||||
let folder = dir.path().join("files").join(&p.file);
|
||||
|
||||
@@ -160,7 +160,7 @@ fn revoked_links_stop_working_at_once() {
|
||||
assert_eq!((files.len(), files[0].people), (1, 3));
|
||||
// Revoked: Cy is out at once, the others stay.
|
||||
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().rejoinable() && s[0].live().peers.len() == 1);
|
||||
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
|
||||
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
|
||||
assert!(live(&b));
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
@@ -28,7 +29,7 @@ use crate::invite::Endpoint;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::limits::NetLimits;
|
||||
use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Pace, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
/// driver's event methods.
|
||||
@@ -79,6 +80,9 @@ pub(crate) struct Driver<S: Socket> {
|
||||
/// Your latest presence while not welcomed (sent with the welcome).
|
||||
pending: Option<Presence>,
|
||||
throttle: Throttle<Presence>,
|
||||
/// Sync messages waiting their turn (a long queue from while offline), and when each may go.
|
||||
pace: Pace,
|
||||
paced: VecDeque<(Instant, ClientMsg)>,
|
||||
reconnect_at: Option<Instant>,
|
||||
/// What this connection asks a server (a managing one), and what follows its answers.
|
||||
asking: Option<(Box<Exchange>, Then)>,
|
||||
@@ -86,7 +90,7 @@ pub(crate) struct Driver<S: Socket> {
|
||||
}
|
||||
|
||||
impl<S: Socket> Driver<S> {
|
||||
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
|
||||
fn new(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
Self {
|
||||
sock,
|
||||
cfg,
|
||||
@@ -97,6 +101,8 @@ impl<S: Socket> Driver<S> {
|
||||
resume: Resuming::default(),
|
||||
pending: None,
|
||||
throttle: Throttle::new(PRESENCE_INTERVAL),
|
||||
pace: Pace::new(now),
|
||||
paced: VecDeque::new(),
|
||||
reconnect_at: None,
|
||||
asking: None,
|
||||
done: false,
|
||||
@@ -105,7 +111,7 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
/// Start joining (progress arrives in `inbox`).
|
||||
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
let mut d = Self::new(sock, cfg, inbox, now);
|
||||
d.inbox.push(Event::Status(Status::Connecting));
|
||||
d.connect(now);
|
||||
d
|
||||
@@ -114,7 +120,7 @@ impl<S: Socket> Driver<S> {
|
||||
/// Start with what `next` asks a server (a background request): the events it ends in go to
|
||||
/// `inbox`, then the join it ends in, if any, as `cfg` says.
|
||||
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
let mut d = Self::new(sock, cfg, inbox, now);
|
||||
d.follow(next, now);
|
||||
d
|
||||
}
|
||||
@@ -303,8 +309,31 @@ impl<S: Socket> Driver<S> {
|
||||
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
|
||||
return;
|
||||
}
|
||||
if !self.write(&msg) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
let wait = self.pace.wait(now);
|
||||
if wait.is_zero() && self.paced.is_empty() {
|
||||
if !self.write(&msg) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
} else {
|
||||
self.paced.push_back((now + wait, msg));
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the sync messages whose turn has come; false when one couldn't go.
|
||||
fn send_paced(&mut self, now: Instant) -> bool {
|
||||
while let Some((_, msg)) = self.paced.pop_front_if(|(at, _)| *at <= now) {
|
||||
if !self.write(&msg) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconnect now, if it is waiting to: the pauses grow from the beginning again.
|
||||
pub(crate) fn retry(&mut self, now: Instant) {
|
||||
if !self.done && self.reconnect_at.is_some() {
|
||||
self.attempt = 0;
|
||||
self.connect(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +356,7 @@ impl<S: Socket> Driver<S> {
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + c.silence_limit(l)), if c.welcomed { self.throttle.due() } else { None }]
|
||||
}
|
||||
});
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at, self.paced.front().map(|(at, _)| *at)]))
|
||||
}
|
||||
|
||||
/// Do what's due at `now`: reconnect, send held presence or a ping, give up on a connection
|
||||
@@ -357,7 +386,7 @@ impl<S: Socket> Driver<S> {
|
||||
if now >= last_heard + silence {
|
||||
return self.end(Outcome::Retry("the host stopped answering".into()), now);
|
||||
}
|
||||
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
let ok = self.send_paced(now) && self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
|
||||
if !ok {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
@@ -388,6 +417,8 @@ impl<S: Socket> Driver<S> {
|
||||
self.sock.close();
|
||||
}
|
||||
self.throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
// Made for this connection: the sync client sends again what the server didn't get.
|
||||
self.paced.clear();
|
||||
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
|
||||
@@ -252,7 +252,7 @@ fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnects_back_off_then_give_up() {
|
||||
fn a_live_connection_reconnects_in_rounds_and_never_gives_up() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
@@ -260,7 +260,7 @@ fn reconnects_back_off_then_give_up() {
|
||||
let mut now = t0;
|
||||
let mut pauses = Vec::new();
|
||||
d.closed(1006, "", now);
|
||||
while !d.done() {
|
||||
for _ in 0..26 {
|
||||
let next = d.deadline().unwrap();
|
||||
pauses.push(next - now);
|
||||
now = next;
|
||||
@@ -269,11 +269,114 @@ fn reconnects_back_off_then_give_up() {
|
||||
d.closed(1006, "", now);
|
||||
}
|
||||
let secs: Vec<f64> = pauses.iter().map(Duration::as_secs_f64).collect();
|
||||
assert_eq!(secs, [0.5, 1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
|
||||
let round = [1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0];
|
||||
assert_eq!(secs[0], 0.5);
|
||||
assert_eq!(secs[1..12], round);
|
||||
assert_eq!(secs[12], 60.0, "a longer pause after a round");
|
||||
assert_eq!(secs[13..24], round, "then as many again");
|
||||
assert_eq!(secs[24], 60.0);
|
||||
assert!(!d.done());
|
||||
let st = statuses(&inbox.drain());
|
||||
assert_eq!(st.len(), 14, "connecting, 12 reconnects, then failed");
|
||||
assert!(matches!(st.last(), Some(Status::Failed { reason }) if reason.starts_with("Lost the connection to the host")), "{st:?}");
|
||||
assert_eq!(d.deadline(), None);
|
||||
assert!(!st.iter().any(|s| matches!(s, Status::Failed { .. })), "{st:?}");
|
||||
assert!(matches!(st.last(), Some(Status::Reconnecting { attempt: 27 })), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_now_skips_the_pause_and_starts_the_round_over() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.closed(1006, "", t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..12 {
|
||||
now = d.deadline().unwrap();
|
||||
d.tick(now);
|
||||
d.closed(1006, "", now);
|
||||
}
|
||||
assert_eq!(d.deadline().unwrap() - now, Duration::from_secs(60));
|
||||
let opened = d.sock.opened.len();
|
||||
d.retry(now);
|
||||
assert_eq!(d.sock.opened.len(), opened + 1, "connects at once");
|
||||
inbox.drain();
|
||||
d.closed(1006, "", now);
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }]);
|
||||
assert_eq!(d.deadline().unwrap() - now, ms(500), "the pauses grow from the beginning again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_was_live_only_stops_for_a_refusal_that_is_for_good() {
|
||||
let t0 = Instant::now();
|
||||
// The words servers turn a session away with for now.
|
||||
for reason in [
|
||||
"too many messages",
|
||||
"the server is busy: try again in a moment",
|
||||
"the session is full",
|
||||
"no hello in time",
|
||||
"this file can't be opened on the server (its data is damaged); tell the server's owner",
|
||||
"too many connections from your address lately: wait a minute and try again",
|
||||
] {
|
||||
for frame in [false, true] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
inbox.drain();
|
||||
if frame {
|
||||
d.closed(1008, reason, t0);
|
||||
} else {
|
||||
d.message(&encode(&ServerMsg::Refused { reason: reason.into() }), t0);
|
||||
}
|
||||
assert!(!d.done(), "{reason}");
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }], "{reason}");
|
||||
}
|
||||
}
|
||||
// And the words for good.
|
||||
for reason in [
|
||||
"this link isn't valid (it may have been revoked)",
|
||||
"this file isn't on the server (it may have been removed)",
|
||||
"this file was removed from the server",
|
||||
"you were removed from this session",
|
||||
"this server no longer accepts connections from your address",
|
||||
"this server doesn't accept connections from your address",
|
||||
"wrong session token",
|
||||
"protocol 9 isn't supported",
|
||||
"message too large: this server takes at most 64 MiB at once (a change that big can't be kept here)",
|
||||
] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.closed(1008, reason, t0);
|
||||
assert!(d.done(), "{reason}");
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason: r }) if r.contains(reason)), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_queue_goes_out_paced_and_in_order() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
let before = d.sock.sent.len();
|
||||
for seq in 0..500 {
|
||||
d.send(ClientMsg::Ack { seq }, t0);
|
||||
}
|
||||
let at_once = d.sock.sent.len() - before;
|
||||
assert!((1..=200).contains(&at_once), "{at_once} at once: a server drops a connection past 400");
|
||||
let mut now = t0;
|
||||
while d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count() < 500 {
|
||||
now = d.deadline().unwrap();
|
||||
d.tick(now);
|
||||
}
|
||||
let seqs: Vec<u64> = d.sock.sent.iter().filter_map(|m| if let ClientMsg::Ack { seq } = m { Some(*seq) } else { None }).collect();
|
||||
assert_eq!(seqs, (0..500).collect::<Vec<u64>>());
|
||||
assert!(now - t0 >= Duration::from_secs(4), "the rest take their time: {:?}", now - t0);
|
||||
// Unsent ones were made for this connection: the sync client sends them again.
|
||||
for seq in 0..500 {
|
||||
d.send(ClientMsg::Ack { seq }, now);
|
||||
}
|
||||
d.closed(1006, "", now);
|
||||
assert_eq!(d.deadline().unwrap() - now, ms(500), "nothing left to send but the reconnect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::requests;
|
||||
pub use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Pace, Throttle, earliest};
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
pub struct Guest {
|
||||
@@ -44,6 +44,8 @@ enum Cmd {
|
||||
/// For the server, in order (document changes).
|
||||
Send(ClientMsg),
|
||||
Leave,
|
||||
/// Try now rather than after the pause under way (reconnecting).
|
||||
Retry,
|
||||
/// The reader of connection `epoch` got its welcome.
|
||||
Welcomed(u64),
|
||||
/// Connection `epoch` ended.
|
||||
@@ -133,6 +135,11 @@ impl Guest {
|
||||
let _ = self.cmd.send(Cmd::Send(msg));
|
||||
}
|
||||
|
||||
/// Reconnect now, if it is waiting to.
|
||||
pub fn retry(&self) {
|
||||
let _ = self.cmd.send(Cmd::Retry);
|
||||
}
|
||||
|
||||
/// Leave (returns at once: a connection attempt in progress ends on its own).
|
||||
pub fn leave(&mut self) {
|
||||
let _ = self.cmd.send(Cmd::Leave);
|
||||
@@ -227,6 +234,11 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
|
||||
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => return,
|
||||
Ok(Cmd::Presence(p)) => pending = Some(p),
|
||||
// A fresh start: the pauses grow from the beginning again.
|
||||
Ok(Cmd::Retry) => {
|
||||
attempt = 0;
|
||||
break;
|
||||
}
|
||||
// From the old connection's client: the next welcome starts over.
|
||||
Ok(_) => {}
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
@@ -267,6 +279,7 @@ fn session(
|
||||
let hello = hello(cfg, resume);
|
||||
let mut welcomed = false;
|
||||
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
let mut pace = Pace::new(Instant::now());
|
||||
let outcome = if send(&mut writer, &hello) {
|
||||
loop {
|
||||
let ping_at = last_ping + cfg.limits.keepalive;
|
||||
@@ -274,7 +287,11 @@ fn session(
|
||||
let mut out = None;
|
||||
let mut now_send = Vec::new();
|
||||
match rx.recv_timeout(wake.saturating_duration_since(Instant::now())) {
|
||||
Ok(Cmd::Send(m)) if welcomed => now_send.push(m),
|
||||
Ok(Cmd::Send(m)) if welcomed => {
|
||||
// A long queue from while offline doesn't go out faster than the server takes it.
|
||||
std::thread::sleep(pace.wait(Instant::now()));
|
||||
now_send.push(m);
|
||||
}
|
||||
// Made for the previous connection (the sync client sends again what the server
|
||||
// didn't get once it has caught up with this connection's welcome).
|
||||
Ok(Cmd::Send(_)) => {}
|
||||
@@ -292,7 +309,7 @@ fn session(
|
||||
break Outcome::Left;
|
||||
}
|
||||
// From an earlier connection.
|
||||
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
|
||||
Ok(Cmd::Welcomed(_) | Cmd::Lost(..) | Cmd::Retry) => {}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !now_send.iter().all(|m| send(&mut writer, m)) {
|
||||
|
||||
@@ -39,8 +39,10 @@ pub struct GuestConfig {
|
||||
/// The pause before the first reconnect; it doubles each time up to `max_backoff`.
|
||||
pub backoff: Duration,
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
/// Reconnect attempts in a row before the longer pause (`round_pause`), after which it tries
|
||||
/// as many again: a connection that was live is never given up on by itself.
|
||||
pub attempts: u32,
|
||||
pub round_pause: Duration,
|
||||
/// Join as the client you were (an app that starts over after a crash): the first hello
|
||||
/// carries it, and a server that still knows the id gives it back.
|
||||
pub resume: Option<Resume>,
|
||||
@@ -57,6 +59,7 @@ impl GuestConfig {
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
round_pause: Duration::from_secs(60),
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +68,14 @@ impl GuestConfig {
|
||||
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
|
||||
self.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(self.max_backoff)
|
||||
}
|
||||
|
||||
/// The pause before reconnect attempt `attempt` (from 1): growing within a round of
|
||||
/// [`attempts`](Self::attempts), and [`round_pause`](Self::round_pause) between rounds.
|
||||
pub(crate) fn pause(&self, attempt: u32) -> Duration {
|
||||
let round = self.attempts.max(1);
|
||||
let into_round = attempt.saturating_sub(1) % round;
|
||||
if into_round == 0 && attempt > round { self.round_pause } else { self.backoff(into_round + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
@@ -143,11 +154,38 @@ pub(crate) enum Next {
|
||||
Reconnect(Duration),
|
||||
}
|
||||
|
||||
/// After `outcome`: a refusal or the end of the session is final, and so is a first join that
|
||||
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
|
||||
/// attempts run out.
|
||||
/// What a server refuses for good, whenever you ask: the link, the file or your place in it is
|
||||
/// gone, or the two versions can't talk. Anything else a server refuses a session with (it is
|
||||
/// busy, a limit was hit, the file couldn't be read just now) may pass, and servers of any version
|
||||
/// say so in these words, so a reason not listed here means try again.
|
||||
const FINAL_REFUSALS: [&str; 11] = [
|
||||
"isn't valid",
|
||||
"isn't on the server",
|
||||
"was removed from the server",
|
||||
"were removed from this session",
|
||||
"no longer accepts connections",
|
||||
"doesn't accept connections",
|
||||
"wrong session token",
|
||||
"isn't supported",
|
||||
"couldn't be read",
|
||||
"message too large",
|
||||
"this version can't read",
|
||||
];
|
||||
|
||||
/// Whether `reason`, from a server refusing a session that was live, is for good.
|
||||
pub(crate) fn final_refusal(reason: &str) -> bool {
|
||||
FINAL_REFUSALS.iter().any(|f| reason.contains(f))
|
||||
}
|
||||
|
||||
/// After `outcome`: the end of the session is final, and so are a refusal for good and a first
|
||||
/// join that can't reach the host. A live connection that dropped or was turned away for now tries
|
||||
/// again until you leave: with growing pauses, and a longer one between rounds.
|
||||
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
|
||||
let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason })));
|
||||
let outcome = match outcome {
|
||||
Outcome::Refused(why) if was_live && !final_refusal(&why) => Outcome::Retry(why),
|
||||
o => o,
|
||||
};
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
@@ -155,13 +193,10 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
*attempt += 1;
|
||||
if *attempt > cfg.attempts {
|
||||
return failed(format!("Lost the connection to the host ({why})"));
|
||||
}
|
||||
*attempt = attempt.saturating_add(1);
|
||||
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
|
||||
inbox.push(Event::Status(Status::Reconnecting { attempt: *attempt }));
|
||||
Next::Reconnect(cfg.backoff(*attempt))
|
||||
Next::Reconnect(cfg.pause(*attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,12 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: SessionId) {
|
||||
if let Some(Active::Guest(g)) = self.sessions.get(session) {
|
||||
g.retry();
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.presence(p.clone()),
|
||||
|
||||
@@ -124,8 +124,9 @@ fn following_a_viewport_until_you_move() {
|
||||
fn the_host_stopping_ends_the_session_for_guests() {
|
||||
let (mut host, mut guest, _, _) = host_and_join();
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
|
||||
assert!(guest.live().peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().rejoinable());
|
||||
assert!(guest.live().peers.is_empty() && !guest.live().syncing());
|
||||
assert!(guest.live().disconnected(), "the tab says so");
|
||||
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
|
||||
}
|
||||
|
||||
|
||||
@@ -158,8 +158,8 @@ fn two_files_are_hosted_and_joined_independently() {
|
||||
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
|
||||
// The host closes B: its session ends for everyone, A's goes on.
|
||||
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
|
||||
assert_eq!(guest.live_of(gb).status, Status::Off);
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live_of(gb).rejoinable());
|
||||
assert!(guest.live_of(gb).disconnected(), "the guest's tab says so");
|
||||
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
|
||||
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(guest.live_of(ga).status, Status::Live);
|
||||
|
||||
@@ -10,6 +10,33 @@ use web_time::Instant;
|
||||
/// How often presence goes out at most.
|
||||
pub const PRESENCE_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
/// How many messages other than presence go out at once, and per second after that: with presence
|
||||
/// and pings that stays under what a server takes from a connection before it drops it.
|
||||
const PACE_BURST: f64 = 200.0;
|
||||
const PACE_RATE: f64 = 60.0;
|
||||
|
||||
/// Paces messages that must all go out (everything queued while offline leaves at once when the
|
||||
/// connection comes back): `wait` says how long until the next may.
|
||||
#[derive(Debug)]
|
||||
pub struct Pace {
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
pub fn new(now: Instant) -> Self {
|
||||
Self { tokens: PACE_BURST, last: now }
|
||||
}
|
||||
|
||||
/// Take one message's turn at `now`: how long to wait before sending it (zero: at once).
|
||||
pub fn wait(&mut self, now: Instant) -> Duration {
|
||||
let passed = now.saturating_duration_since(self.last).as_secs_f64();
|
||||
self.last = now;
|
||||
self.tokens = (self.tokens + passed * PACE_RATE).min(PACE_BURST) - 1.0;
|
||||
if self.tokens >= 0.0 { Duration::ZERO } else { Duration::from_secs_f64(-self.tokens / PACE_RATE) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Throttle<T> {
|
||||
interval: Duration,
|
||||
|
||||
@@ -242,6 +242,10 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: SessionId) {
|
||||
self.with(session, |d, now| d.retry(now));
|
||||
}
|
||||
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
self.with(session, |d, now| d.presence(p.clone(), now));
|
||||
}
|
||||
|
||||
@@ -318,6 +318,117 @@ fn a_dropped_connection_keeps_editing_and_catches_up() {
|
||||
assert_eq!((doc.node(r).unwrap().name.as_str(), doc.node(f).unwrap().name.as_str()), ("Sent", "Offline"));
|
||||
}
|
||||
|
||||
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") };
|
||||
let (id, out) = self.server.join(h).unwrap();
|
||||
let w = welcome_of(&out, id);
|
||||
let p = &mut self.people[i];
|
||||
p.id = id;
|
||||
p.online = true;
|
||||
p.script.lock().unwrap().events.extend([Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, Event::Sync(w)]);
|
||||
p.s.live_poll();
|
||||
}
|
||||
|
||||
fn event(&mut self, i: usize, e: Event) {
|
||||
let p = &mut self.people[i];
|
||||
p.script.lock().unwrap().events.push(e);
|
||||
p.s.live_poll();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
assert!(!room.s(0).live().disconnected());
|
||||
assert!(room.s(0).execute("collab.reconnect", &json!({})).is_err(), "nothing was lost");
|
||||
room.disconnect(0);
|
||||
assert!(room.s(0).live().disconnected());
|
||||
assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "reconnecting");
|
||||
let session = room.s(0).live().id;
|
||||
assert_eq!(room.people[0].script.lock().unwrap().retried, [session]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
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;
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap();
|
||||
room.event(0, Event::Status(Status::Failed { reason: "The host refused: this link isn't valid".into() }));
|
||||
// The tab says so, and is yours to go on working in.
|
||||
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();
|
||||
// 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());
|
||||
assert!(room.s(0).live_read_only(uid).is_none(), "it can still be edited while it tries");
|
||||
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.settle();
|
||||
// The next try joins in the same tab, and what was done offline opens beside it.
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
room.welcome_anew(0);
|
||||
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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_again_with_nothing_changed_opens_no_copy() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
room.event(0, Event::Ended { reason: "Sharing was stopped".into() });
|
||||
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);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_out_of_step_joins_again_by_itself() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let joins = room.people[0].script.lock().unwrap().joined.len();
|
||||
let tabs = room.s(0).docs.len();
|
||||
// A batch from far in the future: it can't be applied.
|
||||
room.event(0, Event::Sync(ServerMsg::Changes(figcraft_collab::Accepted { seq: 999, author: 42, batch_id: 1, changes: vec![] })));
|
||||
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);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_a_lost_session_makes_the_tab_a_plain_copy() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
room.disconnect(0);
|
||||
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
|
||||
room.s(0).execute("collab.leave", &json!({})).unwrap();
|
||||
assert_eq!(room.s(0).live().status, Status::Off);
|
||||
assert!(!room.s(0).live().disconnected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_commands_with_undo_and_drops_converge_through_the_engine() {
|
||||
for seed in 0..6 {
|
||||
|
||||
@@ -17,7 +17,9 @@ pub(crate) struct Script {
|
||||
pub(crate) events: Vec<Event>,
|
||||
pub(crate) sent: Vec<Presence>,
|
||||
pub(crate) hosted: Vec<HostOptions>,
|
||||
joined: Vec<String>,
|
||||
pub(crate) joined: Vec<String>,
|
||||
/// The sessions told to reconnect now, in order.
|
||||
pub(crate) retried: Vec<u64>,
|
||||
pub(crate) left: usize,
|
||||
pub(crate) msgs: Vec<ClientMsg>,
|
||||
pub(crate) published: Vec<PublishOptions>,
|
||||
@@ -46,6 +48,10 @@ impl LiveLink for Mock {
|
||||
i.url = format!("ws://{}/?file=f&token=t", i.address);
|
||||
Ok(i)
|
||||
}
|
||||
fn retry(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().retried.push(session);
|
||||
}
|
||||
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
if !link.starts_with("figcraft://") {
|
||||
return Err("not an invite".into());
|
||||
@@ -229,7 +235,7 @@ fn events_build_who_is_here() {
|
||||
assert_eq!(s.live_summary()["attempt"], 1);
|
||||
push(&script, vec![Event::Status(Status::Failed { reason: "Lost the connection".into() })]);
|
||||
s.live_poll();
|
||||
assert!(s.live().info.is_none());
|
||||
assert!(s.live().peers.is_empty() && !s.live().syncing());
|
||||
assert_eq!(s.live_summary()["reason"], "Lost the connection");
|
||||
assert_eq!(s.message.as_deref(), Some("Lost the connection"));
|
||||
assert!(!s.live_poll(), "nothing new");
|
||||
|
||||
@@ -140,6 +140,18 @@ pub struct Live {
|
||||
/// The branches of the session's file made or last updated from main in this session: every
|
||||
/// layer of their base was in it (see [`crate::branches`]).
|
||||
branch_bases: BTreeSet<String>,
|
||||
/// You were in this session and aren't now: what joining it again takes (`collab.reconnect`).
|
||||
stranded: Option<Stranded>,
|
||||
}
|
||||
|
||||
/// What's kept of a session you joined once it can't go on by itself. The tab stays yours to work
|
||||
/// in, and says so.
|
||||
#[derive(Clone)]
|
||||
struct Stranded {
|
||||
/// The invite link you joined with (it holds the token: never shown).
|
||||
link: String,
|
||||
/// The server's state when it stopped: what's different from it in the tab is your own work.
|
||||
confirmed: Box<figcraft_collab::Replica>,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
@@ -158,6 +170,41 @@ 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) {
|
||||
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();
|
||||
}
|
||||
*self = Live {
|
||||
id: self.id,
|
||||
tab: self.tab,
|
||||
doc_uid: self.doc_uid,
|
||||
info: self.info.take(),
|
||||
server: std::mem::take(&mut self.server),
|
||||
published: self.published.take(),
|
||||
share: self.share.take(),
|
||||
stranded: held,
|
||||
..Live::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// You were in this session and aren't now: the connection dropped and it is trying again, or
|
||||
/// it can't go on by itself. What you do in its tab isn't reaching anyone.
|
||||
pub fn disconnected(&self) -> bool {
|
||||
matches!(self.status, Status::Reconnecting { .. }) || self.stranded.is_some()
|
||||
}
|
||||
|
||||
/// The session can be joined again in its tab (`collab.reconnect`).
|
||||
pub fn rejoinable(&self) -> bool {
|
||||
self.stranded.is_some() && !self.status.active()
|
||||
}
|
||||
|
||||
/// The document sync client (once the welcome arrived): peers, pending batches, the user's
|
||||
/// undo history.
|
||||
pub fn client(&self) -> Option<&Client> {
|
||||
|
||||
@@ -214,8 +214,15 @@ impl Session {
|
||||
// order.
|
||||
Event::Sync(msg) => live.deferred.push(msg),
|
||||
Event::Ended { reason } => {
|
||||
self.message = Some(if behind.is_empty() { reason } else { format!("{}: {reason}", behind.trim_start()) });
|
||||
self.live.end(id);
|
||||
let joined = live.syncing() && live.role() == Some(Role::Guest);
|
||||
let text = if behind.is_empty() { reason.clone() } else { format!("{}: {reason}", behind.trim_start()) };
|
||||
if joined {
|
||||
// The tab says so and stays yours: it doesn't pass for a file of your own.
|
||||
self.live_fail(id, reason);
|
||||
} else {
|
||||
self.live.end(id);
|
||||
}
|
||||
self.message = Some(text);
|
||||
}
|
||||
Event::Published(p) => {
|
||||
// Shared with a code: the popover shows it, nothing more to say.
|
||||
@@ -389,6 +396,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);
|
||||
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());
|
||||
@@ -435,15 +443,53 @@ impl Session {
|
||||
live.synced = synced;
|
||||
live.client = Some(client);
|
||||
live.deferred.clear();
|
||||
live.stranded = None;
|
||||
}
|
||||
|
||||
/// Session `id` is over with a message: it couldn't be joined, the connection was lost for
|
||||
/// good or sync broke. Its tab shows why.
|
||||
/// 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 };
|
||||
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()) {
|
||||
self.message = Some(format!("Reconnected to {name}"));
|
||||
return;
|
||||
}
|
||||
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}”"));
|
||||
}
|
||||
|
||||
/// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs
|
||||
/// nothing 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 {
|
||||
return Err(crate::bad(cmd, "this file has no live session to join again"));
|
||||
};
|
||||
self.live.leave(id);
|
||||
if let Some(why) = crate::file_access::network_refused() {
|
||||
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))?;
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.status = Status::Connecting;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Session `id` is over with a message: it couldn't be joined, it was refused or ended for
|
||||
/// good, or sync broke. Its tab shows why, and a session you were in can be joined again there.
|
||||
pub(super) fn live_fail(&mut self, id: SessionId, reason: String) {
|
||||
log::warn!("live session: {reason}");
|
||||
self.live.leave(id);
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.reset();
|
||||
live.strand();
|
||||
live.status = Status::Failed { reason: reason.clone() };
|
||||
}
|
||||
self.message = Some(reason);
|
||||
@@ -456,7 +502,7 @@ impl Session {
|
||||
let live = self.live.doc(uid)?;
|
||||
match &live.client {
|
||||
Some(c) => limited_by(c.access()),
|
||||
None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING),
|
||||
None if live.status.active() && live.role() != Some(Role::Host) && !live.disconnected() => Some(PUBLISHING),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,24 @@ fn leave(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ended": ended.is_some_and(|l| l.status.active())}))
|
||||
}
|
||||
|
||||
/// Back into the open file's session now: a reconnect that is waiting tries at once, and a session
|
||||
/// that couldn't go on is joined again in its tab.
|
||||
fn reconnect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.reconnect";
|
||||
let live = s.live();
|
||||
let (id, reconnecting, rejoinable) = (live.id, matches!(live.status, Status::Reconnecting { .. }), live.rejoinable());
|
||||
if reconnecting {
|
||||
if let Some(net) = s.live.link() {
|
||||
net.retry(id);
|
||||
}
|
||||
} else if rejoinable {
|
||||
s.live_rejoin(CMD, id)?;
|
||||
} else {
|
||||
return Err(bad(CMD, "this file has no lost live session to reconnect"));
|
||||
}
|
||||
Ok(json!({"status": s.live().status.id()}))
|
||||
}
|
||||
|
||||
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A file shared online stays its owner's on the server: stopping takes the others' links (and
|
||||
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
|
||||
@@ -192,10 +210,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
join
|
||||
),
|
||||
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",
|
||||
false,
|
||||
reconnect
|
||||
),
|
||||
spec!(
|
||||
"collab.leave",
|
||||
"Leave live session",
|
||||
"{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy",
|
||||
"{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy (also how a tab stops showing a session that couldn't go on)",
|
||||
false,
|
||||
leave
|
||||
),
|
||||
|
||||
@@ -118,7 +118,14 @@ impl Session {
|
||||
for m in std::mem::take(&mut live.deferred) {
|
||||
match client.receive(m, &EngineRecompute) {
|
||||
Ok(more) => out.extend(more),
|
||||
Err(e) => return self.live_fail(id, format!("The live session got out of step ({e}); join again")),
|
||||
Err(e) => {
|
||||
self.live_fail(id, format!("The live session got out of step ({e})"));
|
||||
// A fresh join has nothing to get out of step with.
|
||||
if let Err(e) = self.live_rejoin("collab.reconnect", id) {
|
||||
log::warn!("live session: couldn't join again: {e}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let doc = Arc::new(client.document().clone());
|
||||
|
||||
@@ -496,6 +496,8 @@ pub trait LiveLink: Send {
|
||||
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
|
||||
/// that is over already.
|
||||
fn leave(&mut self, session: SessionId);
|
||||
/// Session `session` is waiting to reconnect: try now.
|
||||
fn retry(&mut self, _session: SessionId) {}
|
||||
/// Your latest presence in `session` (the link throttles and coalesces).
|
||||
fn presence(&mut self, session: SessionId, p: &Presence);
|
||||
/// A message for `session`'s server (document changes), sent in order.
|
||||
|
||||
@@ -406,6 +406,12 @@ name = "English"
|
||||
"Decoration style" = "Decoration style"
|
||||
"Decoration" = "Decoration"
|
||||
"Deep select" = "Deep select"
|
||||
"Disconnected. Reconnecting…" = "Disconnected. Reconnecting…"
|
||||
"Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…" = "Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…"
|
||||
"Dismiss" = "Dismiss"
|
||||
"Not connected: {reason}. Your work is still in this tab." = "Not connected: {reason}. Your work is still in this tab."
|
||||
"Retry now" = "Retry now"
|
||||
"Save a copy…" = "Save a copy…"
|
||||
"Wrap style" = "Wrap style"
|
||||
"Balance" = "Balance"
|
||||
"Pretty" = "Pretty"
|
||||
@@ -415,6 +421,7 @@ name = "English"
|
||||
"Set hug contents" = "Set hug contents"
|
||||
"Set fill container" = "Set fill container"
|
||||
"Double-click an edge of an auto layout frame" = "Double-click an edge of an auto layout frame"
|
||||
"{name}, disconnected from its live session" = "{name}, disconnected from its live session"
|
||||
"⌥ double-click an edge of an auto layout child" = "⌥ double-click an edge of an auto layout child"
|
||||
"Alt+double-click an edge of an auto layout child" = "Alt+double-click an edge of an auto layout child"
|
||||
"Default" = "Default"
|
||||
|
||||
@@ -312,11 +312,13 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
crate::live::draw(app, &painter, &ctx);
|
||||
crate::live::agent::draw(app, &painter, &ctx);
|
||||
crate::live::chat_box(app, &ctx);
|
||||
crate::live::spotlight_banner(app, &ctx, rect);
|
||||
// The other banners float below the strip that says the session is disconnected.
|
||||
let banners = rect.with_min_y(rect.min.y + crate::live::connection_banner(app, &ctx, rect));
|
||||
crate::live::spotlight_banner(app, &ctx, banners);
|
||||
crate::comments::popovers(app, &ctx);
|
||||
crate::versions::preview_banner(app, &ctx, rect);
|
||||
crate::branch_ui::banner(app, &ctx, rect);
|
||||
crate::dev_mode::focus_bar(app, &ctx, rect);
|
||||
crate::versions::preview_banner(app, &ctx, banners);
|
||||
crate::branch_ui::banner(app, &ctx, banners);
|
||||
crate::dev_mode::focus_bar(app, &ctx, banners);
|
||||
// The cursor: the tool's, unless something on the canvas with its own has the pointer.
|
||||
app.canvas.cursor = None;
|
||||
if let (true, Some(at)) = (own_cursor && resp.hovered(), ctx.input(|i| i.pointer.hover_pos())) {
|
||||
|
||||
@@ -205,6 +205,8 @@ icons! {
|
||||
Online,
|
||||
/// In a live session others get into: a point sending waves to both sides.
|
||||
LiveShared,
|
||||
/// Disconnected from a live session: the point and its waves, struck through.
|
||||
LiveDisconnected,
|
||||
/// A file of yours on a server that only you get into: a point inside a closed ring.
|
||||
LivePrivate,
|
||||
/// A design file: a pen nib leaning to the left, its grip filled.
|
||||
|
||||
@@ -809,6 +809,14 @@ fn draw(p: &P, icon: Icon) {
|
||||
p.arc(8.0, 8.0, r, PI - FRAC_PI_4, PI + FRAC_PI_4);
|
||||
}
|
||||
}
|
||||
LiveDisconnected => {
|
||||
p.dot(8.0, 8.0, 1.6);
|
||||
for r in [3.6, 6.0] {
|
||||
p.arc(8.0, 8.0, r, -FRAC_PI_4, FRAC_PI_4);
|
||||
p.arc(8.0, 8.0, r, PI - FRAC_PI_4, PI + FRAC_PI_4);
|
||||
}
|
||||
p.line(3.0, 13.0, 13.0, 3.0);
|
||||
}
|
||||
LivePrivate => {
|
||||
p.dot(8.0, 8.0, 1.6);
|
||||
p.circle(8.0, 8.0, 5.0);
|
||||
|
||||
@@ -436,6 +436,70 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) {
|
||||
app.ui.live.chat = Some(chat);
|
||||
}
|
||||
|
||||
/// While the active document's live session is disconnected: a thin strip across the top of the
|
||||
/// canvas that says so, with what can be done about it. Returns how much of the canvas it takes.
|
||||
pub fn connection_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) -> f32 {
|
||||
const H: f32 = 30.0;
|
||||
let live = app.session.live();
|
||||
let Some(uid) = app.session.docs.get(app.session.active).map(|d| d.uid).filter(|u| crate::tabs::live_lost(app, *u)) else { return 0.0 };
|
||||
let (text, retry, gone) = match &live.status {
|
||||
Status::Failed { reason } => {
|
||||
(trf("Not connected: {reason}. Your work is still in this tab.", &[("reason", &failure_text(reason))]), Some(tr("Try again")), true)
|
||||
}
|
||||
Status::Reconnecting { .. } => {
|
||||
(tr("Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…").to_string(), Some(tr("Retry now")), false)
|
||||
}
|
||||
_ => (tr("Disconnected. Reconnecting…").to_string(), None, false),
|
||||
};
|
||||
// Saved as a file of its own: it is one now.
|
||||
if gone && app.session.docs.iter().any(|d| d.uid == uid && d.path.is_some()) {
|
||||
app.run("collab.leave", json!({}));
|
||||
return 0.0;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let ink = text_on(t.danger);
|
||||
let (mut try_now, mut save, mut dismiss) = (false, false, false);
|
||||
let strip = Rect::from_min_size(canvas.min, vec2(canvas.width(), H));
|
||||
egui::Area::new(egui::Id::unique("live_connection_banner")).order(egui::Order::Foreground).fixed_pos(strip.min).show(ctx, |ui| {
|
||||
ui.set_min_size(strip.size());
|
||||
ui.set_max_size(strip.size());
|
||||
ui.painter().rect_filled(strip, egui::CornerRadius::ZERO, t.danger);
|
||||
let button = |ui: &mut Ui, label: &str| {
|
||||
ui.add(
|
||||
egui::Button::new(egui::RichText::new(label).font(regular(11.0)).color(ink))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.stroke(Stroke::new(1.0, ink))
|
||||
.corner_radius(egui::CornerRadius::same(4)),
|
||||
)
|
||||
.clicked()
|
||||
};
|
||||
let inner = strip.shrink2(vec2(12.0, 0.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if gone {
|
||||
dismiss = button(ui, tr("Dismiss"));
|
||||
save = button(ui, tr("Save a copy…"));
|
||||
}
|
||||
if let Some(label) = retry {
|
||||
try_now = button(ui, label);
|
||||
}
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(ink)).truncate());
|
||||
});
|
||||
});
|
||||
});
|
||||
if try_now {
|
||||
app.run("collab.reconnect", json!({}));
|
||||
}
|
||||
if save {
|
||||
app.save_as_dialog();
|
||||
}
|
||||
if dismiss {
|
||||
app.run("collab.leave", json!({}));
|
||||
}
|
||||
H
|
||||
}
|
||||
|
||||
/// While you spotlight, or someone does and you stopped following them: a banner on the canvas to
|
||||
/// stop, or to follow again.
|
||||
pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
||||
|
||||
@@ -97,3 +97,52 @@ fn leave_takes_a_session_id() {
|
||||
assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id})).unwrap()["ended"], true);
|
||||
assert!(h.app.session.live.get(id).is_none());
|
||||
}
|
||||
|
||||
/// Joined, with the file in its tab.
|
||||
fn joined(h: &mut Harness) -> u64 {
|
||||
join(h);
|
||||
h.welcome();
|
||||
h.sync();
|
||||
h.app.session.docs[h.app.session.active].uid
|
||||
}
|
||||
|
||||
const STRIP: &str = "Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…";
|
||||
|
||||
#[test]
|
||||
fn a_dropped_connection_shows_on_the_tab_and_across_the_canvas_with_retry() {
|
||||
let (mut h, _) = Harness::new();
|
||||
let uid = joined(&mut h);
|
||||
assert!(!crate::tabs::live_lost(&h.app, uid));
|
||||
assert!(!has_text(&h.frames(2), STRIP), "not while connected");
|
||||
h.send(vec![LiveEvent::Status(Status::Reconnecting { attempt: 3 })]);
|
||||
let shapes = h.frames(2);
|
||||
assert!(crate::tabs::live_lost(&h.app, uid));
|
||||
assert!(has_text(&shapes, STRIP));
|
||||
h.click(text_center(&shapes, "Retry now"));
|
||||
assert!(h.app.ui.last_error.is_none(), "{:?}", h.app.ui.last_error);
|
||||
assert_eq!(h.app.session.live().status, Status::Reconnecting { attempt: 3 });
|
||||
// Back: the strip goes.
|
||||
h.welcome();
|
||||
assert!(!has_text(&h.frames(2), STRIP));
|
||||
assert!(!crate::tabs::live_lost(&h.app, uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_ended_stays_on_its_tab_until_dismissed() {
|
||||
let (mut h, _) = Harness::new();
|
||||
let uid = joined(&mut h);
|
||||
h.send(vec![LiveEvent::Status(Status::Failed { reason: "The host refused: this link isn't valid".into() })]);
|
||||
let shapes = h.frames(2);
|
||||
assert!(crate::tabs::live_lost(&h.app, uid));
|
||||
assert!(has_text(&shapes, "Not connected: The host refused: this link isn't valid. Your work is still in this tab."));
|
||||
assert!(has_text(&shapes, "Save a copy…"));
|
||||
h.click(text_center(&shapes, "Try again"));
|
||||
assert_eq!(h.app.session.live().status, Status::Connecting);
|
||||
assert!(crate::tabs::live_lost(&h.app, uid), "still disconnected while it tries");
|
||||
h.send(vec![LiveEvent::Status(Status::Failed { reason: "no".into() })]);
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Dismiss"));
|
||||
assert!(!has_text(&h.frames(2), "Try again"));
|
||||
assert!(!crate::tabs::live_lost(&h.app, uid));
|
||||
assert_eq!(h.app.session.live().status, Status::Off);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ impl Harness {
|
||||
color: "#14AE5C".into(),
|
||||
file_name: "Shared".into(),
|
||||
pages: vec![],
|
||||
link: String::new(),
|
||||
link: "figcraft://join?host=127.0.0.1:47900&file=f&token=t".into(),
|
||||
url: String::new(),
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
|
||||
@@ -149,6 +149,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
let name = &d.doc.name;
|
||||
// In a live session: a mark after the name says whether others get in.
|
||||
let live = live_mark(app, d.uid);
|
||||
let lost = live_lost(app, d.uid);
|
||||
let dirty = d.is_dirty();
|
||||
let r = tab_rects[slot];
|
||||
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
|
||||
@@ -171,8 +172,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
let label = tab_label(name, live);
|
||||
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
|
||||
let label = if lost { trf("{name}, disconnected from its live session", &[("name", name)]) } else { tab_label(name, live) };
|
||||
let resp = if live.is_some() || lost { resp.on_hover_text(&label) } else { resp };
|
||||
crate::a11y::tab(&resp, &label, active);
|
||||
let progress = open_progress(&opening, d.uid, now);
|
||||
let tab = TabPaint {
|
||||
@@ -182,6 +183,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
width: w,
|
||||
state: [active, resp.hovered(), cresp.hovered(), dirty],
|
||||
live,
|
||||
lost,
|
||||
progress,
|
||||
};
|
||||
paint_tab(ui, &t, &tab);
|
||||
@@ -432,7 +434,7 @@ fn detach_tab(app: &mut FigApp, ui: &Ui, i: usize, at: Option<egui::Pos2>) {
|
||||
/// How wide document tab `i` wants to be: its name, the live mark and the unsaved dot.
|
||||
fn preferred_width(app: &FigApp, i: usize) -> f32 {
|
||||
let d = &app.session.docs[i];
|
||||
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
|
||||
let mark_w = if live_mark(app, d.uid).is_some() || live_lost(app, d.uid) { 18.0 } else { 0.0 };
|
||||
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
|
||||
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
|
||||
}
|
||||
@@ -457,14 +459,16 @@ struct TabPaint<'a> {
|
||||
state: [bool; 4],
|
||||
/// In a live session: whether others get in.
|
||||
live: Option<bool>,
|
||||
/// Disconnected from its live session.
|
||||
lost: bool,
|
||||
/// How far the opening animation is (0..=1).
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
/// Draws a document tab: background, icon, name, live mark, unsaved dot and close button.
|
||||
fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
|
||||
let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, progress } = *tab;
|
||||
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
|
||||
let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, lost, progress } = *tab;
|
||||
let mark_w = if live.is_some() || lost { 18.0 } else { 0.0 };
|
||||
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
|
||||
let painter = ui.painter().with_clip_rect(r);
|
||||
if active {
|
||||
@@ -487,8 +491,10 @@ fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
|
||||
t.text.linear_multiply(progress),
|
||||
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
|
||||
);
|
||||
if let Some(shared) = live {
|
||||
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
|
||||
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
|
||||
if lost {
|
||||
icons::paint(&painter, at, Icon::LiveDisconnected, t.danger.linear_multiply(progress));
|
||||
} else if let Some(shared) = live {
|
||||
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
|
||||
}
|
||||
if dirty {
|
||||
@@ -672,6 +678,12 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option<bool> {
|
||||
live.shared().filter(|_| live.doc_uid == Some(uid))
|
||||
}
|
||||
|
||||
/// Document `uid` is a live session's whose connection is gone: what happens in it reaches no one.
|
||||
pub fn live_lost(app: &FigApp, uid: u64) -> bool {
|
||||
let live = app.session.live_of(uid);
|
||||
live.disconnected() && live.doc_uid == Some(uid)
|
||||
}
|
||||
|
||||
/// Minimise, maximise/restore and close at the right end of the title bar; returns their width.
|
||||
fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 {
|
||||
const W: f32 = 46.0;
|
||||
|
||||
+15
-3
@@ -535,9 +535,21 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
`ws://<ip:port>/` and sends the token only inside its `Hello`. Links are pasted into the Join
|
||||
dialog (its field has the keyboard when it opens; Escape closes it), or opened from anywhere:
|
||||
see *Opening invite links* below.
|
||||
- Reconnect: a connection that drops after it was live reconnects with growing pauses (0.5 s,
|
||||
doubling up to 10 s, 12 attempts); a refusal (wrong token, full) or the host ending the session
|
||||
is final; a first join that can't reach the host fails at once. A reconnect resumes as the same
|
||||
- Reconnect: a connection that drops after it was live reconnects until you leave, with growing
|
||||
pauses (0.5 s, doubling up to 10 s, 12 attempts), then a minute's pause and as many again. A
|
||||
server turning a live session away is taken as passing (busy, a limit, a file it couldn't read
|
||||
just now) unless it says why in words that are for good: the link, the file or your place in it
|
||||
is gone, or the versions can't talk (`FINAL_REFUSALS` in `crates/collab-net/src/join.rs`). Those
|
||||
and the host ending the session are final; a first join that can't reach the host fails at once.
|
||||
Messages queued while offline go out paced, so they stay within what a server takes from a
|
||||
connection.
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again |
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
|
||||
|
||||
+4
-1
@@ -1478,7 +1478,10 @@ only: a host stops hosting it, a guest leaves it.
|
||||
see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}`
|
||||
leaves the active document's session (or ends a join started from it that is still on its way
|
||||
or failed), and `collab.leave {session}` the session with that id (from `sessions`), whichever
|
||||
document is active; the synced document stays open as your copy.
|
||||
document is active; the synced document stays open as your copy. `collab.reconnect {}` acts on
|
||||
the active document's lost session: a reconnect that is waiting tries now, and a session you
|
||||
joined that couldn't go on is joined again in its tab. A dropped connection keeps reconnecting
|
||||
until you leave (see *Reconnect* in [`collab-protocol.md`](collab-protocol.md)).
|
||||
- `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server`
|
||||
([`server.md`](server.md)) and syncs it from there (as that document's session; others go on): `server` is the address
|
||||
`figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Releases also ship it ready-built for Linux (`figcraft-server-<version>-linux-x8
|
||||
tells everyone it is restarting. Every change it accepted is on disk before anyone is told about
|
||||
it, so even a hard kill loses nothing confirmed (a file's log is then folded into a new snapshot
|
||||
the next time everyone leaves it). Apps that were in a file keep editing and reconnect once the
|
||||
server is back (for about a minute and a half), as the same participants.
|
||||
server is back, as the same participants.
|
||||
|
||||
## Publishing a file
|
||||
|
||||
|
||||
Reference in new issue
Block a user