A string_view can outlive every byte it names. That failure compiles, links, and passes shallow tests. This walkthrough is a reconstructed lab case. The map stayed honest. The key was already dead.
Do not store a view into a reusable buffer. Copy the key first, or freeze the buffer. Sanitizers will not always save you here. Why? The storage remains allocated. Only the characters change underneath.
The symptom that looked like a race
The handler returned flags for the previous user. It happened after bursts of short names. Edge logs showed the right user string. The cache still answered for someone else.
Was it a race? That was my first story. ThreadSanitizer printed nothing useful at all. AddressSanitizer printed nothing useful either. Have you trusted a quiet sanitizer too early?
I printed the key pointer beside the key text. The pointer stayed stable across later requests. The text did not stay stable. That is aliasing, not a data race.
False lead one: the hash map
I blamed std::unordered_map for mixing tenants. I swapped in an ordered map. I dumped buckets and load factor. The structure was fine. The lookup key was a ghost of the last write.
False lead two: small-string optimization
Short keys failed more often than long keys. That smelled like small-string optimization. It was a hint, not the root. Short keys reused capacity without moving. The view address never jumped. Long keys sometimes reallocated and hid the bug.
Numbered walkthrough
Follow this sequence on any similar flake.
- Capture one failing input pair, then replay it in-process.
- Log key
.data(),.size(), and a copied character snapshot. - Run under ASan and TSan anyway. Record that they were quiet.
- Disable threads. If the flake remains, drop the race theory.
- Find every
clear,resize, and assign on the scratch buffer. - Insert a poison fill after each reset. Watch the held key change.
- Replace the stored view with an owning
std::string. Retest the matrix.
Step six is the cheap microscope. You do not need a new vendor tool. You need to mutate the bytes on purpose. Does the stored key change when you poison? Then you never owned it.
Reconstructed repro
The program below is a lab sample. Treat it as a reduced case. It is not a dump of production source.
#include <algorithm>
#include <cassert>
#include <iostream>
#include <string>
#include <string_view>
#include <unordered_map>
class ScratchKeyBuilder {
public:
std::string_view make_key(std::string const& user, int id) {
buf_.clear();
buf_.append(user);
buf_.push_back(':');
buf_.append(std::to_string(id));
return buf_; // view into member storage
}
void recycle() {
// Next request reuses the same bytes.
std::fill(buf_.begin(), buf_.end(), '#');
buf_.clear();
}
private:
std::string buf_;
};
int lookup_or_store(std::unordered_map<std::string, int>& cache,
std::string_view key,
int value) {
auto it = cache.find(std::string{key});
if (it != cache.end()) {
return it->second;
}
cache.emplace(std::string{key}, value);
return value;
}
int main() {
ScratchKeyBuilder scratch;
std::unordered_map<std::string, int> cache;
std::string_view k1 = scratch.make_key("ada", 1);
int v1 = lookup_or_store(cache, k1, 100);
scratch.recycle();
std::string_view k2 = scratch.make_key("bob", 2);
int v2 = lookup_or_store(cache, k2, 200);
std::string_view stale = scratch.make_key("ada", 1);
scratch.recycle();
std::string ghost{stale}; // may be empty, "###", or worse
std::cout << "v1=" << v1 << " v2=" << v2
<< " ghost='" << ghost << "'\n";
}
Build the lab case with a pedantic sanitizer command.
clang++ -std=c++20 -O0 -g -Wall -Wextra \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
stale_view.cpp -o stale_view
./stale_view
Keep those flags. They are still required. They will not always fire. That silence is the trap.
Why the sanitizers stayed quiet
AddressSanitizer hunts use-after-free and out-of-bounds access. This buffer was still alive. The std::string object still owned a valid region. Recycle overwrote that region in place. That is not a free.
ThreadSanitizer hunts concurrent conflicting memory accesses. My flake survived a single thread. No race existed in the schedule. The view was simply stale after clear.
Would a fuzzer have caught it on day one? Maybe, if the corpus reused the builder. A one-shot unit test would miss it completely. That is why the buffer must be recycled inside the test. Happy-path lookup is not an ownership proof.
Root cause
The helper returned std::string_view into member storage. A later request called clear and append on that storage. Callers still held the old view. Some callers copied late, after recycle. Those copies contained the next user, or poison, or nothing.
The zero-copy shape came from a generated patch. It compiled without a single new warning. It looked modern and allocation-free. It deleted a copy I could see. It introduced an alias I could not see. Sound familiar this month?
A green compile is not an ownership proof. Lifetime lives in the next call. Lifetime also lives in comments nobody asked the model to write. AI assistance did not remove that check. It made a missing check easier to ship.
The fix
Own the key before the buffer can move again.
std::string make_key(std::string const& user, int id) {
std::string out;
out.reserve(user.size() + 16);
out.append(user);
out.push_back(':');
out.append(std::to_string(id));
return out;
}
If the hot path cannot allocate, freeze the buffer. Do not recycle until every view is gone. A generation counter also works as a tripwire. Stamp the view with a generation. Reject a mismatch on use.
struct GenView {
std::string_view bytes;
std::uint64_t generation;
};
bool valid(GenView const& v, std::uint64_t current) {
return v.generation == current;
}
I prefer the owning string unless a profiler says otherwise. Have you measured that copy on the real path? If not, keep the copy. Fancy views are cheaper only when they stay valid.
Decision table
Use this table before you return a view from a helper.
| Handle | Safe to store? | Recycle while live? | Use when |
|---|---|---|---|
Owning std::string
|
Yes | Yes | Default cache keys |
string_view into a member buffer |
No | No | Never across requests |
string_view into a frozen arena |
Yes | Not until freeze lifts | Documented window only |
| Interned pointer | Yes if intern outlives cache | Intern must not evict | Extra machinery |
If your case is not in the yes column, copy. Do not negotiate with the buffer. The table is the whole API review.
Test plan you can paste
Do not trust a single happy-path test. Run this matrix on every scratch builder.
- Two distinct users, same builder instance, sequential requests.
- Same user twice, with a recycle between the lookups.
- A short key, then a longer key that forces reallocation.
- A longer key, then a short key that reuses capacity.
- Poison fill after
clear, then read any held view. - The same sequence under ASan, TSan, and a non-sanitized
-O2build.
Pass means no cross-user hits. Pass also means poison never appears in stored keys. Fail means you still hold a view across recycle. Put step five in CI. A human will skip it later.
Where free model access actually helps
I asked for extra negative tests after I knew the bug class. I did not ask a model to rewrite the cache first. That order matters. The first patch already looked elegant.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Those two facts are the only product claims I am using here. I am not attaching model names, quotas, or timings. Those numbers go stale fast. Your own dashboard is the source of record.
Proposed workflow, labeled as a proposal, not a measured run:
- Paste the reduced repro and the failing matrix.
- Ask for more negative tests, not a new implementation.
- Keep only tests that recycle storage or poison bytes.
- Compile those tests on the free server with sanitizer flags.
- Reject any patch that returns a view into member buffers.
The model is a generator. The sanitizer build is the judge. The recycle test is the oracle. Remove any one of those three and you are guessing again. If you try that matrix on a free server, keep the sanitizer flags.
Limitations
This approach will not catch every lifetime bug. It will not replace owning keys in a public cache API. It will not help if you cannot run a sanitizer build. Quiet ASan is not a proof of ownership. Quiet TSan is not a proof either.
Who should skip this pattern? Anyone shipping a view-based protocol without a freeze window. Anyone who cannot add a recycle test to CI. Anyone hoping a generated zero-copy helper is finished work. Anyone who cannot read the next caller of a returned view.
Do not use a reusable scratch buffer for keys you store. Do not keep string_view in containers that outlive the request. Do not treat a compiled helper as a lifetime review. The compiler will not object. Your next tenant might.
After the fix
I grep for string_view returns from member functions. I require a comment if the view is non-owning. I add one recycle test beside every scratch builder. That is cheap insurance. Another silent cache mix-up is not cheap.
If you run the repro, watch the ghost string first. Then make the key own its bytes. That is the whole lesson, and it still holds when the assistant writes the helper.
Top comments (0)