tracing: add the v2 shared-ring chunk protocol

Add the producer-local shared ring used by tracing v2. This change contains
the shared-memory ABI, one writer per TraceWriter, the single reader, and the
protocol tests. SDK and service wiring follow in separate changes.

- Define a 64-byte ring header:
  - an always-lock-free atomic64 packs write_pos in the high half and read_pos
    in the low half;
  - num_writers_waiting avoids a wake syscall when no writer is asleep;
  - a zero-filled mapping is a valid empty ring.

- Define one atomic32 state word at the start of each chunk:
  - Free(wrap) exposes the chunk to one ring traversal;
  - BeingWritten identifies the writer and its published fragment prefix;
  - Complete says the writer has finished touching the chunk, although it may
    take the chunk back to append more fragments;
  - RewriteRequested says the reader took the published prefix and the writer
    must move any newer suffix;
  - RewriteAcknowledged says the writer released the old chunk and the reader
    may return it to Free.

- Reserve uint32 positions in FIFO order and map them onto a power-of-two
  number of physical chunks. Free carries a 16-bit wrap count so a delayed
  writer cannot claim the same chunk during a later traversal.

- Keep ownership rules local to the chunk word:
  - one reservation permits one exact claim attempt;
  - only the reader writes Free;
  - the reader never waits for a writer;
  - a lost reader CAS leaves read_pos unchanged and retries the position in a
    later drain.

- Store target BufferID after the state word. Payload fragments grow towards
  higher addresses while their sizes grow down from the end of the chunk as
  shortest-form protobuf varints. Fragment counts publish an append-only
  prefix. If the reader reaches an active writer, it copies that prefix and
  asks the writer to move only the unpublished suffix.

- Keep the initial 256-byte minimum chunk size and require a four-byte-aligned
  chunk stride. Check ring geometry and allocation arithmetic before mapping
  memory. Require atomic32 and atomic64 to be always lock-free.

- Honor BufferExhaustedPolicy in SharedRingBufferWriter. Linux and Android
  writers can wait on the read_pos half of rw_positions with a futex. Other
  platforms report that blocking is unavailable instead of spinning. The
  waiter hint and its seq_cst fence pair prevent a missed wake across the two
  atomic words.

- Test ABI encoding, position and wrap rollover, malformed chunk input, every
  state transition, failed claims, prefix copying, suffix relocation, futex
  wake races, and concurrent multi-writer stress.

Bug: 536851377
diff --git a/Android.bp b/Android.bp
index f072867..56e203a 100644
--- a/Android.bp
+++ b/Android.bp
@@ -23009,6 +23009,28 @@
     ],
 }
 
+// GN: //src/tracing/v2:unittests
+filegroup {
+    name: "perfetto_src_tracing_v2_unittests",
+    srcs: [
+        "src/tracing/v2/shared_ring_buffer_concurrency_unittest.cc",
+        "src/tracing/v2/shared_ring_buffer_reader_unittest.cc",
+        "src/tracing/v2/shared_ring_buffer_unittest.cc",
+        "src/tracing/v2/shared_ring_buffer_writer_unittest.cc",
+        "src/tracing/v2/tracing_v2_abi_unittest.cc",
+    ],
+}
+
+// GN: //src/tracing/v2:v2
+filegroup {
+    name: "perfetto_src_tracing_v2_v2",
+    srcs: [
+        "src/tracing/v2/shared_ring_buffer.cc",
+        "src/tracing/v2/shared_ring_buffer_reader.cc",
+        "src/tracing/v2/shared_ring_buffer_writer.cc",
+    ],
+}
+
 // GN: //test:integrationtest_initializer
 filegroup {
     name: "perfetto_test_integrationtest_initializer",
@@ -24412,6 +24434,8 @@
         ":perfetto_src_tracing_system_backend",
         ":perfetto_src_tracing_test_test_support",
         ":perfetto_src_tracing_unittests",
+        ":perfetto_src_tracing_v2_unittests",
+        ":perfetto_src_tracing_v2_v2",
         ":perfetto_test_sanitizers_unittests",
     ],
     shared_libs: [
diff --git a/gn/perfetto_unittests.gni b/gn/perfetto_unittests.gni
index e349a85..de56e67 100644
--- a/gn/perfetto_unittests.gni
+++ b/gn/perfetto_unittests.gni
@@ -38,6 +38,7 @@
 _tracing_unittests_targets += [
   "src/tracing/core:unittests",
   "src/tracing/service:unittests",
+  "src/tracing/v2:unittests",
   "src/tracing:unittests",
 ]
 
diff --git a/src/tracing/v2/BUILD.gn b/src/tracing/v2/BUILD.gn
new file mode 100644
index 0000000..de2894d
--- /dev/null
+++ b/src/tracing/v2/BUILD.gn
@@ -0,0 +1,65 @@
+# Copyright (C) 2026 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import("../../../gn/test.gni")
+
+# The tracing v2 producer-local shared ring: its ABI, the writer half of the
+# chunk protocol and the single-consumer reader. It does not interpret fragment
+# payloads.
+source_set("v2") {
+  public_deps = [
+    # shared_ring_buffer.h holds a base::PagedMemory by value.
+    "../../../include/perfetto/ext/base",
+
+    # The headers use WriterID and BufferID.
+    "../../../include/perfetto/ext/tracing/core",
+
+    # tracing_v2_abi.h uses the public varint helpers.
+    "../../../include/perfetto/public:protozero",
+
+    # shared_ring_buffer_writer.h takes a BufferExhaustedPolicy.
+    "../../../include/perfetto/tracing",
+  ]
+  deps = [
+    "../../../gn:default_deps",
+    "../../base",
+  ]
+  sources = [
+    "shared_ring_buffer.cc",
+    "shared_ring_buffer.h",
+    "shared_ring_buffer_reader.cc",
+    "shared_ring_buffer_reader.h",
+    "shared_ring_buffer_writer.cc",
+    "shared_ring_buffer_writer.h",
+    "tracing_v2_abi.h",
+  ]
+}
+
+perfetto_unittest_source_set("unittests") {
+  testonly = true
+  deps = [
+    ":v2",
+    "../../../gn:default_deps",
+    "../../../gn:gtest_and_gmock",
+    "../../base",
+    "../../base:test_support",
+  ]
+  sources = [
+    "shared_ring_buffer_concurrency_unittest.cc",
+    "shared_ring_buffer_reader_unittest.cc",
+    "shared_ring_buffer_unittest.cc",
+    "shared_ring_buffer_writer_unittest.cc",
+    "tracing_v2_abi_unittest.cc",
+  ]
+}
diff --git a/src/tracing/v2/shared_ring_buffer.cc b/src/tracing/v2/shared_ring_buffer.cc
new file mode 100644
index 0000000..535962b
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer.cc
@@ -0,0 +1,494 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer.h"
+
+#include <errno.h>
+#include <stdint.h>
+
+#include <atomic>
+#include <memory>
+#include <optional>
+#include <utility>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/compiler.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/paged_memory.h"
+#include "perfetto/ext/base/utils.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \
+    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
+#define PERFETTO_TRACING_V2_HAS_FUTEX() 1
+#else
+#define PERFETTO_TRACING_V2_HAS_FUTEX() 0
+#endif
+
+#if PERFETTO_TRACING_V2_HAS_FUTEX()
+#include <linux/futex.h>
+#include <sys/syscall.h>
+#include <time.h>
+#include <unistd.h>
+#endif
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+// PagedMemory adds two guard pages and rounds the mapping up to a page.
+std::optional<size_t> ComputeAllocationSize(uint32_t num_chunks,
+                                            uint32_t chunk_size,
+                                            size_t page_size) {
+  const uint64_t ring_size = uint64_t{sizeof(RingBufferHeader)} +
+                             uint64_t{num_chunks} * uint64_t{chunk_size};
+  if (ring_size > SIZE_MAX || !base::IsPowerOfTwo(page_size))
+    return std::nullopt;
+
+  const size_t allocation_size = static_cast<size_t>(ring_size);
+
+  // PagedMemory rounds the request up and adds two guard pages using unchecked
+  // size_t arithmetic. Reject sizes that would overflow either operation.
+  const size_t page_rounding = page_size - 1;
+  if (allocation_size > SIZE_MAX - page_rounding)
+    return std::nullopt;
+  const size_t rounded_allocation_size =
+      (allocation_size + page_rounding) & ~page_rounding;
+
+  if (page_size > SIZE_MAX / 2)
+    return std::nullopt;
+  const size_t guard_pages_size = page_size * 2;
+  if (rounded_allocation_size > SIZE_MAX - guard_pages_size)
+    return std::nullopt;
+
+  return allocation_size;
+}
+
+#if PERFETTO_TRACING_V2_HAS_FUTEX()
+// A futex operation acts on one aligned 32-bit word. The word writers park on
+// is the low half of rw_positions. This pointer is passed only to the kernel;
+// C++ accesses the field as atomic<uint64_t>.
+uint32_t* ReadPosFutexWord(std::atomic<uint64_t>* rw_positions) {
+  static_assert(PERFETTO_IS_LITTLE_ENDIAN(),
+                "The low-word futex requires read_pos to be the first four "
+                "bytes of rw_positions");
+  return reinterpret_cast<uint32_t*>(rw_positions);
+}
+
+// TODO(sashwinbalaji): Drop *_PRIVATE when the reader moves into traced.
+// Private futexes are keyed by process, so a traced wake would not match a
+// producer wait.
+int FutexSyscall(uint32_t* word,
+                 int op,
+                 uint32_t value,
+                 const struct timespec* timeout) {
+  return static_cast<int>(
+      syscall(SYS_futex, word, op, value, timeout, nullptr, 0));
+}
+#endif  // PERFETTO_TRACING_V2_HAS_FUTEX()
+
+SharedRingBuffer::WriterWaitResult ClassifyWriterWaitErrno(int wait_errno) {
+  switch (wait_errno) {
+    case ETIMEDOUT:
+      return SharedRingBuffer::WriterWaitResult::kTimedOut;
+    case EAGAIN:
+    case EINTR:
+      return SharedRingBuffer::WriterWaitResult::kRetry;
+    default:
+      return SharedRingBuffer::WriterWaitResult::kUnavailable;
+  }
+}
+
+}  // namespace
+
+// --- Allocation and lifetime. ---
+
+// static
+std::unique_ptr<SharedRingBuffer> SharedRingBuffer::Create(
+    uint32_t num_chunks,
+    uint32_t chunk_size) {
+  if (!base::IsPowerOfTwo(num_chunks) || num_chunks > kMaxChunksPerRing ||
+      chunk_size < kMinChunkSize || chunk_size % kChunkAlignmentBytes != 0) {
+    return nullptr;
+  }
+
+  const std::optional<size_t> allocation_size =
+      ComputeAllocationSize(num_chunks, chunk_size, base::GetSysPageSize());
+  if (!allocation_size)
+    return nullptr;
+
+  // PagedMemory is zero-filled. Zero is both (write_pos=0, read_pos=0) and
+  // Free(0), so no per-chunk initialization pass is needed.
+  auto ring_memory = base::PagedMemory::Allocate(*allocation_size,
+                                                 base::PagedMemory::kMayFail);
+  if (!ring_memory.IsValid())
+    return nullptr;
+
+  return std::unique_ptr<SharedRingBuffer>(
+      new SharedRingBuffer(std::move(ring_memory), num_chunks, chunk_size));
+}
+
+SharedRingBuffer::SharedRingBuffer(base::PagedMemory ring_memory,
+                                   uint32_t num_chunks,
+                                   uint32_t chunk_size)
+    : ring_memory_(std::move(ring_memory)),
+      chunks_begin_(static_cast<uint8_t*>(ring_memory_.Get()) +
+                    sizeof(RingBufferHeader)),
+      num_chunks_(num_chunks),
+      chunk_size_(chunk_size),
+      chunk_index_bits_(GetChunkIndexBits(num_chunks)) {}
+
+SharedRingBuffer::~SharedRingBuffer() = default;
+
+// --- Writer-side reservation. ---
+
+SharedRingBuffer::Reservation SharedRingBuffer::TryReserveWritePos() {
+  // PublishReadPos() releases after reclaiming chunks. If this load sees a new
+  // read_pos, acquire also sees those reclaims. If the load is stale, the
+  // compare-and-swap below fails unless the complete rw_positions word is
+  // still current.
+  return TryReserveWritePosImpl(
+      header()->rw_positions.load(std::memory_order_acquire));
+}
+
+SharedRingBuffer::Reservation SharedRingBuffer::TryReserveWritePosImpl(
+    uint64_t expected_rw_positions) {
+  RingBufferHeader* ring_header = header();
+  Reservation reservation{};
+  for (;;) {
+    const uint32_t write_pos = WritePosOf(expected_rw_positions);
+    const uint32_t read_pos = ReadPosOf(expected_rw_positions);
+    reservation.read_pos_sample = read_pos;
+
+    if (NumOutstandingPositions(write_pos, read_pos) >= num_chunks_) {
+      reservation.result = ReserveResult::kFull;
+      return reservation;
+    }
+
+    // Acquire pairs with the reader publishing newly reclaimed capacity.
+    if (ring_header->rw_positions.compare_exchange_weak(
+            expected_rw_positions, PackRwPositions(write_pos + 1, read_pos),
+            std::memory_order_acquire, std::memory_order_acquire)) {
+      reservation.result = ReserveResult::kReserved;
+      reservation.position = write_pos;
+      return reservation;
+    }
+    // Failure updates |expected_rw_positions|. No reservation, and therefore
+    // no hole, was created.
+  }
+}
+
+// --- Writer-side chunk transitions. ---
+
+bool SharedRingBuffer::TryAcquireChunkForWriting(uint32_t position,
+                                                 uint32_t being_written_word) {
+  PERFETTO_DCHECK(ChunkStateOf(being_written_word) ==
+                  ChunkState::kBeingWritten);
+  PERFETTO_DCHECK(NumFragmentsOf(being_written_word) == 0);
+
+  // A reservation authorizes a claim against this exact Free word.
+  uint32_t expected =
+      MakeFreeStateWord(WrapCountForPosition(position, chunk_index_bits_));
+
+  // Acquire on success consumes the reader's release reclaim, so the reader's
+  // last reads of the previous traversal's payload happen before this writer's
+  // first store into the chunk. On failure this position is left unclaimed;
+  // the caller does not use the word returned in |expected|.
+  return chunk_state_word_at(ChunkIndexOfPosition(position, num_chunks_))
+      ->compare_exchange_strong(expected, being_written_word,
+                                std::memory_order_acquire,
+                                std::memory_order_relaxed);
+}
+
+bool SharedRingBuffer::TrySetChunkComplete(uint32_t chunk_index,
+                                           uint32_t* observed,
+                                           uint32_t complete_word) {
+  PERFETTO_DCHECK(ChunkStateOf(*observed) == ChunkState::kBeingWritten);
+  PERFETTO_DCHECK(ChunkStateOf(complete_word) == ChunkState::kComplete);
+
+  // Release publishes the new fragments, their size varints and, on the first
+  // publication, the target BufferID. On failure the reader has marked the
+  // chunk, and acquire orders the reader's completed copy ahead of this
+  // writer's relocation work.
+  return chunk_state_word_at(chunk_index)
+      ->compare_exchange_strong(*observed, complete_word,
+                                std::memory_order_release,
+                                std::memory_order_acquire);
+}
+
+bool SharedRingBuffer::TryReacquireChunkForWriting(uint32_t chunk_index,
+                                                   uint32_t observed) {
+  PERFETTO_DCHECK(ChunkStateOf(observed) == ChunkState::kComplete);
+
+  uint32_t expected = observed;
+  // This relaxed read-modify-write remains in the release sequence started by
+  // TrySetChunkComplete(). A reader that acquire-loads the resulting
+  // BeingWritten word therefore sees the prefix already published by this
+  // writer. On failure the writer drops its cached handle and does not use the
+  // returned state.
+  return chunk_state_word_at(chunk_index)
+      ->compare_exchange_strong(
+          expected, ReplaceChunkState(observed, ChunkState::kBeingWritten),
+          std::memory_order_relaxed, std::memory_order_relaxed);
+}
+
+bool SharedRingBuffer::TryAcknowledgeRewrite(uint32_t chunk_index,
+                                             uint32_t observed) {
+  PERFETTO_DCHECK(ChunkStateOf(observed) == ChunkState::kRewriteRequested);
+
+  uint32_t expected = observed;
+  // Release tells the reader that the writer has finished all accesses to the
+  // old chunk. Only this writer may leave RewriteRequested, so failure is a
+  // protocol error.
+  return chunk_state_word_at(chunk_index)
+      ->compare_exchange_strong(expected, kRewriteAcknowledgedStateWord,
+                                std::memory_order_release,
+                                std::memory_order_relaxed);
+}
+
+// --- Reader-side chunk transitions. ---
+
+uint32_t SharedRingBuffer::LoadChunkStateWord(uint32_t chunk_index) const {
+  // Pairs with every writer release transition, so each published fragment and
+  // its size varint is visible before the reader walks and copies them.
+  return chunk_state_word_at(chunk_index)->load(std::memory_order_acquire);
+}
+
+uint32_t SharedRingBuffer::LoadWritePos() const {
+  // Once a position is observed, the chunk word provides payload visibility.
+  return WritePosOf(header()->rw_positions.load(std::memory_order_relaxed));
+}
+
+bool SharedRingBuffer::TryRequestRewrite(uint32_t chunk_index,
+                                         uint32_t* observed) {
+  PERFETTO_DCHECK(ChunkStateOf(*observed) == ChunkState::kBeingWritten);
+
+  // On success, release finishes the reader's copy before the writer sees the
+  // rewrite request and relocates its suffix. On failure, the reader discards
+  // its copy and retries the position with a new acquire load.
+  return chunk_state_word_at(chunk_index)
+      ->compare_exchange_strong(
+          *observed,
+          ReplaceChunkState(*observed, ChunkState::kRewriteRequested),
+          std::memory_order_release, std::memory_order_relaxed);
+}
+
+bool SharedRingBuffer::TryMoveFreeChunkToNextWrap(uint32_t position,
+                                                  uint32_t* observed) {
+  PERFETTO_DCHECK(ChunkStateOf(*observed) == ChunkState::kFree);
+
+  // Release finishes the reader's accesses before the next writer claims the
+  // chunk. On failure, the reader retries the position with a new acquire
+  // load.
+  return chunk_state_word_at(ChunkIndexOfPosition(position, num_chunks_))
+      ->compare_exchange_strong(*observed, MakeFreeWordForNextWrap(position),
+                                std::memory_order_release,
+                                std::memory_order_relaxed);
+}
+
+bool SharedRingBuffer::TryReleaseCompleteChunkAsFree(uint32_t position,
+                                                     uint32_t* observed) {
+  PERFETTO_DCHECK(ChunkStateOf(*observed) == ChunkState::kComplete);
+
+  // Release finishes the reader's copy before the next writer uses the chunk.
+  // On failure, the reader discards its copy and retries the position with a
+  // new acquire load.
+  return chunk_state_word_at(ChunkIndexOfPosition(position, num_chunks_))
+      ->compare_exchange_strong(*observed, MakeFreeWordForNextWrap(position),
+                                std::memory_order_release,
+                                std::memory_order_relaxed);
+}
+
+bool SharedRingBuffer::TryReleaseRewriteAcknowledgedChunkAsFree(
+    uint32_t position,
+    uint32_t* observed) {
+  // RewriteAcknowledged has one canonical encoding. Compare against that exact
+  // word rather than trusting a value that merely decodes to the same state.
+  uint32_t expected = kRewriteAcknowledgedStateWord;
+  // Acquire observes the writer's final release. Release hands the chunk to the
+  // next writer. Only the reader may leave RewriteAcknowledged, so failure is a
+  // protocol error.
+  const bool reclaimed =
+      chunk_state_word_at(ChunkIndexOfPosition(position, num_chunks_))
+          ->compare_exchange_strong(expected, MakeFreeWordForNextWrap(position),
+                                    std::memory_order_acq_rel,
+                                    std::memory_order_relaxed);
+  if (!reclaimed)
+    *observed = expected;
+  return reclaimed;
+}
+
+// --- Backpressure. ---
+
+// static
+bool SharedRingBuffer::SupportsWriterWait() {
+  return PERFETTO_TRACING_V2_HAS_FUTEX();
+}
+
+SharedRingBuffer::WriterWaitResult SharedRingBuffer::WaitForReadPosChange(
+    uint32_t expected_read_pos,
+    uint32_t timeout_ms) {
+  PERFETTO_DCHECK(timeout_ms > 0);
+#if !PERFETTO_TRACING_V2_HAS_FUTEX()
+  base::ignore_result(expected_read_pos);
+  base::ignore_result(timeout_ms);
+  return WriterWaitResult::kUnavailable;
+#else
+  RingBufferHeader* ring_header = header();
+
+  // num_writers_waiting and rw_positions are separate atomics. Without the two
+  // seq_cst fences, this execution would be possible:
+  //
+  //   writer                              reader
+  //   ------                              ------
+  //   increment num_writers_waiting       publish read_pos
+  //   read the old read_pos               read zero waiters
+  //
+  // The writer would sleep after the reader skipped the wake. The paired
+  // fences forbid both loads from missing the other side's store. The waiter
+  // count carries no data, so the operations around the fences stay relaxed.
+  ring_header->num_writers_waiting.fetch_add(1, std::memory_order_relaxed);
+  std::atomic_thread_fence(std::memory_order_seq_cst);
+
+  WriterWaitResult result = WriterWaitResult::kRetry;
+  if (ReadPosOf(ring_header->rw_positions.load(std::memory_order_relaxed)) ==
+      expected_read_pos) {
+    struct timespec timeout{};
+    timeout.tv_sec = static_cast<time_t>(timeout_ms / 1000);
+    timeout.tv_nsec = static_cast<long>((timeout_ms % 1000) * 1000000);
+
+    // FUTEX_WAIT checks the value again before sleeping. If read_pos changed
+    // after the load above, the syscall returns EAGAIN and no wake is lost.
+    const int futex_result =
+        FutexSyscall(ReadPosFutexWord(&ring_header->rw_positions),
+                     FUTEX_WAIT_PRIVATE, expected_read_pos, &timeout);
+    if (futex_result != 0) {
+      const int wait_errno = errno;
+      result = ClassifyWriterWaitErrno(wait_errno);
+      if (result == WriterWaitResult::kUnavailable) {
+        errno = wait_errno;
+        PERFETTO_DPLOG("tracing v2: futex wait on read_pos failed");
+      }
+    }
+  }
+
+  ring_header->num_writers_waiting.fetch_sub(1, std::memory_order_relaxed);
+  return result;
+#endif  // PERFETTO_TRACING_V2_HAS_FUTEX()
+}
+
+void SharedRingBuffer::PublishReadPos(uint32_t read_pos) {
+  // The initial load is relaxed: it is only the first expected value for the
+  // compare-and-swap below.
+  PublishReadPosImpl(header()->rw_positions.load(std::memory_order_relaxed),
+                     read_pos);
+}
+
+void SharedRingBuffer::PublishReadPosImpl(uint64_t expected_rw_positions,
+                                          uint32_t read_pos) {
+  RingBufferHeader* ring_header = header();
+
+  // Only the reader changes read_pos, so this can lose only to a writer moving
+  // write_pos. Release publishes the chunk reclaims before their capacity.
+  // Failure only supplies the newer write_pos to preserve on the next attempt.
+  while (!ring_header->rw_positions.compare_exchange_weak(
+      expected_rw_positions, ReplaceReadPos(expected_rw_positions, read_pos),
+      std::memory_order_release, std::memory_order_relaxed)) {
+  }
+
+#if PERFETTO_TRACING_V2_HAS_FUTEX()
+  // Pairs with the fence in WaitForReadPosChange(); see the missed-wake
+  // schedule there.
+  std::atomic_thread_fence(std::memory_order_seq_cst);
+
+  if (ring_header->num_writers_waiting.load(std::memory_order_relaxed) == 0)
+    return;
+
+  // Wake everyone: one drain pass can free many chunks, so waking a single
+  // waiter would leave capacity unused. This runs once per pass, not once per
+  // reclaimed chunk.
+  //
+  // Waits are bounded, so a failed wake delays writers but cannot strand them.
+  if (FutexSyscall(ReadPosFutexWord(&ring_header->rw_positions),
+                   FUTEX_WAKE_PRIVATE, static_cast<uint32_t>(INT32_MAX),
+                   nullptr) < 0) {
+    PERFETTO_DPLOG("tracing v2: futex wake on read_pos failed");
+  }
+#endif  // PERFETTO_TRACING_V2_HAS_FUTEX()
+}
+
+// --- Testing. ---
+
+// static
+std::optional<size_t> SharedRingBuffer::ComputeAllocationSizeForTesting(
+    uint32_t num_chunks,
+    uint32_t chunk_size,
+    size_t page_size) {
+  return ComputeAllocationSize(num_chunks, chunk_size, page_size);
+}
+
+// static
+SharedRingBuffer::WriterWaitResult
+SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(int wait_errno) {
+  return ClassifyWriterWaitErrno(wait_errno);
+}
+
+uint32_t SharedRingBuffer::num_writers_waiting_for_testing() const {
+  return header()->num_writers_waiting.load(std::memory_order_relaxed);
+}
+
+uint32_t SharedRingBuffer::read_pos_for_testing() const {
+  return ReadPosOf(header()->rw_positions.load(std::memory_order_relaxed));
+}
+
+SharedRingBuffer::Reservation SharedRingBuffer::TryReserveWritePosForTesting(
+    uint64_t initial_rw_positions) {
+  return TryReserveWritePosImpl(initial_rw_positions);
+}
+
+void SharedRingBuffer::PublishReadPosForTesting(uint64_t initial_rw_positions,
+                                                uint32_t read_pos) {
+  PublishReadPosImpl(initial_rw_positions, read_pos);
+}
+
+void SharedRingBuffer::SetStateWordForTesting(uint32_t chunk_index,
+                                              uint32_t state_word) {
+  chunk_state_word_at(chunk_index)
+      ->store(state_word, std::memory_order_release);
+}
+
+void SharedRingBuffer::SetWritePosForTesting(uint32_t write_pos) {
+  RingBufferHeader* ring_header = header();
+  const uint64_t rw_positions =
+      ring_header->rw_positions.load(std::memory_order_relaxed);
+  ring_header->rw_positions.store(ReplaceWritePos(rw_positions, write_pos),
+                                  std::memory_order_relaxed);
+}
+
+void SharedRingBuffer::SetPositionsForTesting(uint32_t position) {
+  for (uint32_t chunk_index = 0; chunk_index < num_chunks_; ++chunk_index) {
+    // The first position at or after |position| that maps to this chunk.
+    const uint32_t first_position =
+        position + ((chunk_index - position) & (num_chunks_ - 1));
+    chunk_state_word_at(chunk_index)
+        ->store(MakeFreeStateWord(
+                    WrapCountForPosition(first_position, chunk_index_bits_)),
+                std::memory_order_relaxed);
+  }
+  header()->rw_positions.store(PackRwPositions(position, position),
+                               std::memory_order_release);
+}
+
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer.h b/src/tracing/v2/shared_ring_buffer.h
new file mode 100644
index 0000000..875e730
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer.h
@@ -0,0 +1,261 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACING_V2_SHARED_RING_BUFFER_H_
+#define SRC_TRACING_V2_SHARED_RING_BUFFER_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include <atomic>
+#include <memory>
+#include <optional>
+
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/paged_memory.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+namespace perfetto::tracing_v2 {
+
+// Owns the producer-local ring and implements its atomic transitions.
+//
+// SharedRingBufferWriter calls the writer-side methods concurrently.
+// SharedRingBufferReader is the only caller of the reader-side methods. This
+// is the only class that updates rw_positions or a chunk state word, and only
+// the reader writes Free.
+class SharedRingBuffer {
+ public:
+  // |num_chunks| must be a power of two in [1, 2^30]. |chunk_size| must be at
+  // least 256 bytes and a multiple of four. Returns nullptr if the parameters
+  // are invalid, the allocation size overflows, or the allocation fails.
+  static std::unique_ptr<SharedRingBuffer> Create(uint32_t num_chunks,
+                                                  uint32_t chunk_size);
+
+  ~SharedRingBuffer();
+
+  SharedRingBuffer(const SharedRingBuffer&) = delete;
+  SharedRingBuffer& operator=(const SharedRingBuffer&) = delete;
+  SharedRingBuffer(SharedRingBuffer&&) = delete;
+  SharedRingBuffer& operator=(SharedRingBuffer&&) = delete;
+
+  // Immutable for the life of the ring.
+  uint32_t num_chunks() const { return num_chunks_; }
+  uint32_t chunk_size() const { return chunk_size_; }
+  uint32_t chunk_index_bits() const { return chunk_index_bits_; }
+
+  uint8_t* chunk_at(uint32_t chunk_index) {
+    PERFETTO_DCHECK(chunk_index < num_chunks_);
+    return chunks_begin_ + static_cast<size_t>(chunk_index) * chunk_size_;
+  }
+  const uint8_t* chunk_at(uint32_t chunk_index) const {
+    PERFETTO_DCHECK(chunk_index < num_chunks_);
+    return chunks_begin_ + static_cast<size_t>(chunk_index) * chunk_size_;
+  }
+
+  // Writer-side reservation.
+  //
+  // Reserving a position and acquiring its physical chunk are deliberately
+  // separate operations. TryReserveWritePos() advances write_pos first.
+  // The writer then changes Free(wrap_count(position)) to BeingWritten. If it
+  // is descheduled between the two, the reader resolves that position as an
+  // unclaimed hole and moves the Free word to the next wrap. The delayed
+  // writer's exact compare-and-swap then fails, so it cannot publish behind
+  // the reader.
+
+  enum class ReserveResult {
+    kReserved,
+    // num_chunks positions are already outstanding. Nothing was reserved and
+    // no hole was created; a stalling policy may wait and try again.
+    kFull,
+  };
+
+  struct Reservation {
+    ReserveResult result = ReserveResult::kFull;
+    // Valid only for kReserved. This is a position in the reservation order,
+    // not a physical chunk index.
+    uint32_t position = 0;
+    // The read_pos used for the capacity check. A writer that receives kFull
+    // passes this to WaitForReadPosChange() to avoid a lost wakeup.
+    uint32_t read_pos_sample = 0;
+  };
+
+  // Reserves the next position if the ring has room. A failed
+  // compare-and-swap did not reserve anything, so the operation retries with
+  // the positions returned by the compare-and-swap.
+  Reservation TryReserveWritePos();
+
+  // Writer-side chunk transitions.
+
+  // Free(wrap_count(position)) -> BeingWritten. |being_written_word| must be an
+  // BeingWritten word for this writer with zero fragments.
+  //
+  // If the compare-and-swap fails, this reservation becomes a hole. Do not
+  // retry it against the returned word; reserve a later position.
+  bool TryAcquireChunkForWriting(uint32_t position,
+                                 uint32_t being_written_word);
+
+  // BeingWritten -> Complete. On failure, |*observed| receives the current
+  // word. It must be RewriteRequested with the same contents; no other actor
+  // may change a chunk while this writer owns it.
+  bool TrySetChunkComplete(uint32_t chunk_index,
+                           uint32_t* observed,
+                           uint32_t complete_word);
+
+  // Complete -> BeingWritten, for a writer taking its own cached chunk back to
+  // append more fragments. Failure means the reader reclaimed it first; the
+  // writer just drops its handle.
+  bool TryReacquireChunkForWriting(uint32_t chunk_index, uint32_t observed);
+
+  // RewriteRequested -> RewriteAcknowledged after the writer has stopped
+  // touching the old chunk. Failure is a protocol error.
+  bool TryAcknowledgeRewrite(uint32_t chunk_index, uint32_t observed);
+
+  // Reader side.
+
+  // Returns the current chunk state word. A writer publishes fragments before
+  // changing this word, so the returned word also makes those fragments
+  // visible to the reader.
+  uint32_t LoadChunkStateWord(uint32_t chunk_index) const;
+
+  // Loads write_pos once. A writer may reserve the next position concurrently;
+  // the reader will see it on its next pass.
+  uint32_t LoadWritePos() const;
+
+  // BeingWritten -> RewriteRequested, passing format, flags, num_fragments and
+  // the WriterID through untouched. On failure |*observed| receives the word
+  // that won the race.
+  bool TryRequestRewrite(uint32_t chunk_index, uint32_t* observed);
+
+  // The following transitions are the only ones that expose a chunk to the
+  // next pass around the ring. The new wrap count comes from |position|, not
+  // from the old state word.
+
+  // Free(wrap_count(position)) -> Free(next_wrap(position)). This consumes a
+  // position whose writer never entered BeingWritten and prepares the chunk
+  // for position + num_chunks.
+  bool TryMoveFreeChunkToNextWrap(uint32_t position, uint32_t* observed);
+
+  // Complete -> Free(next_wrap(position)).
+  bool TryReleaseCompleteChunkAsFree(uint32_t position, uint32_t* observed);
+
+  // RewriteAcknowledged -> Free(next_wrap(position)). Failure is a protocol
+  // error and updates |*observed| with the unexpected word.
+  bool TryReleaseRewriteAcknowledgedChunkAsFree(uint32_t position,
+                                                uint32_t* observed);
+
+  // Backpressure: the writer's full-ring path.
+  //
+  // Writers wait on read_pos; the reader publishes a new value and wakes them.
+  // The waiter count only avoids an unnecessary wake syscall. Capacity is
+  // always decided from rw_positions.
+
+  enum class WriterWaitResult {
+    // The writer must recheck capacity. This also covers interrupted and
+    // spurious wakes.
+    kRetry,
+    kTimedOut,
+    // This build or kernel cannot provide the wait. Do not retry the syscall.
+    kUnavailable,
+  };
+
+  // Whether WaitForReadPosChange() is implemented on this platform.
+  static bool SupportsWriterWait();
+
+  // Blocks until read_pos changes or |timeout_ms| elapses.
+  // |expected_read_pos| must be the sample used for the kFull result.
+  WriterWaitResult WaitForReadPosChange(uint32_t expected_read_pos,
+                                        uint32_t timeout_ms);
+
+  // Publishes read_pos without overwriting a concurrent write_pos update, then
+  // wakes waiting writers. Called once per drain pass, so the shared read_pos
+  // can lag the reader's local value; it only under-reports free capacity.
+  void PublishReadPos(uint32_t read_pos);
+
+  // Testing.
+
+  // Exposes the allocation-size calculation for overflow tests.
+  static std::optional<size_t> ComputeAllocationSizeForTesting(
+      uint32_t num_chunks,
+      uint32_t chunk_size,
+      size_t page_size);
+
+  // Exposes the futex error policy without requiring a failing syscall.
+  static WriterWaitResult ClassifyWriterWaitErrnoForTesting(int wait_errno);
+
+  // Diagnostics only. Never a correctness input.
+  uint32_t num_writers_waiting_for_testing() const;
+  uint32_t read_pos_for_testing() const;
+
+  // Starts the production CAS loops with |initial_rw_positions|. Tests pass an
+  // old value to force the first compare-and-swap to fail.
+  Reservation TryReserveWritePosForTesting(uint64_t initial_rw_positions);
+  void PublishReadPosForTesting(uint64_t initial_rw_positions,
+                                uint32_t read_pos);
+
+  // Injects a state word for corruption and unknown-ABI tests.
+  void SetStateWordForTesting(uint32_t chunk_index, uint32_t state_word);
+
+  // Injects a write_pos without reserving the intervening positions.
+  void SetWritePosForTesting(uint32_t write_pos);
+
+  // Seeds a valid ring state near a position or wrap-count rollover.
+  void SetPositionsForTesting(uint32_t position);
+
+ private:
+  SharedRingBuffer(base::PagedMemory ring_memory,
+                   uint32_t num_chunks,
+                   uint32_t chunk_size);
+
+  // CAS loops shared by the production entry points and deterministic race
+  // tests. Production starts them with a freshly loaded rw_positions value;
+  // tests can supply an older value to force the first CAS to fail.
+  Reservation TryReserveWritePosImpl(uint64_t expected_rw_positions);
+  void PublishReadPosImpl(uint64_t expected_rw_positions, uint32_t read_pos);
+
+  // Shared-memory address and wrap-count helpers.
+
+  std::atomic<uint32_t>* chunk_state_word_at(uint32_t chunk_index) {
+    return reinterpret_cast<std::atomic<uint32_t>*>(chunk_at(chunk_index));
+  }
+  const std::atomic<uint32_t>* chunk_state_word_at(uint32_t chunk_index) const {
+    return reinterpret_cast<const std::atomic<uint32_t>*>(
+        chunk_at(chunk_index));
+  }
+
+  // Returns the Free word for the next position that uses the same chunk.
+  uint32_t MakeFreeWordForNextWrap(uint32_t position) const {
+    // Deriving the value from the next position also handles uint32_t rollover.
+    return MakeFreeStateWord(
+        WrapCountForPosition(position + num_chunks_, chunk_index_bits_));
+  }
+
+  RingBufferHeader* header() {
+    return static_cast<RingBufferHeader*>(ring_memory_.Get());
+  }
+  const RingBufferHeader* header() const {
+    return static_cast<const RingBufferHeader*>(ring_memory_.Get());
+  }
+
+  base::PagedMemory ring_memory_;
+  uint8_t* const chunks_begin_;
+  const uint32_t num_chunks_;
+  const uint32_t chunk_size_;
+  const uint32_t chunk_index_bits_;
+};
+
+}  // namespace perfetto::tracing_v2
+
+#endif  // SRC_TRACING_V2_SHARED_RING_BUFFER_H_
diff --git a/src/tracing/v2/shared_ring_buffer_concurrency_unittest.cc b/src/tracing/v2/shared_ring_buffer_concurrency_unittest.cc
new file mode 100644
index 0000000..8fe6616
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_concurrency_unittest.cc
@@ -0,0 +1,721 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// The three shared-ring races, run for real on two threads, plus an
+// MPSC stress test that checks the end-to-end guarantee: every fragment that
+// was published comes out exactly once, in the order its writer wrote it,
+// except for the ones the writer itself accounted as dropped.
+//
+// These are the tests worth running under ThreadSanitizer. Nothing here uses a
+// sleep to make a race likely: the threads meet on a spin barrier, and each
+// iteration asserts that exactly one of the two contenders won.
+
+#include <stdint.h>
+#include <string.h>
+
+#include <atomic>
+#include <map>
+#include <memory>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/time.h"
+#include "perfetto/ext/base/no_destructor.h"
+#include "perfetto/ext/base/utils.h"
+#include "perfetto/tracing/buffer_exhausted_policy.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/shared_ring_buffer_reader.h"
+#include "src/tracing/v2/shared_ring_buffer_writer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+constexpr WriterID kWriterA = 7;
+constexpr WriterID kWriterB = 8;
+constexpr BufferID kBuffer = 5;
+
+class NoopSharedRingBufferWriterDelegate
+    : public SharedRingBufferWriter::Delegate {
+ public:
+  void NotifyReader() override {}
+};
+
+SharedRingBufferWriter::Delegate* GetNoopSharedRingBufferWriterDelegate() {
+  static base::NoDestructor<NoopSharedRingBufferWriterDelegate> delegate;
+  return &delegate.ref();
+}
+
+uint32_t BeingWrittenWord(WriterID writer) {
+  return MakeDataStateWord(ChunkState::kBeingWritten,
+                           ChunkFormat::kTargetBuffer, 0, 0, writer);
+}
+
+// Sequences two threads through a fixed schedule. Each round has three steps:
+// the reader thread prepares the chunk, then the two contenders take their
+// compare-and-swap turns in an order that alternates every round. That makes
+// *both* winners of a two-party race reachable on demand instead of depending
+// on which thread happens to leave a barrier first - which, with a real
+// barrier, is always the same one.
+//
+// The atomics under test still cross a genuine thread boundary, which is what
+// makes these worth running under ThreadSanitizer.
+//
+// Every wait is bounded. When a step never arrives - the other thread failed
+// an assertion, timed out, or returned early - the waiter reports one failure
+// naming the missing step and aborts the schedule, which unblocks every other
+// waiter so the test can join its threads instead of spinning forever.
+class StepSequencer {
+ public:
+  static constexpr uint32_t kStepsPerRound = 3;
+
+  // Returns false when the schedule was aborted, either here on a timeout or
+  // by anybody else. The caller must stop sequencing; the failure has already
+  // been reported by whoever aborted first.
+  bool WaitForStep(uint64_t step) {
+    const base::TimeMillis deadline =
+        base::GetWallTimeMs() + base::TimeMillis(30000);
+    for (;;) {
+      if (aborted_.load(std::memory_order_acquire))
+        return false;
+      if (step_.load(std::memory_order_acquire) == step)
+        return true;
+      if (base::GetWallTimeMs() >= deadline) {
+        ADD_FAILURE() << "Timed out waiting for sequencer step " << step
+                      << " (round " << step / kStepsPerRound
+                      << "); the other thread stopped advancing the schedule";
+        Abort();
+        return false;
+      }
+      std::this_thread::yield();
+    }
+  }
+  void FinishStep(uint64_t step) {
+    step_.store(step + 1, std::memory_order_release);
+  }
+  // Unblocks every WaitForStep(), now and in the future. Harmless once the
+  // schedule has completed, so the tests below call it unconditionally before
+  // joining their threads.
+  void Abort() { aborted_.store(true, std::memory_order_release); }
+
+  // Step at which |round|'s preparation happens.
+  static uint64_t PrepareStep(uint32_t round) {
+    return uint64_t{round} * kStepsPerRound;
+  }
+  // Step at which the reader or the writer takes its turn in |round|. The
+  // reader goes first on even rounds and second on odd ones.
+  static uint64_t ReaderStep(uint32_t round) {
+    return PrepareStep(round) + (round % 2 == 0 ? 1 : 2);
+  }
+  static uint64_t WriterStep(uint32_t round) {
+    return PrepareStep(round) + (round % 2 == 0 ? 2 : 1);
+  }
+
+ private:
+  std::atomic<uint64_t> step_{0};
+  std::atomic<bool> aborted_{false};
+};
+
+// ---------------------------------------------------------------------------
+// Race 1: a writer claims while the reader consumes an unclaimed position.
+// Both compare against Free(wrap_count(p)).
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferConcurrencyTest, ClaimVersusUnclaimedAdvance) {
+  constexpr uint32_t kRounds = 1000;
+  constexpr uint32_t kNumChunks = 2;
+  auto ring = SharedRingBuffer::Create(kNumChunks, 256);
+  ASSERT_NE(ring, nullptr);
+
+  StepSequencer sequencer;
+  std::atomic<uint32_t> claims_won{0};
+  std::atomic<uint32_t> advances_won{0};
+  // Round r resolves position r, so which physical chunk and which wrap count
+  // are in play changes every round.
+  std::thread writer([&] {
+    for (uint32_t round = 0; round < kRounds; ++round) {
+      const uint64_t step = StepSequencer::WriterStep(round);
+      if (!sequencer.WaitForStep(step))
+        return;
+      if (ring->TryAcquireChunkForWriting(round, BeingWrittenWord(kWriterA)))
+        claims_won.fetch_add(1, std::memory_order_relaxed);
+      sequencer.FinishStep(step);
+    }
+  });
+  // Also runs when an ASSERT below returns early: the abort unblocks the
+  // writer, so the join cannot hang and no thread outlives the test.
+  auto join_writer = base::OnScopeExit([&] {
+    sequencer.Abort();
+    if (writer.joinable())
+      writer.join();
+  });
+
+  for (uint32_t round = 0; round < kRounds; ++round) {
+    const uint32_t position = round;
+    const uint32_t chunk_index = ChunkIndexOfPosition(position, kNumChunks);
+
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round)))
+      return;
+    uint32_t observed = ring->LoadChunkStateWord(chunk_index);
+    ASSERT_EQ(ChunkStateOf(observed), ChunkState::kFree) << round;
+    sequencer.FinishStep(StepSequencer::PrepareStep(round));
+
+    const uint64_t step = StepSequencer::ReaderStep(round);
+    if (!sequencer.WaitForStep(step))
+      return;
+    const bool advanced = ChunkStateOf(observed) == ChunkState::kFree &&
+                          ring->TryMoveFreeChunkToNextWrap(position, &observed);
+    if (advanced)
+      advances_won.fetch_add(1, std::memory_order_relaxed);
+    sequencer.FinishStep(step);
+
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round + 1)))
+      return;
+    const uint32_t after = ring->LoadChunkStateWord(chunk_index);
+    if (advanced) {
+      // The reader consumed the hole and prepared the chunk for the writer
+      // holding position + num_chunks. The writer that lost holds |position|
+      // and has spent its one claim attempt.
+      ASSERT_EQ(ChunkStateOf(after), ChunkState::kFree) << round;
+      ASSERT_EQ(
+          WrapCountOf(after),
+          WrapCountForPosition(position + kNumChunks, ring->chunk_index_bits()))
+          << round;
+      ASSERT_FALSE(
+          ring->TryAcquireChunkForWriting(position, BeingWrittenWord(kWriterB)))
+          << round;
+    } else {
+      ASSERT_EQ(ChunkStateOf(after), ChunkState::kBeingWritten) << round;
+      // Put the chunk back the way the writer's publication and the reader's
+      // reclaim would, so the next round starts from a free word again.
+      uint32_t being_written = BeingWrittenWord(kWriterA);
+      const uint32_t complete = MakeDataStateWord(
+          ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0, 0, kWriterA);
+      ASSERT_TRUE(
+          ring->TrySetChunkComplete(chunk_index, &being_written, complete))
+          << round;
+      uint32_t to_reclaim = complete;
+      ASSERT_TRUE(ring->TryReleaseCompleteChunkAsFree(position, &to_reclaim))
+          << round;
+    }
+  }
+  writer.join();
+
+  // Exactly one contender wins every round, and the schedule reaches both.
+  EXPECT_EQ(claims_won.load() + advances_won.load(), kRounds);
+  EXPECT_EQ(claims_won.load(), kRounds / 2);
+  EXPECT_EQ(advances_won.load(), kRounds / 2);
+}
+
+// ---------------------------------------------------------------------------
+// Race 2: a writer publishes while the reader scrapes.
+// Both compare against BeingWritten(w,n).
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferConcurrencyTest, PublishVersusScrape) {
+  constexpr uint32_t kRounds = 1000;
+  auto ring = SharedRingBuffer::Create(2, 256);
+  ASSERT_NE(ring, nullptr);
+
+  StepSequencer sequencer;
+  std::atomic<uint32_t> publishes_won{0};
+  std::atomic<uint32_t> scrapes_won{0};
+  const uint32_t kComplete = MakeDataStateWord(
+      ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0, 1, kWriterA);
+
+  std::thread writer([&] {
+    for (uint32_t round = 0; round < kRounds; ++round) {
+      const uint64_t step = StepSequencer::WriterStep(round);
+      if (!sequencer.WaitForStep(step))
+        return;
+      uint32_t observed = BeingWrittenWord(kWriterA);
+      if (ring->TrySetChunkComplete(0, &observed, kComplete)) {
+        publishes_won.fetch_add(1, std::memory_order_relaxed);
+      } else {
+        // The reader marking the chunk is the only legal way to lose, and the
+        // marked count says exactly which prefix it took.
+        EXPECT_EQ(ChunkStateOf(observed), ChunkState::kRewriteRequested);
+        EXPECT_EQ(WriterIdOf(observed), kWriterA);
+        EXPECT_EQ(NumFragmentsOf(observed), 0u);
+        EXPECT_TRUE(ring->TryAcknowledgeRewrite(0, observed));
+      }
+      sequencer.FinishStep(step);
+    }
+  });
+  auto join_writer = base::OnScopeExit([&] {
+    sequencer.Abort();
+    if (writer.joinable())
+      writer.join();
+  });
+
+  for (uint32_t round = 0; round < kRounds; ++round) {
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round)))
+      return;
+    ring->SetStateWordForTesting(0, BeingWrittenWord(kWriterA));
+    uint32_t observed = ring->LoadChunkStateWord(0);
+    sequencer.FinishStep(StepSequencer::PrepareStep(round));
+
+    const uint64_t step = StepSequencer::ReaderStep(round);
+    if (!sequencer.WaitForStep(step))
+      return;
+    const bool marked = ring->TryRequestRewrite(0, &observed);
+    if (marked) {
+      scrapes_won.fetch_add(1, std::memory_order_relaxed);
+    } else {
+      // The writer completed, so the reader discards its speculative copy and
+      // retries the position.
+      ASSERT_EQ(ChunkStateOf(observed), ChunkState::kComplete) << round;
+      ASSERT_EQ(NumFragmentsOf(observed), 1u) << round;
+    }
+    sequencer.FinishStep(step);
+
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round + 1)))
+      return;
+    // A marked chunk comes back as RewriteAcknowledged: the writer released it
+    // and said nothing about who gets it next.
+    ASSERT_EQ(ring->LoadChunkStateWord(0),
+              marked ? kRewriteAcknowledgedStateWord : kComplete)
+        << round;
+  }
+  writer.join();
+
+  EXPECT_EQ(publishes_won.load() + scrapes_won.load(), kRounds);
+  EXPECT_EQ(publishes_won.load(), kRounds / 2);
+  EXPECT_EQ(scrapes_won.load(), kRounds / 2);
+}
+
+// ---------------------------------------------------------------------------
+// Race 3: a writer reuses while the reader consumes.
+// Both compare against Complete(w,n).
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferConcurrencyTest, ReuseVersusReclaim) {
+  constexpr uint32_t kRounds = 1000;
+  constexpr uint32_t kNumChunks = 2;
+  auto ring = SharedRingBuffer::Create(kNumChunks, 256);
+  ASSERT_NE(ring, nullptr);
+
+  StepSequencer sequencer;
+  std::atomic<uint32_t> reuses_won{0};
+  std::atomic<uint32_t> reclaims_won{0};
+  const uint32_t kComplete = MakeDataStateWord(
+      ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0, 1, kWriterA);
+
+  std::thread writer([&] {
+    for (uint32_t round = 0; round < kRounds; ++round) {
+      const uint64_t step = StepSequencer::WriterStep(round);
+      if (!sequencer.WaitForStep(step))
+        return;
+      if (ring->TryReacquireChunkForWriting(0, kComplete))
+        reuses_won.fetch_add(1, std::memory_order_relaxed);
+      sequencer.FinishStep(step);
+    }
+  });
+  auto join_writer = base::OnScopeExit([&] {
+    sequencer.Abort();
+    if (writer.joinable())
+      writer.join();
+  });
+
+  for (uint32_t round = 0; round < kRounds; ++round) {
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round)))
+      return;
+    ring->SetStateWordForTesting(0, kComplete);
+    uint32_t observed = ring->LoadChunkStateWord(0);
+    sequencer.FinishStep(StepSequencer::PrepareStep(round));
+
+    const uint64_t step = StepSequencer::ReaderStep(round);
+    if (!sequencer.WaitForStep(step))
+      return;
+    // Position 0 every round, so the reclaimed word is always Free of
+    // the wrap after position 0's.
+    const bool reclaimed = ring->TryReleaseCompleteChunkAsFree(0, &observed);
+    if (reclaimed) {
+      reclaims_won.fetch_add(1, std::memory_order_relaxed);
+    } else {
+      // The writer took the chunk back; the reader handles that on its next
+      // look, through the scrape path.
+      ASSERT_EQ(ChunkStateOf(observed), ChunkState::kBeingWritten) << round;
+      ASSERT_EQ(NumFragmentsOf(observed), 1u) << round;
+    }
+    sequencer.FinishStep(step);
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round + 1)))
+      return;
+  }
+  writer.join();
+
+  EXPECT_EQ(reuses_won.load() + reclaims_won.load(), kRounds);
+  EXPECT_EQ(reuses_won.load(), kRounds / 2);
+  EXPECT_EQ(reclaims_won.load(), kRounds / 2);
+}
+
+// ---------------------------------------------------------------------------
+// Race 4: a writer reserves while the reader publishes read_pos. Both
+// compare-and-swap the packed read/write positions, each changing only its own
+// half.
+//
+// The sequencer gives each whole call its own turn, so this checks that both
+// halves survive interleaved calls across a real thread boundary; it does not
+// force a compare-and-swap to lose. The forced CAS-loser schedules - an old
+// rw_positions value failing and the retry using the returned value - are
+// pinned deterministically in SharedRingBufferTest::ReservationCasLoses* and
+// PublicationCasLoses*.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferConcurrencyTest, ReservationVersusReadPosPublication) {
+  constexpr uint32_t kRounds = 1000;
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+
+  StepSequencer sequencer;
+  std::thread writer([&] {
+    for (uint32_t round = 0; round < kRounds; ++round) {
+      const uint64_t step = StepSequencer::WriterStep(round);
+      if (!sequencer.WaitForStep(step))
+        return;
+      const auto reservation = ring->TryReserveWritePos();
+      // Capacity always exists: the reader below never falls more than one
+      // position behind.
+      EXPECT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+      EXPECT_EQ(reservation.position, round);
+      sequencer.FinishStep(step);
+    }
+  });
+  auto join_writer = base::OnScopeExit([&] {
+    sequencer.Abort();
+    if (writer.joinable())
+      writer.join();
+  });
+
+  for (uint32_t round = 0; round < kRounds; ++round) {
+    // Round r starts at (write=r, read=r-1); the writer
+    // reserves position r while this side publishes read_pos = r, in an order
+    // that alternates every round.
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round)))
+      return;
+    sequencer.FinishStep(StepSequencer::PrepareStep(round));
+
+    const uint64_t step = StepSequencer::ReaderStep(round);
+    if (!sequencer.WaitForStep(step))
+      return;
+    ring->PublishReadPos(round);
+    sequencer.FinishStep(step);
+
+    // Whoever went second loaded a value already holding the other side's
+    // half and had to carry it through. Both moves must survive every round.
+    if (!sequencer.WaitForStep(StepSequencer::PrepareStep(round + 1)))
+      return;
+    ASSERT_EQ(ring->LoadWritePos(), round + 1) << round;
+    ASSERT_EQ(ring->read_pos_for_testing(), round) << round;
+  }
+  writer.join();
+}
+
+// ---------------------------------------------------------------------------
+// MPSC stress.
+// ---------------------------------------------------------------------------
+
+struct StressParams {
+  uint32_t num_writers;
+  uint32_t num_chunks;
+  uint32_t chunk_size;
+  uint32_t fragments_per_writer;
+  uint32_t seed_position;
+  BufferExhaustedPolicy policy;
+};
+
+// What a run actually did, so each test can assert that it exercised the path
+// it is named after rather than passing because nothing happened.
+struct StressStats {
+  uint64_t received = 0;
+  // The caller never got payload space for these.
+  uint64_t unwritten = 0;
+  // The reader scraped the chunk and there was no replacement capacity.
+  uint64_t dropped = 0;
+  // The reader scraped a chunk out from under a writer.
+  uint64_t relocations = 0;
+  // How many of the unwritten fragments were refused because the ring was
+  // structurally full, as opposed to because a reserved position's chunk could
+  // not be claimed. The two mean different things and a stalling policy is only
+  // supposed to produce the second.
+  uint64_t reported_full = 0;
+  uint32_t final_read_pos = 0;
+};
+
+class StressDelegate : public SharedRingBufferReader::Delegate {
+ public:
+  void OnChunkRead(
+      const SharedRingBufferReader::ChunkContents& contents) override {
+    for (uint32_t i = 0; i < contents.num_fragments; ++i) {
+      const SharedRingBufferReader::Fragment& fragment = contents.fragments[i];
+      ASSERT_GE(fragment.size, 8u);
+      uint32_t writer = 0;
+      uint32_t sequence = 0;
+      memcpy(&writer, fragment.data, sizeof(writer));
+      memcpy(&sequence, fragment.data + 4, sizeof(sequence));
+      ASSERT_EQ(writer, contents.writer_id);
+      received[writer].push_back(sequence);
+    }
+  }
+
+  void OnDataLoss(WriterID writer_id) override {
+    ADD_FAILURE() << "reader discarded a chunk from writer " << writer_id;
+  }
+
+  std::map<uint32_t, std::vector<uint32_t>> received;
+};
+
+void RunStress(const StressParams& params, StressStats* stats) {
+  auto ring = SharedRingBuffer::Create(params.num_chunks, params.chunk_size);
+  ASSERT_NE(ring, nullptr);
+  ring->SetPositionsForTesting(params.seed_position);
+
+  StressDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  reader.SetReadPosForTesting(params.seed_position);
+
+  std::atomic<uint32_t> writers_done{0};
+  // Set when the drain below gives up, so the writers stop producing and can
+  // be joined instead of racing an unresponsive protocol forever. Every
+  // blocking call a writer makes has a 30 second deadline, so a stopped writer
+  // leaves its loop within one OpenFragment() call.
+  std::atomic<bool> stop_writers{false};
+  std::vector<uint64_t> unwritten(params.num_writers, 0);
+  std::vector<uint64_t> dropped(params.num_writers, 0);
+  std::vector<uint64_t> relocations(params.num_writers, 0);
+  std::vector<uint64_t> reported_full(params.num_writers, 0);
+  std::vector<std::thread> writer_threads;
+
+  for (uint32_t w = 0; w < params.num_writers; ++w) {
+    writer_threads.emplace_back([&, w] {
+      SharedRingBufferWriter writer(ring.get(), static_cast<WriterID>(w + 1),
+                                    kBuffer, params.policy,
+                                    GetNoopSharedRingBufferWriterDelegate());
+      // A payload that varies in size so that chunk boundaries, reuse and the
+      // 255-fragment cap all get exercised rather than one fixed shape.
+      std::vector<uint8_t> payload(64);
+      for (uint32_t n = 0; n < params.fragments_per_writer; ++n) {
+        if (stop_writers.load(std::memory_order_relaxed))
+          break;
+        const uint32_t size = 8 + (n % 40);
+        const uint32_t writer_tag = w + 1;
+        memcpy(payload.data(), &writer_tag, sizeof(writer_tag));
+        memcpy(payload.data() + 4, &n, sizeof(n));
+
+        const SharedRingBufferWriter::FragmentSpan span =
+            writer.OpenFragment(size, false);
+        if (span.outcome != SharedRingBufferWriter::Outcome::kOk) {
+          ++unwritten[w];
+          if (span.outcome == SharedRingBufferWriter::Outcome::kFull)
+            ++reported_full[w];
+          writer.RecordDataLoss();
+          std::this_thread::yield();
+          continue;
+        }
+        memcpy(span.begin, payload.data(), size);
+        // Yield while the chunk is still BeingWritten, so the reader lands
+        // inside it and the scrape-and-relocate path is exercised for real.
+        // Yielding at a protocol boundary is deliberate: a sleep would only
+        // make the race likely, this makes it frequent.
+        if ((n & 7) == 0)
+          std::this_thread::yield();
+        writer.CloseFragment(size, false);
+      }
+      writer.FinishCurrentChunk();
+      dropped[w] = writer.num_fragments_dropped();
+      relocations[w] = writer.num_relocations();
+      writers_done.fetch_add(1, std::memory_order_release);
+    });
+  }
+
+  // Drain until every writer has finished and the ring has been emptied. The
+  // deadline is far beyond what a correct run needs, even under TSAN; hitting
+  // it means the protocol stopped making progress, and the test must fail
+  // rather than spin here forever.
+  const base::TimeMillis drain_deadline =
+      base::GetWallTimeMs() + base::TimeMillis(60000);
+  bool drain_timed_out = false;
+  for (;;) {
+    const bool all_done =
+        writers_done.load(std::memory_order_acquire) == params.num_writers;
+    const SharedRingBufferReader::DrainResult result = reader.Drain(64);
+    if (result.last_outcome == SharedRingBufferReader::Outcome::kProtocolError)
+      break;
+    if (all_done && !result.needs_another_drain())
+      break;
+    if (base::GetWallTimeMs() >= drain_deadline) {
+      drain_timed_out = true;
+      ADD_FAILURE() << "Stress drain made no progress within its deadline: "
+                    << writers_done.load() << "/" << params.num_writers
+                    << " writers done, read_pos " << reader.read_pos()
+                    << ", last outcome "
+                    << static_cast<int>(result.last_outcome);
+      break;
+    }
+    if (result.positions_resolved == 0)
+      std::this_thread::yield();
+  }
+
+  // Stop the writers - a no-op on the success path, where they are already
+  // done - and keep draining until they have all left their loops, so the
+  // joins below cannot wait on a writer parked on a full ring.
+  stop_writers.store(true, std::memory_order_relaxed);
+  const base::TimeMillis shutdown_deadline =
+      base::GetWallTimeMs() + base::TimeMillis(60000);
+  while (writers_done.load(std::memory_order_acquire) != params.num_writers &&
+         base::GetWallTimeMs() < shutdown_deadline) {
+    reader.Drain(64);
+    std::this_thread::yield();
+  }
+  for (std::thread& thread : writer_threads)
+    thread.join();
+  if (drain_timed_out)
+    return;  // Already failed; the accounting below would only add noise.
+  ASSERT_FALSE(reader.has_protocol_error());
+  reader.Drain(1u << 20);
+  stats->final_read_pos = reader.read_pos();
+
+  for (uint32_t w = 0; w < params.num_writers; ++w) {
+    const std::vector<uint32_t>& sequences = delegate.received[w + 1];
+    // Exactly once, and in the order the writer wrote them.
+    for (size_t i = 1; i < sequences.size(); ++i) {
+      ASSERT_LT(sequences[i - 1], sequences[i])
+          << "writer " << w << " fragment " << i;
+    }
+    // Everything that is missing is something the writer itself accounted for.
+    EXPECT_EQ(sequences.size() + unwritten[w] + dropped[w],
+              params.fragments_per_writer)
+        << "writer " << w;
+
+    stats->received += sequences.size();
+    stats->unwritten += unwritten[w];
+    stats->dropped += dropped[w];
+    stats->relocations += relocations[w];
+    stats->reported_full += reported_full[w];
+  }
+}
+
+// The reader only relocates when it lands inside a chunk a writer still owns,
+// which is a scheduling outcome and not something these tests can force from
+// the outside: roughly one run in a hundred sees none. Repeating a bounded
+// number of times keeps them from failing on one unlucky schedule.
+//
+// This is a coverage check, not a proof. That relocation happens, and what it
+// does to the payload and the flags, is settled deterministically by
+// PublishVersusScrape above and by the reader and writer unit tests. All
+// this asserts is that the stress ran with the contention it is named for.
+constexpr uint32_t kAttemptsForRelocations = 8;
+
+void RunStressUntilRelocations(const StressParams& params, StressStats* stats) {
+  for (uint32_t attempt = 0; attempt < kAttemptsForRelocations; ++attempt) {
+    RunStress(params, stats);
+    if (stats->relocations > 0 || testing::Test::HasFatalFailure())
+      return;
+  }
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressFourWritersDropPolicy) {
+  StressStats stats;
+  RunStressUntilRelocations({/*num_writers=*/4, /*num_chunks=*/8,
+                             /*chunk_size=*/256,
+                             /*fragments_per_writer=*/4000, /*seed_position=*/0,
+                             BufferExhaustedPolicy::kDrop},
+                            &stats);
+  // A drop policy on a small ring loses a lot, which is the point - but the
+  // exactly-once and ordering checks above are worthless if nothing got
+  // through. An absolute floor rather than a share of the total: how much a
+  // drop-policy run keeps depends on how fast the reader is scheduled, and
+  // under ThreadSanitizer that is a different number. What has to hold is that
+  // the per-writer checks ran on real data, not that the ring achieved a
+  // particular throughput.
+  EXPECT_GT(stats.received, 100u);
+  EXPECT_GT(stats.relocations, 0u);
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressTinyRingForcesHolesAndRelocations) {
+  StressStats stats;
+  RunStressUntilRelocations({/*num_writers=*/4, /*num_chunks=*/2,
+                             /*chunk_size=*/256,
+                             /*fragments_per_writer=*/2000, /*seed_position=*/0,
+                             BufferExhaustedPolicy::kDrop},
+                            &stats);
+  EXPECT_GT(stats.received, 0u);
+  // Two chunks and four writers is the shape that makes the reader land inside
+  // a live writer's chunk constantly.
+  EXPECT_GT(stats.relocations, 0u);
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressAcrossPositionRollover) {
+  // Seeded so the 32-bit positions roll over part way through, which is where a
+  // reader that incremented the wrap it found in the chunk would lock writers
+  // out.
+  StressStats stats;
+  RunStress({/*num_writers=*/4, /*num_chunks=*/16, /*chunk_size=*/512,
+             /*fragments_per_writer=*/3000,
+             /*seed_position=*/0u - 64u, BufferExhaustedPolicy::kDrop},
+            &stats);
+  EXPECT_GT(stats.received, 0u);
+  EXPECT_LT(stats.final_read_pos, 0u - 64u);
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressAcrossTheWrapIdentityRollover) {
+  // Seeded so the 16-bit wrap identity rolls over part way through - for four
+  // chunks that is every 4 * 65536 positions - while the 32-bit positions are
+  // nowhere near wrapping. The claim/advance arbitration runs right across the
+  // truncation boundary.
+  StressStats stats;
+  RunStress({/*num_writers=*/4, /*num_chunks=*/4, /*chunk_size=*/256,
+             /*fragments_per_writer=*/3000,
+             /*seed_position=*/4u * 65536 - 64u, BufferExhaustedPolicy::kDrop},
+            &stats);
+  EXPECT_GT(stats.received, 0u);
+  EXPECT_LT(WrapCountForPosition(stats.final_read_pos, 2),
+            WrapCountForPosition(4u * 65536 - 64u, 2));
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressStallThenDropPolicy) {
+  StressStats stats;
+  RunStress({/*num_writers=*/3, /*num_chunks=*/4, /*chunk_size=*/1024,
+             /*fragments_per_writer=*/2000, /*seed_position=*/0,
+             BufferExhaustedPolicy::kStallThenDrop},
+            &stats);
+  EXPECT_GT(stats.received, 0u);
+}
+
+TEST(SharedRingBufferConcurrencyTest, StressStallPolicyNeverReportsFull) {
+  StressStats stats;
+  RunStress({/*num_writers=*/3, /*num_chunks=*/8, /*chunk_size=*/1024,
+             /*fragments_per_writer=*/2000, /*seed_position=*/0,
+             BufferExhaustedPolicy::kStall},
+            &stats);
+  EXPECT_GT(stats.received, 0u);
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \
+    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
+  // A stalling writer waits for the reader to expose capacity, so a full ring
+  // never turns into a refusal. What it *can* still hit is a run of positions
+  // whose chunks are pinned by writers the reader has marked for rewrite: that
+  // is NoChunkAvailable, not Full, and the two are different events. Whatever
+  // is lost that way is accounted for exactly, which the per-writer check
+  // inside RunStress() has already asserted.
+  EXPECT_EQ(stats.reported_full, 0u);
+#endif
+}
+
+}  // namespace
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer_reader.cc b/src/tracing/v2/shared_ring_buffer_reader.cc
new file mode 100644
index 0000000..947a5e7
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_reader.cc
@@ -0,0 +1,249 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer_reader.h"
+
+#include <stdint.h>
+
+#include <utility>
+
+#include "perfetto/base/logging.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+namespace perfetto::tracing_v2 {
+
+SharedRingBufferReader::Delegate::~Delegate() = default;
+
+SharedRingBufferReader::SharedRingBufferReader(SharedRingBuffer* ring,
+                                               Delegate* delegate)
+    : ring_(ring),
+      delegate_(delegate),
+      num_chunks_(ring->num_chunks()),
+      chunk_size_(ring->chunk_size()),
+      chunk_index_bits_(ring->chunk_index_bits()) {
+  copied_payload_.reserve(MaxFragmentSizeForEmptyChunk(chunk_size_));
+  copied_fragments_.reserve(kMaxFragmentsPerChunk);
+}
+
+SharedRingBufferReader::~SharedRingBufferReader() = default;
+
+SharedRingBufferReader::DrainResult SharedRingBufferReader::Drain(
+    uint32_t max_positions) {
+  const uint32_t start_pos = read_pos_;
+  DrainResult result{};
+  for (uint32_t i = 0; i < max_positions; ++i) {
+    result.last_outcome = ResolveNextPosition();
+    if (result.last_outcome != Outcome::kChunkRead &&
+        result.last_outcome != Outcome::kSkipped) {
+      break;
+    }
+  }
+
+  result.positions_resolved = read_pos_ - start_pos;
+  if (result.positions_resolved != 0) {
+    // One publication and at most one wake cover the whole pass. Until this
+    // point writers can only under-estimate free capacity.
+    //
+    // TODO(sashwinbalaji): benchmark per-position publication against batch
+    // sizes such as 16, 64 and 256 before trusting any of them. Nothing here
+    // establishes that the caller's current pass size is optimal.
+    ring_->PublishReadPos(read_pos_);
+  }
+  return result;
+}
+
+SharedRingBufferReader::Outcome SharedRingBufferReader::ResolveNextPosition() {
+  if (has_protocol_error_)
+    return Outcome::kProtocolError;
+
+  // A stale write_pos only shortens this drain pass.
+  const uint32_t write_pos = ring_->LoadWritePos();
+  const uint32_t outstanding = NumOutstandingPositions(write_pos, read_pos_);
+  if (outstanding == 0)
+    return Outcome::kNoData;
+  if (outstanding > num_chunks_) {
+    // A legal writer cannot reserve more than num_chunks outstanding
+    // positions.
+    has_protocol_error_ = true;
+    PERFETTO_ELOG(
+        "tracing v2: stopping ring reader; write_pos %u is %u positions ahead "
+        "of read_pos %u, which is more than the %u chunks in the ring",
+        write_pos, outstanding, read_pos_, num_chunks_);
+    return Outcome::kProtocolError;
+  }
+
+  const uint32_t position = read_pos_;
+  const uint32_t chunk_index = ChunkIndexOfPosition(position, num_chunks_);
+
+  // A failed compare-and-swap replaces this with the current state word.
+  uint32_t observed = ring_->LoadChunkStateWord(chunk_index);
+
+  switch (ChunkStateOf(observed)) {
+    case ChunkState::kFree: {
+      if (observed != MakeFreeStateWord(
+                          WrapCountForPosition(position, chunk_index_bits_))) {
+        return StopOnProtocolError(observed);
+      }
+      // Nobody claimed this position. Prepare the chunk for its next wrap.
+      if (!ring_->TryMoveFreeChunkToNextWrap(position, &observed))
+        return Outcome::kRetryLater;
+      ++read_pos_;
+      ++num_positions_skipped_;
+      return Outcome::kSkipped;
+    }
+
+    case ChunkState::kBeingWritten: {
+      // This copy is speculative until the CAS below wins against the writer.
+      const CommittedPrefixStatus status =
+          CopyCommittedPrefix(chunk_index, observed);
+      if (before_arbitration_hook_for_testing_)
+        before_arbitration_hook_for_testing_();
+      // Malformed payload is still marked: validation does not decide
+      // ownership.
+      if (!ring_->TryRequestRewrite(chunk_index, &observed))
+        return Outcome::kRetryLater;
+      ++read_pos_;
+      ++num_scrapes_;
+      return ForwardCopiedChunk(status);
+    }
+
+    case ChunkState::kComplete: {
+      const CommittedPrefixStatus status =
+          CopyCommittedPrefix(chunk_index, observed);
+      if (before_arbitration_hook_for_testing_)
+        before_arbitration_hook_for_testing_();
+      if (!ring_->TryReleaseCompleteChunkAsFree(position, &observed))
+        return Outcome::kRetryLater;
+      ++read_pos_;
+      return ForwardCopiedChunk(status);
+    }
+
+    case ChunkState::kRewriteRequested:
+      // The writer still owns this chunk. Resolve the position as a hole.
+      ++read_pos_;
+      ++num_positions_skipped_;
+      return Outcome::kSkipped;
+
+    case ChunkState::kRewriteAcknowledged:
+      if (!ring_->TryReleaseRewriteAcknowledgedChunkAsFree(position, &observed))
+        return StopOnProtocolError(observed);
+      ++read_pos_;
+      ++num_positions_skipped_;
+      return Outcome::kSkipped;
+
+    case ChunkState::kReserved5:
+    case ChunkState::kReserved6:
+    case ChunkState::kReserved7:
+      // The reader cannot safely reclaim an unknown state.
+      return StopOnProtocolError(observed);
+  }
+}
+
+SharedRingBufferReader::CommittedPrefixStatus
+SharedRingBufferReader::CopyCommittedPrefix(uint32_t chunk_index,
+                                            uint32_t state_word) {
+  copied_fragments_.clear();
+  copied_chunk_ = ChunkContents{};
+  copied_chunk_.writer_id = WriterIdOf(state_word);
+  copied_chunk_.payload_flags = PayloadFlagsOf(state_word);
+
+  const uint32_t num_fragments = NumFragmentsOf(state_word);
+  if (num_fragments == 0) {
+    // Nothing is published, so there is nothing to copy - and nothing else in
+    // the chunk may be touched. In particular the target BufferID is stored
+    // while the writer exclusively owns a freshly claimed chunk and is
+    // published only by the first release transition out of kBeingWritten, so
+    // a BeingWritten word carrying no fragments does not order that store
+    // against this reader at all.
+    return CommittedPrefixStatus::kNoFragments;
+  }
+
+  if (ChunkFormatOf(state_word) != ChunkFormat::kTargetBuffer)
+    return CommittedPrefixStatus::kUnsupportedFormat;
+
+  const uint32_t capacity = chunk_size_ - kTargetBufferPayloadOffset;
+  const uint8_t* chunk = ring_->chunk_at(chunk_index);
+  const uint8_t* const payload_begin = chunk + kTargetBufferPayloadOffset;
+  const uint8_t* directory_cursor = chunk + chunk_size_;
+  uint32_t total = 0;
+  for (uint32_t i = 0; i < num_fragments; ++i) {
+    uint32_t fragment_size = 0;
+    if (!ReadFragmentSize(payload_begin, &directory_cursor, &fragment_size) ||
+        fragment_size > capacity - total) {
+      copied_fragments_.clear();
+      return CommittedPrefixStatus::kMalformed;
+    }
+    total += fragment_size;
+    copied_fragments_.push_back(Fragment{nullptr, fragment_size});
+  }
+
+  const uint32_t directory_bytes =
+      static_cast<uint32_t>(chunk + chunk_size_ - directory_cursor);
+  if (total > capacity - directory_bytes) {
+    copied_fragments_.clear();
+    return CommittedPrefixStatus::kMalformed;
+  }
+
+  copied_payload_.assign(chunk + kTargetBufferPayloadOffset,
+                         chunk + kTargetBufferPayloadOffset + total);
+  uint32_t offset = 0;
+  for (Fragment& fragment : copied_fragments_) {
+    fragment.data = copied_payload_.data() + offset;
+    offset += fragment.size;
+  }
+
+  copied_chunk_.target_buffer = LoadTargetBufferId(chunk);
+  copied_chunk_.fragments = copied_fragments_.data();
+  copied_chunk_.num_fragments = num_fragments;
+  return CommittedPrefixStatus::kReady;
+}
+
+SharedRingBufferReader::Outcome SharedRingBufferReader::ForwardCopiedChunk(
+    CommittedPrefixStatus status) {
+  switch (status) {
+    case CommittedPrefixStatus::kReady:
+      ++num_chunks_read_;
+      delegate_->OnChunkRead(copied_chunk_);
+      return Outcome::kChunkRead;
+    case CommittedPrefixStatus::kMalformed:
+      ++num_malformed_chunks_;
+      delegate_->OnDataLoss(copied_chunk_.writer_id);
+      break;
+    case CommittedPrefixStatus::kUnsupportedFormat:
+      ++num_unknown_format_chunks_;
+      delegate_->OnDataLoss(copied_chunk_.writer_id);
+      break;
+    case CommittedPrefixStatus::kNoFragments:
+      break;
+  }
+
+  ++num_positions_skipped_;
+  return Outcome::kSkipped;
+}
+
+SharedRingBufferReader::Outcome SharedRingBufferReader::StopOnProtocolError(
+    uint32_t state_word) {
+  // Latched, so this logs once per ring rather than once per drain pass.
+  has_protocol_error_ = true;
+  PERFETTO_ELOG(
+      "tracing v2: stopping ring reader at position %u; chunk state word "
+      "0x%08x is not something this build can arbitrate",
+      read_pos_, state_word);
+  return Outcome::kProtocolError;
+}
+
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer_reader.h b/src/tracing/v2/shared_ring_buffer_reader.h
new file mode 100644
index 0000000..a3d92ba
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_reader.h
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACING_V2_SHARED_RING_BUFFER_READER_H_
+#define SRC_TRACING_V2_SHARED_RING_BUFFER_READER_H_
+
+#include <stdint.h>
+
+#include <functional>
+#include <vector>
+
+#include "perfetto/ext/tracing/core/basic_types.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+namespace perfetto::tracing_v2 {
+
+// Reads packet fragments from one SharedRingBuffer.
+//
+// Each instance is used from one execution context. It resolves write
+// positions in order and never waits for a writer. If a writer still owns the
+// chunk, the reader takes its published prefix and leaves the writer to move
+// the unpublished suffix.
+//
+// Initially the reader runs in the producer process. It validates the fragment
+// directory in place and copies the published payload before calling the
+// delegate.
+//
+// TODO(sashwinbalaji): before moving SharedRingBufferReader into traced, copy
+// the published format header, directory and payload into reader-owned memory
+// before parsing them. A producer must not be able to change service-side
+// validation inputs.
+class SharedRingBufferReader {
+ public:
+  // Result of resolving one position.
+  enum class Outcome {
+    // read_pos has caught up with write_pos.
+    kNoData,
+    // A chunk was handed to the delegate and read_pos advanced.
+    kChunkRead,
+    // The position resolved to no payload - nobody claimed it, its writer is
+    // mid-rewrite, or the bytes could not be trusted - and read_pos advanced.
+    kSkipped,
+    // The chunk state changed before the reader completed its transition.
+    // read_pos is unchanged and the same position is retried later.
+    kRetryLater,
+    // Corruption or an incompatible ABI. This ring is left exactly as it was
+    // found and is never read again; the rest of the process is unaffected.
+    kProtocolError,
+  };
+
+  // A view into reader-owned scratch. Valid only for the duration of the
+  // Delegate call.
+  struct Fragment {
+    const uint8_t* data = nullptr;
+    uint32_t size = 0;
+  };
+
+  struct ChunkContents {
+    WriterID writer_id = 0;
+    BufferID target_buffer = 0;
+    // Any of the three PayloadFlags.
+    uint32_t payload_flags = 0;
+    const Fragment* fragments = nullptr;
+    uint32_t num_fragments = 0;
+  };
+
+  struct DrainResult {
+    uint32_t positions_resolved = 0;
+    Outcome last_outcome = Outcome::kNoData;
+
+    // The caller should schedule another Drain() call.
+    bool needs_another_drain() const {
+      return last_outcome != Outcome::kNoData &&
+             last_outcome != Outcome::kProtocolError;
+    }
+  };
+
+  class Delegate {
+   public:
+    virtual ~Delegate();
+
+    // Called after a chunk's published payload has been copied into
+    // reader-owned memory. The view is valid only for this call.
+    virtual void OnChunkRead(const ChunkContents&) = 0;
+
+    // The reader resolved committed data that it could not forward. The
+    // consumer should report the gap on the next packet from this writer.
+    virtual void OnDataLoss(WriterID) = 0;
+  };
+
+  SharedRingBufferReader(SharedRingBuffer* ring, Delegate* delegate);
+  ~SharedRingBufferReader();
+
+  SharedRingBufferReader(const SharedRingBufferReader&) = delete;
+  SharedRingBufferReader& operator=(const SharedRingBufferReader&) = delete;
+  SharedRingBufferReader(SharedRingBufferReader&&) = delete;
+  SharedRingBufferReader& operator=(SharedRingBufferReader&&) = delete;
+
+  // Resolves up to |max_positions|, then publishes read_pos once and wakes any
+  // writer parked on a full ring.
+  DrainResult Drain(uint32_t max_positions);
+
+  // Resolves at most one position. Drain() publishes read_pos once per pass.
+  Outcome ResolveNextPosition();
+
+  bool has_protocol_error() const { return has_protocol_error_; }
+  uint32_t read_pos() const { return read_pos_; }
+
+  // Diagnostics. Never inputs to a decision.
+  uint64_t num_positions_skipped() const { return num_positions_skipped_; }
+  uint64_t num_scrapes() const { return num_scrapes_; }
+  uint64_t num_chunks_read() const { return num_chunks_read_; }
+  uint64_t num_malformed_chunks() const { return num_malformed_chunks_; }
+  uint64_t num_unknown_format_chunks() const {
+    return num_unknown_format_chunks_;
+  }
+
+  // Invoked after the committed prefix has been copied and immediately before
+  // the compare-and-swap that arbitrates it. It exists so that a test can make
+  // the writer win that race deterministically instead of hoping for it.
+  // Nothing in production sets it.
+  void SetArbitrationHookForTesting(std::function<void()> hook) {
+    before_arbitration_hook_for_testing_ = std::move(hook);
+  }
+
+  // Starts the reader at |position| instead of zero, to match a ring seeded
+  // near uint32_t rollover.
+  void SetReadPosForTesting(uint32_t position) { read_pos_ = position; }
+
+ private:
+  enum class CommittedPrefixStatus {
+    kNoFragments,
+    kReady,
+    kMalformed,
+    kUnsupportedFormat,
+  };
+
+  // Validates and copies the committed prefix. A malformed or unknown format
+  // is dropped without changing the ownership transition chosen by the
+  // caller.
+  CommittedPrefixStatus CopyCommittedPrefix(uint32_t chunk_index,
+                                            uint32_t state_word);
+
+  Outcome ForwardCopiedChunk(CommittedPrefixStatus);
+  Outcome StopOnProtocolError(uint32_t state_word);
+
+  SharedRingBuffer* const ring_;
+  Delegate* const delegate_;
+  const uint32_t num_chunks_;
+  const uint32_t chunk_size_;
+  const uint32_t chunk_index_bits_;
+
+  // The reader owns this value and publishes it once per Drain().
+  uint32_t read_pos_ = 0;
+  bool has_protocol_error_ = false;
+  std::function<void()> before_arbitration_hook_for_testing_;
+
+  // Reader-owned scratch, sized once in the constructor so that draining
+  // allocates nothing.
+  std::vector<uint8_t> copied_payload_;
+  std::vector<Fragment> copied_fragments_;
+  ChunkContents copied_chunk_;
+
+  uint64_t num_positions_skipped_ = 0;
+  uint64_t num_scrapes_ = 0;
+  uint64_t num_chunks_read_ = 0;
+  uint64_t num_malformed_chunks_ = 0;
+  uint64_t num_unknown_format_chunks_ = 0;
+};
+
+}  // namespace perfetto::tracing_v2
+
+#endif  // SRC_TRACING_V2_SHARED_RING_BUFFER_READER_H_
diff --git a/src/tracing/v2/shared_ring_buffer_reader_unittest.cc b/src/tracing/v2/shared_ring_buffer_reader_unittest.cc
new file mode 100644
index 0000000..2d059ca
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_reader_unittest.cc
@@ -0,0 +1,847 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer_reader.h"
+
+#include <stdint.h>
+#include <string.h>
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "perfetto/ext/base/no_destructor.h"
+#include "perfetto/tracing/buffer_exhausted_policy.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/shared_ring_buffer_writer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+using Outcome = SharedRingBufferReader::Outcome;
+
+constexpr WriterID kWriterA = 7;
+constexpr WriterID kWriterB = 8;
+constexpr BufferID kBuffer = 0x1234;
+
+class NoopSharedRingBufferWriterDelegate
+    : public SharedRingBufferWriter::Delegate {
+ public:
+  void NotifyReader() override {}
+};
+
+SharedRingBufferWriter::Delegate* GetNoopSharedRingBufferWriterDelegate() {
+  static base::NoDestructor<NoopSharedRingBufferWriterDelegate> delegate;
+  return &delegate.ref();
+}
+
+struct ReadChunk {
+  WriterID writer_id = 0;
+  BufferID target_buffer = 0;
+  uint32_t payload_flags = 0;
+  std::vector<std::string> fragments;
+};
+
+class RecordingDelegate : public SharedRingBufferReader::Delegate {
+ public:
+  void OnChunkRead(
+      const SharedRingBufferReader::ChunkContents& contents) override {
+    ReadChunk chunk;
+    chunk.writer_id = contents.writer_id;
+    chunk.target_buffer = contents.target_buffer;
+    chunk.payload_flags = contents.payload_flags;
+    for (uint32_t i = 0; i < contents.num_fragments; ++i) {
+      chunk.fragments.emplace_back(
+          reinterpret_cast<const char*>(contents.fragments[i].data),
+          contents.fragments[i].size);
+    }
+    chunks.push_back(std::move(chunk));
+  }
+
+  void OnDataLoss(WriterID writer_id) override {
+    writers_with_data_loss.push_back(writer_id);
+  }
+
+  std::vector<std::string> AllFragments() const {
+    std::vector<std::string> all;
+    for (const ReadChunk& chunk : chunks)
+      all.insert(all.end(), chunk.fragments.begin(), chunk.fragments.end());
+    return all;
+  }
+
+  std::vector<ReadChunk> chunks;
+  std::vector<WriterID> writers_with_data_loss;
+};
+
+SharedRingBufferWriter::Outcome WriteFragment(SharedRingBufferWriter* writer,
+                                              const std::string& bytes,
+                                              bool continues_from_prev = false,
+                                              bool continues_on_next = false) {
+  const SharedRingBufferWriter::FragmentSpan span = writer->OpenFragment(
+      static_cast<uint32_t>(bytes.size()), continues_from_prev);
+  if (span.outcome != SharedRingBufferWriter::Outcome::kOk)
+    return span.outcome;
+  memcpy(span.begin, bytes.data(), bytes.size());
+  return writer->CloseFragment(static_cast<uint32_t>(bytes.size()),
+                               continues_on_next);
+}
+
+// ---------------------------------------------------------------------------
+// The nominal path.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferReaderTest, EmptyRingHasNoData) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kNoData);
+  EXPECT_EQ(reader.read_pos(), 0u);
+  const SharedRingBufferReader::DrainResult result = reader.Drain(16);
+  EXPECT_EQ(result.positions_resolved, 0u);
+  EXPECT_FALSE(result.needs_another_drain());
+}
+
+TEST(SharedRingBufferReaderTest,
+     ConsumesACompleteChunkAndFreesItForTheNextTraversal) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "alpha"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, "beta"),
+            SharedRingBufferWriter::Outcome::kOk);
+  writer.FinishCurrentChunk();
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kChunkRead);
+  EXPECT_EQ(reader.read_pos(), 1u);
+  ASSERT_EQ(delegate.chunks.size(), 1u);
+  EXPECT_EQ(delegate.chunks[0].writer_id, kWriterA);
+  EXPECT_EQ(delegate.chunks[0].target_buffer, kBuffer);
+  EXPECT_EQ(delegate.chunks[0].fragments,
+            (std::vector<std::string>{"alpha", "beta"}));
+
+  // The chunk is now tagged for the traversal after the one just resolved.
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kNoData);
+}
+
+TEST(SharedRingBufferReaderTest, DrainPublishesReadPosOncePerPass) {
+  auto ring = SharedRingBuffer::Create(8, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  // Four writers, one chunk each, so the pass has four positions to resolve.
+  std::vector<std::unique_ptr<SharedRingBufferWriter>> writers;
+  for (uint32_t i = 0; i < 4; ++i) {
+    writers.push_back(std::make_unique<SharedRingBufferWriter>(
+        ring.get(), static_cast<WriterID>(10 + i), kBuffer,
+        BufferExhaustedPolicy::kDrop, GetNoopSharedRingBufferWriterDelegate()));
+    ASSERT_EQ(WriteFragment(writers.back().get(), "x"),
+              SharedRingBufferWriter::Outcome::kOk);
+  }
+  // The shared read_pos has not moved yet: ResolveNextPosition() does not
+  // publish it.
+  EXPECT_EQ(ring->read_pos_for_testing(), 0u);
+
+  const SharedRingBufferReader::DrainResult result = reader.Drain(16);
+  EXPECT_EQ(result.positions_resolved, 4u);
+  EXPECT_EQ(result.last_outcome, Outcome::kNoData);
+  EXPECT_FALSE(result.needs_another_drain());
+  EXPECT_EQ(ring->read_pos_for_testing(), 4u);
+  EXPECT_EQ(delegate.chunks.size(), 4u);
+}
+
+TEST(SharedRingBufferReaderTest, DrainStopsAtItsBudgetAndSaysWorkMayRemain) {
+  auto ring = SharedRingBuffer::Create(8, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  std::vector<std::unique_ptr<SharedRingBufferWriter>> writers;
+  for (uint32_t i = 0; i < 4; ++i) {
+    writers.push_back(std::make_unique<SharedRingBufferWriter>(
+        ring.get(), static_cast<WriterID>(10 + i), kBuffer,
+        BufferExhaustedPolicy::kDrop, GetNoopSharedRingBufferWriterDelegate()));
+    ASSERT_EQ(WriteFragment(writers.back().get(), "x"),
+              SharedRingBufferWriter::Outcome::kOk);
+  }
+
+  const SharedRingBufferReader::DrainResult first = reader.Drain(2);
+  EXPECT_EQ(first.positions_resolved, 2u);
+  EXPECT_TRUE(first.needs_another_drain());
+  EXPECT_EQ(ring->read_pos_for_testing(), 2u);
+
+  const SharedRingBufferReader::DrainResult second = reader.Drain(16);
+  EXPECT_EQ(second.positions_resolved, 2u);
+  EXPECT_FALSE(second.needs_another_drain());
+  EXPECT_EQ(delegate.chunks.size(), 4u);
+}
+
+// ---------------------------------------------------------------------------
+// Holes.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferReaderTest,
+     UnclaimedPositionIsAHoleAndPreparesTheNextTraversal) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  // A writer reserved position 0 and never claimed it.
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.read_pos(), 1u);
+  EXPECT_EQ(reader.num_positions_skipped(), 1u);
+  EXPECT_TRUE(delegate.chunks.empty());
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+TEST(SharedRingBufferReaderTest,
+     RewriteRequestedIsSkippedWithoutTouchingTheWord) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  // Chunk 0 is left mid-rewrite by a writer that has not come back, and a later
+  // position maps onto it.
+  const uint32_t being_written = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kTargetBuffer, 0, 0, kWriterB);
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, being_written));
+  uint32_t observed = being_written;
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  const uint32_t marked = ring->LoadChunkStateWord(0);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.read_pos(), 1u);
+  // Only the owning writer may leave that state, so the reader left it alone.
+  EXPECT_EQ(ring->LoadChunkStateWord(0), marked);
+  EXPECT_EQ(reader.num_positions_skipped(), 1u);
+}
+
+TEST(SharedRingBufferReaderTest,
+     RewriteAcknowledgedIsReclaimedForResolvedPosition) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  const uint32_t being_written = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kTargetBuffer, 0, 0, kWriterB);
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, being_written));
+  uint32_t observed = being_written;
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  ASSERT_TRUE(ring->TryAcknowledgeRewrite(
+      0, ReplaceChunkState(being_written, ChunkState::kRewriteRequested)));
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+// ---------------------------------------------------------------------------
+// Scraping a live writer.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferReaderTest, TakesTheCommittedPrefixOfALiveWriter) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "committed"),
+            SharedRingBufferWriter::Outcome::kOk);
+  // The writer is inside the chunk with a fragment still open.
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(6, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  memcpy(span.begin, "suffix", 6);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kChunkRead);
+  EXPECT_EQ(reader.num_scrapes(), 1u);
+  ASSERT_EQ(delegate.chunks.size(), 1u);
+  // Only what the writer had published, and nothing of the open fragment.
+  EXPECT_EQ(delegate.chunks[0].fragments,
+            (std::vector<std::string>{"committed"}));
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteRequested);
+
+  // The writer relocates its suffix, and the reader picks it up next pass.
+  ASSERT_EQ(writer.CloseFragment(6, false),
+            SharedRingBufferWriter::Outcome::kOk);
+  reader.Drain(8);
+  EXPECT_EQ(delegate.AllFragments(),
+            (std::vector<std::string>{"committed", "suffix"}));
+}
+
+TEST(SharedRingBufferReaderTest, ScrapeWithNothingCommittedStillMarksTheChunk) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(writer.OpenFragment(4, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_TRUE(delegate.chunks.empty());
+  // Nothing came out, but the old owner is still stopped from publishing
+  // behind the reader.
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteRequested);
+}
+
+// The reader's mark loses to the writer's publication. The speculative copy is
+// discarded and the position is retried, so the fragments come out exactly
+// once and in order.
+TEST(SharedRingBufferReaderTest,
+     PublicationBeatingTheScrapeIsReadOnTheNextAttempt) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "first"),
+            SharedRingBufferWriter::Outcome::kOk);
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(6, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  memcpy(span.begin, "second", 6);
+
+  // Publish from inside the reader, after it has copied the prefix and just
+  // before it tries to claim it.
+  bool published = false;
+  reader.SetArbitrationHookForTesting([&] {
+    if (published)
+      return;
+    published = true;
+    EXPECT_EQ(writer.CloseFragment(6, false),
+              SharedRingBufferWriter::Outcome::kOk);
+  });
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kRetryLater);
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_TRUE(delegate.chunks.empty());
+
+  reader.SetArbitrationHookForTesting(nullptr);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kChunkRead);
+  EXPECT_EQ(delegate.AllFragments(),
+            (std::vector<std::string>{"first", "second"}));
+  // No relocation happened: the writer won, so there was nothing to move.
+  EXPECT_EQ(writer.num_relocations(), 0u);
+  EXPECT_EQ(reader.num_scrapes(), 0u);
+}
+
+TEST(SharedRingBufferReaderTest, ReuseBeatingTheReclaimIsHandledAsAScrape) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "first"),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // The writer takes the Complete chunk back just as the reader is about to
+  // reclaim it. The reader leaves the position unchanged and takes the
+  // committed prefix on its next attempt.
+  bool reused = false;
+  reader.SetArbitrationHookForTesting([&] {
+    if (reused)
+      return;
+    reused = true;
+    ASSERT_EQ(writer.OpenFragment(3, false).outcome,
+              SharedRingBufferWriter::Outcome::kOk);
+  });
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kRetryLater);
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_TRUE(delegate.chunks.empty());
+
+  reader.SetArbitrationHookForTesting(nullptr);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kChunkRead);
+  EXPECT_EQ(delegate.AllFragments(), (std::vector<std::string>{"first"}));
+  EXPECT_EQ(reader.num_scrapes(), 1u);
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteRequested);
+}
+
+// ---------------------------------------------------------------------------
+// Untrusted input.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferReaderTest,
+     UnknownFormatDropsThePayloadButReleasesTheChunk) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kReservedRouting,
+                           0, 3, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_unknown_format_chunks(), 1u);
+  EXPECT_TRUE(delegate.chunks.empty());
+  EXPECT_EQ(delegate.writers_with_data_loss, (std::vector<WriterID>{kWriterB}));
+  // The loss was reported and the chunk remains usable.
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+  EXPECT_EQ(reader.read_pos(), 1u);
+}
+
+TEST(SharedRingBufferReaderTest,
+     DirectoryLargerThanTheChunkIsRejectedBoundedly) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  // varint(0) takes one byte. 255 such entries do not fit in the 250-byte
+  // payload area.
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           255, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  EXPECT_TRUE(delegate.chunks.empty());
+  EXPECT_EQ(delegate.writers_with_data_loss, (std::vector<WriterID>{kWriterB}));
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+TEST(SharedRingBufferReaderTest, PayloadAndDirectoryMustNotOverlap) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  // The chunk has 250 bytes after its fixed header. A 249-byte fragment fits
+  // by itself, but its two-byte size varint does not fit beside it.
+  WriteFragmentSize(ring->chunk_at(0) + 256, 249);
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           1, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  EXPECT_EQ(delegate.writers_with_data_loss, (std::vector<WriterID>{kWriterB}));
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+TEST(SharedRingBufferReaderTest, SpeculativeParseDoesNotReportDataLoss) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  const uint32_t being_written = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kTargetBuffer, 0, 255, kWriterB);
+  ring->SetStateWordForTesting(0, being_written);
+  reader.SetArbitrationHookForTesting([&] {
+    uint32_t observed = being_written;
+    ASSERT_TRUE(ring->TrySetChunkComplete(
+        0, &observed, ReplaceChunkState(being_written, ChunkState::kComplete)));
+  });
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kRetryLater);
+  EXPECT_EQ(reader.num_malformed_chunks(), 0u);
+  EXPECT_TRUE(delegate.writers_with_data_loss.empty());
+
+  reader.SetArbitrationHookForTesting(nullptr);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  EXPECT_EQ(delegate.writers_with_data_loss, (std::vector<WriterID>{kWriterB}));
+}
+
+TEST(SharedRingBufferReaderTest,
+     CumulativeFragmentSizesBeyondTheChunkAreRejected) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  // Two fragments claiming 400 bytes each in a chunk that has 506 bytes of
+  // payload area, of which four go to the directory.
+  uint8_t* chunk = ring->chunk_at(0);
+  uint8_t* directory_begin = chunk + 512;
+  directory_begin = WriteFragmentSize(directory_begin, 400);
+  WriteFragmentSize(directory_begin, 400);
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           2, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  EXPECT_TRUE(delegate.chunks.empty());
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+TEST(SharedRingBufferReaderTest, UnterminatedFragmentSizeVarIntIsRejected) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  for (uint32_t i = 0; i < kMaxFragmentSizeVarIntBytes; ++i)
+    ring->chunk_at(0)[511 - i] = 0x80;
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           1, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  EXPECT_TRUE(delegate.chunks.empty());
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+}
+
+// A count larger than the writer actually wrote is a producer claim like any
+// other. It cannot make the reader leave the chunk or spin; it just decodes the
+// stale bytes underneath as extra fragments.
+TEST(SharedRingBufferReaderTest, InflatedFragmentCountStaysInsideTheChunk) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           8, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kChunkRead);
+  // Over a zero-filled mapping the inflated count decodes as extra zero-length
+  // fragments. The checks make the walk memory-safe; they do not pretend to
+  // make its contents true.
+  ASSERT_EQ(delegate.chunks.size(), 1u);
+  EXPECT_EQ(delegate.chunks[0].fragments.size(), 8u);
+  for (const std::string& fragment : delegate.chunks[0].fragments)
+    EXPECT_TRUE(fragment.empty());
+  EXPECT_EQ(reader.num_malformed_chunks(), 0u);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), MakeFreeStateWord(1));
+  EXPECT_EQ(reader.read_pos(), 1u);
+}
+
+TEST(SharedRingBufferReaderTest,
+     MalformedBeingWrittenChunkIsStillRewriteRequested) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  ring->SetStateWordForTesting(
+      0, MakeDataStateWord(ChunkState::kBeingWritten,
+                           ChunkFormat::kTargetBuffer, 0, 255, kWriterB));
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kSkipped);
+  EXPECT_EQ(reader.num_malformed_chunks(), 1u);
+  // Malformed bytes may be dropped. What must not happen is leaving an
+  // BeingWritten owner able to publish behind the reader.
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteRequested);
+}
+
+TEST(SharedRingBufferReaderTest,
+     ReservedStateStopsTheRingWithoutChangingAnything) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  const uint32_t reserved_word = 0x00000005u;
+  ring->SetStateWordForTesting(0, reserved_word);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kProtocolError);
+  EXPECT_TRUE(reader.has_protocol_error());
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), reserved_word);
+
+  // The ring is never read again, and a drain over it changes nothing.
+  const SharedRingBufferReader::DrainResult result = reader.Drain(8);
+  EXPECT_EQ(result.positions_resolved, 0u);
+  EXPECT_EQ(result.last_outcome, Outcome::kProtocolError);
+  EXPECT_EQ(ring->read_pos_for_testing(), 0u);
+}
+
+TEST(SharedRingBufferReaderTest, FreeWordFromAnotherTraversalStopsTheRing) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  // Position 0 expects Free(0). Only the reader writes a free word, and
+  // it derives it from the position it is resolving, so no legal execution puts
+  // this here.
+  const uint32_t wrong_wrap = MakeFreeStateWord(3);
+  ring->SetStateWordForTesting(0, wrong_wrap);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kProtocolError);
+  EXPECT_TRUE(reader.has_protocol_error());
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), wrong_wrap);
+}
+
+// The control and fragment-count fields of a Free word are zero. A word using
+// them is some future ABI's, not garbage to be normalized away: the reader
+// must not consume the position as if it understood the word.
+TEST(SharedRingBufferReaderTest, FreeWordWithReservedBitsSetStopsTheRing) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  // The wrap count is position 0's, but a reserved bit is set.
+  const uint32_t reserved_bit_word = MakeFreeStateWord(0) | (1u << 8);
+  ring->SetStateWordForTesting(0, reserved_bit_word);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kProtocolError);
+  EXPECT_TRUE(reader.has_protocol_error());
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), reserved_bit_word);
+}
+
+// A word with the RewriteAcknowledged state bits but a nonzero payload is not
+// the word that transition may leave from. The reclaim's failed exact-value
+// CAS hands the reader the forged word itself, and the ring stops on it.
+TEST(SharedRingBufferReaderTest, ForgedRewriteAcknowledgedWordStopsTheRing) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  const uint32_t forged = kRewriteAcknowledgedStateWord | kFlagDataLoss;
+  ring->SetStateWordForTesting(0, forged);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kProtocolError);
+  EXPECT_TRUE(reader.has_protocol_error());
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_EQ(ring->LoadChunkStateWord(0), forged);
+}
+
+// A writer reserves a position only after seeing fewer than num_chunks
+// outstanding, so write_pos further ahead than that cannot come from a legal
+// producer. Once the mapping is writable by another process, believing it would
+// mean resolving positions that were never reserved, one drain pass after
+// another, for as long as the producer keeps write_pos there.
+TEST(SharedRingBufferReaderTest, TooManyOutstandingPositionsStopsTheRing) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  // Four chunks, five outstanding positions.
+  ring->SetWritePosForTesting(5);
+
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kProtocolError);
+  EXPECT_TRUE(reader.has_protocol_error());
+  EXPECT_EQ(reader.read_pos(), 0u);
+  EXPECT_EQ(ring->read_pos_for_testing(), 0u);
+  EXPECT_TRUE(delegate.chunks.empty());
+}
+
+// The boundary itself is legal and must still be drained: num_chunks
+// outstanding positions is exactly a full ring.
+TEST(SharedRingBufferReaderTest, AFullRingIsNotAnImpossibleDistance) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+  for (uint32_t i = 0; i < 4; ++i) {
+    ASSERT_EQ(WriteFragment(&writer, "x"),
+              SharedRingBufferWriter::Outcome::kOk);
+    writer.FinishCurrentChunk();
+  }
+  ASSERT_EQ(ring->LoadWritePos(), 4u);
+
+  const SharedRingBufferReader::DrainResult result = reader.Drain(8);
+  EXPECT_FALSE(reader.has_protocol_error());
+  EXPECT_EQ(result.positions_resolved, 4u);
+  EXPECT_EQ(delegate.chunks.size(), 4u);
+}
+
+// ---------------------------------------------------------------------------
+// Flags and routing survive the trip.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferReaderTest,
+     ContinuationFlagsAndTargetBufferReachTheDelegate) {
+  auto ring = SharedRingBuffer::Create(8, 512);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  SharedRingBufferWriter first(ring.get(), kWriterA, 11,
+                               BufferExhaustedPolicy::kDrop,
+                               GetNoopSharedRingBufferWriterDelegate());
+  first.RecordDataLoss();
+  ASSERT_EQ(WriteFragment(&first, "head", /*continues_from_prev=*/false,
+                          /*continues_on_next=*/true),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&first, "tail", /*continues_from_prev=*/true,
+                          /*continues_on_next=*/false),
+            SharedRingBufferWriter::Outcome::kOk);
+  first.FinishCurrentChunk();
+
+  SharedRingBufferWriter second(ring.get(), kWriterB, 22,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+  ASSERT_EQ(WriteFragment(&second, "other"),
+            SharedRingBufferWriter::Outcome::kOk);
+  second.FinishCurrentChunk();
+
+  reader.Drain(16);
+  ASSERT_EQ(delegate.chunks.size(), 3u);
+
+  EXPECT_EQ(delegate.chunks[0].target_buffer, 11);
+  EXPECT_EQ(delegate.chunks[0].payload_flags,
+            kFlagDataLoss | kFlagContinuesOnNextChunk);
+  EXPECT_EQ(delegate.chunks[1].target_buffer, 11);
+  EXPECT_EQ(delegate.chunks[1].payload_flags, kFlagContinuesFromPrevChunk);
+  EXPECT_EQ(delegate.chunks[2].target_buffer, 22);
+  EXPECT_EQ(delegate.chunks[2].writer_id, kWriterB);
+  EXPECT_EQ(delegate.chunks[2].payload_flags, 0u);
+}
+
+// A one-chunk ring is legal: every position maps to chunk 0 and the ring
+// alternates between one outstanding reservation and empty.
+TEST(SharedRingBufferReaderTest, OneChunkRingRoundTrips) {
+  auto ring = SharedRingBuffer::Create(1, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  std::vector<std::string> expected;
+  for (uint32_t i = 0; i < 5; ++i) {
+    const std::string bytes = "packet-" + std::to_string(i);
+    ASSERT_EQ(WriteFragment(&writer, bytes),
+              SharedRingBufferWriter::Outcome::kOk)
+        << i;
+    writer.FinishCurrentChunk();
+    expected.push_back(bytes);
+    const SharedRingBufferReader::DrainResult result = reader.Drain(4);
+    EXPECT_EQ(result.positions_resolved, 1u) << i;
+  }
+  EXPECT_EQ(delegate.AllFragments(), expected);
+  EXPECT_EQ(reader.ResolveNextPosition(), Outcome::kNoData);
+}
+
+// An aligned, non-power-of-two chunk size, end to end through writer and
+// reader.
+TEST(SharedRingBufferReaderTest, NonPowerOfTwoChunkSizeRoundTrips) {
+  auto ring = SharedRingBuffer::Create(4, 260);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  // 300 bytes does not fit a 260-byte chunk, so it arrives as two fragments in
+  // two chunks - the split the upper layer would mark with the continuation
+  // flags.
+  const std::string head(252, 'h');
+  const std::string tail(48, 't');
+  ASSERT_EQ(WriteFragment(&writer, head, /*continues_from_prev=*/false,
+                          /*continues_on_next=*/true),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, tail, /*continues_from_prev=*/true,
+                          /*continues_on_next=*/false),
+            SharedRingBufferWriter::Outcome::kOk);
+  writer.FinishCurrentChunk();
+
+  reader.Drain(8);
+  ASSERT_EQ(delegate.chunks.size(), 2u);
+  EXPECT_EQ(delegate.chunks[0].payload_flags, kFlagContinuesOnNextChunk);
+  EXPECT_EQ(delegate.chunks[1].payload_flags, kFlagContinuesFromPrevChunk);
+  EXPECT_EQ(delegate.AllFragments(), (std::vector<std::string>{head, tail}));
+}
+
+TEST(SharedRingBufferReaderTest,
+     FragmentsComeOutInReservationOrderAcrossWriters) {
+  auto ring = SharedRingBuffer::Create(8, 256);
+  ASSERT_NE(ring, nullptr);
+  RecordingDelegate delegate;
+  SharedRingBufferReader reader(ring.get(), &delegate);
+
+  // Interleave two writers so their chunks land at alternating positions.
+  SharedRingBufferWriter a(ring.get(), kWriterA, kBuffer,
+                           BufferExhaustedPolicy::kDrop,
+                           GetNoopSharedRingBufferWriterDelegate());
+  SharedRingBufferWriter b(ring.get(), kWriterB, kBuffer,
+                           BufferExhaustedPolicy::kDrop,
+                           GetNoopSharedRingBufferWriterDelegate());
+  std::vector<std::string> expected;
+  for (uint32_t i = 0; i < 4; ++i) {
+    // Each fragment fills its chunk, so every write consumes a new position.
+    const std::string from_a = "a" + std::to_string(i) + std::string(240, '.');
+    const std::string from_b = "b" + std::to_string(i) + std::string(240, '.');
+    ASSERT_EQ(WriteFragment(&a, from_a), SharedRingBufferWriter::Outcome::kOk);
+    ASSERT_EQ(WriteFragment(&b, from_b), SharedRingBufferWriter::Outcome::kOk);
+    expected.push_back(from_a);
+    expected.push_back(from_b);
+    reader.Drain(16);
+  }
+  a.FinishCurrentChunk();
+  b.FinishCurrentChunk();
+  reader.Drain(16);
+
+  EXPECT_EQ(delegate.AllFragments(), expected);
+}
+
+}  // namespace
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer_unittest.cc b/src/tracing/v2/shared_ring_buffer_unittest.cc
new file mode 100644
index 0000000..6c07be9
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_unittest.cc
@@ -0,0 +1,851 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer.h"
+
+#include <errno.h>
+#include <stdint.h>
+
+#include <atomic>
+#include <memory>
+#include <optional>
+#include <thread>
+#include <vector>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/time.h"
+#include "perfetto/ext/base/waitable_event.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+constexpr uint32_t kChunkSize = 256;
+constexpr WriterID kWriterA = 7;
+constexpr WriterID kWriterB = 9;
+
+uint32_t BeingWrittenWord(WriterID writer) {
+  return MakeDataStateWord(ChunkState::kBeingWritten,
+                           ChunkFormat::kTargetBuffer, 0, 0, writer);
+}
+
+uint32_t CompleteWord(WriterID writer, uint32_t num_fragments) {
+  return MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer, 0,
+                           num_fragments, writer);
+}
+
+// Reads a chunk's state word without going through the ring's own accessors,
+// so that a test observing the ring cannot be fooled by a bug in them.
+uint32_t PeekStateWord(SharedRingBuffer* ring, uint32_t chunk_index) {
+  const uint8_t* chunk = ring->chunk_at(chunk_index);
+  return static_cast<uint32_t>(chunk[0]) |
+         (static_cast<uint32_t>(chunk[1]) << 8) |
+         (static_cast<uint32_t>(chunk[2]) << 16) |
+         (static_cast<uint32_t>(chunk[3]) << 24);
+}
+
+// Spins until |condition| holds. Bounded: returns false after a deadline no
+// correct implementation comes anywhere near, so a broken wait path fails the
+// test instead of hanging the process. A caller must still unblock and join
+// every thread it spawned before asserting on the result.
+template <typename ConditionFn>
+bool SpinUntil(ConditionFn condition) {
+  const base::TimeMillis deadline =
+      base::GetWallTimeMs() + base::TimeMillis(30000);
+  while (base::GetWallTimeMs() < deadline) {
+    if (condition())
+      return true;
+    std::this_thread::yield();
+  }
+  return false;
+}
+
+// ---------------------------------------------------------------------------
+// Ring dimensions.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferTest, RejectsInvalidChunkCountsAndSizes) {
+  // num_chunks must be a power of two no larger than 2^30.
+  EXPECT_EQ(SharedRingBuffer::Create(0, kChunkSize), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(3, kChunkSize), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(6, kChunkSize), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(uint32_t{1} << 31, kChunkSize), nullptr);
+
+  // chunk_size must be at least 256 and keep the state word aligned.
+  EXPECT_EQ(SharedRingBuffer::Create(4, 0), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(4, 128), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(4, 255), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(4, 258), nullptr);
+}
+
+TEST(SharedRingBufferTest, AcceptsValidChunkCountsAndSizes) {
+  for (uint32_t chunk_size : {256u, 260u, 512u, 1000u, 4096u, 65536u}) {
+    auto ring = SharedRingBuffer::Create(4, chunk_size);
+    ASSERT_NE(ring, nullptr) << chunk_size;
+    EXPECT_EQ(ring->chunk_size(), chunk_size);
+  }
+  for (uint32_t num_chunks : {1u, 2u, 4u, 8u, 16u, 1024u}) {
+    auto ring = SharedRingBuffer::Create(num_chunks, kChunkSize);
+    ASSERT_NE(ring, nullptr) << num_chunks;
+    EXPECT_EQ(ring->num_chunks(), num_chunks);
+    EXPECT_EQ(ring->chunk_index_bits(), GetChunkIndexBits(num_chunks));
+  }
+}
+
+// chunk_size must keep the state word aligned and be at least 256 bytes;
+// nothing requires it to be a power of two and there is no arbitrary maximum.
+TEST(SharedRingBufferTest, AcceptsAlignedNonPowerOfTwoChunkSizes) {
+  EXPECT_NE(SharedRingBuffer::Create(4, 260), nullptr);
+  EXPECT_NE(SharedRingBuffer::Create(4, 1000), nullptr);
+  EXPECT_NE(SharedRingBuffer::Create(4, 65536), nullptr);
+  // Still rejected: a misaligned state word, or a chunk below the minimum.
+  EXPECT_EQ(SharedRingBuffer::Create(4, 258), nullptr);
+  EXPECT_EQ(SharedRingBuffer::Create(4, 255), nullptr);
+}
+
+// Free(0) is the all-zero word, so a fresh zero-filled mapping is
+// already correct and the ring does not walk it at construction.
+TEST(SharedRingBufferTest, FreshMappingIsFreeZeroWithNoStampingPass) {
+  auto ring = SharedRingBuffer::Create(1024, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  for (uint32_t i = 0; i < ring->num_chunks(); ++i) {
+    ASSERT_EQ(PeekStateWord(ring.get(), i), 0u) << i;
+    ASSERT_EQ(ChunkStateOf(ring->LoadChunkStateWord(i)), ChunkState::kFree);
+    ASSERT_EQ(WrapCountOf(ring->LoadChunkStateWord(i)), 0u);
+  }
+  EXPECT_EQ(ring->read_pos_for_testing(), 0u);
+  EXPECT_EQ(ring->LoadWritePos(), 0u);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+}
+
+TEST(SharedRingBufferTest, EveryChunkStateWordIsNaturallyAligned) {
+  for (uint32_t chunk_size : {256u, 260u, 512u, 4096u, 65536u}) {
+    auto ring = SharedRingBuffer::Create(8, chunk_size);
+    ASSERT_NE(ring, nullptr);
+    for (uint32_t i = 0; i < ring->num_chunks(); ++i) {
+      const auto address = reinterpret_cast<uintptr_t>(ring->chunk_at(i));
+      // The ABI's alignment requirement: every state word is naturally
+      // aligned, whatever the stride.
+      ASSERT_EQ(address % alignof(std::atomic<uint32_t>), 0u)
+          << chunk_size << "/" << i;
+      // For a cache-line-multiple stride the chunks additionally stay
+      // line-aligned, which keeps two writers on adjacent chunks off each
+      // other's lines. That is a property of those chunk sizes, not of the
+      // ABI.
+      if (chunk_size % 64 == 0) {
+        ASSERT_EQ(address % 64, 0u) << chunk_size << "/" << i;
+      }
+    }
+  }
+}
+
+// The header is one 64-byte line and chunk 0 starts on the next one. The
+// mapping is page-aligned, so the chunk area's misalignment from any
+// page-divisor boundary is exactly the header size - which is how the header's
+// size stays observable from outside the class.
+TEST(SharedRingBufferTest, ChunksStartRightAfterTheOneLineHeader) {
+  for (uint32_t chunk_size : {256u, 260u, 4096u}) {
+    auto ring = SharedRingBuffer::Create(4, chunk_size);
+    ASSERT_NE(ring, nullptr);
+    const uintptr_t first = reinterpret_cast<uintptr_t>(ring->chunk_at(0));
+    EXPECT_EQ(first % 256, 64u) << "chunk_size " << chunk_size;
+    const uintptr_t last = reinterpret_cast<uintptr_t>(ring->chunk_at(3));
+    EXPECT_EQ(last - first, 3u * chunk_size);
+  }
+}
+
+// The whole mapping-size policy, branch by branch, without asking the
+// allocator for anything.
+TEST(SharedRingBufferTest, MappingSizeArithmeticIsChecked) {
+  constexpr size_t kPage = 4096;
+  // The nominal case is the header plus the chunk area, exactly.
+  EXPECT_EQ(SharedRingBuffer::ComputeAllocationSizeForTesting(4, 256, kPage),
+            std::make_optional<size_t>(64 + 4 * 256));
+
+  // The largest legal ring is representable on a 64-bit host and nothing
+  // smaller can overflow there; on a 32-bit host the same request must be
+  // rejected by arithmetic, not by a crash inside the allocator.
+  const auto largest = SharedRingBuffer::ComputeAllocationSizeForTesting(
+      1u << 30, 0xfffffffcu, kPage);
+  if (sizeof(size_t) >= 8) {
+    ASSERT_TRUE(largest.has_value());
+    EXPECT_EQ(*largest, 64 + (uint64_t{1} << 30) * 0xfffffffcull);
+  } else {
+    EXPECT_FALSE(largest.has_value());
+  }
+
+  // The guard-space headroom: a total that fits in size_t but whose padded
+  // allocator request would not is rejected here, because
+  // PagedMemory::Allocate() does its rounding and guard-page addition in
+  // unchecked size_t arithmetic.
+  EXPECT_FALSE(
+      SharedRingBuffer::ComputeAllocationSizeForTesting(4, 256, SIZE_MAX / 3)
+          .has_value());
+  EXPECT_FALSE(
+      SharedRingBuffer::ComputeAllocationSizeForTesting(4, 256, SIZE_MAX / 2)
+          .has_value());
+}
+
+// ---------------------------------------------------------------------------
+// Reserving positions.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferTest, ReservationsAreConsecutiveTicketsUntilFull) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  for (uint32_t expected = 0; expected < 4; ++expected) {
+    const auto reservation = ring->TryReserveWritePos();
+    ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+    EXPECT_EQ(reservation.position, expected);
+  }
+
+  const auto full = ring->TryReserveWritePos();
+  EXPECT_EQ(full.result, SharedRingBuffer::ReserveResult::kFull);
+  // Nothing was reserved, so write_pos did not move: a full ring must not burn
+  // a position.
+  EXPECT_EQ(ring->LoadWritePos(), 4u);
+  // The sample the decision was taken against is exactly what a stalling
+  // writer has to wait on.
+  EXPECT_EQ(full.read_pos_sample, 0u);
+}
+
+TEST(SharedRingBufferTest, CapacityFollowsReadPos) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kFull);
+
+  // Publishing read_pos must preserve a concurrently updated write_pos.
+  ring->PublishReadPos(1);
+  EXPECT_EQ(ring->read_pos_for_testing(), 1u);
+  EXPECT_EQ(ring->LoadWritePos(), 2u);
+
+  const auto reservation = ring->TryReserveWritePos();
+  ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_EQ(reservation.position, 2u);
+  EXPECT_EQ(reservation.read_pos_sample, 1u);
+  EXPECT_EQ(ring->read_pos_for_testing(), 1u);
+  EXPECT_EQ(ring->LoadWritePos(), 3u);
+}
+
+// One load of the packed positions decides capacity across uint32_t rollover.
+TEST(SharedRingBufferTest, CapacityDecisionsAreExactAcrossPositionRollover) {
+  auto ring = SharedRingBuffer::Create(8, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  const uint32_t kSeed = 0xfffffffcu;  // Four positions before the rollover.
+  ring->SetPositionsForTesting(kSeed);
+
+  for (uint32_t i = 0; i < 8; ++i) {
+    const auto reservation = ring->TryReserveWritePos();
+    ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved)
+        << i;
+    EXPECT_EQ(reservation.position, kSeed + i) << i;
+  }
+  // write_pos has wrapped: 0xfffffffc + 8 = 4.
+  EXPECT_EQ(ring->LoadWritePos(), 4u);
+
+  const auto full = ring->TryReserveWritePos();
+  ASSERT_EQ(full.result, SharedRingBuffer::ReserveResult::kFull);
+  EXPECT_EQ(full.read_pos_sample, kSeed);
+
+  ring->PublishReadPos(kSeed + 1);
+  const auto reservation = ring->TryReserveWritePos();
+  ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_EQ(reservation.position, 4u);
+  EXPECT_EQ(reservation.read_pos_sample, kSeed + 1);
+}
+
+// ---------------------------------------------------------------------------
+// The two read/write-position compare-and-swap conflicts, forced
+// deterministically.
+//
+// Two racing threads cannot be scheduled into "load, then lose the CAS" on
+// demand. These tests start the production loop with an old rw_positions
+// value. The first compare-and-swap must fail, and its expected argument is
+// replaced with the current value. The test then checks that the retry uses
+// both halves of that current value.
+// ---------------------------------------------------------------------------
+
+// A reader publication lands between the writer's load and its CAS. The
+// reservation must recheck capacity with the value returned by the failed CAS,
+// not reuse either half of the old value.
+TEST(SharedRingBufferTest, ReservationCasLosesToPublicationAndRedispatches) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  ASSERT_EQ(ring->TryReserveWritePos().position, 1u);
+
+  // The value the writer loaded before the reader published.
+  const uint64_t stale_rw_positions = PackRwPositions(2, 0);
+  ring->PublishReadPos(1);  // Now (write=2, read=1).
+
+  const auto reservation =
+      ring->TryReserveWritePosForTesting(stale_rw_positions);
+  ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_EQ(reservation.position, 2u);
+  // The failed CAS returned read_pos=1. Seeing that value here proves that the
+  // retry did not keep read_pos=0 from the old value.
+  EXPECT_EQ(reservation.read_pos_sample, 1u);
+  EXPECT_EQ(ring->LoadWritePos(), 3u);
+  EXPECT_EQ(ring->read_pos_for_testing(), 1u);
+}
+
+// The same losing CAS, but the value returned by the failure says that the
+// ring is full. The retry must take the Full exit, without burning a position.
+TEST(SharedRingBufferTest, ReservationCasLossRedispatchesIntoFull) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+
+  const uint64_t stale_rw_positions =
+      PackRwPositions(1, 0);  // One outstanding: capacity left.
+  ASSERT_EQ(ring->TryReserveWritePos().position, 1u);  // Now (2, 0): full.
+
+  const auto reservation =
+      ring->TryReserveWritePosForTesting(stale_rw_positions);
+  EXPECT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kFull);
+  EXPECT_EQ(reservation.read_pos_sample, 0u);
+  EXPECT_EQ(ring->LoadWritePos(), 2u);
+}
+
+// A writer reservation lands between the reader's load and its CAS. The
+// publication retry must preserve the returned write_pos: had it kept the
+// stale half, the reservation would be erased and write_pos would read 0.
+TEST(SharedRingBufferTest, PublicationCasLosesToReservationAndPreservesIt) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  // The value loaded by the reader before the writer reserved position 0.
+  const uint64_t stale_rw_positions = PackRwPositions(0, 0);
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);  // Now (write=1, read=0).
+
+  ring->PublishReadPosForTesting(stale_rw_positions, 1);
+  EXPECT_EQ(ring->LoadWritePos(), 1u);
+  EXPECT_EQ(ring->read_pos_for_testing(), 1u);
+}
+
+// The ABI accepts any power-of-two chunk count in [1, 2^30].
+TEST(SharedRingBufferTest, OneChunkRingWorks) {
+  auto ring = SharedRingBuffer::Create(1, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  EXPECT_EQ(ring->num_chunks(), 1u);
+  EXPECT_EQ(ring->chunk_index_bits(), 0u);
+
+  const auto reservation = ring->TryReserveWritePos();
+  ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_EQ(reservation.position, 0u);
+  EXPECT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kFull);
+
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+  uint32_t observed = BeingWrittenWord(kWriterA);
+  ASSERT_TRUE(
+      ring->TrySetChunkComplete(0, &observed, CompleteWord(kWriterA, 1)));
+  observed = CompleteWord(kWriterA, 1);
+  ASSERT_TRUE(ring->TryReleaseCompleteChunkAsFree(0, &observed));
+  // With one chunk the wrap count advances on every position.
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(1));
+  ring->PublishReadPos(1);
+
+  const auto next = ring->TryReserveWritePos();
+  ASSERT_EQ(next.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_EQ(next.position, 1u);
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(1, BeingWrittenWord(kWriterB)));
+}
+
+// ---------------------------------------------------------------------------
+// The writer's transitions.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferTest, ClaimTakesTheChunkForTheReservedPosition) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  const auto reservation = ring->TryReserveWritePos();
+  ASSERT_EQ(reservation.result, SharedRingBuffer::ReserveResult::kReserved);
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(reservation.position,
+                                              BeingWrittenWord(kWriterA)));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), BeingWrittenWord(kWriterA));
+}
+
+// A writer that reserved a position and then slept wakes up expecting the free
+// word for *its* traversal. Once the reader has moved the chunk on, that word
+// is gone, so the late claim cannot land. This is the schedule an all-zero free
+// word could not survive.
+TEST(SharedRingBufferTest, StaleClaimFailsOnceTheReaderHasAdvancedTheWrap) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  // A writer reserves position 4 (chunk 0, wrap 1) and stalls.
+  for (uint32_t i = 0; i < 4; ++i)
+    ASSERT_EQ(ring->TryReserveWritePos().result,
+              SharedRingBuffer::ReserveResult::kReserved);
+  ring->PublishReadPos(4);
+  const auto stale = ring->TryReserveWritePos();
+  ASSERT_EQ(stale.result, SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(stale.position, 4u);
+
+  // The reader resolves positions 0 to 4 as holes, so chunk 0 ends up tagged
+  // for position 8's traversal.
+  for (uint32_t position = 0; position <= 4; ++position) {
+    const uint32_t chunk_index = ChunkIndexOfPosition(position, 4);
+    uint32_t observed = ring->LoadChunkStateWord(chunk_index);
+    ASSERT_TRUE(ring->TryMoveFreeChunkToNextWrap(position, &observed))
+        << position;
+  }
+  ASSERT_EQ(WrapCountOf(PeekStateWord(ring.get(), 0)), 2u);
+
+  // The stale writer's one and only claim attempt uses the operand it computed
+  // back then, and it no longer matches.
+  EXPECT_FALSE(ring->TryAcquireChunkForWriting(stale.position,
+                                               BeingWrittenWord(kWriterA)));
+  EXPECT_EQ(WrapCountOf(PeekStateWord(ring.get(), 0)), 2u);
+
+  // The writer holding position 8 - the one the chunk was actually prepared
+  // for - still gets in.
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(8, BeingWrittenWord(kWriterB)));
+}
+
+TEST(SharedRingBufferTest, TwoStaleClaimantsCannotBothAdoptTheReturnedWord) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  // Positions 0 and 2 both map to chunk 0 but belong to different traversals.
+  uint32_t observed = ring->LoadChunkStateWord(0);
+  ASSERT_TRUE(ring->TryMoveFreeChunkToNextWrap(0, &observed));
+  ASSERT_EQ(WrapCountOf(PeekStateWord(ring.get(), 0)), 1u);
+
+  // Position 0's writer is out, and so is anyone whose ticket is not exactly
+  // position 2. Only position 2 can claim what position 0 left behind.
+  EXPECT_FALSE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+  EXPECT_FALSE(ring->TryAcquireChunkForWriting(4, BeingWrittenWord(kWriterA)));
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(2, BeingWrittenWord(kWriterB)));
+}
+
+// The Free identity is a fixed 16-bit wrap count, so the same physical chunk
+// answers to the same word again after num_chunks * 65536 reservations. That
+// period is an accepted limit of the initial ABI, pinned here so the test
+// fails if the wrap-count width silently changes.
+TEST(SharedRingBufferTest, WrapIdentityRepeatsAfterTheDocumentedPeriod) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  // A fresh chunk 0 is Free(0), tagged for position 0. One full identity
+  // period later, position 2 * 65536 maps to the same chunk and its wrap
+  // count - bit 16 of the shifted position - is truncated back to zero, so
+  // this claim lands even though the ring never ran.
+  EXPECT_TRUE(
+      ring->TryAcquireChunkForWriting(2u * 65536, BeingWrittenWord(kWriterA)));
+}
+
+// The wrap count a seeded ring stamps is the 16-bit truncation of the shifted
+// position, not the shifted position itself.
+TEST(SharedRingBufferTest, SeededPositionsStampSixteenBitWraps) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  // 4 * 65536 is one whole identity period, so every chunk is stamped with
+  // exactly the word a fresh mapping holds.
+  ring->SetPositionsForTesting(4u * 65536);
+  for (uint32_t i = 0; i < 4; ++i)
+    EXPECT_EQ(PeekStateWord(ring.get(), i), 0u) << i;
+}
+
+TEST(SharedRingBufferTest, PublishReuseAndReclaimAreExactValueTransitions) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  ASSERT_EQ(ring->TryReserveWritePos().position, 0u);
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+
+  uint32_t observed = BeingWrittenWord(kWriterA);
+  ASSERT_TRUE(
+      ring->TrySetChunkComplete(0, &observed, CompleteWord(kWriterA, 2)));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), CompleteWord(kWriterA, 2));
+
+  // A publication against a word that is no longer there fails and reports what
+  // is.
+  uint32_t stale = BeingWrittenWord(kWriterA);
+  EXPECT_FALSE(ring->TrySetChunkComplete(0, &stale, CompleteWord(kWriterA, 3)));
+  EXPECT_EQ(stale, CompleteWord(kWriterA, 2));
+
+  // Reuse takes the same chunk back, keeping the published count.
+  ASSERT_TRUE(ring->TryReacquireChunkForWriting(0, CompleteWord(kWriterA, 2)));
+  EXPECT_EQ(ChunkStateOf(PeekStateWord(ring.get(), 0)),
+            ChunkState::kBeingWritten);
+  EXPECT_EQ(NumFragmentsOf(PeekStateWord(ring.get(), 0)), 2u);
+
+  observed =
+      ReplaceChunkState(CompleteWord(kWriterA, 2), ChunkState::kBeingWritten);
+  ASSERT_TRUE(
+      ring->TrySetChunkComplete(0, &observed, CompleteWord(kWriterA, 5)));
+
+  // The reader consumes it and stamps the wrap for the *next* traversal of this
+  // chunk, taken from the position it just resolved.
+  observed = CompleteWord(kWriterA, 5);
+  ASSERT_TRUE(ring->TryReleaseCompleteChunkAsFree(0, &observed));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(1));
+}
+
+TEST(SharedRingBufferTest, MarkForRewritePassesEveryOtherFieldThrough) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+
+  const uint32_t being_written = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kReservedRouting,
+      kFlagContinuesFromPrevChunk | kFlagDataLoss, 0, kWriterA);
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, being_written));
+
+  const uint32_t published = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kReservedRouting,
+      kFlagContinuesFromPrevChunk | kFlagDataLoss, 3, kWriterA);
+  // Simulate the writer having appended three fragments by reusing the chunk
+  // through the publish/reuse pair.
+  uint32_t observed = being_written;
+  ASSERT_TRUE(ring->TrySetChunkComplete(
+      0, &observed, ReplaceChunkState(published, ChunkState::kComplete)));
+  ASSERT_TRUE(ring->TryReacquireChunkForWriting(
+      0, ReplaceChunkState(published, ChunkState::kComplete)));
+
+  observed = published;
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  const uint32_t marked = PeekStateWord(ring.get(), 0);
+  EXPECT_EQ(ChunkStateOf(marked), ChunkState::kRewriteRequested);
+  // The reader can arbitrate a chunk whose format it does not understand
+  // precisely because it changes nothing but the state.
+  EXPECT_EQ(ChunkFormatOf(marked), ChunkFormat::kReservedRouting);
+  EXPECT_EQ(PayloadFlagsOf(marked),
+            kFlagContinuesFromPrevChunk | kFlagDataLoss);
+  EXPECT_EQ(NumFragmentsOf(marked), 3u);
+  EXPECT_EQ(WriterIdOf(marked), kWriterA);
+}
+
+TEST(SharedRingBufferTest, AcknowledgeThenReclaimReturnsTheChunk) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+  uint32_t observed = BeingWrittenWord(kWriterA);
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  const uint32_t marked = ReplaceChunkState(BeingWrittenWord(kWriterA),
+                                            ChunkState::kRewriteRequested);
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), marked);
+
+  // Nobody but the owning writer can leave RewriteRequested, and the writer
+  // says nothing about who gets the chunk next.
+  ASSERT_TRUE(ring->TryAcknowledgeRewrite(0, marked));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), kRewriteAcknowledgedStateWord);
+
+  // Only the reader turns that into a free word, and it stamps the wrap of the
+  // position it is resolving.
+  ASSERT_TRUE(ring->TryReleaseRewriteAcknowledgedChunkAsFree(4, &observed));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(2));
+}
+
+// The reclaim compares against the exact rewrite-acknowledgment word. A failed
+// CAS reports the word that defeated it, rather than doing a second load or
+// returning the caller's stale dispatch word.
+TEST(SharedRingBufferTest,
+     FailedRewriteAcknowledgedReclaimReportsTheDefeatingWord) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  // RewriteAcknowledged state bits over a nonzero payload: the same dispatch
+  // state, but not the one word the transition may leave from.
+  const uint32_t forged = kRewriteAcknowledgedStateWord | kFlagDataLoss;
+  ring->SetStateWordForTesting(0, forged);
+
+  uint32_t observed = 0;
+  EXPECT_FALSE(ring->TryReleaseRewriteAcknowledgedChunkAsFree(0, &observed));
+  EXPECT_EQ(observed, forged);
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), forged);
+
+  // The exact word goes through.
+  ring->SetStateWordForTesting(0, kRewriteAcknowledgedStateWord);
+  EXPECT_TRUE(ring->TryReleaseRewriteAcknowledgedChunkAsFree(0, &observed));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(1));
+}
+
+// Every word that says "this chunk is claimable" comes out of exactly three
+// methods, and all three are the reader's. A writer has no way to produce one:
+// the strongest thing it can say is RewriteAcknowledged.
+TEST(SharedRingBufferTest, NoWriterTransitionProducesAFreeWord) {
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+  uint32_t observed = BeingWrittenWord(kWriterA);
+  ASSERT_TRUE(
+      ring->TrySetChunkComplete(0, &observed, CompleteWord(kWriterA, 1)));
+  EXPECT_NE(ChunkStateOf(PeekStateWord(ring.get(), 0)), ChunkState::kFree);
+
+  ASSERT_TRUE(ring->TryReacquireChunkForWriting(0, CompleteWord(kWriterA, 1)));
+  EXPECT_NE(ChunkStateOf(PeekStateWord(ring.get(), 0)), ChunkState::kFree);
+
+  observed =
+      ReplaceChunkState(CompleteWord(kWriterA, 1), ChunkState::kBeingWritten);
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  ASSERT_TRUE(ring->TryAcknowledgeRewrite(
+      0, ReplaceChunkState(CompleteWord(kWriterA, 1),
+                           ChunkState::kRewriteRequested)));
+  EXPECT_NE(ChunkStateOf(PeekStateWord(ring.get(), 0)), ChunkState::kFree);
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), kRewriteAcknowledgedStateWord);
+
+  ASSERT_TRUE(ring->TryReleaseRewriteAcknowledgedChunkAsFree(0, &observed));
+  EXPECT_EQ(ChunkStateOf(PeekStateWord(ring.get(), 0)), ChunkState::kFree);
+}
+
+TEST(SharedRingBufferTest, ReclaimStampsTheNextWrapAcrossPositionRollover) {
+  // At the 32-bit position rollover the shifted position restarts from zero. A
+  // reader that incremented the 16-bit value it found in the chunk would agree
+  // here by coincidence of the uint16_t wrap, so the assertion that matters is
+  // the exact stamped word, derived from the position.
+  auto ring = SharedRingBuffer::Create(16, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  const uint32_t kLastLap = 0xfffffff0u;  // chunk 0, the last lap's wrap
+  ASSERT_EQ(ChunkIndexOfPosition(kLastLap, 16), 0u);
+  ASSERT_EQ(WrapCountForPosition(kLastLap, ring->chunk_index_bits()), 0xffffu);
+
+  // Walk chunk 0 into RewriteAcknowledged, the state the reader reclaims.
+  ASSERT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+  uint32_t observed = BeingWrittenWord(kWriterA);
+  ASSERT_TRUE(ring->TryRequestRewrite(0, &observed));
+  ASSERT_TRUE(ring->TryAcknowledgeRewrite(
+      0, ReplaceChunkState(BeingWrittenWord(kWriterA),
+                           ChunkState::kRewriteRequested)));
+
+  ASSERT_TRUE(
+      ring->TryReleaseRewriteAcknowledgedChunkAsFree(kLastLap, &observed));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(0));
+  // The writer holding position 0, the one that follows kLastLap on chunk 0,
+  // is exactly the one that can claim it.
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(0, BeingWrittenWord(kWriterA)));
+}
+
+TEST(SharedRingBufferTest, ReclaimStampsZeroAtTheWrapIdentityRollover) {
+  // The 16-bit identity rolls over every num_chunks * 65536 positions, long
+  // before the position does. The wrap after 0xffff is zero, and it comes from
+  // the position, not from incrementing the chunk's value.
+  auto ring = SharedRingBuffer::Create(4, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  const uint32_t kLastLap = 0xffffu * 4;  // chunk 0, wrap 0xffff
+  ASSERT_EQ(ChunkIndexOfPosition(kLastLap, 4), 0u);
+  ASSERT_EQ(WrapCountForPosition(kLastLap, ring->chunk_index_bits()), 0xffffu);
+
+  ring->SetPositionsForTesting(kLastLap);
+  ASSERT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(0xffff));
+
+  uint32_t observed = MakeFreeStateWord(0xffff);
+  ASSERT_TRUE(ring->TryMoveFreeChunkToNextWrap(kLastLap, &observed));
+  EXPECT_EQ(PeekStateWord(ring.get(), 0), MakeFreeStateWord(0));
+  // The next traversal of chunk 0 belongs to position kLastLap + 4, whose wrap
+  // is the truncated zero.
+  EXPECT_TRUE(ring->TryAcquireChunkForWriting(kLastLap + 4,
+                                              BeingWrittenWord(kWriterB)));
+}
+
+// ---------------------------------------------------------------------------
+// Backpressure.
+// ---------------------------------------------------------------------------
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \
+    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
+
+TEST(SharedRingBufferTest, BlockedWriterWakesWhenTheReaderReleasesCapacity) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+
+  const auto full = ring->TryReserveWritePos();
+  ASSERT_EQ(full.result, SharedRingBuffer::ReserveResult::kFull);
+
+  base::WaitableEvent about_to_wait;
+  std::atomic<bool> reserved{false};
+  std::thread writer([&] {
+    about_to_wait.Notify();
+    // Bounded only as a deadlock guard: the reader below always frees capacity,
+    // so a correct implementation never comes close to the timeout.
+    const auto outcome =
+        ring->WaitForReadPosChange(full.read_pos_sample, 30000);
+    EXPECT_EQ(outcome, SharedRingBuffer::WriterWaitResult::kRetry);
+    reserved.store(ring->TryReserveWritePos().result ==
+                   SharedRingBuffer::ReserveResult::kReserved);
+  });
+
+  about_to_wait.Wait();
+  // Wait until the writer is actually parked, so that this exercises the wake
+  // rather than the early-return path.
+  const bool parked =
+      SpinUntil([&] { return ring->num_writers_waiting_for_testing() != 0; });
+
+  // Published unconditionally: it is both the wake under test and what lets
+  // the writer thread finish - and be joined - if parking was never observed.
+  ring->PublishReadPos(1);
+  writer.join();
+  ASSERT_TRUE(parked);
+  EXPECT_TRUE(reserved.load());
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+}
+
+// If read_pos moves between the capacity sample and the wait, the wait must
+// come straight back rather than park on a value that will never be stored
+// again. Without that the writer sleeps with space available.
+TEST(SharedRingBufferTest, WaitReturnsImmediatelyIfReadPosAlreadyMoved) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  const uint32_t stale_sample = ring->read_pos_for_testing();
+  ring->PublishReadPos(5);
+
+  const auto outcome = ring->WaitForReadPosChange(stale_sample, 30000);
+  EXPECT_EQ(outcome, SharedRingBuffer::WriterWaitResult::kRetry);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+}
+
+// Besides the timeout path, this checks the futex watches the read half of the
+// packed positions: write_pos is nonzero while read_pos still equals the
+// expected value, so a wait that watched the wrong half - or a misplaced
+// address - would find a mismatch and return immediately instead of sleeping.
+TEST(SharedRingBufferTest, WaitTimesOutWatchingTheReadHalf) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(ring->TryReserveWritePos().result,
+            SharedRingBuffer::ReserveResult::kReserved);
+  ASSERT_EQ(ring->LoadWritePos(), 2u);
+  ASSERT_EQ(ring->read_pos_for_testing(), 0u);
+
+  // 1 ms because this test relies on the timeout firing.
+  EXPECT_EQ(ring->WaitForReadPosChange(0, 1),
+            SharedRingBuffer::WriterWaitResult::kTimedOut);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+}
+
+TEST(SharedRingBufferTest, SeveralWaitersAreAllReleased) {
+  constexpr uint32_t kNumWaiters = 8;
+  constexpr uint32_t kWaitTimeoutMs = 1000;
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  std::atomic<uint32_t> woke{0};
+  std::vector<SharedRingBuffer::WriterWaitResult> outcomes(
+      kNumWaiters, SharedRingBuffer::WriterWaitResult::kTimedOut);
+  std::vector<std::thread> waiters;
+  for (uint32_t i = 0; i < kNumWaiters; ++i) {
+    waiters.emplace_back([&, i] {
+      outcomes[i] = ring->WaitForReadPosChange(0, kWaitTimeoutMs);
+      woke.fetch_add(1);
+    });
+  }
+
+  const bool all_parked = SpinUntil(
+      [&] { return ring->num_writers_waiting_for_testing() == kNumWaiters; });
+
+  // Published - and every thread joined - before any assertion, so a failure
+  // here cannot leave a joinable thread behind.
+  ring->PublishReadPos(1);
+  for (auto& waiter : waiters)
+    waiter.join();
+
+  ASSERT_TRUE(all_parked);
+  EXPECT_EQ(woke.load(), kNumWaiters);
+  for (auto outcome : outcomes)
+    EXPECT_EQ(outcome, SharedRingBuffer::WriterWaitResult::kRetry);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+}
+
+// A publication may land anywhere between the waiter registering, the
+// user-space recheck and the kernel's atomic compare: none of those windows
+// may lose it. Each round parks a waiter, publishes as soon as the hint says
+// it is registered - which is before it has necessarily reached the kernel -
+// and requires progress, not a timeout. The generous timeout exists only so a
+// lost wake fails loudly instead of hanging.
+TEST(SharedRingBufferTest, PublicationBetweenRegistrationAndSleepIsNotLost) {
+  constexpr uint32_t kRounds = 100;
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+
+  for (uint32_t round = 0; round < kRounds; ++round) {
+    const uint32_t sample = ring->read_pos_for_testing();
+    SharedRingBuffer::WriterWaitResult outcome =
+        SharedRingBuffer::WriterWaitResult::kTimedOut;
+    std::thread writer(
+        [&] { outcome = ring->WaitForReadPosChange(sample, 30000); });
+
+    const bool registered =
+        SpinUntil([&] { return ring->num_writers_waiting_for_testing() != 0; });
+    ring->PublishReadPos(sample + 1);
+    writer.join();
+
+    ASSERT_TRUE(registered) << round;
+    ASSERT_EQ(outcome, SharedRingBuffer::WriterWaitResult::kRetry) << round;
+    ASSERT_EQ(ring->num_writers_waiting_for_testing(), 0u) << round;
+  }
+}
+
+// The hint exists only to elide a syscall. A reader that never looks at it is
+// slower, not wrong, so a writer must make progress even then.
+TEST(SharedRingBufferTest, WaiterHintIsNotRequiredForProgress) {
+  auto ring = SharedRingBuffer::Create(2, kChunkSize);
+  ASSERT_NE(ring, nullptr);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+
+  // Publishing with no waiters must not wake anything or leave the hint dirty.
+  ring->PublishReadPos(3);
+  EXPECT_EQ(ring->num_writers_waiting_for_testing(), 0u);
+  EXPECT_EQ(ring->read_pos_for_testing(), 3u);
+}
+
+#endif  // PERFETTO_OS_LINUX_BUT_NOT_QNX || PERFETTO_OS_ANDROID
+
+// The errno policy of the wait, checked directly rather than by arranging for
+// the kernel to fail. Every branch matters to a stalling writer: two of them
+// send it back to the capacity predicate, one is the slice expiring, and the
+// rest have to stop it waiting at all.
+TEST(SharedRingBufferTest, WaitErrnoPolicy) {
+  using WriterWaitResult = SharedRingBuffer::WriterWaitResult;
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(ETIMEDOUT),
+            WriterWaitResult::kTimedOut);
+  // read_pos moved between the load and the syscall.
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(EAGAIN),
+            WriterWaitResult::kRetry);
+  // A signal. Deliberately not retried inside the wrapper.
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(EINTR),
+            WriterWaitResult::kRetry);
+  // Anything else means this address cannot be waited on, so a stalling writer
+  // must drop rather than come straight back into the same failing syscall.
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(EINVAL),
+            WriterWaitResult::kUnavailable);
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(EFAULT),
+            WriterWaitResult::kUnavailable);
+  EXPECT_EQ(SharedRingBuffer::ClassifyWriterWaitErrnoForTesting(ENOSYS),
+            WriterWaitResult::kUnavailable);
+}
+
+}  // namespace
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer_writer.cc b/src/tracing/v2/shared_ring_buffer_writer.cc
new file mode 100644
index 0000000..7c29d45
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_writer.cc
@@ -0,0 +1,382 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer_writer.h"
+
+#include <stdint.h>
+#include <string.h>
+
+#include <optional>
+
+#include "perfetto/base/logging.h"
+#include "perfetto/base/time.h"
+#include "perfetto/ext/tracing/core/basic_types.h"
+#include "perfetto/tracing/buffer_exhausted_policy.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+namespace perfetto::tracing_v2 {
+
+SharedRingBufferWriter::Delegate::~Delegate() = default;
+
+SharedRingBufferWriter::SharedRingBufferWriter(
+    SharedRingBuffer* ring,
+    WriterID writer_id,
+    BufferID target_buffer,
+    BufferExhaustedPolicy buffer_exhausted_policy,
+    Delegate* delegate)
+    : ring_(ring),
+      writer_id_(writer_id),
+      target_buffer_(target_buffer),
+      buffer_exhausted_policy_(buffer_exhausted_policy),
+      delegate_(delegate),
+      chunk_size_(ring->chunk_size()) {
+  PERFETTO_CHECK(delegate_);
+  relocation_payload_.reserve(MaxFragmentSizeForEmptyChunk(chunk_size_));
+  relocation_fragment_sizes_.reserve(kMaxFragmentsPerChunk);
+}
+
+SharedRingBufferWriter::~SharedRingBufferWriter() {
+  FinishCurrentChunk();
+}
+
+SharedRingBufferWriter::FragmentSpan SharedRingBufferWriter::OpenFragment(
+    uint32_t min_size,
+    bool continues_from_prev) {
+  PERFETTO_DCHECK(!has_open_fragment());
+
+  const uint32_t largest_possible_fragment =
+      MaxFragmentSizeForEmptyChunk(chunk_size_);
+  if (min_size > largest_possible_fragment)
+    return FragmentSpan{Outcome::kTooLarge, nullptr, nullptr};
+
+  if (current_chunk_) {
+    PERFETTO_DCHECK(ChunkStateOf(expected_state_word_) ==
+                    ChunkState::kComplete);
+    if (MaxFragmentSizeInCurrentChunk() >= min_size &&
+        ring_->TryReacquireChunkForWriting(current_chunk_index_,
+                                           expected_state_word_)) {
+      expected_state_word_ =
+          ReplaceChunkState(expected_state_word_, ChunkState::kBeingWritten);
+      return OpenFragmentInCurrentChunk();
+    }
+    // The chunk is full or the reader reclaimed it.
+    ResetCurrentChunk();
+  }
+
+  const uint32_t carried_flags =
+      continues_from_prev ? uint32_t{kFlagContinuesFromPrevChunk} : 0u;
+  const Outcome outcome = AcquireNewChunk(carried_flags);
+  if (outcome != Outcome::kOk)
+    return FragmentSpan{outcome, nullptr, nullptr};
+  return OpenFragmentInCurrentChunk();
+}
+
+SharedRingBufferWriter::Outcome SharedRingBufferWriter::CloseFragment(
+    uint32_t size,
+    bool continues_on_next) {
+  PERFETTO_DCHECK(has_open_fragment());
+  PERFETTO_DCHECK(current_chunk_);
+  PERFETTO_DCHECK(ChunkStateOf(expected_state_word_) ==
+                  ChunkState::kBeingWritten);
+  PERFETTO_DCHECK(size <= open_fragment_end_ - open_fragment_begin_);
+
+  // Nothing becomes visible until CompleteCurrentChunk().
+  const uint32_t varint_bytes = FragmentSizeVarIntBytes(size);
+  PERFETTO_DCHECK(open_fragment_begin_ + size <= fragment_sizes_begin_);
+  PERFETTO_DCHECK(varint_bytes <=
+                  fragment_sizes_begin_ - (open_fragment_begin_ + size));
+  fragment_sizes_begin_ = static_cast<uint32_t>(
+      WriteFragmentSize(current_chunk_ + fragment_sizes_begin_, size) -
+      current_chunk_);
+  payload_end_ = open_fragment_begin_ + size;
+  ++num_fragments_;
+  open_fragment_begin_ = kNoOpenFragment;
+
+  return CompleteCurrentChunk(continues_on_next);
+}
+
+SharedRingBufferWriter::Outcome SharedRingBufferWriter::FinishCurrentChunk() {
+  // An open fragment is not counted and can be abandoned.
+  open_fragment_begin_ = kNoOpenFragment;
+
+  if (current_chunk_ &&
+      ChunkStateOf(expected_state_word_) == ChunkState::kBeingWritten)
+    return CompleteCurrentChunk(/*continues_on_next=*/false);
+
+  ResetCurrentChunk();
+  return Outcome::kOk;
+}
+
+uint32_t SharedRingBufferWriter::MaxFragmentSizeInCurrentChunk() const {
+  if (!current_chunk_)
+    return 0;
+  if (num_fragments_ >= kMaxFragmentsPerChunk)
+    return 0;
+  if (fragment_sizes_begin_ <= payload_end_)
+    return 0;
+  const uint32_t available_bytes = fragment_sizes_begin_ - payload_end_;
+  return MaxFragmentSizeForAvailableBytes(available_bytes);
+}
+
+SharedRingBufferWriter::Outcome SharedRingBufferWriter::AcquireNewChunk(
+    uint32_t carried_flags) {
+  PERFETTO_DCHECK(!current_chunk_);
+
+  BufferExhaustedPolicy policy = buffer_exhausted_policy_;
+  // Once data has been dropped, keep trying with kDrop until a chunk is
+  // acquired. That chunk reports the gap and lets the next full-ring event
+  // stall again.
+  if (policy == BufferExhaustedPolicy::kStallThenDrop && data_loss_pending_)
+    policy = BufferExhaustedPolicy::kDrop;
+
+  // BufferExhaustedPolicy gives a stalling writer a few seconds before kStall
+  // aborts or kStallThenDrop starts dropping. Use the same 30 second ceiling as
+  // the v1 arbiter. A deadline, rather than a retry count, also bounds spurious
+  // futex wakes.
+  constexpr uint32_t kStallTimeoutMs = 30000;
+  std::optional<base::TimeMillis> stall_deadline;
+
+  uint32_t flags = carried_flags;
+  if (data_loss_pending_)
+    flags |= kFlagDataLoss;
+
+  uint32_t failed_claims = 0;
+  bool saw_unclaimable_chunk = false;
+  for (;;) {
+    const auto reservation = ring_->TryReserveWritePos();
+
+    if (reservation.result == SharedRingBuffer::ReserveResult::kReserved) {
+      const uint32_t being_written_word =
+          MakeDataStateWord(ChunkState::kBeingWritten,
+                            ChunkFormat::kTargetBuffer, flags, 0, writer_id_);
+      if (ring_->TryAcquireChunkForWriting(reservation.position,
+                                           being_written_word)) {
+        current_chunk_index_ =
+            ChunkIndexOfPosition(reservation.position, ring_->num_chunks());
+        current_chunk_ = ring_->chunk_at(current_chunk_index_);
+        expected_state_word_ = being_written_word;
+        payload_end_ = kTargetBufferPayloadOffset;
+        fragment_sizes_begin_ = chunk_size_;
+        num_fragments_ = 0;
+        data_loss_pending_ = false;
+        StoreTargetBufferId(current_chunk_, target_buffer_);
+        return Outcome::kOk;
+      }
+
+      // This reservation is now a hole. Never retry it against a different
+      // Free word; reserve a later position instead.
+      ++num_failed_claims_;
+      saw_unclaimable_chunk = true;
+      if (++failed_claims < ring_->num_chunks())
+        continue;
+    }
+
+    // A stalling writer cannot make progress until the reader runs. A dropping
+    // writer only needs to notify it after creating holes.
+    if (failed_claims != 0 || policy != BufferExhaustedPolicy::kDrop) {
+      delegate_->NotifyReader();
+      failed_claims = 0;
+    }
+
+    const Outcome exhausted_outcome =
+        saw_unclaimable_chunk ? Outcome::kNoChunkAvailable : Outcome::kFull;
+    if (policy == BufferExhaustedPolicy::kDrop)
+      return exhausted_outcome;
+
+    const base::TimeMillis now = base::GetWallTimeMs();
+    if (!stall_deadline)
+      stall_deadline = now + base::TimeMillis(kStallTimeoutMs);
+    if (now >= *stall_deadline) {
+      if (policy == BufferExhaustedPolicy::kStall) {
+        PERFETTO_FATAL(
+            "tracing v2: writer %u could not acquire a chunk for %u ms; "
+            "possible deadlock",
+            writer_id_, kStallTimeoutMs);
+      }
+      return exhausted_outcome;
+    }
+
+    const uint32_t timeout_ms =
+        static_cast<uint32_t>((*stall_deadline - now).count());
+    const SharedRingBuffer::WriterWaitResult wait =
+        ring_->WaitForReadPosChange(reservation.read_pos_sample, timeout_ms);
+    if (wait == SharedRingBuffer::WriterWaitResult::kUnavailable) {
+      if (policy == BufferExhaustedPolicy::kStall) {
+        PERFETTO_FATAL(
+            "tracing v2: writer %u cannot stall because waiting on read_pos "
+            "is unavailable",
+            writer_id_);
+      }
+      return exhausted_outcome;
+    }
+  }
+}
+
+SharedRingBufferWriter::Outcome SharedRingBufferWriter::CompleteCurrentChunk(
+    bool continues_on_next) {
+  PERFETTO_DCHECK(current_chunk_);
+  PERFETTO_DCHECK(ChunkStateOf(expected_state_word_) ==
+                  ChunkState::kBeingWritten);
+  PERFETTO_DCHECK(!has_open_fragment());
+
+  // expected_state_word_ contains the number of fragments visible before the
+  // writer took the chunk. num_fragments_ also includes what it appended
+  // afterwards. Usually the writer changes BeingWritten directly to Complete.
+  // If the reader gets there first, it changes BeingWritten(N) to
+  // RewriteRequested(N) and takes those N fragments. The writer then moves only
+  // fragments N..M:
+  //
+  //   BeingWritten(N) -- writer --> Complete(M)
+  //          |
+  //          +--------- reader --> RewriteRequested(N)
+  //                                      |
+  //                         writer copies N..M and acknowledges
+  //
+  // A replacement can lose the same race, hence the loop.
+  for (;;) {
+    uint32_t flags = PayloadFlagsOf(expected_state_word_);
+    if (continues_on_next)
+      flags |= kFlagContinuesOnNextChunk;
+    const uint32_t complete_word =
+        MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer,
+                          flags, num_fragments_, writer_id_);
+
+    uint32_t observed = expected_state_word_;
+    if (ring_->TrySetChunkComplete(current_chunk_index_, &observed,
+                                   complete_word)) {
+      expected_state_word_ = complete_word;
+      // A Complete chunk carrying "continues on next chunk" is never reused.
+      // Together with the rule that a BeingWritten word never carries that
+      // flag, this guarantees that every non-empty prefix the reader can
+      // scrape ends on a packet boundary.
+      if (continues_on_next || MaxFragmentSizeInCurrentChunk() == 0)
+        ResetCurrentChunk();
+      return Outcome::kOk;
+    }
+
+    // Only the reader's rewrite request may beat this publication.
+    if (ChunkStateOf(observed) != ChunkState::kRewriteRequested ||
+        WriterIdOf(observed) != writer_id_) {
+      PERFETTO_FATAL(
+          "tracing v2: publication of chunk %u by writer %u lost to state word "
+          "0x%08x, which is not a rewrite request for this writer",
+          current_chunk_index_, writer_id_, observed);
+    }
+
+    // The reader took this prefix. The remaining fragments must move.
+    const uint32_t taken = NumFragmentsOf(observed);
+    PERFETTO_DCHECK(taken == NumFragmentsOf(expected_state_word_));
+    PERFETTO_DCHECK(taken <= num_fragments_);
+    const uint32_t suffix_fragments = num_fragments_ - taken;
+
+    // Copy the suffix out before acknowledging the rewrite. After that, the
+    // reader may reclaim the chunk.
+    uint32_t suffix_begin = kTargetBufferPayloadOffset;
+    relocation_fragment_sizes_.clear();
+    const uint8_t* size_cursor = current_chunk_ + chunk_size_;
+    for (uint32_t i = 0; i < num_fragments_; ++i) {
+      uint32_t fragment_size = 0;
+      if (!ReadFragmentSize(current_chunk_ + fragment_sizes_begin_,
+                            &size_cursor, &fragment_size)) {
+        PERFETTO_FATAL("tracing v2: writer %u corrupted its size directory",
+                       writer_id_);
+      }
+      if (i < taken) {
+        suffix_begin += fragment_size;
+      } else {
+        relocation_fragment_sizes_.push_back(fragment_size);
+      }
+    }
+    PERFETTO_DCHECK(size_cursor == current_chunk_ + fragment_sizes_begin_);
+    relocation_payload_.assign(current_chunk_ + suffix_begin,
+                               current_chunk_ + payload_end_);
+
+    // Acknowledge before looking for replacement capacity. The other order
+    // leaves the old chunk occupied whenever the ring is full, so every later
+    // traversal of it burns a position. Acknowledge even if the suffix ends up
+    // dropped: the acknowledgement is about the chunk, not about whether the
+    // data survived.
+    if (!ring_->TryAcknowledgeRewrite(current_chunk_index_, observed)) {
+      PERFETTO_FATAL(
+          "tracing v2: writer %u could not acknowledge chunk %u; only its "
+          "owner may leave RewriteRequested",
+          writer_id_, current_chunk_index_);
+    }
+    ++num_relocations_;
+
+    // Prefix flags move only when the reader took no fragments.
+    const uint32_t relocated_flags =
+        taken == 0 ? PayloadFlagsOf(observed) &
+                         (kFlagContinuesFromPrevChunk | kFlagDataLoss)
+                   : 0;
+
+    ResetCurrentChunk();
+
+    if (suffix_fragments == 0) {
+      // Nothing remains to relocate.
+      PERFETTO_DCHECK(!continues_on_next);
+      return Outcome::kOk;
+    }
+
+    if (AcquireNewChunk(relocated_flags) != Outcome::kOk) {
+      num_fragments_dropped_ += suffix_fragments;
+      data_loss_pending_ = true;
+      return Outcome::kRelocationDropped;
+    }
+
+    // Rebuild the suffix in the replacement. A suffix always fits: it came out
+    // of a chunk of the same size.
+    memcpy(current_chunk_ + kTargetBufferPayloadOffset,
+           relocation_payload_.data(), relocation_payload_.size());
+    payload_end_ = kTargetBufferPayloadOffset +
+                   static_cast<uint32_t>(relocation_payload_.size());
+    for (uint32_t fragment_size : relocation_fragment_sizes_) {
+      fragment_sizes_begin_ = static_cast<uint32_t>(
+          WriteFragmentSize(current_chunk_ + fragment_sizes_begin_,
+                            fragment_size) -
+          current_chunk_);
+    }
+    num_fragments_ = static_cast<uint32_t>(relocation_fragment_sizes_.size());
+    // Round again to publish the replacement, which the reader may also scrape.
+  }
+}
+
+void SharedRingBufferWriter::ResetCurrentChunk() {
+  current_chunk_ = nullptr;
+  current_chunk_index_ = 0;
+  expected_state_word_ = 0;
+  payload_end_ = 0;
+  fragment_sizes_begin_ = 0;
+  num_fragments_ = 0;
+  open_fragment_begin_ = kNoOpenFragment;
+  open_fragment_end_ = 0;
+}
+
+SharedRingBufferWriter::FragmentSpan
+SharedRingBufferWriter::OpenFragmentInCurrentChunk() {
+  PERFETTO_DCHECK(current_chunk_);
+  PERFETTO_DCHECK(ChunkStateOf(expected_state_word_) ==
+                  ChunkState::kBeingWritten);
+  const uint32_t available = MaxFragmentSizeInCurrentChunk();
+  PERFETTO_DCHECK(available > 0);
+  open_fragment_begin_ = payload_end_;
+  open_fragment_end_ = payload_end_ + available;
+  return FragmentSpan{Outcome::kOk, current_chunk_ + open_fragment_begin_,
+                      current_chunk_ + open_fragment_end_};
+}
+
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/shared_ring_buffer_writer.h b/src/tracing/v2/shared_ring_buffer_writer.h
new file mode 100644
index 0000000..6fe4742
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_writer.h
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACING_V2_SHARED_RING_BUFFER_WRITER_H_
+#define SRC_TRACING_V2_SHARED_RING_BUFFER_WRITER_H_
+
+#include <stdint.h>
+
+#include <vector>
+
+#include "perfetto/ext/tracing/core/basic_types.h"
+#include "perfetto/tracing/buffer_exhausted_policy.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+namespace perfetto::tracing_v2 {
+
+// Writes the packet fragments produced by one TraceWriter into a
+// SharedRingBuffer.
+//
+// A fragment is one packet, or one part of a packet that crosses a chunk
+// boundary. A chunk can hold several fragments. SharedRingBufferWriter deals
+// only with their byte ranges and lengths; it does not interpret their bytes.
+//
+// Each instance is used by one thread at a time. Several instances can write
+// to the same ring concurrently.
+//
+// Reserving a write position and claiming its physical chunk are separate
+// operations. If the claim fails, the position is still part of the stream and
+// only the reader can resolve it. The writer notifies its delegate before it
+// waits for the reader to make space.
+class SharedRingBufferWriter {
+ public:
+  // Result of an operation that may need a new chunk.
+  enum class Outcome {
+    kOk,
+    // The ring is structurally full: num_chunks positions are outstanding and
+    // the reader is behind. A stalling policy has already waited by the time
+    // this is returned.
+    kFull,
+    // Positions were reserved but their chunks could not be claimed. Chunks
+    // pinned by a stalled writer produce this without the ring being full.
+    //
+    // The reader has been notified before this is returned.
+    kNoChunkAvailable,
+    // The request is larger than a freshly claimed chunk could hold. This is a
+    // caller bug, not backpressure.
+    kTooLarge,
+    // The reader scraped the chunk and there was no replacement capacity, so
+    // the unpublished suffix was dropped. The writer is consistent and its next
+    // publication will carry the data-loss flag.
+    kRelocationDropped,
+  };
+
+  // A contiguous range for the caller to fill. Valid until the next call on
+  // this SharedRingBufferWriter.
+  struct FragmentSpan {
+    Outcome outcome = Outcome::kFull;
+    uint8_t* begin = nullptr;
+    uint8_t* end = nullptr;
+  };
+
+  class Delegate {
+   public:
+    virtual ~Delegate();
+
+    // Makes the reader aware of newly reserved positions. Called before a
+    // writer applies a buffer-exhaustion policy that may wait for read_pos to
+    // advance. Several writers may call this concurrently.
+    virtual void NotifyReader() = 0;
+  };
+
+  SharedRingBufferWriter(SharedRingBuffer* ring,
+                         WriterID writer_id,
+                         BufferID target_buffer,
+                         BufferExhaustedPolicy buffer_exhausted_policy,
+                         Delegate* delegate);
+  ~SharedRingBufferWriter();
+
+  SharedRingBufferWriter(const SharedRingBufferWriter&) = delete;
+  SharedRingBufferWriter& operator=(const SharedRingBufferWriter&) = delete;
+  SharedRingBufferWriter(SharedRingBufferWriter&&) = delete;
+  SharedRingBufferWriter& operator=(SharedRingBufferWriter&&) = delete;
+
+  // Returns space for one fragment of at least |min_size| bytes. This reuses
+  // the current chunk when possible and acquires a new chunk otherwise. At
+  // most one fragment can be open at a time.
+  //
+  // |continues_from_prev| says that the first bytes written here are the tail
+  // of a packet that began in this writer's previous chunk. It is recorded only
+  // when this call moves to a new chunk, which is the only case where the flag
+  // means anything.
+  FragmentSpan OpenFragment(uint32_t min_size, bool continues_from_prev);
+
+  // Closes the open fragment at |size| bytes - which must not exceed the span
+  // OpenFragment() handed out - writes its size varint, and publishes.
+  //
+  // |continues_on_next| says that this fragment is the head of a packet that
+  // continues in this writer's next chunk. A chunk published with that flag is
+  // never reused, which is what makes every non-empty published prefix end on a
+  // packet boundary.
+  Outcome CloseFragment(uint32_t size, bool continues_on_next);
+
+  // Publishes whatever is held and lets go of the chunk. Any open fragment is
+  // abandoned: its bytes were never counted, so nothing is published for it.
+  // Safe to call with nothing held; the destructor calls it.
+  Outcome FinishCurrentChunk();
+
+  // Tells the writer that the caller dropped data, so that the next chunk this
+  // writer publishes reports the gap. The writer sets this itself when it has
+  // to drop a relocated suffix; callers that drop a packet of their own accord
+  // have to say so here.
+  void RecordDataLoss() { data_loss_pending_ = true; }
+
+  // Payload bytes the current chunk could give to one more fragment.
+  // Zero if nothing is held, if the chunk has reached 255 fragments, or if the
+  // payload bytes and fragment-size varints have met.
+  uint32_t MaxFragmentSizeInCurrentChunk() const;
+
+  bool has_open_fragment() const {
+    return open_fragment_begin_ != kNoOpenFragment;
+  }
+  WriterID writer_id() const { return writer_id_; }
+  BufferID target_buffer() const { return target_buffer_; }
+
+  // Diagnostics only.
+  uint64_t num_failed_claims() const { return num_failed_claims_; }
+  uint64_t num_fragments_dropped() const { return num_fragments_dropped_; }
+  uint64_t num_relocations() const { return num_relocations_; }
+
+ private:
+  static constexpr uint32_t kNoOpenFragment = UINT32_MAX;
+
+  Outcome AcquireNewChunk(uint32_t carried_flags);
+  Outcome CompleteCurrentChunk(bool continues_on_next);
+  // Clears only this writer's cached chunk state. It does not modify the ring.
+  void ResetCurrentChunk();
+  FragmentSpan OpenFragmentInCurrentChunk();
+
+  SharedRingBuffer* const ring_;
+  const WriterID writer_id_;
+  const BufferID target_buffer_;
+  const BufferExhaustedPolicy buffer_exhausted_policy_;
+  Delegate* const delegate_;
+  const uint32_t chunk_size_;
+
+  // State cached for the chunk this writer currently owns.
+  uint8_t* current_chunk_ = nullptr;
+  uint32_t current_chunk_index_ = 0;
+  // Exact word expected by this writer's next state transition.
+  uint32_t expected_state_word_ = 0;
+  // Offset of the end of finalized payload.
+  uint32_t payload_end_ = 0;
+  // Offset of the first fragment-size varint. Sizes are prepended from the end
+  // of the chunk towards the payload.
+  uint32_t fragment_sizes_begin_ = 0;
+  // Finalized fragments, published or not. expected_state_word_ contains the
+  // count already visible to the reader.
+  uint32_t num_fragments_ = 0;
+  uint32_t open_fragment_begin_ = kNoOpenFragment;
+  uint32_t open_fragment_end_ = 0;
+
+  // Set when this writer has lost data that the next chunk it publishes must
+  // report.
+  bool data_loss_pending_ = false;
+
+  // Preallocated so relocation does not allocate.
+  std::vector<uint8_t> relocation_payload_;
+  std::vector<uint32_t> relocation_fragment_sizes_;
+
+  uint64_t num_failed_claims_ = 0;
+  uint64_t num_fragments_dropped_ = 0;
+  uint64_t num_relocations_ = 0;
+};
+
+}  // namespace perfetto::tracing_v2
+
+#endif  // SRC_TRACING_V2_SHARED_RING_BUFFER_WRITER_H_
diff --git a/src/tracing/v2/shared_ring_buffer_writer_unittest.cc b/src/tracing/v2/shared_ring_buffer_writer_unittest.cc
new file mode 100644
index 0000000..9032ce2
--- /dev/null
+++ b/src/tracing/v2/shared_ring_buffer_writer_unittest.cc
@@ -0,0 +1,701 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/shared_ring_buffer_writer.h"
+
+#include <stdint.h>
+#include <string.h>
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/ext/base/no_destructor.h"
+#include "perfetto/tracing/buffer_exhausted_policy.h"
+#include "src/tracing/v2/shared_ring_buffer.h"
+#include "src/tracing/v2/tracing_v2_abi.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+constexpr WriterID kWriterA = 7;
+constexpr WriterID kWriterB = 8;
+constexpr BufferID kBuffer = 0x1234;
+
+class CountingSharedRingBufferWriterDelegate
+    : public SharedRingBufferWriter::Delegate {
+ public:
+  void NotifyReader() override { ++num_notifications; }
+
+  uint32_t num_notifications = 0;
+};
+
+class ReleasingSharedRingBufferWriterDelegate
+    : public SharedRingBufferWriter::Delegate {
+ public:
+  explicit ReleasingSharedRingBufferWriterDelegate(SharedRingBuffer* ring)
+      : ring_(ring) {}
+
+  void NotifyReader() override {
+    ++num_notifications;
+    const uint32_t read_pos = ring_->read_pos_for_testing();
+    const uint32_t chunk_index =
+        ChunkIndexOfPosition(read_pos, ring_->num_chunks());
+    uint32_t observed = ring_->LoadChunkStateWord(chunk_index);
+    ASSERT_EQ(ChunkStateOf(observed), ChunkState::kComplete);
+    ASSERT_TRUE(ring_->TryReleaseCompleteChunkAsFree(read_pos, &observed));
+    ring_->PublishReadPos(read_pos + 1);
+  }
+
+  uint32_t num_notifications = 0;
+
+ private:
+  SharedRingBuffer* const ring_;
+};
+
+class NoopSharedRingBufferWriterDelegate
+    : public SharedRingBufferWriter::Delegate {
+ public:
+  void NotifyReader() override {}
+};
+
+SharedRingBufferWriter::Delegate* GetNoopSharedRingBufferWriterDelegate() {
+  static base::NoDestructor<NoopSharedRingBufferWriterDelegate> delegate;
+  return &delegate.ref();
+}
+
+// A minimal, independent decoder for what a chunk holds. It deliberately does
+// not go through SharedRingBufferReader, so that a writer test cannot pass
+// because the reader shares the same misunderstanding.
+struct DecodedChunk {
+  ChunkState state = ChunkState::kFree;
+  WriterID writer_id = 0;
+  BufferID target_buffer = 0;
+  uint32_t payload_flags = 0;
+  std::vector<std::string> fragments;
+};
+
+DecodedChunk Decode(SharedRingBuffer* ring, uint32_t chunk_index) {
+  const uint8_t* chunk = ring->chunk_at(chunk_index);
+  const uint32_t word = ring->LoadChunkStateWord(chunk_index);
+  DecodedChunk decoded;
+  decoded.state = ChunkStateOf(word);
+  if (!HasDataFields(decoded.state))
+    return decoded;
+
+  decoded.writer_id = WriterIdOf(word);
+  decoded.target_buffer = LoadTargetBufferId(chunk);
+  decoded.payload_flags = PayloadFlagsOf(word);
+
+  const uint8_t* directory_cursor = chunk + ring->chunk_size();
+  uint32_t offset = kTargetBufferPayloadOffset;
+  for (uint32_t i = 0; i < NumFragmentsOf(word); ++i) {
+    uint32_t size = 0;
+    const bool valid = ReadFragmentSize(chunk + kTargetBufferPayloadOffset,
+                                        &directory_cursor, &size);
+    EXPECT_TRUE(valid);
+    if (!valid)
+      return decoded;
+    decoded.fragments.emplace_back(
+        reinterpret_cast<const char*>(chunk + offset), size);
+    offset += size;
+  }
+  return decoded;
+}
+
+// Writes one fragment holding |bytes| and publishes it.
+SharedRingBufferWriter::Outcome WriteFragment(SharedRingBufferWriter* writer,
+                                              const std::string& bytes,
+                                              bool continues_from_prev = false,
+                                              bool continues_on_next = false) {
+  const SharedRingBufferWriter::FragmentSpan span = writer->OpenFragment(
+      static_cast<uint32_t>(bytes.size()), continues_from_prev);
+  if (span.outcome != SharedRingBufferWriter::Outcome::kOk)
+    return span.outcome;
+  memcpy(span.begin, bytes.data(), bytes.size());
+  return writer->CloseFragment(static_cast<uint32_t>(bytes.size()),
+                               continues_on_next);
+}
+
+// Plays the reader's part of the scrape: marks whatever the writer currently
+// holds as rewrite-requested and reports what the marked prefix was.
+uint32_t MarkForRewrite(SharedRingBuffer* ring, uint32_t chunk_index) {
+  uint32_t observed = ring->LoadChunkStateWord(chunk_index);
+  EXPECT_EQ(ChunkStateOf(observed), ChunkState::kBeingWritten);
+  const uint32_t taken = NumFragmentsOf(observed);
+  EXPECT_TRUE(ring->TryRequestRewrite(chunk_index, &observed));
+  return taken;
+}
+
+// ---------------------------------------------------------------------------
+// Fragments and the directory.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferWriterTest, WritesPayloadUpAndSizesDown) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, std::string(5, 'a')),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, std::string(200, 'b')),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, std::string(3, 'c')),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // A 256-byte target-buffer chunk, byte for byte.
+  const uint8_t* chunk = ring->chunk_at(0);
+  EXPECT_EQ(chunk[255], 5u);
+  EXPECT_EQ(chunk[254], 0xc8u);
+  EXPECT_EQ(chunk[253], 1u);
+  EXPECT_EQ(chunk[252], 3u);
+
+  const DecodedChunk decoded = Decode(ring.get(), 0);
+  EXPECT_EQ(decoded.state, ChunkState::kComplete);
+  EXPECT_EQ(decoded.writer_id, kWriterA);
+  EXPECT_EQ(decoded.target_buffer, kBuffer);
+  ASSERT_EQ(decoded.fragments.size(), 3u);
+  EXPECT_EQ(decoded.fragments[0], std::string(5, 'a'));
+  EXPECT_EQ(decoded.fragments[1], std::string(200, 'b'));
+  EXPECT_EQ(decoded.fragments[2], std::string(3, 'c'));
+}
+
+TEST(SharedRingBufferWriterTest, FragmentSizesAtTheInterestingBoundaries) {
+  // These sizes straddle the one- and two-byte varint boundary.
+  auto ring = SharedRingBuffer::Create(8, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  const uint32_t kSizes[] = {0, 1, 127, 128, 255, 256};
+  std::vector<std::string> expected;
+  for (uint32_t size : kSizes) {
+    const std::string bytes(size, static_cast<char>('A' + (size % 26)));
+    ASSERT_EQ(WriteFragment(&writer, bytes),
+              SharedRingBufferWriter::Outcome::kOk)
+        << size;
+    expected.push_back(bytes);
+  }
+
+  // 0 + 1 + 127 + 128 + 255 + 256 = 767 bytes, which does not fit in one
+  // 512-byte chunk, so the writer moved on part way through. Walk every chunk
+  // it used and check the fragments come out in order.
+  std::vector<std::string> seen;
+  for (uint32_t i = 0; i < ring->num_chunks(); ++i) {
+    for (const std::string& fragment : Decode(ring.get(), i).fragments)
+      seen.push_back(fragment);
+  }
+  EXPECT_EQ(seen, expected);
+}
+
+TEST(SharedRingBufferWriterTest,
+     LargestFragmentFitsExactlyAndOneMoreByteDoesNot) {
+  auto ring = SharedRingBuffer::Create(4, 256);
+  ASSERT_NE(ring, nullptr);
+  // 256 - 6 header bytes - varint(248), which takes two bytes.
+  constexpr uint32_t kLargest = 248;
+
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(kLargest, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(static_cast<uint32_t>(span.end - span.begin), kLargest);
+  memset(span.begin, 'z', kLargest);
+  ASSERT_EQ(writer.CloseFragment(kLargest, false),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // The payload and the directory have met: the chunk cannot take another
+  // fragment, not even an empty one.
+  EXPECT_EQ(writer.MaxFragmentSizeInCurrentChunk(), 0u);
+  ASSERT_EQ(Decode(ring.get(), 0).fragments.size(), 1u);
+  EXPECT_EQ(Decode(ring.get(), 0).fragments[0].size(), kLargest);
+
+  // One byte more than any chunk of this size could ever hold is a caller
+  // bug, not backpressure, and says so.
+  SharedRingBufferWriter other(ring.get(), kWriterB, kBuffer,
+                               BufferExhaustedPolicy::kDrop,
+                               GetNoopSharedRingBufferWriterDelegate());
+  EXPECT_EQ(other.OpenFragment(kLargest + 1, false).outcome,
+            SharedRingBufferWriter::Outcome::kTooLarge);
+}
+
+TEST(SharedRingBufferWriterTest, ChunkClosesAt255FragmentsEvenWithSpaceLeft) {
+  auto ring = SharedRingBuffer::Create(4, 32768);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  for (uint32_t i = 0; i < kMaxFragmentsPerChunk; ++i)
+    ASSERT_EQ(WriteFragment(&writer, ""), SharedRingBufferWriter::Outcome::kOk)
+        << i;
+
+  const DecodedChunk first = Decode(ring.get(), 0);
+  EXPECT_EQ(first.fragments.size(), kMaxFragmentsPerChunk);
+  // Thousands of payload bytes are still free; the eight-bit count is what
+  // ended the chunk.
+  EXPECT_EQ(writer.MaxFragmentSizeInCurrentChunk(), 0u);
+
+  ASSERT_EQ(WriteFragment(&writer, "x"), SharedRingBufferWriter::Outcome::kOk);
+  const DecodedChunk second = Decode(ring.get(), 1);
+  ASSERT_EQ(second.fragments.size(), 1u);
+  EXPECT_EQ(second.fragments[0], "x");
+}
+
+TEST(SharedRingBufferWriterTest, LargeChunkSupportsAFragmentLargerThanUint16) {
+  constexpr uint32_t kBigChunk = 128 * 1024;
+  constexpr uint32_t kLargest = kBigChunk - 9;
+  auto ring = SharedRingBuffer::Create(2, kBigChunk);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  EXPECT_EQ(writer.OpenFragment(kLargest + 1, false).outcome,
+            SharedRingBufferWriter::Outcome::kTooLarge);
+
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(1, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(static_cast<uint32_t>(span.end - span.begin), kLargest);
+  memset(span.begin, 'a', kLargest);
+  ASSERT_EQ(writer.CloseFragment(kLargest, false),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  const DecodedChunk decoded = Decode(ring.get(), 0);
+  ASSERT_EQ(decoded.fragments.size(), 1u);
+  EXPECT_EQ(decoded.fragments[0], std::string(kLargest, 'a'));
+}
+
+// An aligned, non-power-of-two chunk size, end to end through the writer.
+TEST(SharedRingBufferWriterTest, WritesAndDecodesInANonPowerOfTwoChunk) {
+  auto ring = SharedRingBuffer::Create(4, 260);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  // 260 - 6 header bytes - the two-byte varint for 252.
+  constexpr uint32_t kLargest = 252;
+  EXPECT_EQ(writer.OpenFragment(kLargest + 1, false).outcome,
+            SharedRingBufferWriter::Outcome::kTooLarge);
+  ASSERT_EQ(WriteFragment(&writer, std::string(kLargest, 'z')),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(writer.MaxFragmentSizeInCurrentChunk(), 0u);
+
+  ASSERT_EQ(WriteFragment(&writer, "next"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(Decode(ring.get(), 0).fragments.size(), 1u);
+  EXPECT_EQ(Decode(ring.get(), 0).fragments[0].size(), kLargest);
+  const DecodedChunk second = Decode(ring.get(), 1);
+  ASSERT_EQ(second.fragments.size(), 1u);
+  EXPECT_EQ(second.fragments[0], "next");
+}
+
+// ---------------------------------------------------------------------------
+// Chunk reuse and the payload flags.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferWriterTest, ReusesItsOwnCompleteChunkWhileItCanTakeMore) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "one"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, "two"),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // Both fragments are in the same physical chunk, and only one position was
+  // consumed.
+  EXPECT_EQ(ring->LoadWritePos(), 1u);
+  const DecodedChunk decoded = Decode(ring.get(), 0);
+  ASSERT_EQ(decoded.fragments.size(), 2u);
+  EXPECT_EQ(decoded.fragments[0], "one");
+  EXPECT_EQ(decoded.fragments[1], "two");
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(1)), ChunkState::kFree);
+}
+
+TEST(SharedRingBufferWriterTest, ReuseLosesToTheReaderReclaimingTheChunk) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "one"),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // The reader consumes the Complete chunk before the writer takes it back.
+  uint32_t observed = ring->LoadChunkStateWord(0);
+  ASSERT_EQ(ChunkStateOf(observed), ChunkState::kComplete);
+  ASSERT_TRUE(ring->TryReleaseCompleteChunkAsFree(0, &observed));
+  ring->PublishReadPos(1);
+
+  // The writer's reuse fails; it drops its handle and goes for a fresh chunk
+  // rather than writing behind the reader.
+  ASSERT_EQ(WriteFragment(&writer, "two"),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)), ChunkState::kFree);
+  const DecodedChunk decoded = Decode(ring.get(), 1);
+  ASSERT_EQ(decoded.fragments.size(), 1u);
+  EXPECT_EQ(decoded.fragments[0], "two");
+}
+
+// A chunk published with "continues on next chunk" is never reused. Without
+// that rule a later scrape could take a prefix ending in the middle of a
+// packet.
+TEST(SharedRingBufferWriterTest, ChunkCarryingContinuesOnNextIsNeverReused) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "head", /*continues_from_prev=*/false,
+                          /*continues_on_next=*/true),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, "tail", /*continues_from_prev=*/true,
+                          /*continues_on_next=*/false),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  const DecodedChunk first = Decode(ring.get(), 0);
+  EXPECT_EQ(first.payload_flags, kFlagContinuesOnNextChunk);
+  ASSERT_EQ(first.fragments.size(), 1u);
+  EXPECT_EQ(first.fragments[0], "head");
+
+  const DecodedChunk second = Decode(ring.get(), 1);
+  EXPECT_EQ(second.payload_flags, kFlagContinuesFromPrevChunk);
+  ASSERT_EQ(second.fragments.size(), 1u);
+  EXPECT_EQ(second.fragments[0], "tail");
+}
+
+TEST(SharedRingBufferWriterTest, DataLossIsReportedOnTheNextChunkAndOnlyOnce) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  writer.RecordDataLoss();
+  ASSERT_EQ(WriteFragment(&writer, "after the gap"),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(Decode(ring.get(), 0).payload_flags, kFlagDataLoss);
+
+  // The chunk after it describes no gap of its own.
+  ASSERT_EQ(WriteFragment(&writer, std::string(500, 'x')),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(Decode(ring.get(), 1).payload_flags, 0u);
+}
+
+// ---------------------------------------------------------------------------
+// Backpressure outcomes.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferWriterTest, DropPolicyReportsFullWithoutBlocking) {
+  auto ring = SharedRingBuffer::Create(2, 256);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter a(ring.get(), kWriterA, kBuffer,
+                           BufferExhaustedPolicy::kDrop,
+                           GetNoopSharedRingBufferWriterDelegate());
+  SharedRingBufferWriter b(ring.get(), kWriterB, kBuffer,
+                           BufferExhaustedPolicy::kDrop,
+                           GetNoopSharedRingBufferWriterDelegate());
+
+  // Two writers hold both chunks, so the ring is structurally full.
+  ASSERT_EQ(a.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(b.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+
+  SharedRingBufferWriter c(ring.get(), 11, kBuffer,
+                           BufferExhaustedPolicy::kDrop,
+                           GetNoopSharedRingBufferWriterDelegate());
+  EXPECT_EQ(c.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kFull);
+  // Nothing was reserved, so a full ring costs no position.
+  EXPECT_EQ(ring->LoadWritePos(), 2u);
+  EXPECT_EQ(c.num_failed_claims(), 0u);
+}
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \
+    PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID)
+
+TEST(SharedRingBufferWriterTest, FullRingNotifiesReaderBeforeWaiting) {
+  auto ring = SharedRingBuffer::Create(1, 256);
+  ASSERT_NE(ring, nullptr);
+
+  SharedRingBufferWriter first(ring.get(), kWriterA, kBuffer,
+                               BufferExhaustedPolicy::kDrop,
+                               GetNoopSharedRingBufferWriterDelegate());
+  ASSERT_EQ(WriteFragment(&first, "first"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(first.FinishCurrentChunk(), SharedRingBufferWriter::Outcome::kOk);
+
+  ReleasingSharedRingBufferWriterDelegate delegate(ring.get());
+  SharedRingBufferWriter second(ring.get(), kWriterB, kBuffer,
+                                BufferExhaustedPolicy::kStall, &delegate);
+  EXPECT_EQ(second.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(delegate.num_notifications, 1u);
+}
+
+TEST(SharedRingBufferWriterTest,
+     StallThenDropDoesNotStallAgainBeforeAChunkIsAcquired) {
+  auto ring = SharedRingBuffer::Create(1, 256);
+  ASSERT_NE(ring, nullptr);
+
+  SharedRingBufferWriter first(ring.get(), kWriterA, kBuffer,
+                               BufferExhaustedPolicy::kDrop,
+                               GetNoopSharedRingBufferWriterDelegate());
+  ASSERT_EQ(WriteFragment(&first, "first"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(first.FinishCurrentChunk(), SharedRingBufferWriter::Outcome::kOk);
+
+  ReleasingSharedRingBufferWriterDelegate delegate(ring.get());
+  SharedRingBufferWriter second(ring.get(), kWriterB, kBuffer,
+                                BufferExhaustedPolicy::kStallThenDrop,
+                                &delegate);
+
+  // RecordDataLoss() marks an active drop episode. Retrying while that loss is
+  // still waiting to be reported must use kDrop rather than stall for another
+  // 30 seconds.
+  second.RecordDataLoss();
+  EXPECT_EQ(second.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kFull);
+  EXPECT_EQ(delegate.num_notifications, 0u);
+
+  uint32_t observed = ring->LoadChunkStateWord(0);
+  ASSERT_TRUE(ring->TryReleaseCompleteChunkAsFree(0, &observed));
+  ring->PublishReadPos(1);
+
+  ASSERT_EQ(WriteFragment(&second, "after loss"),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(Decode(ring.get(), 0).payload_flags, kFlagDataLoss);
+  ASSERT_EQ(second.FinishCurrentChunk(), SharedRingBufferWriter::Outcome::kOk);
+
+  // Publishing the loss ends the drop episode. The next exhausted acquisition
+  // stalls again, which gives the delegate a chance to release the chunk.
+  EXPECT_EQ(second.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(delegate.num_notifications, 1u);
+}
+
+#endif  // PERFETTO_OS_LINUX_BUT_NOT_QNX || PERFETTO_OS_ANDROID
+
+// A chunk pinned by a writer that stopped mid-rewrite is not the same thing as
+// a full ring: positions are available, but their chunks cannot be acquired.
+TEST(SharedRingBufferWriterTest,
+     PinnedChunksNotifyReaderAndReportNoChunkAvailable) {
+  // The writer tries each physical chunk once. Those failed claims fill the
+  // reservation window even though no writer acquired a chunk.
+  auto ring = SharedRingBuffer::Create(8, 256);
+  ASSERT_NE(ring, nullptr);
+
+  // Pin every chunk in RewriteRequested, which only its owner may leave.
+  // Claiming them directly leaves write_pos at zero, so there is capacity for
+  // every reservation the writer below makes.
+  for (uint32_t i = 0; i < ring->num_chunks(); ++i) {
+    const uint32_t being_written = MakeDataStateWord(
+        ChunkState::kBeingWritten, ChunkFormat::kTargetBuffer, 0, 0, kWriterB);
+    ASSERT_TRUE(ring->TryAcquireChunkForWriting(i, being_written));
+    uint32_t observed = being_written;
+    ASSERT_TRUE(ring->TryRequestRewrite(i, &observed));
+  }
+  ASSERT_EQ(ring->LoadWritePos(), 0u);
+
+  CountingSharedRingBufferWriterDelegate delegate;
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop, &delegate);
+  EXPECT_EQ(writer.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kNoChunkAvailable);
+  EXPECT_EQ(writer.num_failed_claims(), ring->num_chunks());
+  EXPECT_EQ(ring->LoadWritePos(), ring->num_chunks());
+  EXPECT_EQ(delegate.num_notifications, 1u);
+}
+
+// ---------------------------------------------------------------------------
+// Relocation after a scrape.
+// ---------------------------------------------------------------------------
+
+TEST(SharedRingBufferWriterTest, ScrapeMovesOnlyTheUnpublishedSuffix) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "published-one"),
+            SharedRingBufferWriter::Outcome::kOk);
+  ASSERT_EQ(WriteFragment(&writer, "published-two"),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // The writer takes the chunk back and fills a third fragment. The reader
+  // arrives while it is inside and takes the two published ones.
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(6, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  memcpy(span.begin, "suffix", 6);
+  EXPECT_EQ(MarkForRewrite(ring.get(), 0), 2u);
+
+  ASSERT_EQ(writer.CloseFragment(6, false),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(writer.num_relocations(), 1u);
+  EXPECT_EQ(writer.num_fragments_dropped(), 0u);
+
+  // The old chunk is acknowledged - the writer says nothing about who gets it
+  // next - and only the suffix moved.
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteAcknowledged);
+  const DecodedChunk replacement = Decode(ring.get(), 1);
+  EXPECT_EQ(replacement.state, ChunkState::kComplete);
+  EXPECT_EQ(replacement.writer_id, kWriterA);
+  EXPECT_EQ(replacement.target_buffer, kBuffer);
+  ASSERT_EQ(replacement.fragments.size(), 1u);
+  EXPECT_EQ(replacement.fragments[0], "suffix");
+  // A non-empty prefix went out with the chunk's beginning, so the suffix does
+  // not repeat the flags describing it.
+  EXPECT_EQ(replacement.payload_flags, 0u);
+}
+
+TEST(SharedRingBufferWriterTest,
+     ScrapeWithNoPublishedPrefixCarriesTheFlagsAlong) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+  writer.RecordDataLoss();
+
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(4, /*continues_from_prev=*/true);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  memcpy(span.begin, "tail", 4);
+  // The reader takes nothing: the writer has published no fragment yet.
+  EXPECT_EQ(MarkForRewrite(ring.get(), 0), 0u);
+
+  ASSERT_EQ(writer.CloseFragment(4, false),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  const DecodedChunk replacement = Decode(ring.get(), 1);
+  ASSERT_EQ(replacement.fragments.size(), 1u);
+  EXPECT_EQ(replacement.fragments[0], "tail");
+  // The reader took no beginning, so both flags describing it travel with the
+  // relocated suffix.
+  EXPECT_EQ(replacement.payload_flags,
+            kFlagContinuesFromPrevChunk | kFlagDataLoss);
+}
+
+TEST(SharedRingBufferWriterTest, ScrapeWithNothingUnpublishedJustAcknowledges) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "only"),
+            SharedRingBufferWriter::Outcome::kOk);
+  // Take the chunk back but add nothing, then let the reader scrape it.
+  ASSERT_EQ(writer.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(MarkForRewrite(ring.get(), 0), 1u);
+
+  // Releasing abandons the open fragment; there is nothing left to move.
+  EXPECT_EQ(writer.FinishCurrentChunk(), SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(writer.num_fragments_dropped(), 0u);
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(0)),
+            ChunkState::kRewriteAcknowledged);
+  EXPECT_EQ(ChunkStateOf(ring->LoadChunkStateWord(1)), ChunkState::kFree);
+}
+
+// Acknowledging happens before the writer looks for replacement capacity. The
+// other order would leave the old chunk occupied exactly when the ring is full,
+// so every later traversal of it would burn a position.
+TEST(SharedRingBufferWriterTest,
+     RelocationWithNoCapacityDropsButFreesTheOldChunk) {
+  auto ring = SharedRingBuffer::Create(2, 512);
+  ASSERT_NE(ring, nullptr);
+  SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                BufferExhaustedPolicy::kDrop,
+                                GetNoopSharedRingBufferWriterDelegate());
+
+  ASSERT_EQ(WriteFragment(&writer, "published"),
+            SharedRingBufferWriter::Outcome::kOk);
+
+  // Occupy the ring's only other chunk so no replacement can be had.
+  SharedRingBufferWriter blocker(ring.get(), kWriterB, kBuffer,
+                                 BufferExhaustedPolicy::kDrop,
+                                 GetNoopSharedRingBufferWriterDelegate());
+  ASSERT_EQ(blocker.OpenFragment(1, false).outcome,
+            SharedRingBufferWriter::Outcome::kOk);
+
+  const SharedRingBufferWriter::FragmentSpan span =
+      writer.OpenFragment(4, false);
+  ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+  memcpy(span.begin, "lost", 4);
+  EXPECT_EQ(MarkForRewrite(ring.get(), 0), 1u);
+
+  EXPECT_EQ(writer.CloseFragment(4, false),
+            SharedRingBufferWriter::Outcome::kRelocationDropped);
+  EXPECT_EQ(writer.num_fragments_dropped(), 1u);
+  // The old chunk is acknowledged and therefore reclaimable by the reader,
+  // even though the data did not survive.
+  EXPECT_EQ(ring->LoadChunkStateWord(0), kRewriteAcknowledgedStateWord);
+
+  // The gap is reported on the next chunk this writer manages to publish.
+  uint32_t observed = 0;
+  ASSERT_TRUE(ring->TryReleaseRewriteAcknowledgedChunkAsFree(0, &observed));
+  ring->PublishReadPos(1);
+  ASSERT_EQ(WriteFragment(&writer, "next"),
+            SharedRingBufferWriter::Outcome::kOk);
+  EXPECT_EQ(Decode(ring.get(), 0).payload_flags, kFlagDataLoss);
+}
+
+TEST(SharedRingBufferWriterTest, DestructorPublishesWhateverIsHeld) {
+  auto ring = SharedRingBuffer::Create(4, 512);
+  ASSERT_NE(ring, nullptr);
+  {
+    SharedRingBufferWriter writer(ring.get(), kWriterA, kBuffer,
+                                  BufferExhaustedPolicy::kDrop,
+                                  GetNoopSharedRingBufferWriterDelegate());
+    const SharedRingBufferWriter::FragmentSpan span =
+        writer.OpenFragment(4, false);
+    ASSERT_EQ(span.outcome, SharedRingBufferWriter::Outcome::kOk);
+    memcpy(span.begin, "kept", 4);
+    ASSERT_EQ(writer.CloseFragment(4, false),
+              SharedRingBufferWriter::Outcome::kOk);
+
+    // A second fragment is opened and never closed: it is abandoned.
+    ASSERT_EQ(writer.OpenFragment(4, false).outcome,
+              SharedRingBufferWriter::Outcome::kOk);
+  }
+  const DecodedChunk decoded = Decode(ring.get(), 0);
+  EXPECT_EQ(decoded.state, ChunkState::kComplete);
+  ASSERT_EQ(decoded.fragments.size(), 1u);
+  EXPECT_EQ(decoded.fragments[0], "kept");
+}
+
+}  // namespace
+}  // namespace perfetto::tracing_v2
diff --git a/src/tracing/v2/tracing_v2_abi.h b/src/tracing/v2/tracing_v2_abi.h
new file mode 100644
index 0000000..48a6a5b
--- /dev/null
+++ b/src/tracing/v2/tracing_v2_abi.h
@@ -0,0 +1,571 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACING_V2_TRACING_V2_ABI_H_
+#define SRC_TRACING_V2_TRACING_V2_ABI_H_
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include <atomic>
+
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/tracing/core/basic_types.h"
+#include "perfetto/public/pb_utils.h"
+
+namespace perfetto::tracing_v2 {
+
+// Shared-memory ABI for a tracing-v2 producer ring. Several trace writers can
+// write to the ring at once. One reader drains their data in the order in which
+// the writers reserved space. The reader may run in the producer process or in
+// the tracing service.
+//
+// The ring has one header followed by fixed-size chunks. The header holds the
+// read and write positions. Every chunk starts with one atomic state word. It
+// records who may access the chunk and, while a writer owns it, how many
+// complete fragments have been published.
+//
+// The ABI assumes little-endian producer and service processes. Anything
+// written by the producer is untrusted when the reader runs in the service, so
+// lengths and offsets must be checked before they are used.
+//
+// Shared-memory layout:
+//
+//   +------------------------+---------+---------+---------+-----+
+//   | RingBufferHeader, 64 B | chunk 0 | chunk 1 | chunk 2 | ... |
+//   +------------------------+---------+---------+---------+-----+
+//   0                        64
+
+// Ring header
+// -----------
+//
+// Writers decide whether the ring has room by loading rw_positions once. The
+// high half is write_pos and the low half is read_pos. Keeping them in one
+// atomic prevents a capacity check from combining counters read at different
+// times. On little-endian systems, read_pos is also the first four bytes of
+// rw_positions, which is the address used by the futex wait.
+//
+//   byte offset
+//   0              4              8             12               64
+//   +--------------+--------------+--------------+----------------+
+//   |   read_pos   |  write_pos   | num_writers_ | reserved       |
+//   |              |              | waiting      |                |
+//   +--------------+--------------+--------------+----------------+
+//   \_________ rw_positions _________/ \__ atomic32 __/
+//             atomic<uint64_t>
+//
+// num_writers_waiting lets the reader skip a futex wake when nobody is waiting
+// for space. It is only an optimization; it never decides whether the ring is
+// full or who owns a chunk.
+//
+// Bytes 12..63 pad the header to one cache line. Chunk 0 starts on the next
+// line, so updating the positions does not invalidate a cache line holding
+// chunk data.
+//
+static_assert(sizeof(std::atomic<uint32_t>) == 4 &&
+                  alignof(std::atomic<uint32_t>) <= 4,
+              "Chunk state must fit in a 4-byte-aligned ABI word");
+static_assert(sizeof(std::atomic<uint64_t>) == 8,
+              "The packed positions must occupy 8 bytes");
+static_assert(std::atomic<uint32_t>::is_always_lock_free &&
+                  std::atomic<uint64_t>::is_always_lock_free,
+              "Shared-memory atomics must be lock-free");
+
+struct alignas(64) RingBufferHeader {
+  std::atomic<uint64_t> rw_positions;
+  std::atomic<uint32_t> num_writers_waiting;
+  uint8_t reserved[52];
+};
+
+static_assert(offsetof(RingBufferHeader, num_writers_waiting) == 8 &&
+                  offsetof(RingBufferHeader, reserved) == 12 &&
+                  sizeof(RingBufferHeader) == 64,
+              "RingBufferHeader does not match the shared-memory ABI");
+
+constexpr uint64_t PackRwPositions(uint32_t write_pos, uint32_t read_pos) {
+  return (uint64_t{write_pos} << 32) | uint64_t{read_pos};
+}
+
+constexpr uint32_t WritePosOf(uint64_t rw_positions) {
+  return static_cast<uint32_t>(rw_positions >> 32);
+}
+
+constexpr uint32_t ReadPosOf(uint64_t rw_positions) {
+  return static_cast<uint32_t>(rw_positions);
+}
+
+// Replace one half of a previously loaded rw_positions value, leaving the
+// other half unchanged.
+constexpr uint64_t ReplaceWritePos(uint64_t rw_positions, uint32_t write_pos) {
+  return PackRwPositions(write_pos, ReadPosOf(rw_positions));
+}
+
+constexpr uint64_t ReplaceReadPos(uint64_t rw_positions, uint32_t read_pos) {
+  return PackRwPositions(WritePosOf(rw_positions), read_pos);
+}
+
+// Logical positions and chunk indexing
+// ------------------------------------
+//
+// write_pos is the next position a writer can reserve. read_pos is the next
+// position the reader must resolve. Both counters are uint32_t and are allowed
+// to wrap.
+//
+// The number of reserved positions not yet handled by the reader is:
+//
+//   outstanding = uint32_t(write_pos - read_pos)
+//   outstanding <= num_chunks < 2^31
+//
+// Unsigned subtraction also works when write_pos wraps back to zero. For
+// example:
+//
+//   read_pos  = UINT32_MAX - 3
+//   write_pos = 2
+//   uint32_t(write_pos - read_pos) = 6
+//
+// A legal result is at most num_chunks. A larger result means that the two
+// positions do not describe a valid ring state.
+//
+// num_chunks is a power of two, so no division is needed to map a position:
+//
+//   chunk_index = position & (num_chunks - 1)
+//
+// For num_chunks = 2^k, this uses the low k bits:
+//
+//             bits 31..k                    bits k-1..0
+//   +----------------------------+----------------------------+
+//   |       traversal number     |    physical chunk index    |
+//   +----------------------------+----------------------------+
+//              32 - k bits                    k bits
+//
+// For example, an eight-chunk ring has three chunk-index bits. The low three
+// bits select chunks 0 through 7. The remaining 29 bits count completed
+// traversals.
+
+// Positions can wrap from UINT32_MAX to zero. Unsigned subtraction still gives
+// the number of outstanding positions as long as fewer than 2^31 are in use.
+// Since num_chunks is a power of two, 2^30 is the largest legal chunk count.
+constexpr uint32_t kMaxChunksPerRing = 1u << 30;
+
+constexpr uint32_t NumOutstandingPositions(uint32_t write_pos,
+                                           uint32_t read_pos) {
+  return write_pos - read_pos;
+}
+
+// Returns k for num_chunks = 2^k. The low k bits of a position select the
+// physical chunk. num_chunks must be a non-zero power of two.
+constexpr uint32_t GetChunkIndexBits(uint32_t num_chunks) {
+  uint32_t chunk_index_bits = 0;
+  while (chunk_index_bits < 31 && (1u << chunk_index_bits) < num_chunks) {
+    ++chunk_index_bits;
+  }
+  return chunk_index_bits;
+}
+
+constexpr uint32_t ChunkIndexOfPosition(uint32_t position,
+                                        uint32_t num_chunks) {
+  return position & (num_chunks - 1);
+}
+
+// The initial ABI keeps the 256-byte minimum chosen for the producer-local
+// ring. Smaller chunks are not supported.
+constexpr uint32_t kMinChunkSize = 256;
+
+// Every chunk starts with a 32-bit atomic state word. Chunks are contiguous, so
+// the chunk size must be a multiple of four bytes to keep every state word
+// aligned.
+constexpr uint32_t kChunkAlignmentBytes = 4;
+
+// Chunk ownership
+// ---------------
+//
+// The state word is also the arbitration point between the reader and a
+// writer. For example, when a writer finishes while the reader is scraping the
+// same chunk, they race to change the exact same BeingWritten word:
+//
+//   Writer: BeingWritten -> Complete
+//   Reader: BeingWritten -> RewriteRequested
+//
+// If the writer wins, the reader sees Complete and consumes the finished
+// chunk. If the reader wins, the writer sees RewriteRequested and moves only
+// the data it appended after the published prefix. No second atomic is needed
+// to decide which event happened first.
+//
+// Claiming Free is different. A writer's reservation authorizes one exact
+// Free(wrap) word. If that compare-and-swap fails, the reader has already
+// resolved the position or another traversal owns the chunk. The writer drops
+// that reservation and must not retry against the new word.
+//
+// A chunk can move only along these paths:
+//
+//   Free(wrap) --Writer: claim---------------------> BeingWritten
+//   Free(wrap) --Reader: resolve unclaimed---------> Free(next wrap)
+//   BeingWritten --Writer: publish-----------------> Complete
+//   Complete  --Writer: reuse----------------------> BeingWritten
+//   Complete  --Reader: consume--------------------> Free(next wrap)
+//   BeingWritten --Reader: resolve committed prefix-> RewriteRequested
+//   RewriteRequested --Writer: release old chunk---> RewriteAcknowledged
+//   RewriteAcknowledged --Reader: reclaim----------> Free(next wrap)
+//
+// Only the reader writes Free.
+//
+// A Free word contains:
+//
+//    31                              16 15       8 7               0
+//   +----------------------------------+-----------+------------------+
+//   |            wrap_count            |   zero    |  control = Free  |
+//   +----------------------------------+-----------+------------------+
+//                   16 bits               8 bits          8 bits
+//
+// BeingWritten, Complete and RewriteRequested contain:
+//
+//    31                              16 15       8 7               0
+//   +----------------------------------+-----------+------------------+
+//   |             WriterID             |    num    |   control byte   |
+//   |                                  | fragments |                  |
+//   +----------------------------------+-----------+------------------+
+//                   16 bits               8 bits          8 bits
+//
+// The control byte is:
+//
+//   +---------+---------+---------+---------+---------+
+//   |  bit 7  |  bit 6  |  bit 5  |bits 4-3 |bits 2-0 |
+//   +---------+---------+---------+---------+---------+
+//   |continues|continues|  data   | format  |  state  |
+//   |from prev| on next |  loss   |         |         |
+//   +---------+---------+---------+---------+---------+
+//
+// RewriteAcknowledged carries no other fields; every other bit is zero.
+
+enum class ChunkState : uint32_t {
+  // The chunk may be claimed by the reservation with this wrap count.
+  kFree = 0,
+
+  // One writer owns the chunk. num_frags is the prefix it has already
+  // published. The writer may be appending another fragment after that prefix.
+  kBeingWritten = 1,
+
+  // The writer has published num_frags fragments and is no longer touching the
+  // chunk. It may take the chunk back before the reader reclaims it.
+  kComplete = 2,
+
+  // The reader took the published prefix while the writer still owned the
+  // chunk. The writer must move anything it appended afterwards, then release
+  // this chunk.
+  kRewriteRequested = 3,
+
+  // The writer has finished with the old chunk. The reader may reclaim it.
+  kRewriteAcknowledged = 4,
+
+  // A reader that does not know a state cannot tell who owns the chunk. It
+  // stops rather than reclaiming it.
+  kReserved5 = 5,
+  kReserved6 = 6,
+  kReserved7 = 7,
+};
+
+enum class ChunkFormat : uint32_t {
+  // Bytes 4..5 contain a little-endian target BufferID. Payload starts at 6.
+  kTargetBuffer = 0,
+
+  // Reserved for a format carrying per-packet routing information.
+  kReservedRouting = 1,
+  kReserved2 = 2,
+  kReserved3 = 3,
+};
+
+// Field widths and positions in the 32-bit word. The low byte is the control
+// byte, followed by the fragment count and WriterID.
+constexpr uint32_t kPayloadFlagsBits = 3;
+constexpr uint32_t kChunkFormatBits = 2;
+constexpr uint32_t kChunkStateBits = 3;
+constexpr uint32_t kNumFragmentsBits = 8;
+constexpr uint32_t kWriterIdBits = 16;
+
+constexpr uint32_t kChunkStateShift = 0;
+constexpr uint32_t kChunkFormatShift = kChunkStateShift + kChunkStateBits;
+constexpr uint32_t kPayloadFlagsShift = kChunkFormatShift + kChunkFormatBits;
+constexpr uint32_t kNumFragmentsShift = kPayloadFlagsShift + kPayloadFlagsBits;
+constexpr uint32_t kWriterIdShift = kNumFragmentsShift + kNumFragmentsBits;
+
+constexpr uint32_t kChunkStateMask = (1u << kChunkStateBits) - 1;
+constexpr uint32_t kChunkFormatMask = ((1u << kChunkFormatBits) - 1)
+                                      << kChunkFormatShift;
+constexpr uint32_t kPayloadFlagsMask = ((1u << kPayloadFlagsBits) - 1)
+                                       << kPayloadFlagsShift;
+constexpr uint32_t kNumFragmentsMask = ((1u << kNumFragmentsBits) - 1)
+                                       << kNumFragmentsShift;
+constexpr uint32_t kWriterIdMask = ((1u << kWriterIdBits) - 1)
+                                   << kWriterIdShift;
+
+constexpr uint32_t kMaxFragmentsPerChunk = (1u << kNumFragmentsBits) - 1;
+
+// Free uses the WriterID field for the wrap count. Its other data bits must be
+// zero.
+constexpr uint32_t kWrapCountBits = 16;
+constexpr uint32_t kWrapCountShift = kWriterIdShift;
+constexpr uint32_t kWrapCountMask = ((1u << kWrapCountBits) - 1)
+                                    << kWrapCountShift;
+constexpr uint32_t kFreeReservedBitsMask = ~(kChunkStateMask | kWrapCountMask);
+
+// Free stores the low 16 bits of the position's traversal number:
+//
+//   wrap_count = uint16_t(position >> chunk_index_bits)
+//
+// A writer can claim a chunk only when this value matches its reservation.
+// This stops a delayed writer from claiming a Free word belonging to another
+// traversal. The 16-bit identity repeats after:
+//
+//   min(num_chunks * 65536, 2^32) reservations
+constexpr uint16_t WrapCountForPosition(uint32_t position,
+                                        uint32_t chunk_index_bits) {
+  return static_cast<uint16_t>(position >> chunk_index_bits);
+}
+
+enum PayloadFlags : uint32_t {
+  // The writer dropped trace data before writing this chunk.
+  kFlagDataLoss = 1u << kPayloadFlagsShift,
+
+  // The last fragment is not the end of its packet; the packet continues in
+  // this writer's next chunk.
+  kFlagContinuesOnNextChunk = 1u << (kPayloadFlagsShift + 1),
+
+  // The first fragment contains the next part of a packet that started in this
+  // writer's previous chunk.
+  kFlagContinuesFromPrevChunk = 1u << (kPayloadFlagsShift + 2),
+};
+
+constexpr ChunkState ChunkStateOf(uint32_t state_word) {
+  return static_cast<ChunkState>((state_word & kChunkStateMask) >>
+                                 kChunkStateShift);
+}
+
+constexpr bool HasDataFields(ChunkState state) {
+  return state == ChunkState::kBeingWritten || state == ChunkState::kComplete ||
+         state == ChunkState::kRewriteRequested;
+}
+
+// Use these helpers only with Free. The uint16_t parameter keeps all reserved
+// bits clear.
+constexpr uint16_t WrapCountOf(uint32_t state_word) {
+  return static_cast<uint16_t>((state_word & kWrapCountMask) >>
+                               kWrapCountShift);
+}
+
+constexpr uint32_t MakeFreeStateWord(uint16_t wrap_count) {
+  return static_cast<uint32_t>(wrap_count) << kWrapCountShift;
+}
+
+// These accessors apply to BeingWritten, Complete and RewriteRequested.
+// PayloadFlagsOf() leaves the flags in their encoded bit positions, so its
+// result can be passed to MakeDataStateWord().
+constexpr ChunkFormat ChunkFormatOf(uint32_t state_word) {
+  return static_cast<ChunkFormat>((state_word & kChunkFormatMask) >>
+                                  kChunkFormatShift);
+}
+
+constexpr uint32_t PayloadFlagsOf(uint32_t state_word) {
+  return state_word & kPayloadFlagsMask;
+}
+
+constexpr uint32_t NumFragmentsOf(uint32_t state_word) {
+  return (state_word & kNumFragmentsMask) >> kNumFragmentsShift;
+}
+
+constexpr WriterID WriterIdOf(uint32_t state_word) {
+  return static_cast<WriterID>((state_word & kWriterIdMask) >> kWriterIdShift);
+}
+
+inline uint32_t MakeDataStateWord(ChunkState state,
+                                  ChunkFormat format,
+                                  uint32_t payload_flags,
+                                  uint32_t num_fragments,
+                                  WriterID writer_id) {
+  PERFETTO_DCHECK(HasDataFields(state));
+  PERFETTO_DCHECK(static_cast<uint32_t>(format) < (1u << kChunkFormatBits));
+  PERFETTO_DCHECK((payload_flags & ~kPayloadFlagsMask) == 0);
+  PERFETTO_DCHECK(num_fragments <= kMaxFragmentsPerChunk);
+  return (static_cast<uint32_t>(state) << kChunkStateShift) |
+         (static_cast<uint32_t>(format) << kChunkFormatShift) |
+         (payload_flags & kPayloadFlagsMask) |
+         ((num_fragments << kNumFragmentsShift) & kNumFragmentsMask) |
+         (static_cast<uint32_t>(writer_id) << kWriterIdShift);
+}
+
+constexpr uint32_t kRewriteAcknowledgedStateWord =
+    static_cast<uint32_t>(ChunkState::kRewriteAcknowledged) << kChunkStateShift;
+
+// The reader can request a rewrite without understanding the chunk format.
+constexpr uint32_t ReplaceChunkState(uint32_t state_word, ChunkState state) {
+  return (state_word & ~kChunkStateMask) |
+         (static_cast<uint32_t>(state) << kChunkStateShift);
+}
+
+// Target-buffer chunk format
+// --------------------------
+//
+// A data-bearing format-0 chunk begins with this six-byte header:
+//
+//   +---------+---------+-------------------+-------------------+
+//   | byte 0  | byte 1  |     bytes 2-3     |     bytes 4-5     |
+//   +---------+---------+-------------------+-------------------+
+//   | control |  num    |     WriterID      |  target BufferID  |
+//   |  byte   |fragments|                   |                   |
+//   +---------+---------+-------------------+-------------------+
+//   \_____________ atomic state word _______/
+//
+// Free uses bytes 2-3 for the wrap count and requires every other data bit to
+// be zero. RewriteAcknowledged carries only the control byte; bytes 1-3 are
+// zero.
+//
+// The rest of a format-0 chunk is laid out as follows:
+//
+//   low address                                                high address
+//   0                 4          6                    chunk_size
+//   +-----------------+----------+-----------+------+--------------+
+//   | atomic state    | BufferID | payloads  | free | size varints |
+//   | word            |          | grow ---> |      | <--- grow    |
+//   +-----------------+----------+-----------+------+--------------+
+//                                                      ... N  1  0
+//
+// Fragment 0's size is stored at the end of the chunk. num_frags publishes the
+// same number of payload fragments and size varints. The writer fills those
+// bytes before its release transition out of BeingWritten. The reader
+// acquire-loads the state word, then decodes and checks every length before
+// copying the payload.
+
+constexpr uint32_t kTargetBufferIdOffset = 4;
+constexpr uint32_t kTargetBufferPayloadOffset = 6;
+
+inline void StoreTargetBufferId(uint8_t* chunk, BufferID target_buffer_id) {
+  chunk[kTargetBufferIdOffset] = static_cast<uint8_t>(target_buffer_id);
+  chunk[kTargetBufferIdOffset + 1] =
+      static_cast<uint8_t>(target_buffer_id >> 8);
+}
+
+inline BufferID LoadTargetBufferId(const uint8_t* chunk) {
+  return static_cast<BufferID>(
+      static_cast<uint32_t>(chunk[kTargetBufferIdOffset]) |
+      (static_cast<uint32_t>(chunk[kTargetBufferIdOffset + 1]) << 8));
+}
+
+// Each fragment has a varint length in the directory at the end of the chunk.
+// The first fragment's varint ends at chunk_size. Each later varint is
+// prepended to the directory:
+//
+//   low address                                 high address
+//   +----------+----------+----------+----------+----------+
+//   | size N-1 |   ...    |  size 2  |  size 1  |  size 0  |
+//   +----------+----------+----------+----------+----------+
+//
+// The reader walks the directory from high addresses to low addresses. It sees
+// the bytes of each length in normal protobuf varint order and stops at that
+// varint's final byte. It never has to inspect the next, unpublished entry.
+//
+// Fragment lengths must use the shortest varint encoding. For example, 1 is
+// encoded as 01, not 81 00. The reader rejects redundant encodings.
+
+constexpr uint32_t kMaxFragmentSizeVarIntBytes = PERFETTO_PB_VARINT_MAX_SIZE_32;
+
+// Each varint byte carries seven value bits. Its top bit is set when another
+// byte follows.
+constexpr uint32_t kVarIntDataBitsPerByte = 7;
+constexpr uint8_t kVarIntContinuationBit = 1u << kVarIntDataBitsPerByte;
+
+constexpr uint32_t FragmentSizeVarIntBytes(uint32_t fragment_size) {
+  uint32_t bytes = 1;
+  while (fragment_size >= kVarIntContinuationBit) {
+    fragment_size >>= kVarIntDataBitsPerByte;
+    ++bytes;
+  }
+  return bytes;
+}
+
+// Returns the largest fragment that fits in |available_bytes| together with
+// its varint length. The loop runs at most four times: each decrement buys one
+// more byte for a varint that can be at most five bytes long.
+constexpr uint32_t MaxFragmentSizeForAvailableBytes(uint32_t available_bytes) {
+  if (available_bytes <= 1)
+    return 0;
+  uint32_t fragment_size = available_bytes - 1;
+  while (FragmentSizeVarIntBytes(fragment_size) >
+         available_bytes - fragment_size) {
+    --fragment_size;
+  }
+  return fragment_size;
+}
+
+constexpr uint32_t MaxFragmentSizeForEmptyChunk(uint32_t chunk_size) {
+  if (chunk_size < kMinChunkSize)
+    return 0;
+  const uint32_t available_bytes = chunk_size - kTargetBufferPayloadOffset;
+  return MaxFragmentSizeForAvailableBytes(available_bytes);
+}
+
+// Prepends one fragment length and returns the new beginning of the directory.
+// The destination moves down while the encoded bytes are copied in order. A
+// reader moving down through the directory therefore sees an ordinary protobuf
+// varint. The caller must leave FragmentSizeVarIntBytes(fragment_size) bytes
+// before |directory_begin|.
+inline uint8_t* WriteFragmentSize(uint8_t* directory_begin,
+                                  uint32_t fragment_size) {
+  uint8_t encoded[PERFETTO_PB_VARINT_MAX_SIZE_32];
+  const uint8_t* const encoded_end =
+      PerfettoPbWriteVarInt(fragment_size, encoded);
+  const size_t encoded_size = static_cast<size_t>(encoded_end - encoded);
+  for (size_t i = 0; i < encoded_size; ++i) {
+    --directory_begin;
+    *directory_begin = encoded[i];
+  }
+  return directory_begin;
+}
+
+// Reads the next varint while moving |*directory_cursor| towards lower
+// addresses. On success, leaves the cursor immediately before that varint.
+// |directory_begin| is the first byte the decoder may inspect.
+inline bool ReadFragmentSize(const uint8_t* directory_begin,
+                             const uint8_t** directory_cursor,
+                             uint32_t* fragment_size) {
+  uint8_t encoded[PERFETTO_PB_VARINT_MAX_SIZE_32];
+  uint32_t encoded_size = 0;
+  const uint8_t* cursor = *directory_cursor;
+  for (;;) {
+    if (cursor == directory_begin ||
+        encoded_size == kMaxFragmentSizeVarIntBytes) {
+      return false;
+    }
+    const uint8_t byte = *--cursor;
+    encoded[encoded_size++] = byte;
+    if ((byte & kVarIntContinuationBit) == 0)
+      break;
+  }
+
+  uint64_t value = 0;
+  if (PerfettoPbParseVarInt(encoded, encoded + encoded_size, &value) !=
+          encoded + encoded_size ||
+      value > UINT32_MAX ||
+      FragmentSizeVarIntBytes(static_cast<uint32_t>(value)) != encoded_size) {
+    return false;
+  }
+
+  *directory_cursor = cursor;
+  *fragment_size = static_cast<uint32_t>(value);
+  return true;
+}
+
+}  // namespace perfetto::tracing_v2
+
+#endif  // SRC_TRACING_V2_TRACING_V2_ABI_H_
diff --git a/src/tracing/v2/tracing_v2_abi_unittest.cc b/src/tracing/v2/tracing_v2_abi_unittest.cc
new file mode 100644
index 0000000..e82feb8
--- /dev/null
+++ b/src/tracing/v2/tracing_v2_abi_unittest.cc
@@ -0,0 +1,419 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/tracing/v2/tracing_v2_abi.h"
+
+#include <stdint.h>
+
+#include <vector>
+
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::tracing_v2 {
+namespace {
+
+// Chunk state word
+// ----------------
+
+// Keep exact wire values in the test, rather than obscuring the ABI header with
+// compile-time examples. These literals are intentionally independent of the
+// masks used by the encoding helpers.
+TEST(TracingV2AbiTest, StateWordEncoding) {
+  EXPECT_EQ(MakeFreeStateWord(0), 0x00000000u);
+  EXPECT_EQ(MakeFreeStateWord(5), 0x00050000u);
+  EXPECT_EQ(MakeFreeStateWord(0xffff), 0xffff0000u);
+  EXPECT_EQ(MakeDataStateWord(ChunkState::kBeingWritten,
+                              ChunkFormat::kTargetBuffer, 0, 0, 7),
+            0x00070001u);
+  EXPECT_EQ(MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer,
+                              0, 3, 7),
+            0x00070302u);
+  EXPECT_EQ(MakeDataStateWord(ChunkState::kRewriteRequested,
+                              ChunkFormat::kTargetBuffer, 0, 3, 7),
+            0x00070303u);
+  EXPECT_EQ(MakeDataStateWord(ChunkState::kComplete, ChunkFormat::kTargetBuffer,
+                              kFlagContinuesOnNextChunk, 2, 0x1234),
+            0x12340242u);
+  EXPECT_EQ(kRewriteAcknowledgedStateWord, 0x00000004u);
+}
+
+TEST(TracingV2AbiTest, PayloadFlagEncoding) {
+  EXPECT_EQ(kFlagDataLoss, 0x00000020u);
+  EXPECT_EQ(kFlagContinuesOnNextChunk, 0x00000040u);
+  EXPECT_EQ(kFlagContinuesFromPrevChunk, 0x00000080u);
+}
+
+TEST(TracingV2AbiTest, StateOrdinalsAndTheirDecoding) {
+  EXPECT_EQ(ChunkStateOf(0x00000000u), ChunkState::kFree);
+  EXPECT_EQ(ChunkStateOf(0x00000001u), ChunkState::kBeingWritten);
+  EXPECT_EQ(ChunkStateOf(0x00000002u), ChunkState::kComplete);
+  EXPECT_EQ(ChunkStateOf(0x00000003u), ChunkState::kRewriteRequested);
+  EXPECT_EQ(ChunkStateOf(0x00000004u), ChunkState::kRewriteAcknowledged);
+  EXPECT_EQ(ChunkStateOf(0x00000005u), ChunkState::kReserved5);
+  EXPECT_EQ(ChunkStateOf(0x00000006u), ChunkState::kReserved6);
+  EXPECT_EQ(ChunkStateOf(0x00000007u), ChunkState::kReserved7);
+
+  EXPECT_TRUE(HasDataFields(ChunkState::kBeingWritten));
+  EXPECT_TRUE(HasDataFields(ChunkState::kComplete));
+  EXPECT_TRUE(HasDataFields(ChunkState::kRewriteRequested));
+  EXPECT_FALSE(HasDataFields(ChunkState::kFree));
+  EXPECT_FALSE(HasDataFields(ChunkState::kRewriteAcknowledged));
+  EXPECT_FALSE(HasDataFields(ChunkState::kReserved5));
+  EXPECT_FALSE(HasDataFields(ChunkState::kReserved6));
+  EXPECT_FALSE(HasDataFields(ChunkState::kReserved7));
+}
+
+TEST(TracingV2AbiTest, DataStateFieldsRoundTrip) {
+  const ChunkState kStates[] = {ChunkState::kBeingWritten,
+                                ChunkState::kComplete,
+                                ChunkState::kRewriteRequested};
+  const uint32_t kFlagSets[] = {
+      0,
+      kFlagContinuesFromPrevChunk,
+      kFlagContinuesOnNextChunk,
+      kFlagDataLoss,
+      kFlagContinuesFromPrevChunk | kFlagDataLoss,
+      kFlagContinuesFromPrevChunk | kFlagContinuesOnNextChunk | kFlagDataLoss};
+  const uint32_t kCounts[] = {0, 1, 127, 128, 254, 255};
+  const WriterID kWriters[] = {0, 1, 0x1234, 0x7fff, 0xffff};
+
+  for (ChunkState state : kStates) {
+    for (uint32_t flags : kFlagSets) {
+      for (uint32_t count : kCounts) {
+        for (WriterID writer : kWriters) {
+          const uint32_t word = MakeDataStateWord(
+              state, ChunkFormat::kTargetBuffer, flags, count, writer);
+          EXPECT_EQ(ChunkStateOf(word), state);
+          EXPECT_EQ(ChunkFormatOf(word), ChunkFormat::kTargetBuffer);
+          EXPECT_EQ(PayloadFlagsOf(word), flags);
+          EXPECT_EQ(NumFragmentsOf(word), count);
+          EXPECT_EQ(WriterIdOf(word), writer);
+        }
+      }
+    }
+  }
+}
+
+TEST(TracingV2AbiTest, EveryFormatRoundTrips) {
+  const ChunkFormat kFormats[] = {
+      ChunkFormat::kTargetBuffer, ChunkFormat::kReservedRouting,
+      ChunkFormat::kReserved2, ChunkFormat::kReserved3};
+  for (ChunkFormat format : kFormats) {
+    const uint32_t word = MakeDataStateWord(ChunkState::kComplete, format,
+                                            kFlagDataLoss, 9, 0xabcd);
+    EXPECT_EQ(ChunkFormatOf(word), format);
+    // Whatever the format is, the state and the fields the reader needs to
+    // arbitrate ownership stay where they are. That is what lets an old reader
+    // release a chunk whose layout it has never heard of.
+    EXPECT_EQ(ChunkStateOf(word), ChunkState::kComplete);
+    EXPECT_EQ(NumFragmentsOf(word), 9u);
+    EXPECT_EQ(WriterIdOf(word), 0xabcd);
+  }
+}
+
+TEST(TracingV2AbiTest, ReplaceChunkStateChangesOnlyTheState) {
+  const uint32_t being_written = MakeDataStateWord(
+      ChunkState::kBeingWritten, ChunkFormat::kReservedRouting,
+      kFlagContinuesFromPrevChunk | kFlagDataLoss, 17, 0x0f0f);
+  const uint32_t marked =
+      ReplaceChunkState(being_written, ChunkState::kRewriteRequested);
+
+  EXPECT_EQ(ChunkStateOf(marked), ChunkState::kRewriteRequested);
+  EXPECT_EQ(being_written & ~kChunkStateMask, marked & ~kChunkStateMask);
+  EXPECT_EQ(ChunkFormatOf(marked), ChunkFormat::kReservedRouting);
+  EXPECT_EQ(PayloadFlagsOf(marked),
+            kFlagContinuesFromPrevChunk | kFlagDataLoss);
+  EXPECT_EQ(NumFragmentsOf(marked), 17u);
+  EXPECT_EQ(WriterIdOf(marked), 0x0f0f);
+}
+
+TEST(TracingV2AbiTest, FreeSplitsIntoStateReservedBitsAndA16BitWrap) {
+  EXPECT_EQ(kWrapCountMask, 0xffff0000u);
+  EXPECT_EQ(kFreeReservedBitsMask, 0x0000fff8u);
+
+  EXPECT_EQ(WrapCountOf(MakeFreeStateWord(0)), 0u);
+  EXPECT_EQ(WrapCountOf(MakeFreeStateWord(1)), 1u);
+  EXPECT_EQ(WrapCountOf(MakeFreeStateWord(0xffff)), 0xffffu);
+
+  // MakeFreeStateWord takes a uint16_t, so no caller can produce a word with a
+  // reserved bit set; decoding one still yields only bytes 2-3.
+  EXPECT_EQ(WrapCountOf(0x0005fff8u), 5u);
+  EXPECT_EQ(ChunkStateOf(0x0005fff8u), ChunkState::kFree);
+}
+
+// Packed read/write positions
+// ---------------------------
+
+TEST(TracingV2AbiTest, RwPositionsEncoding) {
+  // write_pos occupies the high half of the numeric value and read_pos the low
+  // half.
+  EXPECT_EQ(PackRwPositions(0, 0), 0u);
+  EXPECT_EQ(PackRwPositions(1, 0), 0x0000000100000000ull);
+  EXPECT_EQ(PackRwPositions(0, 1), 0x0000000000000001ull);
+  EXPECT_EQ(PackRwPositions(0xdeadbeefu, 0x12345678u), 0xdeadbeef12345678ull);
+  EXPECT_EQ(PackRwPositions(0x00000002u, 0xfffffffcu), 0x00000002fffffffcull);
+}
+
+TEST(TracingV2AbiTest, PackedRwPositionsRoundTrips) {
+  const uint32_t kPositions[] = {0u,          1u,          0x7fffffffu,
+                                 0x80000000u, 0xfffffffeu, 0xffffffffu};
+  for (uint32_t write_pos : kPositions) {
+    for (uint32_t read_pos : kPositions) {
+      const uint64_t rw_positions = PackRwPositions(write_pos, read_pos);
+      EXPECT_EQ(WritePosOf(rw_positions), write_pos);
+      EXPECT_EQ(ReadPosOf(rw_positions), read_pos);
+    }
+  }
+}
+
+TEST(TracingV2AbiTest, ReplaceWritePosReplacesOnlyTheWriteHalf) {
+  const uint64_t rw_positions = PackRwPositions(0x11111111u, 0x22222222u);
+  const uint64_t moved = ReplaceWritePos(rw_positions, 0x11111112u);
+  EXPECT_EQ(WritePosOf(moved), 0x11111112u);
+  EXPECT_EQ(ReadPosOf(moved), 0x22222222u);
+  // Including across the halves' own rollovers.
+  EXPECT_EQ(ReplaceWritePos(PackRwPositions(0xffffffffu, 7), 0),
+            PackRwPositions(0u, 7u));
+}
+
+TEST(TracingV2AbiTest, ReplaceReadPosReplacesOnlyTheReadHalf) {
+  const uint64_t rw_positions = PackRwPositions(0x11111111u, 0x22222222u);
+  const uint64_t moved = ReplaceReadPos(rw_positions, 0x22222223u);
+  EXPECT_EQ(WritePosOf(moved), 0x11111111u);
+  EXPECT_EQ(ReadPosOf(moved), 0x22222223u);
+  EXPECT_EQ(ReplaceReadPos(PackRwPositions(7, 0xffffffffu), 0),
+            PackRwPositions(7u, 0u));
+}
+
+// Logical positions
+// -----------------
+
+TEST(TracingV2AbiTest, ChunkIndexAndWrapCountForSeveralRingSizes) {
+  // A worked example.
+  const uint32_t kNumChunks = 4;
+  const uint32_t kChunkIndexBits = GetChunkIndexBits(kNumChunks);
+  const uint32_t kExpectedIndex[] = {0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0};
+  const uint32_t kExpectedWrap[] = {0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3};
+  for (uint32_t p = 0; p < 13; ++p) {
+    EXPECT_EQ(ChunkIndexOfPosition(p, kNumChunks), kExpectedIndex[p]) << p;
+    EXPECT_EQ(WrapCountForPosition(p, kChunkIndexBits), kExpectedWrap[p]) << p;
+  }
+
+  // Including the one-chunk ring, where every position maps to chunk 0 and the
+  // wrap count is the position itself, truncated.
+  for (uint32_t num_chunks : {1u, 2u, 4u, 8u, 1024u}) {
+    const uint32_t chunk_index_bits = GetChunkIndexBits(num_chunks);
+    for (uint32_t p = 0; p < 3 * num_chunks + 3; ++p) {
+      EXPECT_EQ(ChunkIndexOfPosition(p, num_chunks), p % num_chunks);
+      EXPECT_EQ(WrapCountForPosition(p, chunk_index_bits),
+                (p / num_chunks) & 0xffffu);
+    }
+  }
+}
+
+TEST(TracingV2AbiTest, OutstandingPositionsAreExactAcrossRollover) {
+  // A worked example.
+  EXPECT_EQ(NumOutstandingPositions(0x00000002u, 0xfffffffcu), 6u);
+  EXPECT_EQ(NumOutstandingPositions(0u, 0u), 0u);
+  EXPECT_EQ(NumOutstandingPositions(0u, 0xffffffffu), 1u);
+  EXPECT_EQ(NumOutstandingPositions(0xffffffffu, 0xfffffffeu), 1u);
+
+  const uint32_t kNumChunks = 8;
+  const uint32_t kExpected[] = {4, 5, 6, 7, 0, 1};
+  uint32_t p = 0xfffffffcu;
+  for (uint32_t i = 0; i < 6; ++i, ++p)
+    EXPECT_EQ(ChunkIndexOfPosition(p, kNumChunks), kExpected[i]) << i;
+}
+
+TEST(TracingV2AbiTest, NextWrapComesFromTheNextPositionNotFromTheChunk) {
+  // Away from the rollovers next_wrap is simply "one more".
+  const uint32_t kNumChunks = 4;
+  const uint32_t kChunkIndexBits = GetChunkIndexBits(kNumChunks);
+  for (uint32_t p = 0; p < 16; ++p) {
+    EXPECT_EQ(WrapCountForPosition(p + kNumChunks, kChunkIndexBits),
+              WrapCountForPosition(p, kChunkIndexBits) + 1)
+        << p;
+  }
+
+  // At the 16-bit truncation boundary it is not: the wrap after 0xffff is
+  // zero. A reader that incremented the value it found in the chunk would
+  // agree here by accident of the uint16_t, so the position rollover below is
+  // the discriminating case.
+  const uint32_t kLastLapOfPeriod = 0xffffu * kNumChunks;  // wrap 0xffff
+  EXPECT_EQ(WrapCountForPosition(kLastLapOfPeriod, kChunkIndexBits), 0xffffu);
+  EXPECT_EQ(
+      WrapCountForPosition(kLastLapOfPeriod + kNumChunks, kChunkIndexBits), 0u);
+
+  // At the uint32_t position rollover the shifted position restarts from zero
+  // mid-way through the 16-bit range whenever num_chunks > 65536, which is why
+  // the protocol derives the wrap from the position and never increments the
+  // value it finds in the chunk.
+  const uint32_t kBigRing = 1u << 20;
+  const uint32_t kBigChunkIndexBits = GetChunkIndexBits(kBigRing);
+  const uint32_t kLastPosition = 0u - kBigRing;  // the last lap's chunk 0
+  EXPECT_EQ(ChunkIndexOfPosition(kLastPosition, kBigRing), 0u);
+  EXPECT_EQ(WrapCountForPosition(kLastPosition, kBigChunkIndexBits), 0xfffu);
+  EXPECT_EQ(WrapCountForPosition(kLastPosition + kBigRing, kBigChunkIndexBits),
+            0u);
+  EXPECT_NE(WrapCountForPosition(kLastPosition + kBigRing, kBigChunkIndexBits),
+            WrapCountForPosition(kLastPosition, kBigChunkIndexBits) + 1u);
+}
+
+// Pin the finite period of the 16-bit Free identity.
+TEST(TracingV2AbiTest, WrapIdentityRepeatsAfterTheDocumentedPeriod) {
+  // For num_chunks up to 65536 the identity period is num_chunks * 65536
+  // reservations: the same chunk carries the same wrap count again once the
+  // shifted position has run through the whole uint16_t.
+  for (uint32_t num_chunks : {1u, 2u, 16u}) {
+    const uint32_t chunk_index_bits = GetChunkIndexBits(num_chunks);
+    const uint32_t period = num_chunks * 65536;
+    EXPECT_EQ(WrapCountForPosition(0, chunk_index_bits),
+              WrapCountForPosition(period, chunk_index_bits));
+    EXPECT_EQ(ChunkIndexOfPosition(0, num_chunks),
+              ChunkIndexOfPosition(period, num_chunks));
+    // No earlier lap of the same chunk aliases position 0.
+    for (uint32_t lap = 1; lap < 8; ++lap) {
+      const uint32_t p = lap * num_chunks;
+      EXPECT_NE(WrapCountForPosition(p, chunk_index_bits),
+                WrapCountForPosition(0, chunk_index_bits));
+    }
+  }
+
+  // From 65537 chunks up the truncation throws nothing away - the shifted
+  // position already fits in 16 bits - so the identity repeats only when the
+  // 32-bit position itself wraps: the period is min(num_chunks * 65536, 2^32).
+  const uint32_t kBigRing = 1u << 20;
+  const uint32_t kBigChunkIndexBits = GetChunkIndexBits(kBigRing);
+  EXPECT_EQ(WrapCountForPosition(UINT32_MAX, kBigChunkIndexBits), 0xfffu);
+  for (uint32_t lap = 1; lap < 8; ++lap) {
+    EXPECT_NE(WrapCountForPosition(lap * kBigRing, kBigChunkIndexBits),
+              WrapCountForPosition(0, kBigChunkIndexBits));
+  }
+}
+
+// Target-buffer chunk format
+// --------------------------
+
+TEST(TracingV2AbiTest, FragmentSizeVarIntBytesAtBoundaries) {
+  EXPECT_EQ(FragmentSizeVarIntBytes(0), 1u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(127), 1u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(128), 2u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(16383), 2u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(16384), 3u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(0x1fffff), 3u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(0x200000), 4u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(0x0fffffff), 4u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(0x10000000), 5u);
+  EXPECT_EQ(FragmentSizeVarIntBytes(UINT32_MAX), 5u);
+}
+
+TEST(TracingV2AbiTest, MaxFragmentSizeLeavesRoomForItsVarInt) {
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(0), 0u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(1), 0u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(2), 1u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(128), 127u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(129), 127u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(130), 128u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(250), 248u);
+  EXPECT_EQ(MaxFragmentSizeForAvailableBytes(UINT32_MAX), UINT32_MAX - 5u);
+}
+
+TEST(TracingV2AbiTest, MaxFragmentSizeForEmptyChunk) {
+  EXPECT_EQ(MaxFragmentSizeForEmptyChunk(kMinChunkSize - 1), 0u);
+  EXPECT_EQ(MaxFragmentSizeForEmptyChunk(256), 248u);
+  EXPECT_EQ(MaxFragmentSizeForEmptyChunk(260), 252u);  // Non-power-of-two.
+  EXPECT_EQ(MaxFragmentSizeForEmptyChunk(65536), 65527u);
+  EXPECT_EQ(MaxFragmentSizeForEmptyChunk(128u * 1024), 128u * 1024 - 9u);
+}
+
+TEST(TracingV2AbiTest, VarIntDirectoryGrowsDownFromTheEndOfTheChunk) {
+  std::vector<uint8_t> chunk(256, 0);
+  uint8_t* directory_begin = chunk.data() + chunk.size();
+  for (uint32_t size : {5u, 200u, 3u})
+    directory_begin = WriteFragmentSize(directory_begin, size);
+
+  // Fragment 0 is nearest the end. Reading towards lower addresses yields the
+  // normal varint byte sequence c8 01 for 200.
+  EXPECT_EQ(directory_begin, chunk.data() + 252);
+  EXPECT_EQ(chunk[252], 0x03u);
+  EXPECT_EQ(chunk[253], 0x01u);
+  EXPECT_EQ(chunk[254], 0xc8u);
+  EXPECT_EQ(chunk[255], 0x05u);
+
+  const uint8_t* cursor = chunk.data() + chunk.size();
+  for (uint32_t expected : {5u, 200u, 3u}) {
+    uint32_t actual = 0;
+    ASSERT_TRUE(ReadFragmentSize(directory_begin, &cursor, &actual));
+    EXPECT_EQ(actual, expected);
+  }
+  EXPECT_EQ(cursor, directory_begin);
+}
+
+TEST(TracingV2AbiTest, FragmentSizeVarIntsRoundTrip) {
+  const uint32_t kSizes[] = {0,          1,          127,       128,
+                             16383,      16384,      0x1fffff,  0x200000,
+                             0x0fffffff, 0x10000000, UINT32_MAX};
+  std::vector<uint8_t> directory(64, 0xee);
+  uint8_t* directory_begin = directory.data() + directory.size();
+  for (uint32_t size : kSizes)
+    directory_begin = WriteFragmentSize(directory_begin, size);
+
+  const uint8_t* cursor = directory.data() + directory.size();
+  for (uint32_t expected : kSizes) {
+    uint32_t actual = 0;
+    ASSERT_TRUE(ReadFragmentSize(directory_begin, &cursor, &actual));
+    EXPECT_EQ(actual, expected);
+  }
+  EXPECT_EQ(cursor, directory_begin);
+}
+
+TEST(TracingV2AbiTest, RejectsMalformedOrRedundantFragmentSizeVarInts) {
+  const uint8_t kUnterminated[] = {0x80};
+  const uint8_t kTooLong[] = {0x00, 0x80, 0x80, 0x80, 0x80, 0x80};
+  const uint8_t kUint32Overflow[] = {0x10, 0xff, 0xff, 0xff, 0xff};
+  const uint8_t kNonCanonical[] = {0x00, 0x80};
+
+  auto expect_rejected = [](const uint8_t* begin, size_t size) {
+    const uint8_t* cursor = begin + size;
+    uint32_t fragment_size = 0;
+    EXPECT_FALSE(ReadFragmentSize(begin, &cursor, &fragment_size));
+    EXPECT_EQ(cursor, begin + size);
+  };
+  expect_rejected(kUnterminated, sizeof(kUnterminated));
+  expect_rejected(kTooLong, sizeof(kTooLong));
+  expect_rejected(kUint32Overflow, sizeof(kUint32Overflow));
+  expect_rejected(kNonCanonical, sizeof(kNonCanonical));
+}
+
+TEST(TracingV2AbiTest, TargetBufferIdIsLittleEndianAtBytesFourAndFive) {
+  std::vector<uint8_t> chunk(256, 0);
+  StoreTargetBufferId(chunk.data(), 0x1234);
+  EXPECT_EQ(chunk[4], 0x34u);
+  EXPECT_EQ(chunk[5], 0x12u);
+  EXPECT_EQ(LoadTargetBufferId(chunk.data()), 0x1234);
+  // The state word is not disturbed and the payload area still starts at 6.
+  EXPECT_EQ(chunk[0], 0u);
+  EXPECT_EQ(chunk[3], 0u);
+  EXPECT_EQ(chunk[kTargetBufferPayloadOffset], 0u);
+
+  StoreTargetBufferId(chunk.data(), 0xffff);
+  EXPECT_EQ(LoadTargetBufferId(chunk.data()), 0xffff);
+  StoreTargetBufferId(chunk.data(), 0);
+  EXPECT_EQ(LoadTargetBufferId(chunk.data()), 0);
+}
+
+}  // namespace
+}  // namespace perfetto::tracing_v2