diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4b278..59b0f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ All notable milestones for the nera project are documented in this file. including a sender paused after its push, and both inbox drain paths. An empty-inbox control retains the initial process's wait and links. +## Deep term operations use heap-allocated work stacks + +- `<` and `>` comparisons, `term_to_binary/1`, `iolist_to_binary/1` and + `erlang:phash2/1` handle iolists nested 200,000 levels deep in head + position without overflowing the native stack. A shared native/BEAM + regression checks ordering, complete encoded bytes and flattened output. +- `erlang:phash2/1,2` now use OTP's portable data-term hashing instead of + Nera's FNV-style hash, including byte-list packing, binary blocks and + order-independent map pairs. Previously stored Nera hash values may change; + fun hashes still lack BEAM's function metadata. + ## Oversized binary copies raise Erlang errors - `binary:copy/2` checks result-size multiplication and the binary header's diff --git a/crates/nera-runtime/src/bif.rs b/crates/nera-runtime/src/bif.rs index 75ee1c6..6724922 100644 --- a/crates/nera-runtime/src/bif.rs +++ b/crates/nera-runtime/src/bif.rs @@ -281,146 +281,6 @@ pub unsafe fn bif_byte_size(_heap: &mut ProcessHeap, args: &[ErlTerm]) -> Result // Hashing // --------------------------------------------------------------------------- -/// FNV-1a-style hash mixing. -#[inline] -fn hash_mix(hash: u64, value: u64) -> u64 { - (hash ^ value).wrapping_mul(16777619) -} - -/// Compute a deterministic hash of an Erlang term. -/// -/// Integers equal to floats must produce the same hash (e.g. 1 and 1.0). -/// # Safety -/// Source graphs obey the enclosing BIF live, immutable, unmoved input contract. -unsafe fn hash_erlang_term(term: ErlTerm) -> u64 { - // Small integer - if let Some(n) = term.as_small_int() { - return hash_mix(0xcbf29ce484222325, n as u64); - } - - // Atom - if let Some(id) = term.as_atom_id() { - return hash_mix(0xcbf29ce484222325 ^ 0xa1, id as u64); - } - - // Pid - if term.is_pid() { - return hash_mix(0xcbf29ce484222325 ^ 0xb1, term.0); - } - - // Nil - if term.is_nil() { - return 0xcbf29ce484222325 ^ 0xc1; - } - - // Cons cell — iterate to avoid stack overflow - if term.is_cons() { - let mut hash = 0xcbf29ce484222325_u64 ^ 0xd1; - let mut current = term; - while current.is_cons() { - if let Some(ptr) = current.as_cons_ptr() { - unsafe { - hash = hash_mix(hash, hash_erlang_term((*ptr).head)); - current = (*ptr).tail; - } - } else { - break; - } - } - // Hash the tail (nil or improper) - if !current.is_nil() { - hash = hash_mix(hash, hash_erlang_term(current)); - } - return hash; - } - - // Boxed types - if term.is_boxed() { - if let Some(ptr) = term.as_boxed_ptr() { - unsafe { - let header = BoxedHeader(*ptr); - match header.subtype() { - BOXED_FLOAT => { - let bits = *ptr.add(1); - let val = f64::from_bits(bits); - // If float is a whole number, hash it as integer for consistency - if val.fract() == 0.0 && val.is_finite() { - return hash_mix(0xcbf29ce484222325, val as i64 as u64); - } - return hash_mix(0xcbf29ce484222325 ^ 0xe1, bits); - } - BOXED_TUPLE => { - let arity = header.arity() as usize; - let mut hash = hash_mix(0xcbf29ce484222325 ^ 0xf1, arity as u64); - for i in 1..=arity { - hash = hash_mix(hash, hash_erlang_term(ErlTerm(*ptr.add(i)))); - } - return hash; - } - BOXED_BINARY | BOXED_REFC_BINARY => { - let size = header.arity() as usize; - let byte_len = size.div_ceil(8); - let data_ptr = if header.subtype() == BOXED_BINARY { - (ptr.add(1)) as *const u8 - } else { - // Read the stable backing object to get the data pointer. - // The raw word is a *const BinaryData, not a direct data pointer. - let raw_arc_ptr = *ptr.add(REFC_BINARY_PTR_WORD_INDEX) as *const BinaryData; - let offset = *ptr.add(REFC_BINARY_OFFSET_WORD_INDEX) as usize; - let vec_ref = &*raw_arc_ptr; - vec_ref.as_ptr().add(offset) - }; - let mut hash = 0xcbf29ce484222325_u64 ^ 0xa2; - for i in 0..byte_len { - hash = hash_mix(hash, *data_ptr.add(i) as u64); - } - return hash; - } - BOXED_MAP => { - let num_pairs = header.arity() as usize; - // Order-independent: XOR individual pair hashes - let mut hash = 0_u64; - for i in 0..num_pairs { - let key = ErlTerm(*ptr.add(1 + i * 2)); - let val = ErlTerm(*ptr.add(2 + i * 2)); - let pair_hash = hash_mix( - hash_erlang_term(key), - hash_erlang_term(val), - ); - hash ^= pair_hash; - } - return hash_mix(0xcbf29ce484222325 ^ 0xb2, hash); - } - BOXED_BIGINT => { - let limb_count = header.arity() as usize; - let mut hash = 0xcbf29ce484222325_u64 ^ 0xc2; - // Hash sign word - hash = hash_mix(hash, *ptr.add(1)); - // Hash limbs - for i in 0..limb_count { - hash = hash_mix(hash, *ptr.add(2 + i)); - } - return hash; - } - BOXED_REFERENCE => { - return hash_mix(0xcbf29ce484222325 ^ 0xd2, *ptr.add(1)); - } - BOXED_CLOSURE => { - return hash_mix(0xcbf29ce484222325 ^ 0xe2, *ptr.add(1)); - } - _ => { - // Unknown boxed type — hash raw bits - return hash_mix(0xcbf29ce484222325 ^ 0xff, term.0); - } - } - } - } - } - - // Fallback - hash_mix(0xcbf29ce484222325, term.0) -} - /// `erlang:phash2/1` -- portable hash of an Erlang term. /// /// Returns an integer in the range 0..(2^27 - 1). @@ -432,7 +292,7 @@ pub unsafe fn bif_phash2(_heap: &mut ProcessHeap, args: &[ErlTerm]) -> Result Res Some(r) if r > 0 => r as u64, _ => return Err(ErlTerm::atom(ATOM_BADARG)), }; - let hash = hash_erlang_term(args[0]); + let hash = u64::from(crate::phash2::hash(args[0])); let result = (hash % range) as i64; Ok(ErlTerm::small_int(result)) } @@ -1647,6 +1507,15 @@ const NEW_PID_EXT: u8 = 88; /// # Safety /// Source graphs obey the enclosing BIF live, immutable, unmoved input contract. unsafe fn etf_encode(term: ErlTerm, buf: &mut Vec) { + let mut pending = vec![term]; + while let Some(term) = pending.pop() { + etf_encode_next(term, buf, &mut pending); + } +} + +// Schedule children in reverse wire order without borrowing the process heap +// mutably: these raw terms cannot move while the work stack is being drained. +unsafe fn etf_encode_next(term: ErlTerm, buf: &mut Vec, pending: &mut Vec) { // Small integer if let Some(val) = term.as_small_int() { if (0..=255).contains(&val) { @@ -1739,11 +1608,9 @@ unsafe fn etf_encode(term: ErlTerm, buf: &mut Vec) { buf.push(LIST_EXT); buf.extend_from_slice(&(elements.len() as u32).to_be_bytes()); - for e in &elements { - etf_encode(*e, buf); - } - // Tail (nil for proper list, or the improper tail) - etf_encode(cursor, buf); + // Tail follows every element, including for improper lists. + pending.push(cursor); + pending.extend(elements.into_iter().rev()); return; } @@ -1766,8 +1633,8 @@ unsafe fn etf_encode(term: ErlTerm, buf: &mut Vec) { buf.push(LARGE_TUPLE_EXT); buf.extend_from_slice(&(arity as u32).to_be_bytes()); } - for i in 1..=arity { - etf_encode(ErlTerm(*ptr.add(i)), buf); + for i in (1..=arity).rev() { + pending.push(ErlTerm(*ptr.add(i))); } } BOXED_BINARY | BOXED_REFC_BINARY => { @@ -1781,11 +1648,11 @@ unsafe fn etf_encode(term: ErlTerm, buf: &mut Vec) { let npairs = header.arity() as usize; buf.push(MAP_EXT); buf.extend_from_slice(&(npairs as u32).to_be_bytes()); - for i in crate::ffi::compare::map_iteration_order(ptr, npairs) { + for i in crate::ffi::compare::map_iteration_order(ptr, npairs).into_iter().rev() { let key = ErlTerm(*ptr.add(1 + i * 2)); let val = ErlTerm(*ptr.add(2 + i * 2)); - etf_encode(key, buf); - etf_encode(val, buf); + pending.push(val); + pending.push(key); } } BOXED_BIGINT => { @@ -1814,7 +1681,7 @@ unsafe fn etf_encode(term: ErlTerm, buf: &mut Vec) { buf.extend_from_slice(b"ref"); // id as integer let id = *ptr.add(1); - etf_encode(ErlTerm::small_int(id as i64), buf); + pending.push(ErlTerm::small_int(id as i64)); } _ => { // Unsupported boxed type — encode as atom 'undefined' diff --git a/crates/nera-runtime/src/ffi/compare.rs b/crates/nera-runtime/src/ffi/compare.rs index b4db90c..82a595c 100644 --- a/crates/nera-runtime/src/ffi/compare.rs +++ b/crates/nera-runtime/src/ffi/compare.rs @@ -176,10 +176,65 @@ pub(crate) unsafe fn map_iteration_order(ptr: *const u64, npairs: usize) -> Vec< order } +enum CompareWork { + Terms(ErlTerm, ErlTerm, bool), + TupleFields { a: ErlTerm, b: ErlTerm, next: usize, exact: bool }, + MapFields(Box), +} + +struct MapComparison { + a: Vec<(ErlTerm, ErlTerm)>, + b: Vec<(ErlTerm, ErlTerm)>, + next: usize, + exact: bool, +} + /// Map keys require exact ordering throughout their graph, including map values /// nested inside a key. Ordinary map values retain the caller's comparison mode. /// The term lifetime contract is the same as compare_terms's. unsafe fn compare_terms_mode(a: ErlTerm, b: ErlTerm, exact: bool) -> i64 { + let mut pending = Vec::new(); + let mut current = CompareWork::Terms(a, b, exact); + loop { + let (a, b, exact) = match current { + CompareWork::Terms(a, b, exact) => (a, b, exact), + CompareWork::TupleFields { a, b, next, exact } => { + let pa = a.as_boxed_ptr().unwrap(); + let pb = b.as_boxed_ptr().unwrap(); + if next < BoxedHeader(*pa).arity() as usize { + pending.push(CompareWork::TupleFields { a, b, next: next + 1, exact }); + } + (ErlTerm(*pa.add(next)), ErlTerm(*pb.add(next)), exact) + } + CompareWork::MapFields(mut fields) => { + let n = fields.a.len(); + let index = fields.next % n; + let keys = fields.next < n; + let (a, b, exact) = if keys { + (fields.a[index].0, fields.b[index].0, true) + } else { + (fields.a[index].1, fields.b[index].1, fields.exact) + }; + fields.next += 1; + if fields.next < 2 * n { + pending.push(CompareWork::MapFields(fields)); + } + (a, b, exact) + } + }; + let result = compare_terms_step(a, b, exact, &mut pending); + if result != 0 { return result; } + let Some(next) = pending.pop() else { return 0; }; + current = next; + } +} + +// Equal structural prefixes save one indexed continuation per container, +// not every remaining field. Wide tuples still stop at the first mismatch. +unsafe fn compare_terms_step( + a: ErlTerm, b: ErlTerm, exact: bool, + pending: &mut Vec, +) -> i64 { use crate::term::*; // Fast path: identical raw values @@ -210,12 +265,12 @@ unsafe fn compare_terms_mode(a: ErlTerm, b: ErlTerm, exact: bool) -> i64 { if b_num { return 1; } // non-number > number // Same-type structural comparison - // Cons cells: compare heads, then tails recursively + // Cons cells: compare heads before tails if let (Some(pa), Some(pb)) = (a.as_cons_ptr(), b.as_cons_ptr()) { unsafe { - let r = compare_terms_mode((*pa).head, (*pb).head, exact); - if r != 0 { return r; } - return compare_terms_mode((*pa).tail, (*pb).tail, exact); + pending.push(CompareWork::Terms((*pa).tail, (*pb).tail, exact)); + pending.push(CompareWork::Terms((*pa).head, (*pb).head, exact)); + return 0; } } @@ -229,9 +284,11 @@ unsafe fn compare_terms_mode(a: ErlTerm, b: ErlTerm, exact: bool) -> i64 { let aa = ha.arity(); let ab = hb.arity(); if aa != ab { return if aa < ab { -1 } else { 1 }; } - for i in 1..=aa as usize { - let r = compare_terms_mode(ErlTerm(*pa.add(i)), ErlTerm(*pb.add(i)), exact); - if r != 0 { return r; } + if aa > 0 { + if aa > 1 { + pending.push(CompareWork::TupleFields { a, b, next: 2, exact }); + } + pending.push(CompareWork::Terms(ErlTerm(*pa.add(1)), ErlTerm(*pb.add(1)), exact)); } return 0; } @@ -263,13 +320,10 @@ unsafe fn compare_terms_mode(a: ErlTerm, b: ErlTerm, exact: bool) -> i64 { r if r > 0 => std::cmp::Ordering::Greater, _ => std::cmp::Ordering::Equal, }); - for i in 0..na { - let r = compare_terms_mode(ka[i].0, kb[i].0, true); - if r != 0 { return r; } - } - for i in 0..na { - let r = compare_terms_mode(ka[i].1, kb[i].1, exact); - if r != 0 { return r; } + if na > 0 { + pending.push(CompareWork::MapFields(Box::new(MapComparison { + a: ka, b: kb, next: 0, exact, + }))); } return 0; } diff --git a/crates/nera-runtime/src/ffi/helpers.rs b/crates/nera-runtime/src/ffi/helpers.rs index 110aa89..8f6d378 100644 --- a/crates/nera-runtime/src/ffi/helpers.rs +++ b/crates/nera-runtime/src/ffi/helpers.rs @@ -352,7 +352,7 @@ pub(super) unsafe fn iodata_to_bytes(term: ErlTerm) -> Option> { flatten_iolist(term, &mut buf).then_some(buf) } -/// Recursively flatten an Erlang iolist to bytes. +/// Flatten an Erlang iolist to bytes in depth-first order. /// /// An iolist is either: /// - a binary @@ -364,54 +364,39 @@ pub(super) unsafe fn iodata_to_bytes(term: ErlTerm) -> Option> { /// All input terms and reachable objects must remain live and unmoved by GC /// for this call. Binary backing bytes must not be concurrently mutated. pub(super) unsafe fn flatten_iolist(term: ErlTerm, buf: &mut Vec) -> bool { - if term.is_nil() { + if let Some(value) = term.as_small_int() { + let Ok(byte) = u8::try_from(value) else { return false; }; + buf.push(byte); return true; } - if term.is_binary() { - if let Some((ptr, len)) = unsafe { binary_data(term) } { - let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - buf.extend_from_slice(bytes); - return true; - } - return false; - } - if let Some(val) = term.as_small_int() { - if (0..=255).contains(&val) { - buf.push(val as u8); - return true; - } - return false; - } - if term.is_cons() { - let mut cursor = term; - while cursor.is_cons() { - if let Some(ptr) = cursor.as_cons_ptr() { - unsafe { - let head = (*ptr).head; - // Keep flat byte lists on a direct loop, without one - // recursive call per byte. Only nested data recurses. - if let Some(value) = head.as_small_int() { - let Ok(byte) = u8::try_from(value) else { return false; }; - buf.push(byte); - } else if !flatten_iolist(head, buf) { - return false; - } - cursor = (*ptr).tail; - } + let mut pending = vec![term]; + while let Some(mut cursor) = pending.pop() { + // Keep flat byte lists on a direct loop; save only the tails of + // nested heads, preserving depth-first output and tail validation. + while let Some(ptr) = cursor.as_cons_ptr() { + let head = (*ptr).head; + cursor = (*ptr).tail; + if let Some(value) = head.as_small_int() { + let Ok(byte) = u8::try_from(value) else { return false; }; + buf.push(byte); } else { - return false; + if !cursor.is_nil() { pending.push(cursor); } + cursor = head; + break; } } - // Improper list tail: must be nil or binary - if cursor.is_nil() { - return true; - } - if cursor.is_binary() { - return flatten_iolist(cursor, buf); + if cursor.is_cons() { + pending.push(cursor); + } else if cursor.is_nil() { + continue; + } else if cursor.is_binary() { + let Some((ptr, len)) = binary_data(cursor) else { return false; }; + buf.extend_from_slice(std::slice::from_raw_parts(ptr, len)); + } else { + return false; } - return false; } - false + true } /// Map an `io::Error` to a well-known POSIX atom ID. diff --git a/crates/nera-runtime/src/lib.rs b/crates/nera-runtime/src/lib.rs index 8844ce4..aefd4f5 100644 --- a/crates/nera-runtime/src/lib.rs +++ b/crates/nera-runtime/src/lib.rs @@ -49,6 +49,7 @@ pub static MALLOC_CONF: Option<&'static libc::c_char> = { mod base64; mod io_format; mod native_work; +mod phash2; pub mod term; pub mod binary; pub mod match_context; diff --git a/crates/nera-runtime/src/phash2.rs b/crates/nera-runtime/src/phash2.rs new file mode 100644 index 0000000..66b5ac3 --- /dev/null +++ b/crates/nera-runtime/src/phash2.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Ericsson AB 2022-2026. All Rights Reserved. +// Atom-name hashing: Copyright Ericsson AB 1996-2025. All Rights Reserved. +// Adapted to Nera's term layout and explicit work stack from Erlang/OTP 29.0: +// https://github.com/erlang/otp/blob/OTP-29.0/erts/emulator/beam/erl_term_hashing.c +// https://github.com/erlang/otp/blob/OTP-29.0/erts/emulator/beam/atom.c +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +// CONDITIONS OF ANY KIND, either express or implied. See the License for the +// specific language governing permissions and limitations under the License. + +//! Portable term hashing, following OTP 29's make_hash2. +//! The work stack holds terms and map continuations, never recursive hashes. + +use crate::atom_table::global_atom_table; +use crate::term::*; + +const H: u32 = 0x9e3779b9; + +// Bob Jenkins' MIX, as used by ERTS (not lookup3's newer finalizer). +fn mix(mut a: u32, mut b: u32, mut c: u32) -> u32 { + mix_words(&mut a, &mut b, &mut c); + c +} + +fn uint_hash(hash: u32, x: u32, y: u32, constant: u32) -> u32 { + mix(constant.wrapping_add(x), constant.wrapping_add(y), hash) +} + +fn hc(n: u32) -> u32 { + H.wrapping_mul(n) +} + +fn block_hash(bytes: &[u8], initial: u32) -> u32 { + let (mut a, mut b, mut c) = (H, H, initial); + let (chunks, remainder) = bytes.as_chunks::<12>(); + for chunk in chunks { + a = a.wrapping_add(u32::from_le_bytes(chunk[..4].try_into().unwrap())); + b = b.wrapping_add(u32::from_le_bytes(chunk[4..8].try_into().unwrap())); + c = c.wrapping_add(u32::from_le_bytes(chunk[8..].try_into().unwrap())); + // MIX updates all three words; subsequent blocks need a and b too. + mix_words(&mut a, &mut b, &mut c); + } + c = c.wrapping_add(bytes.len() as u32); + for (i, &byte) in remainder.iter().enumerate() { + match i { + 0..=3 => a = a.wrapping_add((byte as u32) << (8 * i)), + 4..=7 => b = b.wrapping_add((byte as u32) << (8 * (i - 4))), + _ => c = c.wrapping_add((byte as u32) << (8 * (i - 8 + 1))), + } + } + mix(a, b, c) +} + +fn mix_words(a: &mut u32, b: &mut u32, c: &mut u32) { + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 13); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 8); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 13); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 12); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 16); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 5); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 3); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 10); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 15); +} + +fn atom_hash(name: &str) -> u32 { + let mut bytes = name.bytes().peekable(); + let mut hash = 0u32; + while let Some(mut byte) = bytes.next() { + // ERTS preserves its pre-UTF-8 hash for Latin-1 characters. + if byte & 0xfe == 0xc2 && bytes.peek().is_some_and(|b| b & 0xc0 == 0x80) { + byte = (byte << 6) | (bytes.next().unwrap() & 0x3f); + } + hash = (hash << 4).wrapping_add(byte as u32); + let high = hash & 0xf0000000; + hash ^= (high >> 24) ^ high; + } + hash +} + +enum Work { + Term(ErlTerm), + MapPair, + MapTail { hash: u32, pairs: u32 }, +} + +/// Hash a live, immutable term graph without native-stack recursion. +/// +/// # Safety +/// Every reachable object and binary backing must remain valid and unmoved +/// throughout the call. No allocation on the process heap or moving GC occurs. +pub(crate) unsafe fn hash(term: ErlTerm) -> u32 { + let mut pending = vec![Work::Term(term)]; + let mut hash = 0u32; + let mut pairs = 0u32; + while let Some(work) = pending.pop() { + let term = match work { + Work::Term(term) => term, + Work::MapPair => { + pairs ^= hash; + hash = 0; + continue; + } + Work::MapTail { + hash: outer, + pairs: outer_pairs, + } => { + hash = uint_hash(outer, pairs, 0, hc(19)); + pairs = outer_pairs; + continue; + } + }; + if let Some(n) = term.as_small_int() { + if (-134217728..134217728).contains(&n) { + if n < 0 { + hash = uint_hash(hash, n.unsigned_abs() as u32, 0, H); + } + hash = uint_hash(hash, n as u32, 0, H); + } else { + let magnitude = n.unsigned_abs(); + hash = uint_hash( + hash, + magnitude as u32, + (magnitude >> 32) as u32, + hc(if n < 0 { 10 } else { 11 }), + ); + } + } else if let Some(id) = term.as_atom_id() { + let value = global_atom_table() + .resolve(id) + .map_or(0, |name| atom_hash(&name)); + hash = if hash == 0 { + value + } else { + uint_hash(hash, value, 0, hc(3)) + }; + } else if term.is_nil() { + hash = if hash == 0 { + 3468870702 + } else { + uint_hash(hash, 2, 0, hc(2)) + }; + } else if term.is_cons() { + let mut cursor = term; + let (mut packed, mut count) = (0u32, 0); + while let Some(ptr) = cursor.as_cons_ptr() { + let Some(byte) = (*ptr).head.as_small_int().filter(|n| (0..=255).contains(n)) + else { + break; + }; + packed = (packed << 8) + byte as u32; + count += 1; + if count == 4 { + hash = uint_hash(hash, packed, 0, hc(4)); + packed = 0; + count = 0; + } + cursor = (*ptr).tail; + } + if count != 0 { + hash = uint_hash(hash, packed, 0, hc(4)); + } + if let Some(ptr) = cursor.as_cons_ptr() { + pending.push(Work::Term((*ptr).tail)); + pending.push(Work::Term((*ptr).head)); + } else { + pending.push(Work::Term(cursor)); + } + } else if let Some(pid) = term.as_pid_id() { + hash = uint_hash(hash, pid.get() as u32, 0, hc(5)); + } else if let Some(ptr) = term.as_boxed_ptr() { + let header = BoxedHeader(*ptr); + match header.subtype() { + BOXED_TUPLE => { + hash = uint_hash(hash, header.arity() as u32, 0, hc(9)); + for i in (1..=header.arity() as usize).rev() { + pending.push(Work::Term(ErlTerm(*ptr.add(i)))); + } + } + BOXED_MAP => { + let size = header.arity() as usize; + hash = uint_hash(hash, size as u32, 0, hc(16)); + if size != 0 { + pending.push(Work::MapTail { hash, pairs }); + hash = 0; + pairs = 0; + for i in (0..size).rev() { + pending.push(Work::MapPair); + pending.push(Work::Term(ErlTerm(*ptr.add(2 + i * 2)))); + pending.push(Work::Term(ErlTerm(*ptr.add(1 + i * 2)))); + } + } + } + BOXED_BINARY | BOXED_REFC_BINARY => { + let bytes = term.binary_bytes_any().unwrap(); + let bits = header.arity() as usize; + // ERTS truncates binary byte lengths to 32 bits for + // compatibility with its historical portable hashes. + let full = (bits / 8) as u32 as usize; + let tail = bits % 8; + let initial = hc(13).wrapping_add(hash); + hash = if full == 0 && tail == 0 { + initial + } else { + block_hash(&bytes[..full], initial) + }; + if tail != 0 { + hash = uint_hash( + hash, + tail as u32, + (bytes[full] >> (8 - tail)) as u32, + hc(15), + ); + } + } + BOXED_BIGINT => { + let constant = hc(if *ptr.add(1) != 0 { 10 } else { 11 }); + for i in 0..header.arity() as usize { + let limb = *ptr.add(2 + i); + hash = uint_hash(hash, limb as u32, (limb >> 32) as u32, constant); + } + } + BOXED_FLOAT => { + let mut bits = *ptr.add(1); + if f64::from_bits(bits) == 0.0 { + bits = 0; + } + hash = uint_hash(hash, (bits >> 32) as u32, bits as u32, hc(12)); + } + BOXED_REFERENCE => { + hash = uint_hash(hash, *ptr.add(REFERENCE_ID_WORD_INDEX) as u32, 0, hc(7)); + } + // Native closures do not carry BEAM module/index/old_uniq + // metadata. Retain an identity-based hash for these terms. + BOXED_CLOSURE => hash = uint_hash(hash, *ptr.add(1) as u32, 0, H), + _ => hash = uint_hash(hash, term.0 as u32, 0, H), + } + } + } + hash +} diff --git a/docs/KNOWN_LIMITS.md b/docs/KNOWN_LIMITS.md index 0a13f5c..3c93abd 100644 --- a/docs/KNOWN_LIMITS.md +++ b/docs/KNOWN_LIMITS.md @@ -93,13 +93,17 @@ These are real differences from the BEAM that we have reproduced. - Terms nested thousands of levels deep (an iolist built by prepending, for example) survive garbage collection, can be sent to other processes, captured in spawned funs, and stored in and read back from ETS and - `persistent_term`, but other operations on them still recurse on the - native stack and crash. Measured on macOS arm64 with a list nested in head - position: comparing it with `<` or `==`, or using it as an `ordered_set` - key, from about 8,000 levels, `term_to_binary/1` from about 11,000, - `erlang:phash2/1` from about 27,000 and `iolist_to_binary/1` from about - 45,000. `=:=` and `iolist_size/1` work at 200,000 levels. The BEAM handles - all of these. + `persistent_term`. Comparing head-nested iolists with `<` or `>`, encoding + them with `term_to_binary/1`, flattening them with `iolist_to_binary/1` + and hashing them with `erlang:phash2/1` work at 200,000 levels, with + BEAM-equivalent results. `==`, `=:=` and `iolist_size/1` also work at that + depth. The ordering fix also applies to head-nested `ordered_set` keys. + This is not a guarantee for every graph shape: sorting map keys still + invokes nested comparisons, and deeply nested ETF decoding remains recursive. +- `erlang:phash2/1,2` use OTP's portable hash for data terms, replacing the + earlier Nera-specific hash. Hashes previously stored by Nera may change. + Native funs lack BEAM's module/index metadata, so their hashes are not + BEAM-compatible. Process and reference identities are local to each runtime. - Comprehension filters that are guard expressions, such as `[X || X <- L, map_size(X) > 0]`, are treated as plain expressions, so a filter that should be skipped can raise. diff --git a/tests/README.md b/tests/README.md index f69041e..7e8f9d6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -242,6 +242,7 @@ These are automatically skipped by the runner and show up as SKIP in output. |----------------------|---------------------------------|------------| | `bool_ops.erl` | and/or/not, andalso/orelse | ~20 | | `comparison_ops.erl` | Equality, ordering, mixed types | ~30 | +| `deep_term_ops.erl` | 200,000-level head-nested iolists: both ordering directions, equal-prefix tails, exact ETF bytes, flattened bytes and OTP-derived portable hashes; same fixture on native and BEAM | Full outputs | | `map_comparison.erl` | Exact recursive map-key ordering, numeric map-value equality, insertion order, comparison operators, sibling key searches and ETS ordered-set identity/order; also run on BEAM | Table-driven | | `atom_order.erl` | Atoms order by name whatever their creation order: operators, guards, lists:sort/1,2, usort/1, max/1 and min/1, run-time atoms, prefixes across eight bytes, Unicode names, tuples, lists, map keys and values, Cowlib's server-sent event keys, erlang:max/2 and min/2 between types, apply and fun calls, ordered_set iteration; also run on BEAM | Table-driven | | `max_min_numbers.erl` | erlang:max/2 and min/2 return an argument unchanged, the first when equal: mixed integers and floats, signed zeros, exact comparison above 2^53, 2^60, 2^63 and beyond 64 bits, apply, fun and guard calls; also run on BEAM | 46 | diff --git a/tests/deep_term_ops.erl b/tests/deep_term_ops.erl new file mode 100644 index 0000000..4c7d3a5 --- /dev/null +++ b/tests/deep_term_ops.erl @@ -0,0 +1,66 @@ +%% EXPECT: deep_term_ops_ok +%% Regression: <, >, term_to_binary/1, iolist_to_binary/1 and phash2/1 on +%% 200,000-level head-nested iolists must finish with BEAM's results. +%% Run this same fixture on native Nera and BEAM. ETF bytes and flattened +%% payloads are checked in full, without decoding a deeply nested ETF term. +-module(deep_term_ops). +-export([test/0, hash_readings/0]). + +-define(DEPTH, 200000). + +test() -> + L = build(?DEPTH, []), + Other = build(?DEPTH, [0]), + true = L < Other, + true = Other > L, + false = L > Other, + false = Other < L, + Equal = build(?DEPTH, []), + false = L < Equal, + false = L > Equal, + %% Equal heads must resume the saved tail comparison, not return early. + true = [L, <<"a">>] < [Equal, <<"b">>], + true = [L, <<"b">>] > [Equal, <<"a">>], + + Payload = binary:copy(<<"chunk">>, ?DEPTH), + Payload = iolist_to_binary(L), + %% Each level is LIST_EXT(2), then the inner term, then BINARY_EXT and NIL. + Prefix = binary:copy(<<108, 0, 0, 0, 2>>, ?DEPTH), + Suffix = binary:copy(<<109, 0, 0, 0, 5, "chunk", 106>>, ?DEPTH), + Encoded = <<131, Prefix/binary, 106, Suffix/binary>>, + Encoded = term_to_binary(L), + %% OTP 29.1.2 reading, reproducible with hash_readings/0 below. + 108113888 = erlang:phash2(L), + + %% Portable hashes must match OTP, not only be in range. These inputs + %% cover the leaf and container paths used inside nested term graphs. + lists:foreach(fun({Term, Hash}) -> Hash = erlang:phash2(Term) end, hash_cases()), + io:format("deep_term_ops_ok~n"), + ok. + +build(0, Acc) -> Acc; +build(N, Acc) -> build(N - 1, [Acc, <<"chunk">>]). + +%% Read from BEAM, never from a reimplementation of its hash algorithm: +%% erlc -o /tmp tests/deep_term_ops.erl +%% erl -noshell -pa /tmp -eval 'deep_term_ops:hash_readings(), halt().' +hash_readings() -> + lists:foreach(fun({Term, _}) -> io:format("~p~n", [erlang:phash2(Term)]) end, + hash_cases()), + io:format("deep: ~p~n", [erlang:phash2(build(?DEPTH, []))]). + +hash_cases() -> + [{[], 113427502}, {0, 88723725}, {1, 2614250}, {-1, 44071773}, + {134217727, 112602999}, {134217728, 12354923}, + {-134217728, 69672967}, {-134217729, 76739502}, + {1 bsl 62, 29238129}, {-(1 bsl 62), 82210594}, {1 bsl 100, 126219984}, + {0.0, 20875736}, {-0.0, 20875736}, {1.0, 77147068}, {-1.5, 13753666}, + {hello, 7258927}, {'é', 233}, {'λ', 3483}, + {<<>>, 13708901}, {<<"chunk">>, 33132665}, {<<1:3>>, 73037028}, + {<< <> || I <- lists:seq(0, 255) >>, 3064723}, + {[1, 2, 3, 4, 5], 66056394}, {[1, 2 | <<"chunk">>], 82515919}, + {[0, hello, 255], 60092478}, {{}, 87486268}, + {{hello, [1, 2], <<"chunk">>}, 56268936}, + {#{}, 39679005}, {#{a => 1, b => 2}, 103634663}, + {#{a => #{x => 1}, b => [2, 3]}, 69340935}, + {build(2, []), 113444212}].