From 73df15978d321c6e3473f6679d1013cbb0a0e11a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:36:29 +0000 Subject: [PATCH] bun:test: print a long run of array holes as one line in matcher diffs A failing matcher that prints a diff formatted every index an array claims: one get_index call and one `undefined,` line per index, holes included. An array of length 1e7 that stores one element took 1.6 s and 585 MB, and threw a 150 MB message. At length 2**32 - 1 the process aborted. In a matcher diff, a run of more than 8 holes now prints as one `N x empty items,` line, the wording console.log uses. The array arm reads each index once as an own slot. For a long run it asks the array's storage for the next index: a scan of the vector, then one sorted copy of the sparse map's keys per array. Snapshots keep one line per hole. Shorter runs in diffs do too. FormatOptions::enable_colors, MessageLevel and the level parameter of JestPrettyFormat::format were never anything but off and Debug. They are gone, so the new option does not make the colour-on half of the formatter live. --- src/jsc/JSValue.rs | 47 ++ src/jsc/bindings/bindings.cpp | 106 ++- src/runtime/test_runner/diff_format.rs | 6 +- src/runtime/test_runner/mod.rs | 5 +- src/runtime/test_runner/pretty_format.rs | 295 ++++---- .../test/printing/diff-array-holes.fixture.ts | 351 ++++++++++ test/js/bun/test/printing/diffexample.test.ts | 653 +++++++++++++++++- 7 files changed, 1305 insertions(+), 158 deletions(-) create mode 100644 test/js/bun/test/printing/diff-array-holes.fixture.ts diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 1eb5cfd82bfa..198f64122689 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2593,6 +2593,53 @@ impl JSValue { index => Some(index as u32), } } + /// The vector-storage half of `next_present_index`: the smallest index + /// `>= start` that the array's butterfly vector holds. Never reads the + /// sparse map, so asking once per run of holes stays linear in what the + /// array stores. + /// Asserts `self` is a `JSArray` (`Array` or `DerivedArray`). + pub fn next_present_vector_index(self, start: u32) -> Option { + debug_assert!(self.is_cell() && self.js_type().is_array()); + unsafe extern "C" { + safe fn Bun__JSArray__nextPresentVectorIndex(this: JSValue, start: u32) -> u64; + } + match Bun__JSArray__nextPresentVectorIndex(self, start) { + u64::MAX => None, + index => Some(index as u32), + } + } + /// The indices in `start..end` that the array's sparse map holds, + /// ascending. The map is walked and sorted once, like JSC's + /// `getOwnIndexedPropertyNames`. The result is a copy: it stays valid, and + /// goes stale, when user code changes the array later. + /// Asserts `self` is a `JSArray` (`Array` or `DerivedArray`). + pub fn sorted_sparse_indices(self, start: u32, end: u32) -> Vec { + use bun_core::UnwrapOrOom as _; + debug_assert!(self.is_cell() && self.js_type().is_array()); + unsafe extern "C" { + fn Bun__JSArray__copySortedSparseIndices( + this: JSValue, + start: u32, + end: u32, + out: *mut u32, + capacity: u32, + ) -> u32; + } + let mut out: Vec = Vec::new(); + loop { + let capacity = u32::try_from(out.capacity()).unwrap_or(u32::MAX); + // SAFETY: `out` has room for `capacity` u32s and C++ writes at most that many. + let count = unsafe { + Bun__JSArray__copySortedSparseIndices(self, start, end, out.as_mut_ptr(), capacity) + }; + if count <= capacity { + // SAFETY: C++ initialized the first `count <= capacity` elements. + unsafe { out.set_len(count as usize) }; + return out; + } + out.try_reserve_exact(count as usize).unwrap_or_oom(); + } + } /// `JSValue.getNameProperty` — the value's `.name` (function/class /// name). Empty for empty/`undefined`/`null`. pub fn get_name_property(self, global: &JSGlobalObject) -> JsResult { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index edb52d236f68..fa356a316f1f 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -7103,22 +7103,16 @@ extern "C" bool Bun__JSArray__contiguousVectorIsStillValid( return reinterpret_cast(butterfly->contiguous().data()) == expected; } -// Smallest own present index of a JSArray that is >= `start`, or UINT64_MAX -// when every index from `start` to the end of the array is a hole. Mirrors the -// butterfly walk in JSObject::getOwnIndexedPropertyNames so the caller can skip -// a run of holes without probing each index of a huge sparse array. -extern "C" uint64_t Bun__JSArray__nextPresentIndex( - JSC::EncodedJSValue encodedValue, - uint32_t start) -{ - static constexpr uint64_t notFound = std::numeric_limits::max(); - - JSC::JSArray* array = uncheckedDowncast(JSC::JSValue::decode(encodedValue).asCell()); +static constexpr uint64_t noPresentIndex = std::numeric_limits::max(); +// Smallest index >= `start` whose slot in the array's vector storage holds a +// value, or noPresentIndex. Does not read an ArrayStorage array's sparse map. +static uint64_t nextPresentVectorIndex(JSC::JSArray* array, uint32_t start) +{ switch (array->indexingType()) { case ALL_BLANK_INDEXING_TYPES: case ALL_UNDECIDED_INDEXING_TYPES: - return notFound; + return noPresentIndex; case ALL_INT32_INDEXING_TYPES: case ALL_CONTIGUOUS_INDEXING_TYPES: { @@ -7128,7 +7122,7 @@ extern "C" uint64_t Bun__JSArray__nextPresentIndex( if (butterfly->contiguous().at(array, i)) return i; } - return notFound; + return noPresentIndex; } case ALL_DOUBLE_INDEXING_TYPES: { @@ -7142,7 +7136,7 @@ extern "C" uint64_t Bun__JSArray__nextPresentIndex( if (value == value) return i; } - return notFound; + return noPresentIndex; } case ALL_ARRAY_STORAGE_INDEXING_TYPES: { @@ -7152,15 +7146,7 @@ extern "C" uint64_t Bun__JSArray__nextPresentIndex( if (storage->m_vector[i]) return i; } - - uint64_t result = notFound; - if (JSC::SparseArrayValueMap* map = storage->m_sparseMap.get()) { - for (const auto& entry : *map) { - if (entry.index() >= start && entry.index() < result) - result = entry.index(); - } - } - return result; + return noPresentIndex; } default: @@ -7169,6 +7155,80 @@ extern "C" uint64_t Bun__JSArray__nextPresentIndex( } } +static JSC::SparseArrayValueMap* sparseMapOf(JSC::JSArray* array) +{ + if (!hasAnyArrayStorage(array->indexingType())) + return nullptr; + return array->butterfly()->arrayStorage()->m_sparseMap.get(); +} + +// Smallest own present index of a JSArray that is >= `start`, or UINT64_MAX +// when every index from `start` to the end of the array is a hole. Mirrors the +// butterfly walk in JSObject::getOwnIndexedPropertyNames so the caller can skip +// a run of holes without probing each index of a huge sparse array. +// +// Walks the whole sparse map on every call. A caller that asks once per run of +// holes with no bound on the runs uses Bun__JSArray__nextPresentVectorIndex and +// Bun__JSArray__copySortedSparseIndices instead. +extern "C" uint64_t Bun__JSArray__nextPresentIndex( + JSC::EncodedJSValue encodedValue, + uint32_t start) +{ + JSC::JSArray* array = uncheckedDowncast(JSC::JSValue::decode(encodedValue).asCell()); + + uint64_t result = nextPresentVectorIndex(array, start); + if (result != noPresentIndex) + return result; + + if (JSC::SparseArrayValueMap* map = sparseMapOf(array)) { + for (const auto& entry : *map) { + if (entry.index() >= start && entry.index() < result) + result = entry.index(); + } + } + return result; +} + +// The vector-storage half of Bun__JSArray__nextPresentIndex. +extern "C" uint64_t Bun__JSArray__nextPresentVectorIndex( + JSC::EncodedJSValue encodedValue, + uint32_t start) +{ + JSC::JSArray* array = uncheckedDowncast(JSC::JSValue::decode(encodedValue).asCell()); + return nextPresentVectorIndex(array, start); +} + +// Copies the indices in [start, end) that the array's sparse map holds into +// `out`, ascending, the way JSObject::getOwnIndexedPropertyNames sorts them +// once. Returns how many there are. When that is more than `capacity`, `out` +// is incomplete and unsorted. +extern "C" uint32_t Bun__JSArray__copySortedSparseIndices( + JSC::EncodedJSValue encodedValue, + uint32_t start, + uint32_t end, + uint32_t* out, + uint32_t capacity) +{ + JSC::JSArray* array = uncheckedDowncast(JSC::JSValue::decode(encodedValue).asCell()); + + JSC::SparseArrayValueMap* map = sparseMapOf(array); + if (!map) + return 0; + + uint32_t count = 0; + for (const auto& entry : *map) { + uint32_t index = entry.index(); + if (index < start || index >= end) + continue; + if (count < capacity) + out[count] = index; + ++count; + } + if (count <= capacity) + std::sort(out, out + count); + return count; +} + extern "C" void JSC__ArrayBuffer__ref(JSC::ArrayBuffer* self) { self->ref(); } extern "C" void JSC__ArrayBuffer__deref(JSC::ArrayBuffer* self) { self->deref(); } extern "C" void JSC__ArrayBuffer__asBunArrayBuffer(JSC::ArrayBuffer* self, Bun__ArrayBuffer* out) diff --git a/src/runtime/test_runner/diff_format.rs b/src/runtime/test_runner/diff_format.rs index 54c086509a49..79f8895f963a 100644 --- a/src/runtime/test_runner/diff_format.rs +++ b/src/runtime/test_runner/diff_format.rs @@ -5,7 +5,7 @@ use bun_core::Output; use bun_jsc::{JSGlobalObject, JSValue, JsResult}; use super::diff::print_diff::{print_diff_main, DiffConfig}; -use super::pretty_format::{FormatOptions, JestPrettyFormat, MessageLevel}; +use super::pretty_format::{FormatOptions, JestPrettyFormat}; /// Renders a Jest-style diff of two already-formatted values. Formatting a JS value runs user code /// (getters, Proxy traps) and can throw, so it happens up front in [`DiffFormatter::new`], never @@ -24,14 +24,13 @@ impl<'a> DiffFormatter<'a> { not: bool, ) -> JsResult> { let fmt_options = FormatOptions { - enable_colors: false, add_newline: false, flush: false, quote_strings: true, + collapse_hole_runs: true, }; let mut received_buf: Vec = Vec::new(); JestPrettyFormat::format( - MessageLevel::Debug, global_this, core::slice::from_ref(&received), 1, @@ -40,7 +39,6 @@ impl<'a> DiffFormatter<'a> { )?; let mut expected_buf: Vec = Vec::new(); JestPrettyFormat::format( - MessageLevel::Debug, global_this, core::slice::from_ref(&expected), 1, diff --git a/src/runtime/test_runner/mod.rs b/src/runtime/test_runner/mod.rs index dad0949a63be..93b24f0028a9 100644 --- a/src/runtime/test_runner/mod.rs +++ b/src/runtime/test_runner/mod.rs @@ -220,15 +220,14 @@ pub(crate) mod expect { impl JSValueTestExt for JSValue { #[inline] fn jest_snapshot_pretty_format(self, out: &mut W, global: &JSGlobalObject) -> JsResult<()> { - use super::pretty_format::{JestPrettyFormat, FormatOptions, MessageLevel}; + use super::pretty_format::{JestPrettyFormat, FormatOptions}; let fmt_options = FormatOptions { - enable_colors: false, add_newline: false, flush: false, quote_strings: true, + collapse_hole_runs: false, }; JestPrettyFormat::format( - MessageLevel::Debug, global, core::slice::from_ref(&self), 1, diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index d722ffeed013..adcf94f42dce 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -88,24 +88,18 @@ impl EventType { #[derive(Default)] pub(crate) struct JestPrettyFormat {} -#[repr(u32)] -#[derive(Copy, Clone, PartialEq, Eq)] -pub(crate) enum MessageLevel { - Error = 2, - Debug = 3, -} - #[derive(Copy, Clone, Default)] pub(crate) struct FormatOptions { - pub(crate) enable_colors: bool, pub(crate) add_newline: bool, pub flush: bool, pub(crate) quote_strings: bool, + /// Print a run of more than `MAX_EXPANDED_HOLE_RUN` array holes as one + /// `N x empty items` line. Off for snapshots, whose bytes are a file format. + pub(crate) collapse_hole_runs: bool, } impl JestPrettyFormat { pub(crate) fn format( - level: MessageLevel, global: &JSGlobalObject, vals: &[JSValue], len: usize, @@ -119,7 +113,7 @@ impl JestPrettyFormat { let result = { let mut bridge = AsFmt::new(&mut *writer); let mut adapted = bun_io::write::FmtAdapter::new(&mut bridge); - Self::format_adapted(level, global, vals, len, &mut adapted, options) + Self::format_adapted(global, vals, len, &mut adapted, options) }; // `FmtAdapter::flush` can't reach `writer`; do the requested flush here. if flush { @@ -129,7 +123,6 @@ impl JestPrettyFormat { } fn format_adapted( - level: MessageLevel, global: &JSGlobalObject, vals: &[JSValue], len: usize, @@ -146,20 +139,11 @@ impl JestPrettyFormat { if len == 1 { fmt = Formatter::new(global); fmt.quote_strings = options.quote_strings; + fmt.collapse_hole_runs = options.collapse_hole_runs; let tag = Tag::get(vals[0], global)?; if tag.tag == Tag::String { - if options.enable_colors { - if level == MessageLevel::Error { - let _ = writer.write_all(pretty_fmt_const!(true, "").as_bytes()); - } - fmt.format::(tag, writer, vals[0], global)?; - if level == MessageLevel::Error { - let _ = writer.write_all(pretty_fmt_const!(true, "").as_bytes()); - } - } else { - fmt.format::(tag, writer, vals[0], global)?; - } + fmt.format::(tag, writer, vals[0], global)?; if options.add_newline { let _ = writer.write_all(b"\n"); } @@ -167,11 +151,7 @@ impl JestPrettyFormat { // The flush must fire on `?` propagation too. Wrap the // fallible body and flush before bubbling the result. let result: JsResult<()> = (|| { - if options.enable_colors { - fmt.format::(tag, writer, vals[0], global)?; - } else { - fmt.format::(tag, writer, vals[0], global)?; - } + fmt.format::(tag, writer, vals[0], global)?; if options.add_newline { let _ = writer.write_all(b"\n"); } @@ -192,56 +172,29 @@ impl JestPrettyFormat { fmt = Formatter::new(global); fmt.remaining_values = &vals[..len][1..]; fmt.quote_strings = options.quote_strings; + fmt.collapse_hole_runs = options.collapse_hole_runs; let result: JsResult<()> = (|| { let mut this_value: JSValue = vals[0]; let mut tag: TagResult; let mut any = false; - if options.enable_colors { - if level == MessageLevel::Error { - let _ = writer.write_all(pretty_fmt_const!(true, "").as_bytes()); - } - loop { - if any { - let _ = writer.write_all(b" "); - } - any = true; - - tag = Tag::get(this_value, global)?; - if tag.tag == Tag::String && !fmt.remaining_values.is_empty() { - tag.tag = Tag::StringPossiblyFormatted; - } - - fmt.format::(tag, writer, this_value, global)?; - if fmt.remaining_values.is_empty() { - break; - } - - this_value = fmt.remaining_values[0]; - fmt.remaining_values = &fmt.remaining_values[1..]; + loop { + if any { + let _ = writer.write_all(b" "); } - if level == MessageLevel::Error { - let _ = writer.write_all(pretty_fmt_const!(true, "").as_bytes()); + any = true; + tag = Tag::get(this_value, global)?; + if tag.tag == Tag::String && !fmt.remaining_values.is_empty() { + tag.tag = Tag::StringPossiblyFormatted; } - } else { - loop { - if any { - let _ = writer.write_all(b" "); - } - any = true; - tag = Tag::get(this_value, global)?; - if tag.tag == Tag::String && !fmt.remaining_values.is_empty() { - tag.tag = Tag::StringPossiblyFormatted; - } - - fmt.format::(tag, writer, this_value, global)?; - if fmt.remaining_values.is_empty() { - break; - } - this_value = fmt.remaining_values[0]; - fmt.remaining_values = &fmt.remaining_values[1..]; + fmt.format::(tag, writer, this_value, global)?; + if fmt.remaining_values.is_empty() { + break; } + + this_value = fmt.remaining_values[0]; + fmt.remaining_values = &fmt.remaining_values[1..]; } if options.add_newline { @@ -318,6 +271,7 @@ pub(crate) struct Formatter<'a> { pub(crate) failed: bool, pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, + pub(crate) collapse_hole_runs: bool, } impl<'a> Formatter<'a> { @@ -332,6 +286,7 @@ impl<'a> Formatter<'a> { failed: false, estimated_line_length: 0, always_newline_scope: false, + collapse_hole_runs: false, } } @@ -406,21 +361,6 @@ pub(crate) enum Tag { } impl Tag { - pub(crate) fn is_primitive(self) -> bool { - matches!( - self, - Tag::String - | Tag::StringPossiblyFormatted - | Tag::Undefined - | Tag::Double - | Tag::Integer - | Tag::Null - | Tag::Boolean - | Tag::Symbol - | Tag::BigInt - ) - } - #[inline] pub(crate) const fn can_have_circular_references(self) -> bool { matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set) @@ -1031,7 +971,125 @@ impl<'a, 'f, W: bun_io::Write, const ENABLE_ANSI_COLORS: bool> } } +/// With `collapse_hole_runs`, a run of array holes longer than this prints as +/// one `N x empty items` line. A shorter run keeps one `undefined` line per +/// hole, so the work per run is bounded by this and not by the run's length. +const MAX_EXPANDED_HOLE_RUN: u32 = 8; + +/// Where the array being printed stores its next element. The indices of the +/// sparse map are copied and sorted once, when a long run of holes first +/// reaches past the vector. Printing an element can run user code that +/// changes the array. The copy then goes stale: an index stored after the +/// copy was taken prints as part of a run of holes. +#[derive(Default)] +struct StoredIndices { + sparse: Option>, + pos: usize, +} + +impl StoredIndices { + /// Smallest index in `from..len` that `array` stores, or `len`. + fn next(&mut self, array: JSValue, from: u32, len: u32) -> u32 { + if self.sparse.is_none() { + if let Some(index) = array.next_present_vector_index(from) { + return index.min(len); + } + // The vector holds nothing more, so only the sparse map can. + self.sparse = Some(array.sorted_sparse_indices(from, len)); + } + let Some(sparse) = &self.sparse else { return len }; + while sparse.get(self.pos).is_some_and(|&index| index < from) { + self.pos += 1; + } + sparse.get(self.pos).copied().unwrap_or(len) + } +} + impl<'a> Formatter<'a> { + /// Prints the run of array holes that starts at `start`. Returns the + /// index after the run and the element there, which is empty when the + /// run reaches `len`. + /// + /// A run of at most `MAX_EXPANDED_HOLE_RUN` holes prints one `undefined` + /// line per hole. A longer run prints one line, and its end comes from + /// the array's storage instead of a probe per index. + #[inline(never)] + fn print_hole_run( + &mut self, + writer: &mut WrappedWriter<'_, W>, + array: JSValue, + start: u32, + len: u32, + stored: &mut StoredIndices, + ) -> JsResult<(u32, JSValue)> { + let mut element = JSValue::ZERO; + let mut i = start + 1; + while i < len && i - start <= MAX_EXPANDED_HOLE_RUN { + element = array.get_direct_index(self.global_this, i)?; + if !element.is_empty() { + break; + } + i += 1; + } + // Only a real JSArray has storage to ask. Anything else that was + // tagged as an array is probed index by index. + let has_storage = array.js_type().is_array(); + while element.is_empty() && i < len { + if has_storage { + i = stored.next(array, i, len); + if i == len { + break; + } + } + // A copied index can be gone by now. It is one more hole. + element = array.get_direct_index(self.global_this, i)?; + if element.is_empty() { + i += 1; + } + } + + let run = i - start; + if run > MAX_EXPANDED_HOLE_RUN { + self.begin_array_item::(writer, start); + writer.print(format_args!( + "{}{} x empty items{}", + pretty_fmt_const!(ENABLE_ANSI_COLORS, ""), + run, + pretty_fmt_const!(ENABLE_ANSI_COLORS, ""), + )); + // What `run` lines of `undefined,` add, less this line's own comma. + // `Tag::Promise` breaks the line on this count, and both sides of a + // diff have to break it in the same place. + self.add_for_new_line((run as usize).saturating_mul(10) - 1); + } else { + for hole in start..i { + self.begin_array_item::(writer, hole); + self.format::( + TagResult { tag: Tag::Undefined, ..Default::default() }, + writer.ctx, + JSValue::UNDEFINED, + self.global_this, + )?; + } + } + Ok((i, element)) + } + + /// The separator before the array item at `index`: the comma of the item + /// before it, then a new indented line. + #[inline] + fn begin_array_item( + &mut self, + writer: &mut WrappedWriter<'_, W>, + index: u32, + ) { + if index > 0 { + self.print_comma::(writer.ctx).expect("unreachable"); + } + writer.write_all(b"\n"); + self.write_indent(writer.ctx).expect("unreachable"); + } + pub(crate) fn print_as( &mut self, writer_: &mut W, @@ -1397,7 +1455,6 @@ impl<'a> Formatter<'a> { writer.write_all(b"\n"); } - let mut was_good_time = self.always_newline_scope; { self.indent += 1; @@ -1410,49 +1467,36 @@ impl<'a> Formatter<'a> { // restored even when `Tag::get` / `format` throw. Wrap the fallible body in // a closure and restore unconditionally afterward. let inner: JsResult<()> = (|| { - { - let element = value.get_index(self.global_this, 0)?; - let tag = Tag::get(element, self.global_this)?; - - was_good_time = was_good_time - || !tag.tag.is_primitive() - || self.good_time_for_a_new_line(); - - self.reset_line(); - writer.write_all(b"["); - writer.write_all(b"\n"); - self.write_indent(writer.ctx).expect("unreachable"); - self.add_for_new_line(1); + self.reset_line(); + writer.write_all(b"["); + self.add_for_new_line(1); - self.format::( - tag, writer.ctx, element, self.global_this, - )?; - - if tag.cell.is_string_like() { - if ENABLE_ANSI_COLORS { - writer.write_all( - pretty_fmt_const!(true, "").as_bytes(), - ); + let mut stored = StoredIndices::default(); + let mut i: u32 = 0; + while i < len { + let mut element = value.get_direct_index(self.global_this, i)?; + if element.is_empty() { + if self.collapse_hole_runs { + (i, element) = self + .print_hole_run::( + &mut writer, + value, + i, + len, + &mut stored, + )?; + if element.is_empty() { + break; + } + } else { + // A snapshot prints what `array[i]` reads for a hole: + // `undefined`, or what the prototype chain supplies. + element = value.get_index(self.global_this, i)?; } } - if len == 1 { - self.print_comma::(writer.ctx) - .expect("unreachable"); - } - } - - let mut i: u32 = 1; - while i < len { - self.print_comma::(writer.ctx) - .expect("unreachable"); - - writer.write_all(b"\n"); - self.write_indent(writer.ctx).expect("unreachable"); - - let element = value.get_index(self.global_this, i)?; let tag = Tag::get(element, self.global_this)?; - + self.begin_array_item::(&mut writer, i); self.format::( tag, writer.ctx, element, self.global_this, )?; @@ -1464,13 +1508,10 @@ impl<'a> Formatter<'a> { ); } } - - if i == len - 1 { - self.print_comma::(writer.ctx) - .expect("unreachable"); - } i += 1; } + self.print_comma::(writer.ctx) + .expect("unreachable"); Ok(()) })(); diff --git a/test/js/bun/test/printing/diff-array-holes.fixture.ts b/test/js/bun/test/printing/diff-array-holes.fixture.ts new file mode 100644 index 000000000000..aaab172b907f --- /dev/null +++ b/test/js/bun/test/printing/diff-array-holes.fixture.ts @@ -0,0 +1,351 @@ +// Run by diffexample.test.ts: `bun diff-array-holes.fixture.ts `. +// Prints the message of each failing matcher. Nothing here may take time or +// memory that follows the `length` an array claims. +import { expect, mock } from "bun:test"; + +function report(name: string, fn: () => void) { + let message = "did not throw"; + try { + fn(); + } catch (e) { + message = (e as Error).message; + } + console.log(`## ${name}\n${message}\n`); +} + +/** `count` holes, then `tail`. */ +function holes(count: number, ...tail: unknown[]): unknown[] { + const array: unknown[] = []; + array.length = count; + array.push(...tail); + return array; +} + +// With one more element the length is 2**32 - 1, the longest an array can claim. +const most = 2 ** 32 - 2; + +/** What a failing `expect(array).toEqual(0)` prints for `array`, from the array's own keys. */ +function model(array: unknown[]): string { + const lines: string[] = []; + let next = 0; + const holesUpTo = (index: number) => { + const run = index - next; + if (run > 8) lines.push(`${run} x empty items,`); + else for (let i = 0; i < run; i++) lines.push("undefined,"); + }; + for (const key of Object.keys(array)) { + const index = Number(key); + holesUpTo(index); + lines.push(`${JSON.stringify(array[index])},`); + next = index + 1; + } + holesUpTo(array.length); + return ["- 0", "+ [", ...lines.map(line => `+ ${line}`), "+ ]"].join("\n"); +} + +const groups: Record void> = { + runs() { + report("8 holes keep one line each", () => expect(holes(8, 1)).toEqual(holes(8, 2))); + report("9 holes are one line", () => expect(holes(9, 1)).toEqual(holes(9, 2))); + report("[1, , 3] is unchanged", () => expect([1, , 3]).toEqual([1, 2, 3])); + report("leading, middle and trailing runs", () => { + const array: unknown[] = []; + array[20] = "a"; + array[51] = "b"; + array.length = 92; + expect(array).toEqual(0); + }); + report("nested", () => expect({ list: [holes(12, 1)] }).toEqual({ list: [holes(12, 2)] })); + report("a long run against explicit undefined", () => + expect(holes(9, 1)).toStrictEqual([...Array.from({ length: 9 }, () => undefined), 1]), + ); + report("class extends Array", () => { + class List extends Array {} + const list = new List(); + list.length = 100; + list[50] = "x"; + expect(list).toEqual(0); + }); + report("frozen", () => expect(Object.freeze(holes(10, 1))).toEqual(0)); + report("circular", () => { + const array = holes(10); + array.push(array); + expect(array).toEqual(0); + }); + report("an own index accessor runs once", () => { + let reads = 0; + const array = holes(20); + Object.defineProperty(array, 4, { get: () => ++reads, enumerable: true }); + expect(array).toEqual(0); + }); + report("indices in the sparse map", () => { + const array: unknown[] = []; + for (let i = 0; i < 5; i++) array[1_000_000 + i * 20] = i; + expect(array).toEqual(0); + }); + // `Promise {}` starts a new line once the line so far counts more than 80 + // columns. One line for 9 holes has to count like 9 `undefined,` lines. + report("a Promise after 9 holes", () => expect(holes(9, Promise.resolve(1))).toEqual(0)); + report("a Promise after 9 undefined", () => + expect([...Array.from({ length: 9 }, () => undefined), Promise.resolve(1)]).toEqual(0), + ); + }, + + // One array per way JSC can store elements. Each is checked against what its own keys say. + storage() { + const shapes: Record unknown[]> = { + "int32": () => { + const array: unknown[] = [1, 2]; + array[30] = 3; + return array; + }, + "double": () => { + const array: unknown[] = [1.5, 2.5]; + array[30] = 3.5; + return array; + }, + "contiguous": () => { + const array: unknown[] = ["a", "b"]; + array[30] = "c"; + return array; + }, + "no elements": () => holes(50), + "array storage, in the vector": () => { + const array: unknown[] = [1]; + array[20] = 2; + array.length = most + 1; + return array; + }, + "array storage, in the sparse map": () => { + const array: unknown[] = []; + array[1_000_000] = 1; + array[1_000_005] = 2; + array[1_000_030] = 3; + return array; + }, + "array storage, in both": () => { + const array: unknown[] = [1]; + array[20] = 2; + array[25] = 3; + array[1_000_000] = 4; + array.length = 2_000_000; + return array; + }, + "frozen": () => { + const array: unknown[] = [1]; + array[20] = 2; + return Object.freeze(array); + }, + "runs of 8 and 9": () => { + const array: unknown[] = []; + array[8] = "after 8"; + array[18] = "after 9"; + array.length = 28; + return array; + }, + }; + for (const [name, make] of Object.entries(shapes)) { + const array = make(); + let printed = "did not throw"; + try { + expect(array).toEqual(0); + } catch (e) { + const lines = (e as Error).message.split("\n"); + printed = lines.slice(lines.indexOf("- 0"), lines.lastIndexOf("+ ]") + 1).join("\n"); + } + const expected = model(array); + console.log(`## ${name}\n${printed === expected ? "as its own keys say" : `printed:\n${printed}\nown keys say:\n${expected}`}\n`); + } + }, + + // Each input lets the comparison answer at once, so only the printer's cost shows. + longest() { + report("toEqual, index 0 differs", () => { + const a = [1]; + a.length = most + 1; + const b = [2]; + b.length = most + 1; + expect(a).toEqual(b); + }); + report("toEqual, other type", () => expect(holes(most, 1)).toEqual(0)); + report("not.toEqual", () => { + const array = holes(most, 1); + expect(array).not.toEqual(array); + }); + report("toStrictEqual, other length", () => expect(new Array(most + 1)).toStrictEqual([])); + report("toMatchObject", () => expect({ a: holes(most, 1) }).toMatchObject({ a: 0 })); + report("toHaveProperty", () => expect({ a: holes(most, 1) }).toHaveProperty("a", 0)); + report("toHaveBeenCalledWith", () => { + const fn = mock((..._: unknown[]) => {}); + fn(holes(most, 1)); + expect(fn).toHaveBeenCalledWith(0); + }); + report("toHaveBeenLastCalledWith", () => { + const fn = mock((..._: unknown[]) => {}); + fn(holes(most, 1)); + expect(fn).toHaveBeenLastCalledWith(0); + }); + report("toHaveBeenNthCalledWith", () => { + const fn = mock((..._: unknown[]) => {}); + fn(holes(most, 1)); + expect(fn).toHaveBeenNthCalledWith(1, 0); + }); + report("matcherHint", () => { + expect.extend({ + toBeZero(received: unknown) { + return { pass: false, message: () => this.utils.matcherHint("toBeZero", received, 0) }; + }, + }); + // @ts-expect-error added by expect.extend above + expect(holes(most, 1)).toBeZero(); + }); + report("frozen class extends Array", () => { + class List extends Array {} + const list = new List(); + list.length = most + 1; + list[7] = "x"; + expect(Object.freeze(list)).toEqual(0); + }); + report("an index accessor that throws", () => { + const array = holes(most + 1); + Object.defineProperty(array, 100, { + get() { + throw new Error("thrown by the accessor"); + }, + }); + expect({ nested: [array] }).toEqual(0); + }); + }, + + // Printing an element can run user code that changes the array being printed. + // The sparse indices are copied once, before the first of them is read. An + // index stored after that stays inside a run of holes. Nothing past the + // length read at the start is printed. + mutation() { + report("an accessor stores a later index", () => { + const array = holes(1_000_000); + Object.defineProperty(array, 10, { + enumerable: true, + get() { + array[500_000] = "late"; + return "getter"; + }, + }); + expect(array).toEqual(0); + }); + report("a Proxy element stores a later index", () => { + const array = holes(1_000_000); + array[200_000] = new Proxy( + {}, + { + ownKeys() { + array[500_000] = "late"; + return []; + }, + }, + ); + array[300_000] = "early"; + expect(array).toEqual(0); + }); + report("an accessor deletes a later index", () => { + const array = holes(1_000_000); + array[300_000] = "deleted"; + array[600_000] = "kept"; + Object.defineProperty(array, 10, { + enumerable: true, + get() { + delete array[300_000]; + return "getter"; + }, + }); + expect(array).toEqual(0); + }); + report("an accessor swaps one later index for another", () => { + const array = holes(1_000_000); + array[300_000] = "deleted"; + array[600_000] = "kept"; + Object.defineProperty(array, 10, { + enumerable: true, + get() { + delete array[300_000]; + array[400_000] = "swapped in"; + return "getter"; + }, + }); + expect(array).toEqual(0); + }); + report("an accessor shrinks the array", () => { + const array = holes(1_000_000); + Object.defineProperty(array, 10, { + enumerable: true, + get() { + array.length = 20; + return "getter"; + }, + }); + expect(array).toEqual(0); + }); + const storesAt = (array: unknown[], index: number) => + new Proxy( + {}, + { + ownKeys() { + array[index] = "past the end"; + return []; + }, + }, + ); + report("a Proxy element grows the vector past the length", () => { + const array = holes(30); + array[3] = storesAt(array, 40); + expect(array).toEqual(0); + }); + report("a Proxy element stores a sparse index past the length", () => { + const array = holes(1_000_000); + array[3] = storesAt(array, 2_000_000); + expect(array).toEqual(0); + }); + }, + + // The time must follow what the array stores. A walk that reads the whole + // sparse map again for each run of holes is quadratic, and fails this. + scale() { + const count = 20_000; + const sparse: unknown[] = []; + for (let i = 0; i < count; i++) sparse[1_000_000 + i * 20] = i; + const dense = Array.from({ length: count }, (_, i) => i); + const time = (array: unknown[]) => { + const start = performance.now(); + try { + expect(array).toEqual(0); + } catch {} + return performance.now() - start; + }; + time(dense); + console.log(JSON.stringify({ baseline: time(dense), ms: time(sparse) })); + }, + + // Own process: an index on Array.prototype changes how JSC reads every array for good. + prototype() { + Array.prototype[1] = "proto"; + report("an index only Array.prototype has is a hole", () => { + const array = [0]; + array.length = most + 1; + expect(array).toEqual(0); + }); + }, +}; + +// If a run of holes is not one line, the groups need gigabytes before the kill switch fires. +let nineHoles = ""; +try { + expect(holes(9)).toEqual(0); +} catch (e) { + nineHoles = (e as Error).message; +} +if (!nineHoles.includes("9 x empty items")) { + console.log(`A run of 9 holes is not one line:\n${nineHoles}`); + process.exit(1); +} + +groups[process.argv[2]](); diff --git a/test/js/bun/test/printing/diffexample.test.ts b/test/js/bun/test/printing/diffexample.test.ts index a80638740158..de8f86adda63 100644 --- a/test/js/bun/test/printing/diffexample.test.ts +++ b/test/js/bun/test/printing/diffexample.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; function cleanOutput(output: string) { return output @@ -900,3 +900,654 @@ test("large diffs are exact rather than abandoned part-way", () => { expect(message.slice(-400)).toContain("\n- 49994,\n+ 49995,\n 49995,\n"); expect(message.slice(-120).trimEnd()).toEndWith(`\n\n- Expected - ${changed}\n+ Received + ${changed}`); }); + +// A matcher diff has to cost what the arrays store, not the `length` they +// claim: a long run of holes is one line, and the formatter finds the end of +// the run in the array's storage. Without that, an array of length 2**32 - 1 +// needs about 56 GB of text per side and the child below never finishes. +async function diffArrayHoles(group: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/diff-array-holes.fixture.ts", group], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + // Kill switch, so a formatter that walks every claimed index fails the + // assertions below instead of hanging the test runner. + timeout: 20_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, signalCode: proc.signalCode, exitCode }; +} + +test.concurrent("a run of more than 8 array holes is one line in a matcher diff", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("runs"); + expect(stderr).toBe(""); + expect(stdout).toMatchInlineSnapshot(` + "## 8 holes keep one line each + expect(received).toEqual(expected) + + [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + - 2, + + 1, + ] + + - Expected - 1 + + Received + 1 + + + ## 9 holes are one line + expect(received).toEqual(expected) + + [ + 9 x empty items, + - 2, + + 1, + ] + + - Expected - 1 + + Received + 1 + + + ## [1, , 3] is unchanged + expect(received).toEqual(expected) + + [ + 1, + - 2, + + undefined, + 3, + ] + + - Expected - 1 + + Received + 1 + + + ## leading, middle and trailing runs + expect(received).toEqual(expected) + + - 0 + + [ + + 20 x empty items, + + "a", + + 30 x empty items, + + "b", + + 40 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + ## nested + expect(received).toEqual(expected) + + { + "list": [ + [ + 12 x empty items, + - 2, + + 1, + ], + ], + } + + - Expected - 1 + + Received + 1 + + + ## a long run against explicit undefined + expect(received).toStrictEqual(expected) + + [ + - undefined, + - undefined, + - undefined, + - undefined, + - undefined, + - undefined, + - undefined, + - undefined, + - undefined, + + 9 x empty items, + 1, + ] + + - Expected - 9 + + Received + 1 + + + ## class extends Array + expect(received).toEqual(expected) + + - 0 + + [ + + 50 x empty items, + + "x", + + 49 x empty items, + + ] + + - Expected - 1 + + Received + 5 + + + ## frozen + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + 1, + + ] + + - Expected - 1 + + Received + 4 + + + ## circular + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + [Circular], + + ] + + - Expected - 1 + + Received + 4 + + + ## an own index accessor runs once + expect(received).toEqual(expected) + + - 0 + + [ + + undefined, + + undefined, + + undefined, + + undefined, + + 1, + + 15 x empty items, + + ] + + - Expected - 1 + + Received + 8 + + + ## indices in the sparse map + expect(received).toEqual(expected) + + - 0 + + [ + + 1000000 x empty items, + + 0, + + 19 x empty items, + + 1, + + 19 x empty items, + + 2, + + 19 x empty items, + + 3, + + 19 x empty items, + + 4, + + ] + + - Expected - 1 + + Received + 12 + + + ## a Promise after 9 holes + expect(received).toEqual(expected) + + - 0 + + [ + + 9 x empty items, + + + + Promise {}, + + ] + + - Expected - 1 + + Received + 5 + + + ## a Promise after 9 undefined + expect(received).toEqual(expected) + + - 0 + + [ + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + + + Promise {}, + + ] + + - Expected - 1 + + Received + 13 + + + " + `); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +test.concurrent("a matcher diff of an array of length 2**32 - 1 follows what the array stores", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("longest"); + expect(stderr).toBe(""); + expect(stdout).toMatchInlineSnapshot(` + "## toEqual, index 0 differs + expect(received).toEqual(expected) + + [ + - 2, + + 1, + 4294967294 x empty items, + ] + + - Expected - 1 + + Received + 1 + + + ## toEqual, other type + expect(received).toEqual(expected) + + - 0 + + [ + + 4294967294 x empty items, + + 1, + + ] + + - Expected - 1 + + Received + 4 + + + ## not.toEqual + expect(received).not.toEqual(expected) + + Expected: not [ + 4294967294 x empty items, + 1, + ] + + + ## toStrictEqual, other length + expect(received).toStrictEqual(expected) + + - [] + + [ + + 4294967295 x empty items, + + ] + + - Expected - 1 + + Received + 3 + + + ## toMatchObject + expect(received).toMatchObject(expected) + + { + - "a": 0, + + "a": [ + + 4294967294 x empty items, + + 1, + + ], + } + + - Expected - 1 + + Received + 4 + + + ## toHaveProperty + expect(received).toHaveProperty(path, value) + + - 0 + + [ + + 4294967294 x empty items, + + 1, + + ] + + - Expected - 1 + + Received + 4 + + + ## toHaveBeenCalledWith + expect(received).toHaveBeenCalledWith(...expected) + + [ + - 0, + + [ + + 4294967294 x empty items, + + 1, + + ], + ] + + - Expected - 1 + + Received + 4 + + + ## toHaveBeenLastCalledWith + expect(received).toHaveBeenLastCalledWith(...expected) + + [ + - 0, + + [ + + 4294967294 x empty items, + + 1, + + ], + ] + + - Expected - 1 + + Received + 4 + + + ## toHaveBeenNthCalledWith + expect(received).toHaveBeenNthCalledWith(n, ...expected) + + Call #1: + [ + - 0, + + [ + + 4294967294 x empty items, + + 1, + + ], + ] + + - Expected - 1 + + Received + 4 + + + ## matcherHint + expect(received).toBeZero() + + expect(received).toBeZero(expected) + + - 0 + + [ + + 4294967294 x empty items, + + 1, + + ] + + - Expected - 1 + + Received + 4 + + + ## frozen class extends Array + expect(received).toEqual(expected) + + - 0 + + [ + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + undefined, + + "x", + + 4294967287 x empty items, + + ] + + - Expected - 1 + + Received + 11 + + + ## an index accessor that throws + thrown by the accessor + + " + `); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +test.concurrent("a matcher diff of an array that changes while it prints", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("mutation"); + expect(stderr).toBe(""); + expect(stdout).toMatchInlineSnapshot(` + "## an accessor stores a later index + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + "getter", + + 999989 x empty items, + + ] + + - Expected - 1 + + Received + 5 + + + ## a Proxy element stores a later index + expect(received).toEqual(expected) + + - 0 + + [ + + 200000 x empty items, + + {}, + + 99999 x empty items, + + "early", + + 699999 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + ## an accessor deletes a later index + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + "getter", + + 599989 x empty items, + + "kept", + + 399999 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + ## an accessor swaps one later index for another + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + "getter", + + 599989 x empty items, + + "kept", + + 399999 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + ## an accessor shrinks the array + expect(received).toEqual(expected) + + - 0 + + [ + + 10 x empty items, + + "getter", + + 999989 x empty items, + + ] + + - Expected - 1 + + Received + 5 + + + ## a Proxy element grows the vector past the length + expect(received).toEqual(expected) + + - 0 + + [ + + undefined, + + undefined, + + undefined, + + {}, + + 26 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + ## a Proxy element stores a sparse index past the length + expect(received).toEqual(expected) + + - 0 + + [ + + undefined, + + undefined, + + undefined, + + {}, + + 999996 x empty items, + + ] + + - Expected - 1 + + Received + 7 + + + " + `); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +test.concurrent("a matcher diff prints an index that only Array.prototype has as a hole", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("prototype"); + expect(stderr).toBe(""); + expect(stdout).toMatchInlineSnapshot(` + "## an index only Array.prototype has is a hole + expect(received).toEqual(expected) + + - 0 + + [ + + 0, + + 4294967294 x empty items, + + ] + + - Expected - 1 + + Received + 4 + + + " + `); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +test.concurrent("a matcher diff reads every way an array can store its elements", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("storage"); + expect(stderr).toBe(""); + expect(stdout).toMatchInlineSnapshot(` + "## int32 + as its own keys say + + ## double + as its own keys say + + ## contiguous + as its own keys say + + ## no elements + as its own keys say + + ## array storage, in the vector + as its own keys say + + ## array storage, in the sparse map + as its own keys say + + ## array storage, in both + as its own keys say + + ## frozen + as its own keys say + + ## runs of 8 and 9 + as its own keys say + + " + `); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +// Not concurrent: it measures time. +test("a matcher diff of a sparse array takes time that follows what the array stores", async () => { + const { stdout, stderr, signalCode, exitCode } = await diffArrayHoles("scale"); + expect(stderr).toBe(""); + const { baseline, ms } = JSON.parse(stdout); + // Measured for 20,000 sparse entries against a dense array of 20,000 elements. + // A machine too slow for `limit` still passes within 20x of the dense array. + const limit = isDebug || isASAN ? 5_000 : 500; + expect(ms).toBeLessThan(Math.max(limit, 20 * baseline)); + expect(signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + +// Snapshots are a file format shared with jest and with every .snap on disk, +// so they keep one line per hole however long the run is. +test("a snapshot still prints one line per array hole", () => { + const nine: unknown[] = []; + nine.length = 9; + nine.push(1); + expect([1, , 3]).toMatchInlineSnapshot(` + [ + 1, + undefined, + 3, + ] + `); + expect(nine).toMatchInlineSnapshot(` + [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 1, + ] + `); + expect({ nested: [nine] }).toMatchInlineSnapshot(` + { + "nested": [ + [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 1, + ], + ], + } + `); +});