docs(native): correct the end-of-file guidance given to client authors

The how-to told an author writing a client in another language that a queued
empty datagram leaves the socket with no events pending, while a closed peer
sets POLLHUP. That is false, and a client written to it reproduces the defect
the receive path has just had fixed, in their code rather than ours.

It also told them to poll with an events mask of zero. The library stopped doing
that when the macOS port landed, because a poll that requests nothing registers
no filter on Darwin and returns no events for a peer that has in fact closed. A
client following the old instruction would never detect a close on macOS at all.

The reference page carried both errors and asserted that a zero-byte read is an
empty datagram and only that.

Both pages now describe what the code does. Request POLLIN. Treat a hang-up as
end of file only when FIONREAD reports nothing queued behind the read. Design
around the one case neither check can resolve, by never giving a zero-length
payload a meaning of its own.
This commit is contained in:
Johnathan Corgan
2026-08-30 08:27:16 +00:00
parent bc809c4747
commit 462b9daf0a
2 changed files with 48 additions and 13 deletions
+30 -6
View File
@@ -56,18 +56,42 @@ Neither can happen while the daemon writes exactly one whole line per `sendmsg`
and treats a short write as an error, which it does. That is an invariant of
two programs, though, not of the socket type.
## Step 3: Treat a zero-byte read as end of file only when POLLHUP is set
## Step 3: Decide end of file from POLLHUP and an empty queue
**An empty datagram and a closed peer both produce a zero-byte read, and
`MSG_EOR` does not tell them apart.** On Linux 6.8, `recvmsg` on an `AF_UNIX`
`SOCK_SEQPACKET` socket returns `msg_flags == 0` for a normal message, an empty
message and end of file alike, so the flag carries no information.
`POLLHUP` does discriminate. After a zero-byte read, a queued empty datagram
leaves the socket with no events pending, while a closed peer leaves `POLLHUP`
set and latched. Poll with an events mask of **zero**, because `POLLHUP` is
reported in `revents` whether or not it was requested. The poll costs nothing:
it runs only on the zero-byte path and does not block.
`POLLHUP` narrows the question and does not answer it. A live peer never sets
it, so a zero-byte read without `POLLHUP` is an empty datagram and nothing else.
A closed peer does set it, **and it latches while messages are still queued**.
Measured on Linux 6.8: a socket holding one empty datagram from a peer that has
since closed reports exactly what a drained socket reports, in `revents`, in
`FIONREAD`, under `MSG_PEEK` and in the `recvmsg` return alike.
Poll with an events mask of **`POLLIN`**. Linux reports `POLLHUP` in `revents`
whether or not it was requested, but a poll that requests nothing registers no
filter on Darwin and returns zero events for a peer that has in fact closed.
`POLLIN` costs nothing on either platform, because you mask the result to
`POLLHUP` regardless. The poll runs only on the zero-byte path and does not
block.
**When `POLLHUP` is set, ask whether anything is still queued before you call it
end of file.** `ioctl(fd, FIONREAD, &n)` reports the bytes the receive queue
holds. A non-zero `n` proves a further message is waiting, so the zero-byte read
you just took was an empty datagram: deliver it and read on. This is the case
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
trusts `POLLHUP` alone discards the message.
**One case has no answer, and you should design around it rather than solve
it.** A zero-length datagram that is the last message before a close is
indistinguishable from the close: reading it drains the queue, and `FIONREAD`
then reports zero because a zero-length message contributes no bytes. If your
protocol gives a zero-length payload a meaning, do not send it as a zero-length
socket message. Carry a one-byte discriminator, and keep the zero-byte read for
end of file alone.
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
+18 -7
View File
@@ -249,13 +249,24 @@ non-blocking. A datagram longer than `buf` is truncated and the remainder
discarded, which is `SOCK_SEQPACKET` behaviour and is not reported; size `buf`
at `max_payload()` and it cannot happen. `EINTR` is retried.
**`Ok(0)` is an empty datagram and only that.** An empty datagram and a closed
peer both produce a zero-byte read, and `MSG_EOR` does not tell them apart. On
the zero-byte path only, the library polls the descriptor with an events mask
of zero and reads `POLLHUP` from `revents`, returning `EPIPE` for a genuine
close and `Ok(0)` otherwise. Without that step a peer could tear down a live
flow by sending nothing. The `EPIPE` that `recv` returns means the daemon went
away, never that a peer finished.
**`Ok(0)` is an empty datagram in every case the socket can distinguish.** An
empty datagram and a closed peer both produce a zero-byte read, and `MSG_EOR`
does not tell them apart. On the zero-byte path only, the library polls the
descriptor with an events mask of `POLLIN` and reads `POLLHUP` from `revents`,
and where `POLLHUP` is set it asks `FIONREAD` whether the queue still holds
anything. It returns `EPIPE` only when the peer has hung up and nothing is
queued behind the read, and `Ok(0)` otherwise. `POLLHUP` alone is not enough,
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
`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
datagram that is the last message before a close is indistinguishable from the
close, because reading it drains the queue and `FIONREAD` then reports zero: a
zero-length message contributes no bytes. Do not give a zero-length payload a
meaning 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
`io::Result<FipsAddr>`, unlike their `TcpStream` counterparts. These are field