diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b2b4d307..32f66b1b 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -18,6 +18,7 @@ use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg}; use figcraft_engine::live::{Event, Status}; +use std::time::Duration; // `std::time::Instant` on native builds; the browser's clock in the web build. use web_time::Instant; @@ -25,6 +26,7 @@ use crate::exchange::{self, Exchange, Then}; use crate::inbox::Inbox; use crate::invite::Endpoint; use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; +use crate::limits::NetLimits; use crate::requests::Publish; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -55,6 +57,13 @@ impl Conn { fn new(now: Instant) -> Self { Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now } } + + /// How long the host may stay silent. Until the welcome is whole, the document is arriving as + /// one message that doesn't count as heard before it is complete, so a large file on a slow + /// link gets the long reply timeout instead of the idle one. + fn silence_limit(&self, l: &NetLimits) -> Duration { + if self.welcomed { l.idle_timeout } else { l.request_reply_timeout } + } } /// A joined (or joining) session over a [`Socket`]. @@ -315,7 +324,7 @@ impl Driver { // A managing connection waits for each answer. (true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None], (true, false) => { - [Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }] + [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])) @@ -332,7 +341,7 @@ impl Driver { } let l = self.cfg.limits; let Some(c) = self.conn.as_ref() else { return }; - let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping); + let (opened, started, last_heard, last_ping, silence) = (c.opened, c.started, c.last_heard, c.last_ping, c.silence_limit(&l)); if !opened { if now >= started + self.cfg.connect_timeout { self.dropped(format!("no answer from {}", self.server().display()), now); @@ -345,7 +354,7 @@ impl Driver { } return; } - if now >= last_heard + l.idle_timeout { + 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))); diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index a94ff064..ea790074 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -184,6 +184,38 @@ fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() { assert!(pings >= 5, "a ping every 15 s ({pings})"); } +#[test] +fn a_welcome_still_downloading_gets_the_long_reply_timeout() { + let t0 = Instant::now(); + let (mut d, inbox) = start(Script::default(), t0); + d.opened(t0); + let limits = GuestConfig::new(invite(), "Ada".into()).limits; + // The welcome (the whole document) is one message that is not heard until complete: past the + // idle timeout the join must not be given up. + let past_idle = t0 + limits.idle_timeout + Duration::from_secs(1); + d.tick(past_idle); + assert!(!d.done()); + assert_eq!(d.sock.closes, 0); + assert_eq!(statuses(&inbox.drain()), vec![Status::Connecting]); + // It does arrive; from then on a silent host is dropped at the idle timeout. + d.message(&welcome(), past_idle); + let silent = past_idle + limits.idle_timeout; + d.tick(silent - ms(1)); + assert_eq!(d.sock.closes, 0); + d.tick(silent); + assert_eq!(d.sock.closes, 1); +} + +#[test] +fn a_host_that_never_welcomes_is_given_up_on_at_the_reply_timeout() { + let t0 = Instant::now(); + let (mut d, _inbox) = start(Script::default(), t0); + d.opened(t0); + let limit = t0 + GuestConfig::new(invite(), "Ada".into()).limits.request_reply_timeout; + d.tick(limit); + assert_eq!(d.sock.closes, 1); +} + #[test] fn a_silent_host_counts_as_dropped_and_the_client_reconnects() { let t0 = Instant::now(); diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 4ddef04d..cdc441f6 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -695,7 +695,9 @@ resuming with its offline edits, and leaving the session going back to snapshot (resuming as the same client after a drop), presence held until the welcome and changes made for an earlier connection dropped (the sync client sends them again once caught up), presence throttled to about 30 updates a second (latest wins), a `Ping` after 15 s without sending (browsers don't expose WebSocket ping frames; - the host answers `Pong`), a connection silent for 45 s counted as dropped, reconnects with the + the host answers `Pong`), a connection silent for 45 s counted as dropped (5 minutes before the + welcome: it carries the whole document as one message, which a large file on a slow link takes + a while to download, and nothing counts as heard until it is complete), reconnects with the same growing pauses after a live connection drops, refusals and the end of the session final, and a first join that can't reach the host failing at once. The browser layer only feeds it the socket's events and one `setTimeout` for its next deadline, and every arrival wakes egui