DEV Community

Morgan Ma
Morgan Ma

Posted on

The Ring Buffer That Passed Until the Index Wrapped

The unit tests stayed green while the buffer was already wrong. I trusted a short happy-path loop and never hit wraparound. Did your last AI queue test actually fill the ring twice?

The conclusion I needed first

A green test suite is not a wraparound proof. Integer width is part of the public queue API. If capacity does not fit in the counter type, the queue will lie.

I learned that on a soak run, not from the compiler. The durable fix was a wider count plus a wrap test.

Symptom, not vibes

I wanted a tiny bounded queue in modern C++. I asked a model for push, pop, size, and a fixed array. The first snippet compiled on the first try.

That easy first compile should have been a warning. I wrote three tests with thirty-two bytes only. Every assertion passed on my laptop without drama.

Then a longer job printed a checksum mismatch I could not ignore. Was the server weird, or did tests never finish a lap?

Why the clean box helped

My laptop sessions stop early and hide wraparound bugs. You rarely sit long enough to reach index two hundred fifty-six. You also miss a uint8_t counter during a short demo.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I used MonkeyCode free model access to draft the first queue. I used the free server option to rerun a longer soak. The box was not my laptop, which was the point.

I write this as product outreach, not as a benchmark lab. I will not invent a token cap, a CPU SKU, or a timing chart.

Remove those product names and the method still holds. You still need wraparound tests and you still need sanitizers.

Reconstructed lab case

This listing is a reconstructed lab case, not secret telemetry. Compile it yourself and watch the counter lie in public.

// ring_bug.cpp
#include <cstdint>
#include <cstddef>
#include <iostream>
#include <vector>

struct Ring {
    static constexpr std::size_t kCap = 256;
    std::uint8_t buf[kCap]{};
    std::uint8_t head = 0;
    std::uint8_t tail = 0;
    std::uint8_t count = 0;  // cannot represent kCap

    bool push(std::uint8_t v) {
        if (count == kCap) {  // never true: count max is 255
            return false;
        }
        buf[head] = v;
        head = static_cast<std::uint8_t>(head + 1);
        count = static_cast<std::uint8_t>(count + 1);
        return true;
    }

    bool pop(std::uint8_t& out) {
        if (count == 0) {
            return false;
        }
        out = buf[tail];
        tail = static_cast<std::uint8_t>(tail + 1);
        count = static_cast<std::uint8_t>(count - 1);
        return true;
    }

    std::size_t size() const { return count; }
};

static std::uint32_t checksum(const std::vector<std::uint8_t>& xs) {
    std::uint32_t s = 0;
    for (auto x : xs) {
        s = s * 16777619u ^ x;
    }
    return s;
}

int main() {
    Ring r;
    std::vector<std::uint8_t> sent;
    std::vector<std::uint8_t> got;
    std::uint8_t x = 0;

    for (int i = 0; i < 32; ++i) {
        auto v = static_cast<std::uint8_t>(i * 3);
        if (!r.push(v)) return 2;
        sent.push_back(v);
    }
    while (r.pop(x)) got.push_back(x);

    std::cout << "short checksum sent=" << checksum(sent)
              << " got=" << checksum(got) << "\n";

    Ring r2;
    sent.clear();
    got.clear();
    for (int i = 0; i < 300; ++i) {
        auto v = static_cast<std::uint8_t>(i);
        if (!r2.push(v)) break;
        sent.push_back(v);
    }
    while (r2.pop(x)) got.push_back(x);

    std::cout << "soak sent_n=" << sent.size()
              << " got_n=" << got.size()
              << " sent=" << checksum(sent)
              << " got=" << checksum(got) << "\n";
    return checksum(sent) == checksum(got) ? 0 : 1;
}
Enter fullscreen mode Exit fullscreen mode

Numbered path I actually followed

1. Freeze the command

I compiled with sanitizers before I blamed the host. I wanted one command that anyone could paste.

c++ -std=c++17 -O1 -g -fsanitize=address,undefined -o ring_bug ring_bug.cpp
./ring_bug; echo exit:$?
Enter fullscreen mode Exit fullscreen mode

The short path printed matching checksums on both builds. The soak path returned exit code one with a quiet AddressSanitizer. Why would a sanitizer miss a pure logic lie like this?

2. Print the counter type

I added a debug line for sizeof(count) versus kCap. Eight bits cannot hold two hundred fifty-six live items. The full check used count == kCap and never fired.

Do you still store sizes in small integers because a model did?

3. Watch integer promotion

I dumped count after each push in a debug build. It climbed to two hundred fifty-five, then the next push succeeded. Then count became zero and pop stopped early.

The array still held old bytes after that wrap. New pushes overwrote them and the checksum drifted. That failure is a type, not a race.

4. Prove it with a wrap test

I stopped arguing with the soak log and wrote one brutal test. It pushes kCap items, rejects one more, then pops everything.

// ring_wrap_assert.cpp fragment
#include <cstddef>
#include <cstdint>
#include <cstdlib>

void require_full_and_reject() {
    Ring r;
    for (std::size_t i = 0; i < Ring::kCap; ++i) {
        if (!r.push(static_cast<std::uint8_t>(i))) std::abort();
    }
    if (r.push(1)) std::abort();  // must reject when full
    if (r.size() != Ring::kCap) std::abort();
}
Enter fullscreen mode Exit fullscreen mode

The buggy ring aborts, and the failure is finally local. Would you ship without that reject assertion in CI?

5. Fix the width, not the story

I changed count to std::size_t and kept the same soak. I wrap head and tail with % kCap after the full check. I still do not treat this as a lock-free design.

struct RingFixed {
    static constexpr std::size_t kCap = 256;
    std::uint8_t buf[kCap]{};
    std::size_t head = 0;
    std::size_t tail = 0;
    std::size_t count = 0;

    bool push(std::uint8_t v) {
        if (count == kCap) return false;
        buf[head] = v;
        head = (head + 1) % kCap;
        ++count;
        return true;
    }

    bool pop(std::uint8_t& out) {
        if (count == 0) return false;
        out = buf[tail];
        tail = (tail + 1) % kCap;
        --count;
        return true;
    }

    std::size_t size() const { return count; }
};
Enter fullscreen mode Exit fullscreen mode

The same soak then matched checksums and returned zero. The wraparound test is the real artifact here. A model can still emit uint8_t on the next prompt.

Decision table I now keep

I keep this table next to the queue tests. It stops me from blaming the compiler first.

Signal Likely cause Next probe
Short tests pass, long soak checksum fails Counter cannot represent capacity Print sizeof(count) versus kCap
push never returns false Full check uses a narrowed type Force kCap+1 pushes in one unit test
pop stops early, array still dirty Count wrapped to zero Dump count after kCap pushes
ASan silent, result still wrong Logic bug, not a wild store Property test, not more -O3
Fails only on another host You never ran the long path locally Soak script on a clean box

Commands I reuse

These commands are the whole lab on any Unix box.

c++ -std=c++17 -O0 -g -fsanitize=address,undefined -o ring_bug ring_bug.cpp
c++ -std=c++17 -O2 -g -fsanitize=undefined -o ring_o2 ring_bug.cpp
./ring_bug; echo bug:$?
./ring_o2; echo o2:$?
Enter fullscreen mode Exit fullscreen mode

I run -O0 and -O2 on the same source file. Width bugs are not optimizer ghosts in this case. If both fail the same way, I stop blaming the compiler.

A mixed push and pop I also keep

A full-then-reject test still misses interleaved wrap. I want random-looking mixes that lap the ring twice.

// Proposed lab helper, not a measured production trace.
void mix_push_pop(RingFixed& q) {
    std::uint8_t out = 0;
    int live = 0;
    for (int i = 0; i < 1024; ++i) {
        if ((i & 1) == 0 || live == 0) {
            if (q.push(static_cast<std::uint8_t>(i))) ++live;
        } else {
            if (q.pop(out)) --live;
        }
    }
    while (q.pop(out)) --live;
    if (live != 0) std::abort();
}
Enter fullscreen mode Exit fullscreen mode

Does your suite ever empty the queue after two full laps? If not, the model still owns your edge cases.

What this is not

This is not a lock-free MPMC queue. This is not a latency study or a model bake-off. This is not a substitute for a fuzzer on a public protocol.

Do not use this ring inside a signal handler. Do not share it across threads without a separate locking design. Do not paste a narrowed counter into a packet path.

Who should skip this workflow without any regret? Anyone who needs a certified realtime queue should skip it. Anyone who cannot run sanitizers should skip it too.

Anyone shipping a public binary protocol still needs a fuzzer. A soak on a free server does not replace review.

Limits of the free path

Free model drafts will guess compact field types. Compact is not correct when capacity exceeds the type. A clean server helps because it is not your laptop session.

It still will not invent wraparound tests for you. I do not know your quota and I will not fake one. I will not pretend hardware details I was not given.

The reusable move is boring and that is the point. Force kCap+1, check reject, pop all, compare a checksum, then repeat.

Close

AI made the first draft cheap and left debt in one byte. Would you have noticed without a wrap test in CI?

MonkeyCode's free server option is one place I ran that soak. Keep the wraparound test in the repo either way.

Top comments (0)