Skip to content

message send writes non-atomically: a reader can observe the inbox file at zero length #3

Description

@schickling-assistant

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:200fs::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:223let contents = fs::read_to_string(entry.path()).unwrap_or_default();
  • src/message.rs:146-151parse_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:messageNative message bus, inbox, archive, and receipts · Set: manualorigin:agentFiled or primarily produced by an AI agent · Set: manualtype:bugSomething broken or a regression · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions