Skip to content

Keep logging from hanging on a stalled systemd journal - #303

Open
zaidoon1 wants to merge 6 commits into
cloudflare:mainfrom
zaidoon1:log-output-safety
Open

zaidoon1 wants to merge 6 commits into
cloudflare:mainfrom
zaidoon1:log-output-safety

Conversation

@zaidoon1

@zaidoon1 zaidoon1 commented Nov 8, 2026 •

Copy link
Copy Markdown

Summary

We had some services (not on foundations yet) hang when journald stopped reading their stdout/stderr stream. Once the socket buffer is full, every blocking write to it waits forever: the foundations logging thread (so shutdown_logger() never returns), and any thread that panics, because the default panic hook writes to stderr. systemd/systemd#44521 fixed one way journald ends up like that, but any other bug that leaves the stream unread has the same effect.

This applies when stdout or stderr is the stream named in JOURNAL_STREAM:

logging thread, per record
  format into a buffer
  send with MSG_DONTWAIT      # never waits, even if the fd is blocking
    no room  -> drop, count logging_records_dropped{reason="journal_full"}
    ENOTSOCK -> stdout/stderr was replaced after init, write() to it like any other writer
    error    -> count reason="journal_error", then ignore_io_errors as before

init
  set O_NONBLOCK on stdout/stderr if they're the journal stream, whatever the log output is
    # so the panic hook and println! can't block on it either
  allow_blocking_journal_stdio: true skips this

If a record only partly fits, the next one starts with a newline so journald doesn't merge them. A JSON record that fails to serialize is thrown away instead of being sent with the next one. A text record that fails goes out cut short on its own line, because slog-term flushes it anyway.

With ignore_io_errors off, which is the default, the first journal_error stops the logging thread like any other output error does. So that counter goes to 1 and stays there, and an alert should fire on any increase. The metric's help text says so.

Logger shutdown leaves stdout and stderr open, for the JSON stdout/stderr writer outside the journal case too, so a panic message written after shutdown can't land in whatever file got fd 2 next. That one is its own commit.

Evidence

  • Before: the stalled journal test, ported to main's API, fails on main for JSON and text output: the panic and shutdown_logger() both wait for the journal until the test gives up after 10s. The two shutdown tests fail on main with EBADF.
    After: all of them pass, the stalled journal one in about 10ms.
  • I also ran it as a real systemd 257 service and froze journald with SIGSTOP. About 1,300 records were dropped and counted at the socket, a panic finished, println! panicked with EAGAIN like the docs say, a child process saw O_NONBLOCK on its stderr, and shutdown returned. Once journald woke up, the next record landed as its own JSON entry.
  • Breaking MSG_DONTWAIT, O_NONBLOCK, the record reset, the newline repair, the buffer cap, the ENOTSOCK fallback or the stdio fd handling each makes a test fail.
  • nextest on Linux aarch64 (all features with CI's unstable flags, and default features), clippy, fmt, rustdoc and the feature powerset check all pass, and it type-checks on macOS.
  • On the logging thread in a release build, JSON records cost about the same as on main, and a bit less for 4KiB records since each one is a single send. Text records to the journal go from over 7µs to under 1µs, one send instead of a write per fragment.

Merge Danger

Door: two-way

O_NONBLOCK stays on a stream until the service restarts and systemd hands it a new one. Rolling back, or turning on allow_blocking_journal_stdio, doesn't clear it on a stream that already has it.

Blast Radius: services logging to the journal under systemd

O_NONBLOCK is set on the stream itself, so it also applies to println!/eprintln! (they panic instead of waiting when the stream is full) and to child processes that inherit it. allow_blocking_journal_stdio: true leaves the stream alone. If seccomp applies to the thread that initializes telemetry, the logging thread needs sendto and init needs fcntl. SERVICE_BASICS doesn't allow sendto, and it only allows fcntl in debug builds. The stdout/stderr fd fix applies to any service logging JSON to stdout or stderr.

@zaidoon1 zaidoon1 changed the title Keep logging from hanging on a stuck systemd journal Keep logging from hanging on a stalled systemd journal Nov 8, 2026
@zaidoon1
zaidoon1 marked this pull request as ready for review November 8, 2026 09:32
@zaidoon1
zaidoon1 force-pushed the log-output-safety branch 2 times, most recently from 54917de to 1e09d4f Compare November 10, 2026 01:37
systemd connects a service's stdout and stderr to a journald socket and
names it in JOURNAL_STREAM. If journald stops reading that socket but
keeps it open, as systemd/systemd#44521 describes after a journald
restart, every blocking write to it waits for good: the logging thread,
then TelemetryDriver::shutdown_logger, and any thread that panics,
because the default panic hook writes its message to stderr.

When stdout or stderr is the stream JOURNAL_STREAM names, log output to
it now goes through a writer that never waits. The writer buffers each
record until the formatter flushes it, then sends it with MSG_DONTWAIT,
so it doesn't wait even if something sharing the descriptor makes it
blocking again. A record that doesn't fit is dropped and counted in
logging_records_dropped{reason="journal_full"}. A full stream isn't an
I/O error, so it never stops the logging thread, whatever
ignore_io_errors is. Other send errors are reported as before and
counted as journal_error. When a record is cut short, the next one
starts with a newline, so journald doesn't join the two. A JSON record
that fails to format is discarded rather than sent with the next one.
slog-term flushes a text record even when formatting fails, so that one
goes out cut short, on its own line.

Initialization also makes those streams nonblocking, whatever the log
output is, so the default panic hook and other writers can't wait for
journald either. The flag is shared by every process holding the stream.
When the stream is full, println! and eprintln! panic and the panic
hook's message can be lost. allow_blocking_journal_stdio leaves the
streams as they are.
let metadata = unsafe { metadata.assume_init() };

metadata.st_mode & libc::S_IFMT == libc::S_IFSOCK
&& journal_stream.as_encoded_bytes()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: OsStr and str can be compared, needs format().as_str()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, it compares against format!(...).as_str() now. Fixed in 380fa72.

impl<W: AsFd> io::Write for JournalWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.new_record.load(Ordering::Relaxed) {
self.new_record.store(false, Ordering::Relaxed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is okay in practice (write happens to be called immediately after store), but this code looks like oops-forgot-a-CAS, so it'd be nice to at least have a comment.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah fair, it does look like a forgotten CAS. Added a comment in 380fa72. Only the logging thread ever touches that flag: the drain sets it before formatting a record and the first write clears it, so nothing can get in between the load and the store. I kept load + store over a swap so every write after the first one in a record is just a plain read.

When JSON logs go to a stdout or stderr that isn't the journal stream,
the drain writes through a File made from fd 1 or 2, and dropping it at
shutdown closed the descriptor. The next file or socket opened took
that number, so anything written to stderr afterwards, like a panic
message, went into it.

The writer wraps the File in ManuallyDrop, so dropping the drain leaves
the descriptor open, the same as the journal writer does.
The journal writer sends with MSG_DONTWAIT, and send fails with ENOTSOCK
if something replaces stdout or stderr with a regular file or a pipe
after logging started. With ignore_io_errors off, that stopped the
logging thread on the next record, so every log after the swap was lost.

On ENOTSOCK the writer writes to the descriptor instead, the same way
the other stdout and stderr writers do, so logs follow the redirect.
When the journal stream blocks, a panic on the logging thread can get
stuck writing its message in the default panic hook, which holds the
hook's lock. A failing assert! in the test then waits for that lock
forever, and nextest has no timeout here, so CI would hang instead of
reporting the failure. The checks that can run in that state go through
fail_without_panicking, like the stalled journal test already does.

Also test that the journal writer keeps the buffer of a normal record
for the next one and frees the buffer of a huge one.
With ignore_io_errors off, the logging thread stops after the first
journal_error, so the counter stays at 1 while every later record is
lost. The metric's help text and the DropReason docs say so, so an alert
can fire on any increase.

ignore_io_errors also says that a full journal stream isn't an I/O
error. The allow_blocking_journal_stdio docs say that a stream stays
nonblocking until the service restarts, and which of foundations'
seccomp allow lists has sendto and fcntl.

The comment on the record reset says what each formatter does with a
record it fails to finish: JSON never flushes it, so it's discarded,
and text flushes it cut short, so it goes out on its own line.
OsStr compares with str directly, so is_journal doesn't need to go
through encoded bytes.

The record-start flag check in JournalWriter::write is a load followed
by a store, which reads like a missing compare-and-swap. A comment
explains that only the logging thread touches the flag, and that the
load keeps every write after a record's first to a plain read.
@zaidoon1

Copy link
Copy Markdown
Author

@kornelski heads up, I pushed a few more commits after you approved and two of them change behaviour, so I wanted to make sure you saw them.

4c53856 fixes something that's already broken on main. When JSON logs go to stdout or stderr and it isn't the journal, the writer is a File made from fd 1 or 2, so dropping the drain at shutdown closed the fd. Whatever got opened next took that number, and a panic message after shutdown would end up in it. The File is wrapped in ManuallyDrop now, so the fd stays open like it does for the journal writer.

c8b7e50 covers stdout or stderr getting replaced after init. If something dup2's a regular file or a pipe over what was the journal stream, send fails with ENOTSOCK, and with ignore_io_errors off that killed the logging thread on the next record. It falls back to a plain write() now so logs follow the redirect. That write can block if the new file does, same as any other writer.

The rest is tests and docs. There's a test for the 64KiB buffer cap now, and the journal tests report failures without panicking, because if the nonblocking code ever regressed, a panic stuck in the hook on the full stream would make a test hang instead of fail. The docs now say that journal_error stops logging after the first one unless ignore_io_errors is on, and what seccomp needs to allow.

I also force pushed once to fix the first commit's message. It said a record that fails to format gets discarded, but that's only true for JSON, slog-term flushes a text one anyway. No code change from that.

Would appreciate another look at those two commits when you get a chance.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants