A viewer moving the pointer sends presence about 30 times a second, and every message sent put
the next ping off by the keep-alive interval, so no ping went out while presence did. The host
answers pings and nothing else to a client alone in a session (presence goes to the other peers),
so nothing was heard for the idle timeout (45 s): the client dropped a healthy connection and
reconnected, over and over. Seen in the browser build; the desktop app's guest has the same
timing (its reader times out the same way) and gets the same change.
Pings are now timed from the last ping, not from the last message sent: one goes out every 15 s
whatever else is on the wire. Regression test: 90 s of presence at 10 a second with the host
answering only the pings keeps the connection (it closed before the change).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A publish uploads a whole file; a large project on a slow link takes the
server a while to receive and answer. But the server's one-shot request
connections (manage, hosting, library, plugins) used the short request_idle
as their read timeout, so a slow upload could be cut off mid-processing. The
manage/hosting connections that carry uploads now wait the longer
request_reply_timeout (as the client already did), keeping the short idle
for the other one-shot requests and sessions.
Co-Authored-By: Claude <noreply@anthropic.com>
* E21: require release font coverage and stabilize capture deadline tests
* E21: separate capture, IME and field-save regression phases
* M0: correct the migration qualification reference
* M-egui037: document input lifecycle and UI regression contracts
* M0: correct egui 0.37 status docs and stabilize join-code regression
---------
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
On Windows a socket's read timeout counts whole milliseconds, so a read
bounded by the hello deadline can time out a moment before it.
`next_message` then took the timeout for a closed connection: the client
got no "no hello in time" refusal, and the socket closed only after the
2 s linger. `Duplex::read` now reads again until the deadline has passed.
Tests:
- `connections_without_a_hello_are_told_why_they_end` (new): twelve
connections that never say hello each get the refusal. Failed 5 of 10
runs without the fix.
- `pre_hello_lifetime` notes the end of the stream on a thread of its own
instead of reading one message per 300 ms poke, where pongs queued
ahead of the close delayed it.
- `quiet_guests_stay_and_silent_connections_are_dropped`: the guest takes
only the host's keepalive. Its 600 ms idle timeout also bounded the wait
for the welcome.
- `two_files_are_hosted_and_joined_independently`: the two joins race, so
the file whose welcome arrived last is in front. The test checks that,
then brings B to the front.
- `a_local_share_is_joined_by_its_code_and_edits_sync`: once the share
stops, another program's host can take the freed port and refuse the
code.
Publishing uploads a whole file, and a large project on a slow link takes
the server several minutes to receive and answer. The one-shot request
connections (hosting, the publish upload, a library, a plugin directory)
waited the session idle timeout (45s), so a large publish timed out with
"no answer from the server" before the server finished. Add a dedicated
request_reply_timeout (5m) for these connections, keeping the session idle
timeout unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Duplex::read_until gives a reader a deadline: each socket read waits at most what's left of it.
The server reads the handshake and hello under the hello deadline, so a hello trickled in
fragments can't outlast it; manage's close drains under CLOSE_DRAIN, so a silent server can't
hold it for the idle timeout.
read() returns Result<Message>, not a lock guard; these test loops
deliberately ignore what the client sent first.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
- 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.
Found while testing crates/collab-net/src/invite.rs:242 (percent_decode)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/collab-net/src/invite.rs:valid_host (around line 205)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Unrelated to the security changes in this branch; `main` already failed
`clippy --workspace --all-targets -- -D warnings` without them. Eight
clippy::nonminimal_bool sites across seven files, each taking clippy's own
suggested rewrite.
Every rewrite is pure boolean algebra with the negation kept outside, so the
float comparisons and their NaN behaviour are untouched: !A && !B becomes
!(A || B), and !opt.is_some_and(f) becomes opt.is_none_or(!f). Two sites needed
explicit parentheses the suggestion omitted, where the rewritten term is the left
operand of an && and || binds looser.
designcraft cleared the same lint in bc840130; this is figcraft's equivalent.
The hub kept the addresses removed from every file it ever had open. The
entry now goes when the file is closed (everyone left) or removed from the
server, so a long-running public server doesn't keep collecting them.
Right-click a person's avatar ▸ "Remove <name> from session", or
`collab.removePerson {client}`, when you host the session or own the file
on a server. The hub closes their connection as the end of the session for
them (close 1001: they keep their copy and don't reconnect) and bars their
address from that file while it runs, so the code or link they hold no
longer gets them in; everyone else stays connected.
Protocol addition within version 2: `Admin::RemovePeer {file, client}` on
an open hosting connection with the file's owner token (or the server
key). Older servers say they can't.
Tests: loopback (removed, can't rejoin, others stay), engine (host can,
guest can't, bad ids refused), codec round trip; the collab junk sweep
covers the new command.
The sessions test waited for a new layer to arrive and then asserted on its name, which the host sets in a second batch; it now waits for the name. Test only.
The recovery copy of one of your online files is no longer discarded when
the file reopens from its server. It is kept until the outcome is known:
- the server has everything in it: the copy goes;
- the copy has changes that never reached the server and nothing else
changed there: they are applied on top as one edit and sent to everyone;
- anything else (others changed the file, the server forgot the client):
the copy opens beside the file as "<name> (recovered copy)", with a toast;
- the server can't be reached: the copy opens as an unsaved document.
The note beside a copy is written after it and records who the app was in
the file and the server's last change it had. The app joins again as that
client (the first hello resumes; a new sync client numbers its batches
after the ones the server processed), so the layers it made are its own to
send. The old copy is removed only once this run has copied what it became.
collab.recoverFile; a tooltip on the tab mark; docs.
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.
The join field is the shared text input: as tall as the Join button beside it.
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.
Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.
collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.