Live join: a large Welcome still downloading is not given up on at the idle timeout (#1664)

This commit is contained in:
MarekSpodBiedry
2026-10-30 16:38:36 +01:00
parent 4f5a25cc82
commit 0ebe626062
3 changed files with 47 additions and 4 deletions
+12 -3
View File
@@ -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<S: Socket> Driver<S> {
// 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<S: Socket> Driver<S> {
}
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<S: Socket> Driver<S> {
}
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)));
+32
View File
@@ -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();
+3 -1
View File
@@ -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