[Bug libstdc++/127228] New: basic_string::_M_create not honoring contracts

samuelgardner101 at gmail dot com gcc-bugzilla@gcc.gnu.org
Sat Sep 5 16:15:20 GMT 2026


https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127228

            Bug ID: 127228
           Summary: basic_string::_M_create not honoring contracts
           Product: gcc
           Version: 17.0
            Status: UNCONFIRMED
          Keywords: ABI, wrong-code
          Severity: normal
          Priority: P3
         Component: libstdc++
          Assignee: unassigned at gcc dot gnu.org
          Reporter: samuelgardner101 at gmail dot com
  Target Milestone: ---

`basic_string::_M_create` no longer honours its old contract: it writes a grown
capacity back through its reference parameter when `__old_capacity` is 0. gcc
pre-17 inlined callers use that same variable as the string's *length*, so a
`std::string` built by already-compiled code reports a `size()` larger than the
string, with the excess reading uninitialised heap. So a 25-character string
might report `size() == 31`, instead of `size() == 25` (see example beneath)

`_M_create`'s contract, as relied on by every pre-17 inlined caller, is that
with `__old_capacity == 0` it leaves `__capacity` alone: its only growth rule
is `__capacity > __old_capacity && __capacity < 2 * __old_capacity`, which
cannot fire when `__old_capacity` is `0`.


EXAMPLE

```c++
// repro.cc
#include <string>
#include <cstdio>

#ifdef OLD_HEADERS
// Compiled against libstdc++ <= 16. Its inline _M_construct is baked in here,
and that code does
//     _M_data(_M_create(__dnew, 0));  _M_capacity(__dnew);  ... 
_M_set_length(__dnew);
// - one variable for the request, the granted capacity and the length.
void check() {
  const char *p = "0123456789012345678901234";           // 25 characters
  std::string s(p, p + 25);
  std::printf("size()=%zu (expected 25)  capacity()=%zu\n", s.size(),
s.capacity());
}
#else
// Compiled against libstdc++ 17 at C++23. Using std::string is enough to emit
this translation unit's
// own copy of basic_string<char>::_M_create_plus, and at C++23 that copy
rounds the capacity up via
// allocate_at_least.
void check();
static std::string join(const char *a, const char *b) { return std::string(a) +
b; }
int main() {
  const std::string s = join("some text that is long enough", " to be heap
allocated");
  std::printf("(unrelated string of %zu chars)\n", s.size());
  check();
}
#endif
```

```sh
$ g++ -std=c++17 -O2 -DOLD_HEADERS -nostdinc++ \
      -isystem /usr/include/c++/14 -isystem
/usr/include/x86_64-linux-gnu/c++/14 \
      -c repro.cc -o old.o
$ g++ -std=c++23 -O0 -c repro.cc -o new.o
$ g++ old.o new.o -o repro && ./repro
(unrelated string of 50 chars)
size()=31 (expected 25)  capacity()=31
```

so we compile repro.cc in the old build to create old.o, then compile the
repro.cc again as the new.o, with `check` stubbed to use the old.o version of
it. and we see the error. the last 6 characters are heap garbage.

```
second object at c++17 -O0   ->  _M_create(23, 0) writes back 23   (no rounding
path)
second object at c++20 -O0   ->  _M_create(23, 0) writes back 23
second object at c++23 -O0   ->  _M_create(23, 0) writes back 31   <-- GREW
second object at c++26 -O0   ->  _M_create(23, 0) writes back 31   <-- GREW
```


before `acfdad706d8`, `basic_string.tcc` had:

```c++
_M_create(size_type& __capacity, size_type __old_capacity)
{
  if (__capacity > max_size()) std::__throw_length_error(...);
  if (__capacity > __old_capacity && __capacity < 2 * __old_capacity)
    { __capacity = 2 * __old_capacity; ... }
  return _S_allocate(_M_get_allocator(), __capacity + 1);
}
```

and its caller, still present in every binary compiled against those headers:

```c++
size_type __dnew = static_cast<size_type>(std::distance(__beg, __end));
if (__dnew > size_type(_S_local_capacity))
  { _M_data(_M_create(__dnew, size_type(0))); _M_capacity(__dnew); }
...
_M_set_length(__dnew);
```

one variable carries the request, the granted capacity and the length, which is
sound only because `_M_create` cannot change it when `__old_capacity == 0`.
`acfdad706d8` renamed that function to `_M_create_plus`, changed it to allocate
with `_S_allocate_at_least`, and added a new `_M_create` in its place:

```c++
// This must remain for ABI stability, though unused in current code.
_M_create(size_type& __capacity, size_type __old_capacity)
{
  _Alloc_result __r = _M_create_plus(__capacity, __old_capacity);
  __capacity = __r.__count - 1;  // Leave room for NUL.
  return __r.__ptr;
}
```

`__capacity` now comes back as the `allocate_at_least` count regardless of
`__old_capacity`, and old callers store it as the length. Keeping the symbol
while changing what it does does not preserve the ABI.

---

suggested fix from Claude, no idea on viability:
```c++
_M_create(size_type& __capacity, size_type __old_capacity)
{
  if (__capacity > max_size())
    std::__throw_length_error(__N("basic_string::_M_create"));
  if (__capacity > __old_capacity && __capacity < 2 * __old_capacity)
    {
      __capacity = 2 * __old_capacity;
      if (__capacity > max_size()) __capacity = max_size();
    }
  return _S_allocate(_M_get_allocator(), __capacity + 1);
}
```

which is what the function did before acfdad706d8 - old callers then see
exactly what they saw when they were compiled; current code does not call
_M_create at all.


More information about the Gcc-bugs mailing list