The intern table did not race at all. It returned views into moving hash keys.
I reproduced this C++ failure as a lab. A coding assistant wrote a string intern table. It stored each token inside std::unordered_map keys. It handed back std::string_view into those keys. Did that API look cheap and clean to you?
It did. It was also quietly wrong.
The conclusion I needed first
Your intern API is lying about object lifetime. A view is not an owned interned string. Tests with twenty tokens will not rehash. A longer word list will force a rehash. Then every stored view points at moved memory.
I keep saying this to myself now. Never return a view into a hash key. Never trust a green test that never grew.
Symptom: garbage, then a sanitizer trap
I ran a tokenizer against a short fixture first. The output matched my golden file exactly. I then fed it several thousand unique lines. Tokens turned into garbage after a burst of inserts. A later run died hard under AddressSanitizer. Why did the short fixture stay perfectly green?
The map never rehashed on that tiny path.
I still suspected a data race at first. I was wrong about that suspicion too. I pinned the process to one thread. I froze the generator seed for repeats. The garbage tokens still remained after that. So I stopped chasing races as the story. I started chasing moves and object lifetimes instead.
Does your test suite ever insert past the bucket count? Mine never did on the green path.
This is a constructed lab, not an outage report
This walkthrough is a constructed lab case. I am not citing a private customer outage. I wanted a small artifact you can compile today.
I asked a free coding model for a first intern table. I then ran the binary on a free server with sanitizers.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode shows up here only as that scratch box. It is an open project with free model access and a free server option. Free model access drafted the first intern table. The free server option ran the AddressSanitizer build. I do not invent quotas, model names, or hardware details. Check current availability on the project page yourself. I also refuse to ship secrets onto a shared machine.
Reader value stays if you delete those sentences. The bug does not care which vendor you used.
The buggy intern table
Here is the shape the model gave me. I trimmed comments and kept the lifetime bug.
// intern_bad.hpp
#pragma once
#include <string>
#include <string_view>
#include <unordered_map>
class InternTable {
public:
std::string_view intern(std::string_view s) {
auto [it, inserted] = pool_.emplace(std::string(s), 1);
(void)inserted;
// DANGER: view into a hash key that can move
return std::string_view(it->first);
}
std::size_t size() const { return pool_.size(); }
private:
std::unordered_map<std::string, int> pool_;
};
Look at that return. It aliases it->first directly. What happens on the next rehash then?
The std::string objects move during bucket growth. Small-string buffers move along with them. Your old view now points at storage inside a moved-from string. Sometimes you read leftover bytes from the heap. Sometimes you crash in a compare. Sometimes you get lucky and ship.
Lucky is the worst outcome here. Tests love lucky more than truth.
Driver that hides, then a driver that bites
The short test looks responsible on a first read. It is not a lifetime test.
// test_small.cpp
#include "intern_bad.hpp"
#include <cassert>
#include <string>
#include <vector>
int main() {
InternTable t;
std::vector<std::string_view> held;
for (int i = 0; i < 8; ++i) {
held.push_back(t.intern("tok" + std::to_string(i)));
}
assert(held[0] == "tok0");
assert(t.size() == 8);
}
Eight inserts often stay inside the first buckets. There is no rehash and no dangle. The bar stays green. Should I really ship it like that?
No. Here is the driver that should have existed first.
// intern_repro.cpp
#include "intern_bad.hpp"
#include <iostream>
#include <string>
#include <vector>
int main() {
InternTable t;
std::vector<std::string_view> held;
held.reserve(10000);
for (int i = 0; i < 10000; ++i) {
held.push_back(t.intern("token-" + std::to_string(i)));
}
// Touch views AFTER many inserts.
long checksum = 0;
for (auto v : held) {
checksum += static_cast<long>(v.size());
if (!v.empty()) checksum += v.front();
}
std::cout << checksum << "\n";
}
That second loop is the real contract test. It uses old views after later inserts. That is the lifetime promise your intern API claimed.
Numbered debugging path I actually used
Follow this sequence and keep the sanitizer build. Skipping ASAN is how I almost blamed threads.
Freeze the input and the thread count. I wrote the unique-token loop above. I ran it single-threaded on purpose. The noise remained without extra threads. So threads were a distraction, not a cause.
Record the last passing insert size. I bisected the insert count by powers of two. The failure jumped near a bucket growth boundary. That pattern is a rehash fingerprint. Do you see why that jump matters?
Rebuild with sanitizers and frame pointers. I used this command on a clean shell.
c++ -std=c++17 -O1 -g \
-fsanitize=address,undefined \
-fno-omit-frame-pointer \
intern_repro.cpp -o intern_repro
./intern_repro
Read the first ASAN frame, not the last. AddressSanitizer blamed a heap-use-after-free inside string compare. The allocating frame was
unordered_maprehash. The use frame was my checksum loop. That pairing is the whole story.Print
bucket_count()around the crash window. I logged size and buckets every 64 inserts. The crash followed a bucket jump every time. Views created before the jump were already poison.Stop blaming the tokenizer for bad text. The tokenizer only held the views it was given. The intern table broke the lifetime contract. Root cause lives at the API boundary, not the printer.
Write a regression that must rehash. I refuse tests that insert fewer keys than two bucket doublings. Tiny fixtures lied to me for an entire afternoon.
Root cause in plain facts
std::unordered_map may reallocate its bucket array on insert. It then moves each std::string key into new nodes. Pointers and references to keys do not survive that rehash. std::string_view is just a pointer and a length. The intern function returned that pointer to callers. Later inserts invalidated it without a compile error. My small tests never reached the rehash threshold. That is the whole bug in this lab.
SSO makes this nastier than a textbook note. A short token lives inside the string object. Move the object and the buffer address changes. A long token lives on the heap instead. A move may keep that heap pointer. So long tokens can appear stable under luck. Short tokens blow up first in the checksum loop. Did your fixture use short names like tok0?
Mine did. Then I "fixed" the fixture with longer names. The crash hid behind heap buffers. That is not a fix at all. That is another green lie.
The fix I will actually keep
I want stable storage for the characters. Node-based sets keep element references valid on insert. std::set<std::string> does that for this API. I still return a view to callers. The view now aliases a node that will not move on insert.
// intern_good.hpp
#pragma once
#include <set>
#include <string>
#include <string_view>
class InternTable {
public:
std::string_view intern(std::string_view s) {
auto [it, inserted] = pool_.emplace(s);
(void)inserted;
return std::string_view(*it);
}
std::size_t size() const { return pool_.size(); }
private:
std::set<std::string> pool_;
};
Is std::set the fastest intern table available? No. It is the honest one for a view-returning API. If I need speed later, I will use std::deque<std::string> plus a lookup map. push_back on deque keeps references to existing strings. I will not return views into unordered_map keys again.
Alternative if you can change the API: return std::string by value. Or return an intern id as an integer. Both remove the hidden lifetime from callers. I prefer an id for large tokenizers. That redesign is a different article.
Regression test I now require
// test_rehash.cpp
#include "intern_good.hpp"
#include <cassert>
#include <string>
#include <vector>
int main() {
InternTable t;
std::vector<std::string_view> held;
held.reserve(5000);
for (int i = 0; i < 5000; ++i) {
auto v = t.intern("k-" + std::to_string(i));
held.push_back(v);
}
for (int i = 0; i < 5000; ++i) {
assert(held[i] == "k-" + std::to_string(i));
}
}
Compile that file with ASAN every time. If it passes, the views still match after growth. If it fails, I still have a lifetime bug. I also run the same binary under UBSan. One sanitizer is not a full proof.
Decision table for intern storage
| Storage | View after many inserts? | Notes |
|---|---|---|
unordered_map<string, ...> |
Invalid after rehash | This was the bug |
unordered_set<string> |
Invalid after rehash | Same move rule |
set<string> |
Valid until erase | Node-based, slower |
deque<string> plus index |
Valid on push_back
|
Do not erase from middle casually |
Return string by value |
Safe | Copies; simplest API |
| Integer intern id | Safe | Extra table lookup |
I print this table near the intern header now. Future me will ignore comments in a hurry. Future me might still read a table.
Limitations of this workflow
AddressSanitizer is not a complete proof of safety. It needs the dangling read to actually happen. Optimizer settings change stack and heap reuse. -O0 and -O2 can disagree on crash shape. I still run UBSan beside ASAN on the growth test. I still force a large insert count on purpose.
A free server is not your compliance boundary. Shared machines can see your source tree. Do not upload keys, tokens, or customer dumps. Do not treat a scratch box as production CI. I use it to confirm a sanitizer build I can also run locally.
This lab ignores threads on purpose. A correct intern table still needs a mutex or a concurrent map. Views also must not outlive the table object itself. I did not solve those problems here.
SSO, allocator replacement, and debug iterators can hide or reveal the bug. Reproduce with libstdc++ and libc++ if you can. I did not record timings for set versus unordered_map. I do not have a benchmark to sell you.
Who should not follow this approach
Skip the view-returning intern table if you can. Return ids instead of views. Skip a free shared server if your source cannot leave your laptop. Skip my std::set fix if you already measured a hot intern path and have a reviewer. Skip sanitizer-only confidence if your process never touches the old views. The contract still matters after the process exits green.
If you cannot run ASAN at all, do not ship view-based interning. Change the API before you tune buckets. That constraint is the real fix.
What I check before I trust an intern helper
I ask four questions now before merging. Can this container rehash or relocate on insert? Do I store views across later inserts in callers? Does any test grow past two capacity doublings? Will ASAN run on that growth test in CI?
If any answer is fuzzy, I reject the patch. Agents assume the happy path on small fixtures. Hash maps do not keep your pointers polite. Cheap generated C++ still owes you a lifetime contract.
If you want a clean ASAN shell for this repro, I used MonkeyCode's free server option as one scratch box. That is the only ask.
Top comments (0)