cli/engine/transfer; the browser has its own in client/lib/transfer.
The sequence
The sender runs this for each file, in order:metadata, sender to receiver, naming the file and its size.ack, receiver back to sender, before any bytes move.- Binary chunks, sender to receiver, until the file is exhausted.
end, sender to receiver, closing the file off.
received comes once, after every file, and only
from a Go receiver.
Four things that order does not show.
- Step 3 is zero or more frames. An empty file is
metadata,ack,end, with no binary frame between them at all. - There is no cancel message. Canceling is a channel close, and a receiver declining at the CLI’s prompt closes the channel without sending anything. Every wait in the Go engine watches for that close as a separate signal and gives up at once. The browser sender does not: it sees a close only through its buffer-drain polls, so a decline reaches it as the full 120-second ack timeout instead.
- A peer that stops on purpose says why. Canceling is still a bare close, but a side that
refuses a transfer for a reason it can name sends that reason first, in an
incompatibleframe, and waits for it to flush before tearing the connection down. That covers the 2 GB relay cap on the sending side and a file the receiver would not keep on the other. See incompatible. - A person can sit between steps 1 and 2.
floe receiveprintsAccept? [Y/n]and waits for an answer before it sends the ack. That is the whole reason the ack deadline is 120 seconds. - The end of a batch is a race, not a message. A Go sender finishes on whichever comes
first: the
receivedframe, its send buffer reaching zero, or a full 60 seconds in which the buffer did not shrink at all.
How a frame is classified
There is no length prefix and no envelope. What a frame is comes from how it was sent, not from what is inside it. In the direction file data travels, sender to receiver, a frame is a control message if and only if it is a text frame. A binary frame is file data, whatever its bytes spell. That is the whole rule, and it is what lets a small.json file whose entire content is {"type":"end"}
arrive intact instead of being swallowed as an end marker.
A text frame is then read as a control message when both of these hold:
- It is at most 1000 bytes. A longer one is a protocol error and stops the transfer, rather than being written to a file.
- It parses as JSON with a
typefield naming a message below.
ack, received and incompatible as binary, and a browser receiver sends ack as a
string and incompatible as binary.
If you are building another client, one rule falls out of all of this: every frame you send to
a receiver that is not file data has to be a text frame. A binary one lands in somebody’s file.
One implementation detail worth knowing, because it is the one place the two disagree. The Go
receiver skips leading whitespace before looking for the opening {; the browser requires the
frame to start with it. So a control message padded with leading spaces is read by a Go receiver
and treated as file data by a browser receiver. Nothing in Floe sends one.
Message types
metadata
Sender to receiver, before each file.metadata
string
required
A per-file identifier. The ack echoes it.
string
required
The name as the sender sees it, including any relative path when a folder is being sent. The
receiver sanitizes it before writing.
integer
required
The file’s length in bytes. The receiver checks the bytes it wrote against this value.
integer
required
Position in the batch, 1-based.
integer
required
How many files are in the batch.
integer
required
The batch’s total size. Older senders may send
0 for a single file, in which case a receiver
substitutes fileSize.integer
Highest protocol version this peer speaks. Absent on releases before v1.6.0, which are treated
as version 1.
integer
Lowest protocol version this peer still supports.
string
The human release string, and its shape depends on the build. The CLI sends
1.10.11 with no
leading v, because GoReleaser strips it. The desktop app sends the tag verbatim, as
desktop-v0.2.12. A build from source sends dev. Browser peers omit the field entirely. It is
informational and never gates a transfer.ack
Receiver to sender, once per file, before any bytes move.ack
integer
required
Where the sender should start reading. Reserved for mid-file resume: both senders honor a
non-zero value and seek to it. The Go receiver hard-codes
0. The browser receiver would reply
with what it already holds if a second metadata arrived for a file id it had buffered bytes
for, but no current sender ever reuses an id that way, and no resume state is kept on disk or
across connections. Against any current sender it is always 0.Binary chunks
Raw bytes, sent until the file is exhausted. Chunk size is a local sender choice and not part of the protocol; every receiver handles any size. Both implementations size chunks to the connection’s negotiated maximum message size, capped at 256 KB. When that size is unavailable they fall back to different defaults: 16 KB in the Go engine, 64 KB in the browser. The browser reads the source file in 4 MB slabs and sub-slices those into chunks. The Go sender reads straight into a chunk-sized buffer.end
Sender to receiver, after the last chunk of a file.end
received
Receiver to sender, once, after every file in the batch is written.received
incompatible
Two jobs, told apart by thepv range it carries.
A version mismatch. Sent by the receiver in place of the ack when the two protocol version
ranges do not overlap. The receiver aborts before creating any file, so the sender fails fast
rather than waiting for an ack that never comes. Senders that predate this message type ignore it
safely.
A deliberate abort with a reason, when the pv range does overlap. A peer reads the
overlap as “this is not about versions” and prints the reason verbatim instead of rebuilding a
version remedy from pv and pvMin. That is what lets a side that is stopping on purpose name
the cause without a new message type and without a ProtocolVersion bump. It carries the relay
cap and a source file that changed under the send from a sender, and a discarded file, a
sender that overran its announced size, or a refused file description from a receiver.
Framing depends on direction, and this is the one rule another client has to get right.
Receiver to sender is binary, which is what every Floe receiver has always sent. Sender to
receiver has to be text, because on that path a binary frame is file data by definition, and
Go receivers from v1.1.0 to v1.5.5 will write a binary one into the file being received.
The whole encoded frame stays within 1000 bytes, reason included, or a browser receiver stops
reading it as a control message. Whichever side displays it caps the reason again, at 300
characters.
Both Go peers wait for the send buffer to drain, up to 2 seconds, before closing, and so does a
browser sender. Without that wait the frame is routinely lost: closing a pion connection aborts
the SCTP association and discards whatever was still queued, so the peer would see the close and
report a lost connection rather than the reason. A browser receiver sends without waiting,
because it does not close the connection afterwards.
incompatible
Protocol versioning
The wire protocol carries a version of its own, independent of Floe’s release numbers. That is what lets peers on different releases interoperate while still catching a genuine breaking change cleanly.- Each peer advertises
pv, the highest version it speaks, andpvMin, the lowest it still supports. Both are 1 today. - Two peers are compatible when their ranges overlap:
max(localMin, remoteMin) <= min(localMax, remoteMax). They operate at the highest common version. - A peer that omits the fields is treated as version 1, so every release ever shipped stays compatible.
- When the ranges miss, the receiver sends
incompatibleand each side prints a remedy for its own surface:floe updateon the CLI, the Microsoft Store or the download page on the desktop, a page refresh in the browser.
reason for a
version mismatch: both implementations rebuild the message locally from the frame’s pv and
pvMin, so the side they name is always correct. Both receivers also word the reason they put
on the wire from the recipient’s point of view, in words that name no surface, for peers that
display it verbatim. When a message names a remedy that makes no sense for your surface, the peer
that sent it is older than this convention, so compare the two version strings it prints and
update the older one.
Constants live in cli/engine/transfer/protocol.go as ProtocolVersion and MinProtocolVersion,
and in client/lib/transfer/protocol.ts as PROTOCOL_VERSION and MIN_PROTOCOL_VERSION. Bump
ProtocolVersion only on a breaking wire change, and raise MinProtocolVersion only when
deliberately dropping support for an old format. Keep the two implementations in step. A test on
each side pins both numbers and names its twin (TestProtocolVersionPinnedToClient in
cli/engine/transfer/protocol_test.go, the constants block in
client/lib/transfer/protocol.test.ts), so a bump cannot land on one side without a failing test
pointing at the other.
Flow control
The sender pauses when the SCTP send buffer reaches 8 MB and resumes once it drains below 4 MB. Both implementations use the same two numbers. If the buffer has not shrunk at all after 60 seconds, the Go sender gives up on the transfer. A wait that is merely slow does not trigger it; only a buffer that never moves does. The browser adds two drains the Go engine does not have: it waits for the buffer to fall below 64 KB before sending each file’s metadata, and it drains to zero before declaring the batch complete.Integrity
A file is committed only when the bytes written matchfileSize exactly. A mismatch in either
direction discards the file, because a short count means truncation and an over-count means a
frame boundary was wrong, which corrupts the result just as thoroughly.
The sender holds itself to the same number. It reads at most fileSize bytes, so a
file that grows after its size is announced cannot overrun the announcement, and if
the file changed on disk in that window the sender fails locally and sends no end
marker, so the receiver is told the file changed rather than left to report a byte count that
reads like a network fault.
Floe computes no hash of file contents at any point. The check is a byte count. The transport
already authenticates every packet, so corruption in flight is not the gap being covered here;
truncation is.
The two implementations disagree on one input. If a peer announces no usable fileSize, the
browser skips the check and keeps whatever arrived, while the Go receiver reads the missing size
as 0 and therefore discards the file. Only a peer predating the field can produce that, and none
has shipped in a long time.
Receivers that write to disk stage each file under a name ending in .part and rename it only
after that check passes, so a killed transfer never leaves a file sitting under its final name.
See Known limitations.
Timeouts
Every value the Go engine enforces. The browser shares two of them: the 120-second ack deadline and the 2-second wait for an abort reason to reach the wire.
One deadline sits outside the engine.
floe send and floe receive give the server 20 seconds
to answer join-room before failing with timed out waiting for the server to assign a role.
Floe Desktop has always used the same 20 seconds.
A browser sender waits about 2 seconds after the peers connect before it checks the route and
sends the first byte. A floe receive can linger for up to about 10 seconds after printing its
summary, which is the teardown grace plus the report.
One SDP detail worth knowing
Pion, the WebRTC stack behind the CLI and the desktop app, omitsa=max-message-size from the
answer it generates. RFC 8841 makes the absent default 65536, and Chrome then rejects every chunk
larger than that. The Go engine patches the attribute into the answer with a value of 1 GB before
sending it, which is what makes browser-to-CLI transfers work at all.
If you are building another client against Floe, this is the non-obvious part.
Related
HTTP API
The signaling server’s endpoints, up to the point where this protocol takes over.
Architecture
Components, transports, and the shape of a whole session.