luna_core/runtime/table.rs
1//! Lua table: hybrid array + hash.
2//!
3//! Array part uses split tag/payload storage (9 bytes/slot — the Lua 5.5
4//! "compact arrays" layout, bench-validated in benches/value_repr.rs).
5//! Hash part is the PUC node layout: main-position chaining with relocation
6//! (Brent's variation), capacity a power of two, rehash sizing per
7//! luaH_rehash/computesizes.
8
9use crate::runtime::heap::{Gc, GcHeader, Heap, Marker};
10use crate::runtime::value::{RawVal, Value, f2i_exact, raw};
11
12/// Errors that table mutation can raise back to the interpreter.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum TableError {
15 /// `t[nil] = …` — `nil` is forbidden as a key.
16 NilIndex,
17 /// `t[0/0] = …` — NaN floats are forbidden as keys.
18 NanIndex,
19 /// `next` called with a key not present in the table.
20 InvalidNext,
21 /// PUC `luaH_resizearray` — the array part would have to grow past
22 /// `MAXASIZE`, or the hash part past `MAXHBITS`. Raised back as
23 /// "table overflow" so a runaway `a[i] = i` loop walls within budget
24 /// (5.5/5.4 heavy.lua's `toomanyidx` pcalls exactly this scenario).
25 Overflow,
26}
27
28/// PUC `MAXASIZE` analogue: the highest power of two an array part may
29/// grow to. Choose a cap that comfortably fits in the gate's 60-second
30/// budget (each grow is O(n), so 2^27 entries × 16 bytes ≈ 2 GB is the
31/// effective ceiling). Beyond this `rehash` returns `TableError::Overflow`.
32pub(crate) const MAX_ASIZE: usize = 1 << 27;
33
34/// JIT layout constants for table-field IC.
35///
36/// luna-jit's trace lowerer needs to emit direct loads against
37/// `Table.nodes` (the hash part) without paying the helper-call ABI
38/// for each `Op::GetField` / `Op::SetField`. These constants expose
39/// the field offsets so the cranelift IR can be parameterised at
40/// compile time. The `Node` struct itself remains `pub(crate)` — only
41/// the offsets cross the crate boundary.
42///
43/// Layout assumptions:
44/// - `Box<[Node]>` is a fat pointer `(data_ptr, len)` on 64-bit
45/// targets (16 bytes total). The data pointer occupies the low 8
46/// bytes, length the high 8. This is the de-facto Rust ABI for
47/// `Box<[T]>` / `&[T]` but isn't formally guaranteed; the unit
48/// test `node_layout_pinned` and the const assertion
49/// on `size_of::<Box<[Node]>>()` catch drift.
50/// - `Value` is `#[repr(C, u8)]` so the discriminant byte sits at
51/// offset 0 and the payload starts at offset 8 (after 7 bytes of
52/// alignment padding). Total size 16 bytes per the existing
53/// `value_is_16_bytes` test in `runtime/value.rs`.
54/// - `Node` is `#[derive(Clone, Copy)]` with field order
55/// `(key: Value, val: Value, next: i32, dead_key: bool)`, so
56/// `key` lives at offset 0 and `val` at offset 16. The trailing
57/// `next + dead_key` fields are not read by the IC.
58pub mod jit_layout {
59 use super::Node;
60 use crate::runtime::Table;
61
62 /// Byte offset of the `nodes: Box<[Node]>` field within `Table`.
63 /// The fat-ptr low word (data ptr) lives at this offset; the
64 /// high word (length) at `TABLE_NODES_OFFSET + 8`. luna-jit
65 /// adds `TABLE_NODES_PTR_OFFSET` / `TABLE_NODES_LEN_OFFSET`
66 /// constants in `jit_backend/mod.rs` to express that split.
67 pub const TABLE_NODES_OFFSET: usize = std::mem::offset_of!(Table, nodes);
68
69 /// Byte offset of `key: Value` within `Node` (= 0).
70 pub const NODE_KEY_OFFSET: usize = std::mem::offset_of!(Node, key);
71
72 /// Byte offset of `val: Value` within `Node` (= 16 — `key` is 16-byte
73 /// `Value`, no inner padding).
74 pub const NODE_VAL_OFFSET: usize = std::mem::offset_of!(Node, val);
75
76 /// Total `Node` size in bytes (= 40 on 64-bit). Used as the stride
77 /// in `node_addr = nodes_ptr + slot_idx * SIZEOF_NODE`.
78 pub const SIZEOF_NODE: usize = std::mem::size_of::<Node>();
79
80 /// Static guard: pin the assumptions luna-jit relies on at compile
81 /// time. Layout drift here breaks IR emit, so trap it at compile
82 /// time rather than at trace-fire time.
83 ///
84 /// `Box<[T]>` is a fat pointer of `2 * usize` — 16 bytes on 64-bit
85 /// targets, 8 bytes on 32-bit (e.g. `wasm32`). Use a width-aware
86 /// expected size so the wasm32-unknown-unknown CI build does not
87 /// trip the assertion. The runtime layout still matters for luna-jit
88 /// IR emit on 64-bit hosts (the only platforms where Cranelift JIT
89 /// runs); the 32-bit branch documents the size in passing.
90 const _: () = {
91 assert!(std::mem::size_of::<Box<[Node]>>() == 2 * std::mem::size_of::<usize>());
92 assert!(NODE_KEY_OFFSET == 0);
93 assert!(NODE_VAL_OFFSET == 16);
94 assert!(SIZEOF_NODE >= 32);
95 };
96}
97
98#[derive(Clone, Copy)]
99pub(crate) struct Node {
100 key: Value,
101 val: Value,
102 /// absolute index of the next node in this chain, or NONE
103 next: i32,
104 /// PUC `setdeadkey` analogue: the key was a collectable that got swept
105 /// out of a weak table. The Gc pointer in `key` is now dangling — its
106 /// memory may have been reused for a new allocation with potentially
107 /// equal content. Marking the node "dead-key" lets `find_node` skip the
108 /// raw_eq probe (which could spuriously match a reallocated object) and
109 /// `insert_new` treat the slot as available for a fresh main-position
110 /// owner while leaving chain back-links intact for traversal.
111 dead_key: bool,
112}
113
114const NONE: i32 = -1;
115
116impl Node {
117 const EMPTY: Node = Node {
118 key: Value::Nil,
119 val: Value::Nil,
120 next: NONE,
121 dead_key: false,
122 };
123}
124
125/// SoA Robin Hood meta-word layout.
126///
127/// Each `meta[idx]` slot encodes the open-addressing slot state in a
128/// single u16:
129/// - bit 15 (`OCCUPIED_BIT`): 0 = empty, 1 = occupied
130/// - bit 14 (`TOMBSTONE_BIT`): 0 = live, 1 = tombstoned-occupied
131/// - bits 13..0 (`PSL_MASK`): probe-sequence length (0..16383)
132///
133/// The 14-bit PSL field is **far** beyond any realistic Robin Hood
134/// max-PSL at load ≤ 0.75 (expected max ~20 on 1024 slots; even the
135/// long-tail outliers seen empirically with luna's existing hash
136/// distributions stay under 200). 2 bytes/slot is still 20× smaller
137/// than the 40-byte Node, so the SoA bandwidth gain is preserved.
138///
139/// A 1-byte meta with a 6-bit PSL cap of 63 is too narrow: under load
140/// 0.676 on cap=1024 the LuaStr+mix64 hash distribution produces a
141/// long-tail PSL of 64+.
142///
143/// Tombstones do NOT free the slot for `find` (probe continues past), but
144/// DO free it for `insert` (write the new entry, clear the tomb bit). They
145/// accumulate; the rehash path compacts them periodically.
146#[allow(dead_code)]
147pub(crate) mod meta_bits {
148 pub const OCCUPIED_BIT: u16 = 0b1000_0000_0000_0000;
149 pub const TOMBSTONE_BIT: u16 = 0b0100_0000_0000_0000;
150 pub const PSL_MASK: u16 = 0b0011_1111_1111_1111;
151 pub const PSL_MAX: u16 = PSL_MASK;
152 /// Empty slot — bit 15 = 0, all others 0.
153 pub const EMPTY: u16 = 0;
154
155 #[inline(always)]
156 pub fn is_occupied(m: u16) -> bool {
157 (m & OCCUPIED_BIT) != 0
158 }
159 #[inline(always)]
160 pub fn is_tombstone(m: u16) -> bool {
161 (m & TOMBSTONE_BIT) != 0
162 }
163 /// Live = occupied AND not tombstoned. `next()` iteration cursor returns
164 /// these. `find_slot_rh` short-circuits on a live match.
165 #[inline(always)]
166 pub fn is_live(m: u16) -> bool {
167 (m & (OCCUPIED_BIT | TOMBSTONE_BIT)) == OCCUPIED_BIT
168 }
169 #[inline(always)]
170 pub fn psl(m: u16) -> u16 {
171 m & PSL_MASK
172 }
173 #[inline(always)]
174 pub fn pack(psl: u16, tomb: bool) -> u16 {
175 debug_assert!(psl <= PSL_MAX);
176 let mut m = OCCUPIED_BIT | (psl & PSL_MASK);
177 if tomb {
178 m |= TOMBSTONE_BIT;
179 }
180 m
181 }
182}
183
184/// Inline storage threshold. Tables whose array part has
185/// `asize <= INLINE_ASIZE` keep their atags+avals inside the Table
186/// struct itself (`inline_storage`), skipping the slab Box entirely
187/// — binary_trees's `{nil, nil}` and `{...}` 2-element leaves live
188/// here, sparing one allocator round-trip per NewTable.
189pub(crate) const INLINE_ASIZE: u64 = 2;
190/// `INLINE_ASIZE` u64 slots for avals + `ceil(INLINE_ASIZE / 8)` u64
191/// slots covering the atags bytes (with trailing pad). For
192/// `INLINE_ASIZE = 2`: 2 avals + 1 atags = 3 u64s = 24 bytes.
193pub(crate) const INLINE_U64S: usize = INLINE_ASIZE as usize + INLINE_ASIZE.div_ceil(8) as usize;
194
195/// Lua table — hybrid array + hash storage, with optional metatable and
196/// weak-mode flags.
197#[repr(C)]
198pub struct Table {
199 /// read through raw casts by the GC, not by field access
200 #[allow(dead_code)]
201 pub(crate) hdr: GcHeader,
202 /// Single backing pointer for the array part. Points
203 /// to `inline_storage` (asize <= INLINE_ASIZE) or `slab.as_ptr()`
204 /// (asize > INLINE_ASIZE). The JIT inline aset reads this with one
205 /// `load i64`, no branch — the choice between inline and slab is
206 /// already encoded in the pointer. Initialised in `Heap::new_table`
207 /// AFTER the Table reaches its final heap address (so that
208 /// `&mut self.inline_storage` is the stable heap pointer, not a
209 /// stack-local one). Updated by `Table::resize`.
210 pub array_ptr: *mut u8,
211 /// External backing for the array part when
212 /// `asize > INLINE_ASIZE`. Layout: `[avals: asize × 8 bytes][atags:
213 /// asize bytes]`. Empty box (dangling, no alloc) when the inline
214 /// path is in use.
215 pub(crate) slab: Box<[u64]>,
216 /// Length of the array part in slots. u64 (rather than `usize` or
217 /// `u32`) so the JIT can load it with a single `load i64`.
218 pub asize: u64,
219 /// Inline backing used when `asize <= INLINE_ASIZE`.
220 /// Same layout as the slab: avals at low addresses (`asize * 8`
221 /// bytes from offset 0), atags at the trailing `asize` bytes.
222 ///
223 /// `UnsafeCell` because `array_ptr` is a SELF-REFERENTIAL cached
224 /// pointer into this field. Under Stacked Borrows, every
225 /// `&mut self` method call's function-entry retag re-tags the
226 /// whole `*self` byte range Unique and would pop the cached
227 /// pointer's tag — subsequent `array_ptr` accesses would be UB (Miri:
228 /// "retag ... tag does not exist in the borrow stack").
229 /// An `UnsafeCell` region instead receives SharedReadWrite on
230 /// retag, which coexists with the pointer derived from
231 /// `UnsafeCell::get`. All reads/writes of the inline bytes MUST
232 /// go through `array_ptr` / `.get()` — never through a direct
233 /// `&`/`&mut` borrow of the array contents.
234 pub(crate) inline_storage: std::cell::UnsafeCell<[u64; INLINE_U64S]>,
235 /// hash part: power-of-two length (or empty)
236 /// hash part: power-of-two length (or empty)
237 /// `pub(crate)` so `Heap::free_obj` (pool recycle path) can reset.
238 pub(crate) nodes: Box<[Node]>,
239 /// free-slot search position, counts down (PUC lastfree).
240 /// `pub(crate)` so `Heap::new_table` can reset on pool recycle.
241 pub(crate) lastfree: u32,
242 /// SoA Robin Hood hash part, kept parallel to `nodes`. It is not
243 /// on the public get/set/next path yet: the chain `nodes` stay
244 /// authoritative and only the `soa_*` methods touch these arrays.
245 /// `meta` layout per the `meta_bits` module above. Empty
246 /// `Box::new([])` until a `soa_insert` grows it.
247 pub(crate) keys: Box<[Value]>,
248 pub(crate) vals: Box<[Value]>,
249 pub(crate) meta: Box<[u16]>,
250 /// Count of tombstoned-occupied meta slots; rehash trigger.
251 pub(crate) tombstones: u32,
252 /// Iterator-guard counter. Meant to count in-flight `pairs`/`next`
253 /// traversals; while > 0 the SoA path MUST defer rehash (which
254 /// would rebase slot indices and break the PUC
255 /// `nextvar.lua:520-521` invariant). Nothing increments it yet, so
256 /// it stays 0.
257 pub(crate) iter_depth: u32,
258 /// Visible outside the module so the JIT can
259 /// take its field offset at compile time and emit an inline
260 /// "metatable.is_none()" guard before the inline aget fast path.
261 /// `Option<Gc<Table>>` is 8 bytes via the NonNull-pointer-opt: 0
262 /// ⇔ None, non-zero ⇔ Some.
263 pub metatable: Option<Gc<Table>>,
264 /// reserved for an absent-metamethod cache (PUC `flags`); currently
265 /// unread — luna's mm lookup walks `metatable.get` each time
266 #[allow(dead_code)]
267 pub(crate) flags: u8,
268}
269
270// SAFETY: `array_ptr` looks like an unprotected raw pointer field, but
271// it always refers to memory the same Table owns (either its own inline
272// storage or its `slab` Box). The Table is heap-allocated and never
273// moved post-adoption, so the pointer stays valid for the table's
274// lifetime. No thread-unsafety concern: tables are accessed only
275// through the Vm, single-threaded.
276unsafe impl Send for Table {}
277unsafe impl Sync for Table {}
278
279impl Table {
280 pub(crate) fn new(hdr: GcHeader) -> Table {
281 Table {
282 hdr,
283 // `array_ptr` is fixed up in
284 // `Heap::new_table` after the Table reaches its final heap
285 // address (so that `&inline_storage` is the heap address,
286 // not a stack-local one). Null sentinel here so a
287 // bug-detection invariant flags any pre-fixup read.
288 array_ptr: std::ptr::null_mut(),
289 slab: Box::new([]),
290 asize: 0,
291 inline_storage: std::cell::UnsafeCell::new([0; INLINE_U64S]),
292 nodes: Box::new([]),
293 lastfree: 0,
294 keys: Box::new([]),
295 vals: Box::new([]),
296 meta: Box::new([]),
297 tombstones: 0,
298 iter_depth: 0,
299 metatable: None,
300 flags: 0,
301 }
302 }
303
304 /// Set `array_ptr` to the inline storage's stable heap
305 /// address. Called by `Heap::new_table` once the Table is at its
306 /// final location.
307 #[inline]
308 pub(crate) fn init_array_ptr(&mut self) {
309 self.array_ptr = self.inline_storage.get() as *mut u8;
310 }
311
312 /// Freshly-derived base pointer for the array part. Rust-side
313 /// accessors MUST use this instead of the cached `array_ptr`
314 /// field when the backing is inline: a pointer into `*self`
315 /// cached across `&mut self` boundaries is invalidated by every
316 /// function-entry retag under Stacked Borrows — `&mut` retags
317 /// ignore `UnsafeCell` (only `&` retags respect it), so the
318 /// cached tag dies on the next method call (Miri:
319 /// `retag ... tag does not exist in the borrow stack`). Deriving
320 /// through `UnsafeCell::get()` at each use gives a fresh
321 /// SharedReadWrite tag valid for reads AND writes even from
322 /// `&self`. The slab case keeps the cached pointer: its tag
323 /// lives on the heap allocation, outside `*self`, untouched by
324 /// entry retags. `array_ptr` itself stays maintained for the
325 /// JIT, whose emitted code loads the field directly (no Rust
326 /// borrows involved).
327 #[inline(always)]
328 fn array_base(&self) -> *mut u8 {
329 if self.asize <= INLINE_ASIZE {
330 self.inline_storage.get() as *mut u8
331 } else {
332 self.array_ptr
333 }
334 }
335
336 /// Read view onto the array-part tag bytes. Trails
337 /// the avals portion in the active backing (inline or slab).
338 #[inline(always)]
339 pub(crate) fn atags(&self) -> &[u8] {
340 let n = self.asize as usize;
341 if n == 0 {
342 return &[];
343 }
344 // SAFETY: `array_ptr` always points to a buffer with `n`
345 // RawVal slots followed by `n` u8 tag bytes (either
346 // `inline_storage` of `INLINE_U64S` u64s, or a `slab` of
347 // `asize + ceil(asize/8)` u64s). The tag bytes start at byte
348 // offset `n * 8` from the buffer base.
349 unsafe {
350 let ptr = self.array_base().add(n * 8);
351 std::slice::from_raw_parts(ptr, n)
352 }
353 }
354
355 #[inline(always)]
356 pub(crate) fn atags_mut(&mut self) -> &mut [u8] {
357 let n = self.asize as usize;
358 if n == 0 {
359 return &mut [];
360 }
361 // SAFETY: `array_ptr` was allocated by `Heap::init_array_ptr` with `array_cap` slots; the table holds it for its lifetime and the heap is single-threaded so no concurrent writers exist.
362 unsafe {
363 let ptr = self.array_base().add(n * 8);
364 std::slice::from_raw_parts_mut(ptr, n)
365 }
366 }
367
368 /// Read view onto the array-part payload slots. Sits
369 /// at the start of the active backing (u64-aligned, identical size
370 /// and layout to `RawVal`).
371 #[inline(always)]
372 pub(crate) fn avals(&self) -> &[RawVal] {
373 let n = self.asize as usize;
374 if n == 0 {
375 return &[];
376 }
377 // SAFETY: inline_storage / slab both store u64s, so the cast
378 // to `*const RawVal` is alignment-safe (RawVal size = 8,
379 // align = 8). The buffer holds at least `n` such slots.
380 unsafe { std::slice::from_raw_parts(self.array_base() as *const RawVal, n) }
381 }
382
383 #[inline(always)]
384 pub(crate) fn avals_mut(&mut self) -> &mut [RawVal] {
385 let n = self.asize as usize;
386 if n == 0 {
387 return &mut [];
388 }
389 // SAFETY: `array_ptr` was allocated by `Heap::init_array_ptr` with `array_cap` slots; the table holds it for its lifetime and the heap is single-threaded so no concurrent writers exist.
390 unsafe { std::slice::from_raw_parts_mut(self.array_base() as *mut RawVal, n) }
391 }
392
393 /// Allocate a fresh external `[avals: asize × 8 bytes][atags: asize
394 /// bytes]` slab. Only used when `asize > INLINE_ASIZE`. The buffer
395 /// is u64-aligned via `Box<[u64]>` and zeroed (avals = `RawVal::
396 /// NIL` aka `0`; atags = `raw::NIL` aka `0`).
397 fn alloc_slab(asize: usize) -> Box<[u64]> {
398 if asize == 0 {
399 return Box::new([]);
400 }
401 let avals_u64s = asize;
402 let atags_u64s = asize.div_ceil(8);
403 let total = avals_u64s + atags_u64s;
404 vec![0u64; total].into_boxed_slice()
405 }
406
407 /// This table's metatable, if any.
408 pub fn metatable(&self) -> Option<Gc<Table>> {
409 self.metatable
410 }
411
412 /// Install (or clear) this table's metatable. Does not perform any
413 /// `__metatable` guarding; that belongs in the Vm-level `setmetatable`.
414 pub fn set_metatable(&mut self, mt: Option<Gc<Table>>) {
415 self.metatable = mt;
416 }
417
418 /// Bytes occupied by the table's *external* internal allocations
419 /// (slab and nodes). Cheap O(1) read — Box len × element size, no
420 /// allocator query. `Heap::free_obj` subtracts this on the way out
421 /// so the credit applied via `set`/`rehash`/`ensure_*` is symmetric.
422 ///
423 /// Inline storage doesn't count toward this (it's part
424 /// of the Table struct itself, accounted for by `size_of::<Table>()`
425 /// at adoption time). When the array part lives inline, the slab
426 /// is empty and contributes nothing here.
427 pub(crate) fn internal_bytes(&self) -> usize {
428 let n = self.asize as usize;
429 let array_external = if n > INLINE_ASIZE as usize {
430 n + n * std::mem::size_of::<RawVal>()
431 } else {
432 0
433 };
434 let soa_external = self.keys.len() * std::mem::size_of::<Value>()
435 + self.vals.len() * std::mem::size_of::<Value>()
436 + self.meta.len() * std::mem::size_of::<u16>();
437 array_external + self.nodes.len() * std::mem::size_of::<Node>() + soa_external
438 }
439
440 fn asize(&self) -> usize {
441 self.asize as usize
442 }
443
444 fn aget(&self, idx: usize) -> Value {
445 // SAFETY: callers gate on `idx < self.asize()` before reaching here
446 // (`get_int`, `iter_array`, etc.). atags and avals are sized
447 // identically by `rehash`, so a bound check passed against atags
448 // covers avals too.
449 unsafe {
450 Value::pack(
451 *self.atags().get_unchecked(idx),
452 *self.avals().get_unchecked(idx),
453 )
454 }
455 }
456
457 fn aset(&mut self, idx: usize, v: Value) {
458 let (t, b) = v.unpack();
459 // SAFETY: see `aget`. callers (`set_norm`, `set_int`) gate on
460 // `idx < self.asize()`. The two `*_mut` calls each take a
461 // distinct `&mut self` borrow whose lifetime ends at the
462 // statement boundary, so they don't overlap.
463 unsafe {
464 *self.atags_mut().get_unchecked_mut(idx) = t;
465 *self.avals_mut().get_unchecked_mut(idx) = b;
466 }
467 }
468
469 // ---- reads ----
470
471 /// Raw lookup (no `__index` metamethod). Returns `Value::Nil` when
472 /// the key is absent. `Value::Nil` and NaN floats return `nil` directly.
473 pub fn get(&self, key: Value) -> Value {
474 match key {
475 Value::Int(i) => self.get_int(i),
476 Value::Float(f) => match f2i_exact(f) {
477 Some(i) => self.get_int(i),
478 None => {
479 if f.is_nan() {
480 Value::Nil
481 } else {
482 self.get_hash(key)
483 }
484 }
485 },
486 Value::Nil => Value::Nil,
487 k => self.get_hash(k),
488 }
489 }
490
491 /// Integer-keyed variant of [`Self::get`].
492 pub fn get_int(&self, i: i64) -> Value {
493 if i >= 1 && (i as u64) <= self.asize() as u64 {
494 return self.aget(i as usize - 1);
495 }
496 self.get_hash(Value::Int(i))
497 }
498
499 /// String-keyed variant of [`Self::get`] for the GetField fast
500 /// path: the GetField interp arm always has a `Gc<LuaStr>` key from
501 /// `Proto.consts`. Skips the outer `Value` match (which would only
502 /// take the `_ => self.get_hash(k)` arm anyway) so the dispatcher
503 /// pays one less branch per call. ~5 GetField/iter × 1000 iters/cell
504 /// on the Redis-Lua-shape workload — every shaved nanosecond shows
505 /// up at the bench level.
506 #[inline]
507 pub fn get_str(&self, key: crate::runtime::Gc<crate::runtime::string::LuaStr>) -> Value {
508 self.get_hash(Value::Str(key))
509 }
510
511 fn get_hash(&self, k: Value) -> Value {
512 match self.find_node(k) {
513 Some(idx) => self.nodes[idx].val,
514 None => Value::Nil,
515 }
516 }
517
518 /// Same logic as [`find_node`] but exposed
519 /// to luna-core's recorder so it can capture the slot index for
520 /// the table-field IC snapshot. luna-jit reads neither the
521 /// `nodes` field nor `Node` directly; only the slot index
522 /// crosses the crate boundary (baked into the IR as a `iconst`).
523 #[allow(dead_code)]
524 pub(crate) fn find_node_idx(&self, k: Value) -> Option<usize> {
525 self.find_node(k)
526 }
527
528 /// Accessor for the recorder's
529 /// `FieldIcSnapshot` capture: read the slot's value's tag byte
530 /// for the cached_val_tag field. The recorder needs this to
531 /// match the runtime guard the IC emits. Returns None when
532 /// `idx >= nodes.len()`.
533 #[allow(dead_code)]
534 pub(crate) fn node_val_at(&self, idx: usize) -> Option<Value> {
535 self.nodes.get(idx).map(|n| n.val)
536 }
537
538 /// Accessor for `nodes.len()` so the recorder
539 /// can capture the shape-guard's `nodes_len` field without
540 /// reaching into the private `nodes` member.
541 #[allow(dead_code)]
542 pub(crate) fn nodes_capacity(&self) -> usize {
543 self.nodes.len()
544 }
545
546 /// Walk the chain rooted at the key's main position.
547 fn find_node(&self, k: Value) -> Option<usize> {
548 // read-time probe (gc-verify): both the query key and
549 // every node key compared below must be live. This is the
550 // convergence point of ALL hash lookups, so a dangling string
551 // is named at its dereference site with role attribution.
552 #[cfg(feature = "gc-verify")]
553 {
554 let hdr = |v: Value| -> Option<usize> {
555 match v {
556 Value::Str(s) => Some(s.as_ptr() as usize),
557 Value::Table(t) => Some(t.as_ptr() as usize),
558 _ => None,
559 }
560 };
561 if let Some(p) = hdr(k)
562 && crate::runtime::gc_verify_probe::is_freed(p)
563 {
564 panic!("[gc-verify] find_node QUERY key {p:#x} is freed (dangling)");
565 }
566 for (i, n) in self.nodes.iter().enumerate() {
567 // NOTE: tombstones (val nil, key kept) are NOT skipped —
568 // the walk below raw_eq's their keys too.
569 if n.dead_key {
570 continue;
571 }
572 if let Some(p) = hdr(n.key)
573 && crate::runtime::gc_verify_probe::is_freed(p)
574 {
575 panic!(
576 "[gc-verify] find_node NODE key {p:#x} (slot {i}, \
577 tombstone {}, table {:#x}) is freed (dangling)",
578 n.val.is_nil(),
579 self as *const Table as usize
580 );
581 }
582 }
583 }
584 if self.nodes.is_empty() {
585 return None;
586 }
587 let mut idx = self.main_position(k);
588 loop {
589 let n = &self.nodes[idx];
590 // Dead-key slots carry a dangling Gc pointer whose memory may
591 // have been reallocated to a different live object; raw_eq on
592 // such a key can spuriously match the freshly-reused address.
593 // Skip the comparison and only follow `next` (PUC `setdeadkey`
594 // / `equalkey` short-circuit). 5.5 gc.lua :459-:478 was 12%
595 // flaky on this exact path — a swept B-string's slot kept
596 // chaining into A's slot, so `a[k] = nil` (k = A_string) hit
597 // the dead slot and wrote nil there, leaving A's val untouched.
598 if !n.dead_key && n.key.raw_eq(k) {
599 return Some(idx);
600 }
601 if n.next == NONE {
602 return None;
603 }
604 idx = n.next as usize;
605 }
606 }
607
608 // ---- writes ----
609
610 /// Insert / update `(key, val)`. `heap` is used to credit any internal
611 /// Box growth (rehash) to `heap.bytes` so the counter stays in sync with
612 /// real memory; `free_obj` subtracts `internal_bytes()` on the way out.
613 pub fn set(&mut self, heap: &mut Heap, key: Value, val: Value) -> Result<(), TableError> {
614 let k = normalize_set_key(key)?;
615 self.set_norm(heap, k, val)
616 }
617
618 /// PUC `luaV_fastset` / `luaV_finishfastset` analogue: single-walk
619 /// in-place update for an existing key. Returns `true` iff `key` is
620 /// present with a non-nil value and the slot was overwritten with
621 /// `val`. Returns `false` when the key is absent, the slot holds nil,
622 /// or the key normalisation rejects it — the caller is then expected
623 /// to run the `__newindex` chain or fall back to `set` for the raw
624 /// insert.
625 ///
626 /// Collapses the SetField hot path from two hash-chain walks
627 /// (`get` + `set`) to one. The `__newindex` invariant ("fires iff
628 /// `get` would have returned nil") is preserved because this method
629 /// writes only when the existing slot is non-nil — the exact set the
630 /// prior `tb.get(key).is_nil()` gate already excluded from
631 /// `__newindex` eligibility. See
632 /// semantics check.
633 ///
634 /// The caller is responsible for firing `Heap::barrier_back` after a
635 /// `true` return (same contract as the surrounding `raw_set`
636 /// wrapper).
637 pub fn try_set_existing(&mut self, key: Value, val: Value) -> bool {
638 let k = match normalize_set_key(key) {
639 Ok(k) => k,
640 Err(_) => return false,
641 };
642 if let Value::Int(i) = k
643 && i >= 1
644 && (i as u64) <= self.asize() as u64
645 {
646 let idx = i as usize - 1;
647 // SAFETY: `idx < self.asize()` is guarded by the conditional
648 // above, mirroring the bound on `aget`/`aset`.
649 let tag = unsafe { *self.atags().get_unchecked(idx) };
650 if tag != raw::NIL {
651 // Nil-val on a live slot must follow the same tombstone
652 // discipline as `set_norm` — routed through
653 // `clear_existing_slot` so the chain layout and any
654 // future data-layout cutover (SoA) stay aligned.
655 if val.is_nil() {
656 self.clear_existing_slot(k);
657 } else {
658 self.aset(idx, val);
659 }
660 return true;
661 }
662 // Array slot present-but-nil → __newindex eligible: do NOT
663 // write. Caller falls through to the metamethod chain.
664 return false;
665 }
666 if let Some(idx) = self.find_node(k)
667 && !self.nodes[idx].val.is_nil()
668 {
669 if val.is_nil() {
670 self.clear_existing_slot(k);
671 } else {
672 self.nodes[idx].val = val;
673 }
674 return true;
675 }
676 false
677 }
678
679 /// Shared "live with val=Nil is illegal" tombstone routine for the
680 /// two write entry points (`set_norm` and `try_set_existing`). The
681 /// slot must already be known live (array slot inside `asize()` /
682 /// node returned by `find_node`).
683 ///
684 /// Chain-world today:
685 /// - array slot → `aset(_, Nil)` clears the atag, so `next()`'s
686 /// `tag != raw::NIL` filter skips the slot.
687 /// - node slot → soft tombstone (key kept, `val = Nil`); chain
688 /// `next()` filter `!n.val.is_nil()` skips it, and `find_node`
689 /// still routes a future re-insert into the same slot without
690 /// a rehash.
691 ///
692 /// Centralising the discipline here lets a future SoA cutover
693 /// (linear probe, or any layout that switches `next()`'s filter to
694 /// `meta_bits::is_live`) migrate both entry points in lockstep; if
695 /// they diverge, `pairs()` yields `(key, nil)` zombies.
696 fn clear_existing_slot(&mut self, k: Value) {
697 if let Value::Int(i) = k
698 && i >= 1
699 && (i as u64) <= self.asize() as u64
700 {
701 self.aset(i as usize - 1, Value::Nil);
702 return;
703 }
704 if let Some(idx) = self.find_node(k) {
705 self.nodes[idx].val = Value::Nil;
706 }
707 }
708
709 /// Integer-keyed variant of [`Self::set`].
710 pub fn set_int(&mut self, heap: &mut Heap, i: i64, val: Value) -> Result<(), TableError> {
711 self.set_norm(heap, Value::Int(i), val)
712 }
713
714 /// `k` is already normalized (no nil, no NaN, integral floats → Int).
715 fn set_norm(&mut self, heap: &mut Heap, k: Value, v: Value) -> Result<(), TableError> {
716 if let Value::Int(i) = k
717 && i >= 1
718 && (i as u64) <= self.asize() as u64
719 {
720 // Live array slot + Nil write goes through the shared
721 // tombstone routine (see `clear_existing_slot` for the
722 // chain ↔ future-SoA rationale). The non-Nil branch is
723 // identical to a bare `aset` today.
724 if v.is_nil() {
725 self.clear_existing_slot(k);
726 } else {
727 self.aset(i as usize - 1, v);
728 }
729 return Ok(());
730 }
731 if let Some(idx) = self.find_node(k) {
732 if v.is_nil() {
733 self.clear_existing_slot(k);
734 } else {
735 self.nodes[idx].val = v;
736 }
737 return Ok(());
738 }
739 if v.is_nil() {
740 return Ok(()); // absent key set to nil: nothing to record
741 }
742 self.insert_new(heap, k, v)
743 }
744
745 fn insert_new(&mut self, heap: &mut Heap, k: Value, v: Value) -> Result<(), TableError> {
746 if self.nodes.is_empty() {
747 self.rehash(heap, k)?;
748 return self.set_norm(heap, k, v);
749 }
750 let mp = self.main_position(k);
751 // A truly empty slot (key=Nil, !dead_key) is free for direct placement.
752 // A dead-key slot still belongs to some chain (its `next` points to a
753 // live entry the chain reaches), so we treat it as occupied here and
754 // route the new key through the collision path below — that preserves
755 // the back-links into this slot from other nodes' `next` fields.
756 if self.nodes[mp].key.is_nil() && !self.nodes[mp].dead_key {
757 self.nodes[mp] = Node {
758 key: k,
759 val: v,
760 next: NONE,
761 dead_key: false,
762 };
763 return Ok(());
764 }
765 let Some(free) = self.free_pos() else {
766 self.rehash(heap, k)?;
767 return self.set_norm(heap, k, v);
768 };
769 // Dead-key slot: it carries no live key, so by definition nobody else
770 // counts it as "their main position owner". We give it directly to
771 // the new key but preserve `next` so the chain it sits inside still
772 // reaches its downstream entries.
773 if self.nodes[mp].dead_key {
774 let preserved_next = self.nodes[mp].next;
775 self.nodes[mp] = Node {
776 key: k,
777 val: v,
778 next: preserved_next,
779 dead_key: false,
780 };
781 return Ok(());
782 }
783 let other_mp = self.main_position(self.nodes[mp].key);
784 if other_mp != mp {
785 // colliding node is out of its main position: relocate it to the
786 // free slot and take its place
787 let mut prev = other_mp;
788 while self.nodes[prev].next != mp as i32 {
789 prev = self.nodes[prev].next as usize;
790 }
791 self.nodes[prev].next = free as i32;
792 self.nodes[free] = self.nodes[mp];
793 self.nodes[mp] = Node {
794 key: k,
795 val: v,
796 next: NONE,
797 dead_key: false,
798 };
799 } else {
800 // colliding node owns this position: chain the new node behind it
801 self.nodes[free] = Node {
802 key: k,
803 val: v,
804 next: self.nodes[mp].next,
805 dead_key: false,
806 };
807 self.nodes[mp].next = free as i32;
808 }
809 Ok(())
810 }
811
812 fn free_pos(&mut self) -> Option<usize> {
813 while self.lastfree > 0 {
814 self.lastfree -= 1;
815 let n = &self.nodes[self.lastfree as usize];
816 // Dead-key slots are still occupied for chain purposes (their
817 // `next` may be the only path to a downstream entry) — don't
818 // hand them out as free.
819 if n.key.is_nil() && !n.dead_key {
820 return Some(self.lastfree as usize);
821 }
822 }
823 None
824 }
825
826 // ---- rehash (PUC luaH_rehash) ----
827
828 fn rehash(&mut self, heap: &mut Heap, pending: Value) -> Result<(), TableError> {
829 let mut nums = [0usize; 65];
830 let mut int_keys = 0usize;
831 let mut total = 1; // the pending key
832 if let Value::Int(i) = pending
833 && i >= 1
834 {
835 nums[ceil_log2(i as u64)] += 1;
836 int_keys += 1;
837 }
838 let atags = self.atags();
839 for (i, &tag) in atags.iter().enumerate() {
840 if tag != raw::NIL {
841 nums[ceil_log2(i as u64 + 1)] += 1;
842 int_keys += 1;
843 total += 1;
844 }
845 }
846 for n in self.nodes.iter() {
847 if !n.val.is_nil() {
848 total += 1;
849 if let Value::Int(i) = n.key
850 && i >= 1
851 {
852 nums[ceil_log2(i as u64)] += 1;
853 int_keys += 1;
854 }
855 }
856 }
857 // computesizes: optimal array size = largest 2^i with more than 2^(i-1)
858 // integer keys in [1, 2^i]
859 let mut new_asize = 0usize;
860 let mut in_array = 0usize;
861 let mut a = 0usize;
862 let mut two_to_i = 1usize;
863 let mut i = 0usize;
864 while int_keys > two_to_i / 2 {
865 a += nums[i];
866 if a > two_to_i / 2 {
867 new_asize = two_to_i;
868 in_array = a;
869 }
870 i += 1;
871 match two_to_i.checked_mul(2) {
872 Some(n) => two_to_i = n,
873 None => break,
874 }
875 }
876 // PUC `luaH_resizearray` raises "table overflow" when the array part
877 // would have to grow past MAXASIZE. luna mirrors with `MAX_ASIZE`,
878 // checked on both the array and the hash bucket count (the latter is
879 // a power-of-two of total - in_array entries).
880 if new_asize > MAX_ASIZE {
881 return Err(TableError::Overflow);
882 }
883 let hash_entries = total - in_array;
884 if hash_entries > MAX_ASIZE {
885 return Err(TableError::Overflow);
886 }
887 self.resize(heap, new_asize, hash_entries);
888 Ok(())
889 }
890
891 /// Resize the table's array and hash parts. The array part grows
892 /// (or shrinks) to `new_asize` NIL-initialized slots; the hash
893 /// part rounds to the next power of two ≥ `hash_entries`. Any
894 /// existing entries are re-inserted into the new layout. The
895 /// Box growth is debited/credited to `heap.bytes` so `free_obj`
896 /// can subtract the symmetric amount.
897 ///
898 /// `Heap::new_table_sized` calls this on a freshly
899 /// adopted empty table to pre-allocate the array part, sparing
900 /// the table-fill loop from O(log N) intermediate `rehash`es.
901 pub(crate) fn resize(&mut self, heap: &mut Heap, new_asize: usize, hash_entries: usize) {
902 let before = self.internal_bytes();
903 // snapshot the old array entries before we
904 // re-install the backing. The active buffer can be inline OR
905 // slab; `array_ptr` already points to whichever it is, so
906 // walking via raw offsets works the same for either case.
907 let old_asize = self.asize as usize;
908 let mut old_pairs: Vec<(u8, RawVal)> = Vec::with_capacity(old_asize);
909 if old_asize > 0 {
910 // SAFETY: `array_ptr` was set up by `Heap::new_table` or
911 // an earlier `resize`; it covers `old_asize * 9` bytes
912 // (avals + atags).
913 let avals_base = self.array_base() as *const RawVal;
914 let atags_base = unsafe { self.array_base().add(old_asize * 8) as *const u8 };
915 for i in 0..old_asize {
916 // SAFETY: `i < array_len` is enforced by the surrounding loop bound; `atags_base` / `avals_base` point into the table's parallel arrays allocated in lockstep by `init_array_ptr`.
917 let tag = unsafe { *atags_base.add(i) };
918 // SAFETY: `i < array_len` is enforced by the surrounding loop bound; `atags_base` / `avals_base` point into the table's parallel arrays allocated in lockstep by `init_array_ptr`.
919 let val = unsafe { *avals_base.add(i) };
920 old_pairs.push((tag, val));
921 }
922 }
923 let old_nodes = std::mem::take(&mut self.nodes);
924
925 // Install the new array backing first, then update `array_ptr`
926 // (before potentially dropping the old slab via the assignment
927 // below) so the JIT never observes a stale pointer.
928 self.asize = new_asize as u64;
929 if new_asize <= INLINE_ASIZE as usize {
930 // Inline path — zero the inline buffer; drop any prior
931 // external slab.
932 // SAFETY: exclusive &mut self; write through the cell to
933 // stay on the raw-pointer access path (no &mut borrow of
934 // the array contents is ever formed).
935 unsafe {
936 *self.inline_storage.get() = [0; INLINE_U64S];
937 }
938 self.array_ptr = self.inline_storage.get() as *mut u8;
939 self.slab = Box::new([]);
940 } else {
941 // External slab — allocate, then re-point `array_ptr`.
942 self.slab = Self::alloc_slab(new_asize);
943 self.array_ptr = self.slab.as_mut_ptr() as *mut u8;
944 }
945
946 let hsize = if hash_entries == 0 {
947 0
948 } else {
949 hash_entries.next_power_of_two()
950 };
951 self.nodes = vec![Node::EMPTY; hsize].into_boxed_slice();
952 self.lastfree = hsize as u32;
953 // PUC `g->GCtotalbytes` analogue: credit (or debit) the box-size
954 // delta so `Heap.bytes` reflects this table's actual internal
955 // memory. `free_obj` subtracts `internal_bytes()` on the way out.
956 let after = self.internal_bytes();
957 heap.apply_bytes_delta(before, after);
958 // Re-insert old array entries via the public set_norm path
959 // (which handles rehashing if the new array shrinks below the
960 // entry count).
961 for (i, (tag, val)) in old_pairs.into_iter().enumerate() {
962 if tag != raw::NIL {
963 // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
964 let v = unsafe { Value::pack(tag, val) };
965 let _ = self.set_norm(heap, Value::Int(i as i64 + 1), v);
966 }
967 }
968 for n in old_nodes.iter() {
969 if !n.val.is_nil() {
970 let _ = self.set_norm(heap, n.key, n.val);
971 }
972 }
973 }
974
975 fn main_position(&self, k: Value) -> usize {
976 debug_assert!(!self.nodes.is_empty());
977 hash_key(k) as usize & (self.nodes.len() - 1)
978 }
979
980 // ---- length / iteration ----
981
982 /// A border: `n` where `t[n]` is non-nil and `t[n+1]` is nil (PUC `luaH_getn`).
983 /// This is Lua `#` semantics, not a container size — an `is_empty`
984 /// counterpart would be meaningless.
985 #[allow(clippy::len_without_is_empty)]
986 pub fn len(&self) -> i64 {
987 let asize = self.asize();
988 let atags = self.atags();
989 if asize > 0 && atags[asize - 1] == raw::NIL {
990 // binary search inside the array part
991 let (mut lo, mut hi) = (0usize, asize);
992 while hi - lo > 1 {
993 let m = lo + (hi - lo) / 2;
994 if atags[m - 1] == raw::NIL {
995 hi = m;
996 } else {
997 lo = m;
998 }
999 }
1000 return lo as i64;
1001 }
1002 if self.nodes.is_empty() {
1003 return asize as i64;
1004 }
1005 // array is full (or absent): unbound search through the hash part
1006 let mut lo = asize as i64;
1007 let mut hi = lo + 1;
1008 while !self.get_int(hi).is_nil() {
1009 lo = hi;
1010 match hi.checked_mul(2) {
1011 Some(n) => hi = n,
1012 None => {
1013 // pathological sparse keys (the doubling overflowed): scan
1014 // linearly from 1 for the first border, as PUC's
1015 // unbound_search does — finds a small border fast instead of
1016 // returning the huge one.
1017 let mut i = 1i64;
1018 while !self.get_int(i).is_nil() {
1019 i += 1;
1020 }
1021 return i - 1;
1022 }
1023 }
1024 }
1025 while hi - lo > 1 {
1026 let m = lo + (hi - lo) / 2;
1027 if self.get_int(m).is_nil() {
1028 hi = m;
1029 } else {
1030 lo = m;
1031 }
1032 }
1033 lo
1034 }
1035
1036 /// Lua `next`: iterate array part then hash part.
1037 pub fn next(&self, key: Value) -> Result<Option<(Value, Value)>, TableError> {
1038 let start = match key {
1039 Value::Nil => 0,
1040 k => {
1041 let k = match k {
1042 Value::Float(f) => match f2i_exact(f) {
1043 Some(i) => Value::Int(i),
1044 None => k,
1045 },
1046 k => k,
1047 };
1048 if let Value::Int(i) = k
1049 && i >= 1
1050 && (i as u64) <= self.asize() as u64
1051 {
1052 i as usize
1053 } else {
1054 match self.find_node(k) {
1055 Some(idx) => self.asize() + idx + 1,
1056 None => return Err(TableError::InvalidNext),
1057 }
1058 }
1059 }
1060 };
1061 let atags = self.atags();
1062 for i in start..self.asize() {
1063 if atags[i] != raw::NIL {
1064 return Ok(Some((Value::Int(i as i64 + 1), self.aget(i))));
1065 }
1066 }
1067 let hstart = start.saturating_sub(self.asize());
1068 for (idx, n) in self.nodes.iter().enumerate().skip(hstart) {
1069 if !n.val.is_nil() {
1070 let _ = idx;
1071 return Ok(Some((n.key, n.val)));
1072 }
1073 }
1074 Ok(None)
1075 }
1076
1077 /// `(weak_keys, weak_values)` from the metatable's `__mode` field. Read by
1078 /// scanning the metatable for the `__mode` string (no interned key needed
1079 /// inside the collector).
1080 pub(crate) fn weak_mode(&self) -> (bool, bool) {
1081 let Some(mt) = self.metatable else {
1082 return (false, false);
1083 };
1084 for n in mt.nodes.iter() {
1085 if let (Value::Str(k), Value::Str(mode)) = (n.key, n.val)
1086 && k.as_bytes() == b"__mode"
1087 {
1088 let b = mode.as_bytes();
1089 return (b.contains(&b'k'), b.contains(&b'v'));
1090 }
1091 }
1092 (false, false)
1093 }
1094
1095 /// True when this table holds at least one direct reference (array slot,
1096 /// hash key, or hash value) to a coroutine whose mark bit is still clear.
1097 /// Used by the GC's cycle-finalize check (PUC 5.3 gc.lua :502) to detect
1098 /// the table ↔ thread reference cycle that needs an extra GC round before
1099 /// `__gc` runs. Tag-level scan avoids walking the full reference graph.
1100 pub(crate) fn refs_contain_unmarked_coro(&self) -> bool {
1101 use crate::runtime::heap::header_is_marked;
1102 let atags = self.atags();
1103 let avals = self.avals();
1104 for (i, &tag) in atags.iter().enumerate() {
1105 if tag == raw::CORO {
1106 // SAFETY: raw union access — the tag byte at the same index in `atags` was previously confirmed to be `co` (closure/object pointer) so the `co` variant of `RawVal` holds the valid payload.
1107 let p = unsafe { avals[i].co } as *mut crate::runtime::heap::GcHeader;
1108 if !header_is_marked(p) {
1109 return true;
1110 }
1111 }
1112 }
1113 for n in self.nodes.iter() {
1114 if let Value::Coro(co) = n.key
1115 && !header_is_marked(co.as_ptr() as *mut crate::runtime::heap::GcHeader)
1116 {
1117 return true;
1118 }
1119 if let Value::Coro(co) = n.val
1120 && !header_is_marked(co.as_ptr() as *mut crate::runtime::heap::GcHeader)
1121 {
1122 return true;
1123 }
1124 }
1125 false
1126 }
1127
1128 /// `gc-verify`: after a completed sweep, every collectable
1129 /// reference this table still holds (array values, node keys/values,
1130 /// metatable) must point at a live heap object. Nodes flagged
1131 /// `dead_key` are the sanctioned exception — their key pointer is
1132 /// documented-dangling and never dereferenced. `describe` receives
1133 /// (what, node-index, tag-byte, ptr) on violation.
1134 #[cfg(feature = "gc-verify")]
1135 pub(crate) fn verify_refs(
1136 &self,
1137 is_live: &dyn Fn(Value) -> bool,
1138 report: &dyn Fn(&str, usize, Value),
1139 ) {
1140 let atags = self.atags();
1141 let avals = self.avals();
1142 for (i, &tag) in atags.iter().enumerate() {
1143 if raw::is_gc(tag) {
1144 // SAFETY: tags/vals parallel arrays kept in sync by all table writers.
1145 let v = unsafe { Value::pack(tag, avals[i]) };
1146 if !is_live(v) {
1147 report("array value", i, v);
1148 }
1149 }
1150 }
1151 for (i, n) in self.nodes.iter().enumerate() {
1152 if n.val.is_nil() {
1153 continue;
1154 }
1155 if !n.dead_key && !is_live(n.key) {
1156 report("node key", i, n.key);
1157 }
1158 if !is_live(n.val) {
1159 report("node value", i, n.val);
1160 }
1161 }
1162 if let Some(mt) = self.metatable
1163 && !is_live(Value::Table(mt))
1164 {
1165 report("metatable", 0, Value::Table(mt));
1166 }
1167 }
1168
1169 pub(crate) fn trace(&self, m: &mut Marker) {
1170 let (wk, wv) = self.weak_mode();
1171 if wk || wv {
1172 m.weak.push(self as *const Table as *mut Table);
1173 }
1174 // weak keys + strong values = an ephemeron table: its hash values are
1175 // marked only if the key proves reachable (deferred to the convergence
1176 // pass), not here. PUC 5.1 predates ephemerons — under `no_ephemeron`
1177 // a weak-key table marks its values strongly during this pass, which
1178 // is what gc.lua's "weak tables" section requires.
1179 let ephemeron = wk && !wv && !m.no_ephemeron;
1180 if ephemeron {
1181 m.ephemeron.push(self as *const Table as *mut Table);
1182 }
1183 // array keys are integers (never weakly collected); skip values only
1184 // when the table has weak values
1185 if !wv {
1186 let atags = self.atags();
1187 let avals = self.avals();
1188 for (i, &tag) in atags.iter().enumerate() {
1189 if raw::is_gc(tag) {
1190 // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
1191 m.value(unsafe { Value::pack(tag, avals[i]) });
1192 }
1193 }
1194 }
1195 for n in self.nodes.iter() {
1196 if !wk {
1197 m.value(n.key);
1198 }
1199 // ephemeron hash values are deferred; otherwise mark strong values
1200 if !wv && !ephemeron {
1201 m.value(n.val);
1202 }
1203 }
1204 if let Some(mt) = self.metatable {
1205 m.value(Value::Table(mt));
1206 }
1207 }
1208
1209 /// Ephemeron pass: mark the value of every hash entry whose key is alive
1210 /// (`alive` decides — strong/marked keys, plus strings/numbers which are
1211 /// never weakly collected). Returns true if any value was newly marked, so
1212 /// the caller can iterate to a fixpoint (PUC `traverseephemeron`).
1213 pub(crate) fn converge_ephemeron(&self, alive: &dyn Fn(Value) -> bool, m: &mut Marker) -> bool {
1214 let mut changed = false;
1215 for n in self.nodes.iter() {
1216 if !n.val.is_nil() && alive(n.key) {
1217 changed |= m.value(n.val);
1218 }
1219 }
1220 changed
1221 }
1222
1223 /// Clear entries whose weak key/value did not survive marking. `is_dead`
1224 /// reports whether a GC value was left unmarked (about to be swept).
1225 /// Clear weak-table entries whose key/value no longer carries a live
1226 /// reference. `is_dead` is a **pure** check (no side effects); the GC
1227 /// uses `mark_string` to resurrect any string that's still reachable via
1228 /// a *surviving* entry — Lua manual §2.5.4 says strings in weak tables
1229 /// are not collected as long as their entry is, and PUC `iscleared`
1230 /// implements that by marking the string during the same scan.
1231 pub(crate) fn clear_weak(
1232 &mut self,
1233 wk: bool,
1234 wv: bool,
1235 is_dead: &dyn Fn(Value) -> bool,
1236 mark_string: &dyn Fn(Value),
1237 ) {
1238 if wv {
1239 let n = self.asize as usize;
1240 for i in 0..n {
1241 let tag = self.atags()[i];
1242 if raw::is_gc(tag) {
1243 // SAFETY: `tag` and the raw value come from this table's parallel `atags` / `avals` arrays, which the table writers always keep in sync — the tag byte matches the raw payload's discriminator (see `runtime::value` `raw` module).
1244 let v = unsafe { Value::pack(tag, self.avals()[i]) };
1245 if is_dead(v) {
1246 self.atags_mut()[i] = raw::NIL;
1247 self.avals_mut()[i] = RawVal::NIL;
1248 } else {
1249 mark_string(v);
1250 }
1251 }
1252 }
1253 }
1254 for n in self.nodes.iter_mut() {
1255 if n.val.is_nil() {
1256 // PUC `clearbykeys`/`clearbyvalues` end with
1257 // `if (isempty(gval(n))) clearkey(n)`: an EMPTY entry's
1258 // collectable key must be demoted to a dead key. A
1259 // tombstone (`t[k] = nil` leaves val nil, key kept for
1260 // chain links) is otherwise invisible to this sweep AND
1261 // unmarked by the weak-key trace, so its string key gets
1262 // freed while `find_node` still raw_eq's it walking the
1263 // chain (a use-after-free ASAN reports on Linux).
1264 if !n.dead_key
1265 && matches!(
1266 n.key,
1267 Value::Table(_)
1268 | Value::Closure(_)
1269 | Value::Native(_)
1270 | Value::Coro(_)
1271 | Value::Userdata(_)
1272 | Value::Str(_)
1273 )
1274 {
1275 n.key = Value::Nil;
1276 n.dead_key = true;
1277 }
1278 continue;
1279 }
1280 let key_dead = wk && is_dead(n.key);
1281 let val_dead = wv && is_dead(n.val);
1282 if key_dead || val_dead {
1283 // entry removed. PUC `setdeadkey`: when the key was a
1284 // collectable, drop the Gc pointer so a later raw_eq cannot
1285 // spuriously match a new object that gets allocated at the
1286 // same freed address. Keep `next` so the chain back-links
1287 // through this node still reach downstream entries; the
1288 // `dead_key` flag tells `find_node` to skip the comparison
1289 // and `insert_new` to treat the slot as a free
1290 // main-position owner that may inherit the chain.
1291 n.val = Value::Nil;
1292 if matches!(
1293 n.key,
1294 Value::Table(_)
1295 | Value::Closure(_)
1296 | Value::Native(_)
1297 | Value::Coro(_)
1298 | Value::Userdata(_)
1299 | Value::Str(_)
1300 ) {
1301 n.key = Value::Nil;
1302 n.dead_key = true;
1303 }
1304 } else {
1305 // entry survives — resurrect any string reachable through it
1306 if wk {
1307 mark_string(n.key);
1308 }
1309 if wv {
1310 mark_string(n.val);
1311 }
1312 }
1313 }
1314 }
1315}
1316
1317// =====================================================================
1318// SoA + Robin Hood open-addressing hash part.
1319//
1320// Parallel to the chain-walk path: the chain `nodes` / `lastfree` is
1321// the authoritative read path, and nothing outside this block and its
1322// tests calls into it yet. These methods operate only on the `keys` /
1323// `vals` / `meta` / `tombstones` SoA arrays — chain state is never
1324// touched.
1325//
1326// Layout invariants the methods below maintain:
1327// - `keys.len() == vals.len() == meta.len()`, all power-of-two
1328// (or zero in the empty-stub state)
1329// - `meta[i] = meta_bits::EMPTY` iff slot i is free
1330// - tombstoned slots are scanned past by find but reused by insert
1331// - `tombstones` counts the meta slots with TOMBSTONE_BIT set
1332// - load factor (live + tombstone) / cap is kept ≤ 0.75 via
1333// `soa_grow_if_needed`, which bounds PSL
1334// - rehash is REFUSED when `iter_depth > 0` (nothing increments the
1335// counter yet, so the refusal path is unreachable today)
1336// =====================================================================
1337
1338/// Initial SoA capacity when growing from empty. Power of two.
1339/// Picked at 4 so a 3-element table doesn't trigger an immediate
1340/// regrowth.
1341#[allow(dead_code)] // not yet wired into the public table paths
1342pub(crate) const SOA_INITIAL_CAP: usize = 4;
1343
1344/// High load-factor threshold (3/4). SoA grow trigger. PSL_MAX is the u16 14-bit value so
1345/// long-tail PSL overruns are recoverable via grow-retry.
1346#[allow(dead_code)]
1347const SOA_LOAD_NUM: usize = 3;
1348#[allow(dead_code)]
1349const SOA_LOAD_DEN: usize = 4;
1350
1351/// Tombstone density threshold (1/4). When tombstones/cap ≥ 25%
1352/// the next non-resize-triggering rehash compacts them.
1353#[allow(dead_code)]
1354const SOA_TOMB_NUM: usize = 1;
1355#[allow(dead_code)]
1356const SOA_TOMB_DEN: usize = 4;
1357
1358#[allow(dead_code)] // not yet wired into public set/get/next
1359impl Table {
1360 /// Current SoA hash-part capacity in slots (0 = empty stub).
1361 #[inline]
1362 pub(crate) fn soa_cap(&self) -> usize {
1363 self.meta.len()
1364 }
1365
1366 /// Count of live (occupied & not tombstone) SoA slots.
1367 /// O(n) — only used by the equivalence tests; the
1368 /// hot rehash trigger uses `live_estimate = cap*3/4 - tombstones`
1369 /// implicitly via `soa_grow_if_needed`.
1370 #[cfg(test)]
1371 pub(crate) fn soa_live_count(&self) -> usize {
1372 self.meta.iter().filter(|&&m| meta_bits::is_live(m)).count()
1373 }
1374
1375 /// Count of occupied (live OR tombstoned) SoA slots; this is
1376 /// the value the load factor compares against `cap * 3/4`.
1377 #[inline]
1378 fn soa_occupied_count(&self) -> usize {
1379 // O(n) sweep on each insert, so the worst case is bounded by
1380 // per-insert amortised cost. A counter maintained incrementally
1381 // would avoid the sweep if it ever shows up in profiles.
1382 self.meta
1383 .iter()
1384 .filter(|&&m| meta_bits::is_occupied(m))
1385 .count()
1386 }
1387
1388 /// Robin Hood lookup. Returns the slot index of a *live*
1389 /// matching key, or None if absent. Walks past tombstones (they
1390 /// preserve probe chains). Returns None if the SoA cap is zero
1391 /// (empty-stub state). Bound by `cap` probes; in practice
1392 /// expected ≤ 8 at load 0.75.
1393 pub(crate) fn soa_find_slot(&self, k: Value) -> Option<usize> {
1394 let cap = self.meta.len();
1395 if cap == 0 {
1396 return None;
1397 }
1398 let mask = cap - 1;
1399 let mut idx = (hash_key(k) as usize) & mask;
1400 // Walk until empty slot or wrap. The `steps <= cap` bound
1401 // is a safety net: a properly maintained Robin Hood table
1402 // with load < 1 always has at least one empty slot, so a
1403 // full wrap means table invariant violation.
1404 for _ in 0..cap {
1405 let m = self.meta[idx];
1406 if !meta_bits::is_occupied(m) {
1407 return None;
1408 }
1409 if !meta_bits::is_tombstone(m) && self.keys[idx].raw_eq(k) {
1410 return Some(idx);
1411 }
1412 idx = (idx + 1) & mask;
1413 }
1414 None
1415 }
1416
1417 /// Allocate fresh SoA arrays at `new_cap` (power of two) and
1418 /// re-insert every live entry from the old SoA arrays. Tombstones
1419 /// are dropped (count resets to 0). Used by `soa_grow_if_needed`
1420 /// (new_cap = max(SOA_INITIAL_CAP, 2*cap)) and by tombstone
1421 /// compaction (new_cap = cap).
1422 ///
1423 /// IMPORTANT: rehash MUST NOT fire while `iter_depth > 0`. All
1424 /// current callers enter from non-iteration paths.
1425 fn soa_rehash_to(&mut self, heap: &mut Heap, new_cap: usize) -> Result<(), TableError> {
1426 debug_assert!(new_cap.is_power_of_two() && new_cap > 0);
1427 let before = self.internal_bytes();
1428 // Snapshot old live entries. This list is the canonical
1429 // "must be present after rehash" set; we restart from it on
1430 // any PSL-overflow retry.
1431 let mut survivors: Vec<(Value, Value)> = Vec::with_capacity(self.meta.len());
1432 for i in 0..self.meta.len() {
1433 if meta_bits::is_live(self.meta[i]) {
1434 survivors.push((self.keys[i], self.vals[i]));
1435 }
1436 }
1437 // Install fresh empty arrays at `new_cap`. On PSL overflow
1438 // during the re-insert pass (extremely rare with the 14-bit
1439 // PSL budget — would need a pathological hash distribution),
1440 // double the cap and replay the original `survivors` list
1441 // from scratch. We don't try to salvage partial work — the
1442 // rare-path retry cost is bounded by O(n × max_doublings),
1443 // and max_doublings has a hard MAX_ASIZE ceiling.
1444 let mut cap = new_cap;
1445 loop {
1446 if cap > MAX_ASIZE {
1447 return Err(TableError::Overflow);
1448 }
1449 self.keys = vec![Value::Nil; cap].into_boxed_slice();
1450 self.vals = vec![Value::Nil; cap].into_boxed_slice();
1451 self.meta = vec![meta_bits::EMPTY; cap].into_boxed_slice();
1452 self.tombstones = 0;
1453 let mut overflowed = false;
1454 for (k, v) in survivors.iter().copied() {
1455 if self.soa_place_known_absent(k, v).is_err() {
1456 overflowed = true;
1457 break;
1458 }
1459 }
1460 if !overflowed {
1461 break;
1462 }
1463 cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1464 }
1465 let after = self.internal_bytes();
1466 heap.apply_bytes_delta(before, after);
1467 Ok(())
1468 }
1469
1470 /// Raw rob-from-rich placement for a key known to be absent
1471 /// from the SoA arrays. Used by `soa_rehash_to` (re-insert pass)
1472 /// and by `soa_insert` (new-key path after the explicit
1473 /// soa_find_slot check). This routine does NOT auto-grow on a
1474 /// load-factor trigger (caller's responsibility), but hands the
1475 /// pending pair back as `Err((k, v))` when the probe sequence
1476 /// passes `meta_bits::PSL_MAX` before an empty slot turns up. The
1477 /// caller (`soa_insert`) grows and retries.
1478 ///
1479 /// On success returns the slot index where the new key landed
1480 /// (after any rob-from-rich shuffle, the original `k` value is at
1481 /// this returned index).
1482 fn soa_place_known_absent(&mut self, k: Value, v: Value) -> Result<usize, (Value, Value)> {
1483 let cap = self.meta.len();
1484 debug_assert!(cap > 0);
1485 let mask = cap - 1;
1486 let landing = (hash_key(k) as usize) & mask;
1487 let mut idx = landing;
1488 let mut cur_psl: u16 = 0;
1489 let mut cur_key = k;
1490 let mut cur_val = v;
1491 let mut placed_at: Option<usize> = None;
1492 for _ in 0..cap {
1493 let m = self.meta[idx];
1494 if !meta_bits::is_occupied(m) || meta_bits::is_tombstone(m) {
1495 if meta_bits::is_tombstone(m) {
1496 self.tombstones = self.tombstones.saturating_sub(1);
1497 }
1498 self.meta[idx] = meta_bits::pack(cur_psl, false);
1499 self.keys[idx] = cur_key;
1500 self.vals[idx] = cur_val;
1501 return Ok(placed_at.unwrap_or(idx));
1502 }
1503 let stored_psl = meta_bits::psl(m);
1504 if cur_psl > stored_psl {
1505 // Rob: swap cur into this slot, evict stored to continue.
1506 std::mem::swap(&mut cur_key, &mut self.keys[idx]);
1507 std::mem::swap(&mut cur_val, &mut self.vals[idx]);
1508 self.meta[idx] = meta_bits::pack(cur_psl, false);
1509 if placed_at.is_none() {
1510 placed_at = Some(idx);
1511 }
1512 cur_psl = stored_psl;
1513 }
1514 idx = (idx + 1) & mask;
1515 cur_psl = cur_psl.saturating_add(1);
1516 if cur_psl > meta_bits::PSL_MAX {
1517 // PSL exceeds the 14-bit storage budget — exceptionally
1518 // rare with 16384 max. Caller (soa_insert / rehash
1519 // outer loop) handles by growing & retrying. Partial
1520 // state: all entries are still in the table EXCEPT
1521 // `(cur_key, cur_val)` which is the latest homeless
1522 // evictee — return it so caller can re-issue.
1523 return Err((cur_key, cur_val));
1524 }
1525 }
1526 // Wrapped cap probes with no free slot — invariant violation
1527 // (load < 1 should guarantee at least one empty). Signal as
1528 // PSL-overflow equivalent so caller grows + retries.
1529 Err((cur_key, cur_val))
1530 }
1531
1532 /// Grow SoA capacity if the load factor is at or above the
1533 /// 0.75 trigger. Doubles cap; from empty grows to SOA_INITIAL_CAP.
1534 fn soa_grow_if_needed(&mut self, heap: &mut Heap) -> Result<(), TableError> {
1535 // defer rehash when an iterator is in flight (iter_depth is
1536 // never incremented yet, so this does not fire today)
1537 if self.iter_depth > 0 {
1538 return Ok(());
1539 }
1540 let cap = self.meta.len();
1541 if cap == 0 {
1542 return self.soa_rehash_to(heap, SOA_INITIAL_CAP);
1543 }
1544 let occupied = self.soa_occupied_count();
1545 if occupied * SOA_LOAD_DEN >= cap * SOA_LOAD_NUM {
1546 let new_cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1547 return self.soa_rehash_to(heap, new_cap);
1548 }
1549 // Tombstone compaction (same cap, drops tombstones).
1550 if self.tombstones as usize * SOA_TOMB_DEN >= cap * SOA_TOMB_NUM {
1551 return self.soa_rehash_to(heap, cap);
1552 }
1553 Ok(())
1554 }
1555
1556 /// Insert (or update) `(k, v)` in the SoA hash part. Routes
1557 /// through `soa_find_slot` first so an existing key updates its
1558 /// val in place; otherwise rob-from-rich places a new entry.
1559 /// Auto-rehashes if the load factor would exceed 0.75 OR if the
1560 /// place chain runs into a PSL overflow on a pathological hash
1561 /// distribution.
1562 ///
1563 /// Only the equivalence tests call this; it is not yet hooked
1564 /// into public `set` / `set_norm`.
1565 pub(crate) fn soa_insert(
1566 &mut self,
1567 heap: &mut Heap,
1568 k: Value,
1569 v: Value,
1570 ) -> Result<(), TableError> {
1571 debug_assert!(!matches!(k, Value::Nil));
1572 // 1. Update-in-place if key is already present (live slot).
1573 if let Some(idx) = self.soa_find_slot(k) {
1574 self.vals[idx] = v;
1575 return Ok(());
1576 }
1577 // 2. New key: ensure capacity, then place. On PSL-overflow
1578 // from the place chain (extremely rare with 14-bit PSL budget),
1579 // grow + rehash with the homeless evictee merged in.
1580 // `soa_rehash_with_extra` handles further retries internally,
1581 // bounded by MAX_ASIZE.
1582 self.soa_grow_if_needed(heap)?;
1583 match self.soa_place_known_absent(k, v) {
1584 Ok(_) => Ok(()),
1585 Err(homeless) => {
1586 let cap = self.meta.len();
1587 let new_cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1588 self.soa_rehash_with_extra(heap, new_cap, homeless)
1589 }
1590 }
1591 }
1592
1593 /// Rehash to `new_cap` while merging in an extra (k, v) pair
1594 /// not currently in the SoA arrays. Used by `soa_insert` to
1595 /// recover from PSL overflow: the homeless evictee from the failed
1596 /// place chain gets appended to the survivor list before the
1597 /// re-insert pass.
1598 fn soa_rehash_with_extra(
1599 &mut self,
1600 heap: &mut Heap,
1601 new_cap: usize,
1602 extra: (Value, Value),
1603 ) -> Result<(), TableError> {
1604 let before = self.internal_bytes();
1605 let mut survivors: Vec<(Value, Value)> = Vec::with_capacity(self.meta.len() + 1);
1606 for i in 0..self.meta.len() {
1607 if meta_bits::is_live(self.meta[i]) {
1608 survivors.push((self.keys[i], self.vals[i]));
1609 }
1610 }
1611 // Avoid duplicating the extra if its key was already placed at
1612 // some slot during the failed rob chain (the rob may have
1613 // landed the original input into a slot before overflowing on
1614 // a downstream evictee — that case the meta-walk above picks
1615 // it up).
1616 if !survivors.iter().any(|(k, _)| k.raw_eq(extra.0)) {
1617 survivors.push(extra);
1618 }
1619 let mut cap = new_cap;
1620 loop {
1621 if cap > MAX_ASIZE {
1622 return Err(TableError::Overflow);
1623 }
1624 self.keys = vec![Value::Nil; cap].into_boxed_slice();
1625 self.vals = vec![Value::Nil; cap].into_boxed_slice();
1626 self.meta = vec![meta_bits::EMPTY; cap].into_boxed_slice();
1627 self.tombstones = 0;
1628 let mut overflowed = false;
1629 for (k, v) in survivors.iter().copied() {
1630 if self.soa_place_known_absent(k, v).is_err() {
1631 overflowed = true;
1632 break;
1633 }
1634 }
1635 if !overflowed {
1636 break;
1637 }
1638 cap = cap.checked_mul(2).ok_or(TableError::Overflow)?;
1639 }
1640 let after = self.internal_bytes();
1641 heap.apply_bytes_delta(before, after);
1642 Ok(())
1643 }
1644
1645 /// Read SoA hash part. Mirrors `get_hash` but reads from
1646 /// keys/vals/meta rather than nodes. Used by the equivalence
1647 /// tests; not yet hooked into public `get` / `get_hash`.
1648 pub(crate) fn soa_get(&self, k: Value) -> Value {
1649 match self.soa_find_slot(k) {
1650 Some(idx) => self.vals[idx],
1651 None => Value::Nil,
1652 }
1653 }
1654
1655 /// Tombstone deletion. Marks the live slot for `k` as
1656 /// tombstoned, preserving the slot index (no backward shift).
1657 /// Slot-index stability is the PUC `next()` iteration invariant
1658 /// — `nextvar.lua:520-521` requires that deleting prior keys
1659 /// during a `pairs` traversal does NOT move unvisited keys.
1660 /// Backward-shift deletion would violate this; tombstones are
1661 /// the standard Robin Hood resolution.
1662 ///
1663 /// keys[idx] / vals[idx] are reset to Nil so the GC marker is
1664 /// not held to the previous entries — only the tombstone bit
1665 /// distinguishes "occupied tombstone" from "free empty".
1666 ///
1667 /// Returns true if the key was found and deleted, false if absent.
1668 ///
1669 /// Not yet hooked into public `set(k, Nil)`; that has to move
1670 /// together with `next()`.
1671 pub(crate) fn soa_delete(&mut self, k: Value) -> bool {
1672 if let Some(idx) = self.soa_find_slot(k) {
1673 let psl = meta_bits::psl(self.meta[idx]);
1674 self.meta[idx] = meta_bits::pack(psl, true);
1675 self.keys[idx] = Value::Nil;
1676 self.vals[idx] = Value::Nil;
1677 self.tombstones = self.tombstones.saturating_add(1);
1678 true
1679 } else {
1680 false
1681 }
1682 }
1683}
1684
1685fn normalize_set_key(key: Value) -> Result<Value, TableError> {
1686 match key {
1687 Value::Nil => Err(TableError::NilIndex),
1688 Value::Float(f) => match f2i_exact(f) {
1689 Some(i) => Ok(Value::Int(i)),
1690 None if f.is_nan() => Err(TableError::NanIndex),
1691 None => Ok(key),
1692 },
1693 k => Ok(k),
1694 }
1695}
1696
1697fn hash_key(k: Value) -> u64 {
1698 match k {
1699 Value::Int(i) => i as u64, // identity mod size (PUC hashint)
1700 Value::Float(f) => mix64(f.to_bits()),
1701 Value::Bool(b) => b as u64 + 1,
1702 Value::Str(s) => s.hash() as u64,
1703 Value::Table(t) => mix64(t.as_ptr() as u64),
1704 Value::Closure(c) => mix64(c.as_ptr() as u64),
1705 Value::Native(n) => mix64(n.as_ptr() as u64),
1706 Value::Coro(co) => mix64(co.as_ptr() as u64),
1707 Value::Userdata(u) => mix64(u.as_ptr() as u64),
1708 Value::LightUserdata(p) => mix64(p as u64),
1709 Value::Nil => 0, // unreachable as a stored key
1710 }
1711}
1712
1713/// splitmix64 finalizer.
1714fn mix64(mut x: u64) -> u64 {
1715 x ^= x >> 30;
1716 x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
1717 x ^= x >> 27;
1718 x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
1719 x ^ (x >> 31)
1720}
1721
1722/// For k ≥ 1: the bucket l such that k ∈ (2^(l-1), 2^l].
1723fn ceil_log2(k: u64) -> usize {
1724 (u64::BITS - (k - 1).leading_zeros()) as usize
1725}
1726
1727impl Table {
1728 /// Preallocate the array part (table.create); existing contents are
1729 /// preserved.
1730 pub fn ensure_array(&mut self, heap: &mut Heap, n: usize) {
1731 if n > self.asize() {
1732 let hash_entries = self.nodes.iter().filter(|nd| !nd.val.is_nil()).count();
1733 self.resize(heap, n, hash_entries);
1734 }
1735 }
1736}
1737
1738impl Table {
1739 /// Preallocate hash-part capacity (table.create's second size).
1740 pub fn ensure_hash(&mut self, heap: &mut Heap, n: usize) {
1741 let entries = self.nodes.iter().filter(|nd| !nd.val.is_nil()).count();
1742 if n > self.nodes.len() {
1743 self.resize(heap, self.asize(), n.max(entries));
1744 }
1745 }
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750 use super::*;
1751 use crate::runtime::heap::Heap;
1752
1753 fn with_table(f: impl FnOnce(&mut Heap, &mut Table)) {
1754 let mut heap = Heap::new();
1755 let t = heap.new_table();
1756 f(&mut heap, unsafe { t.as_mut() });
1757 }
1758
1759 fn assert_is_border(t: &Table, n: i64) {
1760 if n == 0 {
1761 assert!(t.get_int(1).is_nil(), "border 0 but t[1] non-nil");
1762 } else {
1763 assert!(!t.get_int(n).is_nil(), "border {n} but t[{n}] is nil");
1764 assert!(
1765 t.get_int(n + 1).is_nil(),
1766 "border {n} but t[{}] non-nil",
1767 n + 1
1768 );
1769 }
1770 }
1771
1772 /// Pin `Box<[Node]>` fat-ptr layout at runtime.
1773 /// The luna-jit table-field IC reads `(ptr, len)` directly out of
1774 /// the `nodes` field assuming the data pointer occupies the low 8
1775 /// bytes and the length the high 8 bytes (de-facto Rust ABI on
1776 /// 64-bit targets but not formally guaranteed). If a future Rust
1777 /// release reorders the fat-ptr, this test fails before IC fires
1778 /// at runtime.
1779 #[test]
1780 #[allow(clippy::assertions_on_constants)]
1781 #[cfg(target_pointer_width = "64")]
1782 fn node_layout_pinned() {
1783 use jit_layout::*;
1784 assert_eq!(std::mem::size_of::<Box<[Node]>>(), 16);
1785 assert_eq!(NODE_KEY_OFFSET, 0);
1786 assert_eq!(NODE_VAL_OFFSET, 16);
1787 assert!(SIZEOF_NODE >= 32);
1788
1789 // Construct a real Box<[Node]> with a known length, then
1790 // peek at the fat-pointer's two halves to confirm the
1791 // (data_ptr, len) order. Use a 4-slot box so the length is
1792 // non-zero and the data pointer is heap-allocated.
1793 let b: Box<[Node]> = vec![Node::EMPTY; 4].into_boxed_slice();
1794 let raw_ptr = b.as_ptr();
1795 let raw_len = b.len();
1796 // SAFETY: reading the fat pointer's two words is exactly the
1797 // layout luna-jit's IR assumes; it's the safest possible test
1798 // of that assumption.
1799 let words: [usize; 2] = unsafe { std::mem::transmute_copy(&b) };
1800 assert_eq!(words[0], raw_ptr as usize, "fat-ptr low word = data ptr");
1801 assert_eq!(words[1], raw_len, "fat-ptr high word = len");
1802 drop(b);
1803 }
1804
1805 #[test]
1806 fn sequence_grows_into_array() {
1807 with_table(|heap, t| {
1808 for i in 1..=1000 {
1809 let _ = t.set_int(heap, i, Value::Int(i * 10));
1810 }
1811 for i in 1..=1000 {
1812 assert!(t.get_int(i).raw_eq(Value::Int(i * 10)));
1813 }
1814 assert_eq!(t.len(), 1000);
1815 });
1816 }
1817
1818 #[test]
1819 fn string_and_mixed_keys() {
1820 with_table(|heap, t| {
1821 let k1 = Value::Str(heap.intern(b"alpha"));
1822 let k2 = Value::Str(heap.intern(b"beta"));
1823 t.set(heap, k1, Value::Int(1)).unwrap();
1824 t.set(heap, k2, Value::Int(2)).unwrap();
1825 t.set(heap, Value::Bool(true), Value::Int(3)).unwrap();
1826 t.set(heap, Value::Int(-5), Value::Int(4)).unwrap();
1827 // re-interned key reaches the same slot
1828 let k1b = Value::Str(heap.intern(b"alpha"));
1829 assert!(t.get(k1b).raw_eq(Value::Int(1)));
1830 assert!(t.get(k2).raw_eq(Value::Int(2)));
1831 assert!(t.get(Value::Bool(true)).raw_eq(Value::Int(3)));
1832 assert!(t.get(Value::Int(-5)).raw_eq(Value::Int(4)));
1833 assert!(t.get(Value::Str(heap.intern(b"gamma"))).is_nil());
1834 });
1835 }
1836
1837 #[test]
1838 fn float_keys_normalize_to_int() {
1839 with_table(|heap, t| {
1840 t.set(heap, Value::Float(2.0), Value::Int(22)).unwrap();
1841 assert!(t.get(Value::Int(2)).raw_eq(Value::Int(22)));
1842 t.set(heap, Value::Int(3), Value::Int(33)).unwrap();
1843 assert!(t.get(Value::Float(3.0)).raw_eq(Value::Int(33)));
1844 // -0.0 is key 0
1845 t.set(heap, Value::Float(-0.0), Value::Int(0)).unwrap();
1846 assert!(t.get(Value::Int(0)).raw_eq(Value::Int(0)));
1847 // non-integral floats are their own keys
1848 t.set(heap, Value::Float(0.5), Value::Int(55)).unwrap();
1849 assert!(t.get(Value::Float(0.5)).raw_eq(Value::Int(55)));
1850 assert!(t.get(Value::Int(0)).raw_eq(Value::Int(0)));
1851 });
1852 }
1853
1854 #[test]
1855 fn bad_keys() {
1856 with_table(|heap, t| {
1857 assert_eq!(
1858 t.set(heap, Value::Nil, Value::Int(1)),
1859 Err(TableError::NilIndex)
1860 );
1861 assert_eq!(
1862 t.set(heap, Value::Float(f64::NAN), Value::Int(1)),
1863 Err(TableError::NanIndex)
1864 );
1865 // reads with bad keys are nil, not errors
1866 assert!(t.get(Value::Nil).is_nil());
1867 assert!(t.get(Value::Float(f64::NAN)).is_nil());
1868 });
1869 }
1870
1871 #[test]
1872 fn delete_and_reinsert() {
1873 with_table(|heap, t| {
1874 let k = Value::Str(heap.intern(b"k"));
1875 t.set(heap, k, Value::Int(1)).unwrap();
1876 t.set(heap, k, Value::Nil).unwrap();
1877 assert!(t.get(k).is_nil());
1878 t.set(heap, k, Value::Int(2)).unwrap();
1879 assert!(t.get(k).raw_eq(Value::Int(2)));
1880 // setting an absent key to nil stays absent
1881 let k2 = Value::Str(heap.intern(b"k2"));
1882 t.set(heap, k2, Value::Nil).unwrap();
1883 assert!(t.get(k2).is_nil());
1884 });
1885 }
1886
1887 #[test]
1888 fn borders_with_holes() {
1889 with_table(|heap, t| {
1890 let _ = t.set_int(heap, 1, Value::Int(1));
1891 let _ = t.set_int(heap, 2, Value::Int(2));
1892 assert_eq!(t.len(), 2);
1893 t.set_int(heap, 2, Value::Nil).unwrap();
1894 assert_is_border(t, t.len());
1895 // hash-resident tail
1896 let _ = t.set_int(heap, 1_000_000, Value::Int(1));
1897 assert_is_border(t, t.len());
1898 });
1899 }
1900
1901 #[test]
1902 fn len_on_empty_and_hash_only() {
1903 with_table(|heap, t| {
1904 assert_eq!(t.len(), 0);
1905 let xk = Value::Str(heap.intern(b"x"));
1906 t.set(heap, xk, Value::Int(1)).unwrap();
1907 assert_eq!(t.len(), 0);
1908 });
1909 }
1910
1911 #[test]
1912 fn next_iterates_everything_exactly_once() {
1913 with_table(|heap, t| {
1914 let mut expected = 0i64;
1915 for i in 1..=64 {
1916 let _ = t.set_int(heap, i, Value::Int(i));
1917 expected += i;
1918 }
1919 for i in 0..32 {
1920 let k = Value::Str(heap.intern(format!("s{i}").as_bytes()));
1921 t.set(heap, k, Value::Int(1000 + i)).unwrap();
1922 expected += 1000 + i;
1923 }
1924 t.set(heap, Value::Float(2.5), Value::Int(7)).unwrap();
1925 expected += 7;
1926
1927 let mut sum = 0i64;
1928 let mut count = 0;
1929 let mut key = Value::Nil;
1930 while let Some((k, v)) = t.next(key).unwrap() {
1931 let Value::Int(x) = v else {
1932 panic!("bad value")
1933 };
1934 sum += x;
1935 count += 1;
1936 key = k;
1937 }
1938 assert_eq!(count, 64 + 32 + 1);
1939 assert_eq!(sum, expected);
1940 });
1941 }
1942
1943 #[test]
1944 fn next_skips_nil_values_and_rejects_alien_keys() {
1945 with_table(|heap, t| {
1946 let _ = t.set_int(heap, 1, Value::Int(1));
1947 let _ = t.set_int(heap, 3, Value::Int(3));
1948 let k = Value::Str(heap.intern(b"gone"));
1949 t.set(heap, k, Value::Int(9)).unwrap();
1950 t.set(heap, k, Value::Nil).unwrap();
1951 let mut seen = Vec::new();
1952 let mut key = Value::Nil;
1953 while let Some((k, v)) = t.next(key).unwrap() {
1954 let Value::Int(x) = v else { panic!() };
1955 seen.push(x);
1956 key = k;
1957 }
1958 assert_eq!(seen, vec![1, 3]);
1959 // a key never inserted is invalid for next
1960 let alien = Value::Str(heap.intern(b"never"));
1961 assert!(matches!(t.next(alien), Err(TableError::InvalidNext)));
1962 // ...but a deleted (nil-valued) key is still a valid cursor
1963 assert!(t.next(k).is_ok());
1964 });
1965 }
1966
1967 #[test]
1968 fn collision_relocation_keeps_chains_intact() {
1969 with_table(|heap, t| {
1970 // dense negative ints all land in the hash part; with identity
1971 // hashing they exercise both chain cases heavily
1972 for i in 0..512 {
1973 let _ = t.set_int(heap, -i, Value::Int(i));
1974 }
1975 for i in 0..512 {
1976 assert!(t.get_int(-i).raw_eq(Value::Int(i)), "lost key {}", -i);
1977 }
1978 });
1979 }
1980
1981 // -----------------------------------------------------------------
1982 // SoA Robin Hood equivalence tests.
1983 //
1984 // Cross-check the new SoA + RH path against the existing chain-walk
1985 // path: replay the same insert/lookup sequence on a table via
1986 // `set` (chain) and another via `soa_insert` (SoA), then assert
1987 // `get == soa_get` for every key.
1988 // -----------------------------------------------------------------
1989
1990 fn replay_chain(heap: &mut Heap, ops: &[(Value, Value)]) -> *mut Table {
1991 let t = heap.new_table();
1992 let tref = unsafe { t.as_mut() };
1993 for (k, v) in ops.iter().copied() {
1994 tref.set(heap, k, v).unwrap();
1995 }
1996 t.as_ptr()
1997 }
1998
1999 fn replay_soa(heap: &mut Heap, ops: &[(Value, Value)]) -> *mut Table {
2000 let t = heap.new_table();
2001 let tref = unsafe { t.as_mut() };
2002 for (k, v) in ops.iter().copied() {
2003 tref.soa_insert(heap, k, v).unwrap();
2004 }
2005 t.as_ptr()
2006 }
2007
2008 #[test]
2009 fn c3_soa_equivalence_string_keys() {
2010 let mut heap = Heap::new();
2011 let mut ops = Vec::new();
2012 for i in 0..40 {
2013 let k = Value::Str(heap.intern(format!("key_{i:03}").as_bytes()));
2014 ops.push((k, Value::Int(i * 7)));
2015 }
2016 let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2017 let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2018 for (k, _) in &ops {
2019 let cv = chain.get(*k);
2020 let sv = soa.soa_get(*k);
2021 assert!(
2022 cv.raw_eq(sv),
2023 "SoA vs chain mismatch on key — chain={:?} soa={:?}",
2024 cv,
2025 sv,
2026 );
2027 }
2028 // Absent key returns nil from both paths.
2029 let absent = Value::Str(heap.intern(b"never"));
2030 assert!(chain.get(absent).is_nil());
2031 assert!(soa.soa_get(absent).is_nil());
2032 }
2033
2034 #[test]
2035 fn c3_soa_equivalence_negative_int_keys() {
2036 // Dense negative ints with identity hashing — same collision
2037 // profile as the existing `collision_relocation_keeps_chains_intact`
2038 // test, but verified through the SoA RH path. Triggers
2039 // rob-from-rich repeatedly.
2040 let mut heap = Heap::new();
2041 let mut ops = Vec::new();
2042 for i in 0..256 {
2043 let k = Value::Int(-i);
2044 ops.push((k, Value::Int(i)));
2045 }
2046 let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2047 let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2048 for (k, _) in &ops {
2049 let cv = chain.get(*k);
2050 let sv = soa.soa_get(*k);
2051 assert!(cv.raw_eq(sv), "SoA mismatch on key {:?}", k);
2052 }
2053 }
2054
2055 #[test]
2056 fn c3_soa_equivalence_mixed_keys_with_updates() {
2057 // Insert, then update the same keys with new values — exercises
2058 // the soa_find_slot in-place update branch.
2059 let mut heap = Heap::new();
2060 let kstr = Value::Str(heap.intern(b"x"));
2061 let kint = Value::Int(42);
2062 let kbool = Value::Bool(true);
2063 let ops: Vec<(Value, Value)> = vec![
2064 (kstr, Value::Int(1)),
2065 (kint, Value::Int(2)),
2066 (kbool, Value::Int(3)),
2067 (kstr, Value::Int(11)), // update
2068 (kint, Value::Int(22)), // update
2069 (kbool, Value::Int(33)), // update
2070 ];
2071 let chain = unsafe { &*replay_chain(&mut heap, &ops) };
2072 let soa = unsafe { &*replay_soa(&mut heap, &ops) };
2073 for k in [kstr, kint, kbool] {
2074 assert!(chain.get(k).raw_eq(soa.soa_get(k)));
2075 }
2076 }
2077
2078 #[test]
2079 fn c3_soa_equivalence_delete_then_read() {
2080 // tombstone delete + read on both paths, verify
2081 // matching nil-for-deleted, original-val-for-live.
2082 let mut heap = Heap::new();
2083 let mut ops_insert = Vec::new();
2084 for i in 0..30 {
2085 let k = Value::Str(heap.intern(format!("d_key_{i:03}").as_bytes()));
2086 ops_insert.push((k, Value::Int(i * 11)));
2087 }
2088 let chain = unsafe { &mut *replay_chain(&mut heap, &ops_insert) };
2089 let soa = unsafe { &mut *replay_soa(&mut heap, &ops_insert) };
2090 // Delete every 3rd key.
2091 let mut deleted: Vec<Value> = Vec::new();
2092 for (i, (k, _)) in ops_insert.iter().enumerate() {
2093 if i % 3 == 0 {
2094 // chain: set to Nil is the chain-path's delete equivalent
2095 chain.set(&mut heap, *k, Value::Nil).unwrap();
2096 let was_present = soa.soa_delete(*k);
2097 assert!(was_present, "soa_delete miss on inserted key {:?}", k);
2098 deleted.push(*k);
2099 }
2100 }
2101 // Read each key: deleted → nil, non-deleted → original val.
2102 for (k, v) in &ops_insert {
2103 let cv = chain.get(*k);
2104 let sv = soa.soa_get(*k);
2105 assert!(
2106 cv.raw_eq(sv),
2107 "delete/read mismatch on key {:?} — chain={:?} soa={:?}",
2108 k,
2109 cv,
2110 sv,
2111 );
2112 if deleted.iter().any(|d| d.raw_eq(*k)) {
2113 assert!(cv.is_nil(), "deleted key {:?} chain non-nil", k);
2114 assert!(sv.is_nil(), "deleted key {:?} soa non-nil", k);
2115 } else {
2116 assert!(cv.raw_eq(*v), "live key {:?} chain val drift", k);
2117 }
2118 }
2119 // Deleting an absent key is a no-op (returns false) on SoA.
2120 let absent = Value::Str(heap.intern(b"never_d"));
2121 assert!(!soa.soa_delete(absent));
2122 }
2123
2124 #[test]
2125 fn c3_soa_delete_then_reinsert_uses_tombstone() {
2126 // After delete + reinsert, key is findable with new val. The
2127 // SoA path may reuse the tombstoned slot (preferred) or place
2128 // elsewhere — either is correct as long as soa_get returns
2129 // the new val.
2130 let mut heap = Heap::new();
2131 let t = heap.new_table();
2132 let tref = unsafe { t.as_mut() };
2133 let k = Value::Str(heap.intern(b"reinsert_target"));
2134 tref.soa_insert(&mut heap, k, Value::Int(100)).unwrap();
2135 assert!(tref.soa_get(k).raw_eq(Value::Int(100)));
2136 let pre_tombs = tref.tombstones;
2137 assert!(tref.soa_delete(k));
2138 assert!(tref.tombstones == pre_tombs + 1);
2139 assert!(tref.soa_get(k).is_nil());
2140 // Reinsert with new val.
2141 tref.soa_insert(&mut heap, k, Value::Int(200)).unwrap();
2142 assert!(tref.soa_get(k).raw_eq(Value::Int(200)));
2143 // Tombstone reused — count back to pre_tombs.
2144 assert_eq!(tref.tombstones, pre_tombs);
2145 }
2146
2147 #[test]
2148 fn c3_soa_grows_under_load_pressure() {
2149 // Stress test: insert enough entries to trigger multiple RH
2150 // rehashes (cap doubles at load 0.75). Confirms PSL overflow
2151 // never fires and all keys survive grow cycles.
2152 let mut heap = Heap::new();
2153 let t = heap.new_table();
2154 let tref = unsafe { t.as_mut() };
2155 for i in 0..1024 {
2156 let k = Value::Str(heap.intern(format!("entry_{i:05}").as_bytes()));
2157 tref.soa_insert(&mut heap, k, Value::Int(i)).unwrap();
2158 }
2159 // Verify every key is findable.
2160 for i in 0..1024 {
2161 let k = Value::Str(heap.intern(format!("entry_{i:05}").as_bytes()));
2162 let v = tref.soa_get(k);
2163 assert!(
2164 v.raw_eq(Value::Int(i)),
2165 "SoA lost key entry_{:05} — got {:?}",
2166 i,
2167 v,
2168 );
2169 }
2170 assert!(tref.soa_live_count() == 1024);
2171 // Cap should have grown past the initial SOA_INITIAL_CAP via
2172 // the 0.75 load-factor trigger.
2173 assert!(
2174 tref.soa_cap() >= 2048,
2175 "SoA cap = {} after 1024 inserts — load gate didn't grow",
2176 tref.soa_cap(),
2177 );
2178 }
2179
2180 #[test]
2181 fn rehash_redistributes_into_array() {
2182 with_table(|heap, t| {
2183 // insert 1..n in reverse: starts in hash, rehash must migrate
2184 for i in (1..=256).rev() {
2185 let _ = t.set_int(heap, i, Value::Int(i));
2186 }
2187 assert_eq!(t.len(), 256);
2188 for i in 1..=256 {
2189 assert!(t.get_int(i).raw_eq(Value::Int(i)));
2190 }
2191 });
2192 }
2193}