log_ring: seqlock the records to prevent torn cross-process reads
The lossy MPSC log ring published each record by writing its text and THEN storing the slot's sequence. A consumer that passed the seq==generation check could then read text while a producer 'capacity' generations later overwrote that same slot (it wrote text before bumping seq), yielding a torn line. Diagnostics-only and practically unreachable (it needs the consumer a full ring behind -- ~60k lines/s between two host drains), but a real data race. Make it a proper seqlock: the producer stores seq 0 (in-progress) and fences BEFORE touching the record, then publishes the generation after the text; the consumer copies the record out and re-checks seq, dropping the line if it changed. The ring stays lossy, never torn. Adds log_ring_test (previously zero coverage): a deterministic wrap-drop case plus a threaded torn-read guard (4 producers + a slow consumer on a 32-slot ring) that emits 0 torn lines out of ~300k produced. Closes both the cross-process item and the log_ring coverage gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,12 +92,19 @@ inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint32_t level, st
|
||||
{
|
||||
const std::uint64_t idx = r.write_index.fetch_add(1, std::memory_order_acq_rel);
|
||||
LogRecord& rec = log_ring_records(&r)[idx % r.capacity];
|
||||
// Seqlock write. Mark the slot in-progress (seq 0) and fence BEFORE touching the record, so a
|
||||
// consumer still reading the slot's previous occupant sees seq change and bails instead of reading
|
||||
// half-overwritten text; publish the new generation only after the text is fully written. Without
|
||||
// this the consumer's single seq check passed before the read, so an overwrite mid-read tore it.
|
||||
rec.seq.store(0, std::memory_order_relaxed);
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
rec.pid = pid;
|
||||
rec.level = level;
|
||||
rec.millis = millis;
|
||||
std::strncpy(rec.text, text, kLogMsgLen - 1);
|
||||
rec.text[kLogMsgLen - 1] = '\0';
|
||||
rec.seq.store(idx + 1, std::memory_order_release); // publish: record is ready
|
||||
std::atomic_thread_fence(std::memory_order_release);
|
||||
rec.seq.store(idx + 1, std::memory_order_relaxed); // publish: generation idx is ready
|
||||
}
|
||||
|
||||
// Consumer (host): emit each new record since `cursor` (advanced in place). Skips
|
||||
@@ -116,16 +123,28 @@ inline void log_ring_drain(LogRing& r, std::uint64_t& cursor, F&& emit)
|
||||
for (; i < w; ++i)
|
||||
{
|
||||
LogRecord& rec = recs[i % r.capacity];
|
||||
const std::uint64_t s = rec.seq.load(std::memory_order_acquire);
|
||||
if (s == i + 1)
|
||||
const std::uint64_t s1 = rec.seq.load(std::memory_order_acquire);
|
||||
if (s1 <= i)
|
||||
{
|
||||
emit(rec); // ready
|
||||
break; // generation i not written yet (in-flight, or being overwritten); retry next call
|
||||
}
|
||||
else if (s <= i)
|
||||
if (s1 != i + 1)
|
||||
{
|
||||
break; // slot not written for this generation yet (in-flight); retry later
|
||||
continue; // s1 > i+1: overwritten by a later generation before we got here; lost, skip
|
||||
}
|
||||
// s > i + 1: overwritten before we read it; skip (lost)
|
||||
// Seqlock read: copy the record out, then re-check seq. A producer overwriting this slot stores
|
||||
// seq 0 before it writes and the new generation after, so any change means our copy may be torn.
|
||||
LogRecord snap{};
|
||||
snap.pid = rec.pid;
|
||||
snap.level = rec.level;
|
||||
snap.millis = rec.millis;
|
||||
std::memcpy(snap.text, rec.text, kLogMsgLen);
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
if (rec.seq.load(std::memory_order_relaxed) == i + 1)
|
||||
{
|
||||
emit(snap); // consistent snapshot
|
||||
}
|
||||
// else: overwritten while we copied -> skip (lost)
|
||||
}
|
||||
cursor = i;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user