mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
Document that an empty datagram sent just before a close reads as the close on Linux
The native API's receive rule cannot tell a zero-length datagram that is the last message before a close from the close itself on SOCK_SEQPACKET, which Linux uses: reading it drains the queue, and every observation then matches a bare end of file. macOS and FreeBSD carry the flow on SOCK_DGRAM, where the empty datagram is delivered and the close is reported by the read after it. The doc comment on Received::Datagram said an empty datagram is never a close, which contradicted the limitation stated a few hundred lines below it. It now says where the exception applies, and the recv_once rationale, the FipsStream::recv rustdoc, the native API reference and the client how-to scope the limitation to Linux. The reference page's list of places where data disappears gains the send-side consequence: a program that sends an empty datagram and then drops its stream may have the daemon read it as the close, so it never reaches the peer. The datagram how-to, which counts those places, is updated to match. A new test pins the behaviour per socket type, branching on the module's socket-type constant rather than on the OS, so the documentation and the test cannot drift apart.
This commit is contained in:
@@ -286,10 +286,11 @@ port from one dropped because a client was not reading fast enough.
|
|||||||
For the response shape, see
|
For the response shape, see
|
||||||
[../reference/control-socket.md](../reference/control-socket.md#read-only-queries).
|
[../reference/control-socket.md](../reference/control-socket.md#read-only-queries).
|
||||||
|
|
||||||
Reach for this when datagrams go missing. **Four places lose data with
|
Reach for this when datagrams go missing. **Five places lose data with
|
||||||
nothing reported to your program**: a full per-flow queue, a listener that
|
nothing reported to your program**: a full per-flow queue, a listener that
|
||||||
does not accept fast enough, an outbound datagram sent before a session
|
does not accept fast enough, an outbound datagram sent before a session
|
||||||
exists, and an outbound datagram after the transport MTU has fallen.
|
exists, an outbound datagram after the transport MTU has fallen, and, on
|
||||||
|
Linux, an empty datagram sent just before a close.
|
||||||
[../reference/native-api.md](../reference/native-api.md#where-data-disappears)
|
[../reference/native-api.md](../reference/native-api.md#where-data-disappears)
|
||||||
describes each and what bounds it.
|
describes each and what bounds it.
|
||||||
|
|
||||||
|
|||||||
@@ -85,13 +85,15 @@ that costs a real payload if you get it wrong. A client that sends an empty
|
|||||||
datagram, then a message, then closes, leaves both queued, and a reader that
|
datagram, then a message, then closes, leaves both queued, and a reader that
|
||||||
trusts `POLLHUP` alone discards the message.
|
trusts `POLLHUP` alone discards the message.
|
||||||
|
|
||||||
**One case has no answer, and you should design around it rather than solve
|
**One case has no answer on Linux, where the pair is `SOCK_SEQPACKET`, and you
|
||||||
it.** A zero-length datagram that is the last message before a close is
|
should design around it rather than solve it.** A zero-length datagram that is
|
||||||
indistinguishable from the close: reading it drains the queue, and `FIONREAD`
|
the last message before a close is indistinguishable from the close: reading it
|
||||||
then reports zero because a zero-length message contributes no bytes. If your
|
drains the queue, and `FIONREAD` then reports zero because a zero-length message
|
||||||
protocol gives a zero-length payload a meaning, do not send it as a zero-length
|
contributes no bytes. If your protocol gives a zero-length payload a meaning, do
|
||||||
socket message. Carry a one-byte discriminator, and keep the zero-byte read for
|
not send it as a zero-length socket message. Carry a one-byte discriminator, and
|
||||||
end of file alone.
|
keep the zero-byte read for end of file alone. On macOS and FreeBSD the pair is
|
||||||
|
`SOCK_DGRAM`: the empty datagram reads as zero bytes, and the close is reported
|
||||||
|
by the read after it.
|
||||||
|
|
||||||
Both directions of the mistake are real. Reading an empty datagram as a close
|
Both directions of the mistake are real. Reading an empty datagram as a close
|
||||||
lets a peer tear down a live flow by sending nothing, and presents as a
|
lets a peer tear down a live flow by sending nothing, and presents as a
|
||||||
|
|||||||
@@ -260,13 +260,15 @@ because it latches while messages are still queued: a read that trusted it
|
|||||||
would report the close early and discard whatever was waiting. The `EPIPE` that
|
would report the close early and discard whatever was waiting. The `EPIPE` that
|
||||||
`recv` returns means the daemon went away, never that a peer finished.
|
`recv` returns means the daemon went away, never that a peer finished.
|
||||||
|
|
||||||
**One case is reported as `EPIPE` although it is a datagram.** A zero-length
|
**On Linux, one case is reported as `EPIPE` although it is a datagram.** A
|
||||||
datagram that is the last message before a close is indistinguishable from the
|
zero-length datagram that is the last message before a close is
|
||||||
close, because reading it drains the queue and `FIONREAD` then reports zero: a
|
indistinguishable from the close, because reading it drains the queue and
|
||||||
zero-length message contributes no bytes. Do not give a zero-length payload a
|
`FIONREAD` then reports zero: a zero-length message contributes no bytes. macOS
|
||||||
meaning of its own on this API. Carry a one-byte discriminator instead.
|
and FreeBSD carry the flow on `SOCK_DGRAM`, where the datagram is delivered as
|
||||||
Separating the two needs a payload that is never zero bytes on the wire, which
|
`Ok(0)` and the close follows it. Do not give a zero-length payload a meaning
|
||||||
is a protocol change rather than a receive-path one.
|
of its own on this API. Carry a one-byte discriminator instead. Separating the
|
||||||
|
two needs a payload that is never zero bytes on the wire, which is a protocol
|
||||||
|
change rather than a receive-path one.
|
||||||
|
|
||||||
**`peer_addr()`** and **`local_addr()`** return `FipsAddr`, not
|
**`peer_addr()`** and **`local_addr()`** return `FipsAddr`, not
|
||||||
`io::Result<FipsAddr>`, unlike their `TcpStream` counterparts. These are field
|
`io::Result<FipsAddr>`, unlike their `TcpStream` counterparts. These are field
|
||||||
@@ -438,7 +440,7 @@ There is no acknowledgement, no retransmission, no ordering guarantee and no
|
|||||||
flow control between the two ends. A program that needs confirmation gets it
|
flow control between the two ends. A program that needs confirmation gets it
|
||||||
from the peer, in the payload.
|
from the peer, in the payload.
|
||||||
|
|
||||||
Four places lose data with nothing reported to the client.
|
Five places lose data with nothing reported to the client.
|
||||||
|
|
||||||
**A full per-flow queue.** Inbound datagrams beyond `pending_per_flow` are
|
**A full per-flow queue.** Inbound datagrams beyond `pending_per_flow` are
|
||||||
dropped with a trace log and no client-visible signal. A program that stops
|
dropped with a trace log and no client-visible signal. A program that stops
|
||||||
@@ -467,6 +469,14 @@ snapshot taken at setup. The daemon re-checks each outbound datagram against
|
|||||||
the node's current limit and drops it silently if the transport MTU has since
|
the node's current limit and drops it silently if the transport MTU has since
|
||||||
fallen.
|
fallen.
|
||||||
|
|
||||||
|
**An empty datagram sent just before a close, on Linux.** A program that sends
|
||||||
|
a zero-length datagram and then drops its `FipsStream` may have the daemon read
|
||||||
|
that datagram as the close: the daemon frees the flow and the empty datagram
|
||||||
|
never reaches the peer. In the other direction, an empty datagram from the peer
|
||||||
|
that the daemon delivers immediately before its own half of the flow closes,
|
||||||
|
as when the daemon stops, reaches `recv` as `EPIPE` rather than `Ok(0)`. macOS
|
||||||
|
and FreeBSD are not affected. See `recv` above.
|
||||||
|
|
||||||
### The drop causes, and what they mean
|
### The drop causes, and what they mean
|
||||||
|
|
||||||
An inbound datagram can be refused for eight reasons, which render as seven
|
An inbound datagram can be refused for eight reasons, which render as seven
|
||||||
|
|||||||
@@ -488,10 +488,12 @@ impl FipsStream {
|
|||||||
/// [`bytes_queued`](super::seqpacket::bytes_queued) reports nothing behind
|
/// [`bytes_queued`](super::seqpacket::bytes_queued) reports nothing behind
|
||||||
/// it. The daemon half applies the identical rule, on the same pair.
|
/// it. The daemon half applies the identical rule, on the same pair.
|
||||||
///
|
///
|
||||||
/// One case survives both checks: a zero-length datagram that is the last
|
/// One case survives both checks on Linux, where the pair is
|
||||||
/// message before the close is indistinguishable from the close, because
|
/// `SOCK_SEQPACKET`: a zero-length datagram that is the last message before
|
||||||
/// reading it drains the queue and a zero-length message contributes no
|
/// the close is indistinguishable from the close, because reading it drains
|
||||||
/// bytes. Do not give a zero-length payload a meaning of its own.
|
/// the queue and a zero-length message contributes no bytes, so it is
|
||||||
|
/// reported as `EPIPE`. On macOS and FreeBSD it is delivered as `Ok(0)` and
|
||||||
|
/// the close follows. Do not give a zero-length payload a meaning of its own.
|
||||||
///
|
///
|
||||||
/// A datagram longer than `buf` is truncated and the remainder discarded,
|
/// A datagram longer than `buf` is truncated and the remainder discarded,
|
||||||
/// which is `SOCK_SEQPACKET` behaviour. Size `buf` at
|
/// which is `SOCK_SEQPACKET` behaviour. Size `buf` at
|
||||||
|
|||||||
+55
-8
@@ -46,7 +46,11 @@ use tokio::io::unix::AsyncFd;
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Received {
|
pub enum Received {
|
||||||
/// A datagram of this many bytes. Zero is a legitimate value: a client may
|
/// A datagram of this many bytes. Zero is a legitimate value: a client may
|
||||||
/// send an empty datagram, and that is not the same as closing.
|
/// send an empty datagram, and while it keeps its half open that is not the
|
||||||
|
/// same as closing. On Linux, where the pair is `SOCK_SEQPACKET`, an empty
|
||||||
|
/// datagram that is the last thing a client sends before it closes reads as
|
||||||
|
/// [`Received::Eof`] instead; `recv_once` says why that one case cannot be
|
||||||
|
/// told apart.
|
||||||
Datagram(usize),
|
Datagram(usize),
|
||||||
/// The peer closed its half of the pair.
|
/// The peer closed its half of the pair.
|
||||||
Eof,
|
Eof,
|
||||||
@@ -308,14 +312,18 @@ impl Seqpacket {
|
|||||||
/// message behind it is never taken.
|
/// message behind it is never taken.
|
||||||
///
|
///
|
||||||
/// This matters because reading an empty datagram as a close would let a client
|
/// This matters because reading an empty datagram as a close would let a client
|
||||||
/// tear down its own flow by sending nothing, and the defect would present as a
|
/// tear down its own flow by sending nothing while it is still connected, and the
|
||||||
/// spurious disconnect.
|
/// defect would present as a spurious disconnect.
|
||||||
///
|
///
|
||||||
/// **One case survives and cannot be fixed here.** A zero-length datagram that
|
/// **One case survives on `SOCK_SEQPACKET`, which is Linux, and cannot be fixed
|
||||||
/// is the last message before a close is indistinguishable from the close:
|
/// here.** A zero-length datagram that is the last message before a close is
|
||||||
/// reading it drains the queue, and every observation then matches a bare end of
|
/// indistinguishable from the close: reading it drains the queue, and every
|
||||||
/// file. Separating those needs a payload that is never zero bytes on the wire,
|
/// observation then matches a bare end of file. The flow ends early and the empty
|
||||||
/// which is a protocol change rather than a receive-path one.
|
/// datagram is never delivered. Separating those needs a payload that is never
|
||||||
|
/// zero bytes on the wire, which is a protocol change rather than a receive-path
|
||||||
|
/// one. On `SOCK_DGRAM`, which macOS and FreeBSD use, the empty datagram is
|
||||||
|
/// delivered and the close is reported by the read after it; the tests below
|
||||||
|
/// pin both behaviours.
|
||||||
///
|
///
|
||||||
/// **`ECONNRESET` is treated as end of file too**, for the platform whose
|
/// **`ECONNRESET` is treated as end of file too**, for the platform whose
|
||||||
/// datagram sockets report a close that way rather than through `POLLHUP`. Both
|
/// datagram sockets report a close that way rather than through `POLLHUP`. Both
|
||||||
@@ -671,6 +679,45 @@ mod tests {
|
|||||||
assert_eq!(recv_bounded(&daemon, &mut buf).await, Received::Eof);
|
assert_eq!(recv_bounded(&daemon, &mut buf).await, Received::Eof);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_trailing_empty_datagram_before_a_close_reads_as_the_close_only_on_seqpacket() {
|
||||||
|
// The case recv_once documents as unfixable, pinned per socket type so
|
||||||
|
// the documentation cannot drift from what the kernel does. On
|
||||||
|
// SOCK_SEQPACKET the empty datagram is the last thing queued when the
|
||||||
|
// client closes, so reading it drains the queue with POLLHUP latched
|
||||||
|
// and it is indistinguishable from the close. On SOCK_DGRAM it is
|
||||||
|
// delivered and the close is reported by the read after it. The branch
|
||||||
|
// is on SOCK_TYPE rather than on the OS, because the socket type is the
|
||||||
|
// property the limitation depends on.
|
||||||
|
let (daemon, theirs) = pair().unwrap();
|
||||||
|
let daemon = Seqpacket::new(daemon).unwrap();
|
||||||
|
let theirs = client(theirs);
|
||||||
|
|
||||||
|
// SAFETY: the descriptor is open and owned by `theirs`.
|
||||||
|
let sent = unsafe { libc::send(theirs.as_raw_fd(), std::ptr::null(), 0, 0) };
|
||||||
|
assert_eq!(sent, 0, "{}", io::Error::last_os_error());
|
||||||
|
drop(theirs);
|
||||||
|
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
if SOCK_TYPE == libc::SOCK_SEQPACKET {
|
||||||
|
assert_eq!(
|
||||||
|
recv_bounded(&daemon, &mut buf).await,
|
||||||
|
Received::Eof,
|
||||||
|
"a trailing empty datagram on SOCK_SEQPACKET was delivered rather than \
|
||||||
|
read as the close; the documented limitation no longer holds on this \
|
||||||
|
kernel, so re-measure it and correct the docs that describe it"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
recv_bounded(&daemon, &mut buf).await,
|
||||||
|
Received::Datagram(0),
|
||||||
|
"a trailing empty datagram on SOCK_DGRAM was not delivered; the docs \
|
||||||
|
say only SOCK_SEQPACKET platforms lose it"
|
||||||
|
);
|
||||||
|
assert_eq!(recv_bounded(&daemon, &mut buf).await, Received::Eof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn both_halves_of_a_pair_are_close_on_exec() {
|
fn both_halves_of_a_pair_are_close_on_exec() {
|
||||||
// Asserted rather than assumed because the platforms disagree on how it
|
// Asserted rather than assumed because the platforms disagree on how it
|
||||||
|
|||||||
Reference in New Issue
Block a user