st2 message send writes the message file non-atomically, so a reader can observe it at zero length. The symptom is a delivered DING that reads (no subject) (from unknown) for a message whose file on disk is complete.
Verified at 4e25f56. Apologies in advance if this is already fixed in work you have locally — happy for this to be closed in favour of your version.
Mechanism
send_to_inbox writes straight to the final name:
src/message.rs:200 — fs::write(&path, &contents)? into <inbox>/<unix-ms>-<rand6>.md
fs::write is open(O_WRONLY|O_CREAT|O_TRUNC) followed by write, so between those two syscalls the file exists at zero length under its final, is_message_filename-matching name. Any reader that scans the inbox in that window sees an empty file.
The reader then degrades silently rather than skipping or retrying:
src/message.rs:223 — let contents = fs::read_to_string(entry.path()).unwrap_or_default();
src/message.rs:146-151 — parse_message only reads frontmatter when it finds an opening --- and a closing ---. A zero-length (or truncated-before-the-closing-delimiter) read therefore yields from: None, subject: None.
None/None is exactly the (no subject) (from unknown) rendering.
Note that src/status.rs:143-154 already establishes the atomic idiom in this codebase (write_atomic: tmp sibling + rename), and src/status.rs:7 documents it as deliberate — "Writes are atomic (tmp + rename) so a concurrent reader never sees a partial file." The inbox write does not use it.
Reproduction
Against a scratch catalog with one agent, at 4e25f56:
# writer: 40 sends of a 40 MB body, clearing the inbox between each
for i in $(seq 1 40); do
st2 message send --catalog "$CATALOG" --as tester --host <host> <host>.<id> \
--subject "big-$i" < big.txt >/dev/null
rm -f "$INBOX"/*.md
done
# reader, concurrently: tight loop
while ...; do
st2 message ls --catalog "$CATALOG" --host <host> <host>.<id> --json
done
Result: 4 partial reads in 589 reader iterations.
[{"filename":"<ms>-<rand6>.md","ts":<ms>,"from":null,"subject":null,"inReplyTo":null,"tags":[],"priority":null}]
The large body only widens the window; it is not required. We have also seen the (no subject) (from unknown) DING organically under concurrent load with ordinary message sizes, and not on an idle host — so it is intermittent and load-dependent, not size-dependent.
Impact for an adopter
A message can be delivered to an agent with its sender and subject erased. There is no error and nothing in any log; the file on disk is complete by the time anyone looks, so it is indistinguishable from a malformed send. For a supervisor/worker topology that routes on from, a poke that loses its sender is unactionable.
Offer
The fix looks small — reuse the existing write_atomic idiom (tmp sibling in the same directory, then rename) inside send_to_inbox. We have a PR with a regression test ready and will link it here. Entirely your call whether the reader half (unwrap_or_default() swallowing an unreadable message) should also change; we left it alone since an atomic write removes the window that makes it observable.
st2 message sendwrites the message file non-atomically, so a reader can observe it at zero length. The symptom is a delivered DING that reads(no subject) (from unknown)for a message whose file on disk is complete.Verified at
4e25f56. Apologies in advance if this is already fixed in work you have locally — happy for this to be closed in favour of your version.Mechanism
send_to_inboxwrites straight to the final name:src/message.rs:200—fs::write(&path, &contents)?into<inbox>/<unix-ms>-<rand6>.mdfs::writeisopen(O_WRONLY|O_CREAT|O_TRUNC)followed bywrite, so between those two syscalls the file exists at zero length under its final,is_message_filename-matching name. Any reader that scans the inbox in that window sees an empty file.The reader then degrades silently rather than skipping or retrying:
src/message.rs:223—let contents = fs::read_to_string(entry.path()).unwrap_or_default();src/message.rs:146-151—parse_messageonly reads frontmatter when it finds an opening---and a closing---. A zero-length (or truncated-before-the-closing-delimiter) read therefore yieldsfrom: None, subject: None.None/Noneis exactly the(no subject) (from unknown)rendering.Note that
src/status.rs:143-154already establishes the atomic idiom in this codebase (write_atomic: tmp sibling +rename), andsrc/status.rs:7documents it as deliberate — "Writes are atomic (tmp + rename) so a concurrent reader never sees a partial file." The inbox write does not use it.Reproduction
Against a scratch catalog with one agent, at
4e25f56:Result: 4 partial reads in 589 reader iterations.
[{"filename":"<ms>-<rand6>.md","ts":<ms>,"from":null,"subject":null,"inReplyTo":null,"tags":[],"priority":null}]The large body only widens the window; it is not required. We have also seen the
(no subject) (from unknown)DING organically under concurrent load with ordinary message sizes, and not on an idle host — so it is intermittent and load-dependent, not size-dependent.Impact for an adopter
A message can be delivered to an agent with its sender and subject erased. There is no error and nothing in any log; the file on disk is complete by the time anyone looks, so it is indistinguishable from a malformed send. For a supervisor/worker topology that routes on
from, a poke that loses its sender is unactionable.Offer
The fix looks small — reuse the existing
write_atomicidiom (tmp sibling in the same directory, thenrename) insidesend_to_inbox. We have a PR with a regression test ready and will link it here. Entirely your call whether the reader half (unwrap_or_default()swallowing an unreadable message) should also change; we left it alone since an atomic write removes the window that makes it observable.