Skip to main content

praxis_runtime/
heap.rs

1//! The GC heap and the precise non-moving mark-and-sweep collector (§12,
2//! ADR-011, ADR-103).
3//!
4//! Every allocation is `[GcHeader | payload]` laid out contiguously in a **block**
5//! on a size-class **page** ([`crate::page`]). The page's `allocated` bitmap is
6//! the record of every outstanding allocation, so sweep is precise: object
7//! boundaries are recovered from the page's stride rather than scanned for, and
8//! there is no side registry to push to or walk. Objects never move (§12.1), so
9//! `GcRef` addresses are stable for the object's lifetime.
10//!
11//! Collection is mark-and-sweep with no write barrier (§12.1):
12//!   1. **Mark** — start from the root set; for each reachable object, set its
13//!      block's bit in its page's `mark` bitmap and run its descriptor `trace`
14//!      callback to enqueue child references.
15//!   2. **Sweep** — walk every page a word at a time; each block in `allocated &
16//!      !mark` gets its descriptor `drop_value` called (§12.5), is poisoned, and
17//!      has its `allocated` bit cleared so the block can be reissued (RT-01).
18//!   3. **Clear the weak set** — scan the one set that names objects without
19//!      keeping them alive (the crash debugger's value slots) and turn every
20//!      entry naming a block step 2 just reclaimed into an absence (ADR-106).
21//!      This runs inside the collection because a reclaimed block is only
22//!      *recognisable* as one between the sweep and the next allocation.
23
24use std::cell::{Cell, RefCell};
25use std::marker::PhantomData;
26use std::num::NonZeroUsize;
27use std::ptr::NonNull;
28use std::sync::OnceLock;
29
30use crate::Tracer;
31use crate::descriptor::{Payload, TypeDescriptor};
32use crate::gc::{GcHeader, GcRef, HeapId};
33use crate::page::{self, NUM_CLASSES, PageHeader, SizeClass};
34use crate::roots::{RootSet, RuntimeRoots, WeakSet};
35
36/// Proof that the collector was given a chance to run at this point.
37///
38/// [`Heap::alloc`] and [`Heap::alloc_with`] demand one, and [`Heap::pace`] —
39/// which *performs* the [`Heap::maybe_collect`] — is its only producer. The
40/// field is private to this module, so "allocate on the paced path without
41/// pacing" has no spelling: obtaining the token is the pacing.
42///
43/// `pace` in turn takes a [`RuntimeRoots`], which is constructible only from a
44/// live `RuntimeContext` and is exhaustive over the runtime's owners, so the
45/// collection a token permits can never run against a partial root set.
46///
47/// Deliberately neither `Copy` nor `Clone`: one token, one allocation. A
48/// wrapper that allocates twice paces twice.
49///
50/// # Generated code holds no token, and does not need one (ADR-113, ADR-119)
51///
52/// The Cranelift backend reproduces [`Heap::collection_is_due`] inline
53/// (ADR-113) and, when it answers `false`, reads an interned small `Int` out of
54/// [`crate::small_int`]'s table without entering this module at all. That is not
55/// a forged token, and the reason is what this type means: **the token is
56/// permission to *collect*, not permission to allocate.** It takes that branch
57/// only where `maybe_collect` would have returned `false`, which is the branch
58/// on which `pace` mints a token having done nothing at all. Where the predicate
59/// answers `true` the inline path branches to `praxis_alloc_int`, which paces
60/// through `pace`.
61///
62/// The inline path also does more than hand back an immortal (ADR-119): on the
63/// branch the predicate leaves open, generated code claims a block out of a
64/// page's `allocated` bitmap, writes the header and the payload itself, and
65/// bumps both live counters and the pacing charge — everything `alloc_raw` →
66/// `claim_block` → `occupy` does, in that order, without entering this module.
67/// Three parts carry that:
68///
69/// 1. **Entry.** Every store the sequence performs is dominated, in the emitted
70///    Cranelift CFG, by the branch on [`Heap::collection_is_due`]. Asserted with
71///    a dominator tree over a function with two claim sites, so it is a
72///    dominance claim and not a claim about one lowering's shape.
73/// 2. **Duration**, which is the part that carries the weight. Between that
74///    branch and the last store there is **no call**, and a collection begins
75///    only inside `Heap::collect_inner`, which generated code reaches only
76///    through a `praxis_*` wrapper. So *not due on entry* implies *not due
77///    throughout*: no sweep can observe the block half-written, and a re-entrant
78///    claim cannot be handed the same free bit because there is nothing to
79///    re-enter through.
80/// 3. **State.** The heap is left field-for-field as the paced path would have
81///    left it, both live counters included — each is only ever *decremented*
82///    elsewhere and never recomputed, so a skipped increment underflows rather
83///    than decays.
84///
85/// The store order (header, payload, `allocated` bit, counters) is a severity
86/// ranking against a collection part 2 says cannot occur — **not** the safety
87/// argument. All three parts are claims about an instruction stream, so all
88/// three are carried by tests in `crates/praxis-codegen-cranelift/src/lower.rs`
89/// that read the emitted Cranelift, and the displacements they name are checked
90/// against live objects by `the_claim_site_displacements_name_the_fields_they_claim_to`
91/// below. [`InlineInternSite`] and [`InlineClaimSite`] carry the half a type can:
92/// which table may be probed, and which descriptors have a claim sequence at all.
93#[must_use = "a Safepoint is the permission to allocate; dropping it wasted a pacing check"]
94pub struct Safepoint<'a>(PhantomData<&'a Heap>);
95
96/// The pacing predicate's operands, as displacements: where the `Heap` hangs off
97/// a [`RuntimeContext`](crate::RuntimeContext), and where its two pacing words
98/// sit inside it.
99///
100/// # Not a constructor argument, anywhere
101///
102/// [`Self::new`] takes nothing and reads all three off `Heap`'s own
103/// [`Heap::BYTES_SINCE_COLLECT_OFFSET`] and [`Heap::COLLECT_THRESHOLD_OFFSET`],
104/// so a site that described a table to probe — or a block to claim — *without*
105/// carrying the pacing predicate's operands has no spelling. That is the one
106/// thing [`InlineInternSite`] exists to withhold, and skipping the predicate is
107/// the one thing ADR-040's [`Safepoint`] exists to make unwritable.
108///
109/// # One value, because the compare has one authority
110///
111/// [`Heap::collection_is_due`] is the one statement of the predicate and
112/// generated code is the one reader that cannot call it: it loads these two
113/// words and compares them. The direction (`>=`, so that a zero threshold is
114/// *always* due) and the operand order are therefore transcribed from a rule the
115/// compiler cannot check, and every transcription is a fresh chance to write it
116/// backwards. Both [`InlineInternSite`] and [`InlineClaimSite`] carry *this*
117/// value rather than three fields each, so the backend's `emit_pacing_test` —
118/// the one place the transcription lives — cannot pair one site's `since` with
119/// another's `threshold`.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub struct PacingOffsets {
122    heap_offset: usize,
123    bytes_since_collect_offset: usize,
124    collect_threshold_offset: usize,
125}
126
127impl PacingOffsets {
128    /// No parameters, and that is the point — see the type's doc.
129    const fn new() -> PacingOffsets {
130        PacingOffsets {
131            heap_offset: core::mem::offset_of!(crate::RuntimeContext, heap),
132            bytes_since_collect_offset: Heap::BYTES_SINCE_COLLECT_OFFSET,
133            collect_threshold_offset: Heap::COLLECT_THRESHOLD_OFFSET,
134        }
135    }
136
137    /// Where the `Heap` pointer sits in a `RuntimeContext`. The first load of
138    /// the sequence, and the base the two pacing loads are relative to.
139    #[must_use]
140    pub const fn heap_offset(self) -> usize {
141        self.heap_offset
142    }
143
144    /// Where [`Heap::bytes_since_collect`] sits within a `Heap`.
145    ///
146    /// The predicate's left operand — and, for the claim sequence, the word it
147    /// loads a second time and stores back, once, at the end: the pacing charge,
148    /// which is not the predicate.
149    #[must_use]
150    pub const fn bytes_since_collect_offset(self) -> usize {
151        self.bytes_since_collect_offset
152    }
153
154    /// Where [`Heap::collect_threshold`] sits within a `Heap`.
155    ///
156    /// Generated code loads this word and the one above and takes the branch
157    /// `since >= threshold` to its cold path. That is
158    /// [`Heap::collection_is_due`] transcribed, and that function's doc is where
159    /// the obligation is written down.
160    #[must_use]
161    pub const fn collect_threshold_offset(self) -> usize {
162        self.collect_threshold_offset
163    }
164}
165
166/// Everything generated code may bake in to answer an interned scalar inline,
167/// and nothing else (ADR-113).
168///
169/// # What this makes unrepresentable
170///
171/// The Cranelift backend's inline sequence for `Inst::Materialize { Int }` is
172/// four displacements and three immediates: where `Heap` hangs off the context,
173/// where the two pacing words sit inside it, where the intern table's base
174/// pointer sits in the context, and the range and stride of the table itself.
175/// Six of those seven numbers name a **private** field of a `#[repr(C)]` struct
176/// in this crate. Handed to the backend as loose constants they would be six
177/// independent chances to pair the `Int` table's base with the `Char` table's
178/// bounds — a read past the end of a table whose length is the only thing
179/// keeping the probe in bounds.
180///
181/// So they are one value with private fields, and there is exactly one of it:
182/// [`crate::small_int::INLINE_INTERN_SITE`]. `InlineInternSite::new` is
183/// `pub(crate)`, so a site can only be minted inside this crate — and the one
184/// place it is minted is beside the range constants it describes, in the module
185/// whose doc already calls itself "the one statement of the range". "Inline-probe
186/// a table the backend has no right to probe" has no spelling, because there is
187/// no second value to name; a future `Char` arm (P-4a) mints its own in
188/// `small_char.rs`, next to *its* bounds, and cannot get `Int`'s by accident.
189///
190/// # And the half it cannot make unrepresentable
191///
192/// The pacing offsets are **not** arguments to `new`: it fills them from
193/// [`PacingOffsets::new`], which reads `Heap`'s own
194/// [`Heap::BYTES_SINCE_COLLECT_OFFSET`] and
195/// [`Heap::COLLECT_THRESHOLD_OFFSET`], so a site cannot exist that describes an
196/// intern table without also carrying the pacing predicate's operands. That is
197/// as far as a type can go. A type cannot force the backend to *emit* the
198/// compare — that claim is about an instruction stream, and it is carried by
199/// `an_inline_int_box_tests_the_pacing_counter_before_it_reads_the_table` in the
200/// backend, which reads the emitted IR.
201#[derive(Clone, Copy, Debug)]
202pub struct InlineInternSite {
203    pacing: PacingOffsets,
204    table_offset: usize,
205    min: i64,
206    span: u64,
207    stride_shift: u8,
208}
209
210impl InlineInternSite {
211    /// The site for an intern table whose base pointer sits at `table_offset`
212    /// within a [`RuntimeContext`](crate::RuntimeContext) and which holds one
213    /// object per value in `min..=max`, `stride` bytes apart.
214    ///
215    /// `pub(crate)`, for [`crate::immortal::ImmortalWitness`]'s reason: minting
216    /// is confined to the modules that own the ranges, so the set of tables
217    /// generated code may probe is a list this crate wrote rather than anything
218    /// a caller can assemble.
219    ///
220    /// # Panics
221    /// Panics at compile time (in a `const` context) if `max < min`, if the
222    /// range does not fit a `u64`, or if `stride` is not a power of two — the
223    /// three assumptions the emitted sequence's arithmetic rests on. Every call
224    /// is a `const` initializer, so "panics" here means "fails the build".
225    pub(crate) const fn new(
226        table_offset: usize,
227        min: i64,
228        max: i64,
229        stride: usize,
230    ) -> InlineInternSite {
231        assert!(min <= max, "an intern table's range runs upwards");
232        assert!(stride.is_power_of_two(), "the index scale must be a shift");
233        InlineInternSite {
234            // Not a parameter: a site that described a table but not the pacing
235            // predicate would be permission to skip the predicate, which is the
236            // one thing this type exists to withhold. `PacingOffsets::new`
237            // takes nothing, so there is nothing here to get wrong.
238            pacing: PacingOffsets::new(),
239            table_offset,
240            min,
241            // `max - min` as an unsigned width, which is the immediate the
242            // one-compare range test uses. See [`Self::span`].
243            span: max.wrapping_sub(min) as u64,
244            stride_shift: stride.trailing_zeros() as u8,
245        }
246    }
247
248    /// The pacing predicate's operands — the three displacements generated code
249    /// emits the compare from, before it looks at the value at all.
250    ///
251    /// The *same* value [`InlineClaimSite::pacing`] answers, which is what lets
252    /// the backend transcribe [`Heap::collection_is_due`] in one place. See
253    /// [`PacingOffsets`].
254    #[must_use]
255    pub const fn pacing(self) -> PacingOffsets {
256        self.pacing
257    }
258
259    /// Where the table's base pointer sits in a `RuntimeContext`.
260    #[must_use]
261    pub const fn table_offset(self) -> usize {
262        self.table_offset
263    }
264
265    /// The lowest interned value — the addend that turns a value into an index.
266    #[must_use]
267    pub const fn min(self) -> i64 {
268        self.min
269    }
270
271    /// `max - min`, as the unsigned bound of the **one** compare that decides
272    /// membership.
273    ///
274    /// Generated code tests `(value - min) as u64 <= span` rather than comparing
275    /// against `min` and `max` separately: in two's complement that single
276    /// unsigned compare is exactly `min <= value <= max` for every `i64`
277    /// including the wrapping ones, it reuses the subtract the index needs
278    /// anyway, and it costs one branch where the two-compare form costs two.
279    /// `crate::small_int`'s
280    /// `the_unsigned_range_test_generated_code_emits_answers_index_of` is the
281    /// proof, over the boundary values and both extremes of the type.
282    #[must_use]
283    pub const fn span(self) -> u64 {
284        self.span
285    }
286
287    /// `log2(stride)` — the shift that scales an index to a byte offset.
288    ///
289    /// A shift rather than a multiply because the stride is a pointer width and
290    /// a shift is the thing actually meant. `new` asserts the stride is a power
291    /// of two, so this is exact rather than approximate.
292    #[must_use]
293    pub const fn stride_shift(self) -> u8 {
294        self.stride_shift
295    }
296}
297
298/// Everything generated code may bake in to **claim and initialize a block**
299/// inline, and nothing else (ADR-119).
300///
301/// # What it makes unrepresentable
302///
303/// [`InlineInternSite`] confines *which table* the backend may probe. This
304/// confines something stronger: **which descriptors have a claim sequence at
305/// all.** [`InlineClaimSite::of`] is a `const fn` returning `Option`, and it
306/// answers `None` for a descriptor the sequence cannot reproduce the runtime's
307/// bookkeeping for —
308///
309/// - one that carries an [`owned_bytes`](TypeDescriptor::owned_bytes) callback,
310///   because `Heap::occupy` charges `stride + owned_bytes_of(payload)` against
311///   the pacing counter and the second term is a call the sequence has no way to
312///   make. Every scalar descriptor answers `None` to it; every `Text` and `Vec`
313///   answers `Some`, and those are exactly the descriptors this refuses;
314/// - one whose block [`SizeClass::of`] rejects, because a large page is claimed
315///   by a linear scan of `empty_large` keyed on the whole layout, which is not a
316///   bitmap claim in any sense.
317///
318/// Both refusals are `const`, so a `praxis-codegen-cranelift` arm that named a
319/// descriptor with an `owned_bytes` charge would fail to build rather than
320/// silently under-charge the collector — the failure mode ADR-113's "What was
321/// deliberately not done" identified as this path's whole risk. The one place a
322/// site is minted is [`crate::scalars`], beside the descriptors it describes.
323///
324/// # And the half it cannot make unrepresentable
325///
326/// The same half as [`InlineInternSite`]'s, and one more. It cannot force the
327/// backend to emit the pacing compare, to emit the stores in an order, or to
328/// emit all of them — those are claims about an instruction stream, and ADR-119
329/// decision 4 carries all three with tests that read the emitted Cranelift. What
330/// this type does is make the *numbers* one authority's, so that the tests are
331/// checking a shape rather than checking arithmetic.
332#[derive(Clone, Copy, Debug)]
333pub struct InlineClaimSite {
334    pacing: PacingOffsets,
335    heap_id_offset: usize,
336    heap_live_count_offset: usize,
337    partial_head_offset: usize,
338    page_cursor_offset: usize,
339    page_last_word_offset: usize,
340    page_allocated_offset: usize,
341    page_live_count_offset: usize,
342    header_descriptor_offset: usize,
343    header_payload_offset_offset: usize,
344    header_heap_id_offset: usize,
345    first_block: usize,
346    stride: usize,
347    payload_offset: usize,
348}
349
350impl InlineClaimSite {
351    /// The claim site for `descriptor`, or `None` if the inline sequence cannot
352    /// reproduce what [`Heap::alloc_raw`] would have done for it.
353    ///
354    /// See the type's doc for the two refusals and why each is total rather than
355    /// conservative.
356    pub(crate) const fn of(descriptor: &'static TypeDescriptor) -> Option<InlineClaimSite> {
357        // The charge `Heap::occupy` makes is `stride + owned_bytes_of(payload)`.
358        // The sequence can reproduce the first term (it is `class.block_size()`,
359        // a compile-time fact) and not the second (it is an indirect call
360        // through the descriptor, on a payload that does not exist yet). A
361        // descriptor with the callback therefore has no inline form, and this is
362        // the only place that is decided.
363        if descriptor.owned_bytes.is_some() {
364            return None;
365        }
366        let (payload_offset, block) = BlockLayout::of(descriptor);
367        let Some(class) = SizeClass::of(block) else {
368            return None;
369        };
370        let stride = class.block_size();
371        Some(InlineClaimSite {
372            // Not a parameter, for `InlineInternSite::new`'s reason: a site that
373            // described a block to claim but not the pacing predicate would be
374            // permission to skip the predicate, and skipping it is the one thing
375            // ADR-040's token exists to make unwritable.
376            pacing: PacingOffsets::new(),
377            heap_id_offset: core::mem::offset_of!(Heap, id),
378            heap_live_count_offset: core::mem::offset_of!(Heap, live_count),
379            // The class's availability-list head, folded: `partial` is an array
380            // and the index is a compile-time fact, so the backend names one
381            // displacement rather than an array base and a scale it could pair
382            // with the wrong class.
383            partial_head_offset: core::mem::offset_of!(Heap, partial)
384                + class.index() * core::mem::size_of::<Cell<*mut PageHeader>>(),
385            page_cursor_offset: PageHeader::CURSOR_OFFSET,
386            page_last_word_offset: PageHeader::LAST_WORD_OFFSET,
387            page_allocated_offset: PageHeader::ALLOCATED_OFFSET,
388            page_live_count_offset: PageHeader::LIVE_COUNT_OFFSET,
389            header_descriptor_offset: GcHeader::DESCRIPTOR_OFFSET,
390            header_payload_offset_offset: GcHeader::PAYLOAD_OFFSET_FIELD_OFFSET,
391            header_heap_id_offset: GcHeader::HEAP_ID_OFFSET,
392            first_block: PageHeader::first_block_of(stride),
393            stride,
394            payload_offset,
395        })
396    }
397
398    /// The pacing predicate's operands — the three displacements the guard in
399    /// front of this sequence is emitted from, and the one whose
400    /// `bytes_since_collect` the sequence loads a second time and stores back,
401    /// once, at the end.
402    ///
403    /// The *same* value [`InlineInternSite::pacing`] answers: see
404    /// [`Heap::collection_is_due`], which is the one statement of the predicate
405    /// those two words are the operands of, and [`PacingOffsets`], which is the
406    /// one value the backend transcribes it from.
407    #[must_use]
408    pub const fn pacing(self) -> PacingOffsets {
409        self.pacing
410    }
411
412    /// Where the owning [`HeapId`] sits within a `Heap` — the `u32` the sequence
413    /// copies into every header it writes.
414    #[must_use]
415    pub const fn heap_id_offset(self) -> usize {
416        self.heap_id_offset
417    }
418
419    /// Where `Heap::live_count` sits. One of the two counters ADR-119 decision 1
420    /// part 3 is about: sweep *decrements* it and never recomputes it, so a
421    /// claim that skips this bump underflows it on the first collection.
422    #[must_use]
423    pub const fn heap_live_count_offset(self) -> usize {
424        self.heap_live_count_offset
425    }
426
427    /// Where this descriptor's size class's availability-list head sits within a
428    /// `Heap`. A null here is the sequence's first bail-out: growing a class is
429    /// `Heap::grow_class`, which allocates a page.
430    #[must_use]
431    pub const fn partial_head_offset(self) -> usize {
432        self.partial_head_offset
433    }
434
435    /// Where `PageHeader::cursor` sits. The word the scan starts at, and — since
436    /// the inline sequence scans exactly one word — the word it claims from.
437    #[must_use]
438    pub const fn page_cursor_offset(self) -> usize {
439        self.page_cursor_offset
440    }
441
442    /// Where `PageHeader::last_word` sits. The sequence bails when
443    /// `cursor >= last_word`, which is both the "past the end" test
444    /// `claim_free_block`'s loop condition performs and the tail-word refusal
445    /// ADR-119 decision 3 measures — one compare doing both.
446    #[must_use]
447    pub const fn page_last_word_offset(self) -> usize {
448        self.page_last_word_offset
449    }
450
451    /// Where the `allocated` bitmap begins. Indexed by the cursor word, scaled
452    /// by eight.
453    #[must_use]
454    pub const fn page_allocated_offset(self) -> usize {
455        self.page_allocated_offset
456    }
457
458    /// Where `PageHeader::live_count` sits. The *other* counter of decision 1
459    /// part 3: `relink_pages` reads it to decide which availability list a page
460    /// joins, so a skipped bump puts a page holding live blocks on the empty
461    /// pool, where `reclass` hands its storage to another layout.
462    #[must_use]
463    pub const fn page_live_count_offset(self) -> usize {
464        self.page_live_count_offset
465    }
466
467    /// Where a [`GcHeader`]'s descriptor pointer sits. The first store, and the
468    /// one whose absence is unrecoverable — see ADR-119 decision 1's severity
469    /// ranking.
470    #[must_use]
471    pub const fn header_descriptor_offset(self) -> usize {
472        self.header_descriptor_offset
473    }
474
475    /// Where a [`GcHeader`]'s recorded payload displacement sits. A `u16`.
476    #[must_use]
477    pub const fn header_payload_offset_offset(self) -> usize {
478        self.header_payload_offset_offset
479    }
480
481    /// Where a [`GcHeader`]'s owning-heap id sits. A `u32`.
482    #[must_use]
483    pub const fn header_heap_id_offset(self) -> usize {
484        self.header_heap_id_offset
485    }
486
487    /// Byte offset of block 0 from a page's base, for this descriptor's class.
488    ///
489    /// Folded rather than loaded from `PageHeader::first_block`, because it is a
490    /// function of the stride alone and every page on this class's list has the
491    /// same one — [`PageHeader::first_block_of`] is the derivation, stated in
492    /// the module that owns the geometry.
493    #[must_use]
494    pub const fn first_block(self) -> usize {
495        self.first_block
496    }
497
498    /// The byte stride between blocks of this descriptor's class, which is also
499    /// exactly what `Heap::occupy` charges against the pacing counter for one of
500    /// them — the `owned_bytes` term being `None` is what
501    /// [`InlineClaimSite::of`] refused a descriptor for.
502    #[must_use]
503    pub const fn stride(self) -> usize {
504        self.stride
505    }
506
507    /// Where this descriptor's payload begins within its block, which is also
508    /// the value the header records. [`GcHeader::payload_offset_for`]'s answer,
509    /// carried beside the offset it is stored at so the two cannot be paired
510    /// wrongly.
511    #[must_use]
512    pub const fn payload_offset(self) -> usize {
513        self.payload_offset
514    }
515}
516
517/// A precise, non-moving GC heap (§12.1, ADR-011).
518///
519/// `#[repr(C)]` so the `RuntimeContext.heap` pointer offset is stable
520/// (Appendix B). Every mutable field is a [`Cell`]: the collector runs through a
521/// `&Heap` that the descriptor `trace` callbacks reborrow, so a `RefCell` would
522/// only buy a double-borrow panic that a scalar and a raw pointer cannot need —
523/// and would charge a borrow-flag round trip on the hottest path in the runtime.
524#[repr(C)]
525pub struct Heap {
526    /// This heap's identity, stamped into every header it allocates. The mark
527    /// phase compares it against a root's `heap_id` before touching anything
528    /// the header points at, so a root from another heap — or one whose storage
529    /// this heap has already swept — is rejected rather than traced.
530    id: HeapId,
531    /// How many collectable objects this heap holds — what [`HeapStats`]
532    /// reports.
533    ///
534    /// A running counter, not the length of any registry: immortals are not in
535    /// it, because [`Heap::alloc_immortal`] does not bump it, and sweep
536    /// decrements it by the blocks it actually reclaimed.
537    live_count: Cell<usize>,
538    /// Bytes allocated since the last collection. Used by [`Heap::maybe_collect`]
539    /// to trigger automatic collection on allocation pressure (§12.4). This is
540    /// the mechanism that makes "survives collection" observable from JIT'd
541    /// code: the alloc wrappers call `maybe_collect` with the current roots.
542    ///
543    /// A `Cell`, not a `RefCell`: a `usize` is `Copy`, so there is nothing to
544    /// borrow, and paying a borrow-flag round trip for it on the hottest path in
545    /// the runtime buys only a double-borrow panic that a scalar cannot need.
546    bytes_since_collect: Cell<usize>,
547    /// The threshold at/above which [`Heap::maybe_collect`] runs a collection.
548    /// Recomputed after each *paced* collection by [`Heap::pacer`]'s
549    /// [`Pacer::next_threshold`] from this value and [`Heap::live_bytes`]: the
550    /// ratchet doubles it up to a ceiling, and the live set can push it past
551    /// that ceiling when a program legitimately holds more (ADR-112).
552    ///
553    /// A `Cell` for [`Heap::bytes_since_collect`]'s reason.
554    collect_threshold: Cell<usize>,
555    /// Every page this heap owns, newest first, linked by `PageHeader::next`.
556    /// The only list that is exhaustive: sweep, `reset` and `Drop` all walk it,
557    /// and a page is on it from the moment it is created until the heap dies.
558    pages: Cell<*mut PageHeader>,
559    /// Per size class, the head of the list of pages that may still have a free
560    /// block, linked by `PageHeader::next_of_class`.
561    ///
562    /// Allocation takes the head, and drops it off this list the moment it
563    /// reports itself full. Sweep rebuilds all three availability lists from the
564    /// pages' own `live_count`s, which is what keeps a page from ever being on
565    /// two of them at once.
566    partial: [Cell<*mut PageHeader>; NUM_CLASSES],
567    /// Small pages holding nothing, awaiting a class.
568    ///
569    /// **This is what stands in for a free list.** A per-layout free list would
570    /// leave an emptied bucket as dead capital for every other layout — a
571    /// program that filled a heap with `Int`s and then with `Text`s would pay
572    /// for both. A page that empties is re-classed on demand instead, so
573    /// storage is reusable across layouts (RT-01).
574    empty: Cell<*mut PageHeader>,
575    /// Large pages holding nothing. Keyed on the whole layout rather than a
576    /// class, because that is exactly what the ladder rejected them for; the
577    /// list is empty in every real program, which is why a linear scan is the
578    /// right shape for it.
579    empty_large: Cell<*mut PageHeader>,
580    /// Pages flagged immortal, linked by `PageHeader::next_of_class`.
581    ///
582    /// A separate list rather than a single page because the immortals are not
583    /// one size class: `Unit` is a bare header and the interned small-`Int`
584    /// table ([`crate::small_int`]) is a thousand blocks of the next rung up.
585    immortal_pages: Cell<*mut PageHeader>,
586    /// Block bytes the last sweep found still live — the input the pacer's
587    /// mandatory term is computed from (ADR-112).
588    ///
589    /// **Block bytes only.** The `Box<str>` behind a `Text` and the `HashMap`
590    /// table behind a `Map` are charged to [`Heap::bytes_since_collect`] at
591    /// allocation, but they are not recoverable at sweep without an
592    /// `owned_bytes_of` call per *survivor* — which is precisely the O(live)
593    /// walk ADR-103 rules out. So this number under-counts, and it under-counts
594    /// in the safe direction: a smaller `live` makes the next threshold
595    /// smaller, so the collector runs **more** often, never less. It cannot
596    /// produce an unbounded heap; it can only cost time, and only on a program
597    /// whose live set is mostly owned bytes — where mark cost is O(live
598    /// *objects*), which is small by construction for exactly that shape.
599    ///
600    /// Immortals are excluded, for [`Heap::live_count`]'s reason and RT-04's:
601    /// an object no collection can reclaim exerts no pressure, so it must not
602    /// buy the program a larger budget either.
603    live_bytes: Cell<usize>,
604    /// How the next paced threshold is chosen. Not a `Cell` — see [`Pacer`].
605    pacer: Pacer,
606    /// The mark phase's grey set, kept across collections so the collector does
607    /// not allocate a buffer proportional to the live set on every one of them.
608    /// See [`Heap::mark`], which is where the reason is written down.
609    mark_worklist: RefCell<Vec<GcRef>>,
610}
611
612/// What ran a collection. Only allocation pressure grows the pacing threshold:
613/// a host that collects on a schedule is not evidence the program needs a
614/// larger budget between collections (RT-04).
615#[derive(Clone, Copy, PartialEq, Eq, Debug)]
616enum Trigger {
617    /// [`Heap::maybe_collect`] found the pacing counter at the threshold.
618    Paced,
619    /// A host called [`Heap::collect`] outright.
620    Explicit,
621}
622
623/// The size and alignment of one whole `[header|payload]` allocation — what a
624/// page must be able to hold. Not the payload's own layout: the payload's offset
625/// within the block is recomputed on every reuse, so two descriptors that split
626/// the same total differently still share a block.
627///
628/// Deliberately not `Hash`. Keying a free list on this costs a hash of a
629/// 16-byte key twice per object — once to find a bucket in [`Heap::alloc_raw`],
630/// once to file a swept block in [`Heap::sweep`] — which measured 34% of runtime
631/// on `collatz` and 33% on `primes`, ahead of the generated code
632/// (docs/handovers/21-where-the-time-goes.md §3.1). A block's page is a mask and
633/// its class is a subtraction, so no hash is needed; withholding the derive
634/// makes introducing one a compile error rather than a silent regression.
635#[derive(Clone, Copy, PartialEq, Eq, Debug)]
636pub(crate) struct BlockLayout {
637    pub(crate) size: usize,
638    pub(crate) align: usize,
639}
640
641impl BlockLayout {
642    /// The block `descriptor`'s objects occupy, and where their payload starts
643    /// within it. The single calculation both [`Heap::alloc_raw`] and
644    /// [`SizeClass::of`] read, so a block can only be placed on a page that
645    /// holds the layout it actually has.
646    ///
647    /// # Panics
648    /// Panics if the payload alignment exceeds what a `GcHeader` can record, or
649    /// if the total size overflows.
650    ///
651    /// `const` for [`SizeClass::of`]'s reason (ADR-119): the inline claim
652    /// sequence's stride and payload displacement come off this calculation in a
653    /// `const` initializer, so they are this function's answer at build time and
654    /// not a second derivation in the backend.
655    pub(crate) const fn of(descriptor: &TypeDescriptor) -> (usize, BlockLayout) {
656        let payload_align = descriptor.align();
657        let payload_offset = GcHeader::payload_offset_for(payload_align);
658        let size = match payload_offset.checked_add(descriptor.size()) {
659            Some(size) => size,
660            None => panic!("allocation size overflow"),
661        };
662        let header_align = std::mem::align_of::<GcHeader>();
663        let align = if payload_align > header_align {
664            payload_align
665        } else {
666            header_align
667        };
668        (payload_offset, BlockLayout { size, align })
669    }
670}
671
672/// The initial collection threshold (bytes). Small enough that the first
673/// collection runs early in a program's life (catching rooting bugs fast in
674/// tests), then grows under whichever [`Pacer`] the heap was built with.
675pub const INITIAL_COLLECT_THRESHOLD: usize = 1 << 16; // 64 KiB
676
677/// The ceiling on the *speculative* half of the pacing rule: six doublings of
678/// [`INITIAL_COLLECT_THRESHOLD`] and then no more (ADR-112, amended by ADR-129).
679///
680/// Chosen by measurement, not by derivation. The only ceiling-dependent cost is
681/// the *per-collection fixed cost*, which is why total sweep work is independent
682/// of this constant — and why the knee has to be re-measured whenever that fixed
683/// cost changes. On this tree 8 MiB costs 1.6% and 4 MiB costs 1.9%, and 4 MiB
684/// peaks 3.3× lower — the suite goes from 3.6× CPython's resident set to 1.1×
685/// (ADR-129's Measurements). A figure derived from physical RAM would be more
686/// principled, but this workspace has no platform code for `sysctl
687/// hw.memsize` / `sysconf(_SC_PHYS_PAGES)`, and a constant is honest and
688/// testable where a derivation would make every Praxis program's schedule depend
689/// on the machine that ran it.
690pub const MAX_COLLECT_THRESHOLD: usize = INITIAL_COLLECT_THRESHOLD << 6; // 4 MiB
691
692/// How many times the measured live set the threshold must leave room for.
693///
694/// `k = 2` means a program whose live set is *L* bytes may allocate another *L*
695/// bytes before the next collection, so the collector's marginal mark cost is
696/// capped at one mark of the live set per equal quantity of fresh allocation —
697/// and the resident set is bounded at `(1 + k) × live`, which is the whole
698/// deliverable of ADR-112 and is why this is 2 and not 4. Raising it to 4
699/// measurably buys back time on a mark-bound program and costs every program
700/// in the language a `5 × live` bound instead of a `3 × live` one; ADR-112's
701/// Measurements price both.
702pub const LIVE_HEADROOM: usize = 2;
703
704/// How [`Heap::collect_inner`] chooses the next paced collection threshold.
705///
706/// Fixed at construction (there is no `Cell`): a collector that could change
707/// its own schedule mid-run would make "when does this program collect" a
708/// function of history rather than of the heap it was built with, and every
709/// pacing test would become order-dependent.
710///
711/// Both arms exist in one binary so the A/B behind [`Pacer::from_env`] is a
712/// single build rather than two, and the branch is taken once per *collection*
713/// — never on the allocation path.
714#[derive(Clone, Copy, PartialEq, Eq, Debug)]
715pub enum Pacer {
716    /// ADR-011's original heuristic: `max(previous × 2, INITIAL)`, unbounded.
717    /// Retained as the measured-against arm; see ADR-112.
718    Doubling,
719    /// `max(min(previous × 2, ceiling), live × live_factor, INITIAL)`.
720    ///
721    /// Constructible only through [`Pacer::bounded`], which clamps both fields,
722    /// so "a ceiling below the first threshold" and "zero headroom" have no
723    /// spelling.
724    Bounded {
725        /// The largest the *ratchet* term may reach. It does **not** bound the
726        /// whole expression; see [`Pacer::next_threshold`].
727        ceiling: NonZeroUsize,
728        /// The multiple of the measured live set the threshold must leave room
729        /// for, whatever the ceiling says.
730        live_factor: NonZeroUsize,
731    },
732}
733
734impl Pacer {
735    /// What a [`Heap`] paces with when nothing says otherwise.
736    ///
737    /// One named constant rather than a literal at the sites that need it, so
738    /// "what does this workspace's collector actually do" has exactly one
739    /// answer to read.
740    pub const DEFAULT: Pacer = Pacer::bounded(MAX_COLLECT_THRESHOLD, LIVE_HEADROOM);
741
742    /// The bounded rule, with both parameters clamped into the range in which
743    /// they mean something.
744    ///
745    /// A ceiling below [`INITIAL_COLLECT_THRESHOLD`] is raised to it: the first
746    /// threshold is already `INITIAL`, so a lower ceiling would describe a
747    /// heap that had exceeded its own bound before its first allocation. A
748    /// `live_factor` of zero is raised to one: it would delete the mandatory
749    /// term, which is the whole anti-thrash half of the rule. Neither clamp is
750    /// a convenience — they are why the two illegal states have no spelling
751    /// (`a_bounded_pacer_cannot_be_built_with_a_ceiling_below_the_first_threshold`).
752    pub const fn bounded(ceiling: usize, live_factor: usize) -> Pacer {
753        // Written out rather than `usize::max`, which is not a `const fn`. This
754        // has to be `const` so `Pacer::DEFAULT` can be one, which is what keeps
755        // the shipped ceiling and factor readable as two named constants
756        // instead of as whatever `Heap::new` happens to pass.
757        let ceiling = if ceiling < INITIAL_COLLECT_THRESHOLD {
758            INITIAL_COLLECT_THRESHOLD
759        } else {
760            ceiling
761        };
762        let live_factor = if live_factor < 1 { 1 } else { live_factor };
763        Pacer::Bounded {
764            ceiling: match NonZeroUsize::new(ceiling) {
765                Some(ceiling) => ceiling,
766                None => panic!("clamped to at least INITIAL_COLLECT_THRESHOLD, which is non-zero"),
767            },
768            live_factor: match NonZeroUsize::new(live_factor) {
769                Some(factor) => factor,
770                None => panic!("clamped to at least 1, which is non-zero"),
771            },
772        }
773    }
774
775    /// The threshold the collection that just finished sets for the next one.
776    ///
777    /// `previous` is the threshold that was in force; `live` is the block bytes
778    /// [`Heap::sweep`] just measured.
779    ///
780    /// **The ceiling clamps the ratchet term only, and never the whole
781    /// expression.** `min(ceiling)` applied to the result would make a program
782    /// whose live set legitimately exceeds the ceiling collect on essentially
783    /// every allocation, which is a thrash bug and not a memory bound. The
784    /// ceiling bounds *speculative* growth — the part of the threshold that is
785    /// a guess about the future; `live × live_factor` is *mandatory* headroom,
786    /// a statement about the present, and it must be allowed to exceed the
787    /// ceiling. ADR-112 decision 2 is the argument, and
788    /// `a_bounded_pacer_gives_a_large_live_set_its_headroom` is the test that
789    /// fails if someone folds the ceiling over the max.
790    ///
791    /// The rule is monotonically non-decreasing up to the ceiling — once
792    /// `previous >= ceiling`, `min(previous × 2, ceiling) == ceiling` — so no
793    /// separate growth floor is needed: the ratchet-to-ceiling *is* the floor,
794    /// and it is what keeps a shrinking live set from dragging the threshold
795    /// back down toward it
796    /// (`a_shrinking_live_set_does_not_lower_the_threshold_below_the_ceiling`).
797    pub fn next_threshold(self, previous: usize, live: usize) -> usize {
798        match self {
799            Pacer::Doubling => previous.saturating_mul(2).max(INITIAL_COLLECT_THRESHOLD),
800            Pacer::Bounded {
801                ceiling,
802                live_factor,
803            } => previous
804                .saturating_mul(2)
805                .min(ceiling.get())
806                .max(live.saturating_mul(live_factor.get()))
807                .max(INITIAL_COLLECT_THRESHOLD),
808        }
809    }
810
811    /// The pacer every [`Heap::new`] is built with, read once per process from
812    /// `PRAXIS_GC_PACER`.
813    ///
814    /// **This is the only `std::env` read in any `src` file in this
815    /// workspace**, and ADR-112 decision 4 is why it earns that. Read through a
816    /// [`OnceLock`] so a process that mints several heaps — the debugger mints
817    /// a second one (ADR-032) — cannot have two of them disagree about the
818    /// collector's schedule, and so repeated `Heap::new` does not re-parse.
819    fn from_env() -> Pacer {
820        static PACER: OnceLock<Pacer> = OnceLock::new();
821        *PACER.get_or_init(|| Pacer::from_spec(std::env::var("PRAXIS_GC_PACER").ok().as_deref()))
822    }
823
824    /// [`Pacer::from_env`]'s parse, split out so it is testable without an
825    /// ambient environment.
826    ///
827    /// An unparseable value prints one line to stderr and falls back. A
828    /// *silent* fallback would let a typo in one arm of an A/B measure the
829    /// wrong build and report the result as if it were the right one, which is
830    /// the exact failure mode this knob exists to serve.
831    fn from_spec(spec: Option<&str>) -> Pacer {
832        let Some(spec) = spec else {
833            return Pacer::DEFAULT;
834        };
835        match Pacer::parse(spec) {
836            Ok(pacer) => pacer,
837            Err(reason) => {
838                eprintln!(
839                    "praxis: ignoring PRAXIS_GC_PACER={spec:?} ({reason}); \
840                     using the default pacer {:?}",
841                    Pacer::DEFAULT
842                );
843                Pacer::DEFAULT
844            }
845        }
846    }
847
848    /// The grammar: `doubling` | `bounded` | `bounded:<ceiling>` |
849    /// `bounded:<ceiling>:<k>`, where `<ceiling>` accepts a `K`/`M`/`G` suffix.
850    fn parse(spec: &str) -> Result<Pacer, String> {
851        let mut parts = spec.trim().split(':');
852        let head = parts.next().unwrap_or_default();
853        let pacer = match head {
854            "doubling" => Pacer::Doubling,
855            "bounded" => {
856                let ceiling = match parts.next() {
857                    Some(text) => {
858                        parse_bytes(text).ok_or_else(|| format!("{text:?} is not a byte count"))?
859                    }
860                    None => MAX_COLLECT_THRESHOLD,
861                };
862                let factor = match parts.next() {
863                    Some(text) => text
864                        .parse::<usize>()
865                        .map_err(|_| format!("{text:?} is not a live-set factor"))?,
866                    None => LIVE_HEADROOM,
867                };
868                Pacer::bounded(ceiling, factor)
869            }
870            other => return Err(format!("{other:?} is not a pacer")),
871        };
872        match parts.next() {
873            Some(extra) => Err(format!("trailing {extra:?}")),
874            None => Ok(pacer),
875        }
876    }
877}
878
879/// A byte count with an optional binary suffix: `65536`, `64K`, `8M`, `1G`.
880fn parse_bytes(text: &str) -> Option<usize> {
881    let (digits, scale) = match text.as_bytes().last()? {
882        b'k' | b'K' => (&text[..text.len() - 1], 1_usize << 10),
883        b'm' | b'M' => (&text[..text.len() - 1], 1_usize << 20),
884        b'g' | b'G' => (&text[..text.len() - 1], 1_usize << 30),
885        _ => (text, 1),
886    };
887    digits.parse::<usize>().ok()?.checked_mul(scale)
888}
889
890// SAFETY: the heap owns raw allocations that are only accessed through `GcRef`s
891// the caller keeps rooted. It is `Send` because the collector is
892// single-threaded (§12.1) and the heap is never shared across threads. It is
893// not `Sync`: no `&Heap` may alias across threads.
894unsafe impl Send for Heap {}
895
896/// Lightweight allocation statistics for tests and debugging.
897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
898pub struct HeapStats {
899    /// Number of live collectable allocations.
900    pub live_count: usize,
901    /// Block bytes the last sweep found still live. Zero on a heap that has
902    /// never collected, and block bytes only — see [`Heap::live_bytes`].
903    ///
904    /// On [`HeapStats`] rather than behind a `#[cfg(test)]` accessor because
905    /// the property it makes checkable — a long-running program's heap stops
906    /// growing — is an *end-to-end* property, and the test that says so belongs
907    /// where generated code runs (`praxis-codegen-cranelift`'s `jit.rs`), not
908    /// in this crate.
909    pub live_bytes: usize,
910}
911
912impl Heap {
913    /// Where [`Heap::bytes_since_collect`] and [`Heap::collect_threshold`] sit
914    /// within a `Heap`, for the one caller outside this crate that needs
915    /// them: the Cranelift backend, which loads both and compares them inline
916    /// (ADR-113).
917    ///
918    /// Exported from here, with `offset_of!`, for
919    /// [`GcHeader::DESCRIPTOR_OFFSET`](crate::GcHeader::DESCRIPTOR_OFFSET)'s
920    /// reason — the fields are **private** and this struct is their one layout
921    /// authority, so the alternative is a number written out in the backend that
922    /// nothing keeps true. `the_pacing_predicate_is_one_unsigned_compare_of_the_two_exported_words`
923    /// reads a live `Heap` through exactly these two displacements and asserts
924    /// the words it finds are the ones [`Heap::collection_is_due`] compares, so
925    /// the offsets and the predicate cannot drift apart.
926    ///
927    /// **This pair is the whole export surface, and its narrowness is
928    /// deliberate.** A pacer whose predicate needed a third term would have
929    /// nothing to hand the backend, which is the point at which whoever writes
930    /// it has to read [`Heap::collection_is_due`]'s doc.
931    pub const BYTES_SINCE_COLLECT_OFFSET: usize = core::mem::offset_of!(Heap, bytes_since_collect);
932    /// See [`Heap::BYTES_SINCE_COLLECT_OFFSET`].
933    pub const COLLECT_THRESHOLD_OFFSET: usize = core::mem::offset_of!(Heap, collect_threshold);
934
935    /// A fresh, empty heap, paced by [`Pacer::from_env`].
936    ///
937    /// No page is created here: the first allocation of a class creates that
938    /// class's first page. A heap that never allocates costs nothing, which
939    /// matters because the debugger mints a second one (ADR-032).
940    pub fn new() -> Self {
941        Heap::with_pacer(Pacer::from_env())
942    }
943
944    /// A fresh, empty heap paced by an explicit [`Pacer`].
945    ///
946    /// The door every pacing test goes through, so no test's result depends on
947    /// the ambient environment — including the debug-profile pass ADR-112's
948    /// Consequences requires, which runs the whole suite with
949    /// `PRAXIS_GC_PACER` set.
950    pub fn with_pacer(pacer: Pacer) -> Self {
951        Heap {
952            id: HeapId::mint(),
953            live_count: Cell::new(0),
954            bytes_since_collect: Cell::new(0),
955            collect_threshold: Cell::new(INITIAL_COLLECT_THRESHOLD),
956            pages: Cell::new(std::ptr::null_mut()),
957            partial: std::array::from_fn(|_| Cell::new(std::ptr::null_mut())),
958            empty: Cell::new(std::ptr::null_mut()),
959            empty_large: Cell::new(std::ptr::null_mut()),
960            immortal_pages: Cell::new(std::ptr::null_mut()),
961            live_bytes: Cell::new(0),
962            pacer,
963            mark_worklist: RefCell::new(Vec::new()),
964        }
965    }
966
967    /// Bytes of address space this heap's pages occupy.
968    ///
969    /// The number RT-01 is about: a program that allocates and collects a
970    /// bounded working set in a loop must not grow it.
971    pub fn committed_bytes(&self) -> usize {
972        self.walk_pages().map(|page| page.page_bytes()).sum()
973    }
974
975    /// How many pages this heap holds, live or pooled.
976    pub fn page_count(&self) -> usize {
977        self.walk_pages().count()
978    }
979
980    /// Every page this heap owns, in no particular order.
981    ///
982    /// A borrowing iterator rather than a raw loop at each call site: the pages
983    /// outlive any one borrow, so handing out `&PageHeader` bound to `&self` is
984    /// exactly the lifetime the callers want, and it keeps the `unsafe` in one
985    /// place.
986    fn walk_pages(&self) -> impl Iterator<Item = &PageHeader> {
987        let mut next = self.pages.get();
988        std::iter::from_fn(move || {
989            if next.is_null() {
990                return None;
991            }
992            // SAFETY: every page on this list was created by this heap and is
993            // released only by `Heap::drop`, which runs after every borrow of
994            // `self` has ended.
995            let page = unsafe { &*next };
996            next = page.next();
997            Some(page)
998        })
999    }
1000
1001    /// Thread a freshly created page onto the heap's page list.
1002    fn adopt(&self, page: *mut PageHeader) {
1003        // SAFETY: `page` was just created and nothing else names it.
1004        unsafe { (*page).set_next(self.pages.get()) };
1005        self.pages.set(page);
1006    }
1007
1008    /// Take a block of `block`'s layout, and report the stride it was taken at
1009    /// — which is what the pacing counter is charged, because it is what the
1010    /// heap actually spent.
1011    #[inline]
1012    fn claim_block(
1013        &self,
1014        descriptor: &'static TypeDescriptor,
1015        payload_offset: usize,
1016        block: BlockLayout,
1017    ) -> (*mut u8, usize) {
1018        let Some(class) = SizeClass::of(block) else {
1019            return (
1020                self.claim_large_block(descriptor, payload_offset, block),
1021                block.size,
1022            );
1023        };
1024        // Resolve the class's list head **once**. `class.index()` is below
1025        // `NUM_CLASSES` by construction but the optimizer cannot see that, so
1026        // indexing inside the loop would put a bounds check and its panic path
1027        // on the hottest instruction sequence in the runtime, twice per turn.
1028        let head_cell = self
1029            .partial
1030            .get(class.index())
1031            .expect("SizeClass::of yields an index below NUM_CLASSES");
1032        loop {
1033            let head = head_cell.get();
1034            if head.is_null() {
1035                self.grow_class(class);
1036                continue;
1037            }
1038            // SAFETY: a page on an availability list is one of this heap's own,
1039            // live until `Heap::drop`.
1040            let page = unsafe { &*head };
1041            match page.claim_free_block() {
1042                Some(base) => return (base, class.block_size()),
1043                // Full. Drop it off the availability list — sweep relinks it if
1044                // it ever frees anything, and until then re-scanning its bitmap
1045                // on every allocation would be the cost this design removes.
1046                None => head_cell.set(page.next_of_class()),
1047            }
1048        }
1049    }
1050
1051    /// Put a page of `class` at the head of its availability list: a pooled
1052    /// empty one if there is one, a fresh one otherwise.
1053    #[cold]
1054    #[inline(never)]
1055    fn grow_class(&self, class: SizeClass) {
1056        let page = match self.pop_empty() {
1057            Some(page) => {
1058                // SAFETY: a pooled page is live and holds nothing.
1059                unsafe { (*page).reclass(class) };
1060                page
1061            }
1062            None => {
1063                let page = PageHeader::new_small(class, self.id.get());
1064                self.adopt(page);
1065                page
1066            }
1067        };
1068        // SAFETY: `page` is live and on no availability list.
1069        unsafe { (*page).set_next_of_class(self.partial[class.index()].get()) };
1070        self.partial[class.index()].set(page);
1071    }
1072
1073    /// Pop a pooled empty page, if any.
1074    fn pop_empty(&self) -> Option<*mut PageHeader> {
1075        let page = self.empty.get();
1076        if page.is_null() {
1077            return None;
1078        }
1079        // SAFETY: a pooled page is one of this heap's own.
1080        self.empty.set(unsafe { (*page).next_of_class() });
1081        Some(page)
1082    }
1083
1084    /// The one block of a page laid out for exactly this layout — pooled if one
1085    /// is available, fresh otherwise.
1086    ///
1087    /// No production descriptor comes here; see [`PageHeader::new_large`].
1088    #[cold]
1089    #[inline(never)]
1090    fn claim_large_block(
1091        &self,
1092        descriptor: &'static TypeDescriptor,
1093        payload_offset: usize,
1094        block: BlockLayout,
1095    ) -> *mut u8 {
1096        let mut previous: *mut PageHeader = std::ptr::null_mut();
1097        let mut current = self.empty_large.get();
1098        while !current.is_null() {
1099            // SAFETY: a pooled page is one of this heap's own.
1100            let page = unsafe { &*current };
1101            if page.fits_large(payload_offset, block) {
1102                if previous.is_null() {
1103                    self.empty_large.set(page.next_of_class());
1104                } else {
1105                    // SAFETY: `previous` is the page we visited last.
1106                    unsafe { (*previous).set_next_of_class(page.next_of_class()) };
1107                }
1108                page.set_next_of_class(std::ptr::null_mut());
1109                page.rewind_cursor();
1110                return page
1111                    .claim_free_block()
1112                    .expect("an empty large page has its block");
1113            }
1114            previous = current;
1115            current = page.next_of_class();
1116        }
1117        let page = PageHeader::new_large(descriptor, payload_offset, block, self.id.get());
1118        self.adopt(page);
1119        // SAFETY: `page` was just created with one free block.
1120        unsafe { (*page).claim_free_block() }.expect("a fresh large page has its block")
1121    }
1122
1123    /// Rebuild the three availability lists from the pages' own liveness, and
1124    /// rewind every page's allocation cursor.
1125    ///
1126    /// Rebuilding rather than unlinking is what makes "a page is on at most one
1127    /// availability list" structural instead of a discipline four call sites
1128    /// have to keep. Membership is a function of `live_count`, and after a sweep
1129    /// every `live_count` is final.
1130    ///
1131    /// Rewinding the cursor is not cosmetic: it is what makes the next
1132    /// allocation of a class take the *lowest* free block, so the address a
1133    /// collection just reclaimed is the address the next object of that layout
1134    /// gets. `a_reclaimed_block_is_reused_for_the_next_object_of_its_layout`
1135    /// pins it, and a "resume where we left off" cursor would silently break it.
1136    fn relink_pages(&self) {
1137        for head in &self.partial {
1138            head.set(std::ptr::null_mut());
1139        }
1140        self.empty.set(std::ptr::null_mut());
1141        self.empty_large.set(std::ptr::null_mut());
1142        let mut current = self.pages.get();
1143        while !current.is_null() {
1144            // SAFETY: every page on this list is this heap's own.
1145            let page = unsafe { &*current };
1146            let next = page.next();
1147            if !page.is_immortal() {
1148                page.rewind_cursor();
1149                // An empty page joins the pool its geometry can be reused from;
1150                // a small page with room goes back to its class; a full page —
1151                // and a large page holding its one object — joins nothing, and
1152                // waits for a later sweep to free something.
1153                let list = if page.live_count() == 0 {
1154                    match page.class() {
1155                        Some(_) => Some(&self.empty),
1156                        None => Some(&self.empty_large),
1157                    }
1158                } else {
1159                    match page.class() {
1160                        Some(class) if (page.live_count() as usize) < page.block_count() => {
1161                            Some(&self.partial[class.index()])
1162                        }
1163                        _ => None,
1164                    }
1165                };
1166                match list {
1167                    Some(head) => {
1168                        page.set_next_of_class(head.get());
1169                        head.set(current);
1170                    }
1171                    None => page.set_next_of_class(std::ptr::null_mut()),
1172                }
1173            }
1174            current = next;
1175        }
1176    }
1177
1178    /// This heap's identity. Every header it allocates carries it.
1179    pub fn id(&self) -> HeapId {
1180        self.id
1181    }
1182
1183    /// Whether `value` was allocated by this heap and has not been swept.
1184    ///
1185    /// O(1): it reads the owning id out of the header, which is the same test
1186    /// the collector applies to every root, and the same one that licenses
1187    /// masking an address to find its page.
1188    #[inline]
1189    pub fn owns(&self, value: GcRef) -> bool {
1190        value.header().heap_id() == Some(self.id)
1191    }
1192
1193    /// Current allocation count, and what the last sweep measured.
1194    pub fn stats(&self) -> HeapStats {
1195        HeapStats {
1196            live_count: self.live_count.get(),
1197            live_bytes: self.live_bytes.get(),
1198        }
1199    }
1200
1201    /// Bytes charged against the pacing counter since the last collection.
1202    ///
1203    /// Test-only, and deliberately not part of [`HeapStats`]: pacing is the
1204    /// collector's own schedule and nothing outside this crate has any business
1205    /// reading it, let alone deciding from it. It is here so a sibling module's
1206    /// test can assert the RT-04 property that a *non*-collectable allocation
1207    /// leaves the schedule alone (see [`Heap::alloc_immortal`]).
1208    #[cfg(test)]
1209    pub(crate) fn bytes_since_collect(&self) -> usize {
1210        self.bytes_since_collect.get()
1211    }
1212
1213    /// Charge `bytes` of *owned* growth — a collection's backing buffer
1214    /// reallocating — against the pacing counter.
1215    ///
1216    /// # Why the spine is charged and not only the object
1217    ///
1218    /// [`Heap::alloc_raw`] charges `stride + owned_bytes_of(payload)` once, at
1219    /// construction. Growth *after* that point — a `push` that reallocates — has
1220    /// to be charged too, or a program whose memory is mostly *buffers* barely
1221    /// advances the counter at all: with scalars promoted out of the heap
1222    /// (ADR-121), the arithmetic feeding a `push` is not itself a paced
1223    /// allocation pacing the collector on the spine's behalf. Measured on `bfs`,
1224    /// whose adjacency lists are a `Vec` of `Vec`s: **41 collections with the
1225    /// spine charged against 6 without**, and a peak resident set of 61 MiB
1226    /// against 224, for an identical live set. So the pacer's input is the
1227    /// memory the program actually took rather than the share of it that
1228    /// happened to be shaped like an object.
1229    ///
1230    /// Cheap by construction: callers invoke this only on the reallocation path,
1231    /// which amortized doubling already makes rare, and it is a load, an add and
1232    /// a store. It deliberately does **not** collect — the caller decides where
1233    /// its safepoint is, and every one of them already polls
1234    /// [`Heap::maybe_collect`] on entry.
1235    pub fn charge_owned_growth(&self, bytes: usize) {
1236        self.bytes_since_collect
1237            .set(self.bytes_since_collect.get().saturating_add(bytes));
1238    }
1239
1240    /// Allocate an immortal object: same layout as [`Heap::alloc`], but on a
1241    /// page the collector never walks, so it is never reclaimed (§4.3). Used for
1242    /// the `Unit`/`Bool` singletons and the interned small-`Int` table.
1243    ///
1244    /// The exemption is a page flag: sweep and `finalize_all` do not read an
1245    /// immortal page's `allocated` bitmap at all, so there is no window in which
1246    /// an immortal is momentarily collectable and no scan whose cost grows with
1247    /// the heap.
1248    ///
1249    /// Restricted to [`Immortals::new`](crate::immortal::Immortals::new) by the
1250    /// [`ImmortalWitness`](crate::immortal::ImmortalWitness) it takes, which
1251    /// only that module can construct. The restriction is load-bearing twice
1252    /// over: an immortal is invisible to sweep *and* to [`Heap`]'s `Drop`, so
1253    /// every immortal payload must be `Copy` (nothing to finalize) and must be
1254    /// minted exactly once at startup. Minting one per call would be storage
1255    /// nothing ever reclaims (RT-03).
1256    ///
1257    /// **This allocation is pacing-neutral, and that is not an optimization.**
1258    /// [`Heap::alloc_raw`] charges every block against `bytes_since_collect`
1259    /// because pacing measures the pressure a program is putting on the
1260    /// collector (RT-04) — and an object no collection can ever reclaim exerts
1261    /// none: collecting harder does not give one byte of it back. Charging it
1262    /// anyway would make the immortal table a hidden GC-schedule change, because
1263    /// the interned small-`Int` table ([`crate::small_int`]) is ~40 KiB against a
1264    /// 64 KiB [`INITIAL_COLLECT_THRESHOLD`]: every program's *first* real
1265    /// allocation would arrive with two thirds of its budget already spent, and
1266    /// widening the interned range would move the first collection of every
1267    /// program in the language. So the counter is snapshotted and restored around
1268    /// the call rather than the charge being skipped inside `occupy`, which would
1269    /// need a flag on the one path every real allocation takes.
1270    pub(crate) fn alloc_immortal<T: Copy>(
1271        &self,
1272        payload: Payload<T>,
1273        value: T,
1274        _witness: crate::immortal::ImmortalWitness,
1275    ) -> GcRef {
1276        let descriptor = payload.descriptor();
1277        let (payload_offset, block) = BlockLayout::of(descriptor);
1278        let class = SizeClass::of(block).expect(
1279            "an immortal payload is a scalar, and the size-class ladder holds every scalar",
1280        );
1281        let charged_before = self.bytes_since_collect.get();
1282        let base = self.claim_immortal_block(class);
1283        // SAFETY: `base` is a fresh block of `class`, whose stride is at least
1284        // `block.size`; `T: Copy`, so writing the bytes fully initializes the
1285        // payload.
1286        let r = unsafe {
1287            self.occupy(
1288                base,
1289                class.block_size(),
1290                descriptor,
1291                payload_offset,
1292                |payload| (payload as *mut T).write(value),
1293            )
1294        };
1295        // Un-charge the block: see this function's doc. Restoring the snapshot
1296        // rather than subtracting the block size keeps this correct whatever
1297        // `occupy` decides an object costs (it also charges the descriptor's
1298        // owned bytes, which for a `Copy` immortal is zero today and need not
1299        // stay so).
1300        self.bytes_since_collect.set(charged_before);
1301        r
1302    }
1303
1304    /// A block on an immortal page of `class`, creating one if none has room.
1305    ///
1306    /// Linear over the immortal pages, which is right: there are three of them
1307    /// after `Immortals::new` and none is ever added afterwards, because
1308    /// [`ImmortalWitness`](crate::immortal::ImmortalWitness) confines minting to
1309    /// startup. They are not one class — `Unit` is a bare header and the
1310    /// interned `Int` table is a thousand blocks of the next rung up — which is
1311    /// why this is a list rather than the single page it would otherwise be.
1312    #[cold]
1313    #[inline(never)]
1314    fn claim_immortal_block(&self, class: SizeClass) -> *mut u8 {
1315        let mut current = self.immortal_pages.get();
1316        while !current.is_null() {
1317            // SAFETY: an immortal page is one of this heap's own.
1318            let page = unsafe { &*current };
1319            if page.class() == Some(class)
1320                && let Some(base) = page.claim_free_block()
1321            {
1322                return base;
1323            }
1324            current = page.next_of_class();
1325        }
1326        let page = PageHeader::new_small(class, self.id.get());
1327        // SAFETY: `page` was just created and nothing else names it.
1328        unsafe {
1329            (*page).set_immortal();
1330            (*page).set_next_of_class(self.immortal_pages.get());
1331        }
1332        self.adopt(page);
1333        self.immortal_pages.set(page);
1334        // SAFETY: a fresh page has room.
1335        unsafe { (*page).claim_free_block() }.expect("a fresh page has room")
1336    }
1337
1338    /// Give the collector a chance to run, and hand back the [`Safepoint`] that
1339    /// permits one allocation.
1340    ///
1341    /// This is the only producer of a `Safepoint`, and it is what makes
1342    /// "allocate without pacing" unwritable on the paced path: the token
1343    /// [`Heap::alloc`] demands cannot be obtained except by performing the
1344    /// [`Heap::maybe_collect`] that mints it, against the whole
1345    /// [`RuntimeRoots`].
1346    pub fn pace(&self, roots: &RuntimeRoots<'_>) -> Safepoint<'_> {
1347        self.maybe_collect(roots);
1348        Safepoint(PhantomData)
1349    }
1350
1351    /// Allocate an object with the given descriptor and a `Copy` payload `value`,
1352    /// returning a reference to it.
1353    ///
1354    /// Takes the [`Safepoint`] minted by [`Heap::pace`]: an allocation on this
1355    /// path has necessarily given the collector its chance. For payloads that
1356    /// own Rust resources (`Box<str>`, `VecPayload`) use [`Heap::alloc_with`],
1357    /// which writes the value via `ptr::write` so its `Drop` later runs
1358    /// correctly.
1359    ///
1360    /// The descriptor arrives as a [`Payload<T>`], so "this value is not that
1361    /// descriptor's payload" is a type error at the call rather than an assert
1362    /// here.
1363    pub fn alloc<T: Copy>(
1364        &self,
1365        _safepoint: Safepoint<'_>,
1366        payload: Payload<T>,
1367        value: T,
1368    ) -> GcRef {
1369        self.alloc_unpaced(payload, value)
1370    }
1371
1372    /// Allocate an object whose payload owns Rust resources, initializing it
1373    /// with `init`. `init` receives a pointer to the uninitialized payload bytes
1374    /// and must fully initialize them.
1375    ///
1376    /// This path keeps its runtime layout assertions, deliberately: `init` is a
1377    /// closure writing through a `*mut u8`, so there is no payload type for a
1378    /// [`Payload<T>`] to carry, as there is on the `Copy` path. Every caller
1379    /// here writes a specific non-`Copy` payload — a `Box<str>`, a `VecPayload`
1380    /// — and passes that type's own `size_of`/`align_of`.
1381    ///
1382    /// Reach for [`Heap::alloc_payload`] instead unless `init` genuinely needs
1383    /// the raw pointer: it derives both numbers *and* the write from the payload
1384    /// type, leaving nothing for a caller to keep in agreement.
1385    ///
1386    /// # Safety
1387    /// `init` must initialize the payload in place and must not panic after
1388    /// partial initialization (if it does, the payload's `Drop` will not run,
1389    /// leaking the partially-initialized resources). The descriptor's `size`/
1390    /// `align` must match the value `init` writes.
1391    pub unsafe fn alloc_with(
1392        &self,
1393        _safepoint: Safepoint<'_>,
1394        descriptor: &'static TypeDescriptor,
1395        size: usize,
1396        align: usize,
1397        init: impl FnOnce(*mut u8),
1398    ) -> GcRef {
1399        // SAFETY: forwarded from the caller's contract above.
1400        unsafe { self.alloc_with_unpaced(descriptor, size, align, init) }
1401    }
1402
1403    /// [`Heap::alloc_with`] for a payload the caller can hand over **by value**:
1404    /// the size, the alignment and the write are all derived from `P`.
1405    ///
1406    /// This is the shape a non-`Copy` allocation wants. `alloc_with` takes the
1407    /// layout as two loose numbers and the write as a closure over a `*mut u8`,
1408    /// so every caller names its payload type three times and nothing but the
1409    /// assertions in [`Heap::alloc_with_unpaced`] holds the three together. Here
1410    /// it is named once and the compiler derives the rest — what [`Payload<T>`]
1411    /// does for the `Copy` path, carried as far as a payload that owns Rust
1412    /// resources can carry it.
1413    ///
1414    /// # Safety
1415    /// `descriptor` must be `P`'s own descriptor. A mismatched *layout* is
1416    /// caught by [`Heap::alloc_with_unpaced`]'s assertions; a same-layout
1417    /// mismatch is not, and the descriptor's `drop_value`, `trace` and `format`
1418    /// callbacks are dispatched against these bytes.
1419    pub unsafe fn alloc_payload<P>(
1420        &self,
1421        safepoint: Safepoint<'_>,
1422        descriptor: &'static TypeDescriptor,
1423        payload: P,
1424    ) -> GcRef {
1425        // SAFETY: writing an owned `P` initializes the payload completely and
1426        // cannot panic partway, so `alloc_with`'s contract holds by
1427        // construction, and the layout passed is `P`'s own.
1428        unsafe {
1429            self.alloc_with(
1430                safepoint,
1431                descriptor,
1432                std::mem::size_of::<P>(),
1433                std::mem::align_of::<P>(),
1434                |p| (p as *mut P).write(payload),
1435            )
1436        }
1437    }
1438
1439    /// [`Heap::alloc`] **without** pacing the collector.
1440    ///
1441    /// The heap grows by this allocation and nothing here gives the collector a
1442    /// chance to reclaim; something else must pace, or the heap grows until it
1443    /// does. **One** caller legitimately cannot pace, and it is the only one:
1444    /// the host's own `Runtime::alloc_*` helpers, which hold their results in
1445    /// Rust locals that no root set can see, so a collection *here* would
1446    /// reclaim the value being returned.
1447    ///
1448    /// Do not add a second caller: the argument that justifies this one is "no
1449    /// root set can see my locals", and the answer to that is a `NativeScope`
1450    /// (ADR-040 Decision 3), not this.
1451    ///
1452    /// A `praxis_*` wrapper must never use this: generated code roots what it
1453    /// holds across a call the manifest declares `Allocates`, which is exactly
1454    /// what makes the paced path safe there.
1455    pub(crate) fn alloc_unpaced<T: Copy>(&self, payload: Payload<T>, value: T) -> GcRef {
1456        // SAFETY: `T: Copy`, so writing the bytes is sufficient initialization
1457        // (no `Drop` to run later); and `Payload<T>` is the descriptor's own
1458        // payload type, so the bytes fit the block `alloc_raw` lays out.
1459        unsafe { self.alloc_raw(payload.descriptor(), |p| (p as *mut T).write(value)) }
1460    }
1461
1462    /// [`Heap::alloc_with`] **without** pacing the collector. See
1463    /// [`Heap::alloc_unpaced`] for who may call this and why.
1464    ///
1465    /// # Safety
1466    /// As [`Heap::alloc_with`].
1467    pub(crate) unsafe fn alloc_with_unpaced(
1468        &self,
1469        descriptor: &'static TypeDescriptor,
1470        size: usize,
1471        align: usize,
1472        init: impl FnOnce(*mut u8),
1473    ) -> GcRef {
1474        assert_eq!(
1475            size,
1476            descriptor.size(),
1477            "payload size mismatch for descriptor {}",
1478            descriptor.name
1479        );
1480        assert_eq!(
1481            align,
1482            descriptor.align(),
1483            "payload align mismatch for descriptor {}",
1484            descriptor.name
1485        );
1486        // SAFETY: forwarded from the caller's contract above.
1487        unsafe { self.alloc_raw(descriptor, init) }
1488    }
1489
1490    /// [`Heap::alloc_payload`] **without** pacing the collector. See
1491    /// [`Heap::alloc_unpaced`] for who may call this and why.
1492    ///
1493    /// # Safety
1494    /// As [`Heap::alloc_payload`].
1495    pub(crate) unsafe fn alloc_payload_unpaced<P>(
1496        &self,
1497        descriptor: &'static TypeDescriptor,
1498        payload: P,
1499    ) -> GcRef {
1500        // SAFETY: as `alloc_payload` — an owned `P` written by value initializes
1501        // the payload completely, and the layout passed is `P`'s own.
1502        unsafe {
1503            self.alloc_with_unpaced(
1504                descriptor,
1505                std::mem::size_of::<P>(),
1506                std::mem::align_of::<P>(),
1507                |p| (p as *mut P).write(payload),
1508            )
1509        }
1510    }
1511
1512    /// The shared low-level allocator: take a block from a page, lay out
1513    /// `[GcHeader | payload]` in it, and run `init` on the payload. Claiming the
1514    /// block *is* the registration — the page's `allocated` bit is what sweep
1515    /// enumerates.
1516    ///
1517    /// The whole fast path is a load of the class's partial-page pointer, one
1518    /// bitmap word, an `andnot`, a `trailing_zeros`, a bitmap store, the header
1519    /// store, the payload `init` and two counter bumps. No hash, no registry
1520    /// push, no reallocation, no `RefCell` borrow.
1521    ///
1522    /// **Generated code reproduces that sequence inline (ADR-119)**, for the two
1523    /// descriptors [`InlineClaimSite::of`] admits, behind the same pacing branch
1524    /// [`Heap::collection_is_due`] states. A change to what this function writes
1525    /// — a third counter, a header field, a different charge — is a change the
1526    /// Cranelift backend's `emit_inline_claim` owes too, and the test that
1527    /// notices is `the_inline_claim_writes_every_word_the_wrapper_would`, which
1528    /// asserts the emitted store list against the displacements the site
1529    /// carries.
1530    ///
1531    /// # Safety
1532    /// `init` must fully initialize `descriptor.size` bytes of the payload and
1533    /// the bytes must be valid as the descriptor's payload type thereafter.
1534    unsafe fn alloc_raw(
1535        &self,
1536        descriptor: &'static TypeDescriptor,
1537        init: impl FnOnce(*mut u8),
1538    ) -> GcRef {
1539        // Where the payload starts is `GcHeader::payload_offset_for`'s decision
1540        // and nobody else's — the same call the header records and `payload()`
1541        // reads back — and the block that holds it is `BlockLayout::of`'s, the
1542        // same call `SizeClass::of` chooses a page from.
1543        let (payload_offset, block) = BlockLayout::of(descriptor);
1544        let (base, stride) = self.claim_block(descriptor, payload_offset, block);
1545        self.live_count.set(self.live_count.get() + 1);
1546        // SAFETY: `base` is a fresh block of at least `block.size` bytes,
1547        // aligned for a `GcHeader` and for this descriptor's payload; `init`'s
1548        // contract is forwarded from this function's.
1549        unsafe { self.occupy(base, stride, descriptor, payload_offset, init) }
1550    }
1551
1552    /// Head a claimed block with `descriptor`, initialize its payload, and
1553    /// charge the allocation against the pacing counter.
1554    ///
1555    /// Shared by [`Heap::alloc_raw`] and [`Heap::alloc_immortal`], which differ
1556    /// only in which page the block came from and in whether the collector will
1557    /// ever look at it again. Keeping one body is what stops the immortal path
1558    /// from drifting away from the layout every other object has — the whole
1559    /// point of an immortal is that its accessors work on it unchanged.
1560    ///
1561    /// # Safety
1562    /// `base` must be an unclaimed block of at least `payload_offset +
1563    /// descriptor.size()` bytes, aligned for a `GcHeader` and for the payload;
1564    /// `init` must fully initialize the payload as `descriptor`'s type.
1565    unsafe fn occupy(
1566        &self,
1567        base: *mut u8,
1568        stride: usize,
1569        descriptor: &'static TypeDescriptor,
1570        payload_offset: usize,
1571        init: impl FnOnce(*mut u8),
1572    ) -> GcRef {
1573        // Unreachable in practice — a payload aligned past a page's reach is
1574        // rejected by `PageHeader::new_large` before it gets here — but it is
1575        // the header field's own bound, and ADR-039's Consequences say the
1576        // allocator panics naming the descriptor rather than truncating.
1577        let recorded_offset = u16::try_from(payload_offset).unwrap_or_else(|_| {
1578            panic!(
1579                "payload alignment {} of descriptor {} exceeds the \
1580                 largest offset a GcHeader can record",
1581                descriptor.align(),
1582                descriptor.name
1583            )
1584        });
1585        let header_ptr = base as *mut GcHeader;
1586        // SAFETY: the block is at least `payload_offset + size` bytes.
1587        let payload_ptr = unsafe { base.add(payload_offset) };
1588
1589        // Write the header. Mark starts white (unscanned).
1590        // SAFETY: `base` is an unclaimed, header-aligned block.
1591        unsafe {
1592            std::ptr::write(
1593                header_ptr,
1594                GcHeader::new(descriptor, recorded_offset, self.id),
1595            );
1596        }
1597        // Initialize the payload.
1598        init(payload_ptr);
1599
1600        // Account for the allocation against the collection pacing counter.
1601        // Reused storage counts too: pacing measures the pressure a program is
1602        // putting on the collector, not the heap's high-water mark. The stride
1603        // is what a block costs — a class rounds up to it, and that rounding is
1604        // real memory the program spent.
1605        //
1606        // The block is only part of what the object costs. A `Text` is 48 bytes
1607        // of block and a `Box<str>` of whatever length the program read; a
1608        // freshly built `Vec` is 48 bytes and a buffer of `capacity` refs. The
1609        // descriptor measures the rest, so a text-heavy program does not
1610        // under-report its pressure by essentially its whole footprint (RT-04).
1611        // Growth *after* this point — a `push` that reallocates — is charged by
1612        // `Heap::charge_owned_growth`, called from the reallocation path itself.
1613        // SAFETY: `init` has run, so the payload is a valid value of `descriptor`.
1614        let owned = unsafe { descriptor.owned_bytes_of(payload_ptr) };
1615        self.bytes_since_collect
1616            .set(self.bytes_since_collect.get() + stride.saturating_add(owned));
1617        // SAFETY: `header_ptr` is inside a live page, so it is non-null, and it
1618        // has just been initialized.
1619        unsafe { GcRef::from_non_null(NonNull::new_unchecked(header_ptr)) }
1620    }
1621
1622    /// Run a mark-and-sweep collection (§12.1, ADR-011).
1623    ///
1624    /// Every `GcRef` reachable from `roots` (plus everything transitively
1625    /// reachable through descriptor `trace` callbacks) is marked black and
1626    /// survives; everything else is finalized via `drop_value` and reclaimed.
1627    ///
1628    /// `roots` is passed twice, as both the strong and the weak set, because a
1629    /// [`RuntimeRoots`] is both: five arms say what must survive and a sixth
1630    /// says what must be told when something did not (ADR-106). The sealed set
1631    /// being the source of both is what keeps them from disagreeing about which
1632    /// collection they belong to.
1633    pub fn collect(&self, roots: &RuntimeRoots<'_>) {
1634        self.collect_inner(roots, roots, Trigger::Explicit);
1635    }
1636
1637    /// [`Heap::collect`] against an arbitrary root set.
1638    ///
1639    /// Test-only: production collection roots from a
1640    /// [`RuntimeRoots`](crate::roots::RuntimeRoots), which is constructible
1641    /// only from a live `RuntimeContext` and is exhaustive over the runtime's
1642    /// owners. Accepting a `&dyn RootSet` there would let the automatic
1643    /// collector run against a partial root set — the shadow chain alone.
1644    ///
1645    /// The weak set is `()`: a bare `RootScope` has no debug frames behind it,
1646    /// so there is nothing to clear. [`Heap::collect_with_weak`] is for the
1647    /// tests that do have one.
1648    #[cfg(test)]
1649    pub fn collect_with(&self, roots: &dyn RootSet) {
1650        self.collect_inner(roots, &(), Trigger::Explicit);
1651    }
1652
1653    /// [`Heap::collect_with`] against an explicit weak set as well.
1654    ///
1655    /// Test-only. Production collection takes both from one `RuntimeRoots`, so
1656    /// the two cannot describe different runtimes; this is how an in-crate test
1657    /// drives the weak path without building a whole context.
1658    #[cfg(test)]
1659    pub fn collect_with_weak(&self, roots: &dyn RootSet, weak: &dyn WeakSet) {
1660        self.collect_inner(roots, weak, Trigger::Explicit);
1661    }
1662
1663    /// [`Heap::maybe_collect`] against an arbitrary root set. Test-only, and
1664    /// the counterpart of [`Heap::collect_with`]: it is the only way an
1665    /// in-crate test can exercise the *paced* path, which is the one that grows
1666    /// the threshold.
1667    #[cfg(test)]
1668    pub fn maybe_collect_with(&self, roots: &dyn RootSet) -> bool {
1669        let should = self.collection_is_due();
1670        if should {
1671            self.collect_inner(roots, &(), Trigger::Paced);
1672        }
1673        should
1674    }
1675
1676    fn collect_inner(&self, roots: &dyn RootSet, weak: &dyn WeakSet, trigger: Trigger) {
1677        self.mark(roots);
1678        self.sweep();
1679        // Step 3, and its position is the decision (ADR-106 decision 2). After
1680        // the sweep, so every block this collection reclaimed is poisoned and
1681        // therefore recognisable; before anything else can allocate, because the
1682        // first `claim_free_block` to reissue one of those blocks writes a live
1683        // header over the poison and the weak set's entry silently becomes a
1684        // reference to an object of another type. Between those two points is
1685        // the only place the question "did this die?" has an answer, and this is
1686        // that place.
1687        weak.clear_reclaimed();
1688        self.bytes_since_collect.set(0);
1689        // Re-pace — but only when *pacing* was what ran this collection.
1690        //
1691        // Growing the threshold on an explicit collection too would let a host
1692        // that collects on a schedule (the debugger between REPL commands, a
1693        // test between phases) push the automatic threshold up without any
1694        // allocation pressure having caused it, until after a few such calls
1695        // the program is effectively running without a collector (RT-04).
1696        //
1697        // `self.sweep()` ran two lines up, so `live_bytes` is the live set
1698        // *this* collection measured rather than the previous one's — which is
1699        // the whole reason the pacer can be a pure function of two numbers
1700        // (ADR-112 decision 1).
1701        if trigger == Trigger::Paced {
1702            self.collect_threshold.set(
1703                self.pacer
1704                    .next_threshold(self.collect_threshold.get(), self.live_bytes.get()),
1705            );
1706        }
1707    }
1708
1709    /// Run a collection if allocation pressure has reached the threshold,
1710    /// rooting from `roots`. Reached from every allocation through
1711    /// [`Heap::pace`] (§12.4), so collection happens automatically inside JIT'd
1712    /// code — this is what makes "nested vectors survive collection" (§19)
1713    /// testable without the host forcing it.
1714    ///
1715    /// Returns `true` if a collection ran.
1716    pub fn maybe_collect(&self, roots: &RuntimeRoots<'_>) -> bool {
1717        let should = self.collection_is_due();
1718        if should {
1719            self.collect_inner(roots, roots, Trigger::Paced);
1720        }
1721        should
1722    }
1723
1724    /// Has allocation pressure reached the threshold? — **the one statement of
1725    /// the pacing predicate**, and the second-most-copied line in the runtime
1726    /// (ADR-113).
1727    ///
1728    /// [`Heap::maybe_collect`] and [`Heap::maybe_collect_with`] are its two
1729    /// callers here. Its third reader is not in this crate and cannot call it:
1730    /// the Cranelift backend **reproduces this expression inline** (ADR-113)
1731    /// in every `Inst::Materialize { Int }` it emits — two loads at
1732    /// [`Heap::BYTES_SINCE_COLLECT_OFFSET`] and
1733    /// [`Heap::COLLECT_THRESHOLD_OFFSET`], an unsigned compare, and a branch to
1734    /// a cold block that calls `praxis_alloc_int` — so that the overwhelmingly
1735    /// common case (a loop counter inside [`crate::small_int`]'s range, on a
1736    /// heap that is nowhere near its threshold) is a table read rather than a
1737    /// guarded call into this module.
1738    ///
1739    /// # The obligation, and what a third term would cost
1740    ///
1741    /// ADR-040's [`Safepoint`] exists so that "allocate on the paced path
1742    /// without pacing" has no spelling. Generated code does not breach it,
1743    /// because it takes that path **only** where this function answers `false`,
1744    /// which is exactly the branch on which `maybe_collect` returns without
1745    /// doing anything. That is the entire argument, and it holds only while this
1746    /// expression is what the backend emits.
1747    ///
1748    /// **The branch this guards also allocates** (ADR-119): generated code
1749    /// claims a block and writes its header on the far side of it. What makes
1750    /// that sound is that between this branch and the last store there is no
1751    /// call, so *not due here* is *not due throughout*. See [`Safepoint`], which
1752    /// states all three parts.
1753    ///
1754    /// So: **a term added here must be added to `emit_pacing_test` in
1755    /// `crates/praxis-codegen-cranelift/src/lower.rs`, or generated code
1756    /// allocates on a branch where the collector was due.** That is the one
1757    /// place the backend transcribes this expression — `emit_inline_intern` and
1758    /// `emit_inline_claim_box` both call it — and [`PacingOffsets`] is the one
1759    /// value it reads the displacements off. The failure mode is not a wrong
1760    /// answer — it is a collection that silently does not happen, which looks
1761    /// like a memory leak in a program the reader will not connect to a pacer
1762    /// change. Two things fire when it is forgotten:
1763    /// `the_pacing_predicate_is_one_unsigned_compare_of_the_two_exported_words`
1764    /// below, which compares this function's answer against the two words the
1765    /// backend loads, and the deliberately narrow export surface — a third term
1766    /// has no offset constant to be baked from, so writing one is a decision
1767    /// rather than an oversight.
1768    ///
1769    /// It is `pub` because it is part of that contract, not an implementation
1770    /// detail: what the backend inlines should be nameable, and a host that
1771    /// wants to know whether the next allocation will collect should ask this
1772    /// rather than reconstruct it.
1773    #[inline]
1774    #[must_use]
1775    pub fn collection_is_due(&self) -> bool {
1776        self.bytes_since_collect.get() >= self.collect_threshold.get()
1777    }
1778
1779    /// Mark phase: set the page bit of every reachable object.
1780    fn mark(&self, roots: &dyn RootSet) {
1781        // **The grey set is reused across collections, and that is a memory
1782        // decision rather than a speed one.**
1783        //
1784        // A fresh `Vec::new()` per collection would be grown by doubling to the
1785        // size of the transitive closure and dropped at the end of the phase. On
1786        // `pipeline`'s 1M-element working set that is an 8 MiB buffer reached
1787        // through the whole doubling ladder — 1, 2, 4, 8 — and freed again,
1788        // sixty-odd times in one run. macOS's allocator does not return large
1789        // freed regions to the OS promptly; it caches them, and `vmmap` measured
1790        // **64 cached `MALLOC_LARGE (empty)` regions holding 489 MiB** against
1791        // ~84 MiB of live malloc bytes — none of that half-gigabyte in use, all
1792        // of it resident, and `peak_rss` counts resident. So the collector does
1793        // not allocate a buffer proportional to the live set on every
1794        // collection, whatever the compiler above it is doing.
1795        //
1796        // `clear()` keeps the capacity, so after the first collection the mark
1797        // phase allocates nothing at all. The retained buffer is bounded by the
1798        // largest transitive closure the program has ever had — never more than
1799        // the live set, which the heap is already holding.
1800        //
1801        // A `RefCell` rather than the `Cell` the rest of this struct uses,
1802        // because a `Vec` is not `Copy`. Collection is not re-entrant, so the
1803        // borrow cannot overlap; if some future tracer callback made it
1804        // re-entrant, `borrow_mut` panics loudly rather than corrupting the grey
1805        // set, which is the right failure for a collector invariant.
1806        let mut worklist = self.mark_worklist.borrow_mut();
1807        worklist.clear();
1808        roots.push_roots(&mut worklist);
1809
1810        // The tracer enqueues child references onto the worklist. The grey set
1811        // *is* this worklist — a transient grey colour would say nothing extra
1812        // in a single-threaded collector with no concurrency, which is why the
1813        // header has no third colour and no byte for one.
1814        struct Enqueuer<'a>(&'a mut Vec<GcRef>);
1815        impl Tracer for Enqueuer<'_> {
1816            fn trace(&mut self, reference: GcRef) {
1817                self.0.push(reference);
1818            }
1819        }
1820
1821        while let Some(r) = worklist.pop() {
1822            let header = r.header();
1823            // (a) Provenance check, before anything the header points at is
1824            // read, and before the address is masked. A reference this heap did
1825            // not allocate is not this heap's to colour: marking a foreign
1826            // object delays *its* heap's reclamation of it, and a swept
1827            // object's descriptor is a null pointer into finalized storage.
1828            // Both are rejected here (ADR-039 Decision 2).
1829            //
1830            // **This check is also what makes the mask below sound.** Only this
1831            // heap's allocator writes this heap's id into a header, and it only
1832            // ever writes one into a block on one of this heap's pages — so a
1833            // header that passes here is inside a page, and `page_of` is
1834            // arithmetic on an address whose provenance is already established.
1835            // A `GcHeader` that lives anywhere else (a test fixture, another
1836            // heap's object) carries an id this test rejects.
1837            if header.heap_id() != Some(self.id) {
1838                continue;
1839            }
1840            // (b) The mark bit lives in the page, not in the header: sweep's
1841            // per-survivor "reset to white" store — a random-access write per
1842            // live object per collection — becomes one store per 64 blocks.
1843            let address = r.as_ptr() as *const u8;
1844            // SAFETY: (a) established that this heap allocated this block, so
1845            // masking its address yields one of this heap's own live pages.
1846            let page = unsafe { &*page::page_of(address) };
1847            debug_assert_eq!(page.heap_id(), self.id.get());
1848            let index = page.block_index(address);
1849            debug_assert!(page.is_allocated(index), "a live header on a free block");
1850            // Set the bit first, *then* trace, so the descriptor's `trace`
1851            // callback may enqueue children that point back to this object
1852            // without re-tracing it infinitely.
1853            if page.test_and_set_mark(index) {
1854                continue;
1855            }
1856            let desc = header.descriptor();
1857            let payload = r.payload::<u8>();
1858            let mut enq = Enqueuer(&mut worklist);
1859            // SAFETY: `r` is a live, reachable object whose payload matches its
1860            // descriptor.
1861            unsafe { (desc.trace)(payload, &mut enq) };
1862        }
1863    }
1864
1865    /// Sweep phase: finalize every allocated-but-unmarked block, release it for
1866    /// reuse, and clear the mark bitmap for the next cycle.
1867    ///
1868    /// A page in which nothing died costs one `alive & !marked == 0` test and at
1869    /// most two stores per 64 blocks, so sweep never touches a survivor —
1870    /// neither to test its colour nor to reset it.
1871    ///
1872    /// It also measures the live set in bytes, for the pacer (ADR-112). That
1873    /// costs **one multiply per page** — `live_count × block_size`, both of
1874    /// which the page already knows and neither of which is on a survivor — so
1875    /// ADR-103's "sweep does not touch survivors" property is preserved
1876    /// exactly. Reconstructing the same number from the objects would be the
1877    /// O(live) walk this design exists to avoid.
1878    fn sweep(&self) {
1879        let mut reclaimed = 0usize;
1880        let mut live_bytes = 0usize;
1881        for page in self.walk_pages() {
1882            let words = page.words();
1883            if page.is_immortal() {
1884                // Nothing on an immortal page is ever finalized and no
1885                // `allocated` bit of it is ever cleared — that is what the flag
1886                // means. Its *mark* bits are cleared, though: a root may alias
1887                // an immortal, and a mark bit left set would make the next
1888                // cycle stop at it instead of tracing through it. Every
1889                // immortal payload is a scalar with no children today, so that
1890                // would be harmless — but "harmless because of what the payload
1891                // happens to be" is not an invariant, and one store per 64
1892                // blocks on three pages is not a cost worth taking the risk for.
1893                for word in 0..words {
1894                    if page.mark_word(word) != 0 {
1895                        page.clear_mark_word(word);
1896                    }
1897                }
1898                continue;
1899            }
1900            let mut freed = 0u32;
1901            for word in 0..words {
1902                let alive = page.allocated_word(word);
1903                let marked = page.mark_word(word);
1904                let mut dead = alive & !marked;
1905                if dead != 0 {
1906                    while dead != 0 {
1907                        let index = page.block_index_in(word, dead.trailing_zeros());
1908                        dead &= dead - 1;
1909                        // SAFETY: the block's `allocated` bit is set and nothing
1910                        // has finalized it since it was, and the
1911                        // `set_allocated_word` below is what clears it.
1912                        unsafe { Self::finalize_block(page, index) };
1913                        freed += 1;
1914                    }
1915                    page.set_allocated_word(word, alive & marked);
1916                }
1917                if marked != 0 {
1918                    page.clear_mark_word(word);
1919                }
1920            }
1921            if freed != 0 {
1922                page.release_blocks(freed);
1923                reclaimed += freed as usize;
1924            }
1925            // The page's liveness is final here, so this is the one point in the
1926            // cycle at which the live set is knowable without touching an
1927            // object. A large page reports its one block's stride rather than
1928            // its padded `page_bytes`, which under-counts by the padding — safe
1929            // in the direction `Heap::live_bytes` documents, and no production
1930            // descriptor takes the large path at all. Immortal pages `continue`
1931            // above and are excluded on purpose (RT-04).
1932            live_bytes += page.live_count() as usize * page.block_size();
1933        }
1934        // Accumulated into a local and stored once, so this line stays beside
1935        // an unchecked subtraction without making that subtraction's ordering
1936        // subtle.
1937        self.live_count.set(self.live_count.get() - reclaimed);
1938        self.live_bytes.set(live_bytes);
1939        self.relink_pages();
1940    }
1941
1942    /// Finalize the object in block `index` of `page`, then poison its header.
1943    ///
1944    /// The whole of what happens to a dead block, and the two callers differ
1945    /// only in which bits they walk — [`Heap::sweep`] takes what it proved
1946    /// unreachable, [`Heap::finalize_all`] takes everything still allocated.
1947    ///
1948    /// # Safety
1949    /// The block's `allocated` bit must be set, so that `alloc_raw` initialized
1950    /// a header and a payload of its descriptor there, and nothing may have
1951    /// finalized it since. The caller must clear that bit before anything can
1952    /// reach the block again — the poison below is only half of that protocol.
1953    unsafe fn finalize_block(page: &PageHeader, index: usize) {
1954        // SAFETY: the caller's contract is that this block holds an initialized
1955        // `[GcHeader | payload]`.
1956        let header = unsafe { &*(page.block_ptr(index) as *const GcHeader) };
1957        let desc = header.descriptor();
1958        // SAFETY: the payload matches `desc` and is about to become invalid.
1959        unsafe { (desc.drop_value)(header.payload::<u8>()) };
1960        // Poison before the caller clears the `allocated` bit, so a stale
1961        // `GcRef` that still names this storage is rejected by the mark phase's
1962        // provenance check instead of being traced through a finalized payload.
1963        // This is also RT-01's precondition: between releasing the block and
1964        // handing it out again, it must not claim to be a typed object, or a
1965        // stale reference would be traced through whatever the allocator put
1966        // there next.
1967        header.poison();
1968    }
1969
1970    /// Finalize **every** still-live allocation, reachable or not, and empty
1971    /// every page.
1972    ///
1973    /// Sweep only finalizes what it proved unreachable, so this is the other
1974    /// half: at teardown, whatever a program left live still owns the
1975    /// `Box<str>` / `Vec` / `HashMap` backing allocations its payload points
1976    /// at, and those are not in a page — releasing the pages reclaims the
1977    /// `[header|payload]` blocks and leaks everything they own (RT-02).
1978    ///
1979    /// Enumeration needs no registry: the `allocated` bitmaps already name every
1980    /// live object, exactly.
1981    ///
1982    /// Immortal pages are left alone: an immortal payload is `Copy` by
1983    /// [`ImmortalWitness`](crate::immortal::ImmortalWitness)'s argument, so
1984    /// there is nothing to finalize.
1985    ///
1986    /// After this the heap holds nothing collectable, so [`Heap::reset`] and
1987    /// `Drop` can both use it and neither can double-finalize — the bitmap it
1988    /// cleared is the same one that told it what to finalize.
1989    fn finalize_all(&self) {
1990        for page in self.walk_pages() {
1991            if page.is_immortal() {
1992                continue;
1993            }
1994            for word in 0..page.words() {
1995                let mut alive = page.allocated_word(word);
1996                while alive != 0 {
1997                    let index = page.block_index_in(word, alive.trailing_zeros());
1998                    alive &= alive - 1;
1999                    // SAFETY: as in `sweep` — an allocated bit means an
2000                    // initialized header and payload, and the `clear_bitmaps`
2001                    // below is what clears it.
2002                    unsafe { Self::finalize_block(page, index) };
2003                }
2004            }
2005            page.clear_bitmaps();
2006        }
2007        self.live_count.set(0);
2008        // The other place liveness is repudiated wholesale, and the one both
2009        // `Heap::reset` and `Drop` go through. A stale `live_bytes` here would
2010        // let a reset heap's first paced collection inherit the *previous*
2011        // program's live set as headroom.
2012        self.live_bytes.set(0);
2013        self.relink_pages();
2014    }
2015
2016    /// Reset the heap to empty, dropping everything. Used by tests. Immortal
2017    /// singletons must be re-allocated afterwards — the whole `Immortals` value,
2018    /// not just the three singletons, because of the small-`Int` table
2019    /// ([`crate::small_int`]): a `RuntimeContext` minted before the reset holds
2020    /// `unit_ref`, `true_ref`, `false_ref` **and** a `small_ints` pointer, and
2021    /// every one of them names storage the heap is now free to hand out again.
2022    pub fn reset(&mut self) {
2023        // Finalize every live allocation before repudiating the pages.
2024        self.finalize_all();
2025        // A reset heap is a different heap: the immortals it handed out are
2026        // gone, and every `GcRef` minted before this point names storage the
2027        // heap is free to hand out again. A fresh identity makes those refs fail
2028        // the mark phase's provenance check rather than be traced.
2029        let id = HeapId::mint();
2030        // The pages are **kept**, and that is deliberate: a stale `GcRef` must
2031        // mask to storage that is still mapped, or the rejection above becomes a
2032        // use-after-free. What is repudiated is everything recorded on them —
2033        // every allocated bit (including the immortal pages', which is what
2034        // makes the immortal singletons genuinely gone) and the owning identity.
2035        for page in self.walk_pages() {
2036            page.clear_bitmaps();
2037            page.clear_immortal();
2038            page.set_heap_id(id.get());
2039        }
2040        self.immortal_pages.set(std::ptr::null_mut());
2041        self.relink_pages();
2042        // Pacing is part of the heap's state, so a reset heap paces like a fresh
2043        // one. Leaving the counter and the geometrically-grown threshold in
2044        // place would let a reset heap run for megabytes before its first
2045        // collection, or collect on its very first allocation (RT-04).
2046        self.bytes_since_collect.set(0);
2047        self.collect_threshold.set(INITIAL_COLLECT_THRESHOLD);
2048        self.id = id;
2049    }
2050
2051    /// Return every page to the global allocator.
2052    ///
2053    /// The only place a page is ever unmapped, and it runs after
2054    /// [`Heap::finalize_all`]. Everything else keeps pages mapped forever,
2055    /// because "a stale `GcRef` masks to a page that is still there" is what
2056    /// makes the rejection in `Heap::mark` a rejection rather than a wild read.
2057    fn release_pages(&mut self) {
2058        let mut current = self.pages.get();
2059        while !current.is_null() {
2060            // SAFETY: every page on this list came from `PageHeader::new_*` and
2061            // is released exactly once, here.
2062            let next = unsafe { (*current).next() };
2063            // SAFETY: as above; `&mut self` means nothing else can name a block.
2064            unsafe { PageHeader::release(current) };
2065            current = next;
2066        }
2067        self.pages.set(std::ptr::null_mut());
2068        for head in &self.partial {
2069            head.set(std::ptr::null_mut());
2070        }
2071        self.empty.set(std::ptr::null_mut());
2072        self.empty_large.set(std::ptr::null_mut());
2073        self.immortal_pages.set(std::ptr::null_mut());
2074    }
2075}
2076
2077impl Drop for Heap {
2078    /// Finalize whatever the program left live (RT-02), then release the pages.
2079    ///
2080    /// Releasing the pages reclaims the `[header|payload]` blocks, and nothing
2081    /// else: the `Box<str>` behind a `Text`, the `Vec<GcRef>` behind a `Vec[T]`,
2082    /// the `HashMap` behind a `Map[K,V]` are ordinary Rust allocations no page
2083    /// ever owned. Without the finalize, every object still reachable at
2084    /// teardown would leak its backing store.
2085    ///
2086    /// **A `GcRef` does not outlive the heap.** Finalizing here makes reading
2087    /// one afterwards a visible use-after-free rather than a quiet read of
2088    /// stale-but-intact bytes. The two consumers that take a value out of the
2089    /// runtime keep to that:
2090    ///
2091    /// * `praxis-cli/src/run.rs` takes the crash snapshot, renders it, then
2092    ///   moves the `Runtime` into the `DebugSession` the `Repl` owns. `Repl`
2093    ///   declares `snapshot` before `session`, so the snapshot is dropped
2094    ///   first and nothing reads a `GcRef` after teardown.
2095    /// * `praxis-debugger/src/repl.rs` replaces its snapshot after a
2096    ///   `restart`/`reload` while the runtime is still alive.
2097    ///
2098    /// `CrashSnapshot` and `ParseDetail` hold `GcRef`s but have no `Drop` that
2099    /// dereferences one, so field order within `Runtime` — where `heap` is
2100    /// declared first and therefore dropped first — is safe either way. No
2101    /// descriptor's `drop_value` dereferences a `GcRef` either, so finalization
2102    /// order among live objects does not matter.
2103    fn drop(&mut self) {
2104        self.finalize_all();
2105        self.release_pages();
2106    }
2107}
2108
2109impl Default for Heap {
2110    fn default() -> Self {
2111        Self::new()
2112    }
2113}
2114
2115#[cfg(test)]
2116mod tests {
2117    use super::*;
2118    use crate::collections::{VEC, VecPayload};
2119    use crate::descriptor::TypeDescriptor;
2120    use crate::roots::RootScope;
2121    use crate::scalars::{INT, INT_PAYLOAD, UNIT_PAYLOAD};
2122    use crate::{GcRef, Tracer};
2123    use std::cell::Cell;
2124    use std::sync::{
2125        Arc,
2126        atomic::{AtomicUsize, Ordering},
2127    };
2128
2129    #[repr(C)]
2130    struct DropProbe(Arc<AtomicUsize>);
2131
2132    impl Drop for DropProbe {
2133        fn drop(&mut self) {
2134            self.0.fetch_add(1, Ordering::SeqCst);
2135        }
2136    }
2137
2138    unsafe fn probe_trace(_: *mut u8, _: &mut dyn Tracer) {}
2139    unsafe fn probe_drop(payload: *mut u8) {
2140        unsafe { std::ptr::drop_in_place(payload as *mut DropProbe) };
2141    }
2142    unsafe fn probe_format(_: *const u8, _: &mut crate::FormatSink<'_>) {}
2143
2144    static DROP_PROBE: TypeDescriptor = TypeDescriptor::for_test::<DropProbe>(
2145        1,
2146        "DropProbe",
2147        probe_trace,
2148        probe_drop,
2149        probe_format,
2150        None,
2151        None,
2152        None,
2153    );
2154
2155    #[repr(C, align(64))]
2156    struct Overaligned(u8);
2157
2158    unsafe fn overaligned_drop(_: *mut u8) {}
2159    static OVERALIGNED: TypeDescriptor = TypeDescriptor::for_test::<Overaligned>(
2160        0,
2161        "Overaligned",
2162        probe_trace,
2163        overaligned_drop,
2164        probe_format,
2165        None,
2166        None,
2167        None,
2168    );
2169
2170    #[test]
2171    fn alloc_int_round_trips_payload() {
2172        let heap = Heap::new();
2173        let r = heap.alloc_unpaced(INT_PAYLOAD, 42_i64);
2174        assert_eq!(r.descriptor().name, "Int");
2175        // SAFETY: `r` was allocated with INT, payload is i64.
2176        let v = unsafe { *r.payload::<i64>() };
2177        assert_eq!(v, 42);
2178        assert_eq!(heap.stats().live_count, 1);
2179    }
2180
2181    #[test]
2182    fn collect_reclaims_unrooted_allocation() {
2183        let heap = Heap::new();
2184        let _ = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
2185        assert_eq!(heap.stats().live_count, 1);
2186
2187        let roots = RootScope::new(); // nothing rooted
2188        heap.collect_with(&roots);
2189        assert_eq!(
2190            heap.stats().live_count,
2191            0,
2192            "unrooted Int should be reclaimed"
2193        );
2194    }
2195
2196    #[test]
2197    fn collect_preserves_rooted_allocation() {
2198        let heap = Heap::new();
2199        let mut scope = RootScope::new();
2200        let r = heap.alloc_unpaced(INT_PAYLOAD, 7_i64);
2201        scope.root(r);
2202        assert_eq!(heap.stats().live_count, 1);
2203
2204        heap.collect_with(&scope);
2205        assert_eq!(heap.stats().live_count, 1, "rooted Int survives");
2206        // Payload still readable after collection.
2207        // SAFETY: `r` survived and was allocated with INT.
2208        let v = unsafe { *r.payload::<i64>() };
2209        assert_eq!(v, 7);
2210    }
2211
2212    #[test]
2213    fn collect_preserves_nested_references() {
2214        // A Vec of Int is rooted, garbage is allocated, and after collection the
2215        // whole nested graph survives and is readable through the element
2216        // descriptors.
2217        let heap = Heap::new();
2218        let mut scope = RootScope::new();
2219
2220        // Build [10, 20, 30] as Int GcRefs.
2221        let elems: Vec<GcRef> = [10_i64, 20, 30]
2222            .iter()
2223            .map(|&v| heap.alloc_unpaced(INT_PAYLOAD, v))
2224            .collect();
2225
2226        // Wrap in a Vec[T] payload. Element type is recorded in the payload
2227        // (ADR-013).
2228        // SAFETY: VecPayload is VEC's payload type.
2229        let vec_ref = unsafe {
2230            heap.alloc_payload_unpaced(
2231                &VEC,
2232                VecPayload {
2233                    element_descriptor: &INT,
2234                    items: elems.into(),
2235                },
2236            )
2237        };
2238        scope.root(vec_ref);
2239
2240        // Allocate garbage that should be reclaimed.
2241        for i in 0..5_i64 {
2242            let _ = heap.alloc_unpaced(INT_PAYLOAD, 1000 + i);
2243        }
2244        assert_eq!(heap.stats().live_count, 9); // vec + 3 ints + 5 garbage
2245
2246        heap.collect_with(&scope);
2247
2248        // The vec and its 3 elements survive; the 5 garbage ints are reclaimed.
2249        assert_eq!(heap.stats().live_count, 4);
2250
2251        // Format the vec through its descriptor to prove the nested graph is
2252        // intact and readable end to end.
2253        let mut out = String::new();
2254        let desc = vec_ref.descriptor();
2255        // SAFETY: vec_ref's payload is a VecPayload.
2256        unsafe {
2257            (desc.format)(
2258                vec_ref.payload::<u8>() as *const u8,
2259                &mut crate::FormatSink::display(&mut out),
2260            )
2261        };
2262        assert_eq!(out, "[10, 20, 30]");
2263    }
2264
2265    #[test]
2266    fn collect_handles_vec_of_vec() {
2267        // Deeper nesting: [[1, 2], [3]] — only the outer vec is rooted; the
2268        // inner vecs and their ints must survive via transitive tracing.
2269        let heap = Heap::new();
2270        let mut scope = RootScope::new();
2271
2272        let inner_alloc = |ints: &[i64]| -> GcRef {
2273            let elems: Vec<GcRef> = ints
2274                .iter()
2275                .map(|&v| heap.alloc_unpaced(INT_PAYLOAD, v))
2276                .collect();
2277            // SAFETY: VecPayload is VEC's payload type.
2278            unsafe {
2279                heap.alloc_payload_unpaced(
2280                    &VEC,
2281                    VecPayload {
2282                        element_descriptor: &INT,
2283                        items: elems.into(),
2284                    },
2285                )
2286            }
2287        };
2288
2289        let inner0 = inner_alloc(&[1, 2]);
2290        let inner1 = inner_alloc(&[3]);
2291        // SAFETY: VecPayload is VEC's payload type.
2292        let outer = unsafe {
2293            heap.alloc_payload_unpaced(
2294                &VEC,
2295                VecPayload {
2296                    // The element descriptor of a Vec-of-X is VEC itself.
2297                    element_descriptor: &VEC,
2298                    items: vec![inner0, inner1].into(),
2299                },
2300            )
2301        };
2302        scope.root(outer);
2303
2304        // Garbage.
2305        let _ = heap.alloc_unpaced(UNIT_PAYLOAD, ());
2306
2307        heap.collect_with(&scope);
2308
2309        // outer + 2 inner vecs + 3 ints = 6 survivors; the Unit garbage dies.
2310        assert_eq!(heap.stats().live_count, 6);
2311
2312        let mut out = String::new();
2313        unsafe {
2314            (outer.descriptor().format)(
2315                outer.payload::<u8>() as *const u8,
2316                &mut crate::FormatSink::display(&mut out),
2317            )
2318        };
2319        assert_eq!(out, "[[1, 2], [3]]");
2320    }
2321
2322    #[test]
2323    fn collect_finalizes_unreachable_owned_payload_exactly_once() {
2324        let drops = Arc::new(AtomicUsize::new(0));
2325        let heap = Heap::new();
2326        // SAFETY: DropProbe is DROP_PROBE's payload type.
2327        unsafe {
2328            heap.alloc_payload_unpaced(&DROP_PROBE, DropProbe(Arc::clone(&drops)));
2329        }
2330
2331        let roots = RootScope::new();
2332        heap.collect_with(&roots);
2333        assert_eq!(drops.load(Ordering::SeqCst), 1);
2334
2335        heap.collect_with(&roots);
2336        assert_eq!(
2337            drops.load(Ordering::SeqCst),
2338            1,
2339            "a swept payload must never be finalized twice"
2340        );
2341    }
2342
2343    #[test]
2344    fn dropping_heap_finalizes_live_owned_payloads() {
2345        let drops = Arc::new(AtomicUsize::new(0));
2346        {
2347            let heap = Heap::new();
2348            // SAFETY: DropProbe is DROP_PROBE's payload type.
2349            unsafe {
2350                heap.alloc_payload_unpaced(&DROP_PROBE, DropProbe(Arc::clone(&drops)));
2351            }
2352            assert_eq!(drops.load(Ordering::SeqCst), 0);
2353        }
2354
2355        assert_eq!(
2356            drops.load(Ordering::SeqCst),
2357            1,
2358            "tearing down a heap must run descriptor finalizers for live payloads"
2359        );
2360    }
2361
2362    /// Reachability is irrelevant at teardown: an object the collector would
2363    /// have *kept* still owns its backing allocations, and the heap is the last
2364    /// owner. Rooting it must not exempt it.
2365    #[test]
2366    fn dropping_heap_finalizes_reachable_payloads_too() {
2367        let drops = Arc::new(AtomicUsize::new(0));
2368        {
2369            let heap = Heap::new();
2370            let mut scope = RootScope::new();
2371            // SAFETY: DropProbe is DROP_PROBE's payload type.
2372            let probe =
2373                unsafe { heap.alloc_payload_unpaced(&DROP_PROBE, DropProbe(Arc::clone(&drops))) };
2374            scope.root(probe);
2375            heap.collect_with(&scope);
2376            assert_eq!(drops.load(Ordering::SeqCst), 0, "a rooted probe survives");
2377        }
2378        assert_eq!(drops.load(Ordering::SeqCst), 1);
2379    }
2380
2381    /// `reset` and `Drop` share one finalizer loop, and it clears the same
2382    /// `allocated` bits it finalized from — so a heap that is reset and then
2383    /// dropped finalizes each payload once, not twice.
2384    #[test]
2385    fn resetting_then_dropping_finalizes_each_payload_once() {
2386        let drops = Arc::new(AtomicUsize::new(0));
2387        {
2388            let mut heap = Heap::new();
2389            // SAFETY: DropProbe is DROP_PROBE's payload type.
2390            unsafe {
2391                heap.alloc_payload_unpaced(&DROP_PROBE, DropProbe(Arc::clone(&drops)));
2392            }
2393            heap.reset();
2394            assert_eq!(drops.load(Ordering::SeqCst), 1, "reset finalizes");
2395        }
2396        assert_eq!(
2397            drops.load(Ordering::SeqCst),
2398            1,
2399            "the drop after a reset must find nothing left to finalize"
2400        );
2401    }
2402
2403    #[test]
2404    fn overaligned_payload_accessor_matches_initialized_address() {
2405        let initialized_at = Cell::new(std::ptr::null_mut());
2406        let heap = Heap::new();
2407        // The one allocation here that cannot go through `alloc_payload_unpaced`:
2408        // the property under test *is* the address `init` was handed, so this
2409        // needs the raw-pointer closure rather than a payload by value.
2410        let value = unsafe {
2411            heap.alloc_with_unpaced(
2412                &OVERALIGNED,
2413                std::mem::size_of::<Overaligned>(),
2414                std::mem::align_of::<Overaligned>(),
2415                |payload| {
2416                    initialized_at.set(payload);
2417                    (payload as *mut Overaligned).write(Overaligned(7));
2418                },
2419            )
2420        };
2421
2422        assert_eq!(
2423            value.payload::<Overaligned>() as *mut u8,
2424            initialized_at.get(),
2425            "GcHeader::payload must account for alignment padding inserted by Heap::alloc_raw"
2426        );
2427    }
2428
2429    #[test]
2430    fn foreign_heap_root_cannot_delay_reclamation() {
2431        let first = Heap::new();
2432        let second = Heap::new();
2433        let value = first.alloc_unpaced(INT_PAYLOAD, 1_i64);
2434        let mut foreign_roots = RootScope::new();
2435        foreign_roots.root(value);
2436
2437        second.collect_with(&foreign_roots);
2438        first.collect_with(&RootScope::new());
2439
2440        assert_eq!(
2441            first.stats().live_count,
2442            0,
2443            "a collection on another heap must not mutate this heap's mark colors"
2444        );
2445    }
2446
2447    /// Allocate until the pacing counter runs a collection, and return whether
2448    /// one happened within `limit` allocations.
2449    fn allocate_until_paced(heap: &Heap, limit: usize) -> bool {
2450        for i in 0..limit {
2451            let _ = heap.alloc_unpaced(INT_PAYLOAD, i as i64);
2452            if heap.maybe_collect_with(&RootScope::new()) {
2453                return true;
2454            }
2455        }
2456        false
2457    }
2458
2459    /// **The ADR-113 obligation, as an assertion rather than a comment.**
2460    ///
2461    /// Generated code does not call [`Heap::collection_is_due`]; it loads two
2462    /// words at [`Heap::BYTES_SINCE_COLLECT_OFFSET`] and
2463    /// [`Heap::COLLECT_THRESHOLD_OFFSET`] and compares them. This test *is* that
2464    /// sequence, in Rust, run against a live `Heap` whose two fields are driven
2465    /// across the boundary — so it fails in three separate ways, each of which
2466    /// is a real defect:
2467    ///
2468    /// 1. an offset that names the wrong field (the reads disagree with the
2469    ///    fields);
2470    /// 2. a `Cell` that stops being `#[repr(transparent)]` over its contents, or
2471    ///    a `Heap` that stops being `#[repr(C)]` (same symptom);
2472    /// 3. **a third term in the predicate** — the one this is really for. The
2473    ///    moment `collection_is_due` is anything other than these two words
2474    ///    compared, some state below makes the two answers differ, and whoever
2475    ///    added the term arrives at that function's doc, which tells them the
2476    ///    backend has to change too.
2477    ///
2478    /// It deliberately does not go through `maybe_collect`: that would collect,
2479    /// which resets the counter, and the states worth checking are the ones on
2480    /// either side of the boundary.
2481    #[test]
2482    fn the_pacing_predicate_is_one_unsigned_compare_of_the_two_exported_words() {
2483        let heap = Heap::new();
2484        // Straddle the boundary in both directions, and include the degenerate
2485        // pair (0, 0) — a zero threshold means *always* due, and `>=` is what
2486        // makes that true where `>` would not.
2487        for (since, threshold) in [
2488            (0_usize, 0_usize),
2489            (0, 1),
2490            (0, INITIAL_COLLECT_THRESHOLD),
2491            (INITIAL_COLLECT_THRESHOLD - 1, INITIAL_COLLECT_THRESHOLD),
2492            (INITIAL_COLLECT_THRESHOLD, INITIAL_COLLECT_THRESHOLD),
2493            (INITIAL_COLLECT_THRESHOLD + 1, INITIAL_COLLECT_THRESHOLD),
2494            (usize::MAX, INITIAL_COLLECT_THRESHOLD),
2495            (INITIAL_COLLECT_THRESHOLD, usize::MAX),
2496        ] {
2497            heap.bytes_since_collect.set(since);
2498            heap.collect_threshold.set(threshold);
2499
2500            let base = std::ptr::from_ref(&heap).cast::<u8>();
2501            // SAFETY: `Heap` is `#[repr(C)]` and both constants are
2502            // `offset_of!` of a `Cell<usize>` field of it, and `Cell<T>` is
2503            // `#[repr(transparent)]` over `T`. This is byte-for-byte the load
2504            // `emit_inline_intern` emits.
2505            let (read_since, read_threshold) = unsafe {
2506                (
2507                    *base.add(Heap::BYTES_SINCE_COLLECT_OFFSET).cast::<usize>(),
2508                    *base.add(Heap::COLLECT_THRESHOLD_OFFSET).cast::<usize>(),
2509                )
2510            };
2511            assert_eq!(read_since, since, "BYTES_SINCE_COLLECT_OFFSET names it");
2512            assert_eq!(read_threshold, threshold, "COLLECT_THRESHOLD_OFFSET does");
2513            assert_eq!(
2514                read_since >= read_threshold,
2515                heap.collection_is_due(),
2516                "the predicate generated code emits (since={since}, \
2517                 threshold={threshold}) is no longer the predicate \
2518                 `collection_is_due` applies — see its doc: the backend's \
2519                 `emit_inline_intern` owes the same change, or generated code \
2520                 answers from the intern table on a branch where the collector \
2521                 was due"
2522            );
2523        }
2524    }
2525
2526    /// The site the backend is handed carries the *same* two offsets, so
2527    /// permission to probe the table and the obligation to pace cannot come
2528    /// apart. `InlineInternSite::new` fills them itself rather than taking them,
2529    /// and this is the assertion that it filled them from here.
2530    ///
2531    /// And that the claim site's are not a second set: both carry one
2532    /// [`PacingOffsets`], which is what lets the backend emit the compare in one
2533    /// place rather than transcribing `collection_is_due` twice.
2534    #[test]
2535    fn an_inline_intern_site_carries_the_heaps_own_pacing_offsets() {
2536        let pacing = crate::small_int::INLINE_INTERN_SITE.pacing();
2537        assert_eq!(
2538            pacing.bytes_since_collect_offset(),
2539            Heap::BYTES_SINCE_COLLECT_OFFSET
2540        );
2541        assert_eq!(
2542            pacing.collect_threshold_offset(),
2543            Heap::COLLECT_THRESHOLD_OFFSET
2544        );
2545        assert_ne!(
2546            pacing.bytes_since_collect_offset(),
2547            pacing.collect_threshold_offset(),
2548            "two distinct fields, or the compare is `x >= x`"
2549        );
2550        assert_eq!(
2551            pacing.heap_offset(),
2552            core::mem::offset_of!(crate::RuntimeContext, heap),
2553            "and the base those two are relative to is the context's `heap`"
2554        );
2555        assert_eq!(
2556            crate::scalars::INT_CLAIM_SITE.pacing(),
2557            pacing,
2558            "and the claim site's are the same three, because they are the same \
2559             value — one predicate, one authority"
2560        );
2561    }
2562
2563    /// **The other half of ADR-119 decision 1 part 3, as an assertion.**
2564    ///
2565    /// The IR test in the backend says *which displacements* the claim sequence
2566    /// stores to and in what order. Nothing there can say those displacements
2567    /// name the fields they are supposed to — that is this test, and it is the
2568    /// `the_pacing_predicate_is_one_unsigned_compare_of_the_two_exported_words`
2569    /// shape widened from two words to a heap, a page and a header.
2570    ///
2571    /// Every read below is byte-for-byte a load the emitted sequence performs,
2572    /// against a live heap that has just allocated one `Int` through the
2573    /// wrapper — so a `#[repr(C)]` that stopped being one, a reordered
2574    /// `PageHeader`, or an `offset_of!` naming a neighbouring field fails here
2575    /// rather than in a program that silently writes a `heap_id` over a
2576    /// `payload_offset`.
2577    #[test]
2578    fn the_claim_site_displacements_name_the_fields_they_claim_to() {
2579        let site = crate::scalars::INT_CLAIM_SITE;
2580        let heap = Heap::new();
2581        let value = heap.alloc_unpaced(INT_PAYLOAD, 7_i64);
2582
2583        let heap_base = std::ptr::from_ref(&heap).cast::<u8>();
2584        // SAFETY: `Heap` is `#[repr(C)]`, every constant below is an
2585        // `offset_of!` of one of its fields, and `Cell<T>` is
2586        // `#[repr(transparent)]` over `T`.
2587        let (read_id, read_live, read_head) = unsafe {
2588            (
2589                *heap_base.add(site.heap_id_offset()).cast::<u32>(),
2590                *heap_base.add(site.heap_live_count_offset()).cast::<usize>(),
2591                *heap_base
2592                    .add(site.partial_head_offset())
2593                    .cast::<*mut PageHeader>(),
2594            )
2595        };
2596        assert_eq!(read_id, heap.id.get(), "heap_id_offset names `Heap::id`");
2597        assert_eq!(
2598            read_live,
2599            heap.live_count.get(),
2600            "heap_live_count_offset names `Heap::live_count`"
2601        );
2602        assert!(
2603            !read_head.is_null(),
2604            "partial_head_offset names the `Int` class's list head, and the \
2605             allocation above put a page on it"
2606        );
2607
2608        // SAFETY: the head of an availability list is one of this heap's pages.
2609        let page = unsafe { &*read_head };
2610        let page_base = std::ptr::from_ref(page).cast::<u8>();
2611        // SAFETY: `PageHeader` is `#[repr(C)]` and each constant is an
2612        // `offset_of!` of one of its `Cell` fields.
2613        let (read_cursor, read_last, read_page_live, read_word) = unsafe {
2614            (
2615                *page_base.add(site.page_cursor_offset()).cast::<u32>(),
2616                *page_base.add(site.page_last_word_offset()).cast::<u32>(),
2617                *page_base.add(site.page_live_count_offset()).cast::<u32>(),
2618                *page_base.add(site.page_allocated_offset()).cast::<u64>(),
2619            )
2620        };
2621        assert_eq!(
2622            read_cursor, 0,
2623            "the first claim leaves the cursor at word 0"
2624        );
2625        assert_eq!(
2626            read_last,
2627            page.words() as u32 - 1,
2628            "page_last_word_offset names `PageHeader::last_word`"
2629        );
2630        // The inline sequence bails at `cursor >= last_word`, ceding the tail
2631        // word to the wrapper (ADR-119 decision 3). On a page with **one**
2632        // bitmap word that bail would fire on every claim and the inline arm
2633        // would be dead code — a claim about reach rather than correctness,
2634        // asserted here because nothing else would notice it going false.
2635        assert!(
2636            read_last >= 1,
2637            "a claimable class must have more than one bitmap word, or the \
2638             tail-word bail-out cedes the whole page to the wrapper"
2639        );
2640        assert_eq!(
2641            read_page_live,
2642            page.live_count(),
2643            "page_live_count_offset names `PageHeader::live_count`"
2644        );
2645        assert_eq!(
2646            read_word,
2647            page.allocated_word(0),
2648            "page_allocated_offset names the base of the `allocated` bitmap"
2649        );
2650
2651        // The geometry the sequence folds rather than loads.
2652        assert_eq!(
2653            site.stride(),
2654            page.block_size(),
2655            "the stride the pacer is charged is the page's own"
2656        );
2657        assert_eq!(
2658            site.first_block(),
2659            page.first_block(),
2660            "the folded `first_block` is the page's own — see \
2661             `PageHeader::first_block_of`"
2662        );
2663        assert_eq!(
2664            site.payload_offset(),
2665            page.payload_offset(),
2666            "and the payload displacement the header will record is the one \
2667             the page was laid out with (ADR-039 decision 1)"
2668        );
2669
2670        // And the header the sequence writes, read back through *its* three
2671        // displacements against the one the wrapper just wrote.
2672        let header_base = value.as_ptr().cast::<u8>();
2673        // SAFETY: `value` is a live object this heap allocated, and `GcHeader`
2674        // is `#[repr(C)]` with these three fields.
2675        let (read_desc, read_payload_offset, read_header_id) = unsafe {
2676            (
2677                *header_base
2678                    .add(site.header_descriptor_offset())
2679                    .cast::<*const TypeDescriptor>(),
2680                *header_base
2681                    .add(site.header_payload_offset_offset())
2682                    .cast::<u16>(),
2683                *header_base.add(site.header_heap_id_offset()).cast::<u32>(),
2684            )
2685        };
2686        assert!(
2687            std::ptr::eq(read_desc, &crate::scalars::INT),
2688            "header_descriptor_offset names the descriptor pointer"
2689        );
2690        assert_eq!(
2691            read_payload_offset as usize,
2692            site.payload_offset(),
2693            "header_payload_offset_offset names the recorded displacement, and \
2694             it is the one the site carries"
2695        );
2696        assert_eq!(
2697            read_header_id,
2698            heap.id.get(),
2699            "header_heap_id_offset names the provenance word"
2700        );
2701    }
2702
2703    /// A descriptor whose payload owns bytes outside its block has **no** claim
2704    /// site, and that refusal is the whole of why the inline sequence may charge
2705    /// `stride` and nothing else.
2706    ///
2707    /// `Heap::occupy` charges `stride + owned_bytes_of(payload)`. Generated code
2708    /// can reproduce the first term and cannot make the indirect call the second
2709    /// needs, so a `Text` or a `Vec` claimed inline would under-charge the pacer
2710    /// by its entire buffer, breaking RT-04. This walks every built-in descriptor
2711    /// and asserts the refusal is exactly the `owned_bytes` set, rather than a
2712    /// list someone kept in step by hand.
2713    #[test]
2714    fn only_a_descriptor_with_no_owned_bytes_charge_has_a_claim_site() {
2715        for descriptor in crate::descriptor::BUILTINS {
2716            let claimable = InlineClaimSite::of(descriptor).is_some();
2717            let charges_outside = descriptor.owned_bytes.is_some();
2718            let on_the_ladder = SizeClass::of(BlockLayout::of(descriptor).1).is_some();
2719            assert_eq!(
2720                claimable,
2721                !charges_outside && on_the_ladder,
2722                "{}: a claim site exists exactly when the pacing charge is the \
2723                 stride alone and the block is on the ladder",
2724                descriptor.name
2725            );
2726        }
2727        assert!(
2728            InlineClaimSite::of(&crate::scalars::INT).is_some(),
2729            "and `Int` is on the claimable side, which is the whole package"
2730        );
2731        assert!(
2732            InlineClaimSite::of(&crate::text::TEXT).is_none(),
2733            "…and `Text` is not: its `owned_bytes` is the `Box<str>` the \
2734             sequence has no way to measure"
2735        );
2736    }
2737
2738    #[test]
2739    fn reset_restores_collection_pacing() {
2740        let mut heap = Heap::new();
2741        // Only a *paced* collection grows the threshold, so drive one.
2742        assert!(allocate_until_paced(&heap, 100_000));
2743        let _ = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
2744        assert_ne!(heap.bytes_since_collect.get(), 0);
2745        assert_ne!(heap.collect_threshold.get(), INITIAL_COLLECT_THRESHOLD);
2746
2747        heap.reset();
2748
2749        assert_eq!(heap.bytes_since_collect.get(), 0);
2750        assert_eq!(heap.collect_threshold.get(), INITIAL_COLLECT_THRESHOLD);
2751    }
2752
2753    /// A host that collects on a schedule — the debugger between REPL commands,
2754    /// a test between phases — is not evidence that the program needs a bigger
2755    /// budget between automatic collections. Doubling on every explicit collect
2756    /// would let a few such calls leave the program effectively running without
2757    /// a collector (RT-04).
2758    #[test]
2759    fn an_explicit_collection_does_not_grow_the_pacing_threshold() {
2760        let heap = Heap::new();
2761        for _ in 0..8 {
2762            heap.collect_with(&RootScope::new());
2763        }
2764        assert_eq!(
2765            heap.collect_threshold.get(),
2766            INITIAL_COLLECT_THRESHOLD,
2767            "an explicit collection must leave the automatic threshold alone"
2768        );
2769
2770        assert!(allocate_until_paced(&heap, 100_000));
2771        assert_eq!(
2772            heap.collect_threshold.get(),
2773            INITIAL_COLLECT_THRESHOLD * 2,
2774            "a paced collection is what grows it"
2775        );
2776    }
2777
2778    /// The RT-04 property above is the pacer's, not the doubling rule's, and a
2779    /// bounded pacer must not smuggle threshold growth in through an explicit
2780    /// collection either. Written as a twin rather than by widening the test
2781    /// above, so that one keeps pinning the *exact* doubling constant.
2782    #[test]
2783    fn an_explicit_collection_does_not_grow_a_bounded_pacers_threshold() {
2784        let heap = Heap::with_pacer(Pacer::bounded(1 << 20, LIVE_HEADROOM));
2785        for _ in 0..8 {
2786            heap.collect_with(&RootScope::new());
2787        }
2788        assert_eq!(
2789            heap.collect_threshold.get(),
2790            INITIAL_COLLECT_THRESHOLD,
2791            "an explicit collection must leave the automatic threshold alone"
2792        );
2793
2794        assert!(allocate_until_paced(&heap, 100_000));
2795        assert_eq!(
2796            heap.collect_threshold.get(),
2797            INITIAL_COLLECT_THRESHOLD * 2,
2798            "with nothing rooted the ratchet term is what grows it, exactly as before"
2799        );
2800    }
2801
2802    /// The stride an `Int`'s block is taken at — derived through the same two
2803    /// calls the allocator makes rather than written as a literal, so a change
2804    /// to the ladder moves the expectation with it.
2805    fn int_stride() -> usize {
2806        let (_, block) = BlockLayout::of(&INT);
2807        SizeClass::of(block)
2808            .expect("an Int is on the ladder")
2809            .block_size()
2810    }
2811
2812    /// The input the bounded pacer's mandatory term is computed from. It is
2813    /// measured from the pages' own counts in the walk sweep already performs,
2814    /// so this is also the assertion that the multiply is reading the right two
2815    /// numbers.
2816    #[test]
2817    fn sweep_measures_the_live_set_in_bytes() {
2818        const ROOTED: usize = 100;
2819        let heap = Heap::with_pacer(Pacer::Doubling);
2820        let mut roots = RootScope::new();
2821        for i in 0..1_000_i64 {
2822            let r = heap.alloc_unpaced(INT_PAYLOAD, i);
2823            if (i as usize) < ROOTED {
2824                roots.root(r);
2825            }
2826        }
2827
2828        heap.collect_with(&roots);
2829
2830        assert_eq!(heap.stats().live_count, ROOTED);
2831        assert_eq!(
2832            heap.stats().live_bytes,
2833            ROOTED * int_stride(),
2834            "the live set is the survivors' blocks and nothing else"
2835        );
2836    }
2837
2838    /// The RT-04 twin of `an_immortal_is_invisible_to_sweep_and_to_finalize_all`
2839    /// and of `minting_the_immortals_costs_the_collector_nothing`: an object no
2840    /// collection can ever reclaim exerts no pressure, so it must not buy the
2841    /// program a larger budget between collections either. Without this, every
2842    /// program in the language would start with the ~40 KiB interned small-`Int`
2843    /// table (ADR-100) counted as live and the first bounded threshold moved by
2844    /// it.
2845    #[test]
2846    fn an_immortal_is_not_counted_in_the_live_set() {
2847        let heap = Heap::with_pacer(Pacer::Doubling);
2848        let immortals = crate::immortal::Immortals::new(&heap);
2849        assert!(immortals.small_int(7).is_some(), "7 is interned");
2850        let mut roots = RootScope::new();
2851        roots.root(heap.alloc_unpaced(INT_PAYLOAD, 1_i64));
2852
2853        heap.collect_with(&roots);
2854
2855        assert_eq!(
2856            heap.stats().live_bytes,
2857            int_stride(),
2858            "the immortal tables are on pages sweep never walks, so they are not live bytes"
2859        );
2860    }
2861
2862    /// The threshold's speculative half ratchets to a bound and stops, where the
2863    /// doubling rule's grows without limit for as long as the program runs.
2864    #[test]
2865    fn a_bounded_pacer_stops_doubling_at_the_ceiling() {
2866        const CEILING: usize = 1 << 18; // 256 KiB — two rungs above INITIAL.
2867        let heap = Heap::with_pacer(Pacer::bounded(CEILING, LIVE_HEADROOM));
2868
2869        for round in 0..40 {
2870            assert!(
2871                allocate_until_paced(&heap, 100_000),
2872                "round {round} did not reach the threshold"
2873            );
2874            assert!(
2875                heap.collect_threshold.get() <= CEILING,
2876                "round {round} left the threshold at {} above the {CEILING}-byte ceiling",
2877                heap.collect_threshold.get()
2878            );
2879        }
2880        assert_eq!(
2881            heap.collect_threshold.get(),
2882            CEILING,
2883            "and it ratchets all the way up to it rather than oscillating below"
2884        );
2885    }
2886
2887    /// The ceiling clamps the ratchet term and **not** the whole expression.
2888    /// Folding `min(ceiling)` over the max is a one-character edit that turns a
2889    /// memory bound into a thrash bug: a program whose live set exceeds the
2890    /// ceiling would collect on essentially every allocation, having proved on
2891    /// each one that it cannot reclaim anything. This test is what fails.
2892    #[test]
2893    fn a_bounded_pacer_gives_a_large_live_set_its_headroom() {
2894        const CEILING: usize = 1 << 20; // 1 MiB
2895        let heap = Heap::with_pacer(Pacer::bounded(CEILING, LIVE_HEADROOM));
2896        let mut roots = RootScope::new();
2897        // Two ceilings' worth of live blocks, held across the collection.
2898        for i in 0..(2 * CEILING / int_stride()) as i64 {
2899            roots.root(heap.alloc_unpaced(INT_PAYLOAD, i));
2900        }
2901
2902        assert!(
2903            drive_one_paced_collection(&heap, &roots, 200_000),
2904            "the rooted fixture is already past the threshold"
2905        );
2906
2907        let live = heap.stats().live_bytes;
2908        assert!(
2909            live > CEILING,
2910            "the fixture must hold more than the ceiling, and holds {live}"
2911        );
2912        assert_eq!(
2913            heap.collect_threshold.get(),
2914            live * LIVE_HEADROOM,
2915            "the mandatory term must be allowed to exceed the ceiling"
2916        );
2917    }
2918
2919    /// The difference from a naive `live × k` rule, and the reason no separate
2920    /// growth floor is needed: the ratchet-to-ceiling *is* the floor. A program
2921    /// that briefly holds a large live set and then drops it does not go back to
2922    /// collecting every 64 KiB.
2923    #[test]
2924    fn a_shrinking_live_set_does_not_lower_the_threshold_below_the_ceiling() {
2925        const CEILING: usize = 1 << 20; // 1 MiB
2926        let heap = Heap::with_pacer(Pacer::bounded(CEILING, LIVE_HEADROOM));
2927        {
2928            let mut roots = RootScope::new();
2929            for i in 0..(2 * CEILING / int_stride()) as i64 {
2930                roots.root(heap.alloc_unpaced(INT_PAYLOAD, i));
2931            }
2932            assert!(drive_one_paced_collection(&heap, &roots, 200_000));
2933            assert!(heap.collect_threshold.get() > CEILING);
2934        }
2935
2936        // The roots are gone; the next collection finds nothing live.
2937        assert!(drive_one_paced_collection(
2938            &heap,
2939            &RootScope::new(),
2940            1_000_000
2941        ));
2942
2943        assert_eq!(heap.stats().live_bytes, 0);
2944        assert_eq!(
2945            heap.collect_threshold.get(),
2946            CEILING,
2947            "an empty live set must leave the threshold at the ceiling, not at INITIAL"
2948        );
2949    }
2950
2951    /// RT-01 restated for the pacer: a program that holds a bounded working set
2952    /// has a bounded heap, whatever its total allocation. The doubling arm below
2953    /// is not decoration — it is the measurement of what the bound is worth, and
2954    /// it fails the same assertion by construction.
2955    #[test]
2956    fn a_bounded_heap_stops_growing() {
2957        const CEILING: usize = 1 << 18; // 256 KiB
2958        const RETAINED: usize = 1_024;
2959        const CHURN: i64 = 512 * 1_024;
2960
2961        fn churn(pacer: Pacer) -> (usize, usize) {
2962            let heap = Heap::with_pacer(pacer);
2963            let mut roots = RootScope::new();
2964            for i in 0..RETAINED as i64 {
2965                roots.root(heap.alloc_unpaced(INT_PAYLOAD, i));
2966            }
2967            for i in 0..CHURN {
2968                let _ = heap.alloc_unpaced(INT_PAYLOAD, i);
2969                heap.maybe_collect_with(&roots);
2970            }
2971            (heap.committed_bytes(), heap.stats().live_bytes)
2972        }
2973
2974        // One page of slack per rung is the worst case ADR-103 names for a
2975        // heap that has touched every class; this fixture touches one.
2976        let slack = 14 * page::PAGE_SIZE;
2977        let (bounded_bytes, live) = churn(Pacer::bounded(CEILING, LIVE_HEADROOM));
2978        assert_eq!(live, RETAINED * int_stride());
2979        assert!(
2980            bounded_bytes <= live + CEILING + slack,
2981            "a bounded pacer left {bounded_bytes} bytes committed against a {live}-byte \
2982             live set and a {CEILING}-byte ceiling"
2983        );
2984
2985        let (doubling_bytes, _) = churn(Pacer::Doubling);
2986        assert!(
2987            doubling_bytes > live + CEILING + slack,
2988            "the doubling rule is supposed to fail this bound, and committed only \
2989             {doubling_bytes} bytes — the fixture is no longer measuring anything"
2990        );
2991    }
2992
2993    /// Allocate against `roots` until one paced collection runs, and report
2994    /// whether it did within `limit` allocations. The rooted counterpart of
2995    /// `allocate_until_paced`, for the tests whose whole subject is what a
2996    /// *non-empty* live set does to the next threshold.
2997    fn drive_one_paced_collection(heap: &Heap, roots: &dyn RootSet, limit: usize) -> bool {
2998        for i in 0..limit {
2999            let _ = heap.alloc_unpaced(INT_PAYLOAD, i as i64);
3000            if heap.maybe_collect_with(roots) {
3001                return true;
3002            }
3003        }
3004        false
3005    }
3006
3007    /// `Heap::reset` re-seeds the pacer's *previous*, and `finalize_all` —
3008    /// which reset goes through — repudiates its *live*. Both halves matter: a
3009    /// reset heap that inherited the previous program's live set as headroom
3010    /// would run for megabytes before its first collection (RT-04), which is
3011    /// the same failure `reset_restores_collection_pacing` pins for the
3012    /// threshold.
3013    #[test]
3014    fn reset_repudiates_the_measured_live_set() {
3015        let mut heap = Heap::with_pacer(Pacer::bounded(1 << 20, LIVE_HEADROOM));
3016        {
3017            let mut roots = RootScope::new();
3018            for i in 0..8_192_i64 {
3019                roots.root(heap.alloc_unpaced(INT_PAYLOAD, i));
3020            }
3021            assert!(drive_one_paced_collection(&heap, &roots, 200_000));
3022            assert_ne!(heap.stats().live_bytes, 0);
3023        }
3024
3025        heap.reset();
3026
3027        assert_eq!(heap.stats().live_bytes, 0);
3028        assert_eq!(heap.collect_threshold.get(), INITIAL_COLLECT_THRESHOLD);
3029    }
3030
3031    /// The default is the bounded rule, at the measured ceiling and factor
3032    /// (ADR-112, ADR-129). Both are pinned here rather than left implicit, so a
3033    /// re-tuning is a visible edit to a test that names the numbers, not a
3034    /// silent change to every Praxis program's memory profile.
3035    #[test]
3036    fn the_default_pacer_is_bounded_at_the_measured_ceiling() {
3037        assert_eq!(Pacer::from_spec(None), Pacer::DEFAULT);
3038        assert_eq!(Pacer::DEFAULT, Pacer::bounded(4 << 20, 2));
3039        assert_eq!(
3040            Pacer::DEFAULT.next_threshold(1 << 30, 0),
3041            MAX_COLLECT_THRESHOLD
3042        );
3043    }
3044
3045    /// The "make illegal states unrepresentable" gate on the constructor. A
3046    /// ceiling below the first threshold would describe a heap that had
3047    /// exceeded its own bound before its first allocation; a zero factor would
3048    /// delete the mandatory term and with it the anti-thrash half of the rule.
3049    #[test]
3050    fn a_bounded_pacer_cannot_be_built_with_a_ceiling_below_the_first_threshold() {
3051        assert_eq!(
3052            Pacer::bounded(0, 0),
3053            Pacer::bounded(INITIAL_COLLECT_THRESHOLD, 1)
3054        );
3055        assert_eq!(
3056            Pacer::bounded(1, 0).next_threshold(INITIAL_COLLECT_THRESHOLD, 1_000_000),
3057            1_000_000,
3058            "a clamped factor of one still gives the live set its own bytes"
3059        );
3060    }
3061
3062    /// The knob's grammar, and — the part that matters for an A/B — that a
3063    /// value it cannot read is a loud fallback rather than a silent one. A
3064    /// silent fallback lets a typo in one arm measure the other build and
3065    /// report the result as if it were the right one.
3066    #[test]
3067    fn a_pacer_spec_parses_its_grammar_and_rejects_everything_else() {
3068        assert_eq!(Pacer::parse("doubling"), Ok(Pacer::Doubling));
3069        assert_eq!(
3070            Pacer::parse("bounded"),
3071            Ok(Pacer::bounded(MAX_COLLECT_THRESHOLD, LIVE_HEADROOM))
3072        );
3073        assert_eq!(
3074            Pacer::parse("bounded:8M"),
3075            Ok(Pacer::bounded(8 << 20, LIVE_HEADROOM))
3076        );
3077        assert_eq!(Pacer::parse("bounded:1G:3"), Ok(Pacer::bounded(1 << 30, 3)));
3078        assert_eq!(
3079            Pacer::parse("bounded:65536"),
3080            Ok(Pacer::bounded(INITIAL_COLLECT_THRESHOLD, LIVE_HEADROOM))
3081        );
3082
3083        for bad in [
3084            "",
3085            "bounde",
3086            "bounded:huge",
3087            "bounded:8M:x",
3088            "bounded:8M:2:2",
3089        ] {
3090            assert!(Pacer::parse(bad).is_err(), "{bad:?} must not parse");
3091            assert_eq!(
3092                Pacer::from_spec(Some(bad)),
3093                Pacer::DEFAULT,
3094                "{bad:?} must fall back to the default"
3095            );
3096        }
3097    }
3098
3099    /// Pacing counts what an object *costs*, not the size of its fixed block.
3100    /// A `Text` is 48 bytes of block plus a `Box<str>` of whatever the program
3101    /// read; charging only the block would make a text-heavy program invisible
3102    /// to the collector, under-reporting its footprint by essentially all of it
3103    /// (RT-04).
3104    #[test]
3105    fn pacing_charges_the_bytes_a_payload_owns() {
3106        use crate::text::{TEXT, TextPayload};
3107
3108        let alloc_text = |heap: &Heap, len: usize| {
3109            let owned: Box<str> = "x".repeat(len).into_boxed_str();
3110            // SAFETY: TextPayload is TEXT's payload type.
3111            unsafe { heap.alloc_payload_unpaced(&TEXT, TextPayload::owned(owned)) }
3112        };
3113
3114        let small = Heap::new();
3115        alloc_text(&small, 8);
3116        let big = Heap::new();
3117        alloc_text(&big, 64 * 1024);
3118
3119        let charged_small = small.bytes_since_collect.get();
3120        let charged_big = big.bytes_since_collect.get();
3121        assert_eq!(
3122            charged_big - charged_small,
3123            64 * 1024 - 8,
3124            "the Box<str> must be charged at its real length"
3125        );
3126
3127        // And the consequence that matters: one large Text is enough pressure
3128        // to reach the threshold on its own.
3129        assert!(
3130            big.maybe_collect_with(&RootScope::new()),
3131            "a 64 KiB Text must reach the 64 KiB threshold on its own"
3132        );
3133    }
3134
3135    /// **The pacer is charged the stride, which is what turns a narrower block
3136    /// into a smaller resident set (ADR-109).**
3137    ///
3138    /// A header that records no size saves eight bytes per object in the heap,
3139    /// but that saving only turns into a smaller resident set because
3140    /// [`Heap::occupy`] charges the *stride* against `bytes_since_collect`: a
3141    /// 24-byte `Int` is 25% fewer bytes charged than a 32-byte one, so an
3142    /// `Int`-dominated program reaches `collect_threshold` after a third more
3143    /// allocations and takes one fewer doubling to hold the same live set. If
3144    /// pacing ever moved to counting *objects*, or to charging the payload
3145    /// rather than the block, the narrow header would go on being true and stop
3146    /// paying — and nothing else in the suite would notice.
3147    ///
3148    /// So this asserts the product, not the factors: N `Int`s cost N × 24, and
3149    /// the 24 is written out rather than re-derived from `SizeClass`, for the
3150    /// reason `page::tests::an_int_block_is_the_header_plus_eight` gives.
3151    #[test]
3152    fn the_pacer_is_charged_the_narrower_stride() {
3153        const N: usize = 100;
3154        let heap = Heap::new();
3155        assert_eq!(
3156            heap.bytes_since_collect.get(),
3157            0,
3158            "a fresh heap owes nothing"
3159        );
3160
3161        for i in 0..N {
3162            // Above the interned range is irrelevant here — `alloc_unpaced`
3163            // goes to the heap whatever the value — but pacing must not trip a
3164            // collection mid-count, which is exactly what `_unpaced` guarantees.
3165            let _ = heap.alloc_unpaced(INT_PAYLOAD, i as i64);
3166        }
3167
3168        assert_eq!(
3169            heap.bytes_since_collect.get(),
3170            N * 24,
3171            "an Int must be charged its 24-byte block and nothing else"
3172        );
3173    }
3174
3175    /// A source-slice `Text` borrows its owner's buffer. Charging its length
3176    /// would count the same bytes once per slice — a parser that slices a
3177    /// megabyte of input into a thousand fields would report a gigabyte.
3178    #[test]
3179    fn a_source_slice_text_is_charged_nothing_beyond_its_block() {
3180        use crate::text::{TEXT, TextPayload};
3181        let heap = Heap::new();
3182
3183        let owner: Box<str> = "x".repeat(4096).into_boxed_str();
3184        // SAFETY: TextPayload is TEXT's payload type.
3185        let owner_ref = unsafe { heap.alloc_payload_unpaced(&TEXT, TextPayload::owned(owner)) };
3186        let after_owner = heap.bytes_since_collect.get();
3187
3188        // SAFETY: `owner_ref` is the live Text allocated just above, and the
3189        // range lands inside it.
3190        let slice = unsafe { crate::text::SourceSlice::new(owner_ref, 0, 4096) }
3191            .expect("the whole owner is a valid slice of itself");
3192        // SAFETY: TextPayload is TEXT's payload type.
3193        unsafe {
3194            heap.alloc_payload_unpaced(&TEXT, TextPayload::Slice(slice));
3195        }
3196
3197        let (_, block) = BlockLayout::of(&TEXT);
3198        let stride = SizeClass::of(block)
3199            .expect("a Text is on the ladder")
3200            .block_size();
3201        assert_eq!(
3202            heap.bytes_since_collect.get() - after_owner,
3203            stride,
3204            "a slice owns no bytes of its own"
3205        );
3206    }
3207
3208    #[test]
3209    fn repeated_collection_reuses_dead_object_storage() {
3210        let heap = Heap::new();
3211        const OBJECTS_PER_CYCLE: usize = 4_096;
3212
3213        for i in 0..OBJECTS_PER_CYCLE {
3214            let _ = heap.alloc_unpaced(INT_PAYLOAD, i as i64);
3215        }
3216        heap.collect_with(&RootScope::new());
3217        let first_cycle_bytes = heap.committed_bytes();
3218
3219        for cycle in 1..=8 {
3220            for i in 0..OBJECTS_PER_CYCLE {
3221                let _ = heap.alloc_unpaced(INT_PAYLOAD, (cycle * OBJECTS_PER_CYCLE + i) as i64);
3222            }
3223            heap.collect_with(&RootScope::new());
3224        }
3225        let final_bytes = heap.committed_bytes();
3226
3227        assert!(
3228            final_bytes <= first_cycle_bytes.saturating_mul(2),
3229            "reclaiming the same bounded working set repeatedly grew the heap \
3230             from {first_cycle_bytes} to {final_bytes} bytes"
3231        );
3232    }
3233
3234    /// The reason the pool holds pages rather than blocks: a bucket keyed by
3235    /// layout is dead capital for every other layout, so a program that fills a
3236    /// heap with one shape and then another would pay for both. An emptied page
3237    /// is re-classed, so it does not.
3238    #[test]
3239    fn an_emptied_page_is_reused_for_another_size_class() {
3240        use crate::text::{TEXT, TextPayload};
3241        let heap = Heap::new();
3242        // Enough `Text`s to need many pages of their own class.
3243        for _ in 0..8_000 {
3244            // SAFETY: TextPayload is TEXT's payload type.
3245            unsafe {
3246                heap.alloc_payload_unpaced(&TEXT, TextPayload::owned("x"));
3247            }
3248        }
3249        heap.collect_with(&RootScope::new());
3250        let after_texts = heap.page_count();
3251        assert!(after_texts > 1, "the fixture must span several pages");
3252
3253        // The same count of `Int`s, which are a different class entirely and
3254        // pack more densely — so every page they need can come from the pool.
3255        for i in 0..8_000_i64 {
3256            let _ = heap.alloc_unpaced(INT_PAYLOAD, i);
3257        }
3258
3259        assert_eq!(
3260            heap.page_count(),
3261            after_texts,
3262            "the pages the `Text`s emptied must have been re-classed for the `Int`s, \
3263             not left as dead capital beside fresh ones"
3264        );
3265    }
3266
3267    /// An immortal is on a page sweep does not walk, so it is never finalized,
3268    /// never counted, and never handed back out.
3269    #[test]
3270    fn an_immortal_is_invisible_to_sweep_and_to_finalize_all() {
3271        let drops = Arc::new(AtomicUsize::new(0));
3272        {
3273            let heap = Heap::new();
3274            // Through `Immortals::new`, which is the only route there is — the
3275            // `ImmortalWitness` seal is RT-03 and this test does not get to
3276            // widen it.
3277            let immortals = crate::immortal::Immortals::new(&heap);
3278            let immortal = immortals.small_int(7).expect("7 is interned");
3279            let address = immortal.as_ptr();
3280            // A collectable object of the same class, to prove sweep is running
3281            // and that the immortal's page is not simply unreachable.
3282            // SAFETY: DropProbe is DROP_PROBE's payload type.
3283            unsafe {
3284                heap.alloc_payload_unpaced(&DROP_PROBE, DropProbe(Arc::clone(&drops)));
3285            }
3286
3287            heap.collect_with(&RootScope::new());
3288            assert_eq!(drops.load(Ordering::SeqCst), 1, "the probe was reclaimed");
3289            assert_eq!(heap.stats().live_count, 0, "an immortal is not counted");
3290            assert!(!immortal.header().is_poisoned(), "an immortal is not swept");
3291            assert_eq!(immortal.header().heap_id(), Some(heap.id()));
3292            // SAFETY: the immortal is still a live `Int`.
3293            assert_eq!(unsafe { *immortal.payload::<i64>() }, 7);
3294
3295            // Nothing else may be given the immortal's block.
3296            for i in 0..4_000_i64 {
3297                assert_ne!(heap.alloc_unpaced(INT_PAYLOAD, i).as_ptr(), address);
3298            }
3299        }
3300        assert_eq!(
3301            drops.load(Ordering::SeqCst),
3302            1,
3303            "teardown must not finalize anything twice"
3304        );
3305    }
3306
3307    /// Two heaps' pages are disjoint allocations, so one heap's mark bits can
3308    /// never be the other's. The mask is what makes this worth stating: it is
3309    /// arithmetic, and arithmetic does not check which heap it belongs to.
3310    #[test]
3311    fn two_heaps_pages_do_not_alias() {
3312        let first = Heap::new();
3313        let second = Heap::new();
3314        for i in 0..2_000_i64 {
3315            let _ = first.alloc_unpaced(INT_PAYLOAD, i);
3316            let _ = second.alloc_unpaced(INT_PAYLOAD, i);
3317        }
3318        let mine: Vec<usize> = first
3319            .walk_pages()
3320            .map(|page| page.base() as usize)
3321            .collect();
3322        for page in second.walk_pages() {
3323            assert!(!mine.contains(&(page.base() as usize)));
3324        }
3325        assert!(mine.len() > 1);
3326    }
3327
3328    /// The allocator must record the offset it actually used, for every
3329    /// alignment — this is the invariant that makes `GcHeader::payload` and
3330    /// `alloc_raw` two readings of one calculation rather than two
3331    /// calculations that happen to agree.
3332    #[test]
3333    fn every_allocation_records_the_offset_it_was_laid_out_with() {
3334        let heap = Heap::new();
3335
3336        let int = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3337        assert_eq!(
3338            int.payload::<i64>() as usize - int.as_ptr() as usize,
3339            GcHeader::payload_offset_for(INT.align())
3340        );
3341
3342        // SAFETY: Overaligned is OVERALIGNED's payload type.
3343        let over = unsafe { heap.alloc_payload_unpaced(&OVERALIGNED, Overaligned(1)) };
3344        assert_eq!(
3345            over.payload::<Overaligned>() as usize - over.as_ptr() as usize,
3346            GcHeader::payload_offset_for(OVERALIGNED.align())
3347        );
3348        assert_eq!(over.payload::<Overaligned>() as usize % 64, 0);
3349    }
3350
3351    /// Every allocation carries its heap's identity, and only that heap's.
3352    #[test]
3353    fn allocations_carry_their_owning_heap() {
3354        let first = Heap::new();
3355        let second = Heap::new();
3356        let mine = first.alloc_unpaced(INT_PAYLOAD, 1_i64);
3357
3358        assert_eq!(mine.header().heap_id(), Some(first.id()));
3359        assert!(first.owns(mine));
3360        assert!(!second.owns(mine));
3361    }
3362
3363    /// Sweep poisons before it clears the block's `allocated` bit, so the
3364    /// storage stops claiming to be a typed object the moment it stops being
3365    /// one. This is the precondition for reusing swept storage (RT-01): without
3366    /// it, a stale `GcRef` would be traced into whatever the allocator put there
3367    /// next.
3368    #[test]
3369    fn sweeping_poisons_the_reclaimed_header() {
3370        let heap = Heap::new();
3371        let doomed = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3372        assert!(!doomed.header().is_poisoned());
3373
3374        heap.collect_with(&RootScope::new());
3375
3376        assert_eq!(heap.stats().live_count, 0);
3377        assert!(doomed.header().is_poisoned());
3378        assert_eq!(doomed.header().heap_id(), None);
3379    }
3380
3381    /// A stale root — one naming storage this heap has already swept — must be
3382    /// rejected by the same provenance check that rejects a foreign root,
3383    /// rather than dereferencing the finalized payload's descriptor.
3384    #[test]
3385    fn a_swept_reference_is_not_traced_again() {
3386        let heap = Heap::new();
3387        let stale = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3388        heap.collect_with(&RootScope::new());
3389        assert!(stale.header().is_poisoned());
3390
3391        let mut stale_roots = RootScope::new();
3392        stale_roots.root(stale);
3393        heap.collect_with(&stale_roots);
3394
3395        assert_eq!(
3396            heap.stats().live_count,
3397            0,
3398            "a poisoned header must not be resurrected by rooting it"
3399        );
3400    }
3401
3402    /// A stand-in for the crash debugger's value slots (ADR-106): references
3403    /// held somewhere the collector does not trace.
3404    ///
3405    /// It records what each slot's header looked like *at the moment the
3406    /// collector called it*, which is the only way a test can observe where in
3407    /// `collect_inner` the scan sits. The real arm reaches its slots through a
3408    /// `DebugFrameEntry` and is tested against a live `Runtime` in
3409    /// `crate::debug`; this one exists to pin the heap's half of the contract.
3410    struct WeakSlots {
3411        slots: std::cell::RefCell<Vec<Option<GcRef>>>,
3412        poisoned_at_scan: std::cell::RefCell<Vec<bool>>,
3413    }
3414
3415    impl WeakSlots {
3416        fn holding(refs: &[GcRef]) -> WeakSlots {
3417            WeakSlots {
3418                slots: std::cell::RefCell::new(refs.iter().copied().map(Some).collect()),
3419                poisoned_at_scan: std::cell::RefCell::new(Vec::new()),
3420            }
3421        }
3422    }
3423
3424    impl crate::roots::WeakSet for WeakSlots {
3425        fn clear_reclaimed(&self) -> usize {
3426            let mut cleared = 0;
3427            let mut seen = self.poisoned_at_scan.borrow_mut();
3428            for slot in self.slots.borrow_mut().iter_mut() {
3429                let Some(r) = *slot else {
3430                    seen.push(false);
3431                    continue;
3432                };
3433                let poisoned = r.header().is_poisoned();
3434                seen.push(poisoned);
3435                if poisoned {
3436                    *slot = None;
3437                    cleared += 1;
3438                }
3439            }
3440            cleared
3441        }
3442    }
3443
3444    /// The weak set is a *clear*, not a second sweep: it nulls exactly the
3445    /// entries whose objects this collection reclaimed and leaves every entry
3446    /// naming a survivor alone.
3447    ///
3448    /// Nulling everything would satisfy "no dangling reference" and destroy the
3449    /// debugger; retaining everything would satisfy the debugger and make the
3450    /// debug slots strong roots. This is the statement that it does neither.
3451    #[test]
3452    fn the_weak_scan_nulls_only_what_this_collection_reclaimed() {
3453        let heap = Heap::new();
3454        let doomed = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3455        let kept = heap.alloc_unpaced(INT_PAYLOAD, 2_i64);
3456        let weak = WeakSlots::holding(&[doomed, kept]);
3457        let mut scope = RootScope::new();
3458        scope.root(kept);
3459
3460        heap.collect_with_weak(&scope, &weak);
3461
3462        assert_eq!(
3463            *weak.poisoned_at_scan.borrow(),
3464            vec![true, false],
3465            "the scan ran before the sweep, or the sweep did not poison"
3466        );
3467        assert_eq!(
3468            *weak.slots.borrow(),
3469            vec![None, Some(kept)],
3470            "exactly the reclaimed entry becomes an absence"
3471        );
3472        assert_eq!(heap.stats().live_count, 1);
3473    }
3474
3475    /// The scan's position inside `collect_inner`, as an observation rather than
3476    /// a comment (ADR-106 decision 2).
3477    ///
3478    /// Two facts pin it from both sides. The slot's header was already poisoned
3479    /// when the scan looked at it, so the scan runs *after* the sweep. And the
3480    /// very next allocation of that layout takes the same block back and reads
3481    /// as a `Float`, so the scan ran *before* the reissue — which is the moment
3482    /// after which no predicate could have told the two apart.
3483    #[test]
3484    fn the_weak_scan_runs_after_the_sweep_and_before_the_block_is_reissued() {
3485        use crate::scalars::FLOAT_PAYLOAD;
3486        let heap = Heap::new();
3487        let doomed = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3488        let address = doomed.as_ptr();
3489        let weak = WeakSlots::holding(&[doomed]);
3490
3491        heap.collect_with_weak(&RootScope::new(), &weak);
3492
3493        assert_eq!(*weak.poisoned_at_scan.borrow(), vec![true]);
3494        assert_eq!(*weak.slots.borrow(), vec![None]);
3495
3496        let reused = heap.alloc_unpaced(FLOAT_PAYLOAD, 2.5_f64);
3497        assert_eq!(
3498            reused.as_ptr(),
3499            address,
3500            "this test only says anything if the block really came back"
3501        );
3502        assert_eq!(reused.descriptor().name, "Float");
3503    }
3504
3505    /// A page is not keyed by the type that happened to occupy it first, so a
3506    /// reclaimed `Int` block houses the next `Float`. The reused object must be
3507    /// indistinguishable from a fresh one: re-headed with this heap's id,
3508    /// unpoisoned, and reading back as its new type.
3509    ///
3510    /// The exact-address assertion is what pins `relink_pages`'s cursor rewind:
3511    /// the next allocation of a class must take the *lowest* free block.
3512    #[test]
3513    fn a_reclaimed_block_is_reused_for_the_next_object_of_its_layout() {
3514        use crate::scalars::FLOAT_PAYLOAD;
3515        let heap = Heap::new();
3516
3517        let doomed = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3518        let address = doomed.as_ptr();
3519        heap.collect_with(&RootScope::new());
3520        assert!(doomed.header().is_poisoned());
3521
3522        // `Float`'s payload has `Int`'s size and alignment, so it lands on the
3523        // same rung of the ladder.
3524        let reused = heap.alloc_unpaced(FLOAT_PAYLOAD, 2.5_f64);
3525
3526        assert_eq!(
3527            reused.as_ptr(),
3528            address,
3529            "a swept block must be handed back out, not left spent"
3530        );
3531        assert!(!reused.header().is_poisoned());
3532        assert_eq!(reused.header().heap_id(), Some(heap.id()));
3533        assert_eq!(reused.descriptor().name, "Float");
3534        // SAFETY: `reused` was just allocated with FLOAT.
3535        assert_eq!(unsafe { *reused.payload::<f64>() }, 2.5);
3536        assert_eq!(heap.stats().live_count, 1);
3537    }
3538
3539    /// A reset heap keeps its storage — a stale `GcRef` must mask to a page
3540    /// that is still mapped, or the rejection below would be a use-after-free —
3541    /// and repudiates everything recorded on it.
3542    #[test]
3543    fn reset_repudiates_every_page_and_keeps_the_storage() {
3544        let mut heap = Heap::new();
3545        let doomed = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3546        heap.collect_with(&RootScope::new());
3547        let live_ref = heap.alloc_unpaced(INT_PAYLOAD, 3_i64);
3548        let committed = heap.committed_bytes();
3549        assert!(committed > 0);
3550
3551        heap.reset();
3552
3553        assert_eq!(
3554            heap.committed_bytes(),
3555            committed,
3556            "reset keeps every page, so a stale reference still masks to mapped storage"
3557        );
3558        for page in heap.walk_pages() {
3559            assert_eq!(page.live_count(), 0, "no page may still claim a live block");
3560            assert!(
3561                !page.is_immortal(),
3562                "reset repudiates the immortal pages too"
3563            );
3564            assert_eq!(page.heap_id(), heap.id().get());
3565        }
3566        // Both the swept reference and the one that was still live before the
3567        // reset now belong to nobody this heap recognizes.
3568        assert!(!heap.owns(doomed));
3569        assert!(!heap.owns(live_ref));
3570
3571        let fresh = heap.alloc_unpaced(INT_PAYLOAD, 2_i64);
3572        assert_eq!(fresh.header().heap_id(), Some(heap.id()));
3573    }
3574
3575    /// The large path is not decoration: an over-aligned block must be
3576    /// reclaimed and reissued like any other, and must still land at its
3577    /// alignment.
3578    #[test]
3579    fn an_overaligned_block_round_trips_through_its_own_page() {
3580        let heap = Heap::new();
3581        // SAFETY: Overaligned is OVERALIGNED's payload type.
3582        let doomed = unsafe { heap.alloc_payload_unpaced(&OVERALIGNED, Overaligned(1)) };
3583        let address = doomed.as_ptr();
3584
3585        heap.collect_with(&RootScope::new());
3586        let pages = heap.page_count();
3587
3588        // SAFETY: as above.
3589        let reused = unsafe { heap.alloc_payload_unpaced(&OVERALIGNED, Overaligned(2)) };
3590        assert_eq!(
3591            reused.as_ptr(),
3592            address,
3593            "an over-aligned block must be handed back out, not left spent"
3594        );
3595        assert_eq!(reused.payload::<Overaligned>() as usize % 64, 0);
3596        assert_eq!(
3597            heap.page_count(),
3598            pages,
3599            "the emptied large page must be reused, not left beside a fresh one"
3600        );
3601    }
3602
3603    /// One half of the pair `a_swept_block_is_never_handed_to_a_request_of_another_alignment`
3604    /// needs: a payload that agrees with [`Aligned16`]'s in width and differs
3605    /// from it in alignment. **The two widths are deliberately identical.**
3606    ///
3607    /// The test asserts equal block sizes as its own precondition, and whether
3608    /// that holds is a fact about the *header*, not about these structs: a
3609    /// header whose size is not a multiple of 16 makes `payload_offset_for(16)`
3610    /// pad an over-aligned payload forward, so two different payload widths
3611    /// would reach one block size only by cancellation. Giving both the same
3612    /// 32-byte payload makes the parity structural rather than coincidental — it
3613    /// holds for any header whose size is a multiple of 16 — and leaves the
3614    /// block at the 48 bytes this test talks about.
3615    #[repr(C)]
3616    struct Aligned8([u64; 4]);
3617
3618    /// [`Aligned8`]'s payload at [`Aligned8`]'s width, aligned twice as
3619    /// strictly. See [`Aligned8`] for why the widths must match.
3620    #[repr(C, align(16))]
3621    struct Aligned16([u64; 4]);
3622
3623    static ALIGNED_8: TypeDescriptor = TypeDescriptor::for_test::<Aligned8>(
3624        2,
3625        "Aligned8",
3626        probe_trace,
3627        overaligned_drop,
3628        probe_format,
3629        None,
3630        None,
3631        None,
3632    );
3633
3634    static ALIGNED_16: TypeDescriptor = TypeDescriptor::for_test::<Aligned16>(
3635        3,
3636        "Aligned16",
3637        probe_trace,
3638        overaligned_drop,
3639        probe_format,
3640        None,
3641        None,
3642        None,
3643    );
3644
3645    /// The adversarial test for the one way size-class indexing can go wrong.
3646    /// Both blocks are 48 bytes; only the alignment separates them, and a swept
3647    /// 8-aligned block must never satisfy a 16-aligned request.
3648    ///
3649    /// The first assertion is a precondition, not the property. If it is what
3650    /// fails, the fixtures have stopped sharing a block size and nothing about
3651    /// alignment reuse has regressed — read [`Aligned8`]'s doc, which explains
3652    /// what the shared size depends on.
3653    #[test]
3654    fn a_swept_block_is_never_handed_to_a_request_of_another_alignment() {
3655        let (_, eight) = BlockLayout::of(&ALIGNED_8);
3656        let (_, sixteen) = BlockLayout::of(&ALIGNED_16);
3657        assert_eq!(
3658            eight.size, sixteen.size,
3659            "the fixtures must share a size or this test proves nothing"
3660        );
3661        assert_ne!(eight.align, sixteen.align);
3662
3663        let heap = Heap::new();
3664        // SAFETY: Aligned8 is ALIGNED_8's payload type.
3665        let doomed = unsafe { heap.alloc_payload_unpaced(&ALIGNED_8, Aligned8([1, 2, 3, 4])) };
3666        let address = doomed.as_ptr();
3667        heap.collect_with(&RootScope::new());
3668
3669        // SAFETY: Aligned16 is ALIGNED_16's payload type.
3670        let other = unsafe { heap.alloc_payload_unpaced(&ALIGNED_16, Aligned16([5, 6, 7, 8])) };
3671        assert_ne!(
3672            other.as_ptr(),
3673            address,
3674            "a block filed under {{48, 8}} must not satisfy a {{48, 16}} request"
3675        );
3676        assert_eq!(other.payload::<Aligned16>() as usize % 16, 0);
3677    }
3678
3679    /// A reset heap is a different heap, so the refs it minted before the reset
3680    /// no longer pass the provenance check even though the retained pages may
3681    /// hand their addresses out again.
3682    #[test]
3683    fn reset_mints_a_new_heap_identity() {
3684        let mut heap = Heap::new();
3685        let before = heap.id();
3686        let _ = heap.alloc_unpaced(INT_PAYLOAD, 1_i64);
3687
3688        heap.reset();
3689
3690        assert_ne!(heap.id(), before);
3691        assert_eq!(
3692            heap.alloc_unpaced(INT_PAYLOAD, 2_i64).header().heap_id(),
3693            Some(heap.id())
3694        );
3695    }
3696}