RFA-266 · Case file with fixtures · Case 238 of 694 · Runtime evidence
Why mpsc recv Still Returns Messages After Every Sender Is Dropped
Channel disconnection stops future sends but does not erase already accepted messages. Receive until the buffered queue is empty before treating RecvError as end of stream.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets with std threading
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Disconnection prevents future sends but does not revoke messages already accepted into the channel buffer.
- First discriminating check
- Send one message, drop every Sender clone before receiving, and record the first and second receive results separately.
Dropping every sender closes an mpsc channel for future production. It does not cancel messages already accepted into the channel.
The failing program sends "queued", drops the only sender, and calls Receiver::recv. The first call returns the message. Only the following receive reports disconnection.
Closed and empty are separate state dimensions
A receiver can describe the channel with two questions:
Can another message still be sent?
Is an accepted message currently buffered?
Dropping all senders changes the first answer to no. It does not force the second answer to no.
recv returns queued messages while they exist. It returns RecvError when no message remains and no sender can ever add one.
Accepted work is not revoked by sender lifetime
A successful send transfers the message into the channel's delivery responsibility. If later sender destruction erased it, shutdown timing could silently lose already accepted work.
Rust's behavior makes graceful producer shutdown possible: producers disappear, consumers drain what was sent, then receive a definitive end-of-stream error.
The repaired program asserts this two-step sequence exactly.
Disconnection is not processing completion
Even after the receiver obtains the final message, its side effect may still be running or may fail. Channel empty/disconnected means no more messages will arrive through that channel; it does not prove every job committed successfully.
For important work I add acknowledgements, join worker threads, or track durable job state. Sender drop is an input-lifecycle signal, not an end-to-end completion barrier.
This complements the earlier principle that successful send means accepted by the channel, not processed by the consumer.
Receiver iteration follows the same drain rule
Receiver::iter blocks while senders remain and no item is available. After all senders are gone, it yields buffered messages and ends once drained.
Keeping an unused sender clone alive can therefore make a consumer loop wait forever after useful producers are done. I audit clones and ownership rather than assuming the lexical producer function owns every sender.
Using for message in receiver is concise when channel disconnection is the intended stream terminator.
Forced shutdown may need a different policy
Sometimes shutdown must abandon pending work. Simply dropping senders does not express that. The consumer needs a cancellation signal, deadline, message policy, or ownership design that permits dropping the receiver and its queue.
Graceful drain and immediate cancellation have different durability guarantees. I name them separately in shutdown APIs and test both.
If messages hold resources, dropping the receiver destroys queued messages without processing them, which may release memory but skip application effects.
Multiple producers do not change the rule
The channel remains connected while any Sender clone exists. One producer ending does not mean end of stream. The final clone's drop establishes that no future messages can arrive.
This is easy to get wrong when a sender is stored in a supervisor, closure, or test variable. I keep sender ownership narrow and explicitly drop orchestration copies before waiting for consumer completion.
try_recv exposes empty versus disconnected
Nonblocking receive distinguishes a temporarily empty connected channel from a disconnected empty channel. That distinction prevents a poll loop from treating “nothing right now” as permanent completion.
Buffered messages still win before the disconnected condition, as with blocking receive. Timeout receive adds another temporary outcome but does not change the drain semantics.
What I test
My matrix includes connected empty, connected buffered, disconnected buffered, and disconnected empty states. With multiple senders it drops them one by one and proves only the last drop closes production.
For worker systems I separately test processing acknowledgements and failure paths. Channel mechanics alone cannot prove business completion.
FIFO order is local to accepted sends
Messages from one sender arrive in their send order. With multiple producers, scheduling determines how their sends interleave. Draining after disconnection preserves the channel's accepted queue order, not a global timestamp order invented afterward.
If consumers require deterministic cross-producer order, messages need sequence metadata and a merge policy. Waiting until every sender drops does not automatically sort buffered work.
I test ordering claims separately from the disconnect sequence. Combining them in one assertion can make a scheduling-dependent test look like a shutdown failure.
The core principle is that closing future input does not erase past accepted input. Rust's mpsc receiver drains buffered messages after every sender is dropped and reports disconnection only when the queue is empty. Build graceful and forced shutdown around that state machine rather than one vague “closed” flag.