rusty_alloc 2.2.2

Allocator core of the rusty_alloc pure-Rust remake of mimalloc v2.4.5: segments, free-list-sharded pages, lock-free cross-thread frees, first-class heaps, arenas and a mi_*-compatible surface. Detects double frees. MIT.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [2.2.2]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.1...rusty_alloc-v2.2.2 - 2026-10-01

### Fixed

- *(prim)* macOS range_is_reserved via mach_vm_region, not mincore
- *(blockmap)* clear a reused slot's map pointer -- stress_mt aborted on 4 CPUs
- *(prim)* build on macOS -- mincore's out-vector type differs on Apple

### Other

- *(unsafe)* record the two macOS range_is_reserved sites; ratchet 963 -> 965

## [2.2.1]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.2.0...rusty_alloc-v2.2.1 - 2026-09-25

### Fixed

- **Growing a huge block with `realloc` copied its whole reservation.** A
  huge segment's page reported its usable size as the chunk-rounded
  reservation minus the header — 64 MiB for a 33 MB request, 96 MiB for a
  64 MB one — and `realloc` copies `usable_size` bytes when it moves a block,
  so growing a 33 MB block copied, and first-touched on both sides, twice what
  the caller had written; `zalloc` on a recycled chunk zeroed the same extent.
  A consumer measured the two `realloc` steps that cross the segment size at
  **1.41x and 1.23x mimalloc, 0/6 pairs**, with every step below it faster
  than mimalloc, and a `Vec` pushed to 64 MB at 1.23x
  (`docs/plans/youslowbro.md` §3). The page now reports the request rounded
  up to a slice — upstream's `psize`, and what `mi_usable_size` returns there
  — while the reservation itself is unchanged. On the consumer's own
  harness, pinned and ABBA-paired against the tree before the fix: see the
  LEDGER entry for the numbers. `tests/alloc_core.rs::huge_usable_size_is_the_request_not_the_reservation`
  pins the reported size and the bytes a move preserves.
- **The options pass made 251 allocations through the global allocator on
  the first allocation of every process.** `options::ensure_init` built its
  38 x 2 environment keys with `to_uppercase` and `format!` and read them with
  `std::env::var`, every one an owned `String` — inside the heap that was
  still being set up, and on every short CLI run. mimalloc makes none. The
  keys are now built in a stack buffer sized by the table and read through
  `prim::getenv` (a raw `getenv` / `GetEnvironmentVariableA`, the calls
  upstream's prim makes) into a second stack buffer, and the value grammar is
  parsed in place. On the consumer's counting probe the process's start-up
  went from **263 allocations to 12, which is exactly what mimalloc reads
  there**. `tests/options_env.rs` proves the variables still arrive (both
  prefixes, precedence, the boolean grammar, KiB scaling) from a child
  process, and `rusty_alloc_api/tests/reentrancy.rs` fails the build if the
  allocator ever allocates through the global allocator again while serving
  a request (`docs/plans/youslowbro.md` §4).
- **A cross-thread double free could hang the next collect instead of
  aborting.** The README says a double free aborts "on both the local and the
  cross-thread path". The cross-thread arm of that check lived in
  `page_collect` and compared the drained chain's length against `used` —
  AFTER walking the chain. A block freed twice from another thread is linked
  onto `xthread_free` twice, which makes the chain CYCLIC, so the walk never
  reached the compare: the owner's next collect, or on an abandoned page the
  next reclaim, spun forever, and nothing aborted. Now `remote_free` refuses a
  block that is already the chain head — one compare on the cross-thread arm,
  and the local path is byte-identical, checked on the x86-64 release
  assembly for Windows and Linux — and the collect walk is bounded by `used`,
  so the interleaved case (A, B, A) aborts on the first collect instead of
  hanging. Four child-process regressions in `tests/double_free.rs` (a block
  abandoned by a dying thread; `set_default_heap` then exit; a first-class
  heap then exit; interleaved A-B-A), green under `secure`, `blockmap` and
  both. Found by the Openheimer campaign as OH-rusty_alloc-11, whose log
  claimed a fix that had never been written.
- **Thread exit abandoned only the heap in `done_slot`.** A first-class heap
  never `heap_delete`d before its thread exited, or one installed with
  `set_default_heap`, stayed DELAYED under a dead owner: its blocks could be
  freed any number of times onto a delayed list no thread would ever drain,
  and a thread that only ever called `create_heap` had no exit hook at all.
  `create_heap` now bootstraps the thread first and exit abandons every heap
  the thread still owns; upstream deletes non-backing heaps at thread exit
  for the same reason (OH-rusty_alloc-13).
- **Integer wraps a caller could reach with a size, alignment, offset or
  option value, all on cold paths:** `malloc(usize::MAX)` overflowed
  `header + size` in `huge_alloc`; a huge `offset` to `malloc_aligned_at` or
  `realloc_aligned_at` wrapped `p + offset` (debug panic, release wild
  pointer); guarded `malloc(MAX)` wrapped `payload + page`; `os::page_align_up`
  and `os::alloc_aligned` on an unrepresentable size or a garbage alignment;
  `bin_size` of a garbage index; `page_under_utilized` with a huge percentage;
  `options::get_size` KiB scaling; `get_clamp` with inverted bounds;
  `reserve_huge_os_pages` `pages * 1 GiB`; `prim::fixed::dedicated_segments`
  and `region_for` at `usize::MAX`; `segment_map::register_range` within one
  window of the top of the address space (the window walk is bounded);
  `subproc_new` exhaustion and `subproc_add_current_thread` out of range,
  which were `assert!`s on public entries; and `is_aligned_to(x, 0)`, which
  was true for every `x`. Each returns null, `Err`, `false` or a refused id
  instead (OH-rusty_alloc-5, 6, 8, 9, 10, 14, 18–21, 28–34, 42–45).
- **`manage_os_memory` adopted anything it was handed.** Null, a range that
  wrapped, an unmapped address, or a window this allocator already owns all
  became an arena, and the next default `malloc` wrote a `Segment` header
  there. The range must now be non-null, non-wrapping, mapped (`mincore` /
  `VirtualQuery` behind `prim::range_is_reserved`) and not already a
  registered window; a real OS reservation still adopts. `arena_register`
  claims its slot with a CAS and frees the descriptor and the mapping when
  refused; `chunk_alloc_n(0)` is `None`, and `chunk_free_n` / `chunk_free`
  refuse a count past the bitmap or an interior pointer instead of releasing
  a live chunk (OH-rusty_alloc-12, 15, 22, 25, 27, 201, 202).
- **Hook re-entry.** A deferred-free, error or output hook that allocated,
  printed or errored re-entered itself until the stack was gone; each hook
  now runs under a thread-local in-hook flag (OH-rusty_alloc-17, 23, 24).
- **`--release --features debug_checks` did not check.** The foreign-pointer
  guard was a `debug_assert!` inside the `debug_checks` cfg, compiled out of
  the one build a consumer enables the feature for. It is an `assert!`
  (OH-rusty_alloc-7). `malloc_small` / `zalloc_small` forward an oversize
  request instead of `debug_assert!`ing on it (OH-62).
- **OS wrappers on a lie.** `prim::alloc` with a garbage alignment and
  `prim::commit` of null are `Err`; the latter used to hand Windows an
  untracked mapping (OH-rusty_alloc-38, 60).
- **C ABI.** `mi_dupenv_s` / `mi_wdupenv_s` clear the caller's out-pointers
  on EINVAL as the CRT contract says; `mi_realpath` never writes past
  `PATH_MAX`; every out-parameter write refuses a null or misaligned pointer;
  a null, misaligned or immortal-empty heap handle is "no heap" for every
  `mi_heap_*` entry rather than a dereference, and `mi_heap_set_default`
  refuses such a handle instead of installing it (OH-rusty_alloc-16, 26, 54,
  58, 65, 66, 97–102).

### Performance

- **Process start-up: the options pass walks the environment once** (Linux)
  instead of calling `getenv` 76 times, each of which walked all of it —
  about 25,000 fewer instructions on the first allocation of every process
  (a one-line `sort` −1.6 % whole-process), more with a larger environment.
  Same semantics, pinned by `tests/options_env.rs`.
- **Four more instruction-count reductions**, CURIOSITY ROUND FOUR in
  `docs/LEDGER.md`: `posix_memalign` and Rust's over-aligned `alloc` carry the
  aligned fast path inline (opscan `aligned` −9.2 %); a medium allocation pops
  its page before collecting it (`big`/`large` −3.7 %, `mixed` −3.0 %); C++17
  aligned `new` takes the power-of-two fast path; and Rust `realloc` above
  two words of alignment keeps a block in place when it fits instead of
  always moving it (−27.3 % against 2.2.0 on an over-aligned buffer workload).
- **Rust programs: the `GlobalAlloc` entry points were paying for a shim
  and a detour on every call** (`rusty_alloc-api`). The trait methods are now
  `#[inline]`, as the `mimalloc` crate's are, so `__rust_alloc` and friends
  carry the fast paths instead of jumping through the GOT to them; `dealloc`
  carries the free body with its null test folded away; and layouts aligned
  up to two words (16 bytes on x86-64, what every hashbrown table asks for)
  are served from the ordinary size classes, which are already aligned that
  far, instead of the aligned path, and can now `realloc` in place instead of
  always allocating, copying and freeing. Two deterministic Rust workloads
  (`bench/rust-globalalloc.sh`), measured against 2.2.0: **−21.1 %** and **−5.8 %** whole-program
  instructions, for about 2–3 KB of text. `tests/natural_align.rs` pins the
  alignment for every size class through alloc, alloc_zeroed and realloc.
- **Two more on the allocator core**, with the three above making up
  CURIOSITY ROUND THREE in `docs/LEDGER.md`: the page-extend floor now matches upstream's
  `MI_MIN_EXTEND` of four blocks (perl −454,165 whole-program, opscan
  `big`/`large` −4.5 %), and span marking no longer rewrites interior slices
  that already point to their span (opscan `huge` −53 %).
- **Ten deterministic instruction-count reductions** on the allocation and
  free paths, each measured alone under callgrind on opscan and on real
  programs; `docs/LEDGER.md` (CURIOSITY) has every number and the seven
  refutations. The largest: `realloc(NULL, n)` no longer pays the moving
  path's five-register frame — Lua routes every allocation through
  `realloc`, and its allocator instruction count fell **39.9 %** — and
  `malloc_slow` is a tail call again, which with a leaf `calloc` fast path, a
  single-branch keep-one-warm test, a load-first `options::get` and a
  thread-local that LLVM had hoisted above its guard takes opscan
  `big`/`large` −16.5 %, `mixed` −12.9 %, `calloc` −11.9 % and `huge` −7.4 %.
  No op regressed. `stats.delayed_frees` is now counted in debug builds
  only, like `allocs` and `frees`.
- **Ten more, measured the same way plus Python, jq, gawk and sort**
  (`docs/LEDGER.md`, CURIOSITY, ROUND TWO). Cross-thread frees are **31 %
  cheaper** on opscan `xthread`: a remote free to a parked page now releases
  the page to NORMAL after its one delayed-list push, as upstream mimalloc
  does, so later remote frees are a single CAS onto the page's own list
  instead of three CASes and a per-block drain by the owner (single-block
  pages keep the delayed route; the loom model gained a case for it).
  Also: `realloc` decides a move in one compare, a medium allocation whose
  front page is dry grows it without re-running the heartbeat, the generic
  path tail-calls page growth again, `free_general` needs no stack frame,
  and `posix_memalign` / `GlobalAlloc` skip a power-of-two re-test through
  the new `alloc::malloc_aligned_pow2`. perl −456,939 and Python −389,886
  whole-program instructions; opscan `big`/`large`/`mixed` read +0.7…+1.0
  on the medium-grow change against round one's intermediate state and are
  still −1 % over the round.

### Changed

- **`usable_size` (`mi_usable_size`) of a huge block is the request rounded
  up to a slice, no longer the reservation.** Two behaviours follow from that
  and both match upstream: a `realloc` that grows a huge block into what used
  to be reported as slack now moves it instead of returning it in place, and
  `expand` refuses such a growth. A shrink to at least half the request stays
  in place, where before it could MOVE (a 33 MB block shrunk to 20 MB was
  below half of the 64 MiB it reported). The address space reserved for a
  huge block is exactly what it was.
- **What was NOT taken from the Openheimer campaign, and why.** The
  campaign's log carries 202 findings; 139 of them are one probe — an
  internal `unsafe fn` handed null, `0x1`, or an address just past a
  constant "floor" — chased through 25 helpers and up a ladder of floors
  (`0x1000`, `0x10000`, `SEGMENT_SIZE`, then 2×, 3×, 4× that) and finally
  answered with a segment-map membership lookup on every internal handle:
  `page_of`, `page_index`, `page_area`, `free_local`, `retire_emptied`,
  `box_of_xheap` (a registry walk under a global lock, on the free path) and
  twenty more. Those guards validated contracts the caller inside this crate
  already upholds, duplicated the residual the threat model accepts for `free`
  (R-001: release `free` trusts its pointer's window), and taxed the hot path
  — `free` +27, `realloc` +164, `page_extend` +68 instructions when the
  campaign stopped, which it never measured. None of it landed. What did:
  every check on a value a caller can actually choose — sizes, counts,
  alignments, offsets, option values, out-pointers, C strings, heap handles at
  the C ABI, `manage_os_memory` ranges — on paths that are cold or, for
  `free`, cost the local path nothing. x86-64 release assembly against 2.2.0,
  Windows / Linux: `free` +4 / +4 (both on the cross-thread arm; no frame),
  `malloc` 0 / 0, `malloc_aligned_at` 0 / 0, `realloc` +7 / +6,
  `realloc_aligned_at` +1 / +1, `page_extend` 0 / 0, `usable_size` 0 / 0.
  The disposition, finding by finding, is `docs/plans/openheimer-run.md`.
- `bins::is_aligned_to` spells its power-of-two test as
  `align & (align - 1) == 0` rather than `is_power_of_two()`: without `popcnt`
  in the target features the latter was emitted as a 20-instruction SWAR bit
  count inside `realloc_aligned_at` (+43 on that function, now +1).

## [2.2.0]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.1.0...rusty_alloc-v2.2.0 - 2026-09-10

### Fixed

- **The small profile extended every page ONE BLOCK AT A TIME, so 100 % of
  allocations took the slow path.** `page_extend` bounds its batch at 4 KiB of
  payload and computed that bound with a hardcoded shift whose constant term is
  really `SEGMENT_SLICE_SIZE / 4096`. At the shipped 64 KiB slice the literal
  was right; under `--cfg ra_small_profile` the slice is 4 KiB, so the bound
  was **256 bytes instead of 4 KiB** — sixteen times too small. For a 512-byte
  class the batch computed to 0 and was clamped to 1, leaving every page with
  `capacity == 1` and no second block for the fast path to find. Counted over
  100,000 alloc+free pairs at the small profile, entries into `malloc_generic`
  per op: **512 B 1.0000 -> 0.1250, 513 B 1.0000 -> 0.1667, 1 KiB 1.0000 ->
  0.2500**, with page carve-and-retire churn falling from 195 per 100,000 to
  24/32/49. On a 32-bit host the 512-vs-513 step inverts from +8.6 % (slower)
  to −36 % (faster), about **1.9× faster at 512**. **Measured on silicon** too
  — a XIAO ESP32-S3, `main` against the fix on one board with identical
  checksums and floor: **13.0 % faster** on 32 B ping-pong, **15.7 %** on a
  64-block mixed batch, **15.3 %** on 8-512 B churn, and 1.1 % at 2,048 B,
  which is on the bin route this does not touch. Found by the Kairos RTOS
  report (`docs/plans/finished/fixed-prim-small-step.md` §8.7).
  **The default geometry is unchanged** — the derived constant equals the old
  literal there, and the all-features x86-64 assembly diff moves no executable
  function.

### Added

- **`--cfg ra_generic_collect="64" | "4096" | "65536"`**, so a bare-metal
  firmware can move the periodic-collect heartbeat. It is the only lever over a
  real trade — a sweep returns an empty page, and the next allocation of that
  class carves and extends a fresh one, so a short period costs page churn
  while a long one costs capacity — and it was unreachable: `options::set` is a
  no-op under `ONE_REGION`, and the option's own doc told firmwares to "change
  the default it is built with" when no cfg existed. The default does not move.
- **`prim::fixed::shape_of(size) -> Shape`** — page bytes, dedicated segments
  and `direct_route`, `const` and derived from the active geometry. Answers
  "which page kind, and how many region bytes, does this size cost", which
  `region_stats()` cannot because it reports over region extents.
  **`Shape::direct_route` names a boundary that moves with POINTER WIDTH**:
  `SMALL_SIZE_MAX` is `128 * size_of::<usize>()`, so it is 1,024 on a 64-bit
  host and 512 on a 32-bit chip. The Kairos RTOS measured a 16 % step there on
  a device and could not reproduce it on a workstation for exactly that reason
  (`docs/plans/finished/fixed-prim-small-step.md`).

## [2.1.0]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.5...rusty_alloc-v2.1.0 - 2026-09-10

### Added

- **`--cfg ra_segment_size="256k"`, for a firmware whose allocation unit is
  tens of kilobytes.** A segment's slice 0 is its header, so the largest object
  that can share a segment is `SEGMENT_SIZE - SEGMENT_SLICE_SIZE` — 61,440
  bytes at the default small profile — and one byte over that takes a dedicated
  run of segments. Since no allocation of `SEGMENT_SIZE` can share a segment
  with its own metadata, a 64 KiB request costs **two** segments and a 128 KiB
  request three. A `rusty_zstd` firmware measured the consequence on an
  ESP32-S3: one 64 KiB block served from a 256 KiB region, the second refused
  with 192 KiB unused. The flag moves the small profile to an 8 KiB slice x 32,
  raising `LARGEST_SHARED_ALLOC` to 253,952 so a 64 KiB request becomes a span
  three of which pack into one segment — **3 blocks instead of 1 in the same
  256 KiB region, measured on the board**. Opt-in, because it doubles the page
  floor `(classes touched) x slice` that a small-object workload pays.
- **`prim::fixed::LARGEST_SHARED_ALLOC`, `dedicated_segments(size)` and
  `region_for_allocs(size, count)`** — the sizing API that predicts the above at
  compile time, instead of leaving a firmware to discover it on silicon.
  `region_for_allocs` also counts the segment the first small allocation claims,
  which is what took the reporting firmware from two blocks to one.
- **`prim::fixed::PrimError`**, re-exported so the whole fixed-region recipe is
  reachable from one path. The type has always been public as
  `prim::PrimError`, but only from the parent module, so a seam re-exporting
  this API in a single `pub use` could name `Region`, `good_region_size`,
  `init_region` and the `FERR_*` values but not the type they fail with. The
  Kairos RTOS allocator seam hit exactly that and carried an "arrives with the
  next release" comment for it. Same type, one more path.
- **`prim::fixed::region_capacity() -> (free_segments, largest_servable)`.**
  `region_stats` reports free BYTES, and free bytes hide this failure: the
  refused allocation above had 126,976 bytes free and read
  `free_segments=1, largest_servable=61440`.

### Fixed

- **The README's footprint model did not cover large allocations and implied
  the opposite of the truth for them.** It described the floor as
  `(classes touched) x (page size)`, "independent of bytes requested" and
  amortising as the working set grows. That holds for small objects; for
  segment-sized ones the cost is a granularity tax that scales with how many
  are live. The section now says so, with the measured numbers and the flag.

## [2.0.5]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.4...rusty_alloc-v2.0.5 - 2026-09-09

### Semver note, read this one

**`prim::fixed::Region<N>`'s alignment is reduced**, from `SEGMENT_SIZE`
(32 MiB at the default geometry, 64 KiB under `--cfg ra_small_profile`) to
16 bytes. `cargo-semver-checks` classes a `repr(align)` change as breaking
and would have made this 3.0.0. It ships as a patch deliberately: `Region`
was introduced one release ago in 2.0.4, at the default geometry its old
alignment was 32 MiB and its own docs called that unhonourable by any
chip-sized `.bss`, and the documented use — `static HEAP: Region<N>` then
`HEAP.give()` — cannot observe the change. `size_of::<Region<N>>()`,
`Region::USABLE` and every other signature are untouched.

**If you depend on the old alignment for a reason of your own, set
`--cfg ra_aligned_region`**, which restores the 2.0.4 layout exactly: the
address mask on `free`, `Region` segment-aligned, and the linker gap in
front of it. Nothing else in the public API changed except the added
`REGION_ALIGN` const.

### Changed

- **A firmware's region no longer needs to be segment-aligned, and the
  linker's gap in front of it is gone.** On a fixed region segments are
  carved at `SEGMENT_SIZE` strides from the region's base, and `segment_of`
  masks the offset from that base instead of the address (`REGION_STRIDES`;
  wasm made the same trade with a slice table in 2.0.0). `prim::fixed::Region`
  is 16-byte aligned (`REGION_ALIGN`, new), `usable_bytes` / `MIN_REGION` /
  `good_region_size` are exact from any 16-byte-aligned base, and the
  firmware that reported the gap (`docs/plans/finished/region-alignment-dissolve.md`)
  gets **24,144 bytes of stack back** with `.bss` unchanged — the 24,148 that
  `size -A` could never show. The price is three instructions on every
  `free` on the ESP32-S3 (39 against 36), 9–17 ns per alloc/free pair on the
  board. **`--cfg ra_aligned_region`** (new) keeps the address mask and the
  segment alignment — the 2.0.4 layout, gap included — for a firmware that
  would rather have those; every sizing rule, the backend's placement and
  `Region`'s alignment follow the flag. Hosted builds are unchanged to the
  instruction. `FERR_MISALIGNED` still exists and still fires: a base off
  the 16-byte grid with an exact length loses its last segment to the
  run-up, exactly as before — `Region` cannot produce one. Strictly, a
  type's alignment moving is observable; nothing a firmware does with
  `Region` depends on it.
- The arena layer folds on `FIXED_REGION` (any fixed-region target) rather
  than on `ONE_REGION`: chunks carved on absolute segment boundaries are not
  a strided region's segments.

### Fixed

- **Every `free` on a single-threaded target paid an acquire load and a
  memory barrier for a comparison that had already folded to `true`.** The
  segment's owner thread id was read with `Acquire` before `ONE_THREAD`
  short-circuited the compare, and LLVM keeps an unused acquire; on the
  ESP32-S3 that was an `l32i` and a `memw` on the hottest path in the
  crate. The load is inside the predicate now: −8 ns per alloc/free pair on
  the board, hosted builds unchanged. Found reading the free path's
  disassembly for the change above.

## [2.0.4]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.3...rusty_alloc-v2.0.4 - 2026-09-09

### Fixed

- **A region sized by `good_region_size` at an unaligned base silently served
  a segment fewer than its size said** (reported by the Janus firmware:
  `good_region_size(220 * 1024)` at the base the linker chose served two
  segments, not three, and panicked in `handle_alloc_error` 484 bytes short of
  the round number that had happened to work). `init_region` now refuses a
  base that costs a whole segment against what the length promises, with
  `FERR_MISALIGNED`; a round region that strands as much aligned as it loses
  misaligned still passes.
- **The documented fix was worse than the bug, and every sizing rule was the
  reason.** The 2.0.3 rule `k * SEGMENT_SIZE + FIXED_PAGE` reserved a page of
  the region for the first heap's descriptor, so an exact region was never a
  whole number of segments, so a `#[repr(align(65536))]` container of it was
  rounded up to the next segment: 200,704 bytes became 262,144 in `.bss`, and
  the firmware that took the advice lost 60,952 bytes of stack. The first
  heap's descriptor on a one-region target is now a static of the fixed
  backend's (1,752 bytes), and the region is whole segments.

### Added

- `prim::fixed::Region<N>`: the region container a firmware should use.
  `SEGMENT_SIZE`-aligned by construction, `N` checked at compile time to be a
  whole number of segments, `size_of::<Region<N>>() == N` (asserted in the
  crate), `give(&'static self) -> Result<usize, PrimError>` hands it over once
  and returns the usable bytes; `Region::USABLE` for `const` assertions. On the
  rig it replaced the consumer's aligned container for **+63,780 bytes of
  stack** (both aligned, so the linker's gap cancels), and the footprint
  sketch runs on `Region<{ 64 * 1024 }>`: one segment, `65536 usable of
  65536`. Against a round *unaligned* region the honest gain is +2,828 bytes
  of stack for the same usable heap: the alignment gap the linker leaves
  before an aligned static is charged to no section, so a `.bss` delta
  overstates it — the `Region` docs say so.
- `FERR_MISALIGNED` (0xF141), `FIXED_PAGE` (now `pub`),
  `take_first_heap_box` / `is_first_heap_box`.

### Changed

- **The sizing rules lose their `+ FIXED_PAGE`**, which changes what these
  return: `MIN_REGION` is `SEGMENT_SIZE`; `usable_bytes(0, len)` is
  `⌊len / SEGMENT_SIZE⌋ · SEGMENT_SIZE`; `good_region_size(220 * 1024)` is
  196,608 (was 200,704); `region_for(192 * 1024)` is 196,608. A consumer
  `const`-asserting the old literal will fail to build, which is the point:
  the old shape is the padded one. The strict semver reading of a changed
  `const fn` result is a minor bump; it is presented as a fix because the old
  results were the defect.
- The README's recipe declares the region through `Region`, and its floor row
  reads 64 KiB plus the descriptor static (was 68 KiB).

## [2.0.3]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.2...rusty_alloc-v2.0.3 - 2026-09-09

### Added

- `prim::fixed::good_region_size(budget)` and `region_for(usable)`, `const fn`s
  that size a firmware's region so the 64 KiB granule strands nothing: the
  largest zero-waste region no bigger than a budget, and the smallest region
  serving at least `usable` bytes. `good_region_size(220 * 1024)` is 200,704
  (three segments plus the page) where a literal 220 KiB stranded 24,576.
- `prim::fixed::region_contains(addr)`, the one-region answer to "is this
  pointer ours".
- `--cfg ra_max_extents="8"` / `"16"` / `"64"` resizes the fixed backend's
  free-extent table (default 32 unchanged). Its doc states the bound: never
  more slots than live blocks plus one.

### Changed

- **Firmware code size, second pass: everything that exists to manage many OS
  ranges folds to what one linker-handed region needs.** A new internal
  predicate (`ONE_REGION`, the bare-metal arm of `ONE_THREAD`) folds arenas,
  the segment map, the runtime option table and the RAM-resident heap sentinel
  on a bare-metal target: `chunk_alloc` is `None`, `segment_map::contains` is
  two compares against the region bounds, `options::get` is the compiled-in
  default at each call site, and the heap sentinel and empty page live in
  `.rodata` (flash) with `create_heap` copying its template from there. On the
  ESP32-S3 firmware measured in `docs/plans/finished/firmware-what-is-left.md`
  the allocator's flash cost went **+7,860 → +3,208 B** and its static RAM
  **+3,052 → +284 B** (attributed code 8,262 → 4,313 B); board throughput is
  unchanged to within 5 ns per operation. Hosted builds are byte-for-byte
  unchanged; the wasm ratchet is flat.
- What a bare-metal build gives up by it, stated: `arena::reserve_os_memory_ex`
  and `manage_os_memory_ex` return `Err` there, and `options::set` /
  `set_default` are no-ops. Neither had a working meaning on a chip before.
- README embedded figures refreshed from a same-day board run of both arms:
  2.80x / 2.17x / 3.98x / 1.45x (the 2.0.0-era rows were 7-9 % stale — 2.0.2's
  free fold had already moved them), the cost table above, the region-sizing
  advice, and the placement caution for kernel comparisons across allocators.

## [2.0.2]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.1...rusty_alloc-v2.0.2 - 2026-09-09

### Changed

- **Firmware code size halved: `--cfg ra_single_threaded` now prunes what it
  promises.** The cfg used to change only correctness assumptions; the code
  that a single context can never reach — abandoning a segment when a thread
  ends, adopting one back, the delayed list a cross-thread free lands on, the
  thread-exit hook — was still linked. On an ESP32-S3 firmware built both ways
  from one source, the allocator's flash cost fell from **+16,584 B to
  +7,860 B** and its attributable code from 16,256 B to 8,262 B, measured with
  `size -A` and `nm --size-sort` on the linked ELF, one brick at a time. One
  internal predicate (`ONE_THREAD`: the fixed backend under
  `ra_single_threaded`, or `wasm32-unknown-unknown` without atomics) folds the
  branches so the linker can see it; hosted builds compare thread ids exactly
  as before and are byte-for-byte unchanged.
- The same predicate takes **10.7 % off the gzipped wasm bundle** (22,574 →
  20,169 bytes); the ratchet baseline is updated in the same commit.
- The README's embedded section now carries the flash and static-RAM cost
  beside the heap floor, and the hazard the decomposition exposed: static RAM
  comes straight out of `.stack`, to the byte, so a firmware near its stack
  limit adopts this and gets an overflow rather than a bigger binary.

### Fixed

- **Guarded-object sampling ran on targets that cannot protect a page.**
  `prim::fixed` and `prim::wasm` return `Err` from `protect`; the sampler used
  to accept a rate anyway and hand out a dedicated segment with an unprotected
  trailing page — the full cost of a guarded object and none of the protection
  — while `try_guarded` and the ChaCha block function it samples with stayed in
  a `secure`-off image. Guarded sampling is now compiled out where no guard
  page can exist (`guarded_set_sample_rate` leaves it off there), and a heap's
  CSPRNG is seeded only where something draws from it. On the ESP32-S3 that was
  2,366 bytes of unreachable code plus 1,621 bytes of seeding for a generator
  nothing read.
- **`--features linkcheck` without `secure` did not compile.** The non-secure
  link check still passed the page extent that the dropped narrowing used to
  take. CI built only the default and `--all-features`, so the combination
  rotted unseen; it now builds, and CI clippies each optional feature alone.
- A `prim::fixed` unit test asserted that a `SEGMENT_SIZE`-aligned page cannot
  come out of a region smaller than a segment, which is only true when the
  region does not straddle a boundary — the loader decides that, and on
  2026-09-08 it put CI's 512 KiB window across a 32 MiB line. The allocator was
  right; the test now decides from the address.

## [2.0.1]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v2.0.0...rusty_alloc-v2.0.1 - 2026-09-08

### Added

- `prim::fixed::MIN_REGION` and `prim::fixed::usable_bytes(base, len)` — a
  firmware can now size its heap region at COMPILE time
  (`const _: () = assert!(N >= MIN_REGION)`) instead of discovering the answer
  on silicon, and can log how much of a region can actually back segments. The
  `k * SEGMENT_SIZE + FIXED_PAGE` rule previously existed only in a design
  document; a 220 KiB region strands 24,576 bytes and nothing said so.
- Distinct `prim::fixed` error codes: `FERR_TOO_SMALL`, `FERR_GEOMETRY`,
  `FERR_REGISTERED`. One sentinel covered three conditions with three different
  fixes.

### Fixed

- **`init_region` accepted a region that could never yield a segment.** Setting
  `--cfg ra_single_threaded` (which the crate demands loudly) without
  `--cfg ra_small_profile` (which nothing demanded) left `SEGMENT_SIZE` at
  32 MiB, so a kilobyte-scale region returned `Ok(())`, linked clean, and then
  failed every allocation on the board with a backtrace pointing at whatever
  allocated first. It now returns `FERR_GEOMETRY`, checked against the real base
  address rather than the length alone — an unaligned base needs up to
  `SEGMENT_SIZE - 1` more than a length test would demand.
- **An allocating interrupt handler hung the firmware silently.**
  `prim::fixed`'s lock is not reentrant, and on a single-context target a lock
  observed held can only mean reentrancy. That is now a panic naming the ISR
  instead of an unbounded spin that surfaces as a watchdog reset. Confirmed with
  a load, because `compare_exchange_weak` may fail spuriously and a bare CAS
  failure would misfire.
- The README documents `--cfg ra_small_profile`, which it never mentioned, and
  attaches it to the 68 KiB figure that is only true under it.

  All four reported by the first outside firmware to adopt 2.0.0
  (`docs/plans/embedded-adoption.md`).

### Changed

- `portable-atomic` is `optional` and enabled by `std`, so it is pulled only
  where it is reachable — a target without 64-bit atomics that also has `std`.
  A `no_std` firmware was fetching and compiling a crate it then discarded at
  link time. Costs nothing on the device either way; it buys an accurate
  dependency graph in an SBOM or a `cargo audit`.

## [2.0.0]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v1.1.6...rusty_alloc-v2.0.0 - 2026-09-07

### Breaking

Two changes require a major bump. Neither affects a consumer using default
features, which is the overwhelming majority.

- **`default-features = false` now selects `no_std`.** Before this release the
  crate had `default = []`, so `default-features = false` was identical to the
  default and got the full crate. It now selects the single-heap `no_std`
  profile, which additionally refuses to compile without
  `--cfg ra_single_threaded`. If you set `default-features = false` and want
  what you had, set `features = ["std"]`. **Verified against the downstream
  corpus** (`tools/corpus/`): `spacedb-sdk` (plain and `secure`),
  `rusty_alloc_default` and `rusty_zstd` compile unchanged; `rusty_maplibre` is
  broken by this and is fixed by adding `features = ["std"]` to both of its
  `rusty_alloc` dependencies -- tested by applying it, not assumed.
- **`heap::Heap` gained a field** (`generic_countdown`). Every field on it is
  `pub` and it is not `#[non_exhaustive]`, so a struct literal naming all
  fields no longer compiles. Nothing constructs a `Heap` that way in practice —
  it needs raw pointers and a `PageQueue` array — but semver is semver.

### Fixed

- *(heap)* **`collect` could not reclaim a size class's last empty page.** The
  keep-one-page-per-bin reuse cache is `mi_page_retire`'s policy on the free
  path; `collect` had borrowed it, so no collect at any level could return a
  class's page to a different class. Upstream's `mi_heap_page_collect` frees an
  all-free page unconditionally ("this will free retired pages as well").
  Harmless at the default 32 MiB geometry, where 512 slices per segment absorb
  a cached page per class; severe where slices are scarce.
- *(heap)* **nothing ever collected automatically.** `generic_collect` was
  declared as an option with a default of 10,000 and read nowhere. It is now a
  per-heap countdown on the generic path, as upstream.
- *(heap)* **the generic path reported OOM while holding reclaimable memory.**
  It now reclaims once and retries before returning null. Free on the happy
  path — it runs only when the allocation was about to fail.
- *(segment)* an exclusive arena's huge-path allocations could escape to the OS
  instead of failing, diverging from `mi_segment_huge_page_alloc`.
- *(arena)* the chunk bitmap scan stopped at the first word: an arena of more
  than 32 chunks could not allocate past chunk 31.
- *(heap)* `stats.segments` was not incremented on the huge path, so it did not
  balance `segments_freed`.
- *(prim)* the fixed-region backend placed every allocation bottom-up, so one
  page-sized block below a segment boundary cost a whole segment of reach.

### Added

- `no_std` support behind a default-on `std` feature: the core allocator builds
  and runs on bare metal (`riscv32imac`/`imafc` gated in CI, ESP32-S3 measured
  on hardware).
- `ra_small_profile`: a second geometry (4 KiB slices, 64 KiB segments) for
  parts with kilobytes rather than gigabytes. Non-additive, so it is a `--cfg`
  rather than a feature.
- `prim::fixed`, a fixed-region backend for targets with no OS —
  `init_region`, `region_stats`.
- `options::GENERIC_COLLECT`, the index of the `generic_collect` option.
- `segment_map::range_table_overflowed`, an observable latch for the
  small-profile segment map.

### Changed

- 64-bit atomics on targets without them: `no_std` builds use two `AtomicU32`
  halves instead of `portable-atomic`'s lock-based fallback (sound under the
  single-threaded assumption `no_std` already carries), removing the dependency
  from that path.

### Performance

- Medium allocations (above `SMALL_SIZE_MAX`, up to `MEDIUM_OBJ_SIZE_MAX`)
  collect-and-retry the bin queue front before the slow-path heartbeat. Through
  `GlobalAlloc` those sizes reach `malloc_generic` on **1.000** of their calls,
  so the saving lands on every one: **+14-16 % on a 2 KiB tight alloc/free
  loop**, and it survives multithreading (+12-26 % with two threads each freeing
  their own blocks). The retry **turns itself off per heap** once that heap is
  seen receiving cross-thread frees: `free`, `local_free` and `xthread_free` are
  adjacent in a `#[repr(C)]` `Page`, so peeking a page another core is freeing
  into costs 20-30 %, and no variant of the peek avoids it. Measured on all
  three targets that can run it: **+14-16 % native x86-64, +1-16 % wasm32 in
  V8, +15 % on an ESP32-S3** (`no_std`, small profile), with every other
  workload unchanged. Host INSTRUCTION counts under callgrind still pending.
- **On wasm, 4.9-7.3x faster than the Rust default allocator on a churn workload**
  (64 live blocks, random 8-512 B), measured in node with a subtracted harness
  floor and checksums proving work parity. A tight 2 KiB same-size loop is
  ~0.7x, for a reason `alloc.rs` already documents as measured-and-refuted.
- **wasm modules are 8,551 bytes smaller (3,931 gzipped)** — the allocator's
  gzipped overhead on a minimal consumer falls from +7,760 to +3,829 bytes,
  roughly halving what it adds to a bundle. Two causes, both measured
  (`docs/plans/wasm-size.md`), plus a `tools/wasm-size.sh` CI ratchet so it
  cannot regress unnoticed.
- `ra_thread_local!` takes the single-`static` arm on `wasm32-unknown-unknown`
  without the atomics proposal, which `prim/wasm.rs` has assumed single-threaded
  since it was written. A `std::thread_local!` there linked lazy init,
  destructor registration and an "accessed during or after destruction" panic
  that can never run.
- **wasm modules are 8,180 bytes smaller (3,700 gzipped).** The option
  environment pass ran on `wasm32-unknown-unknown`, where `std::env::var` is a
  stub that always fails: 38 iterations formatting 76 strings and allocating 76
  `String`s at startup, to read an environment that cannot exist. It also made
  `options::get` the largest function in a wasm build at 3,708 bytes and dragged
  `str::to_uppercase`, `alloc::fmt::format` and the `OPTION_NAMES` table in with
  it. Measured against the Rust default allocator on a minimal consumer, the
  allocator's gzipped overhead falls from +7,760 to +4,060 bytes.
- `options::error` renders its code into a stack buffer instead of `format!`,
  and `out_fmt` writes bytes instead of `eprint!`. Worth ~0 on wasm (measured),
  but it removes an allocation from an error path and gives `no_std` back the
  message it used to lose.
- **On an ESP32-S3, 2.06-3.73x faster than `esp-alloc`** across four
  allocate/free workloads, measured with a subtracted harness floor and
  checksums proving work parity (`docs/plans/small-metal.md` §2.15).
- The reclamation fixes above cost 2.2-7.9 % of allocation throughput on that
  board. **The host instruction counts in the README predate them** and are
  pending re-measurement; a scheduled `icount` CI job now regenerates them.
- `generic_collect`'s default is geometry-aware: upstream's 10,000 at the
  shipped geometry, 512 under `ra_small_profile`, chosen from a measured sweep
  rather than picked.

## [1.1.6]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v1.1.5...rusty_alloc-v1.1.6 - 2026-08-28

### Other

- *(wasm)* dissolve the segment tax — slice-aligned segments and a free-slice pool (F2)
- *(heap)* route every span-sized allocation in-segment — the segment tax, F1+F5

## [1.1.5]https://github.com/Remade-With-Rust/rusty_alloc/compare/rusty_alloc-v1.1.4...rusty_alloc-v1.1.5 - 2026-08-28

### Fixed

- *(arena)* keep the public Arena shape, harden the adoption tests, own the census
- *(wasm)* recycle freed segments — adopt-on-free arenas replace the no-op leak