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}