Skip to content

fix: zero the new TLS slots after growth (first import from Bun, #78) - #148

Merged
zackees merged 3 commits into
mainfrom
fix/78-tls-slot-slack-from-bun
Aug 2, 2026
Merged

fix: zero the new TLS slots after growth (first import from Bun, #78)#148
zackees merged 3 commits into
mainfrom
fix/78-tls-slot-slack-from-bun

Conversation

@zackees

@zackees zackees commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The first genuine import from the Bun fork (#78), and a real memory-corruption bug we ship today.

imported from oven-sh/mimalloc @ d078ad06 (src/threadlocal.c), MIT

The bug

mi_thread_locals_expand grew the slot array with rezalloc and assumed the new slots were zero. They are not. _mi_theap_realloc_zero starts its zero-fill from the block's old usable size, not from the size originally requested — it cannot do better, since the requested size isn't tracked in release builds — so the slack between requested and bin size is carried across verbatim. That slack was never initialized by anyone; it holds whatever the previous tenant of the page left.

Confirmed deterministically on our own tree rather than taken on faith:

requested=40 usable=48 slack=8
stale bytes carried into [40,48): 8

Why it is dangerous

_mi_thread_local_get validates a slot only by comparing its version lane against the key's version — and versions are small sequential counters. A garbage lane that happens to equal a live key's version makes the adjacent garbage lane be returned as a cached mi_theap_t*. Application bytes get dereferenced as a theap, and mimalloc then writes page queues and stat counters through it.

This is the third bug found in this one function: #128 B3 (array allocated from a user-destroyable heap) and now this. They're independent — the B3 fix does not address this one, and Bun still has B3 while we still had this.

On the C.2 rule, stated plainly

test/test-rezalloc-slack.c pins the zero-fill boundary that makes the memzero necessary. It does not fail without this fix: mi_thread_locals_expand is static, and the corruption is only observable through a heap→theap lookup returning garbage. Bun's own reproducer is probabilistic ("roughly once per several hundred process runs") and pthread-only, so it is not usable in CI.

So #66 C.2 — import only with a test that fails without it — is not strictly met here. The mechanism is pinned deterministically instead, and I'd rather record the deviation than pretend the box is ticked.

The test also caught my own error on its first run: I predicted the in-place realloc branch's boundary, but newsize > usable takes the move path, which zeroes and then copies min(newsize, old_usable) bytes over the top — so the entire slack survives, not slack-minus-one-word.

20/20 locally. Second commit regenerates the vendored amalgamation.

zackees and others added 3 commits August 1, 2026 18:41
imported from oven-sh/mimalloc @ d078ad06 (src/threadlocal.c), MIT.

mi_thread_locals_expand grew the slot array with rezalloc and assumed the new
slots were zero. They are not. _mi_theap_realloc_zero starts its zero-fill from
the block's OLD USABLE size, not from the size originally requested -- it cannot
do better, since the requested size is not tracked in release builds -- so the
slack between the requested size and the bin size is carried across verbatim, and
that slack was never initialized by anyone.

Confirmed deterministically on our tree, not taken on faith: mi_malloc(40) yields
usable 48, and after mi_rezalloc(p,112) the eight bytes [40,48) still read 0xAA.

It matters because _mi_thread_local_get validates a slot ONLY by comparing its
version lane against the key's version, and versions are small sequential
counters. A garbage lane equal to a live key's version makes the adjacent garbage
lane be returned as a cached mi_theap_t* -- application bytes dereferenced as a
theap, which mimalloc then writes page queues and stats through.

This is the third bug in this one function: #128 B3 (array allocated from a
destroyable heap) and now this. They are independent; the B3 fix does not address
this, and Bun still has B3.

test/test-rezalloc-slack.c pins the zero-fill boundary that makes the memzero
necessary. Note honestly what it is NOT: it does not fail without this fix, since
mi_thread_locals_expand is static and the corruption is only observable through a
heap->theap lookup returning garbage. Bun's own reproducer is probabilistic
("roughly once per several hundred process runs") and pthread-only. So #66 C.2's
"import only with a test that fails without it" is not strictly met here; the
mechanism is pinned deterministically instead and the deviation is recorded rather
than glossed over.

The test also caught my own wrong model on first run: I predicted the in-place
realloc branch's boundary, but newsize > usable takes the move path, which zeroes
then copies min(newsize, old_usable) bytes over the top -- so the ENTIRE slack
survives, not slack minus one word.

Co-Authored-By: Claude <noreply@anthropic.com>
#78)

Separate commit per repo rule 2; required by the #88 drift gate.

Co-Authored-By: Claude <noreply@anthropic.com>
CI caught this: mi_malloc carries mi_attr_alloc_size(1), so with _FORTIFY_SOURCE
glibc's fortified memset sees a 40-byte object and aborts with
'*** buffer overflow detected ***' when the test writes the usable slack. Writing
up to mi_usable_size is legal for mimalloc; the compiler has no way to know.

Volatile byte loop instead. Windows/MinGW never showed it -- three Linux jobs did.

Co-Authored-By: Claude <noreply@anthropic.com>
@zackees
zackees merged commit 677704e into main Aug 2, 2026
45 of 46 checks passed
@zackees
zackees deleted the fix/78-tls-slot-slack-from-bun branch August 2, 2026 01:56
zackees added a commit that referenced this pull request Aug 2, 2026
) (#149)

#78 and #66 require every import to carry three things: an in-code
"imported from <repo> @ <sha>, <license>" comment, a row in MIMALLOC_FORKS.md,
and its own commit. #148 shipped the code comment and the commit but NOT the
MIMALLOC_FORKS.md row -- my omission, and exactly the provenance gap the rule
exists to prevent. We intend to upstream to Microsoft, and they will not take code
whose origin we cannot state.

Adds:
- MIMALLOC_FORKS.md row for the TLS slot zeroing, marked IMPORTED (#148), with
  the deterministic confirmation and the symmetry worth remembering: Bun fixed the
  zeroing, we fixed the same function's provenance bug (#128 B3), and each fork
  still had the other's half until now.
- MIMALLOC_FORKS.md row for the page-map os_align over-count, scored 4 and marked
  NOT yet taken, stating plainly that the over-count is reproduced but the
  corruption is not.
- README "Adopted from other forks" row for the same import.

Also corrects two rows that had gone stale:
- the __GCC__ row said "report tracked in #114"; it is now filed as
  microsoft/mimalloc#1349 with a 44x thread-churn measurement.
- zero-tracking said "on v4 only"; the v4 line was dissolved into main in #129.

Co-authored-by: Claude <noreply@anthropic.com>
zackees added a commit that referenced this pull request Sep 2, 2026
…w notes

Opus review (PR #305):
- B1: TLS-slot zeroing was attributed to d078ad06 ("macOS VM tag default"),
  not the actual fix. Verified against oven-sh/mimalloc directly
  (`git show --stat afb41757 d078ad06`): afb41757 ("threadlocal: zero new
  slots explicitly when expanding the slot array", src/threadlocal.c) is
  the real commit. Fixed in README.md (text + href) and
  MIMALLOC_FORKS.md:325, where the error originated (PR #148's body).
- B2: "upstream is 10" -> 1000ms. Verified `git show 6def7be9:src/options.c`
  at the pin: `purge_delay` upstream default is 1000ms, not 10.
- B4: dropped the "(open, stacked on #299)" parenthetical from the
  purge_holes row now that #299 is merged; PR stays draft until #302 merges.
- M1: the scavenger row's deviation (c) had it backwards -- the park
  protocol IS Bun's, imported as part of this PR. Replaced with the real
  deviation: mi_subproc_t's new fields are appended at the struct tail
  rather than mid-struct, which is where Bun's placement would have
  shifted `stats` (the free path touches it, ~2 ns/alloc+free).
- Minor: "Our PR" -> "Landed in"; added the regen command
  (`uv run ci/bench_hole_purging.py --build-dir ... --include-dir include
  --out-dir .github/assets [--table]`) after the source sentence; added
  a sentence to the v3-only note that upstream dev3 is itself still
  pre-release, linking docs/fork-divergence.md#how-v3-was-validated;
  added "The scavenger is on in both runs; the chart isolates hole
  purging" to the prose; re-rendered both RSS SVGs for the new title.

`check_doc_snippets.py` and `pytest ci/tests` (235 tests) both pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT4jVokb2gdT8ngFrH8i8S
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant