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>
158 lines
5.6 KiB
C++
158 lines
5.6 KiB
C++
// Shared-memory log channel: the injected hook (coop_hook.dll) streams its log
|
|
// lines to the host (coop_host.exe), which shows them in a Log window. Separate
|
|
// mapping from the input/status SharedBlock, named coop_log_<pid>.
|
|
//
|
|
// Lossy multi-producer / single-consumer ring: the hook logs from several threads
|
|
// (worker, audio render thread, window thread), so producers claim a slot with an
|
|
// atomic fetch_add and publish each record with a release store of its sequence;
|
|
// the host consumer reads in order and tolerates losing the oldest lines if it
|
|
// ever falls a whole ring behind (fine for diagnostics). POD + version-locked.
|
|
#pragma once
|
|
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
namespace coop
|
|
{
|
|
|
|
// 'CLOG' little-endian.
|
|
inline constexpr std::uint32_t kLogRingMagic = 0x474F4C43u;
|
|
inline constexpr std::uint32_t kLogRingVersion = 1;
|
|
|
|
// Per-pid mapping name, mirroring the other channels: coop_log_<pid>.
|
|
inline constexpr wchar_t kLogRingPrefix[] = L"Local\\coop_log_";
|
|
|
|
inline constexpr std::uint32_t kLogMsgLen = 192; // chars per line (incl. NUL)
|
|
inline constexpr std::uint32_t kLogCapacity = 1024; // ring records
|
|
|
|
// Severity of a log line; drives the host Log window's colour. Stored in
|
|
// LogRecord::level. Info is 0 so existing/zero-filled records read as Info.
|
|
enum LogLevel : std::uint32_t
|
|
{
|
|
LogLevel_Info = 0,
|
|
LogLevel_Warn = 1,
|
|
LogLevel_Error = 2,
|
|
};
|
|
|
|
struct LogRecord
|
|
{
|
|
std::atomic<std::uint64_t> seq; // 0 = empty; else (global index + 1) once written
|
|
std::uint32_t pid;
|
|
std::uint32_t level; // LogLevel
|
|
std::uint64_t millis; // producer timestamp (GetTickCount64)
|
|
char text[kLogMsgLen];
|
|
};
|
|
|
|
struct LogRing
|
|
{
|
|
std::uint32_t magic;
|
|
std::uint32_t version;
|
|
std::uint32_t capacity; // number of records
|
|
std::uint32_t msg_len; // kLogMsgLen (sanity)
|
|
std::atomic<std::uint64_t> write_index; // total records ever claimed (free-running)
|
|
std::uint8_t reserved[32];
|
|
// LogRecord records[capacity] follows immediately.
|
|
};
|
|
|
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
|
"log ring needs a lock-free 64-bit atomic for cross-process use");
|
|
|
|
inline constexpr std::size_t log_ring_total_size(std::uint32_t capacity)
|
|
{
|
|
return sizeof(LogRing) + static_cast<std::size_t>(capacity) * sizeof(LogRecord);
|
|
}
|
|
|
|
inline LogRecord* log_ring_records(LogRing* r)
|
|
{
|
|
return reinterpret_cast<LogRecord*>(reinterpret_cast<std::uint8_t*>(r) + sizeof(LogRing));
|
|
}
|
|
|
|
// Host: stamp a freshly created (zero-filled) mapping. Records start empty (seq 0).
|
|
inline void log_ring_init(LogRing& r, std::uint32_t capacity)
|
|
{
|
|
r.capacity = capacity;
|
|
r.msg_len = kLogMsgLen;
|
|
r.write_index.store(0, std::memory_order_relaxed);
|
|
std::memset(r.reserved, 0, sizeof(r.reserved));
|
|
r.version = kLogRingVersion;
|
|
r.magic = kLogRingMagic; // last
|
|
}
|
|
|
|
inline bool log_ring_valid(const LogRing& r)
|
|
{
|
|
return r.magic == kLogRingMagic && r.version == kLogRingVersion && r.capacity != 0 &&
|
|
r.msg_len == kLogMsgLen;
|
|
}
|
|
|
|
// Producer (hook): append a line at severity `level` (LogLevel). Multi-producer safe.
|
|
inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint32_t level, std::uint64_t millis,
|
|
const char* text)
|
|
{
|
|
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';
|
|
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
|
|
// records lost to ring wrap; stops at an in-flight record and retries next call.
|
|
template <typename F>
|
|
inline void log_ring_drain(LogRing& r, std::uint64_t& cursor, F&& emit)
|
|
{
|
|
const std::uint64_t w = r.write_index.load(std::memory_order_acquire);
|
|
if (w <= cursor)
|
|
{
|
|
return;
|
|
}
|
|
const std::uint64_t lo = (w > r.capacity) ? (w - r.capacity) : 0;
|
|
std::uint64_t i = cursor < lo ? lo : cursor; // skip records already overwritten
|
|
LogRecord* recs = log_ring_records(&r);
|
|
for (; i < w; ++i)
|
|
{
|
|
LogRecord& rec = recs[i % r.capacity];
|
|
const std::uint64_t s1 = rec.seq.load(std::memory_order_acquire);
|
|
if (s1 <= i)
|
|
{
|
|
break; // generation i not written yet (in-flight, or being overwritten); retry next call
|
|
}
|
|
if (s1 != i + 1)
|
|
{
|
|
continue; // s1 > i+1: overwritten by a later generation before we got here; lost, skip
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
inline std::wstring log_ring_name(unsigned long target_pid)
|
|
{
|
|
return std::wstring(kLogRingPrefix) + std::to_wstring(target_pid);
|
|
}
|
|
|
|
} // namespace coop
|