From fbb4a22e01893b815073fc2917c0882058c655d2 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:12:13 +0530 Subject: [PATCH 1/6] fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857) - Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination. - Set core max_message_bytes in local live session HostConfig to max_server_message. - Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'. - Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun. --- crates/collab-net/src/guest.rs | 12 ++++- crates/collab-net/src/hub.rs | 20 ++++---- crates/collab-net/src/link.rs | 2 +- crates/collab-net/src/loopback_tests.rs | 66 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index eb899b8d..1cfd784c 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -367,7 +367,17 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()), + Err(tungstenite::Error::Capacity(_)) => { + let max = ctx.cfg.limits.max_server_message; + let limit = if max >= 1 << 20 { + format!("{} MiB", max >> 20) + } else if max >= 1 << 10 { + format!("{} KiB", max >> 10) + } else { + format!("{max} bytes") + }; + break Outcome::Failed(format!("received message exceeded size limit (max {limit})")); + } Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..8ddde6e2 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -860,7 +860,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; - let cfg = config(limits.max_client_message); + let write_cfg = config(limits.max_server_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). #[allow(clippy::result_large_err)] @@ -904,14 +904,16 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit max_block: limits.max_queued_block, max_all: limits.max_queued_total, }; - let writer = - match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; + let writer = match sock + .try_clone() + .map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(write_cfg)), &rx, &s))) + { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; let end = |why: Option<&str>| { if let Some(why) = why { refuse(&out, why); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index a84395aa..66d07477 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -139,7 +139,7 @@ impl LiveLink for NetLink { doc, name: o.name, limits: self.limits, - core: figcraft_collab::Limits::default(), + core: figcraft_collab::Limits { max_message_bytes: self.limits.max_server_message, ..figcraft_collab::Limits::default() }, }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; let invite = Invite::new(host.addr().to_string(), file, token); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 32c86bd0..9d86939c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -642,3 +642,69 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } + +#[test] +fn welcome_messages_larger_than_client_message_limit_are_delivered() { + let mut doc = Document::new("A".repeat(3000)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_client_message: 1024, max_server_message: 64 << 10, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = limits; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("welcome", welcomed); + assert!(welcomed(&gp.events)); + host.stop(); +} + +#[test] +fn oversized_server_message_reports_accurate_diagnostic() { + let mut doc = Document::new("B".repeat(3000)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_server_message: 64 << 10, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = NetLimits { max_server_message: 512, ..fast_limits() }; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("accurate failure diagnostic", |e| { + e.iter().any(|ev| { + matches!( + ev, + Event::Status(Status::Failed { reason }) + if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 512 bytes)") + ) + }) + }); + assert!(!welcomed(&gp.events)); + host.stop(); +} From 227e182f6889e8f42f7527f0fb79731c7d95419c Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:23:09 +0530 Subject: [PATCH 2/6] fix(collab-net): report exact bytes when server message limit is not a whole unit --- crates/collab-net/src/guest.rs | 4 ++-- crates/collab-net/src/loopback_tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 1cfd784c..7a986ea5 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -369,9 +369,9 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(_) => {} Err(tungstenite::Error::Capacity(_)) => { let max = ctx.cfg.limits.max_server_message; - let limit = if max >= 1 << 20 { + let limit = if max >= 1 << 20 && max.is_multiple_of(1 << 20) { format!("{} MiB", max >> 20) - } else if max >= 1 << 10 { + } else if max >= 1 << 10 && max.is_multiple_of(1 << 10) { format!("{} KiB", max >> 10) } else { format!("{max} bytes") diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 9d86939c..eebda6de 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -693,7 +693,7 @@ fn oversized_server_message_reports_accurate_diagnostic() { let guest_probe = Probe::new(); let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = NetLimits { max_server_message: 512, ..fast_limits() }; + guest_cfg.limits = NetLimits { max_server_message: 1536, ..fast_limits() }; let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); let mut gp = guest_probe; gp.wait("accurate failure diagnostic", |e| { @@ -701,7 +701,7 @@ fn oversized_server_message_reports_accurate_diagnostic() { matches!( ev, Event::Status(Status::Failed { reason }) - if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 512 bytes)") + if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 1536 bytes)") ) }) }); From e8a6033199c6fc6cbe12bccd580d58e63d2b6f2e Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:33:32 +0530 Subject: [PATCH 3/6] test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB --- crates/collab-net/src/loopback_tests.rs | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index eebda6de..b59f767c 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -708,3 +708,33 @@ fn oversized_server_message_reports_accurate_diagnostic() { assert!(!welcomed(&gp.events)); host.stop(); } + +#[test] +#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] +fn welcome_larger_than_64_mib_is_delivered() { + let mut doc = Document::new("A".repeat((64 << 20) + 4096)); + doc.repair(); + let probe = Probe::new(); + let limits = NetLimits { max_client_message: 1024, max_server_message: 128 << 20, ..fast_limits() }; + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits, + core: Limits { max_message_bytes: 128 << 20, ..Limits::default() }, + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let guest_probe = Probe::new(); + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = limits; + let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); + let mut gp = guest_probe; + gp.wait("welcome", welcomed); + assert!(welcomed(&gp.events)); + host.stop(); +} From 6369b6b7a5fa2c073e23cc67328bb48e8a7a92bd Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Tue, 20 Oct 2026 01:43:50 +0530 Subject: [PATCH 4/6] test(collab-net): exercise NetLink host in large welcome regression test --- crates/collab-net/src/loopback_tests.rs | 30 ------------------------- crates/collab-net/src/session_tests.rs | 16 +++++++++++++ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index b59f767c..eebda6de 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -708,33 +708,3 @@ fn oversized_server_message_reports_accurate_diagnostic() { assert!(!welcomed(&gp.events)); host.stop(); } - -#[test] -#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] -fn welcome_larger_than_64_mib_is_delivered() { - let mut doc = Document::new("A".repeat((64 << 20) + 4096)); - doc.repair(); - let probe = Probe::new(); - let limits = NetLimits { max_client_message: 1024, max_server_message: 128 << 20, ..fast_limits() }; - let cfg = HostConfig { - addr: SocketAddr::from(([127, 0, 0, 1], 0)), - any_port_if_taken: false, - file: FILE.into(), - token: TOKEN.into(), - doc, - name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 128 << 20, ..Limits::default() }, - }; - let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); - let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - - let guest_probe = Probe::new(); - let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = limits; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("welcome", welcomed); - assert!(welcomed(&gp.events)); - host.stop(); -} diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index 32920123..de60b211 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -249,3 +249,19 @@ fn cursor_chat_and_spotlight_between_apps() { pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none()); pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none()); } + +#[test] +#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] +fn welcome_larger_than_64_mib_is_delivered() { + let mut host = session("Hana"); + let mut guest = session("Ada"); + host.doc_mut().unwrap().name = "A".repeat((64 << 20) + 4096); + let r = host.execute("collab.host", &json!({"port": 0})).unwrap(); + assert_eq!(r["status"], "live"); + let link = r["link"].as_str().unwrap().to_string(); + let r = guest.execute("collab.join", &json!({"link": link})).unwrap(); + assert_eq!(r["status"], "connecting"); + pump(&mut host, &mut guest, "the large welcome", |h, g| g.live().status == Status::Live && h.live().peers.len() == 1); + assert_eq!(guest.doc().unwrap().name.len(), (64 << 20) + 4096); + host.execute("collab.stop", &json!({})).unwrap(); +} From 89529b3d0035a3835f6282873243ae02e2985bee Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:22:26 +0700 Subject: [PATCH 5/6] #889: drop the hub write-cap and core-limit changes (neither is read) --- crates/collab-net/src/hub.rs | 20 +++++++++----------- crates/collab-net/src/link.rs | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 8ddde6e2..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -860,7 +860,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; - let write_cfg = config(limits.max_server_message); + let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). #[allow(clippy::result_large_err)] @@ -904,16 +904,14 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit max_block: limits.max_queued_block, max_all: limits.max_queued_total, }; - let writer = match sock - .try_clone() - .map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(write_cfg)), &rx, &s))) - { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; + let writer = + match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; let end = |why: Option<&str>| { if let Some(why) = why { refuse(&out, why); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 66d07477..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -139,7 +139,7 @@ impl LiveLink for NetLink { doc, name: o.name, limits: self.limits, - core: figcraft_collab::Limits { max_message_bytes: self.limits.max_server_message, ..figcraft_collab::Limits::default() }, + core: figcraft_collab::Limits::default(), }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; let invite = Invite::new(host.addr().to_string(), file, token); From cc4799061e240a17aca12174114bafe70b4d9343 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 04:38:28 +0700 Subject: [PATCH 6/6] #889: say a join is too large in words for people (native and web), translated; keep one test --- crates/collab-net/src/driver.rs | 5 ++- crates/collab-net/src/driver_tests.rs | 11 ++++- crates/collab-net/src/guest.rs | 14 +----- crates/collab-net/src/join.rs | 8 +++- crates/collab-net/src/loopback_tests.rs | 58 +++++-------------------- crates/collab-net/src/session_tests.rs | 16 ------- crates/engine/src/live.rs | 4 ++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/i18n_tests.rs | 9 ++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/live.rs | 14 ++++-- 12 files changed, 60 insertions(+), 83 deletions(-) diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b8f6c154..4ebbec15 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status}; use web_time::Instant; use crate::inbox::Inbox; -use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// A WebSocket the driver opens, writes and closes; what it receives comes back through the @@ -114,6 +114,9 @@ impl Driver { /// A binary message from the host. pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { let Some(c) = self.conn.as_mut() else { return }; + if bytes.len() > self.cfg.limits.max_server_message { + return self.end(too_large(&self.cfg.limits), now); + } c.last_heard = now; let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { // The join code's answer: join the file it opens, on a new connection. diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ca6563c5..5e395b98 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, Status}; +use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() { assert!(d.done()); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused"))); } + // A message over the size limit: the file is too large for this app, not refused by the host. + let inbox = Inbox::new(Arc::new(|| {})); + let mut cfg = GuestConfig::new(invite(), "Ada".into()); + cfg.limits.max_server_message = 2 << 20; + let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0); + d.opened(t0); + d.message(&vec![0; (2 << 20) + 1], t0); + assert!(d.done()); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2"))); } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index 7a986ea5..2e1d5046 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; pub use crate::join::GuestConfig; -use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::manage; use crate::net::{Duplex, NetLimits, config, open, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -367,17 +367,7 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => { - let max = ctx.cfg.limits.max_server_message; - let limit = if max >= 1 << 20 && max.is_multiple_of(1 << 20) { - format!("{} MiB", max >> 20) - } else if max >= 1 << 10 && max.is_multiple_of(1 << 10) { - format!("{} KiB", max >> 10) - } else { - format!("{max} bytes") - }; - break Outcome::Failed(format!("received message exceeded size limit (max {limit})")); - } + Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits), Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 81cd2437..cc3be839 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status}; +use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { }) } +/// The host sent a message over this app's size limit (the file's welcome, usually): final, and +/// not the host's doing. +pub(crate) fn too_large(limits: &NetLimits) -> Outcome { + Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) +} + /// The server's answer to the join code: the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index eebda6de..a043d8c7 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, PEOPLE, Status}; +use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -644,11 +644,10 @@ fn the_host_removes_someone_and_they_cant_come_back() { } #[test] -fn welcome_messages_larger_than_client_message_limit_are_delivered() { - let mut doc = Document::new("A".repeat(3000)); +fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { + let mut doc = Document::new("B".repeat(1 << 20)); doc.repair(); let probe = Probe::new(); - let limits = NetLimits { max_client_message: 1024, max_server_message: 64 << 10, ..fast_limits() }; let cfg = HostConfig { addr: SocketAddr::from(([127, 0, 0, 1], 0)), any_port_if_taken: false, @@ -656,55 +655,18 @@ fn welcome_messages_larger_than_client_message_limit_are_delivered() { token: TOKEN.into(), doc, name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, + limits: fast_limits(), + core: Limits::default(), }; let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - let guest_probe = Probe::new(); let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = limits; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("welcome", welcomed); - assert!(welcomed(&gp.events)); - host.stop(); -} - -#[test] -fn oversized_server_message_reports_accurate_diagnostic() { - let mut doc = Document::new("B".repeat(3000)); - doc.repair(); - let probe = Probe::new(); - let limits = NetLimits { max_server_message: 64 << 10, ..fast_limits() }; - let cfg = HostConfig { - addr: SocketAddr::from(([127, 0, 0, 1], 0)), - any_port_if_taken: false, - file: FILE.into(), - token: TOKEN.into(), - doc, - name: "Hana".into(), - limits, - core: Limits { max_message_bytes: 64 << 10, ..Limits::default() }, - }; - let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); - let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); - - let guest_probe = Probe::new(); - let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); - guest_cfg.limits = NetLimits { max_server_message: 1536, ..fast_limits() }; - let _g = Guest::start(guest_cfg, guest_probe.inbox.clone()).unwrap(); - let mut gp = guest_probe; - gp.wait("accurate failure diagnostic", |e| { - e.iter().any(|ev| { - matches!( - ev, - Event::Status(Status::Failed { reason }) - if !reason.contains("The host refused") && reason.contains("received message exceeded size limit (max 1536 bytes)") - ) - }) - }); + guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() }; + let mut gp = Probe::new(); + let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap(); + let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1"); + gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large))); assert!(!welcomed(&gp.events)); host.stop(); } diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index de60b211..32920123 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -249,19 +249,3 @@ fn cursor_chat_and_spotlight_between_apps() { pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none()); pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none()); } - -#[test] -#[ignore = "allocates a document larger than 64 MiB: cargo test -p figcraft-collab-net -- --ignored"] -fn welcome_larger_than_64_mib_is_delivered() { - let mut host = session("Hana"); - let mut guest = session("Ada"); - host.doc_mut().unwrap().name = "A".repeat((64 << 20) + 4096); - let r = host.execute("collab.host", &json!({"port": 0})).unwrap(); - assert_eq!(r["status"], "live"); - let link = r["link"].as_str().unwrap().to_string(); - let r = guest.execute("collab.join", &json!({"link": link})).unwrap(); - assert_eq!(r["status"], "connecting"); - pump(&mut host, &mut guest, "the large welcome", |h, g| g.live().status == Status::Live && h.live().peers.len() == 1); - assert_eq!(guest.doc().unwrap().name.len(), (64 << 20) + 4096); - host.execute("collab.stop", &json!({})).unwrap(); -} diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index ed13de5f..cd8a0f03 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -86,6 +86,10 @@ pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { Some((byte(0)?, byte(2)?, byte(4)?)) } +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + /// The connection to a live session. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf833be..ae566fc8 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1393,6 +1393,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1187c658..596feda6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1394,6 +1394,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index fcf21fde..046212c1 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> { v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); + v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN); // The starter templates' names, descriptions and kinds. v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v @@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() { assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); } +#[test] +fn a_live_failure_the_ui_knows_is_read_back_for_translation() { + use figcraft_engine::live::TOO_LARGE_TO_JOIN; + let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024"); + assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason)); + assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_))); +} + #[test] fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84aedd6a..8495c30d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1510,7 +1510,7 @@ impl FigApp { // What happened in a live session (the network woke us for it). if self.session.live_poll() { if let Some(m) = self.session.message.take() { - self.toast(m); + self.toast(live::failure_text(&m)); } self.background_done(&ctx); } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 513060ab..a4baa303 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -13,10 +13,11 @@ //! and it fades a few seconds after the last change (frames are asked for only until then). //! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. +use std::borrow::Cow; use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page}; use serde_json::{Value, json}; use crate::FigApp; @@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { (Status::Reconnecting { attempt }, _) => { (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } - (Status::Failed { .. }, _) => (status.label(), t.danger), + (Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger), _ => (tr(&status.label()).to_string(), t.text_secondary), }; if !line.is_empty() { @@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { match app.session.live.get(session).map(|l| &l.status) { Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary), - Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger), + Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger), _ => {} } } +/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's +/// English otherwise). +pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { + let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after)); + mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)]))) +} + /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {